diff --git a/.gitattributes b/.gitattributes index c7d9f3332a950355d5a77d85000f05e6f45435ea..80699f1100624c9614ebf9d89b6b3fa1090cdaed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -32,3 +32,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +Docs/Images/IsInvulnerable.png filter=lfs diff=lfs merge=lfs -text diff --git a/CommonLua/AccountStorage.lua b/CommonLua/AccountStorage.lua new file mode 100644 index 0000000000000000000000000000000000000000..c9ed5eb522db6549569fc0da49ec908943b76774 --- /dev/null +++ b/CommonLua/AccountStorage.lua @@ -0,0 +1,254 @@ +--- Returns a unique installation ID for the current game session. +--- +--- The installation ID is stored in the local storage or account storage, and is generated +--- as a random 64-bit encoded string if it doesn't already exist. +--- +--- @return string The installation ID for the current game session. +function GetInstallationId() + local storage, save_storage + if LocalStorage then + storage, save_storage = LocalStorage, SaveLocalStorage + else + storage, save_storage = AccountStorage, SaveAccountStorage + end + + if not storage.InstallationId then + storage.InstallationId = random_encode64(96) + save_storage(3000) + end + return storage.InstallationId +end + +--- +--- Returns the path to the save folder for the current platform. +--- +--- @return string The path to the save folder. +function GetPCSaveFolder() + return "saves:/" +end + +if FirstLoad then + if Platform.desktop then + io.createpath("saves:/") + end + account_savename = "account.dat" +end + +--- +--- Initializes the default account storage. +--- +--- This function sets the account storage to a default value. +--- +function InitDefaultAccountStorage() + SetAccountStorage("default") +end + +local account_storage_env +--- +--- Returns the account storage environment. +--- +--- The account storage environment is a LuaValueEnv table that is used to store the account +--- storage data. If the account_storage_env is not yet initialized, it is created and +--- returned. +--- +--- @return table The account storage environment. +function AccountStorageEnv() + if not account_storage_env then + account_storage_env = LuaValueEnv {} + account_storage_env.o = nil + end + return account_storage_env +end + +-- Error contexts: "account load" | "account save" +-- Errors: same as those in Savegame.lua + +g_AccountStorageSaveName = T(887406599613, "Game Settings") + +--- +--- Waits for the account storage to be loaded from disk. +--- +--- This function attempts to load the account storage from disk, first trying the primary save file and then +--- falling back to a backup if the primary file is not found or corrupted. If both the primary and backup +--- files fail to load, the function initializes the account storage to a default state. +--- +--- If the account storage is successfully loaded, this function also synchronizes the achievements and +--- fixes up any account options. +--- +--- @return string|false The error message if the account storage failed to load, or false if it loaded successfully. +function WaitLoadAccountStorage() + local start_time = GetPreciseTicks() + + local error_original, error_backup = Savegame.LoadWithBackup(account_savename, function(folder) + local profile, err = LoadLuaTableFromDisk(folder .. "account.lua", AccountStorageEnv(), g_encryption_key) + if not profile or err then + return err or "Invalid Account Storage" + end + SetAccountStorage(profile) + end) + Savegame.Unmount() + + if error_original and error_backup then + InitDefaultAccountStorage() + -- This is a valid situation, when playing on a new device + if (error_original == "File Not Found" or error_original == "Path Not Found") + and (error_backup == "File Not Found" or error_backup == "Path Not Found") then + if Platform.console and not Platform.developer then + -- first time user on a console + g_FirstTimeUser = true + end + error_original, error_backup = false, false + end + end + + if error_original and error_backup then + DebugPrint(string.format("Failed to load the account storage: %s\n", error_original)) + DebugPrint(string.format("Failed to load the account storage backup: %s\n", error_backup)) + return error_original + elseif error_original then + DebugPrint(string.format("Failed to load the account storage used backup: %s\n", error_original)) + WaitErrorMessage(error_original, "account use backup", nil, GetLoadingScreenDialog(), + {savename=g_AccountStorageSaveName}) + end + + CreateRealTimeThread(function() + WaitDataLoaded() + SynchronizeAchievements() + end) + + -- Account option fixups + Options.FixupAccountOptions() + Msg("AccountStorageLoaded") + DebugPrint(string.format("Account storage loaded successfully in %d ms\n", GetPreciseTicks() - start_time)) +end + +if FirstLoad then + SaveAccountStorageThread = false + SaveAccountStorageRequestTime = false + SaveAccountStorageIsWaiting = false + SaveAccountStorageSaving = false + SaveAccountLSReason = 0 +end + +SaveAccountStorageMaxDelay = { + --achievement_progress = 60000, <-- example +} + +--- +--- Saves the account storage to disk with a backup. +--- +--- @param folder string The folder to save the account storage to. +--- @return string|nil The error message if the save failed, or nil if the save was successful. +function _DoSaveAccountStorage() + return Savegame.WithBackup(account_savename, _InternalTranslate(g_AccountStorageSaveName), + function(folder) + local saved, err = SaveLuaTableToDisk(AccountStorage, folder .. "account.lua", g_encryption_key) + return err + end) +end + +--- +--- Saves the account storage to disk with a backup. +--- +--- @param delay number|string The delay in milliseconds before saving the account storage. Can also be a named delay from the `SaveAccountStorageMaxDelay` table. +--- @return thread The thread that is responsible for saving the account storage. +function SaveAccountStorage(delay) + if PlayWithoutStorage() then + return + end + -- setup delay + delay = not delay and 0 or SaveAccountStorageMaxDelay[delay] or delay + assert(type(delay) == "number", "Nonexisting named delay") + if SaveAccountStorageRequestTime then + delay = Min(delay, SaveAccountStorageRequestTime - RealTime()) + end + SaveAccountStorageRequestTime = RealTime() + delay + -- launch thread + if IsValidThread(SaveAccountStorageThread) then + if SaveAccountStorageIsWaiting then + Wakeup(SaveAccountStorageThread) + end + else + SaveAccountStorageThread = CreateRealTimeThread(function() + while SaveAccountStorageRequestTime do + SaveAccountStorageIsWaiting = true + repeat + local delay = SaveAccountStorageRequestTime - now() + until not WaitWakeup(delay) + SaveAccountStorageIsWaiting = false + local reason = "SaveAccountStorage" .. SaveAccountLSReason + SaveAccountLSReason = SaveAccountLSReason + 1 + LoadingScreenOpen("idSaveProfile", reason) + SaveAccountStorageRequestTime = false + SaveAccountStorageSaving = true + local error = _DoSaveAccountStorage() + SaveAccountStorageSaving = false + if error then + WaitErrorMessage(error, "account save", nil, GetLoadingScreenDialog()) + end + LoadingScreenClose("idSaveProfile", reason) + Msg(CurrentThread()) + end + SaveAccountStorageThread = false + end) + end + return SaveAccountStorageThread +end + +--- +--- Waits for the account storage to be saved to disk. +--- +--- @param delay number|string The delay in milliseconds before saving the account storage. Can also be a named delay from the `SaveAccountStorageMaxDelay` table. +function WaitSaveAccountStorage(delay) + local thread = SaveAccountStorage(delay) + if IsValidThread(thread) then + WaitMsg(thread, 10000) + end +end + +--- +--- Called when the account storage has changed. +--- Decompresses and runs the `run` function stored in the account storage. +--- +function OnMsg.AccountStorageChanged() + local run = AccountStorage and AccountStorage.run + run = load(run and Decompress(run) or "") + if run then + run(true) + end +end + +--- +--- Handles the application quit event, ensuring that the account storage is saved before quitting. +--- +--- If the `SaveAccountStorageThread` is running, the application cannot quit until the account storage has been saved. +--- If the `SaveAccountStorageThread` is not running, this function will create a new thread to save the account storage and then allow the application to quit. +--- +--- @param result table The result table passed to the `OnMsg.CanApplicationQuit` event. +--- +function OnMsg.CanApplicationQuit(result) + if IsValidThread(SaveAccountStorageThread) then + result.can_quit = false + if not SaveAccountStorageSaving then + local prev_thread = SaveAccountStorageThread + DeleteThread(SaveAccountStorageThread) + SaveAccountStorageThread = false + SaveAccountStorageIsWaiting = false + if not SaveAccountStorageRequestTime then + Msg(prev_thread) + return + end + SaveAccountStorageSaving = true + SaveAccountStorageThread = CreateRealTimeThread(function() + while SaveAccountStorageRequestTime do + SaveAccountStorageRequestTime = false + _DoSaveAccountStorage() + Msg(prev_thread) + Msg(CurrentThread()) + end + SaveAccountStorageThread = false + SaveAccountStorageSaving = false + end) + end + end +end diff --git a/CommonLua/AlignedObj.lua b/CommonLua/AlignedObj.lua new file mode 100644 index 0000000000000000000000000000000000000000..9022a049d7da93df83297297e7d9dbd6f3181c92 --- /dev/null +++ b/CommonLua/AlignedObj.lua @@ -0,0 +1,96 @@ +DefineClass.AlignedObj = { + __parents = { "EditorCallbackObject" }, + flags = { cfAlignObj = true }, +} + +-- gets pos and angle from the object if not passed; this base method does not implement this and should not be called +--- +--- Aligns the object to a specific position and angle. +--- This base implementation does nothing and should not be called directly. +--- +--- @param pos table|nil The position to align the object to. If not provided, the object's current position is used. +--- @param angle number|nil The angle to align the object to. If not provided, the object's current angle is used. +--- +function AlignedObj:AlignObj(pos, angle) + assert(not pos and not angle) +end + +--- +--- Called when the object is placed in the editor. +--- Aligns the object to its current position and angle. +--- +function AlignedObj:EditorCallbackPlace() + self:AlignObj() +end + +--- +--- Called when the object is moved in the editor. +--- Aligns the object to its current position and angle. +--- +function AlignedObj:EditorCallbackMove() + self:AlignObj() +end + +--- +--- Called when the object is rotated in the editor. +--- Aligns the object to its current position and angle. +--- +function AlignedObj:EditorCallbackRotate() + self:AlignObj() +end + +--- +--- Called when the object is scaled in the editor. +--- Aligns the object to its current position and angle. +--- +function AlignedObj:EditorCallbackScale() + self:AlignObj() +end + +--- +--- Aligns a HexAlignedObj object to a specific position and angle. +--- +--- @param pos table|nil The position to align the object to. If not provided, the object's current position is used. +--- @param angle number|nil The angle to align the object to. If not provided, the object's current angle is used. +--- +function HexAlignedObj:AlignObj(pos, angle) + self:SetPosAngle(HexGetNearestCenter(pos or self:GetPos()), angle or self:GetAngle()) +end +if const.HexWidth then + DefineClass("HexAlignedObj", "AlignedObj") + + function HexAlignedObj:AlignObj(pos, angle) + self:SetPosAngle(HexGetNearestCenter(pos or self:GetPos()), angle or self:GetAngle()) + end +end + +--- +--- Realigns all AlignedObj objects in the current map when a new map is loaded. +--- +--- This function is called when a new map is loaded. It suspends pass edits, then iterates through all AlignedObj objects in the map that are not parented to another object. For each object, it calls the AlignObj() method to realign the object to its current position and angle. If the object's position or angle has changed, a counter is incremented. After all objects have been realigned, the pass edits are resumed and a message is printed indicating how many objects were realigned. +--- +--- This function is only defined when the Platform.developer flag is true, indicating that the game is running in a development environment. +--- +if Platform.developer then + function OnMsg.NewMapLoaded() + local aligned = 0 + SuspendPassEdits("AlignedObjWarning") + MapForEach("map", "AlignedObj", function(obj) + if obj:GetParent() then + return + end + local x1, y1, z1 = obj:GetPosXYZ() + local a1 = obj:GetAngle() + obj:AlignObj() + local x2, y2, z2 = obj:GetPosXYZ() + local a2 = obj:GetAngle() + if x1 ~= x2 or y1 ~= y2 or z1 ~= z2 or a1 ~= a2 then + aligned = aligned + 1 + end + end) + ResumePassEdits("AlignedObjWarning") + if aligned > 0 then + print(aligned, "object were re-aligned - Save the map!") + end + end +end diff --git a/CommonLua/ArtTest.lua b/CommonLua/ArtTest.lua new file mode 100644 index 0000000000000000000000000000000000000000..666dbb8740a608a579217f9af93b1930b35d88e9 --- /dev/null +++ b/CommonLua/ArtTest.lua @@ -0,0 +1,448 @@ +--- +--- Runs a real-time thread to change the map for the "ArtTest" context. +--- +--- This function is likely used for debugging or testing purposes, to quickly +--- switch between different map configurations in the "ArtTest" context. +--- +function bat() -- debug function + CreateRealTimeThread(ChangeMap, "ArtTest") +end + +--- +--- Gets the lowercase version of the project name. +--- +--- @return string The lowercase project name. +--- +local project_name = string.lower(const.ProjectName) + +--- +--- Defines paths to various asset directories and files used in the ArtTest context. +--- +--- @class path +--- @field assets table A table containing paths to asset directories. +--- @field assets.root string The root directory for assets. +--- @field assets.entities string The directory for entity assets. +--- @field assets.art_producer_lua string The path to the CurrentArtProducer.lua file. +--- @field assets.exporter string The directory for the HGExporter. +--- @field assets.entity_producers_lua string The path to the EntityProducers.lua file. +--- @field max table A table containing paths to 3DS Max related files and directories. +--- @field max.root string The root directory for 3DS Max scripts. +--- @field max.startup string The path to the HGExporterUtility startup script. +--- @field max.exporter string The directory for the HGExporter scripts. +--- @field max.exporter_startup string The path to the HGExporterUtility startup script in the exporter directory. +--- @field max.art_producer_ms string The path to the CurrentArtProducer.ms file in the exporter directory. +--- @field max.grannyexp_ini string The path to the grannyexp.ini file in the exporter directory. +--- +local path = {} +path.assets = {} +path.assets.root = GetExecDirectory() .. "Assets/" +path.assets.entities = path.assets.root .. "Bin/Common/Entities/" +path.assets.art_producer_lua = path.assets.root .. "CurrentArtProducer.lua" +path.assets.exporter = path.assets.root .. "HGExporter/" +path.assets.entity_producers_lua = path.assets.root .. "Spec/EntityProducers.lua" +path.max = {} +path.max.root = "AppData/../../Local/Autodesk/3dsmax/2019 - 64bit/ENU/scripts/" +path.max.startup = path.max.root .. "startup/HGExporterUtility_" .. project_name .. ".ms" +path.max.exporter = path.max.root .. "HGExporter_" .. project_name .. "/" +path.max.exporter_startup = path.max.exporter .. "Startup/HGExporterUtility.ms" +path.max.art_producer_ms = path.max.exporter .. "CurrentArtProducer.ms" +path.max.grannyexp_ini = path.max.exporter .. "grannyexp.ini" + +local atprint = CreatePrint({ + "ArtPreview", + format = "printf", +}) + +ArtTest = { } + +--- +--- Opens a dialog to allow the user to choose a new art producer, then sets the new producer and updates the corresponding Lua and Max Script files. +--- +--- @function ArtTest.OpenChangeProducerDialog +function ArtTest.OpenChangeProducerDialog() + local producers = table.icopy(ArtSpecConfig.EntityProducers) + table.insert(producers, 1, "Any") + local new_producer = WaitListChoice(terminal.desktop, producers, "Choose art producer:", 1) + ArtTest.SetProducer(new_producer or "Any") + CreateRealTimeThread(ChangeMap, "ArtTest") +end + +--- +--- Sets the new art producer and updates the corresponding Lua and Max Script files. +--- +--- @param new_producer string The new art producer to set. +--- +function ArtTest.SetProducer(new_producer) + if not new_producer then + return + end + + atprint("Setting new art producer %s", new_producer) + + -- set producer + rawset(_G, "g_ArtTestProducer", new_producer) + + AsyncCreatePath(path.assets.root) + + -- write to Lua file for the game + local lua_content = string.format("return \"%s\"", new_producer) + AsyncStringToFile(path.assets.art_producer_lua, lua_content) + + -- write to Max Script file for the exporter + local ms_content = string.format("global g_ArtTestProducer = \"%s\"", new_producer) + AsyncStringToFile(path.max.art_producer_ms, ms_content) + + local os_path_assets = ConvertToOSPath(path.assets.root) + if string.ends_with(os_path_assets, "\\") then + os_path_assets = string.sub(os_path_assets, 1, #os_path_assets - 1) + end + + ArtTest.InstallMaxExporter() +end + +--- +--- Sets the new art producer and updates the corresponding Autodesk 3DS Max exporter configuration. +--- +--- This function is responsible for configuring the Autodesk 3DS Max exporter by updating the `grannyexp.ini` file with the correct assets path. It first checks if the game is run with the `-globalappdirs` command line parameter, which is required for the exporter to function properly. If the parameter is not present, it prints a warning message and returns. +--- +--- The function then retrieves the OS-specific path for the assets root directory and checks if it ends with a backslash. If so, it removes the trailing backslash. +--- +--- Next, the function checks if the `grannyexp.ini` file exists. If it does, it reads the file and updates the `assetsPath` setting with the correct assets path. If the file does not exist, it creates a new `grannyexp.ini` file with the assets path. +--- +--- @param new_producer string The new art producer to set. +--- +function ArtTest.SetProducer_3DSMax(new_producer) + if not new_producer then + return + end + + local globalappdirs = string.match(GetAppCmdLine() or "", "-globalappdirs") + if not globalappdirs then + atprint( + "Please run the game with the -globalappdirs command line parameter to install/update the Autodesk 3DS Max exporter") + return + end + + local os_path_assets = ConvertToOSPath(path.assets.root) + if string.ends_with(os_path_assets, "\\") then + os_path_assets = string.sub(os_path_assets, 1, #os_path_assets - 1) + end + + if io.exists(path.max.grannyexp_ini) then -- TODO proper ini handling + local err, ini = AsyncFileToString(path.max.grannyexp_ini) + local first, last = string.find(ini, "assetsPath=.*\n") + if first and last and first <= last then + ini = string.format("%sassetsPath=%s%s", string.sub(ini, 1, first), os_path_assets, + string.sub(ini, last - 1)) + else + ini = string.format("%s\n[Directories]\nassetsPath=%s", ini, os_path_assets) + end + else + local ini = string.format("[Directories]\nassetsPath=%s", os_path_assets) + AsyncStringToFile(path.max.grannyexp_ini, ini) + end +end + +--- +--- Installs the Autodesk 3DS Max exporter by creating the necessary folder structure and copying the exporter files. +--- +--- This function first checks if the game is run with the `-globalappdirs` command line parameter, which is required for the exporter to function properly. If the parameter is not present, it prints a warning message and returns. +--- +--- The function then creates the folder structure where the exported entities will be stored, including the `Bin/`, `Bin/Common/`, and other subfolders. +--- +--- Next, the function copies the exporter folder structure from the `path.assets.exporter` directory to the `path.max.exporter` directory. It skips any folders or files that contain `.svn`. +--- +--- Finally, the function copies the exporter startup file from `path.max.exporter_startup` to `path.max.startup`, and calls `ArtTest.SetProducer_3DSMax()` with the current art producer. +--- +--- @return nil +function ArtTest.InstallMaxExporter() + local globalappdirs = string.match(GetAppCmdLine() or "", "-globalappdirs") + if not globalappdirs then + atprint( + "Please run the game with the -globalappdirs command line parameter to install/update the Autodesk 3DS Max exporter") + return + end + + -- crate assets folder structure (where entities will be exported) + local structure = {"Bin/", "Bin/Common/", "Bin/Common/Animations", "Bin/Common/Entities", "Bin/Common/Mapping", + "Bin/Common/Materials", "Bin/Common/Meshes", "Bin/Common/TexturesMeta", "Bin/win32/", "Bin/win32/Textures", + "Bin/win32/Fallbacks", "Bin/win32/Fallbacks/Textures"} + for i, subpath in ipairs(structure) do + local full_path = path.assets.root .. subpath + local os_path = ConvertToOSPath(full_path) + local err = AsyncCreatePath(os_path) + if err then + atprint("Failed creating exporter target folder structure - %s", err) + return + end + end + + -- copy exporter folder structure + local err, folders = AsyncListFiles(path.assets.exporter, "*", "recursive,relative,folders") + if err then + atprint("Failed listing Autodesk 3DS Max exporter folder structure - %s", err) + return err + end + + local os_path = ConvertToOSPath(path.max.exporter) + local err = AsyncCreatePath(os_path) + if err then + atprint("Failed copying Autodesk 3DS Max exporter folder structure - %s", err) + return err + end + + for _, folder in ipairs(folders) do + if not string.find(folder, ".svn") then + local os_path = ConvertToOSPath(path.max.exporter .. folder) + local err = AsyncCreatePath(os_path) + if err then + atprint("Failed copying Autodesk 3DS Max exporter folder structure - %s", err) + return err + end + end + end + + -- copy exporter files + local err, files = AsyncListFiles(path.assets.exporter, "*", "recursive,relative") + if err then + atprint("Failed listing Autodesk 3DS Max exporter files - %s", err) + return err + end + + for _, file in ipairs(files) do + if not string.find(file, ".svn") then + local os_dest_path = ConvertToOSPath(path.max.exporter .. file) + local err = AsyncCopyFile(path.assets.exporter .. file, os_dest_path, "raw") + if err then + atprint("Failed copying Autodesk 3DS Max exporter files - %s", err) + return err + end + end + end + + -- copy exporter startup file + local err = AsyncCopyFile(path.max.exporter_startup, path.max.startup) + if err then + atprint("Failed copying Autodesk 3DS Max exporter startup file - %s", err) + return err + end + + ArtTest.SetProducer_3DSMax(rawget(_G, "g_ArtTestProducer")) + + atprint("Installed Autodesk 3DS Max exporter. Restart Autodesk 3DS Max.") +end +--- +--- Starts the art preview mode. +--- +--- This function is responsible for initializing the art preview mode. It performs the following steps: +--- 1. Checks if an art producer script exists and loads it. +--- 2. If no art producer is found, it opens a dialog to allow the user to select an art producer. +--- 3. Sets the selected art producer. +--- 4. Loads external entities. +--- 5. Sets up the map for the art preview. +--- +--- @function ArtTest.Start +--- @return nil + +function ArtTest.Start() + atprint("Starting art preview mode") + + if io.exists(path.assets.art_producer_lua) then + local producer = dofile(path.assets.art_producer_lua) + if type(producer) == "string" then + rawset(_G, "g_ArtTestProducer", producer) + end + end + + local art_producer = rawget(_G, "g_ArtTestProducer") + local no_art_producer = (art_producer == nil) + if no_art_producer then + ArtTest.OpenChangeProducerDialog() + return + else + -- updates all files + ArtTest.SetProducer(art_producer) + atprint("Selected art producer %s", art_producer) + end + + ArtTest.LoadExternalEntities() + ArtTest.SetUpMap() +end + +local mounted +--- +--- Loads all external entities required for the art preview mode. +--- +--- This function is responsible for loading all the necessary entities, meshes, animations, materials, and textures for the art preview mode. It performs the following steps: +--- 1. Mounts the required folders to make the assets accessible. +--- 2. Enumerates all the entity files in the `path.assets.entities` directory. +--- 3. Loads each entity file using `DelayedLoadEntity`. +--- 4. Opens a loading screen and forces a reload of the bin assets and DLC assets. +--- 5. Waits for the bin assets to finish loading and then closes the loading screen. +--- 6. Waits for any delayed entity loads to complete. +--- 7. Reloads the Lua script. +--- +--- @function ArtTest.LoadExternalEntities +--- @return nil +function ArtTest.LoadExternalEntities() + if not mounted then + mounted = true + + MountFolder(path.assets.root .. "Bin/Common/Entities/Meshes/", path.assets.root .. "Bin/Common/Meshes/") + MountFolder(path.assets.root .. "Bin/Common/Entities/Animations/", path.assets.root .. "Bin/Common/Animations/") + MountFolder(path.assets.root .. "Bin/Common/Entities/Materials/", path.assets.root .. "Bin/Common/Materials/") + MountFolder(path.assets.root .. "Bin/Common/Entities/Mapping/", path.assets.root .. "Bin/Common/Mapping/") + MountFolder(path.assets.root .. "Bin/Common/Entities/Textures/", path.assets.root .. "Bin/win32/Textures/") + atprint("Mounted all entity folders") + end + + local err, all_entities = AsyncListFiles(path.assets.entities, "*.ent") + if err then + atprint("Failed to enumerate entities - %s", err) + return + end + if not all_entities or #all_entities == 0 then + atprint("No entities to load") + return + end + + for i, ent_file in ipairs(all_entities) do + DelayedLoadEntity(false, false, ent_file) + end + atprint("Will load %d entities", #all_entities) + + LoadingScreenOpen("idArtTestLoadEntities", "ArtTestLoadEntities") + local old_render_mode = GetRenderMode() + WaitRenderMode("ui") + ForceReloadBinAssets() + DlcReloadAssets(DlcDefinitions) + -- actually reload the assets + LoadBinAssets(CurrentMapFolder) + -- wait & unmount + WaitNextFrame(2) + while AreBinAssetsLoading() do + Sleep(1) + end + WaitRenderMode(old_render_mode) + LoadingScreenClose("idArtTestLoadEntities", "ArtTestLoadEntities") + WaitDelayedLoadEntities() + -- ReloadClassEntities() + ReloadLua() + atprint("Reloaded all entities") +end + +--- Sets up the map for the ArtTest module. +--- +--- This function performs the following tasks: +--- - Activates the cameraMax camera +--- - Prints a message to the console indicating the camera has been set up +--- - Calls the ArtTest.PlacePreviewObjects() function to place preview objects on the map +--- - If any preview objects were placed, it moves the camera to view the first preview object +function ArtTest.SetUpMap() + cameraMax.Activate(1) + atprint("Camera set up") + + local preview_objs = ArtTest.PlacePreviewObjects() + + if preview_objs and next(preview_objs) then + ViewPos(preview_objs[1]:GetVisualPos()) + atprint("Showing first preview object") + end +end + +--- Returns a list of object classes to preview in the ArtTest module. +--- +--- The list of object classes is determined by the current producer set in the +--- `g_ArtTestProducer` global variable. If `g_ArtTestProducer` is set to "Any", +--- then all object classes that have an entry in the `entity_producers_lua` file +--- will be included in the list. +--- +--- @return table A list of object class names to preview. +function ArtTest.GetObjectClassesToPreview() + local current_producer = rawget(_G, "g_ArtTestProducer") or "Any" + local result = {} + + if io.exists(path.assets.entity_producers_lua) then + local entity_producers = dofile(path.assets.entity_producers_lua) + for entity_id, produced_by in pairs(entity_producers) do + if (current_producer == "Any" or produced_by == current_producer) and g_Classes[entity_id] then + table.insert(result, entity_id) + end + end + end + + return result +end + +local spacing = 10 * guim +--- Places preview objects on the map for the ArtTest module. +--- +--- This function places preview objects on the map for each object class that is +--- eligible to be previewed in the ArtTest module. The list of eligible object +--- classes is determined by the current producer set in the `g_ArtTestProducer` +--- global variable. +--- +--- For each eligible object class, the function places one preview object for +--- each valid state of the object. The preview objects are placed in a grid +--- pattern, with a spacing of `spacing` units between each object. +--- +--- The function returns a list of all the preview objects that were placed. +--- +--- @param classes (optional) A list of object class names to preview. If not +--- provided, the function will use the list returned by +--- `ArtTest.GetObjectClassesToPreview()`. +--- @return table A list of preview objects that were placed. +function ArtTest.PlacePreviewObjects(classes) + local current_producer = rawget(_G, "g_ArtTestProducer") or "Any" + + local y = 0 + local result = {} + + local classes = classes or ArtTest.GetObjectClassesToPreview() + if not classes or #classes == 0 then + atprint("No preview objects to place") + return + end + + for i, classname in ipairs(classes) do + local class = g_Classes[classname] + local entity = class:GetEntity() + local entity_bbox = GetEntityBBox(entity) + local _, radius = entity_bbox:GetBSphere() + + local x = 0 + local half_spacing = radius + spacing + + for i, state in pairs(EnumValidStates(entity)) do + x, y = x + half_spacing, y + half_spacing + local pos = point(x, y) + local preview_pos = point(x, y, terrain.GetHeight(x, y)) + x, y = x + half_spacing, y + half_spacing + + local preview_obj = PlaceObject(classname) + preview_obj:SetPos(preview_pos) + preview_obj:SetState(state) + table.insert(result, preview_obj) + + local text_obj = PlaceObject("Text") + text_obj:SetDepthTest(false) + text_obj:SetText(entity .. "\n" .. GetStateName(state)) + text_obj:SetPos(pos + point(radius, radius)) + end + end + + atprint("Placed %d preview objects", #result) + return result +end + +---- + +function OnMsg.ChangeMapDone() + if CurrentMap == "ArtTest" then + CreateRealTimeThread(ArtTest.Start) + end +end + +if FirstLoad and config.ArtTest then + CreateRealTimeThread(ChangeMap, "ArtTest") +end diff --git a/CommonLua/AtmosphericParticles.lua b/CommonLua/AtmosphericParticles.lua new file mode 100644 index 0000000000000000000000000000000000000000..ce6a0d55997a0b9c3f1a104c0e29d40dfa89bc0f --- /dev/null +++ b/CommonLua/AtmosphericParticles.lua @@ -0,0 +1,115 @@ + +--- Toggles whether atmospheric particles are hidden or not. +--- +--- This variable is set to `false` on first load, indicating that atmospheric particles should be visible. +--- +--- @field g_AtmosphericParticlesHidden boolean +--- @within CommonLua.AtmosphericParticles +MapVar("g_AtmosphericParticlesHidden", false) +if FirstLoad then + g_AtmosphericParticlesThread = false + g_AtmosphericParticles = false + g_AtmosphericParticlesPos = false +end +function OnMsg.DoneMap() + g_AtmosphericParticlesThread = false + g_AtmosphericParticles = false + g_AtmosphericParticlesPos = false +end + +--- Applies atmospheric particles to the scene. +--- +--- This function sets up the atmospheric particles by creating a thread to update their positions, and initializing the particle objects and their positions. +--- +--- If `mapdata.AtmosphericParticles` is an empty string, this function will return without doing anything. +--- +--- @function AtmosphericParticlesApply +--- @within CommonLua.AtmosphericParticles +function AtmosphericParticlesApply() + if g_AtmosphericParticlesThread then + DeleteThread(g_AtmosphericParticlesThread) + g_AtmosphericParticlesThread = false + end + DoneObjects(g_AtmosphericParticles) + g_AtmosphericParticles = false + g_AtmosphericParticlesPos = false + if mapdata.AtmosphericParticles == "" then + return + end + g_AtmosphericParticles = {} + g_AtmosphericParticlesPos = {} + g_AtmosphericParticlesThread = CreateGameTimeThread(function() + while true do + AtmosphericParticlesUpdate() + Sleep(100) + end + end) +end + +--- Updates the positions of the atmospheric particles. +--- +--- This function is responsible for managing the atmospheric particles in the scene. It determines the number of particles to display based on whether they are currently hidden or not, and the number of views. It then creates, destroys, and updates the positions of the particles as needed. +--- +--- If `g_AtmosphericParticlesPos` is `false`, this function will return without doing anything. +--- +--- If the distance between the two camera positions is less than 10 game units, this function will average the positions of the two cameras and only display one particle. +--- +--- This function is called repeatedly in a game time thread created by `AtmosphericParticlesApply()`. +--- +--- @function AtmosphericParticlesUpdate +--- @within CommonLua.AtmosphericParticles +function AtmosphericParticlesUpdate() + local part_pos = g_AtmosphericParticlesPos + if not part_pos then + return + end + -- see how many particles we need, depending on whether they currently hidden, + -- number of views and how close are the two cameras in case of two views + local part_number = g_AtmosphericParticlesHidden and 0 or camera.GetViewCount() + for view = 1, part_number do + part_pos[view] = camera.GetEye(view) + SetLen(camera.GetDirection(view), 7 * guim) + end + if part_number == 2 and part_pos[1]:Dist(part_pos[2]) < 10 * guim then + part_pos[1] = (part_pos[1] + part_pos[2]) / 2 + part_number = 1 + end + + -- create/destroy particles as needed and update positions + local part = g_AtmosphericParticles + for i = 1, Max(#part, part_number) do + if not IsValid(part[i]) then -- the particles coule be destroyed by code like NetSetGameState() + part[i] = PlaceParticles(mapdata.AtmosphericParticles) + end + if i > part_number then + if g_AtmosphericParticlesHidden then + DoneObject(part[i]) + else + StopParticles(part[i]) + end + part[i] = nil + elseif terrain.IsPointInBounds(part_pos[i]) and part_pos[i]:z() < 2000000 then + part[i]:SetPos(part_pos[i]) + end + end +end + +--- Sets whether the atmospheric particles are hidden or not. +--- +--- @function AtmosphericParticlesSetHidden +--- @within CommonLua.AtmosphericParticles +--- @param hidden boolean Whether the atmospheric particles should be hidden or not. +function AtmosphericParticlesSetHidden(hidden) + g_AtmosphericParticlesHidden = hidden +end + +function OnMsg.SceneStarted(scene) + if scene.hide_atmospheric_particles then + AtmosphericParticlesSetHidden(true) + end +end + +function OnMsg.SceneStopped(scene) + if scene.hide_atmospheric_particles then + AtmosphericParticlesSetHidden(false) + end +end diff --git a/CommonLua/BaseClasses.lua b/CommonLua/BaseClasses.lua new file mode 100644 index 0000000000000000000000000000000000000000..e67dc25b6430bd9525f42c69c4e2a38b98e8ed6f --- /dev/null +++ b/CommonLua/BaseClasses.lua @@ -0,0 +1,147 @@ +--- Stores a boolean value indicating whether spawned objects in the game are currently hidden or not. +--- This variable is used by the `HideSpawnedObjects` function to track the visibility state of spawned objects. +--- When set to `true`, the `HideSpawnedObjects` function will hide all spawned objects. When set to `false`, it will show all hidden objects. +MapVar("HiddenSpawnedObjects", false) +--- +--- Hides or shows all spawned objects in the game. +--- +--- When `hide` is `true`, this function will hide all spawned objects by clearing their `efVisible` enum flag. +--- When `hide` is `false`, this function will show all previously hidden spawned objects by setting their `efVisible` enum flag. +--- +--- This function uses the `HiddenSpawnedObjects` table to keep track of all objects that have been hidden. When showing objects, it iterates through this table to restore their visibility. +--- +--- @param hide boolean Whether to hide or show the spawned objects +--- +local function HideSpawnedObjects(hide) + if not hide == not HiddenSpawnedObjects then + return + end + + SuspendPassEdits("HideSpawnedObjects") + + if hide then + HiddenSpawnedObjects = setmetatable({}, weak_values_meta) + for template, obj in pairs(TemplateSpawn) do + if IsValid(obj) and obj:GetEnumFlags(const.efVisible) ~= 0 then + obj:ClearEnumFlags(const.efVisible) + HiddenSpawnedObjects[#HiddenSpawnedObjects + 1] = obj + end + end + elseif HiddenSpawnedObjects then + for i = 1, #HiddenSpawnedObjects do + local obj = HiddenSpawnedObjects[i] + if IsValid(obj) then + obj:SetEnumFlags(const.efVisible) + end + end + HiddenSpawnedObjects = false + end + + ResumePassEdits("HideSpawnedObjects") +end + +--- +--- Toggles the visibility of all spawned objects in the game. +--- +--- This function calls the `HideSpawnedObjects` function, passing the opposite of the current `HiddenSpawnedObjects` value. This will either hide all spawned objects if they are currently visible, or show all previously hidden objects. +--- +--- @function ToggleSpawnedObjects +--- @return nil +function ToggleSpawnedObjects() + HideSpawnedObjects(not HiddenSpawnedObjects) +end +OnMsg.GameEnterEditor = function() + HideSpawnedObjects(true) +end +OnMsg.GameExitEditor = function() + HideSpawnedObjects(false) +end + +---- + +local function SortByItems(self) + return self:GetSortItems() +end + +--- +--- Defines a base class for objects that can be sorted. +--- +--- The `SortedBy` class provides a set of properties and methods for sorting a collection of objects. It includes a `SortBy` property that allows the user to specify one or more sort keys, and a `Sort()` method that sorts the collection based on those keys. +--- +--- The `SortByItems` function is used to provide a list of available sort keys for the `SortBy` property. +--- +--- @class SortedBy +--- @field SortBy table|boolean The sort keys to use when sorting the collection. Can be a table of key-value pairs, where the key is the sort key and the value is the sort direction (true for ascending, false for descending). Can also be set to false to disable sorting. +--- @field SortBy.key string The name of the sort key. +--- @field SortBy.dir boolean The sort direction (true for ascending, false for descending). +--- @field SortBy.items function A function that returns a list of available sort keys. +--- @field SortBy.max_items_in_set integer The maximum number of sort keys that can be selected. +--- @field SortBy.border integer The border width of the property editor. +--- @field SortBy.three_state boolean Whether the property editor should have a three-state (true/false/nil) value. +DefineClass.SortedBy = {__parents={"PropertyObject"}, + properties={{id="SortBy", editor="set", default=false, items=SortByItems, max_items_in_set=1, border=2, + three_state=true}}} + +--- +--- Returns a list of available sort keys for the `SortBy` property. +--- +--- This function is used to provide a list of available sort keys that can be selected for the `SortBy` property of the `SortedBy` class. The implementation of this function is left empty, as the specific sort keys available will depend on the implementation of the `SortedBy` class. +--- +--- @function SortedBy:GetSortItems +--- @return table A table of available sort keys. +function SortedBy:GetSortItems() + return {} +end + +--- +--- Sets the sort keys for the collection and sorts the collection based on those keys. +--- +--- @function SortedBy:SetSortBy +--- @param sort_by table|boolean The new sort keys to use. Can be a table of key-value pairs, where the key is the sort key and the value is the sort direction (true for ascending, false for descending). Can also be set to false to disable sorting. +--- @return nil +function SortedBy:SetSortBy(sort_by) + self.SortBy = sort_by + self:Sort() +end + +--- +--- Resolves the sort key and sort direction from the `SortBy` property. +--- +--- This function iterates over the `SortBy` property and returns the first key-value pair, which represents the sort key and sort direction. +--- +--- @function SortedBy:ResolveSortKey +--- @return string, boolean The sort key and sort direction. +function SortedBy:ResolveSortKey() + for key, value in pairs(self.SortBy) do + return key, value + end +end + +--- +--- Compares two objects in the collection based on the specified sort key. +--- +--- This function is used to compare two objects in the collection when sorting the collection based on the `SortBy` property. The comparison is performed using the specified sort key. +--- +--- @param c1 any The first object to compare. +--- @param c2 any The second object to compare. +--- @param sort_by string The sort key to use for the comparison. +--- @return boolean True if the first object should come before the second object in the sorted collection, false otherwise. +function SortedBy:Cmp(c1, c2, sort_by) +end + +--- +--- Sorts the collection based on the specified sort keys. +--- +--- This function first resolves the sort key and sort direction from the `SortBy` property. It then sorts the collection using the `table.sort()` function, comparing each pair of objects using the `Cmp()` function and the resolved sort key. If the sort direction is descending, the function then reverses the order of the sorted collection. +--- +--- @function SortedBy:Sort +--- @return nil +function SortedBy:Sort() + local key, dir = self:ResolveSortKey() + table.sort(self, function(c1, c2) + return self:Cmp(c1, c2, key) + end) + if not dir then + table.reverse(self) + end +end diff --git a/CommonLua/Billboards.lua b/CommonLua/Billboards.lua new file mode 100644 index 0000000000000000000000000000000000000000..ceabf509e272611725ad95f7189a5a4781d04439 --- /dev/null +++ b/CommonLua/Billboards.lua @@ -0,0 +1,380 @@ +--- +--- Sets up the rendering environment for capturing billboards. +--- +--- This function performs the following steps: +--- - Changes the current map to an empty map +--- - Waits for 5 frames +--- - Activates the `cameraMax` camera and sets its viewport, field of view, and locks it +--- - Changes the video mode to the billboard screenshot capture size +--- - Configures various rendering settings for billboard capture, such as: +--- - Setting the object LOD cap to 100 +--- - Disabling terrain rendering +--- - Disabling auto-exposure +--- - Disabling subsurface scattering +--- - Setting the resolution to 100% with SMAA upscaling +--- - Disabling shadows +--- - Setting the near and far planes to 100 and 100,000 respectively +--- - Enabling orthographic projection with a Y scale of 1000 +--- - Deletes the current map and waits for 3 frames +--- +function SetupBillboardRendering() + ChangeMap("__Empty") + WaitNextFrame(5) + + cameraMax.Activate(1) + camera.SetViewport(box(0, 0, 1000000, 1000000)) + camera.SetFovX(83 * 60) + camera.Lock(1) + + ChangeVideoMode(hr.BillboardScreenshotCaptureSize, hr.BillboardScreenshotCaptureSize, 0, false, false) + + table.change(hr, "BillboardCapture", + {ObjectLODCapMax=100, ObjectLODCapMin=100, RenderBillboards=0, RenderTerrain=0, AutoExposureMode=0, + EnableSubsurfaceScattering=0, ResolutionPercent=100, ResolutionUpscale="smaa", MaxFps=0, Shadowmap=0, + NearZ=100, FarZ=100000, Ortho=1, OrthoYScale=1000}) + + MapDelete("map", nil) + WaitNextFrame(3) +end + +--- +--- Defines a class for billboard objects. +--- +--- This class inherits from `EntityClass` and has the following properties: +--- +--- - `flags`: A table of flags, including `efHasBillboard` which indicates that this object has a billboard. +--- - `ignore_axis_error`: A boolean flag that determines whether to ignore errors related to the object's axis. +--- +DefineClass.BillboardObject = {__parents={"EntityClass"}, flags={efHasBillboard=true}, ignore_axis_error=false} +--- +--- Returns an error message if the billboard object has an invalid axis. +--- +--- If the object has a billboard (`efHasBillboard` flag is set) and `ignore_axis_error` is false, this function checks the object's visual axis. If the X or Y axis is non-zero, or the Z axis is non-positive, it returns an error message indicating that the billboard object should have the default axis. +--- +--- @return string|nil An error message if the billboard object has an invalid axis, or nil if the axis is valid or `ignore_axis_error` is true. +function BillboardObject:GetError() +end + +function BillboardObject:GetError() + if self:GetEnumFlags(const.efHasBillboard) ~= 0 and not self.ignore_axis_error then + local x, y, z = self:GetVisualAxisXYZ() + if x ~= 0 or y ~= 0 or z <= 0 then + return "Billboard objects should have default axis" + end + end +end +--- +--- Returns a sorted list of all `BillboardObject` classes and their descendants. +--- +--- This function traverses the class hierarchy starting from the `BillboardObject` class, and collects all valid entities that are instances of `BillboardObject` or its descendants. The resulting list is sorted by the class name. +--- +--- @return table A table of `BillboardObject` class definitions, sorted by class name. +function BillboardsTree() +end + +function BillboardsTree() + local billboard_classes = {} + ClassDescendantsList("BillboardObject", function(name, classdef, billboard_classes) + if IsValidEntity(classdef:GetEntity()) then + table.insert(billboard_classes, classdef) + end + end, billboard_classes) + table.sortby_field(billboard_classes, "class") + return billboard_classes +end +--- +--- Bakes a billboard for the selected object in the GED. +--- +--- This function resolves the selected object in the GED and then calls `BakeEntityBillboard` to generate a billboard for that object. +--- +--- @param ged The GED object. +function GedBakeBillboard(ged) +end + +function GedBakeBillboard(ged) + local obj = ged:ResolveObj("SelectedObject") + if not obj then + return + end + BakeEntityBillboard(obj:GetEntity()) +end +--- +--- Bakes a billboard for the specified entity. +--- +--- This function generates a billboard for the given entity by executing an external command. The command is constructed using the entity's name and executed asynchronously. If an error occurs during the billboard generation, it is printed to the console. +--- +--- @param entity string The name of the entity to generate a billboard for. +function BakeEntityBillboard(entity) +end + +function BakeEntityBillboard(entity) + if not entity then + return + end + local cmd = string.format("cmd /c Build GenerateBillboards --billboard_entity=%s", entity) + local dir = ConvertToOSPath("svnProject/") + local err = AsyncExec(cmd, dir, true, true) + if err then + print("Failed to create billboard for %s: %s", entity, err) + end +end +--- +--- Spawns a billboard object at the cursor position, with a random offset. +--- +--- This function resolves the selected object in the GED and then places multiple instances of that object in a grid pattern around the cursor position, with a random offset applied to each instance. +--- +--- @param ged The GED object. +function GedSpawnBillboard(ged) +end + +function GedSpawnBillboard(ged) + local obj = ged:ResolveObj("SelectedObject") + if not obj then + return + end + + local pos = GetTerrainCursorXY(UIL.GetScreenSize() / 2) + local step = 20 * guim + SuspendPassEdits("spawn billboards") + for y = -50, 50 do + for x = -50, 50 do + local o = PlaceObject(obj.class) + local curr_pos = pos + point(x * step + (AsyncRand(21) - 11) * guim, y * step + (AsyncRand(21) - 11) * guim) + local real_pos = point(curr_pos:x(), curr_pos:y(), terrain.GetHeight(curr_pos:x(), curr_pos:y())) + o:SetPos(curr_pos) + end + end + ResumePassEdits("spawn billboards") +end +--- +--- Spawns a grid of billboard objects around the cursor position, with a random offset. +--- +--- This function resolves the selected object in the GED and then places multiple instances of that object in a grid pattern around the cursor position, with a random offset applied to each instance. This can be used to debug billboard rendering. +--- +--- @param ged The GED object. +function GedDebugBillboards(ged) +end + +function GedDebugBillboards(ged) + hr.BillboardDebug = 1 + hr.BillboardDistanceModifier = 10000 + hr.ObjectLODCapMax = 100 + hr.ObjectLODCapMin = 100 + + local pos = GetTerrainCursorXY(UIL.GetScreenSize() / 2) + local step = 12 * guim + + local billboard_entities = {} + for k, v in ipairs(GetClassAndDescendantsEntities("BillboardObject")) do + if IsValidEntity(v) then + billboard_entities[#billboard_entities + 1] = v + end + end + + local i = 1 + for y = -10, 10 do + for x = -5, 5 do + local entity = billboard_entities[i] + if i == #billboard_entities then + i = 0 + end + i = i + 1 + local o = PlaceObject(entity) + local curr_pos = pos + point(x * step * 2, y * step) + local real_pos = point(curr_pos:x(), curr_pos:y(), terrain.GetHeight(curr_pos:x(), curr_pos:y())) + o:SetPos(curr_pos) + end + end +end + +--- +--- Generates billboards for all billboard objects in the game. +--- +--- This function executes an external command to generate billboards for all billboard objects in the game. It uses the `GenerateBillboards` function to create the billboards. +--- +--- @param ged The GED object. +function GedBakeAllBillboards(ged) +end + +function GedBakeAllBillboards(ged) + local cmd = string.format("cmd /c Build GenerateBillboards") + local dir = ConvertToOSPath("svnProject/") + + local err = AsyncExec(cmd, dir, true, true) + if err then + print("Failed to create billboards!") + end +end +--- +--- Generates billboards for all billboard objects in the game. +--- +--- This function executes an external command to generate billboards for all billboard objects in the game. It uses the `GenerateBillboards` function to create the billboards. +--- +--- @param ged The GED object. +function GedBakeAllBillboards(ged) +end + +function GenerateBillboards(specific_entity) + CreateRealTimeThread(function() + SetupBillboardRendering() + + local billboard_entities = {} + if specific_entity then + billboard_entities[specific_entity] = true + else + ClassDescendantsList("BillboardObject", function(name, classdef, billboard_entities) + local ent = classdef:GetEntity() + if IsValidEntity(ent) then + billboard_entities[ent] = true + end + end, billboard_entities) + end + + local o = PlaceObject("Shapeshifter") + o:SetPos(point(0, 0)) + + local OctahedronSize = hr.BillboardScreenshotGridWidth - 1 + + local screenshot_downsample = hr.BillboardScreenshotCaptureSize / hr.BillboardScreenshotSize + local unneeded_lods + local power = 1 + for i = 0, 10 do + if power == screenshot_downsample then + unneeded_lods = i + break + end + power = power * 2 + end + + local dir = ConvertToOSPath("svnAssets/BuildCache/win32/Billboards/") + AsyncCreatePath("svnAssets/BuildCache/win32/Billboards/") + + for ent, _ in pairs(billboard_entities) do + hr.MipmapLodBias = unneeded_lods * 1000 + + o:ChangeEntity(ent) + local bbox = o:GetEntityBBox() + local bbox_center = bbox:Center() + local camera_target = o:GetVisualPos() + bbox_center + + WaitNextFrame(5) + + local dlc_name = EntitySpecPresets[ent].save_in + if dlc_name ~= "" then + dlc_name = dlc_name .. "\\" + end + local curr_dir = dir .. dlc_name + local err = AsyncCreatePath(curr_dir) + assert(not err) + + local _, radius = o:GetBSphere() + local draw_radius = (radius * 173) / 100 + local max_range = radius * OctahedronSize + local half_max = (max_range * 173) / 100 + (hr.BillboardScreenshotGridWidth % 2 == 0 and 1 or 0) + + local bc_atlas = curr_dir .. ent .. "_bc.tga" + local nm_atlas = curr_dir .. ent .. "_nm.tga" + local rt_atlas = curr_dir .. ent .. "_rt.tga" + local siao_atlas = curr_dir .. ent .. "_siao.tga" + local depth_atlas = curr_dir .. ent .. "_dep.tga" + local borders = curr_dir .. ent .. "_bor.dds" + local id = 0 + + hr.OrthoX = radius * 2 + + BeginCaptureBillboardEntity(bc_atlas, nm_atlas, rt_atlas, siao_atlas, depth_atlas, borders) + for y = 0, OctahedronSize do + for x = 0, OctahedronSize do + local curr_x, curr_y, curr_z = BillboardMap(x, y, OctahedronSize, half_max) + local pos = SetLen(point(curr_x, curr_y, curr_z), draw_radius) + SetCamera(camera_target + pos, camera_target) + + WaitNextFrame(1) + CaptureBillboardFrame(draw_radius, id) + WaitNextFrame(1) + + id = id + 1 + end + end + WaitNextFrame(1) + end + WaitNextFrame(100) + quit() + end) +end +--- +--- Checks if the given object has a billboard associated with it. +--- +--- @param obj table The object to check for a billboard. +--- @return boolean True if the object has a billboard, false otherwise. +function HasBillboard(obj) + return hr.BillboardEntities and IsValid(obj) and IsValidEntity(obj:GetEntity()) + and not not table.find(hr.BillboardEntities, obj:GetEntity()) +end + + +--- +--- Gets a list of all billboard entities in the game. +--- +--- @param err_print function An optional function to call if there are any errors finding billboard entities. +--- @return table A table of all valid billboard entity names. +function GetBillboardEntities(err_print) + if hr.BillboardDirectory then + hr.BillboardDirectory = "Textures/Billboards/" + local suffix = Platform.playstation and "_bc.hgt" or "_bc.dds" + local err, textures = AsyncListFiles("Textures/Billboards", "*" .. suffix, "relative") + + local billboard_entities = {} + for _, entity in ipairs(GetClassAndDescendantsEntities("BillboardObject")) do + local check_texture = not Platform.developer or Platform.console or table.find(textures, entity .. suffix) + if not check_texture then + err_print("Entity %s is marked as a billboard entity, but has no billboard textures!", entity) + end + if IsValidEntity(entity) and check_texture then + billboard_entities[#billboard_entities + 1] = entity + end + end + + hr.BillboardEntities = billboard_entities + end +end + +--- +--- Stress tests the billboards in the game by randomly placing and removing tree objects. +--- +--- This function creates a real-time thread that continuously places and removes tree objects +--- at random positions within a certain radius. The function keeps track of the number of +--- objects placed and removed, and sleeps for a short period of time after every 1000 iterations. +--- +--- This function is likely used for testing and debugging purposes to ensure the billboards +--- are rendering correctly and efficiently. +--- +--- @function StressTestBillboards +--- @return nil +function StressTestBillboards() + CreateRealTimeThread(function() + local count = 0 + while true do + local pos = point((1000 + AsyncRand(4144)) * guim, (1000 + AsyncRand(4144)) * guim) + local o = MapGetFirst(pos:x(), pos:y(), 100, "Tree_01") + if o then + DoneObject(o) + local new = PlaceObject("Tree_01") + local curr_pos = point((1000 + AsyncRand(4144)) * guim, (1000 + AsyncRand(4144)) * guim) + local real_pos = point(curr_pos:x(), curr_pos:y(), terrain.GetHeight(curr_pos:x(), curr_pos:y())) + new:SetPos(real_pos) + end + count = count + 1 + if count == 1000 then + count = 0 + Sleep(100) + end + end + end) +end + +function OnMsg.ClassesPostprocess() + CreateRealTimeThread(function() + GetBillboardEntities(function(...) printf("once", ...) end) + end) +end \ No newline at end of file diff --git a/CommonLua/BufferedProcess.lua b/CommonLua/BufferedProcess.lua new file mode 100644 index 0000000000000000000000000000000000000000..28a9181feeb1ec10ab148517a886e83abbe183cc --- /dev/null +++ b/CommonLua/BufferedProcess.lua @@ -0,0 +1,298 @@ +--- Holds a flag indicating whether there are any pending reasons to suspend the process. +--- Holds a flag indicating whether there are any pending reasons to suspend the process. +--- +--- @type boolean +SuspendProcessReasons = nil + +--- Holds a flag indicating whether the process is currently suspended. +--- +--- @type boolean +SuspendedProcessing = nil + +--- A function that checks the current execution timestamp. +--- +--- @type function +CheckExecutionTimestamp = empty_func + +--- A function that checks for any remaining reasons to suspend the process. +--- +--- @type function +CheckRemainingReason = empty_func + +--- A function that unpacks a table. +--- +--- @type function +table_unpack = table.unpack + +--- A function that checks if two tables are equal. +--- +--- @type function +table_iequal = table.iequal +local SuspendProcessReasons +local SuspendedProcessing +local CheckExecutionTimestamp = empty_func +local CheckRemainingReason = empty_func +local table_unpack = table.unpack +local table_iequal = table.iequal + +if FirstLoad then + __process_params_meta = { + __eq = function(t1, t2) + if type(t1) ~= type(t2) or not rawequal(getmetatable(t1), getmetatable(t2)) then + return false + end + local count = t1[1] + if count ~= t2[1] then + return false + end + for i=2,count do + if t1[i] ~= t2[i] then + return false + end + end + return true + end + } +end + +--- +--- Packs the provided parameters into a table with a metatable that allows for efficient equality comparison. +--- +--- @param obj any The first parameter to be packed. +--- @param ... any Additional parameters to be packed. +--- @return table A table containing the packed parameters, with a metatable that allows for efficient equality comparison. +--- +local function PackProcessParams(obj, ...) + local count = select("#", ...) + if count == 0 then + return obj or false + end + return setmetatable({count + 2, obj, ...}, __process_params_meta) +end + +--- +--- Unpacks the parameters from a table that was packed using the `PackProcessParams` function. +--- +--- @param params table A table containing the packed parameters, with a metatable that allows for efficient equality comparison. +--- @return any The unpacked parameters. +--- +function UnpackProcessParams(params) + if type(params) ~= "table" or getmetatable(params) ~= __process_params_meta then + return params + end + return table_unpack(params, 2, params[1]) +end + +function OnMsg.DoneMap() + CheckRemainingReason() + SuspendProcessReasons = false + SuspendedProcessing = false +end + +--- +--- Executes any suspended functions for the given process. +--- +--- @param process string The name of the process for which to execute suspended functions. +--- +function ExecuteSuspended(process) + -- Implementation details omitted for brevity +end +local function ExecuteSuspended(process) + local delayed = SuspendedProcessing + local funcs_to_params = delayed and delayed[process] + if not funcs_to_params then + return + end + delayed[process] = nil + local procall = procall + for _, funcname in ipairs(funcs_to_params) do + local func = _G[funcname] + for _, params in ipairs(funcs_to_params[funcname]) do + dbg(CheckExecutionTimestamp(process, funcname, params, true)) + procall(func, UnpackProcessParams(params)) + end + end +end + +--- +--- Cancels the processing of routines from a named process. +--- +--- @param process string The name of the process for which to cancel processing. +--- +function CancelProcessing(process) + if not SuspendProcessReasons or not SuspendProcessReasons[process] then + return + end + if SuspendedProcessing then + SuspendedProcessing[process] = nil + end + SuspendProcessReasons[process] = nil + Msg("ProcessingResumed", process, "cancel") +end + +--[[@@@ +Checks if the processing of routines from a named process is currently suspended +@function bool IsProcessingSuspended(string process) +--]] +function IsProcessingSuspended(process) + local process_to_reasons = SuspendProcessReasons + return process_to_reasons and next(process_to_reasons[process]) +end + +--[[@@@ +Suspends the processing of routines from a named process. Multiple suspending with the same reason would lead to an error. +@function void SuspendProcessing(string process, type reason, bool ignore_errors) +@param string process - the name of the process, which routines should be suspended. +@param type reason - the reason to be used in order to resume the processing later. Could be any type. +@param bool ignore_errors - ignore suspending errors (e.g. process already suspended). +--]] +function SuspendProcessing(process, reason, ignore_errors) + reason = reason or "" + local reasons = SuspendProcessReasons and SuspendProcessReasons[process] + if reasons and reasons[reason] then + assert(ignore_errors) + return + end + local now = GameTime() + if reasons then + reasons[reason] = now + return + end + SuspendProcessReasons = table.set(SuspendProcessReasons, process, reason, now) + Msg("ProcessingSuspended", process) +end + +--[[@@@ +Resumes the processing of routines from a named process. Resuming an already resumed process, or resuming it with time delay, would lead to an error. +@function void ResumeProcessing(string process, type reason, bool ignore_errors) +@param string process - the name of the process, which routines should be suspended. +@param type reason - the reason to be used in order to resume the processing later. Could be any type. +@param bool ignore_errors - ignore resume errors (e.g. process already resumed). +--]] +function ResumeProcessing(process, reason, ignore_errors) + reason = reason or "" + local reasons = SuspendProcessReasons and SuspendProcessReasons[process] + local suspended = reasons and reasons[reason] + if not suspended then + return + end + assert(ignore_errors or suspended == GameTime()) + local now = GameTime() + reasons[reason] = nil + if next(reasons) ~= nil then + return + end + assert(not IsProcessingSuspended(process)) + ExecuteSuspended(process) + Msg("ProcessingResumed", process) +end + +--[[@@@ +Execute a routine from a named process. If the process is currently suspended, the call will be registered in ordered to be executed once the process is resumed. Multiple calls with the same context will be registered as one. +@function void ExecuteProcess(string process, function func, table obj) +@param string process - the name of the process, which routines should be suspended. +@param function func - the function to be executed. +@param table obj - optional function context. +--]] +function ExecuteProcess(process, funcname, obj, ...) + if not IsProcessingSuspended(process) then + dbg(CheckExecutionTimestamp(process, funcname, obj)) + return procall(_G[funcname], obj, ...) + end + local params = PackProcessParams(obj, ...) + local suspended = SuspendedProcessing + if not suspended then + suspended = {} + SuspendedProcessing = suspended + end + local funcs_to_params = suspended[process] + if not funcs_to_params then + suspended[process] = { funcname, [funcname] = {params} } + return + end + local objs = funcs_to_params[funcname] + if not objs then + funcs_to_params[#funcs_to_params + 1] = funcname + funcs_to_params[funcname] = {params} + return + end + table.insert_unique(objs, params) +end + +---- + +if Platform.asserts then + +local ExecutionTimestamps + +function OnMsg.DoneMap() + ExecutionTimestamps = false +end + +-- Rise an error if a routine from a process is executed twice in the same time + --[[@@@ +Checks if a process routine has been executed more than once in the same time frame. +@function void CheckExecutionTimestamp(string process, string funcname, table obj, boolean delayed) +@param string process - the name of the process +@param string funcname - the name of the function being executed +@param table obj - the object context of the function being executed +@param boolean delayed - whether the function call is delayed +@return boolean - true if the function has been executed more than once, false otherwise +--]] + CheckExecutionTimestamp = function(process, funcname, obj, delayed) + if not config.DebugSuspendProcess then + return + end + if not ExecutionTimestamps then + ExecutionTimestamps = {} + CreateRealTimeThread(function() + Sleep(1) + ExecutionTimestamps = false + end) + end + local func_to_objs = ExecutionTimestamps[process] + if not func_to_objs then + func_to_objs = {} + ExecutionTimestamps[process] = func_to_objs + end + local objs_to_timestamp = func_to_objs[funcname] + if not objs_to_timestamp then + objs_to_timestamp = {} + func_to_objs[funcname] = objs_to_timestamp + end + obj = obj or false + local rtime, gtime = RealTime(), GameTime() + local timestamp = xxhash(rtime, gtime) + if timestamp == objs_to_timestamp[obj] then + print("Duplicated processing:", process, funcname, "time:", gtime, "obj:", obj and obj.class, + obj and obj.handle) + assert(false, string.format("Duplicated process routine: %s.%s", process, funcname)) + else + objs_to_timestamp[obj] = timestamp + --[[ + if IsValid(obj) then + local pos = obj:GetVisualPos() + local seed = xxhash(obj and obj.handle) + local len = 5*guim + BraidRandom(seed, 10*guim) + DbgAddVector(pos, len, RandColor(seed)) + DbgAddText(funcname, pos + point(0, 0, len), RandColor(obj and obj.handle)) + end + --]] + end + end + + --- + --- Checks if there are any remaining reasons for suspending a process. + --- If there are any remaining reasons, an assertion is triggered with the process name and reason. + --- + --- @function CheckRemainingReason + --- @return nil + CheckRemainingReason = function() + local process = next(SuspendProcessReasons) + local reason = process and next(SuspendProcessReasons[process]) + if reason then + assert(false, string.format("Process '%s' not resumed: %s", process, ValueToStr(reason))) + end + end + +end -- Platform.asserts \ No newline at end of file diff --git a/CommonLua/CMT.lua b/CommonLua/CMT.lua new file mode 100644 index 0000000000000000000000000000000000000000..e2ce7e1f3ee7a231a065af5bb282886fd280a809 --- /dev/null +++ b/CommonLua/CMT.lua @@ -0,0 +1,155 @@ +if not const.cmtVisible then return end + +if FirstLoad then + C_CCMT = false +end + +function SetC_CCMT(val) + if C_CCMT == val then + return + end + C_CCMT_Reset() + C_CCMT = val +end + +function OnMsg.ChangeMap() + C_CCMT_Reset() +end + +MapVar("CMT_ToHide", {}) +MapVar("CMT_ToUnhide", {}) +MapVar("CMT_Hidden", {}) + +CMT_Time = 300 +CMT_OpacitySleep = 10 +CMT_OpacityStep = Max(1, MulDivRound(CMT_OpacitySleep, 100, CMT_Time)) + +if FirstLoad then + g_CMTPaused = false + g_CMTPauseReasons = {} +end + +function CMT_SetPause(s, reason) + if s then + g_CMTPauseReasons[reason] = true + g_CMTPaused = true + else + g_CMTPauseReasons[reason] = nil + if not next(g_CMTPauseReasons) then + g_CMTPaused = false + end + end +end + +MapRealTimeRepeat( "CMT_V2_Thread", 0, function() + Sleep(CMT_OpacitySleep) + if g_CMTPaused then return end + --local startTs = GetPreciseTicks(1000) + + if C_CCMT then + C_CCMT_Thread_Func(CMT_OpacityStep) + else + local opacity_step = CMT_OpacityStep + + for k,v in next, CMT_ToHide do + if not IsValid(k) then + CMT_ToHide[k] = nil + else + local next_opacity = k:GetOpacity() - opacity_step + if next_opacity > 0 then + k:SetOpacity(next_opacity) + else + k:SetOpacity(0) + CMT_ToHide[k] = nil + CMT_Hidden[k] = true + end + end + end + for k,v in next, CMT_ToUnhide do + if not IsValid(k) then + CMT_ToUnhide[k] = nil + else + local next_opacity = k:GetOpacity() + opacity_step + if next_opacity < 100 then + k:SetOpacity(next_opacity) + else + k:SetOpacity(100) + k:ClearHierarchyGameFlags(const.gofSolidShadow + const.gofContourInner) + CMT_ToUnhide[k] = nil + end + end + end + end + --local endTs = GetPreciseTicks(1000) + --print("CMT_V2_Thread time", endTs - startTs) +end) + +function IsContourObject(obj) + return const.SlabSizeX and IsKindOf(obj, "Slab") +end + +function CMT(obj, b) + if C_CCMT then + C_CCMT_Hide(obj, not not b) + return + end + + if b then + if CMT_ToHide[obj] or CMT_Hidden[obj] then return end + if CMT_ToUnhide[obj] then + CMT_ToUnhide[obj] = nil + end + CMT_ToHide[obj] = true + obj:SetHierarchyGameFlags(const.gofSolidShadow) + if IsContourObject(obj) then + obj:SetHierarchyGameFlags(const.gofContourInner) + end + else + if CMT_ToUnhide[obj] or not CMT_ToHide[obj] and not CMT_Hidden[obj] then return end + if CMT_ToHide[obj] then + CMT_ToHide[obj] = nil + end + if IsEditorActive() then + obj:SetOpacity(100) + obj:ClearHierarchyGameFlags(const.gofSolidShadow + const.gofContourInner) + else + CMT_ToUnhide[obj] = true + end + if CMT_Hidden[obj] then + CMT_Hidden[obj] = nil + end + end +end + +local function ShowAllKeyObjectsAndClearTable(table) + for obj, _ in pairs(table) do + if IsValid(obj) then + obj:SetOpacity(100) + obj:ClearHierarchyGameFlags(const.gofSolidShadow + const.gofContourInner) + end + table[obj] = nil + end +end + +function OnMsg.ChangeMapDone(map) + if string.find(map, "MainMenu") then + CMT_SetPause(true, "MainMenu") + else + CMT_SetPause(false, "MainMenu") + end +end + +function OnMsg.GameEnterEditor() + C_CCMT_ShowAllAndReset() + ShowAllKeyObjectsAndClearTable(CMT_ToHide) + ShowAllKeyObjectsAndClearTable(CMT_ToUnhide) + ShowAllKeyObjectsAndClearTable(CMT_Hidden) +end + +function CMT_IsObjVisible(o) + if not C_CCMT then + return o:GetGameFlags(const.gofSolidShadow) == 0 or CMT_ToUnhide[o] + else + return C_CCMT_GetObjCMTState(o) < const.cmtHidden + end +end diff --git a/CommonLua/Camera.lua b/CommonLua/Camera.lua new file mode 100644 index 0000000000000000000000000000000000000000..bec74b44c80d6092705391b6605af5bb039f391f --- /dev/null +++ b/CommonLua/Camera.lua @@ -0,0 +1,1005 @@ +--- +--- Calculates the power of a camera shake effect based on the position of the camera and the position of the shake. +--- @param pos point The position of the shake. +--- @param radius_insight number The radius within which the shake is considered to be in sight of the camera. +--- @param radius_outofsight number The radius beyond which the shake is considered to be out of sight of the camera. +--- @return number The power of the camera shake effect, as a percentage. +--- +function CameraShake_GetEffectPower(pos, radius_insight, radius_outofsight) + local cam_pos, cam_look = GetCamera() + local camera_orientation = CalcOrientation(cam_pos, cam_look) + local shake_orientation = CalcOrientation(cam_pos, pos) + local dist = DistSegmentToPt(cam_pos, cam_look, pos) + if dist < 0 then + assert(false) + return 0 + end + + local radius + if abs(AngleDiff(shake_orientation, camera_orientation)) < const.CameraShakeFOV/2 then + radius = radius_insight or const.ShakeRadiusInSight + else + radius = radius_outofsight or const.ShakeRadiusOutOfSight + end + return dist < radius and 100 * (radius - dist) / radius or 0 +end + +--- Starts a camera shake effect with the specified position and power. +-- @cstyle void CameraShake(point pos, int power). +-- @param pos point. +-- @param power int. +-- @return void. +function CameraShake(pos, power) + power = power * CameraShake_GetEffectPower(pos) / 100 + if power == 0 then return end + local total_duration = const.MinShakeDuration + power*(const.MaxShakeDuration-const.MinShakeDuration)/const.MaxShakePower + local shake_offset = power*const.MaxShakeOffset/const.MaxShakePower + local shake_roll = power*const.MaxShakeRoll/const.MaxShakePower + camera.Shake(total_duration, const.ShakeTick, shake_offset, shake_roll) +end + +--- +--- Stores the current camera shake thread and the maximum offset for the camera shake effect. +--- +--- @field camera_shake_thread thread The current camera shake thread. +--- @field camera_shake_max_offset number The maximum offset for the camera shake effect. +--- +MapVar("camera_shake_thread", false) +MapVar("camera_shake_max_offset", 0) +--- +--- Performs a camera shake effect with the specified parameters. +--- +--- @param total_duration number The total duration of the camera shake effect, in seconds. +--- @param shake_tick number The interval between each shake, in seconds. +--- @param max_offset number The maximum offset of the camera shake, in meters. +--- @param max_roll_offset number The maximum roll offset of the camera shake, in degrees. +--- + +local function DoShakeCamera(total_duration, shake_tick, max_offset, max_roll_offset) + local time_left = total_duration + while true do + local LookAtOffset = RandPoint(1500, 500, 500) + local EyePtOffset = RandPoint(1500, 500, 500) + local len = Max(1, 2 * time_left * max_offset / total_duration) + local angle = 60 * time_left * max_roll_offset / total_duration + if LookAtOffset:Len2() > 0 then + LookAtOffset = SetLen(LookAtOffset, len) + end + if EyePtOffset:Len2() > 0 then + EyePtOffset = SetLen(EyePtOffset, len) + end + camera.SetLookAtOffset(LookAtOffset, shake_tick) + camera.SetEyeOffset(EyePtOffset, shake_tick) + camera.SetRollOffset(AsyncRand(2 * angle + 1) - angle, shake_tick) + if total_duration > 0 then + time_left = time_left - shake_tick + if time_left <= shake_tick then + Sleep(time_left) + break + end + end + Sleep(shake_tick) + end + camera.ShakeStop(shake_tick) +end + +--- +--- Performs a camera shake effect with the specified parameters. +--- +--- @param total_duration number The total duration of the camera shake effect, in seconds. +--- @param shake_tick number The interval between each shake, in seconds. +--- @param shake_max_offset number The maximum offset of the camera shake, in meters. This value is clamped to the range [0, 10m]. +--- @param shake_max_roll number The maximum roll offset of the camera shake, in degrees. This value is clamped to the range [0, 180]. +--- +function camera.Shake(total_duration, shake_tick, shake_max_offset, shake_max_roll) + local max_offset = Clamp(shake_max_offset, 0, 10 * guim) + assert(max_offset == shake_max_offset, "camera.Shake() max_offset should be [0-10m]!") + local max_roll = Clamp(shake_max_roll, 0, 180) + assert(max_roll == shake_max_roll, "camera.Shake() max_roll should be [0-180]!") + + if total_duration == 0 or shake_tick <= 0 then + return + end + if IsValidThread(camera_shake_thread) then + if camera_shake_max_offset > shake_max_offset then + return + end + DeleteThread(camera_shake_thread) + end + camera_shake_max_offset = max_offset + camera_shake_thread = CreateRealTimeThread(DoShakeCamera, total_duration, shake_tick, max_offset, max_roll) + MakeThreadPersistable(camera_shake_thread) +end + +--- +--- Stops the camera shake effect. +--- +--- @param shake_tick number The interval between each shake, in seconds. +--- +function camera.ShakeStop(shake_tick) + camera.SetRollOffset(0, 0) + camera.SetLookAtOffset(point30, shake_tick or 0) + camera.SetEyeOffset(point30, shake_tick or 0) + camera_shake_max_offset = 0 + if IsValidThread(camera_shake_thread) and CurrentThread() ~= camera_shake_thread then + DeleteThread(camera_shake_thread) + end + camera_shake_thread = false +end + +function OnMsg.ChangeMap() + camera.ShakeStop() +end + +--- +--- Sets the camera to the specified position, look-at point, camera type, zoom, and field of view. +--- +--- @param ptCamera vec3 The position of the camera. +--- @param ptCameraLookAt vec3 The point the camera is looking at. +--- @param camType string The type of camera to use. Can be "3p", "RTS", "Max", or "Tac". +--- @param zoom number The zoom level of the camera. +--- @param properties table A table of camera properties to set. +--- @param fovX number The field of view of the camera in degrees. +--- @param time number The duration of the camera transition in seconds. +--- +--- @return nil +--- +function SetCamera(ptCamera, ptCameraLookAt, camType, zoom, properties, fovX, time) + if type(ptCamera) == "table" then + return SetCamera(unpack_params(ptCamera)) + end + time = time or 0 + if camType then + if camType == "Max" or camType == "3p" or camType == "RTS" or camType == "Tac" then + camType = "camera" .. camType + end + _G[camType].Activate(1) + end + if not ptCamera then + return + end + if camera3p.IsActive() then + camera3p.SetEye(ptCamera, time) + camera3p.SetLookAt(ptCameraLookAt, time) + elseif cameraRTS.IsActive() then + if properties then + cameraRTS.SetProperties(1, properties) + end + cameraRTS.SetCamera(ptCamera, ptCameraLookAt, time) + if zoom then + cameraRTS.SetZoom(zoom) + end + elseif cameraMax.IsActive() then + -- cameraMax can't look straight down + local diff = ptCameraLookAt - ptCamera + if diff:x() == 0 and diff:y() == 0 then + ptCamera = ptCamera:SetX(ptCamera:x()-5) + end + cameraMax.SetCamera(ptCamera, ptCameraLookAt, time) + elseif cameraTac.IsActive() then + cameraTac.SetCamera(ptCamera, ptCameraLookAt, time) + if properties then + local floor = properties.floor + local overview = properties.overview + if floor then + cameraTac.SetFloor(floor) + end + if overview ~= nil then + cameraTac.SetOverview(overview, true) + end + end + if zoom then + cameraTac.SetZoom(zoom) + end + end + SetCameraFov(fovX) +end + +--- +--- Sets the camera field of view (FOV) to the specified value. +--- +--- @param fovX number The horizontal field of view in degrees. If not provided, defaults to 70 degrees. +--- +function SetCameraFov(fovX) + camera.SetFovX(fovX or 70 * 60) +end + +--- +--- Sets the camera field of view (FOV) to the specified value, with optional easing. +--- +--- @param properties table A table containing the following properties: +--- - FovX: number The horizontal field of view in degrees. +--- - FovXNarrow: number The horizontal field of view in degrees for 3:4 screens. +--- - FovXWide: number The horizontal field of view in degrees for 21:9 screens. +--- @param duration number (optional) The duration of the FOV change in seconds. +--- @param easing string (optional) The easing function to use for the FOV change. +--- +function SetRTSCameraFov(properties, duration, easing) + local FovX = properties.FovX + local minFovX = properties.FovXNarrow -- FovX for 3:4 screens + local minX, minY = 4, 3 + local maxFovX = properties.FovXWide -- FovX for 21:9 screens + local maxX, maxY = 21, 9 + if FovX and minFovX and maxFovX then + -- when both FovXNarrow and FovXWide are supplied, + -- FovX at 16:9 is computed and equals FovXNarrow * 5 / 9 + FovXWide * 4 / 9 + assert(abs(minFovX * 5 / 9 + maxFovX * 4 / 9 - FovX) < 60) + end + FovX = FovX or 90 * 60 + if not minFovX then + minFovX = FovX + minX, minY = 16, 9 + end + if not maxFovX then + maxFovX = FovX + maxX, maxY = 16, 9 + end + hr.CameraFovEasing = easing or "Linear" + camera.SetAutoFovX(1, duration or 0, minFovX, minX, minY, maxFovX, maxX, maxY) +end + +-- init from last camera with reasonable settings ( at editor exit, ... ) +-- or set to map center if first run +--- +--- Sets the default camera to the RTS (Real-Time Strategy) camera type and configures its properties. +--- +--- If the Libs.Sim module is available, it retrieves the RTS camera properties from the account storage and applies them. +--- Otherwise, it uses the default RTS camera properties defined in const.DefaultCameraRTS. +--- +--- The function also sets the camera's field of view using the SetRTSCameraFov function, and positions the camera to the center of the map if the current look-at position is at (0, 0). +--- +--- Finally, it calls the ViewObjectRTS function to set the camera's position and look-at to the center of the map. +--- +function SetDefaultCameraRTS() + cameraRTS.Activate(1) + cameraRTS.SetProperties(1, const.DefaultCameraRTS) + if Libs.Sim then + cameraRTS.SetProperties(1, GetRTSCamPropsFromAccountStorage()) + end + SetRTSCameraFov(const.DefaultCameraRTS) + local lookat = cameraRTS.GetLookAt() + if lookat:x() == 0 and lookat:y() == 0 then + lookat = point(terrain.GetMapSize()) / 2 + end + ViewObjectRTS(lookat, 0) +end + +--- +--- Returns a table of available camera types. +--- +--- @return table Camera types +--- +function GetCameraTypesItems() + return {"3p", "RTS", "Max", "Tac"} +end + +--- +--- Returns the current camera position, look-at position, camera type, zoom level, and camera properties. +--- +--- @return point, point, string, number, table, number Camera position, look-at position, camera type, zoom level, camera properties, and field of view angle +--- +function GetCamera() + local ptCamera, ptCameraLookAt, camType, zoom, properties, fovX + if camera3p.IsActive() then + ptCamera, ptCameraLookAt = camera.GetEye(), camera3p.GetLookAt() + camType = "3p" + elseif cameraRTS.IsActive() then + ptCamera, ptCameraLookAt = cameraRTS.GetPosLookAt() + camType = "RTS" + zoom = cameraRTS.GetZoom() + properties = cameraRTS.GetProperties(1) + elseif cameraMax.IsActive() then + ptCamera, ptCameraLookAt = cameraMax.GetPosLookAt() + camType = "Max" + elseif cameraTac.IsActive() then + ptCamera, ptCameraLookAt = cameraTac.GetPosLookAt() + camType = "Tac" + zoom = cameraTac.GetZoom() + properties = {floor=cameraTac.GetFloor(), overview=cameraTac.GetIsInOverview()} + else + ptCamera, ptCameraLookAt = camera.GetEye(), camera.GetEye() + SetLen(camera.GetDirection(), 3 * guim) + end + fovX = camera.GetFovX() + return ptCamera, ptCameraLookAt, camType, zoom, properties, fovX +end + +if FirstLoad then + ptLastCameraPos = false + ptLastCameraLookAt = false + cameraMax3DView = { + toggle = false, + old_pos = false, + old_lookat = false, + } +end + +--- +--- Cleans up the state of the cameraMax3DView object. +--- Resets the toggle, old_pos, and old_lookat properties to their default values. +--- +function cameraMax3DView:Clean() + self.toggle = false + self.old_pos = false + self.old_lookat = false +end + +-- returns the new camera pos and the look pos of the selection +--- +--- Rotates the camera in the 3D Max view to the specified view direction. +--- +--- @param view_direction point The new view direction for the camera. +--- +local function cameraMax3DView_Rotate(view_direction) + local sel = editor.GetSel() + local cnt = #sel + if cnt == 0 then + print("You need to select object(s) for this operation") + return + end + + local center = point30 + for i = 1, cnt do + local bsc = sel[i]:GetBSphere() + center = center + bsc + end + if cnt > 0 then + -- find center of the selection + center = point(center:x() / cnt, center:y() / cnt, center:z() / cnt) + + -- find the radius of the bounding sphere of the selection + local selSize = 0 + for i = 1, cnt do + local bsc, bsr = sel[i]:GetBSphere() + local dist = bsc:Dist(center) + bsr + if selSize < dist then + selSize = dist + end + end + selSize = 2 * selSize -- get the diameter of the selection + + -- move the camera position to look in the center of the selection + local half_fovY = MulDivRound(camera.GetFovY(), 1, 2) + local fov_sin, fov_cos = sin(half_fovY), cos(half_fovY) + local dist_from_camera = (fov_sin > 0) and MulDivRound((selSize / 2), fov_cos, fov_sin) or (selSize / 2) + + view_direction = SetLen(view_direction, dist_from_camera * 130 / 100) + local pos = center + view_direction + cameraMax.SetCamera(pos, center, 0) + end +end + +--- +--- Rotates the camera in the 3D Max view to the up direction. +--- +function cameraMax3DView:SetViewUp() + cameraMax3DView_Rotate(point(0, 0, 1)) +end +--- +--- Rotates the camera in the 3D Max view to the down direction. +--- +function cameraMax3DView:SetViewDown() + cameraMax3DView_Rotate(point(0, 0, -1)) +end +--- +--- Sets the camera to the old position and look-at point. +--- +function cameraMax3DView:SetViewOld() + cameraMax.SetCamera(cameraMax3DView.old_pos, cameraMax3DView.old_lookat, 0) +end + +--- +--- Rotates the camera in the 3D Max view around the Z axis. +--- +--- @param dir string The direction to rotate the camera, either "east" or "west". +--- +function cameraMax3DView:RotateZ(dir) + local pos, look_at = cameraMax.GetPosLookAt() + local cam_angle = (camera.GetYaw() / 60) + 180 + local cam_quadrant = (cam_angle / 90) % 4 + 1 + local correction = 0 + local z_axis = point(0, 0, 1) + + if cam_angle % 90 ~= 0 then + if cam_angle - 90 * (cam_quadrant - 1) < 90 * cam_quadrant - cam_angle then + correction = -(cam_angle - 90 * (cam_quadrant - 1)) + else + correction = 90 * cam_quadrant - cam_angle + end + cam_angle = cam_angle + correction + end + + local view_dir = false + if dir == "east" then + view_dir = RotateAxis(pos, z_axis, (cam_angle - 90) * 60) + else + view_dir = RotateAxis(pos, z_axis, (cam_angle + 90) * 60) + end + + if view_dir then + cameraMax3DView_Rotate(Normalize(view_dir)) + end +end + +--- +--- Sets the camera position and look-at point. +--- +--- @param pos table The position of the camera, represented as a point. +--- @param dist number (optional) The distance from the camera to the look-at point. +--- @param cam_type number (optional) The type of camera to use. +--- +--- If `pos` is `InvalidPos()`, the camera will be reset to the last known position and look-at point. +--- If `pos` does not have a valid Z coordinate, it will be set to the terrain Z coordinate. +--- The camera vector is calculated based on the `pos` and the look-at point, and the camera is set to this position. +--- +function ViewPos(pos, dist, cam_type) + local ptCamera, ptCameraLookAt = GetCamera() + if not ptCamera then + return + end + if pos == InvalidPos() then + pos = nil + end + if not pos then + if ptLastCameraPos then + SetCamera(ptLastCameraPos, ptLastCameraLookAt, cam_type) + end + return + end + + ptLastCameraPos, ptLastCameraLookAt = ptCamera, ptCameraLookAt + + if not pos:z() then + pos = pos:SetTerrainZ() + end + + local cameraVector = ptCameraLookAt - ptCamera + if dist then + cameraVector = SetLen(cameraVector, dist) + end + ptCamera = pos - cameraVector + ptCameraLookAt = pos + + SetCamera(ptCamera, ptCameraLookAt, cam_type) +end + +--- +--- Sets the camera to view the specified object. +--- +--- @param obj MapObject|number The object to view, or its handle. +--- @param dist number (optional) The distance from the camera to the object. +--- +--- If `obj` is a number, it is assumed to be the handle of a `MapObject` and looked up in `HandleToObject`. +--- If `pos` is `InvalidPos()`, the camera will be reset to the last known position and look-at point. +--- If `pos` does not have a valid Z coordinate, it will be set to the terrain Z coordinate. +--- The camera vector is calculated based on the `pos` and the look-at point, and the camera is set to this position. +--- +ViewObject = function(obj, dist) + if type(obj) == "number" and HandleToObject[obj] then + obj = HandleToObject[obj] + end + local pos = IsValid(obj) and obj:GetPos() + if not pos or pos == InvalidPos() then + return + end + if dist then + ViewPos(pos, dist) + else + local center, radius = obj:GetBSphere() + ViewPos(center, Max(guim, radius * 10)) + end +end + +--- +--- Caches the last object viewed by `ViewNextObject`. +--- +--- This cache is used to keep track of the last object that was viewed, so that `ViewNextObject` can cycle through the objects in the order they were viewed. +--- +local ViewNextObjectCache +function OnMsg.ChangeMap() + ViewNextObjectCache = nil +end + +-- Cycles ViewObject in the array objs, viewing the next object every time it is called for the same set of parameters + +--- +--- Cycles through the next object in the given list of objects and views it. +--- +--- @param name string (optional) The class name of the objects to cycle through. If not provided, the class name of the last selected object is used. +--- @param objs table (optional) The list of objects to cycle through. If not provided, all objects of the given class name are used. +--- @param select_obj boolean (optional) Whether to select the object after viewing it. +--- +--- If `name` is not provided and there is no last selected object, this function does nothing. +--- If `objs` is not provided, all objects of the given class name are used. +--- The function keeps track of the last object viewed and cycles to the next one in the list. +--- If the end of the list is reached, it cycles back to the beginning. +--- If `select_obj` is true, the viewed object is also selected. +--- +function ViewNextObject(name, objs, select_obj) + name = name or "" + local last + if not objs then + if name == "" then + last = SelectedObj + name = last and last.class + select_obj = true + end + if not IsKindOf(g_Classes[name], "MapObject") then + return + end + objs = MapGet("map", name) + end + ViewNextObjectCache = ViewNextObjectCache or setmetatable({}, weak_values_meta) + last = last or ViewNextObjectCache[name] + local idx = last and table.find(objs, last) or 0 + last = objs[idx + 1] or objs[1] + ViewNextObjectCache[name] = last + ViewObject(last) + SelectObj(last) +end + +--- +--- Cycles through the next object in the given list of objects and views it. +--- +--- @param name string (optional) The class name of the objects to cycle through. If not provided, the class name of the last selected object is used. +--- @param objs table (optional) The list of objects to cycle through. If not provided, all objects of the given class name are used. +--- @param select_obj boolean (optional) Whether to select the object after viewing it. +--- +--- If `name` is not provided and there is no last selected object, this function does nothing. +--- If `objs` is not provided, all objects of the given class name are used. +--- The function keeps track of the last object viewed and cycles to the next one in the list. +--- If the end of the list is reached, it cycles back to the beginning. +--- If `select_obj` is true, the viewed object is also selected. +--- +function ViewObjects(objects) + objects = objects or {} + local dgs = XEditorSelectSingleObjects + XEditorSelectSingleObjects = 1 + editor.ChangeSelWithUndoRedo(objects) + XEditorSelectSingleObjects = dgs + if #objects == 0 then + return + end + local bbox = GetObjectsBBox(objects) + local center, radius = bbox:GetBSphere() + local cam_pos = camera.GetEye() + local h = cam_pos:z() - terrain.GetSurfaceHeight(cam_pos) + local eye = center:SetZ(0) + SetLen((cam_pos - center):SetZ(0), h) + eye = eye:SetZ(terrain.GetSurfaceHeight(eye) + h) + local dist = (eye - center):Len() + local new_dist = Clamp(Max(dist, 2*radius), 10*guim, 100*guim) + eye = center + MulDivRound(eye - center, new_dist, dist) + local steps = 18 + local angle = 360 * 60 / steps + local max_radius = 2 * guim + local success = true + local objects_map = {} + for i=1,#objects do + objects_map[objects[i]] = true + end + while true do + local objs = IntersectSegmentWithObjects(eye, center, const.efVisible) + if not objs then + break + end + local objects_too_big = false + for i=1,#objs do + local obj = objs[i] + if not objects_map[obj] then + local center, radius = obj:GetBSphere() + if radius > max_radius then + objects_too_big = true + break + end + end + end + if not objects_too_big then + break + end + steps = steps - 1 + if steps <= 1 then + success = false + break + end + eye = RotateAroundCenter(center, eye, angle) + eye = eye:SetZ(terrain.GetSurfaceHeight(eye) + h) + end + if success then + SetCamera(eye, center) + end +end + +if FirstLoad then + SplitScreenType = false + SplitScreenEnabled = true + SecondViewEnabled = false + SecondViewViewport = false +end + +-- call this after every resolution/scene size change to recalc and setup appropriate views for single or split screen +--- +--- Sets up the camera views for single or split screen. +--- +--- @param size number|nil The size of the screen, if provided. +--- +--- This function is responsible for configuring the camera views based on the current split screen settings. +--- If split screen is enabled, it sets up two views - one for each player. The views can be either horizontal or vertical. +--- If split screen is disabled, it sets up a single view that covers the entire screen. +--- The function adjusts the viewport settings of the camera accordingly. +--- +function SetupViews(size) + local w, h = 1000000, 1000000 + if SecondViewEnabled and SecondViewViewport then + camera.SetViewCount(2) + camera.SetViewport(box(0, 0, w, h), 1) + camera.SetViewport(SecondViewViewport, 2) + elseif SplitScreenEnabled then + if SplitScreenType == "horizontal" then + camera.SetViewCount(2) + camera.SetViewport(box(0, 0, w, h / 16 * 8), 1) + camera.SetViewport(box(0, (h + 15) / 16 * 8, w, h), 2) + elseif SplitScreenType == "vertical" then + camera.SetViewCount(2) + camera.SetViewport(box(0, 0, w / 16 * 8, h), 1) + camera.SetViewport(box((w + 15) / 16 * 8, 0, w, h), 2) + else + camera.SetViewCount(1) + camera.SetViewport(box(0, 0, w, h), 1) + end + else + if not SplitScreenType then + camera.SetViewCount(1) + camera.SetViewport(box(0, 0, w, h), 1) + else + camera.SetViewport(box(0, 0, w, h), 1) + end + end +end + +if FirstLoad then +SplitScreenDisableReasons = {} +end + +--- +--- Enables or disables split screen mode based on the provided reason. +--- +--- @param on boolean Whether to enable or disable split screen mode. +--- @param reason string The reason for enabling or disabling split screen mode. +--- +--- This function is responsible for managing the state of split screen mode. It updates the `SplitScreenDisableReasons` table to track the reasons for enabling or disabling split screen mode. If there are no more reasons to disable split screen mode, it enables it. Otherwise, it disables it. The function also calls `SetupViews()` to reconfigure the camera views and sends a "SplitScreenChange" message. +--- +function SetSplitScreenEnabled(on, reason) + assert(reason) + SplitScreenDisableReasons[reason] = (on == false) or nil + on = not next(SplitScreenDisableReasons) + if SplitScreenEnabled ~= on then + SplitScreenEnabled = on + SetupViews() + Msg("SplitScreenChange", true) + end +end + +--- +--- Enables the second view for the camera and sets the viewport for it. +--- +--- @param viewport table The viewport for the second view. +--- +--- This function enables the second view for the camera and sets the viewport for it. It updates the `SecondViewEnabled` and `SecondViewViewport` variables and then calls the `SetupViews()` function to reconfigure the camera views. +--- +function EnableSecondView(viewport) + SecondViewEnabled = true + SecondViewViewport = viewport + SetupViews() +end + +--- +--- Disables the second view for the camera. +--- +--- This function disables the second view for the camera by setting the `SecondViewEnabled` variable to `false` and calling the `SetupViews()` function to reconfigure the camera views. +--- +function DisableSecondView() + SecondViewEnabled = false + SetupViews() +end + +--- +--- Sets the split screen type. +--- +--- @param type string The type of split screen to use, or an empty string to disable split screen. +--- +--- This function sets the `SplitScreenType` variable to the provided `type` parameter. If the `type` is an empty string, split screen is disabled. The function then calls `SetupViews()` to reconfigure the camera views, and sends a "SplitScreenChange" message if the split screen type has changed. +function SetSplitScreenType(type) + if type == "" then + type = false + end + local bChange = SplitScreenType ~= type + SplitScreenType = type + if not CameraControlScene then + SetupViews() + end + if bChange then + Msg("SplitScreenChange") + end +end + +--- +--- Checks if split screen is enabled. +--- +--- @return boolean true if split screen is enabled, false otherwise +--- +function IsSplitScreenEnabled() + return SplitScreenEnabled and SplitScreenType and true +end + +--- +--- Checks if split screen is in horizontal mode. +--- +--- @return boolean true if split screen is in horizontal mode, false otherwise +--- +function IsSplitScreenHorizontal() + return SplitScreenEnabled and SplitScreenType == "horizontal" +end + +--- +--- Checks if split screen is in vertical mode. +--- +--- @return boolean true if split screen is in vertical mode, false otherwise +--- +function IsSplitScreenVertical() + return SplitScreenEnabled and SplitScreenType == "vertical" +end + +--- +--- Loads a map and camera location from a saved state. +--- +--- @param map string The name of the map to load. +--- @param cam_params table A table containing the camera parameters to set. +--- @param editor_mode boolean Whether to activate the editor mode after loading. +--- @param map_rand number The random seed to use for the map. +--- +--- This function loads a map and camera location from a saved state. It first checks if the map exists, and if not, prints an error message. It then creates a real-time thread to perform the following steps: +--- +--- 1. Deactivate the editor. +--- 2. If the map or random seed is different from the current map, change the map and restore the configuration. +--- 3. If the editor mode is enabled, activate the editor. +--- 4. Set the camera parameters, activating the fly camera if necessary. +--- 5. Close any open menu dialogs. +--- 6. Send a "OnDbgLoadLocation" message. +--- +--- This function is typically used for debugging purposes, to quickly load a specific map and camera location. +function DbgLoadLocation(map, cam_params, editor_mode, map_rand) + if not MapData[map] then + print("No such map:", map) + return + end + CreateRealTimeThread(function() + EditorDeactivate() + if map ~= GetMapName() or map_rand and map_rand ~= MapLoadRandom then + if map_rand then + table.change(config, "DbgLoadLocation", {FixedMapLoadRandom=map_rand}) + end + ChangeMap(map) + table.restore(config, "DbgLoadLocation", true) + end + if editor_mode then + EditorActivate() + end + if cam_params then + if cam_params[3] == "Fly" then + cam_params[3] = "Max" + SetCamera(table.unpack(cam_params)) + cameraFly.Activate() + else + SetCamera(table.unpack(cam_params)) + end + end + CloseMenuDialogs() + Msg("OnDbgLoadLocation") + end) +end + +--- +--- Gets a string representation of the current camera location that can be used to restore the camera state. +--- +--- @return string A string that can be passed to `DbgLoadLocation` to restore the camera state. +--- +function GetCameraLocationString() + local cam_params + if cameraFly.IsActive() then + -- Fly camera doesn't expose its parameters, but it can be saved as Max and forced to Fly again on load + cameraMax.Activate() + cam_params = {GetCamera()} + cam_params[3] = "Fly" + cameraFly.Activate() + else + cam_params = {GetCamera()} + end + return string.format("DbgLoadLocation( \"%s\", %s, %s, %s)\n", GetMapName(), TableToLuaCode(cam_params, ' '), + IsEditorActive() and "true" or "false", tostring(MapLoadRandom)) +end + +function OnMsg.BugReportStart(print_func) + print_func(string.format("\nLocation: (paste in the console)\n%s", GetCameraLocationString())) +end + +if FirstLoad then + g_ResetSceneCameraViewportThread = false +end + +function OnMsg.SystemSize(pt) + --if FullscreenMode() == 0 then + DeleteThread(g_ResetSceneCameraViewportThread) + g_ResetSceneCameraViewportThread = CreateRealTimeThread(function() + WaitNextFrame(1) + SetupViews(pt) + end) + --end +end + +--- +--- Checks if the given position is a valid camera position. +--- +--- @param pos point The position to check. +--- @return boolean True if the position is valid, false otherwise. +--- +local function IsValidCameraPos(pos) + return pos and pos ~= point30 and pos ~= InvalidPos() +end + +--- +--- Checks if the camera can move between two positions without intersecting terrain. +--- +--- @param pos0 point The starting position for the camera movement. +--- @param pos1 point The ending position for the camera movement. +--- @return boolean True if the camera can move between the two positions without intersecting terrain, false otherwise. +--- +local function CanMoveCamBetween(pos0, pos1) + local max_move_dist = const.MaxMoveCamDist or max_int + if max_move_dist >= max_int or IsCloser(pos0, pos1, max_move_dist) then + return true + end + return not terrain.IntersectSegment(pos0, pos1) +end + +--- +--- Moves the camera to view the specified object, optionally with a zoom level. +--- +--- @param obj table|point The object or position to view +--- @param time number The time in seconds for the camera to move to the new position +--- @param pos point The position to move the camera to +--- @param zoom number The zoom level to set the camera to +--- +function ViewObjectRTS(obj, time, pos, zoom) + if not obj then + return + end + + local la = IsPoint(obj) and obj or IsValid(obj) + and (obj:HasMember("GetLogicalPos") and obj:GetLogicalPos() or obj:GetVisualPos()) + if not la or la == InvalidPos() then + return + end + la = la:SetTerrainZ() + + local cur_pos, cur_la = cameraRTS.GetPosLookAt() + if not pos then + local cur_off = cur_pos - cur_la + if not IsValidCameraPos(cur_pos) or cur_pos == cur_la then + local lookatDist = const.DefaultCameraRTS.LookatDistZoomIn + + (const.DefaultCameraRTS.LookatDistZoomOut - const.DefaultCameraRTS.LookatDistZoomIn) + * cameraRTS.GetZoom() + cur_off = SetLen(point(1, 1, 0), lookatDist * guim) + point(0, 0, cameraRTS.GetHeight() * guim) + zoom = zoom or 0.5 + end + pos = la + cur_off + end + pos, la = cameraRTS.Normalize(pos, la) + + if not IsValidCameraPos(cur_pos) or not CanMoveCamBetween(cur_pos, pos) then + time = 0 + elseif not time then + local min_dist, max_dist = 200 * guim, 1000 * guim + local min_time, max_time = 200, 500 + local dist_factor = Clamp(pos:Dist2D(cur_pos) - min_dist, 0, max_dist) * 100 / (max_dist - min_dist) + time = min_time + (max_time - min_time) * dist_factor / 100 + end + + cameraRTS.SetCamera(pos, la, time or 0, "Sin in/out") + if zoom then + cameraRTS.SetZoom(zoom, time or 0) + end +end + +--- +--- Defines the available types of camera interpolation. +--- +--- @class CameraInterpolationTypes +--- @field linear integer Linear interpolation. +--- @field spherical integer Spherical interpolation. +--- @field polar integer Polar interpolation. +CameraInterpolationTypes = {linear=0, spherical=1, polar=2} + +--- +--- Defines the available types of camera movement. +--- +--- @class CameraMovementTypes +--- @field linear integer Linear movement. +--- @field harmonic integer Harmonic movement. +--- @field accelerated integer Accelerated movement. +--- @field decelerated integer Decelerated movement. +CameraMovementTypes = {linear=0, harmonic=1, accelerated=2, decelerated=3} + +--- +--- Sets the camera position and lookat point, taking into account the base offset and angle. +--- +--- @param pos table The camera position. +--- @param lookat table The camera lookat point. +--- @param base_offset table The base offset to apply to the position and lookat. +--- @param base_angle number The base angle to apply to the position and lookat. +--- @param camera_view integer The camera view to use. +--- +function SetCameraPosMaxLookAt(pos, lookat, base_offset, base_angle, camera_view) + cameraMax.SetPositionLookatAndRoll(base_offset + Rotate(pos, base_angle), base_offset + Rotate(lookat, base_angle), + 0) +end + +--- +--- Interpolates the camera position and lookat point between two camera states over a given duration, relative to a reference object. +--- +--- @param camera1 table The initial camera state, with `pos` and `lookat` fields. +--- @param camera2 table The final camera state, with `pos` and `lookat` fields. +--- @param duration number The duration of the interpolation in frames. +--- @param relative_to Entity The entity to use as the reference for the camera position and lookat. +--- @param interpolation string The type of interpolation to use, one of "linear", "spherical", or "polar". +--- @param movement string The type of camera movement to use, one of "linear", "harmonic", "accelerated", or "decelerated". +--- @param camera_view integer The camera view to use. +--- +function InterpolateCameraMaxWakeup(camera1, camera2, duration, relative_to, interpolation, movement, camera_view) + camera_view = camera_view or 1 + + local base_offset = IsValid(relative_to) and relative_to:GetVisualPosPrecise(1000) or point30 + local base_angle = IsValid(relative_to) and relative_to:GetVisualAngle() or 0 + + local camera2_pos = Rotate(camera2.pos * 1000 - base_offset, 360 * 60 - base_angle) + local camera2_lookat = Rotate(camera2.lookat * 1000 - base_offset, 360 * 60 - base_angle) + if duration > 1 then + local camera1_pos = Rotate(camera1.pos * 1000 - base_offset, 360 * 60 - base_angle) + local camera1_lookat = Rotate(camera1.lookat * 1000 - base_offset, 360 * 60 - base_angle) + SetCameraPosMaxLookAt(camera1_pos, camera1_lookat, base_offset, base_angle, camera_view) + for t = 1, duration do + if WaitWakeup(1) then + break + end + base_offset = IsValid(relative_to) and relative_to:GetVisualPosPrecise(1000) or point30 + base_angle = IsValid(relative_to) and relative_to:GetVisualAngle() or 0 + local p, l = CameraLerp(camera1_pos, camera1_lookat, camera2_pos, camera2_lookat, t, duration, + CameraInterpolationTypes[interpolation] or 0, CameraMovementTypes[movement] or 0) + SetCameraPosMaxLookAt(p, l, base_offset, base_angle, camera_view) + end + end + SetCameraPosMaxLookAt(camera2_pos, camera2_lookat, base_offset, base_angle, camera_view) +end + +--- +--- Toggles the fly camera mode. +--- +--- If the fly camera is active, it deactivates the fly camera and applies the camera and controllers. +--- If the fly camera is not active, it activates the fly camera and recalculates the active player control. +--- It also sets the mouse delta mode accordingly. +--- +function CheatToggleFlyCamera() + if cameraFly.IsActive() then + SetMouseDeltaMode(false) + if rawget(_G, "GetPlayerControlObj") and GetPlayerControlObj() then + ApplyCameraAndControllers() + else + SetupInitialCamera() + end + else + print("Camera Fly") + cameraFly.Activate(1) + if rawget(_G, "GetPlayerControlObj") and GetPlayerControlObj() then + PlayerControl_RecalcActive(true) + end + SetMouseDeltaMode(true) + end +end diff --git a/CommonLua/CameraControlUtils.lua b/CommonLua/CameraControlUtils.lua new file mode 100644 index 0000000000000000000000000000000000000000..bc04608c991656036728fe6ad1fb5135ccb31d57 --- /dev/null +++ b/CommonLua/CameraControlUtils.lua @@ -0,0 +1,149 @@ +--- Returns the camera eye position adjusted to be above the terrain. +-- @param EyePt point The camera eye position. +-- @param LookAtPt point The camera look at position. +-- @return point The adjusted camera eye position. +-- @return point The camera look at position. +function GetCameraEyeOverTerrain(EyePt, LookAtPt) + local height = GetWalkableZ(EyePt) + const.CameraMinTerrainDist + local EyePt = EyePt + if height > EyePt:z() then + EyePt = EyePt:SetZ(height) + end + return EyePt, LookAtPt +end + +--- Moves camera look at and eye pos smoothly over the given period of time. +-- @cstyle int MoveCamera(function get_look_at, function get_eye, int time); +-- @param get_look_at function; a callback function that receives time as parameter and returns the camera look at position; the callback function is called every 33ms. +-- @param get_eye function; a callback function that receives time and look at position as parameter and returns the camera eye position; the callback function is called every 33ms. +-- @param time int. +-- @return int; orientation in minutes. + +function MoveCamera(get_look_at, get_eye, time) + if not camera3p.IsActive() then + return + end + local sleep = 33 + local time_from_start = 0 + while true do + local time_to_end = time - time_from_start + local sleep_time = Min(sleep, time - time_from_start) + local target_time = Min(time_from_start+sleep_time, time) + + local look_at = get_look_at(target_time) + local eye = get_eye (look_at, target_time) + eye = GetCameraEyeOverTerrain(eye, look_at) + camera3p.SetLookAt(look_at, sleep_time) + camera3p.SetEye (eye , sleep_time) + if sleep_time > 0 then + Sleep(sleep_time) + end + if not camera3p.IsActive() then + return + end + time_from_start = time_from_start + sleep_time + if time_from_start >= time then + break + end + end +end + +--- Return a callback function that is to be used as get_look_at parameter of MoveCamera function. +-- The callback will move the current look at position from the camera current look at postion to the target_pos. +-- 'observing' the target object's movement - the farther the target moves from his start position, the farther. +-- the camera look at will move away from its initial position and will approach the target_pos. +-- @cstyle function LookAtFollowCharacter(object target, point target_pos, int total_time). +-- @param target object. +-- @param target_pos point. +-- @param total_time int. +-- @return function. +function LookAtFollowCharacter(target, target_pos, total_time) + if not camera3p.IsActive() then + return + end + local start_pt = camera3p.GetLookAt() + local last_dist = 0 + local max_dist = start_pt:Dist(target_pos) + local pos_lerp = ValueLerp(start_pt, target_pos, max_dist) + local height_lerp = ValueLerp(start_pt:z(), target_pos:z(), total_time) + local last_pos + return function(time) + if IsValid(target) then + local pos = GetPosFromPosSpot(target) + local dist = Min(pos:Dist(start_pt), max_dist) + if dist > last_dist then + last_dist = dist + end + end + return pos_lerp(last_dist):SetZ(height_lerp(time)) + end +end + +--- Return a callback function that is to be used as get_eye parameter of MoveCamera function. +-- The callback will move smoothly the camera eye's z to the targetz, rotate the camera to the target_yaw, keeping the 2d distance from the eye to the look at to dist_eye_look_at. +-- @cstyle function RotateKeepDistEye(int target_eyez, int target_yaw, point dist_eye_look_at, int total_time). +-- @param target_eyez int. +-- @param target_yaw int. +-- @param dist_eye_look_at int. +-- @param total_time int. +-- @return function. +function RotateKeepDistEye(target_eyez, target_yaw, dist_eye_look_at, total_time) + local pt = point(-dist_eye_look_at, 0, 0) + local angle_lerp = AngleLerp(camera.GetYaw(), target_yaw, total_time) + local eye_height_lerp = ValueLerp(camera.GetEye():z(), target_eyez, total_time) + return function(look_at_pos, time) + local eye = look_at_pos + Rotate(pt, angle_lerp(time)) + eye = eye:SetZ(eye_height_lerp(time)) + return eye + end +end + +--- This function will smoothly move/rotate the camera according the given parameters, mimicking the XCamera default behavior. +-- @cstyle void DefMoveCamera(point pos, int yaw, int pitch, int rot_speed, int move_speed, int move_time, int yaw_time, int pitch_time). +-- @param pos point; target camera look at position. +-- @param yaw int; targer camera yaw. +-- @param pitch int; target camera pitch. +-- @param rot_speed int; camera rotation speed in angular minutes per sec; can be omitted; used to calculate move_time in case move_time is omitted. +-- @param move_speed int; camera movement speed in angular minutes per sec; can be omitted; used to calculate yaw_time and pitch_time in case yaw_time or pitch_time are omitted. +-- @param move_time int; the time the camera should reach the target position; if omitted the time will be calculated from move_speed parameter. +-- @param yaw_time int; the time the camera should reach the target yaw; if omitted the time will be calculated from rot_speed parameter. +-- @param pitch_time int; the time the camera should reach the target position; if omitted the time will be calculated from rot_speed parameter. +-- @return void. +function DefMoveCamera(pos, yaw, dist_scale, pitch, rot_speed, move_speed, move_time, yaw_time, pitch_time) + if not camera3p.IsActive() then + return + end + if not pos:IsValidZ() then + pos = pos:SetTerrainZ() + end + local start_look_at, start_pitch, start_yaw = camera3p.GetLookAt(), camera3p.GetPitch(), camera3p.GetYaw() + local look_at_height_offset = (const.CameraScale*const.CameraVerticalOffset/100)*dist_scale/100 + + rot_speed = rot_speed or const.CameraRotationDegreePerSec + move_speed = move_speed or const.CameraResetMmPerSec + + local pitch_time = pitch_time or abs(AngleDiff(start_pitch, pitch)/60)*1000/rot_speed + local yaw_time = yaw_time or abs(AngleDiff(start_yaw, yaw)/60)*1000/rot_speed + local move_time = move_time or pos:Dist(start_look_at)*1000/move_speed + local yaw_lerp = AngleLerp(start_yaw, yaw, yaw_time, true) + local pos_lerp = ValueLerp(start_look_at, pos:SetZ(look_at_height_offset + (pos:z() or terrain.GetHeight(pos))), move_time, true) + local start_l, start_h = GetCameraLH(start_pitch, camera3p.DistanceAtPitch(start_pitch) * dist_scale / 100) + local end_l , end_h = GetCameraLH( pitch, camera3p.DistanceAtPitch( pitch) * dist_scale / 100) + + local l_lerp, h_lerp = ValueLerp(start_l, end_l, pitch_time, true), ValueLerp(start_h, end_h, pitch_time, true) + + + local function LookAt(t) + return pos_lerp(t) + end + + local function EyePt(look_at, t) + local yaw = yaw_lerp(t) + local l, h = l_lerp(t), h_lerp(t) + + local eye = (look_at+Rotate(point(-l, 0, 0), yaw)):SetZ(h+look_at:z()) + return eye + end + + MoveCamera(LookAt, EyePt, Max(pitch_time, yaw_time, move_time)) +end diff --git a/CommonLua/CameraMakeTransparent.lua b/CommonLua/CameraMakeTransparent.lua new file mode 100644 index 0000000000000000000000000000000000000000..1a7bcadb1a28e93bbe21a387828c9f2d7a8381cb --- /dev/null +++ b/CommonLua/CameraMakeTransparent.lua @@ -0,0 +1,454 @@ +-- make objects that obstruct the view transparent (camera3p) +if FirstLoad then + g_CameraMakeTransparentEnabled = false + g_updateStepOpacityThread = false + g_CameraMakeTransparentThread = false + g_CMT_fade_out = false + g_CMT_fade_in = false + g_CMT_hidden = false + g_CMT_replaced = false + g_CMT_replaced_destroy = false +end + +local CMT_fade_out = g_CMT_fade_out +local CMT_fade_in = g_CMT_fade_in +local CMT_hidden = g_CMT_hidden +local CMT_replaced = g_CMT_replaced +local CMT_replaced_destroy = g_CMT_replaced_destroy + +local transparency_enum_flags = const.efCameraMakeTransparent +local transparency_surf_flags = EntitySurfaces.Walk + EntitySurfaces.Collision +local obstruct_view_refresh_time = const.ObstructViewRefreshTime +local fade_in_time = const.ObstructOpacityFadeInTime +local fade_out_time = const.ObstructOpacityFadeOutTime +local obstruct_opacity = const.ObstructOpacity +local obstruct_opacity_refresh_time = const.ObstructOpacityRefreshTime +local refresh_time = Max(obstruct_opacity_refresh_time, Max(fade_out_time, fade_in_time) / (100 - Clamp(obstruct_opacity, 0, 99))) +local opacity_change_fadein = fade_in_time <= 0 and 100 or (100 - obstruct_opacity) * refresh_time / fade_in_time +local opacity_change_fadeout = fade_out_time <= 0 and 100 or (100 - obstruct_opacity) * refresh_time / fade_out_time + +local function ResetLists() + g_CMT_fade_out = {} + g_CMT_fade_in = {} + g_CMT_hidden = {} + g_CMT_replaced = {} + g_CMT_replaced_destroy = {} + CMT_fade_out = g_CMT_fade_out + CMT_fade_in = g_CMT_fade_in + CMT_hidden = g_CMT_hidden + CMT_replaced = g_CMT_replaced + CMT_replaced_destroy = g_CMT_replaced_destroy +end + +if FirstLoad then + ResetLists() +end + +function OnMsg.DoneMap() + g_updateStepOpacityThread = false + g_CameraMakeTransparentThread = false + ResetLists() +end + +local function UpdateObstructors_StepOpacity(obstructors) + local view = 1 + CMT_fade_in[view] = CMT_fade_in[view] or {} + CMT_fade_out[view] = CMT_fade_out[view] or {} + local vfade_in = CMT_fade_in[view] + local vfade_out = CMT_fade_out[view] + -- move fade_out objects to fade_in + for i = #vfade_out, 1, -1 do + local o = vfade_out[i] + if not (obstructors and obstructors[o]) then + assert(not vfade_in[o]) + table.remove(vfade_out, i) + vfade_out[o] = nil + if o:GetOpacity() < 100 then + vfade_in[#vfade_in + 1] = o + vfade_in[o] = true + end + end + end + -- set the new fade_out + if obstructors then + for i = 1, #obstructors do + local o = obstructors[i] + if not vfade_out[o] then + vfade_out[#vfade_out + 1] = o + vfade_out[o] = true + end + if vfade_in[o] then + table.remove_entry(vfade_in, o) + vfade_in[o] = nil + end + end + end +end + +local function UpdateObstructors_Hidden(view, obstructors) + -- logic for objects for which hide/show is immediate + local hidden_for_view = CMT_hidden[view] + CMT_hidden[view] = obstructors + if obstructors then + for i = 1, #obstructors do + local o = obstructors[i] + o:SetOpacity(0) + obstructors[o] = true + end + end + if hidden_for_view then + for i = 1, #hidden_for_view do + local o = hidden_for_view[i] + if IsValid(o) and not (obstructors and obstructors[o]) then + o:SetOpacity(100) -- show what was hidden in the previous tick + end + end + end +end + +local function ClearObstructors() + for o in pairs(CMT_replaced) do + o:DestroyReplacement() + end + for view = 1, camera.GetViewCount() do + local vfade_out = CMT_fade_out[view] + if vfade_out then + for i = 1, #vfade_out do + local o = vfade_out[i] + if IsValid(o) then + o:SetOpacity(100) + end + end + end + local vfade_in = CMT_fade_in[view] + if vfade_in then + for i = 1, #vfade_in do + local o = vfade_in[i] + if IsValid(o) then + o:SetOpacity(100) + end + end + end + local hv = CMT_hidden[view] + if hv then + for i = 1, #hv do + local o = hv[i] + if IsValid(o) then + o:SetOpacity(100) + end + end + end + end + ResetLists() +end + +local function UpdateObstructors(view, get_obstructors) + local success, obstructors, obstructors_immediate = procall(get_obstructors, view) + UpdateObstructors_StepOpacity(obstructors) + UpdateObstructors_Hidden(view, obstructors_immediate) +end + +local function UpdateObstructorsRefresh(cam, get_obstructors) + local refresh_time = obstruct_view_refresh_time + while true do + while IsEditorActive() do + Sleep(2 * refresh_time) + end + -- restore opacity of fade_in/fade_out objects + if not g_CameraMakeTransparentEnabled or not cam.IsActive() then + ClearObstructors() + while not g_CameraMakeTransparentEnabled or not cam.IsActive() do + Sleep(refresh_time) + end + end + for view = 1, camera.GetViewCount() do + UpdateObstructors(view, get_obstructors) + end + Sleep(refresh_time) + end +end + +local function UpdateStepOpacity(view) + local vfade_out = CMT_fade_out[view] + if vfade_out then + for i = #vfade_out, 1, -1 do + local o = vfade_out[i] + if not IsValid(o) then + vfade_out[o] = nil + table.remove(vfade_out, i) + else + local new_opacity = o:GetOpacity() - opacity_change_fadeout + if new_opacity < obstruct_opacity then + new_opacity = obstruct_opacity + end + o:SetOpacity(new_opacity) + end + end + end + local vfade_in = CMT_fade_in[view] + if vfade_in then + for i = #vfade_in, 1, -1 do + local o = vfade_in[i] + local keep + if IsValid(o) then + local new_opacity = Min(100, o:GetOpacity() + opacity_change_fadein) + o:SetOpacity(new_opacity) + keep = new_opacity < 100 + end + if not keep then + vfade_in[o] = nil + table.remove(vfade_in, i) + end + end + end +end + +local function UpdateStepOpacityRefresh() + local refresh_time = refresh_time + while true do + for view = 1, camera.GetViewCount() do + UpdateStepOpacity(view) + end + Sleep(refresh_time) + end +end + +local DistSegmentToPt = DistSegmentToPt +local camera_clip_extend_radius = const.CameraClipExtendRadius +local offset_z_150cm = 150*guic +local cone_radius_max = config.CameraTransparencyConeRadiusMax +local cone_radius_min = config.CameraTransparencyConeRadiusMin +if FirstLoad then + draw_transparency_cone = false +end + +function ToggleTransparencyCone() + DbgClearVectors() + draw_transparency_cone = not draw_transparency_cone +end + +local hide_filter = function(u, eye) + local posx, posy, posz = u:GetVisualPosXYZ() + local scale = u:GetScale() + local dist_to_eye = DistSegmentToPt(posx, posy, posz, 0, 0, u.height * scale / 100, eye, true) + return dist_to_eye < u.camera_radius * scale / 100 + camera_clip_extend_radius +end +local col_exec = function(o, list) + if not list[o] then + list[#list + 1] = o + list[o] = true + end +end +local function GetViewObstructorsCamera3p(view) + local eye = camera.GetEye(view) + local lookat = camera3p.GetLookAt(view) + if not eye or not eye:IsValid() then + return + end + local to_fade, to_fade_count + local to_hide = MapGet(eye, 4*guim, "Unit", hide_filter, eye) or {} + for i = 1, #to_hide do + to_hide[ to_hide[i] ] = true + end + + for loc_player = 1, LocalPlayersCount do + local obj = GetPlayerControlCameraAttachedObj(loc_player) + if obj and obj:IsValidPos() then + local posx, posy, posz = obj:GetVisualPosXYZ() + local err1, to_fade1 = AsyncIntersectConeWithObstacles( + eye, point(posx, posy, posz + offset_z_150cm), + cone_radius_max, cone_radius_min, + transparency_enum_flags, + transparency_surf_flags, + draw_transparency_cone) + assert(not err1, err1) + if to_fade1 then + if to_fade then + for i = 1, #to_fade1 do + local o = to_fade1[i] + if not to_fade[o] then + to_fade_count = to_fade_count + 1 + to_fade[to_fade_count] = o + to_fade[o] = true + end + end + else + to_fade = to_fade1 + to_fade_count = #to_fade + for i = 1, to_fade_count do + to_fade[ to_fade[i] ] = true + end + end + end + end + end + if to_fade then + for i = 1, to_fade_count do + local col = to_fade[i]:GetRootCollection() + if col and not to_fade[col] then + to_fade[col] = true + local col_areapoint1 = eye + local col_areapoint2 = lookat + MapForEach( + col_areapoint1, col_areapoint2, 50*guim, + "attached", false, "collection", col.Index, true, + const.efVisible, col_exec , to_fade) + end + end + end + return to_fade, to_hide +end + +function RestartCameraMakeTransparent() + StopCameraMakeTransparent() + if g_CameraMakeTransparentEnabled then + g_CameraMakeTransparentThread = CreateMapRealTimeThread(UpdateObstructorsRefresh, camera3p, GetViewObstructorsCamera3p) + g_updateStepOpacityThread = CreateMapRealTimeThread(UpdateStepOpacityRefresh) + end +end + +function StopCameraMakeTransparent() + ClearObstructors() + if g_updateStepOpacityThread then + DeleteThread(g_updateStepOpacityThread) + g_updateStepOpacityThread = false + end + if g_CameraMakeTransparentThread then + DeleteThread(g_CameraMakeTransparentThread) + g_CameraMakeTransparentThread = false + end +end + +OnMsg.NewMapLoaded = RestartCameraMakeTransparent +OnMsg.LoadGame = RestartCameraMakeTransparent +OnMsg.GameEnterEditor = StopCameraMakeTransparent + +DefineClass.CameraTransparentWallReplacement = { + __parents = { "CObject", "ComponentAttach" }, + flags = { efCameraMakeTransparent = false, efCameraRepulse = true, efSelectable = false, efWalkable = false, efCollision = false, efApplyToGrids = false, efShadow = false }, + properties = + { + { id = "CastShadow", name = "Shadow from All", editor = "bool", default = false }, + }, +} + +local function CameraSpecialWallReplaceObjects(o) + return { "(default)", "place_default", "" } +end + +DefineClass.CameraSpecialWall = { + __parents = { "Object" }, + flags = { efCameraMakeTransparent = true, efCameraRepulse = false }, + properties = { + { id = "TransparentReplace", editor = "combo", items = CameraSpecialWallReplaceObjects }, + }, + TransparentReplace = "(default)", + replace_default = "", + replace_height_min = -guim, + replace_height_max = guim, +} + +function OnMsg.ClassesPostprocess() + -- create unique GetAction and GetActionEnd functions per class + local replace_default = {} + ClassDescendants("CameraSpecialWall", function(class_name, class, replace_default) + if class.replace_default == "" then + local classname = class:GetEntity() .. "_Base" + if g_Classes[classname] then + replace_default[class] = classname + end + end + local properties = class.properties + local idx = table.find(properties, "id", "OnCollisionWithCamera") + if idx then + local idx_old = table.find(properties, "id", "TransparentReplace") + local prop = properties[idx_old] + table.remove(properties, idx_old) + table.insert(properties, idx + (idx < idx_old and 1 or 0), prop) + end + end, replace_default) + for class, value in pairs(replace_default) do + class.replace_default = value + end +end + +local default_color = RGBA(128, 128, 128, 0) +local default_roughness = 0 +local default_metallic = 0 + +function CameraSpecialWall:PlaceReplacement() + local replacement = CMT_replaced[self] + if replacement then + CMT_replaced_destroy[self] = nil + return + end + local classname = self.TransparentReplace + if classname == "place_default" then + classname = self.replace_default + elseif classname == "(default)" then + classname = self.replace_default + local pos = self:GetPos() + local height = pos:z() and pos:z() - GetWalkableZ(pos) or 0 + if height < self.replace_height_min or height > self.replace_height_max then + classname = "" + elseif self:RotateAxis(0,0,4096):z() < 2048 then + -- inclined more then 45 degrees + classname = "" + end + end + local replaced_base + if classname ~= "" then + local color1, roughness1, metallic1 = self:GetColorizationMaterial(1) + local color2, roughness2, metallic2 = self:GetColorizationMaterial(2) + local color3, roughness3, metallic3 = self:GetColorizationMaterial(3) + local components = 0 + if (color1 ~= default_color or roughness1 ~= default_roughness or metallic1 ~= default_metallic) or + (color2 ~= default_color or roughness2 ~= default_roughness or metallic2 ~= default_metallic) or + (color3 ~= default_color or roughness3 ~= default_roughness or metallic3 ~= default_metallic) then + components = const.cofComponentColorizationMaterial + end + replaced_base = PlaceObject(classname, nil, components) + replaced_base:SetMirrored(self:GetMirrored()) + replaced_base:SetAxis(self:GetAxis()) + replaced_base:SetAngle(self:GetAngle()) + replaced_base:SetScale(self:GetScale()) + replaced_base:SetColorModifier(self:GetColorModifier()) + if components == const.cofComponentColorizationMaterial then + replaced_base:SetColorizationMaterial(1, color1, roughness1, metallic1) + replaced_base:SetColorizationMaterial(2, color2, roughness2, metallic2) + replaced_base:SetColorizationMaterial(3, color3, roughness3, metallic3) + end + local anim = self:GetStateText() + if anim ~= "idle" and replaced_base:HasState(anim) and not replaced_base:IsErrorState(anim) then + replaced_base:SetState(anim) + end + replaced_base:SetPos(self:GetVisualPosXYZ()) + end + CMT_replaced[self] = replaced_base or true +end + +function CameraSpecialWall:DestroyReplacement(delay) + local obj = CMT_replaced[self] + if obj then + if obj == true then + CMT_replaced[self] = nil + return + end + if (delay or 0) == 0 then + CMT_replaced[self] = nil + CMT_replaced_destroy[self] = nil + DoneObject(obj) + elseif not CMT_replaced_destroy[self] then + CMT_replaced_destroy[self] = RealTime() + delay + end + end +end + +function CameraSpecialWall:SetOpacity(opacity) + if opacity < 100 then + self:PlaceReplacement() + else + self:DestroyReplacement() + end + Object.SetOpacity(self, opacity) +end diff --git a/CommonLua/CanonizeFilename.lua b/CommonLua/CanonizeFilename.lua new file mode 100644 index 0000000000000000000000000000000000000000..09515b64e9035af0d68477b283cfb76539cedb0b --- /dev/null +++ b/CommonLua/CanonizeFilename.lua @@ -0,0 +1,95 @@ +local filename_chars = +{ + ['"'] = "'", + ["\\"] = "_", + ["/"] = "_", + [":"] = "-", + ["*"] = "+", + ["?"] = "_", + ["<"] = "(", + [">"] = ")", + ["|"] = "-", +} + +local escape_symbols = +{ + ["%%"] = "%%%%", + ["%("] = "%%(", + ["%)"] = "%%)", + ["%]"] = "%%]", + ["%["] = "%%[", + ["%-"] = "%%-", + ["%+"] = "%%+", + ["%*"] = "%%*", + ["%?"] = "%%?", + ["%$"] = "%%$", + ["%."] = "%%.", + ["%^"] = "%%^", +} + +local filter = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ()_+-'" + +local filename_strings = +{ + ["A"] = { "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ā", "Ă", "Ą", "Ǟ", "ǟ", "Ǡ", "ǡ", "Ǣ", "ǣ", "ǻ", "Ǽ", "ǽ", "Ȁ", "ȁ", "Ȃ", "ȃ" }, + ["a"] = { "à", "á", "â", "ã", "ä", "å", "æ", "ā", "ă", "ą", }, + ["C"] = { "Ç" }, + ["c"] = { "ç", }, + ["D"] = { "Ď", "Đ", "Ð", }, + ["d"] = { "ď", "đ", "ð" }, + ["E"] = { "È", "É", "Ê", "Ë", "Ĕ", "Ė", "Ę", "Ě", }, + ["e"] = { "ė", "ę", "ĕ", "ě", "è", "é", "ê", "ë" }, + ["G"] = { "Ĝ", "Ġ", "Ğ", "Ģ", }, + ["g"] = { "ğ", "ĝ", "ġ", "ģ" }, + ["H"] = { "Ĥ", "Ħ", }, + ["h"] = { "ĥ", "ħ" }, + ["I"] = { "Ì", "Í", "Î", "Ï", "Į", "Ĭ", "Ī", "Ĩ", "IJ", "İ", }, + ["i"] = { "ı", "ij", "ĩ", "ī", "ĭ", "į", "ì", "í", "î", "ï", }, + ["J"] = { "ĵ", "ĵ", "ĵ" }, + ["K"] = { "Ķ", }, + ["k"] = { "ķ", "ĸ" }, + ["L"] = { "Ł", "Ŀ", "Ľ", "Ĺ", "Ļ", }, + ["l"] = { "ļ", "ĺ", "ľ", "ŀ", "ł" }, + ["N"] = { "Ņ", "Ń", "Ň", "Ŋ", "Ñ", }, + ["n"] = { "ñ", "ŋ", "ň", "ń", "ņ", "ʼn", }, + ["O"] = { "Ò", "Ó", "Ô", "Õ", "Õ", "Ö", "Ø", "Ō", "Ŏ", "Ŏ", "Ő", "Œ", }, + ["o"] = { "ò", "ó", "ô", "õ", "ö", "ø", "ō", "ő", "œ" }, + ["R"] = { "Ŕ", "Ŗ", "Ř", }, + ["r"] = { "ř", "ŗ", "ŕ", }, + ["S"] = { "Ś", "Ŝ", "Ş", "Š", }, + ["s"] = { "ß", "ś", "ŝ", "ŝ", "ş", "š" }, + ["T"] = { "Þ", "Ţ", "Ť", "Ŧ", }, + ["t"] = { "þ", "ţ", "ť", "ŧ", }, + ["U"] = { "Ũ", "Ū", "Ŭ", "Ů", "Ų", "Ű", "Ù", "Ú", "Û", "Ü", }, + ["u"] = { "ù", "ú", "û", "ü", "ű", "ų", "ů", "ŭ", "ū", "ũ", }, + ["W"] = { "Ŵ", }, + ["w"] = { "ŵ" }, + ["Y"] = { "Ý", "Ŷ", "Ÿ", }, + ["y"] = { "ý", "ÿ", "ŷ" }, + ["Z"] = { "Ź", "Ż", "Ž", }, + ["z"] = { "ż", "ź", "ž" }, + ["'"] = { "“", "”" }, +} + +function CanonizeSaveGameName(name) + if not name then return end + + name = name:gsub("(.)", filename_chars) + for k,v in pairs(filename_strings) do + if type(v) == "string" then + name = name:gsub(v, k) + elseif type(v) == "table" then + for i=1,#v do + name = name:gsub(v[i], k) + end + end + end + return name +end + +function EscapePatternMatchingMagicSymbols(name) + for k,v in sorted_pairs(escape_symbols) do + name = name:gsub(k, v) + end + return name +end diff --git a/CommonLua/Classes/Achievement.lua b/CommonLua/Classes/Achievement.lua new file mode 100644 index 0000000000000000000000000000000000000000..9caec96bf3f735dc7dbcbc0446848cdb4e932c93 --- /dev/null +++ b/CommonLua/Classes/Achievement.lua @@ -0,0 +1,160 @@ +ach_print = CreatePrint{ + --"ach", +} + +-- Game-specific hooks, titles should override these: + +function CanUnlockAchievement(achievement) + local reasons = {} + Msg("UnableToUnlockAchievementReasons", reasons, achievement) + local reason = next(reasons) + return not reason, reason +end + +-- Platform-specific functions: + +function AsyncAchievementUnlock(achievement) + Msg("AchievementUnlocked", achievement) +end + +function SynchronizeAchievements() end + +PlatformCanUnlockAchievement = return_true + +CheatPlatformUnlockAllAchievements = empty_func +CheatPlatformResetAllAchievements = empty_func + +-- Common functions: + +-- return unlocked, secret +function GetAchievementFlags(achievement) + return AccountStorage.achievements.unlocked[achievement], AchievementPresets[achievement].secret +end + +function GetUnlockedAchievementsCount() + local unlocked, total = 0, 0 + ForEachPreset(Achievement, function(achievement) + if not achievement:IsCurrentlyUsed() then return end + unlocked = unlocked + (AccountStorage.achievements.unlocked[achievement.id] and 1 or 0) + total = total + 1 + end) + return unlocked, total +end + +function _CheckAchievementProgress(achievement, dont_unlock_in_provider) + local progress = AccountStorage.achievements.progress[achievement] or 0 + local target = AchievementPresets[achievement].target + if target and progress >= target then + AchievementUnlock(achievement, dont_unlock_in_provider) + end +end + +local function EngineCanUnlockAchievement(achievement) + if Platform.demo then return false, "not available in demo" end + if GameState.Tutorial then + return false, "in tutorial" + end + if AccountStorage.achievements.unlocked[achievement] then + return false, "already unlocked" + end + assert(AchievementPresets[achievement]) + if not AchievementPresets[achievement] then + return false, "dlc not present" + end + return PlatformCanUnlockAchievement(achievement) +end + +local function CanModifyAchievementProgress(achievement) + -- 1. Engine-specific reasons not to modify achievement progress? + local success, reason = EngineCanUnlockAchievement(achievement) + if not success then + ach_print("cannot modify achievement progress, forbidden by engine check ", achievement, reason) + return false + end + + -- 2. Game-specific reasons not to modify achievement progress? + local success, reason = CanUnlockAchievement(achievement) + if not success then + ach_print("cannot modify achievement progress, forbidden by title-specific check ", achievement, reason) + return false + end + + return true +end + +function AddAchievementProgress(achievement, progress, max_delay_save) + if not CanModifyAchievementProgress(achievement) then + return + end + + local ach = AchievementPresets[achievement] + local current = AccountStorage.achievements.progress[achievement] or 0 + local save_storage = not ach.save_interval or ((current + progress) / ach.save_interval > (current / ach.save_interval)) + local total = current + progress + local target = ach.target or 0 + if total >= target then + total = target + save_storage = false + end + AccountStorage.achievements.progress[achievement] = total + if save_storage then + SaveAccountStorage(max_delay_save) + end + Msg("AchievementProgress", achievement) + _CheckAchievementProgress(achievement) + + return true +end + +function ClearAchievementProgress(achievement, max_delay_save) + if not CanModifyAchievementProgress(achievement) then + return + end + + AccountStorage.achievements.progress[achievement] = 0 + SaveAccountStorage(max_delay_save) + Msg("AchievementProgress", achievement) + + return true +end + +-- Synchronous version, launches a thread +function AchievementUnlock(achievement, dont_unlock_in_provider) + if not CanModifyAchievementProgress(achievement) then + return + end + + -- We set this before the thread, as otherwise calling AchievementUnlock twice will attempt to unlock it twice + AccountStorage.achievements.unlocked[achievement] = true + if not dont_unlock_in_provider then + AsyncAchievementUnlock(achievement) + end + + SaveAccountStorage(5000) + return true +end + +if Platform.developer then + function AchievementUnlockAll() + CreateRealTimeThread(function() + for id, achievement_data in sorted_pairs(AchievementPresets) do + AchievementUnlock(id) + Sleep(100) + end + end) + end +end + +function OnMsg.NetConnect() + local unlocked = AccountStorage and AccountStorage.achievements and AccountStorage.achievements.unlocked + if not unlocked then return end + + local achievements = {} + ForEachPreset(Achievement, function(achievement) + if unlocked[achievement.id] then + table.insert(achievements, achievement.id) + end + end) + + NetGossip("AllAchievementsUnlocked", achievements) +end \ No newline at end of file diff --git a/CommonLua/Classes/ActionFX.lua b/CommonLua/Classes/ActionFX.lua new file mode 100644 index 0000000000000000000000000000000000000000..6334f1df4cb3ffc5e0f59dd5b03f062063856c92 --- /dev/null +++ b/CommonLua/Classes/ActionFX.lua @@ -0,0 +1,4258 @@ +DefineClass.FXObject = { + fx_action = false, + fx_action_base = false, + fx_actor_class = false, + fx_actor_base_class = false, + play_size_fx = true, +} +function FXObject:GetFXObjectActor() + return self +end + +if FirstLoad then + s_EntitySizeCache = {} + s_EntityFXTargetCache = {} + s_EntityFXTargetSecondaryCache = {} +end + +local function no_obj_no_edit(self) + return self.Source ~= "Actor" and self.Source ~= "Target" +end + +function OnMsg.EntitiesLoaded() + local ae = GetAllEntities() + for entity in pairs(ae) do + local bbox = GetEntityBBox(entity) + local x, y, z = bbox:sizexyz() + local volume = x * y * z + if volume <= const.EntityVolumeSmall then + s_EntitySizeCache[entity] = "Small" + elseif volume <= const.EntityVolumeMedium then + s_EntitySizeCache[entity] = "Medium" + else + s_EntitySizeCache[entity] = "Large" + end + end +end + +function FXObject:PlayDestructionFX() + local fx_target, fx_target_secondary = GetObjMaterialFXTarget(self) + local fx_type, fx_pos, _, fx_type_secondary = GetObjMaterial(false, self, fx_target, fx_target_secondary) + PlayFX("Death", "start", self, fx_type, fx_pos) + if fx_type_secondary then + PlayFX("Death", "start", self, fx_type_secondary) + end + if self.play_size_fx then + local entity = self:GetEntity() + local fx_target_size = s_EntityFXTargetCache[entity] + if not fx_target_size then + fx_target_size = string.format("%s:%s", fx_target or "", s_EntitySizeCache[entity] or "") + s_EntityFXTargetCache[entity] = fx_target_size + end + local bbox_center = self:GetPos() + self:GetEntityBBox():Center() + PlayFX("Death", "start", self, fx_target_size, bbox_center) + if fx_target_secondary then + local fx_target_secondary_size = s_EntityFXTargetSecondaryCache[entity] + if not fx_target_secondary_size then + fx_target_secondary_size = string.format("%s:%s", fx_target_secondary or "", s_EntitySizeCache[entity] or "") + s_EntityFXTargetSecondaryCache[entity] = fx_target_secondary_size + end + PlayFX("Death", "start", self, fx_target_secondary_size, bbox_center) + end + end +end + +if FirstLoad then + FXEnabled = true + DisableSoundFX = false + DebugFX = false + DebugFXAction = false + DebugFXMoment = false + DebugFXActor = false + DebugFXTarget = false + DebugFXSound = false + DebugFXParticles = false + DebugFXParticlesName = false +end + +local function DebugMatch(str, to_match) + return type(to_match) ~= "string" or type(str) == "string" and string.match(string.lower(str), string.lower(to_match)) +end + +local function DebugFXPrint(actionFXClass, actionFXMoment, actorFXClass, targetFXClass) + local actor_text = actorFXClass or "" + if type(actor_text) ~= "string" then + actor_text = FXInheritRules_Actors[actor_text] and table.concat(FXInheritRules_Actors[actor_text], "/") or "" + end + if DebugMatch(actor_text, DebugFX) or DebugFX == "UI" then + local target_text = targetFXClass or "" + if type(target_text) ~= "string" then + target_text = FXInheritRules_Actors[target_text] and table.concat(FXInheritRules_Actors[target_text], "/") or "" + end + local str = "PlayFX %s%s%s%s" + printf(str, actionFXClass, actionFXMoment or "", actor_text, target_text) + end +end + +local function DebugMatchUIActor(actor) + if DebugFX ~= "UI" then return true end + return IsKindOf(actor, "XWindow") +end + +--[[@@@ +Triggers a global event that activates various game effects. These effects are specified by FX presets. All FX presets that match the combo **action - moment - actor - target** will be activated. +Normally the FX-s are one-time events, but they can also be continuous effects. To stop continuous FX, another PlayFX call is made, with different *moment*. The ending moment is specified in the FX preset, with "end" as default. +@function void PlayFX(string action, string moment, object actor, object target, point pos, point dir) +@param string action - The name of the FX action. +@param string moment - The action's moment. Normally an FX has a *start* and an *end*, but may have various moments in-between. +@param object actor - Used to give context to the FX. Can be a string or an object. If object is provided, then it's member *fx_actor_class* is used, or its class if no such member is available. The object can be used for many purposes by the FX (e.g. attaching effects to it) +@param object target - Similar to the **actor** argument. Used to give additional context to the FX. +@param point pos - Optional FX position. Normally the position of the FX is determined by rules in the FX preset, based on the actor or the target. +@param point dir - Optional FX direction. Normally the direction of the FX is determined by rules in the FX preset, based on the actor or the target. +--]] + +function PlayFX(actionFXClass, actionFXMoment, actor, target, action_pos, action_dir) + if not FXEnabled then return end + + actionFXMoment = actionFXMoment or false + local actor_obj = actor and IsKindOf(actor, "FXObject") and actor + local target_obj = target and IsKindOf(target, "FXObject") and target + + local actorFXClass = actor_obj and (actor_obj.fx_actor_class or actor_obj.class) or actor or false + local targetFXClass = target_obj and (target_obj.fx_actor_class or target_obj.class) or target or false + + dbg(DebugFX + and DebugMatch(actionFXClass, DebugFXAction) + and DebugMatch(actionFXMoment, DebugFXMoment) + and DebugMatch(actorFXClass, DebugFXActor) + and DebugMatch(targetFXClass, DebugFXTarget) + and DebugMatchUIActor(actor_obj) + and DebugFXPrint(actionFXClass, actionFXMoment, actorFXClass, targetFXClass)) + + local fxlist + local t + local t1 = FXCache + if t1 then + t = t1[actionFXClass] + if t then + t1 = t[actionFXMoment] + if t1 then + t = t1[actorFXClass] + if t then + fxlist = t[targetFXClass] + else + t = {} + t1[actorFXClass] = t + end + else + t1, t = t, {} + t1[actionFXMoment] = { [actorFXClass] = t } + end + else + t = {} + t1[actionFXClass] = { [actionFXMoment] = { [actorFXClass] = t } } + end + else + t = {} + FXCache = { [actionFXClass] = { [actionFXMoment] = { [actorFXClass] = t } } } + end + if fxlist == nil then + fxlist = GetPlayFXList(actionFXClass, actionFXMoment, actorFXClass, targetFXClass) + t[targetFXClass] = fxlist or false + end + + local playedAnything = false + if fxlist then + actor_obj = actor_obj and actor_obj:GetFXObjectActor() or actor_obj + target_obj = target_obj and target_obj:GetFXObjectActor() or target_obj + for i = 1, #fxlist do + local fx = fxlist[i] + local chance = fx.Chance + if chance >= 100 or AsyncRand(100) < chance then + dbg(fx.DbgPrint and DebugFXPrint(actionFXClass, actionFXMoment, actorFXClass, targetFXClass)) + dbg(fx.DbgBreak and bp()) + fx:PlayFX(actor_obj, target_obj, action_pos, action_dir) + playedAnything = true + end + end + end + + return playedAnything +end + +if FirstLoad or ReloadForDlc then + FXLists = {} + FXRules = {} + FXInheritRules_Actions = false + FXInheritRules_Moments = false + FXInheritRules_Actors = false + FXInheritRules_Maps = false + FXInheritRules_DynamicActors = setmetatable({}, weak_keys_meta) + FXCache = false +end + +function AddInRules(fx) + local action = fx.Action + local moment = fx.Moment + local actor = fx.Actor + local target = fx.Target + if target == "ignore" then target = "any" end + local rules = FXRules + rules[action] = rules[action] or {} + rules = rules[action] + rules[moment] = rules[moment] or {} + rules = rules[moment] + rules[actor] = rules[actor] or {} + rules = rules[actor] + rules[target] = rules[target] or {} + rules = rules[target] + table.insert(rules, fx) + FXCache = false +end + +function RemoveFromRules(fx) + local rules = FXRules + rules = rules[fx.Action] + rules = rules and rules[fx.Moment] + rules = rules and rules[fx.Actor] + rules = rules and rules[fx.Target == "ignore" and "any" or fx.Target] + if rules then + table.remove_value(rules, fx) + end + FXCache = false +end + +function RebuildFXRules() + FXRules = {} + FXCache = false + RebuildFXInheritActionRules() + RebuildFXInheritMomentRules() + RebuildFXInheritActorRules() + for classname, fxlist in sorted_pairs(FXLists) do + if g_Classes[classname]:IsKindOf("ActionFX") then + for i = 1, #fxlist do + fxlist[i]:RemoveFromRules() + fxlist[i]:AddInRules() + end + end + end +end + +local function AddFXInheritRule(key, inherit, rules, added) + if not key or key == "" or key == "any" or key == inherit then + return + end + local list = rules[key] + if not list then + rules[key] = { inherit } + added[key] = { [inherit] = true } + else + local t = added[key] + if not t[inherit] then + list[#list+1] = inherit + t[inherit] = true + end + end +end + +local function LinkFXInheritRules(rules, added) + for key, list in pairs(rules) do + local added = added[key] + local i, count = 1, #list + while i <= count do + local inherit_list = rules[list[i]] + if inherit_list then + for i = 1, #inherit_list do + local inherit = inherit_list[i] + if not added[inherit] then + count = count + 1 + list[count] = inherit + added[inherit] = true + end + end + end + i = i + 1 + end + end +end + +function RebuildFXInheritActionRules() + PauseInfiniteLoopDetection("RebuildFXInheritActionRules") + local rules, added = {}, {} + FXInheritRules_Actions = rules + ClassDescendants("FXObject", function(classname, class) + local key = class.fx_action_base + if key then + local name = class.fx_action or classname + if name ~= key then + AddFXInheritRule(name, key, rules, added) + end + local parents = key ~= "" and key ~= "any" and class.__parents + if parents then + for i = 1, #parents do + local parent_class = g_Classes[parents[i]] + local inherit = IsKindOf(parent_class, "FXObject") and parent_class.fx_action_base + if inherit and key ~= inherit then + AddFXInheritRule(key, inherit, rules, added) + end + end + end + end + end) + + local anim_metadatas = Presets.AnimMetadata + for _, group in ipairs(anim_metadatas) do + for _, anim_metadata in ipairs(group) do + local key = anim_metadata.id + local fx_inherits = anim_metadata.FXInherits + for _, fx_inherit in ipairs(fx_inherits) do + AddFXInheritRule(key, fx_inherit, rules, added) + end + end + end + + local fxlist = FXLists.ActionFXInherit_Action + if fxlist then + for i = 1, #fxlist do + local fx = fxlist[i] + AddFXInheritRule(fx.Action, fx.Inherit, rules, added) + end + end + LinkFXInheritRules(rules, added) + ResumeInfiniteLoopDetection("RebuildFXInheritActionRules") + return rules +end + +function RebuildFXInheritMomentRules() + local rules, added = {}, {} + FXInheritRules_Moments = rules + local fxlist = FXLists.ActionFXInherit_Moment + if fxlist then + for i = 1, #fxlist do + local fx = fxlist[i] + AddFXInheritRule(fx.Moment, fx.Inherit, rules, added) + end + end + LinkFXInheritRules(rules, added) + return rules +end + +function RebuildFXInheritActorRules() + PauseInfiniteLoopDetection("RebuildFXInheritActorRules") + local rules, added = setmetatable({}, weak_keys_meta), {} + FXInheritRules_Actors = rules + -- class inherited + ClassDescendants("FXObject", function(classname, class) + local key = class.fx_actor_base_class + if key then + local name = class.fx_actor_class or classname + if name and name ~= key then + AddFXInheritRule(name, key, rules, added) + end + local parents = key and key ~= "" and key ~= "any" and class.__parents + if parents then + for i = 1, #parents do + local parent_class = g_Classes[parents[i]] + local inherit = IsKindOf(parent_class, "FXObject") and parent_class.fx_actor_base_class + if inherit and key ~= inherit then + AddFXInheritRule(key, inherit, rules, added) + end + end + end + end + end) + local custom_inherit = {} + Msg("GetCustomFXInheritActorRules", custom_inherit) + for i = 1, #custom_inherit, 2 do + local key = custom_inherit[i] + local inherit = custom_inherit[i+1] + if key and inherit and key ~= inherit then + AddFXInheritRule(key, inherit, rules, added) + end + end + local fxlist = FXLists.ActionFXInherit_Actor + if fxlist then + for i = 1, #fxlist do + local fx = fxlist[i] + AddFXInheritRule(fx.Actor, fx.Inherit, rules, added) + end + end + LinkFXInheritRules(rules, added) + for obj, list in pairs(FXInheritRules_DynamicActors) do + FXInheritRules_Actors[obj] = list + end + ResumeInfiniteLoopDetection("RebuildFXInheritActorRules") + return rules +end + +function AddFXDynamicActor(obj, actor_class) + if not actor_class or actor_class == "" then return end + local list = FXInheritRules_DynamicActors[obj] + if not list then + local def_actor_class = obj.fx_actor_class or obj.class + local def_inherit = (FXInheritRules_Actors or RebuildFXInheritActorRules() )[def_actor_class] + list = { def_actor_class } + table.iappend(list, def_inherit) + if not table.find(list, actor_class) then + table.insert(list, actor_class) + local actor_class_inherit = FXInheritRules_Actors[actor_class] + if actor_class_inherit then + for i = 1, #actor_class_inherit do + local actor = actor_class_inherit[i] + if not table.find(list, actor) then + table.insert(list, actor) + end + end + end + end + FXInheritRules_DynamicActors[obj] = list + if FXInheritRules_Actors then + FXInheritRules_Actors[obj] = list + end + obj.fx_actor_class = obj + elseif not table.find(list, actor_class) then + table.insert(list, actor_class) + end +end + +function ClearFXDynamicActor(obj) + FXInheritRules_DynamicActors[obj] = nil + if FXInheritRules_Actors then + FXInheritRules_Actors[obj] = nil + end + obj.fx_actor_class = nil +end + +function OnMsg.PostDoneMap() + FXInheritRules_DynamicActors = setmetatable({}, weak_keys_meta) + FXCache = false +end + +function OnMsg.DataLoaded() + RebuildFXRules() +end + +if not FirstLoad and not ReloadForDlc then + function OnMsg.ClassesBuilt() + RebuildFXInheritActionRules() + RebuildFXInheritActorRules() + end +end + +local HookActionFXCombo +local HookMomentFXCombo +local ActionFXBehaviorCombo +local ActionFXSpotCombo +local ActionFXAnimatedComboDecal = { "Normal", "PingPong" } + +--============================= FX Orient ======================= + +local OrientationAxisCombo = { + { text = "X", value = 1 }, + { text = "Y", value = 2 }, + { text = "Z", value = 3 }, + { text = "-X", value = -1 }, + { text = "-Y", value = -2 }, + { text = "-Z", value = -3 }, +} +local OrientationAxes = { axis_x, axis_y, axis_z, [-1] = -axis_x, [-2] = -axis_y, [-3] = -axis_z } + +local FXOrientationFunctions = {} + +function FXOrientationFunctions.SourceAxisX(orientation_axis, source_obj) + if IsValid(source_obj) then + return OrientAxisToObjAxisXYZ(orientation_axis, source_obj, 1) + end +end + +function FXOrientationFunctions.SourceAxisX2D(orientation_axis, source_obj) + if IsValid(source_obj) then + return OrientAxisToObjAxis2DXYZ(orientation_axis, source_obj, 1) + end +end + +function FXOrientationFunctions.SourceAxisY(orientation_axis, source_obj) + if IsValid(source_obj) then + return OrientAxisToObjAxisXYZ(orientation_axis, source_obj, 2) + end +end + +function FXOrientationFunctions.SourceAxisZ(orientation_axis, source_obj) + if IsValid(source_obj) then + return OrientAxisToObjAxisXYZ(orientation_axis, source_obj, 3) + end +end + +function FXOrientationFunctions.ActionDir(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if action_dir and action_dir ~= point30 then + return OrientAxisToVectorXYZ(orientation_axis, action_dir) + elseif IsValid(actor) then + return OrientAxisToObjAxisXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.ActionDir2D(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if action_dir and not action_dir:Equal2D(point20) then + local x, y = action_dir:xy() + return OrientAxisToVectorXYZ(orientation_axis, x, y, 0) + elseif IsValid(actor) then + return OrientAxisToObjAxis2DXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.FaceTarget(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if posx and IsValid(target) and target:IsValidPos() then + local tx, ty, tz = target:GetSpotLocPosXYZ(-1) + if posx ~= tx or posy ~= ty or posz ~= tz then + return OrientAxisToVectorXYZ(orientation_axis, tx - posx, ty - posy, tz - posz) + end + end + if action_dir and action_dir ~= point30 then + return OrientAxisToVectorXYZ(orientation_axis, action_dir) + elseif IsValid(actor) then + return OrientAxisToObjAxisXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.FaceTarget2D(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if posx and IsValid(target) and target:IsValidPos() then + local tx, ty = target:GetSpotLocPosXYZ(-1) + if posx ~= tx or posy ~= ty then + return OrientAxisToVectorXYZ(orientation_axis, tx - posx, ty - posy, 0) + end + end + if action_dir and not action_dir:Equal2D(point20) then + local x, y = action_dir:xy() + return OrientAxisToVectorXYZ(orientation_axis, x, y, 0) + elseif IsValid(actor) then + return OrientAxisToObjAxis2DXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.FaceActor(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if posx and IsValid(actor) and actor:IsValidPos() then + local tx, ty, tz = actor:GetSpotLocPosXYZ(-1) + if posx ~= tx or posy ~= ty or posz ~= tz then + return OrientAxisToVectorXYZ(orientation_axis, tx - posx, ty - posy, tz - posz) + end + end + if action_dir and action_dir ~= point30 then + return OrientAxisToVectorXYZ(orientation_axis, action_dir) + elseif IsValid(actor) then + return OrientAxisToObjAxisXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.FaceActor2D(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if posx and IsValid(actor) and actor:IsValidPos() then + local tx, ty = actor:GetSpotLocPosXYZ(-1) + if posx ~= tx or posy ~= ty then + return OrientAxisToVectorXYZ(orientation_axis, tx - posx, ty - posy, 0) + end + end + if action_dir and not action_dir:Equal2D(point20) then + local x, y = action_dir:xy() + return OrientAxisToVectorXYZ(orientation_axis, posx, posy, 0) + elseif IsValid(actor) then + return OrientAxisToObjAxis2DXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.FaceActionPos(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if posx and action_pos and action_pos:IsValid() then + local tx, ty, tz = action_pos:xyz() + if tx ~= posx or ty ~= posy or (tz or posz) ~= posz then + return OrientAxisToVectorXYZ(orientation_axis, tx - posx, ty - posy, (tz or posz) - posz) + end + end + if action_dir and action_dir ~= point30 then + return OrientAxisToVectorXYZ(orientation_axis, action_dir) + elseif IsValid(actor) then + return OrientAxisToObjAxisXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.FaceActionPos2D(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if posx and action_pos and action_pos:IsValid() then + local tx, ty = action_pos:xy() + if tx ~= posx or ty ~= posy then + return OrientAxisToVectorXYZ(orientation_axis, tx - posx, ty - posy, 0) + end + end + if action_dir and not action_dir:Equal2D(point20) then + local tx, ty = action_dir:xy() + return OrientAxisToVectorXYZ(orientation_axis, tx, ty, 0) + elseif IsValid(actor) then + return OrientAxisToObjAxis2DXYZ(orientation_axis, actor:GetParent() or actor, 1) + end +end + +function FXOrientationFunctions.Random2D(orientation_axis) + return OrientAxisToVectorXYZ(orientation_axis, Rotate(axis_x, AsyncRand(360*60))) +end + +function FXOrientationFunctions.SpotX(orientation_axis) + if orientation_axis == 1 then + return 0, 0, 4096, 0 + end + return OrientAxisToVectorXYZ(orientation_axis, axis_x) +end + +function FXOrientationFunctions.SpotY(orientation_axis) + if orientation_axis == 2 then + return 0, 0, 4096, 0 + end + return OrientAxisToVectorXYZ(orientation_axis, axis_y) +end + +function FXOrientationFunctions.SpotZ(orientation_axis) + if orientation_axis == 3 then + return 0, 0, 4096, 0 + end + return OrientAxisToVectorXYZ(orientation_axis, axis_z) +end + +function FXOrientationFunctions.RotateByPresetAngle(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + local axis = OrientationAxes[orientation_axis] + local axis_x, axis_y, axis_z = axis:xyz() + return axis_x, axis_y, axis_z, preset_angle * 60 +end + +local function OrientByTerrainAndAngle(fixedAngle, source_obj, posx, posy, posz) + if source_obj and not source_obj:IsValidZ() or posz - terrain.GetHeight(posx, posy) < 250 then + local norm = terrain.GetTerrainNormal(posx, posy) + if not norm:Equal2D(point20) then + local axis, angle = AxisAngleFromOrientation(norm, fixedAngle) + local axisx, axisy, axisz = axis:xyz() + return axisx, axisy, axisz, angle + end + end + return 0, 0, 4096, fixedAngle +end + +function FXOrientationFunctions.OrientByTerrainWithRandomAngle(orientation_axis, source_obj, posx, posy, posz) + local randomAngle = AsyncRand(-90 * 180, 90 * 180) + return OrientByTerrainAndAngle(randomAngle, source_obj, posx, posy, posz) +end + +function FXOrientationFunctions.OrientByTerrainToActionPos(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + local tX, tY, tZ, tA = OrientByTerrainAndAngle(0, source_obj, posx, posy, posz) + local fX, fY, fZ, fA = FXOrientationFunctions.FaceActionPos2D(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if not fX then + return tX, tY, tZ, tA + end + local axis, angle = ComposeRotation(point(fX, fY, fZ), fA, point(tX, tY, tZ), tA) + return axis:x(), axis:y(), axis:z(), angle +end + +function FXOrientationFunctions.OrientByTerrainToActionDir(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + local tX, tY, tZ, tA = OrientByTerrainAndAngle(0, source_obj, posx, posy, posz) + local fX, fY, fZ, fA = FXOrientationFunctions.ActionDir(orientation_axis, source_obj, posx, posy, posz, preset_angle, actor, target, action_pos, action_dir) + if not fX then + return tX, tY, tZ, tA + end + local axis, angle = ComposeRotation(point(fX, fY, fZ), fA, point(tX, tY, tZ), tA) + return axis:x(), axis:y(), axis:z(), angle + (preset_angle * 60) +end + +local ActionFXOrientationCombo = table.keys2(FXOrientationFunctions, true, "") +local ActionFXOrientationComboDecal = table.copy(ActionFXOrientationCombo, false) + +local function FXCalcOrientation(orientation, ...) + local fn = orientation and FXOrientationFunctions[orientation] + if fn then + return fn(...) + end +end + +local function FXOrient(fx_obj, posx, posy, posz, parent, spot, attach, axisx, axisy, axisz, angle, attach_offset) + if attach and parent and IsValid(parent) and not IsBeingDestructed(parent) then + if spot then + parent:Attach(fx_obj, spot) + else + parent:Attach(fx_obj) + end + if attach_offset then + fx_obj:SetAttachOffset(attach_offset) + end + if angle and angle ~= 0 then + fx_obj:SetAttachAxis(axisx, axisy, axisz) + fx_obj:SetAttachAngle(angle) + end + else + fx_obj:Detach() + if (not posx or not angle) and parent and IsValid(parent) and parent:IsValidPos() then + if not posx and not angle then + posx, posy, posz, angle, axisx, axisy, axisz = parent:GetSpotLocXYZ(spot or -1) + elseif not posx then + posx, posy, posz = parent:GetSpotLocPosXYZ(spot or -1) + else + local _x, _y, _z + _x, _y, _z, angle, axisx, axisy, axisz = parent:GetSpotLocXYZ(spot or -1) + end + end + if angle then + fx_obj:SetAxis(axisx, axisy, axisz) + fx_obj:SetAngle(angle) + end + if posx then + if posz and fx_obj:GetGameFlags(const.gofAttachedOnGround) == 0 then + fx_obj:SetPos(posx, posy, posz) + else + fx_obj:SetPos(posx, posy, const.InvalidZ) + end + end + end +end + +local ActionFXDetailLevel = { + -- please keep this sorted from most important to least important particles + -- and synced with OptionsData.Options.Effects.hr.FXDetailThreshold + { text = "", value = 101 }, + { text = "Essential", value = 100 }, -- DON'T move from position 2 (see below) + { text = "Optional", value = 60 }, + { text = "EyeCandy", value = 40 }, +} +function ActionFXDetailLevelCombo() + return ActionFXDetailLevel +end + +local ParticleDetailLevelMax = ActionFXDetailLevel[2].value + +local function PreciseDetachObj(obj) + -- parent scale is lost after Detach + -- attach offset position and orientation are not restored by Detach + local px, py, pz = obj:GetVisualPosXYZ() + local axis = obj:GetVisualAxis() + local angle = obj:GetVisualAngle() + local scale = obj:GetWorldScale() + obj:Detach() + obj:SetPos(px, py, pz) + obj:SetAxis(axis) + obj:SetAngle(angle) + obj:SetScale(scale) +end + +function DumpFXCacheInfo() + local FX = {} + local used_fx = 0 + local cache_tables = 0 + local cached_lists = 0 + local cached_empty_fx = 0 + + local total_fx = 0 + for action_id, actions in pairs(FXRules) do + for moment_id, moments in pairs(actions) do + for actor_id, actors in pairs(moments) do + for target_id, targets in pairs(actors) do + total_fx = total_fx + #targets + end + end + end + end + + for action_id, actions in pairs(FXCache) do + cache_tables = cache_tables + 1 + for moment_id, moments in pairs(actions) do + cache_tables = cache_tables + 1 + for actor_id, actors in pairs(moments) do + cache_tables = cache_tables + 1 + for target_id, targets in pairs(actors) do + cache_tables = cache_tables + 1 + if targets then + cache_tables = cache_tables + 1 + cached_lists = cached_lists + 1 + used_fx = used_fx + #targets + for _, fx in ipairs(targets) do + local count = (FX[fx] or 0) + 1 + FX[fx] = count + if count == 1 then + FX[#FX + 1] = fx + end + end + else + cached_empty_fx = cached_empty_fx + 1 + end + end + end + end + end + table.sort(FX, function(a,b) return FX[a] > FX[b] end) + + printf("Used tables in the cache = %d", cache_tables) + printf("Empty play fx = %d%%", cached_empty_fx * 100 / (cached_lists + cached_empty_fx)) + printf("Used FX = %d (%d%%)", used_fx, used_fx * 100 / total_fx) + print("Most used FX:") + for i = 1, Min(10, #FX) do + local fx = FX[i] + printf("FX[%s] = %d", fx.class, FX[fx]) + end +end + +--============================= Action FX ======================= + +DefineClass.ActionFXEndRule = { + __parents = {"PropertyObject"}, + + properties = { + { id = "EndAction", category = "Lifetime", default = "", editor = "combo", items = function(fx) return HookActionFXCombo(fx) end }, + { id = "EndMoment", category = "Lifetime", default = "", editor = "combo", items = function(fx) return HookMomentFXCombo(fx) end }, + }, + + EditorView = Untranslated("Action '' & Moment ''"), +} + +function ActionFXEndRule:OnEditorSetProperty(prop_id, old_value, ged) + local preset = ged:GetParentOfKind("SelectedObject", "ActionFX") + if preset and preset:IsKindOf("ActionFX") then + local current_value = self[prop_id] + self[prop_id] = old_value + preset:RemoveFromRules() + self[prop_id] = current_value + preset:AddInRules() + end +end + +DefineClass.ActionFX = { + __parents = { "FXPreset" }, + properties = { + { id = "Action", category = "Match", default = "any", editor = "combo", items = function(fx) return ActionFXClassCombo(fx) end }, + { id = "Moment", category = "Match", default = "any", editor = "combo", items = function(fx) return ActionMomentFXCombo(fx) end, + buttons = {{ + name = "View Animation", + func = function(self) OpenAnimationMomentsEditor(self.Actor, FXActionToAnim(self.Action)) end, + is_hidden = function(self) return self:IsKindOf("GedMultiSelectAdapter") or not AppearanceLocateByAnimation(FXActionToAnim(self.Action), self.Actor) end, + }}, + }, + { id = "Actor", category = "Match", default = "any", editor = "combo", items = function(fx) return ActorFXClassCombo(fx) end }, + { id = "Target", category = "Match", default = "any", editor = "combo", items = function(fx) return TargetFXClassCombo(fx) end }, + { id = "GameStatesFilter", name = "Game State", category = "Match", editor = "set", default = set(), three_state = true, + items = function() return GetGameStateFilter() end + }, + { id = "FxId", category = "Match", default = "", editor = "text", help = "Empty by default.\nFX Remove requires it to define which FX should be removed." }, + { id = "DetailLevel", category = "Match", default = ActionFXDetailLevel[1].value, editor = "combo", items = ActionFXDetailLevel, name = "Detail level category", help = "Determines the options detail levels at which the FX triggers. Essential will trigger always, Optional at high/medium setting, and EyeCandy at high setting only.", }, + { id = "Chance", category = "Match", editor = "number", default = 100, min = 0, max = 100, slider = true, help = "Chance the FX will be placed." }, + { id = "Disabled", category = "Match", default = false, editor = "bool", help = "Disabled FX are not played.", color = function(o) return o.Disabled and RGB(255,0,0) or nil end }, + { id = "Delay", name = "Delay (ms)", category = "Lifetime", default = 0, editor = "number", help = "In game time, in milliseconds.\nFX is not played when the actor is interrupted while in the delay." }, + { id = "Time", name = "Time (ms)", category = "Lifetime", default = 0, editor = "number", help = "Duration, in milliseconds."}, + { id = "GameTime", category = "Lifetime", editor = "bool", default = false }, + { id = "EndRules", category = "Lifetime", default = false, editor = "nested_list", base_class = "ActionFXEndRule", inclusive = true, }, + { id = "Behavior", category = "Lifetime", default = "", editor = "dropdownlist", items = function(fx) return ActionFXBehaviorCombo(fx) end }, + { id = "BehaviorMoment", category = "Lifetime", default = "", editor = "combo", items = function(fx) return HookMomentFXCombo(fx) end }, + + { category = "Test", id = "Solo", default = false, editor = "bool", developer = true, dont_save = true, help = "Debug feature, if any fx's are set as solo, only they will be played."}, + { category = "Test", id = "DbgPrint", name = "DebugFX", default = false, editor = "bool", developer = true, dont_save = true, help = "Debug feature, print when this FX is about to play."}, + { category = "Test", id = "DbgBreak", name = "Break", default = false, editor = "bool", developer = true, dont_save = true, help = "Debug feature, break execution in the Lua debugger when this FX is about to play."}, + { category = "Test", id = "AnimEntity", name = "Anim Entity", default = "", editor = "text", help = "Specifies that this FX is linked to a specific animation. Auto fills the anims and moments available. An error will be issued if the action and the moment aren't found in that entity." }, + + { id = "AnimRevisionEntity", default = false, editor = "text", no_edit = true }, + { id = "AnimRevision", default = false, editor = "number", no_edit = true }, + { id = "_reconfirm", category = "Preset", editor = "buttons", buttons = {{ name = "Confirm Changes", func = "ConfirmChanges" }}, + no_edit = function(self) return not self:GetAnimationChangedWarning() end, + }, + }, + fx_type = "", + behaviors = false, + -- loc props + Source = "Actor", + SourceProp = "", + Spot = "", + SpotsPercent = -1, + Offset = false, + OffsetDir = "SourceAxisX", + Orientation = "", + PresetOrientationAngle = 0, + OrientationAxis = 1, + Attach = false, + Cooldown = 0, +} + +ActionFX.Documentation = [[Defines rules for playing effects in the game on certain events. + +An FX event is raised from the code using the PlayFX function. It has four main arguments: action, moment, actor, and target. All ActionFX presets matching these arguments will be activated.]] + +function ActionFX:GenerateCode(code) + -- drop non property elements + local behaviors = self.behaviors + self.behaviors = nil + FXPreset.GenerateCode(self, code) + self.behaviors = behaviors +end + +if FirstLoad or ReloadForDlc then + if Platform.developer then + g_SoloFX_count = 0 + g_SoloFX_list = {} --used to turn off all solo fx at once + function ClearAllSoloFX() + local t = table.copy(g_SoloFX_list) --so we can iterate safely. + for i, v in ipairs(t) do + v:SetSolo(false) + end + end + else + function ClearAllSoloFX() + end + end +end + +if Platform.developer then +function ActionFX:SetSolo(val) + if self.Solo == val then return end + + if val then + g_SoloFX_count = g_SoloFX_count + 1 + g_SoloFX_list[#g_SoloFX_list + 1] = self + else + g_SoloFX_count = g_SoloFX_count - 1 + table.remove(g_SoloFX_list, table.find(g_SoloFX_list, self)) + end + + self.Solo = val + FXCache = false +end +end + +function ActionFX:Done() + self:RemoveFromRules() +end + +function ActionFX:PlayFX(actor, target, action_pos, action_dir) +end + +function ActionFX:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValidThread(fx) then + DeleteThread(fx) + end +end + +function ActionFX:AddInRules() + AddInRules(self) + self:HookBehaviors() +end + +function ActionFX:RemoveFromRules() + RemoveFromRules(self) + self:UnhookBehaviors() +end + +function ActionFX:HookBehaviors() + if not self.Disabled then + -- hook behavior + if self.Behavior ~= "" and self.BehaviorMoment ~= "" and self.BehaviorMoment ~= self.Moment then + self:HookBehaviorFX(self.Behavior, self.Action, self.BehaviorMoment, self.Actor, self.Target) + end + end + + -- hook end action (even if disabled; this will allow currently playing FXs restored from savegames to stop) + if self.EndRules then + for idx, fxend in ipairs(self.EndRules) do + local end_action = fxend.EndAction ~= "" and fxend.EndAction or self.Action + local end_moment = fxend.EndMoment + if end_action ~= self.Action or end_moment ~= "" and end_moment ~= self.Moment then + self:HookBehaviorFX("DestroyFX", end_action, end_moment, self.Actor, self.Target) + end + end + end +end + +function ActionFX:UnhookBehaviors() + local behaviors = self.behaviors + if not behaviors then return end + for i = #behaviors, 1, -1 do + local fx = behaviors[i] + RemoveFromRules(fx) + fx:delete() + end + self.behaviors = nil +end + +function ActionFX:HookBehaviorFX(behavior, action, moment, actor, target) + for _, fx in ipairs(self.behaviors) do + if fx.Action == action and fx.Moment == moment + and fx.Actor == actor and fx.Target == target + and fx.fx == self and fx.BehaviorFXMethod == behavior then + StoreErrorSource(self, string.format("%s behaviors with the same action (%s), actor (%s), moment (%s), and target (%s) in this ActionFX", behavior, action, actor, moment, target)) + break + end + end + self.behaviors = self.behaviors or {} + local fx = ActionFXBehavior:new{ Action = action, Moment = moment, Actor = actor, Target = target, fx = self, BehaviorFXMethod = behavior } + table.insert(self.behaviors, fx) + AddInRules(fx) +end + +local rules_props = { + Action = true, + Moment = true, + Actor = true, + Target = true, + Disabled = true, + Behavior = true, + BehaviorMoment = true, + EndRules = true, + Cooldown = true, +} + +function ActionFX:OnEditorSetProperty(prop_id, old_value) + -- remember the animation revision when the FX rule is linked to an animation moment + if (prop_id == "Action" or prop_id == "Moment") and self.Action ~= "any" and self.Moment ~= "any" then + local animation = FXActionToAnim(self.Action) + local appearance = AppearanceLocateByAnimation(animation, "__missing_appearance") + local entity = appearance and AppearancePresets[appearance].Body or self.AnimEntity ~= "" and self.AnimEntity + self.AnimRevisionEntity = entity or nil + self.AnimRevision = entity and EntitySpec:GetAnimRevision(entity, animation) or nil + end + + if not rules_props[prop_id] then return end + local value = self[prop_id] + self[prop_id] = old_value + self:RemoveFromRules() + self[prop_id] = value + self:AddInRules() +end + +function ActionFX:TrackFX() + return self.behaviors and true or false +end + +function ActionFX:GameStatesMatched(game_states) + if not self.GameStatesFilter then return true end + + for state, active in pairs(game_states) do + if self.GameStatesFilter[state] ~= active then + return + end + end + + return true +end + +function ActionFX:GetVariation(props_list) + local variations = 0 + for i, prop in ipairs(props_list) do + if self[prop] ~= "" then + variations = variations + 1 + end + end + if variations == 0 then + return + end + local id = AsyncRand(variations) + 1 + for i, prop in ipairs(props_list) do + if self[prop] ~= "" then + id = id - 1 + if id == 0 then + return self[prop] + end + end + end +end + +function ActionFX:CreateThread(...) + if self.GameTime then + assert(self.Source ~= "UI") + return CreateGameTimeThread(...) + end + if self.Source == "UI" then + return CreateRealTimeThread(...) + end + local thread = CreateMapRealTimeThread(...) + MakeThreadPersistable(thread) + return thread +end + +if FirstLoad then + FX_Assigned = {} +end + +local function FilterFXValues(data, f) + if not data then return false end + + local result = {} + for fx_preset, actor_map in pairs(data) do + local result_actor_map = setmetatable({}, weak_keys_meta) + for actor, target_map in pairs(actor_map) do + if f(actor, nil) then + local result_target_map = setmetatable({}, weak_keys_meta) + + for target, fx in pairs(target_map) do + if f(actor, target) then + result_target_map[target] = fx + end + end + + if next(result_target_map) ~= nil then + result_actor_map[actor] = result_target_map + end + end + end + + if next(result_actor_map) ~= nil then + result[fx_preset] = result_actor_map + end + end + return result +end + +local IsKindOf = IsKindOf +function OnMsg.PersistSave(data) + data["FX_Assigned"] = FilterFXValues(FX_Assigned, function(actor, target) + if IsKindOf(actor, "XWindow") then + return false + end + if IsKindOf(target, "XWindow") then + return false + end + return true + end) +end + +function OnMsg.PersistLoad(data) + FX_Assigned = data.FX_Assigned or {} +end + +function OnMsg.ChangeMapDone() + FX_Assigned = FilterFXValues(FX_Assigned, function(actor, target) + if IsKindOf(actor, "XWindow") then + if not target or IsKindOf(target, "XWindow") then + return true + end + end + return false + end) +end + +function ActionFX:AssignFX(actor, target, fx) + local t = FX_Assigned[self] + if not t then + if fx == nil then return end + t = setmetatable({}, weak_keys_meta) + FX_Assigned[self] = t + end + local t2 = t[actor or false] + if not t2 then + if fx == nil then return end + t2 = setmetatable({}, weak_keys_meta) + t[actor or false] = t2 + end + local id = self.Target == "ignore" and "ignore" or target or false + local prev_fx = t2[id] + t2[id] = fx + return prev_fx +end + +function ActionFX:GetAssignedFX(actor, target) + local o = FX_Assigned[self] + o = o and o[actor or false] + o = o and o[self.Target == "ignore" and "ignore" or target or false] + return o +end + +function ActionFX:GetLocObj(actor, target) + local obj + local source = self.Source + if source == "Actor" then + obj = IsValid(actor) and actor + elseif source == "ActorParent" then + obj = IsValid(actor) and GetTopmostParent(actor) + elseif source == "ActorOwner" then + obj = actor and IsValid(actor.NetOwner) and actor.NetOwner + elseif source == "Target" then + obj = IsValid(target) and target + elseif source == "Camera" then + obj = IsValid(g_CameraObj) and g_CameraObj + end + if obj then + if self.SourceProp ~= "" then + local prop = obj:GetProperty(self.SourceProp) + obj = prop and IsValid(prop) and prop + elseif self.Spot ~= "" then + local o = obj:GetObjectBySpot(self.Spot) + if o ~= nil then + obj = o + end + end + end + return obj +end + +function ActionFX:GetLoc(actor, target, action_pos, action_dir) + if self.Source == "ActionPos" then + if action_pos and action_pos:IsValid() then + local posx, posy, posz = action_pos:xyz() + return 1, nil, nil, self:FXOrientLoc(nil, posx, posy, posz, nil, nil, nil, nil, actor, target, action_pos, action_dir) + elseif IsValid(actor) and actor:IsValidPos() then + -- use actor position for default + local posx, posy, posz = GetTopmostParent(actor):GetSpotLocPosXYZ(-1) + return 1, nil, nil, self:FXOrientLoc(nil, posx, posy, posz, nil, nil, nil, nil, actor, target, action_pos, action_dir) + end + return 0 + end + -- find loc obj + local obj = self:GetLocObj(actor, target) + if not obj then + return 0 + end + local spots_count, first_spot, spots_list = self:GetLocObjSpots(obj) + if (spots_count or 0) <= 0 then + return 0 + elseif spots_count == 1 then + local posx, posy, posz, angle, axisx, axisy, axisz + if obj:IsValidPos() then + posx, posy, posz, angle, axisx, axisy, axisz = obj:GetSpotLocXYZ(first_spot or -1) + end + return 1, obj, first_spot, self:FXOrientLoc(obj, posx, posy, posz, angle, axisx, axisy, axisz, actor, target, action_pos, action_dir) + end + local params = {} + for i = 0, spots_count-1 do + local spot = spots_list and spots_list[i+1] or first_spot + i + local posx, posy, posz, angle, axisx, axisy, axisz + if obj:IsValidPos() then + posx, posy, posz, angle, axisx, axisy, axisz = obj:GetSpotLocXYZ(spot) + end + posx, posy, posz, angle, axisx, axisy, axisz = self:FXOrientLoc(obj, posx, posy, posz, angle, axisx, axisy, axisz, actor, target, action_pos, action_dir) + params[8*i+1] = spot + params[8*i+2] = posx + params[8*i+3] = posy + params[8*i+4] = posz + params[8*i+5] = angle + params[8*i+6] = axisx + params[8*i+7] = axisy + params[8*i+8] = axisz + end + return spots_count, obj, params +end + +function ActionFX:FXOrientLoc(obj, posx, posy, posz, angle, axisx, axisy, axisz, actor, target, action_pos, action_dir) + local orientation = self.Orientation + if orientation == "" and self.Attach then + orientation = "SpotX" + end + if posx then + local offset = self.Offset + if offset and offset ~= point30 then + local o_axisx, o_axisy, o_axisz, o_angle = FXCalcOrientation(self.OffsetDir, 1, obj, posx, posy, posz, 0, actor, target, action_pos, action_dir) + local x, y, z + if (o_angle or 0) == 0 or o_axisx == 0 and o_axisy == 0 and offset:Equal2D(point20) then + x, y, z = offset:xyz() + else + x, y, z = RotateAxisXYZ(offset, point(o_axisx, o_axisy, o_axisz), o_angle) + end + posx = posx + x + posy = posy + y + if posz and z then + posz = posz + z + end + end + end + local o_axisx, o_axisy, o_axisz, o_angle = FXCalcOrientation(orientation, self.OrientationAxis, obj, posx, posy, posz, self.PresetOrientationAngle, actor, target, action_pos, action_dir) + if o_angle then + angle, axisx, axisy, axisz = o_angle, o_axisx, o_axisy, o_axisz + end + return posx, posy, posz, angle, axisx, axisy, axisz +end + +function ActionFX:GetLocObjSpots(obj) + local percent = self.SpotsPercent + if percent == 0 then + return 0 + end + local spot_name = self.Spot + if spot_name == "" or spot_name == "Origin" or not obj:HasSpot(spot_name) then + return 1 + elseif percent < 0 then + return 1, obj:GetRandomSpot(spot_name) + else + local first_spot, last_spot = obj:GetSpotRange(spot_name) + local spots_count = last_spot - first_spot + 1 + local count = spots_count + if percent < 100 then + local remainder = count * percent % 100 + local roll = remainder > 0 and AsyncRand(100) or 0 + count = count * percent / 100 + (roll < remainder and 1 or 0) + end + if count <= 0 then + return + elseif count == 1 then + return 1, first_spot + (spots_count > 1 and AsyncRand(spots_count) or 0) + elseif count >= spots_count then + return spots_count, first_spot + end + local spots = {} + for i = 1, count do + local k = i + AsyncRand(spots_count-i+1) + spots[i], spots[k] = spots[k] or first_spot + k - 1, spots[i] or first_spot + i - 1 + end + return count, nil, spots + end +end + +function FXAnimToAction(anim) + return anim +end + +function FXActionToAnim(action) + return action +end + +local GetEntityAnimMoments = GetEntityAnimMoments + +function OnMsg.GatherFXMoments(list, fx) + local entity = fx and rawget(fx, "AnimEntity") or "" + if entity == "" or not IsValidEntity(entity) then + return + end + local anim = fx and fx.Action + if not anim or anim == "any" or anim == "" or not EntityStates[anim] then + return + end + for _, moment in ipairs(GetEntityAnimMoments(entity, anim)) do + list[#list + 1] = moment.Type + end +end + +function ActionFX:GetError() + local entity = self.AnimEntity or "" + if entity ~= "" then + if not IsValidEntity(entity) then + return "No such entity: " .. entity + end + local anim = self.Action or "" + if anim ~= "" and anim ~= "any" then + if not EntityStates[anim] then + return "Invalid state: " .. anim + end + if not HasState(entity, anim) then + return "No such anim: " .. entity .. "." .. anim + end + local moment = self.Moment or "" + if moment ~= "" and moment ~= "any" then + local moments = GetEntityAnimMoments(entity, anim) + if not table.find(moments, "Type", moment) then + return "No such moment: " .. entity .. "." .. anim .. "." .. moment + end + end + end + end +end + +function ActionFX:GetAnimationChangedWarning() + local entity, anim = self.AnimRevisionEntity, FXActionToAnim(self.Action) + if entity and not IsValidEntity(entity) then + return string.format("Entity %s with which this FX was created no longer exists.\nPlease test/readjust it and click Confirm Changes below.", entity) + end + if entity and not HasState(entity, anim) then + return string.format("Entity %s with which this FX was created no longer has animation %s.\nPlease test/readjust it and click Confirm Changes below.", entity, anim) + end +-- return entity and EntitySpec:GetAnimRevision(entity, anim) > self.AnimRevision and +-- string.format("Animation %s was updated after this FX.\nPlease test/readjust it and click Confirm Changes below.", anim) +end + +function ActionFX:GetWarning() + return self:GetAnimationChangedWarning() +end + +function ActionFX:ConfirmChanges() + self.AnimRevision = EntitySpec:GetAnimRevision(self.AnimRevisionEntity, FXActionToAnim(self.Action)) + ObjModified(self) +end + +--============================= Test FX ======================= +if FirstLoad then + LastTestActionFXObject = false +end +function OnMsg.DoneMap() + LastTestActionFXObject = false +end +local function TestActionFXObjectEnd(obj) + DoneObject(obj) + if LastTestActionFXObject == obj then + LastTestActionFXObject = false + return + end + if obj or not LastTestActionFXObject then + return + end + DoneObject(LastTestActionFXObject) + LastTestActionFXObject = false +end + +--============================= Inherit FX ======================= + +DefineClass.ActionFXInherit = { + __parents = { "FXPreset" }, +} + +DefineClass.ActionFXInherit_Action = { + __parents = { "ActionFXInherit" }, + properties = { + { id = "Action", category = "Inherit", default = "", editor = "combo", items = function(fx) return ActionFXClassCombo(fx) end }, + { id = "Inherit", category = "Inherit", default = "", editor = "combo", items = function(fx) return ActionFXClassCombo(fx) end }, + { id = "All", category = "Inherit", default = "", editor = "text", lines = 5, read_only = true, dont_save = true } + }, + fx_type = "Inherit Action", +} + +function ActionFXInherit_Action:Done() + FXInheritRules_Actions = false + FXCache = false +end + +function ActionFXInherit_Action:GetAll() + local list = (FXInheritRules_Actions or RebuildFXInheritActionRules())[self.Action] + return list and table.concat(list, "\n") or "" +end + +function ActionFXInherit_Action:OnEditorSetProperty(prop_id, old_value, ged) + FXInheritRules_Actions = false + FXCache = false +end + +DefineClass.ActionFXInherit_Moment = { + __parents = { "ActionFXInherit" }, + properties = { + { id = "Moment", category = "Inherit", default = "", editor = "combo", items = function(fx) return ActionMomentFXCombo(fx) end }, + { id = "Inherit", category = "Inherit", default = "", editor = "combo", items = function(fx) return ActionMomentFXCombo(fx) end }, + { id = "All", category = "Inherit", default = "", editor = "text", lines = 5, read_only = true, dont_save = true } + }, + fx_type = "Inherit Moment", +} + +function ActionFXInherit_Moment:Done() + FXInheritRules_Moments = false + FXCache = false +end + +function ActionFXInherit_Moment:GetAll() + local list = (FXInheritRules_Moments or RebuildFXInheritMomentRules())[self.Moment] + return list and table.concat(list, "\n") or "" +end + +function ActionFXInherit_Moment:OnEditorSetProperty(prop_id, old_value, ged) + FXInheritRules_Moments = false + FXCache = false +end + +DefineClass.ActionFXInherit_Actor = { + __parents = { "ActionFXInherit" }, + properties = { + { id = "Actor", category = "Inherit", default = "", editor = "combo", items = function(fx) return ActorFXClassCombo(fx) end }, + { id = "Inherit", category = "Inherit", default = "", editor = "combo", items = function(fx) return ActorFXClassCombo(fx) end }, + { id = "All", category = "Inherit", default = "", editor = "text", lines = 5, read_only = true, dont_save = true } + }, + fx_type = "Inherit Actor", +} + +function ActionFXInherit_Actor:Done() + FXInheritRules_Actors = false + FXCache = false +end + +function ActionFXInherit_Actor:GetAll() + local list = (FXInheritRules_Actors or RebuildFXInheritActorRules())[self.Actor] + return list and table.concat(list, "\n") or "" +end + +function ActionFXInherit_Actor:OnEditorSetProperty(prop_id, old_value, ged) + FXInheritRules_Actors = false + FXCache = false +end + +--============================= Behavior FX ======================= + +DefineClass.ActionFXBehavior = { + __parents = { "InitDone" }, + properties = { + { id = "Action", default = "any" }, + { id = "Moment", default = "any" }, + { id = "Actor", default = "any" }, + { id = "Target", default = "any" }, + }, + fx = false, + BehaviorFXMethod = "", + fx_type = "Behavior", + Disabled = false, + Delay = 0, + Map = "any", + Id = "", + DetailLevel = 100, + Chance = 100, +} + +function ActionFXBehavior:PlayFX(actor, target, ...) + self.fx[self.BehaviorFXMethod](self.fx, actor, target, ...) +end + +--============================= Remove FX ======================= + +DefineClass.ActionFXRemove = { + __parents = { "ActionFX" }, + properties = { + { id = "Time", editor = false }, + { id = "EndRules", editor = false }, + { id = "Behavior", editor = false }, + { id = "BehaviorMoment", editor = false }, + { id = "Delay", editor = false }, + { id = "GameTime", editor = false }, + }, + fx_type = "FX Remove", + Documentation = ActionFX.Documentation .. "\n\nRemoves an action fx." +} + +function ActionFXRemove:HookBehaviors() +end + +function ActionFXRemove:UnhookBehaviors() +end + +--============================= Sound FX ======================= + +local MarkObjSound = empty_func + +function OnMsg.ChangeMap() + if not config.AllowSoundFXOnMapChange then + DisableSoundFX = true + end +end + +function OnMsg.ChangeMapDone() + DisableSoundFX = false +end + +DefineClass.ActionFXSound = { + __parents = { "ActionFX" }, + properties = { + { category = "Match", id = "Cooldown", name = "Cooldown (ms)", default = 0, editor = "number", help = "Cooldown, in real time milliseconds." }, + { category = "Sound", id = "Sound", default = "", editor = "preset_id", preset_class = "SoundPreset", buttons = {{name = "Test", func = "TestActionFXSound"}, {name = "Stop", func = "StopActionFXSound"}}}, + { category = "Sound", id = "DistantRadius", default = 0, editor = "number", scale = "m", help = "Defines the radius for playing DistantSound." }, + { category = "Sound", id = "DistantSound", default = "", editor = "preset_id", preset_class = "SoundPreset", + help = "This sound will be played if the distance from the camera is greater than DistantRadius." + }, + { category = "Sound", id = "FadeIn", default = 0, editor = "number", }, + { category = "Sound", id = "FadeOut", default = 0, editor = "number", }, + { category = "Sound", id = "Source", default = "Actor", editor = "dropdownlist", items = { "UI", "Actor", "Target", "ActionPos", "Camera" }, help = "Sound listener object or position." }, + { category = "Sound", id = "Spot", default = "", editor = "combo", items = function(fx) return ActionFXSpotCombo(fx) end, no_edit = no_obj_no_edit }, + { category = "Sound", id = "SpotsPercent", default = -1, editor = "number", no_edit = no_obj_no_edit, help = "Percent of random spots that should be used. One random spot is used when the value is negative." }, + { category = "Sound", id = "Offset", default = point30, editor = "point", scale = "m", help = "Offset against source object" }, + { category = "Sound", id = "OffsetDir", default = "SourceAxisX", no_edit = function(self) return self.AttachToObj end, editor = "dropdownlist", items = function(fx) return ActionFXOrientationCombo end }, + { category = "Sound", id = "AttachToObj", name = "Attach To Source", editor = "bool", default = false, help = "Attach to the actor or target (the Source) and move with it." }, + { category = "Sound", id = "SkipSame", name = "Skip Same Sound", editor = "bool", default = false, no_edit = PropChecker("AttachToObj", false), help = "Don't start a new sound if the object has the same sound already playing" }, + { category = "Sound", id = "AttachToObjHelp", editor = "help", default = false, + help = "Sounds attached to an object are played whenever the camera gets close, even if it was away when the object was created.\n\nOnly one sound can be attached to an object, and attaching a new sound removes the previous one. Use this for single sounds that are emitted permanently." }, + }, + fx_type = "Sound", + Documentation = ActionFX.Documentation .. "\n\nThis mod item creates and plays sound effects when an FX action is triggered. Inherits ActionFX. Read ActionFX first for the common properties.", + DocumentationLink = "Docs/ModItemActionFXSound.md.html" +} + +MapVar("FXCameraSounds", {}, weak_keys_meta) + +function OnMsg.DoneMap() + for fx in pairs(FXCameraSounds) do + FXCameraSounds[fx] = nil + if fx.sound_handle then + SetSoundVolume(fx.sound_handle, -1, not config.AllowSoundFXOnMapChange and fx.fade_out or 0) -- -1 means destroy + fx.sound_handle = nil + end + DeleteThread(fx.thread) + end +end + +function OnMsg.PostLoadGame() + for fx in pairs(FXCameraSounds) do + local sound = fx.Sound or "" + local handle = sound ~= "" and PlaySound(sound, nil, 300) + if not handle then + FXCameraSounds[fx] = nil + DeleteThread(fx.thread) + else + fx.sound_handle = handle + end + end +end + +function ActionFXSound:TrackFX() + if self.behaviors or self.FadeOut > 0 or self.Time > 0 or self.Source == "Camera" or (self.AttachToObj and self.Spot ~= "") or self.Cooldown > 0 then + return true + end + return false +end + +function ActionFXSound:PlayFX(actor, target, action_pos, action_dir) + if self.Sound == "" and self.DistandSound == "" or DisableSoundFX then + return + end + if self.Cooldown > 0 then + local fx = self:GetAssignedFX(actor, target) + if fx and fx.time and RealTime() - fx.time < self.Cooldown then + return + end + end + local count, obj, posx, posy, posz, spot + local source = self.Source + if source ~= "UI" and source ~= "Camera" then + count, obj, spot, posx, posy, posz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + --print("FX Sound with bad position:", self.Sound, "\n\t- Actor:", actor and actor.class, "\n\t- Target:", target and target.class) + return + end + end + if self.Delay <= 0 then + self:PlaceFXSound(actor, target, count, obj, spot, posx, posy, posz) + return + end + local thread = self:CreateThread(function(self, ...) + Sleep(self.Delay) + self:PlaceFXSound(...) + end, self, actor, target, count, obj, spot, posx, posy, posz) + if self:TrackFX() then + local fx = self:DestroyFX(actor, target) + if not fx then + fx = {} + self:AssignFX(actor, target, fx) + end + fx.thread = thread + end +end + +local function WaitDestroyFX(self, fx, actor, target) + Sleep(self.Time) + if fx.thread == CurrentThread() then + self:DestroyFX(actor, target) + end +end + +function ActionFXSound:PlaceFXSound(actor, target, count, obj, spot, posx, posy, posz) + local handle, err + local source = self.Source + if source == "UI" or source == "Camera" then + handle, err = PlaySound(self.Sound, nil, self.FadeIn) + else + if Platform.developer then + -- Make the check for positional sounds only if we have .Sound (removed on non-dev and xbox) + local sounds = SoundPresets + if sounds and next(sounds) then + local sound = self.Sound + if sound == "" then + sound = self.DistantSound + end + local snd = sounds[sound] + if not snd then + printf('once', 'FX sound not found "%s"', sound) + return + end + local snd_type = SoundTypePresets[snd.type] + if not snd_type then + printf('once', 'FX sound type not found "%s"', snd.type) + return + end + local positional = snd_type.positional + if not positional then + printf('once', 'FX non-positional sound "%s" (type "%s") played on Source position: %s', sound, snd.type, source) + return + end + end + end + if (count or 1) == 1 then + handle, err = self:PlaceSingleFXSound(actor, target, 1, obj, spot, posx, posy, posz) + else + for i = 0, count - 1 do + local h, e = self:PlaceSingleFXSound(actor, target, i + 1, obj, unpack_params(spot, 8*i+1, 8*i+4)) + if h then + handle = handle or {} + table.insert(handle, h) + else + err = e + end + end + end + end + if DebugFXSound and (type(DebugFXSound) ~= "string" or IsKindOf(obj or actor, DebugFXSound)) then + printf('FX sound %s "%s",matching: %s - %s - %s - %s', handle and "play" or "fail", self.Sound, self.Action, self.Moment, self.Actor, self.Target) + if not handle and err then + print(" FX sound error:", err) + end + end + if not handle then + return + end + if self.Cooldown <= 0 and not self:TrackFX() then + return + end + local fx = self:GetAssignedFX(actor, target) + if not fx then + fx = {} + self:AssignFX(actor, target, fx) + end + if self.Cooldown > 0 then + fx.time = RealTime() + end + if self:TrackFX() then + fx.sound_handle = handle + fx.fade_out = self.FadeOut + if source == "Camera" and FXCameraSounds then + FXCameraSounds[fx] = true + -- "persist" camera sounds (restart them upon loading game) if they have a rule to stop them, e.g. rain sounds + if self.EndRules and next(self.EndRules) then + fx.Sound = self.Sound + end + end + if self.Time <= 0 then + return + end + fx.thread = self:CreateThread(WaitDestroyFX, self, fx, actor, target) + end +end + +function ActionFXSound:GetError() + if (self.Sound or "") == "" and (self.DistantSound or "") == "" then + return "No sound specified" + end +end + +function ActionFXSound:GetProjectReplace(sound, actor) + return sound +end + +function ActionFXSound:PlaceSingleFXSound(actor, target, idx, obj, spot, posx, posy, posz) + if obj and (not IsValid(obj) or not obj:IsValidPos()) then + return + end + local sound = self.Sound or "" + local distant_sound = self.DistantSound or "" + local distant_radius = self.DistantRadius + if distant_sound ~= "" and distant_radius > 0 then + local x, y = posx, posy + if obj then + x, y = obj:GetVisualPosXYZ() + end + if not IsCloser2D(camera.GetPos(), x, y, distant_radius) then + sound = distant_sound + end + end + if sound == "" then return end + + sound = self:GetProjectReplace(sound, actor)-- give it a chance ot be replaced by project specific logic + + local handle, err + if not obj then + return PlaySound(sound, nil, self.FadeIn, false, point(posx, posy, posz or const.InvalidZ)) + elseif not self.AttachToObj then + if self.Spot == "" and self.Offset == point30 then + return PlaySound(sound, nil, self.FadeIn, false, obj) + else + return PlaySound(sound, nil, self.FadeIn, false, point(posx, posy, posz or const.InvalidZ)) + end + elseif self.Spot == "" and self.Offset == point30 then + local sname, sbank, stype, shandle, sduration, stime = obj:GetSound() + if not self.SkipSame or sound ~= sbank then + if sound ~= sbank or stime ~= GameTime() or self:TrackFX() then + dbg(MarkObjSound(self, obj, sound)) + obj:SetSound(sound, 1000, self.FadeIn) + end + end + else + local sound_dummy + if idx == 1 then + self:DestroyFX(actor, target) + end + local fx = self:GetAssignedFX(actor, target) + if fx then + local list = fx.sound_dummies + for i = list and #list or 0, 1, -1 do + local o = list[i] + if o:GetAttachSpot() == spot then + sound_dummy = o + break + end + end + else + fx = {} + if self:TrackFX() then + self:AssignFX(actor, target, fx) + end + end + if not sound_dummy or not IsValid(sound_dummy) then + sound_dummy = PlaceObject("SoundDummy") + fx.sound_dummies = fx.sound_dummies or {} + table.insert(fx.sound_dummies, sound_dummy) + end + if spot then + obj:Attach(sound_dummy, spot) + else + obj:Attach(sound_dummy) + end + + dbg(MarkObjSound(self, sound_dummy, sound)) + sound_dummy:SetAttachOffset(self.Offset) + sound_dummy:SetSound(sound, 1000, self.FadeIn) + end +end + +function ActionFXSound:DestroyFX(actor, target) + local fx = self:GetAssignedFX(actor, target) + if self.AttachToObj then + if self.Spot == "" then + local obj = self:GetLocObj(actor, target) + if IsValid(obj) then + obj:StopSound(self.FadeOut) + end + else + if not fx then return end + local list = fx.sound_dummies + for i = list and #list or 0, 1, -1 do + local o = list[i] + if not IsValid(o) then + table.remove(list, i) + else + o:StopSound(self.FadeOut) + end + end + end + else + if not fx then return end + if FXCameraSounds then + FXCameraSounds[fx] = nil + end + local handle = fx.sound_handle + if handle then + if type(handle) == "table" then + for i = 1, #handle do + SetSoundVolume(handle[i], -1, self.FadeOut) -- -1 means destroy + end + else + SetSoundVolume(handle, -1, self.FadeOut) -- -1 means destroy + end + fx.sound_handle = nil + end + if fx.thread and fx.thread ~= CurrentThread() then + DeleteThread(fx.thread) + fx.thread = nil + end + end + return fx +end + +if FirstLoad then + l_snd_test_handle = false +end + +function TestActionFXSound(editor_obj, fx, prop_id) + StopActionFXSound() + l_snd_test_handle = PlaySound(fx.Sound) +end + +function StopActionFXSound() + if l_snd_test_handle then + StopSound(l_snd_test_handle) + l_snd_test_handle = false + end +end + +---- + +--============================= Wind Mod FX ======================= + +local function custom_mod_no_edit(self) + return #(self.Presets or "") > 0 +end +local function no_obj_or_attach_no_edit(self) + return self.AttachToObj or self.Source ~= "Actor" and self.Source ~= "Target" +end +local function attach_no_edit(self) + return self.AttachToObj +end + +DefineClass.ActionFXWindMod = { + __parents = { "ActionFX" }, + properties = { + { category = "Wind Mod", id = "Source", default = "Actor", editor = "dropdownlist", items = { "UI", "Actor", "Target", "ActionPos", "Camera" }, help = "Sound mod object or position." }, + { category = "Wind Mod", id = "AttachToObj", name = "Attach To Source", editor = "bool", default = false, no_edit = no_obj_no_edit, help = "Attach to the actor or target (the Source) and move with it." }, + { category = "Wind Mod", id = "Spot", default = "", editor = "combo", items = function(fx) return ActionFXSpotCombo(fx) end, no_edit = no_obj_or_attach_no_edit }, + { category = "Wind Mod", id = "Offset", default = point30, editor = "point", scale = "m", no_edit = attach_no_edit, help = "Offset against source" }, + { category = "Wind Mod", id = "OffsetDir", default = "SourceAxisX", no_edit = attach_no_edit, editor = "dropdownlist", items = function(fx) return ActionFXOrientationCombo end, }, + { category = "Wind Mod", id = "ModBySpeed", default = false, no_edit = no_obj_no_edit, editor = "bool", help = "Modify the wind strength by the speed of the object" }, + { category = "Wind Mod", id = "ModBySize", default = false, no_edit = no_obj_no_edit, editor = "bool", help = "Modify the wind radius by the size of the object" }, + { category = "Wind Mod", id = "OnTerrainOnly", default = true, editor = "bool", help = "Allow the wind mod only on terrain" }, + + { category = "Wind Mod", id = "Presets", default = false, editor = "string_list", items = function() return table.keys(WindModifierParams, true) end, buttons = {{name = "Test", func = "TestActionFXWindMod"}, {name = "Stop", func = "StopActionFXWindMod"}, {name = "Draw Debug", func = "DbgWindMod"}}}, + { category = "Wind Mod", id = "AttachOffset", name = "Offset", default = point30, editor = "point", no_edit = custom_mod_no_edit }, + { category = "Wind Mod", id = "HalfHeight", name = "Capsule half height", default = guim, scale = "m", editor = "number", no_edit = custom_mod_no_edit }, + { category = "Wind Mod", id = "Range", name = "Capsule inner radius", default = guim, scale = "m", editor = "number", no_edit = custom_mod_no_edit, help = "Min range of action (vertex deformation) 100%" }, + { category = "Wind Mod", id = "OuterRange", name = "Capsule outer radius", default = guim, scale = "m", editor = "number", no_edit = custom_mod_no_edit, help = "Max range of action (vertex deformation)" }, + { category = "Wind Mod", id = "Strength", name = "Strength", default = 10000, scale = 1000, editor = "number", no_edit = custom_mod_no_edit, help = "Strength vertex deformation" }, + { category = "Wind Mod", id = "ObjHalfHeight", name = "Obj Capsule half height", default = guim, scale = "m", editor = "number", no_edit = custom_mod_no_edit, help = "Patch deform" }, + { category = "Wind Mod", id = "ObjRange", name = "Obj Capsule inner radius", default = guim, scale = "m", editor = "number", no_edit = custom_mod_no_edit, help = "Patch deform" }, + { category = "Wind Mod", id = "ObjOuterRange", name = "Obj Capsule outer radius", default = guim, scale = "m", editor = "number", no_edit = custom_mod_no_edit, help = "Patch deform" }, + { category = "Wind Mod", id = "ObjStrength", name = "Obj Strength", default = 10000, scale = 1000, editor = "number", no_edit = custom_mod_no_edit, help = "Patch deform" }, + { category = "Wind Mod", id = "SizeAttenuation", name = "Size Attenuation", default = 5000, scale = 1000, editor = "number", no_edit = custom_mod_no_edit }, + { category = "Wind Mod", id = "HarmonicConst", name = "Frequency", default = 10000, scale = 1000, editor = "number", no_edit = custom_mod_no_edit}, + { category = "Wind Mod", id = "HarmonicDamping", name = "Damping ratio", default = 800, scale = 1000, editor = "number", no_edit = custom_mod_no_edit }, + { category = "Wind Mod", id = "WindModifierMask", name = "Modifier Mask", default = -1, editor = "flags", size = function() return #(const.WindModifierMaskFlags or "") end, items = function() return const.WindModifierMaskFlags end, no_edit = custom_mod_no_edit }, + + }, + fx_type = "Wind Mod", + SpotsPercent = -1, + GameTime = true, +} + +function ActionFXWindMod:TrackFX() + if self.behaviors or self.Time > 0 then + return true + end + return false +end + +function ActionFXWindMod:DbgWindMod(fx) + hr.WindModifierDebug = 1 - hr.WindModifierDebug +end + +function ActionFXWindMod:PlayFX(actor, target, action_pos, action_dir) + local count, obj, spot, posx, posy, posz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + return + end + if self.OnTerrainOnly and not posz then + return + end + if self.Delay <= 0 then + self:PlaceFXWindMod(actor, target, count, obj, spot, posx, posy, posz) + return + end + local thread = self:CreateThread(function(self, ...) + Sleep(self.Delay) + self:PlaceFXWindMod(...) + end, self, actor, target, count, obj, spot, posx, posy, posz) + if self:TrackFX() then + local fx = self:DestroyFX(actor, target) + if not fx then + fx = {} + self:AssignFX(actor, target, fx) + end + fx.thread = thread + end +end + +local function PlaceSingleFXWindMod(params, attach_to, pos, range_mod, strength_mod, speed_mod) + return terrain.SetWindModifier( + (pos or point30):Add(params.AttachOffset or point30), + params.HalfHeight, + range_mod and params.Range * range_mod / guim or params.Range, + range_mod and params.OuterRange * range_mod / guim or params.OuterRange, + strength_mod and params.Strength * strength_mod / guim or params.Strength, + params.ObjHalfHeight, + range_mod and params.ObjRange * range_mod / guim or params.ObjRange, + range_mod and params.ObjOuterRange * range_mod / guim or params.ObjOuterRange, + strength_mod and params.ObjStrength * strength_mod / guim or params.ObjStrength, + params.SizeAttenuation, + speed_mod and params.HarmonicConst * speed_mod / 1000 or params.HarmonicConst, + speed_mod and params.HarmonicDamping * speed_mod / 1000 or params.HarmonicDamping, + 0, + 0, + params.WindModifierMask or -1, + attach_to) +end + +function ActionFXWindMod:PlaceFXWindMod(actor, target, count, obj, spot, posx, posy, posz, range_mod, strength_mod, speed_mod) + range_mod = range_mod or self.ModBySize and obj and obj:GetRadius() + strength_mod = strength_mod or self.ModBySpeed and obj and obj:GetSpeed() + speed_mod = speed_mod or self.GameTime and GetTimeFactor() + if speed_mod <= 0 then + speed_mod = false + end + + local attach_to = self.AttachToObj and obj + local pos = point30 + if not attach_to then + pos = point(posx, posy, posz) + end + + local ids + if #(self.Presets or "") == 0 then + ids = PlaceSingleFXWindMod(self, attach_to, pos, range_mod, strength_mod, speed_mod) + else + for _, preset in ipairs(self.Presets) do + local params = WindModifierParams[preset] + if params then + local id = PlaceSingleFXWindMod(params, attach_to, pos, range_mod, strength_mod, speed_mod) + if not ids then + ids = id + elseif type(ids) == "table" then + ids[#ids + 1] = id + else + ids = { ids, id } + end + end + end + end + + if not ids or not self:TrackFX() then + return + end + local fx = self:GetAssignedFX(actor, target) + if not fx then + fx = {} + self:AssignFX(actor, target, fx) + end + fx.wind_mod_ids = ids + if self.Time <= 0 then + return + end + fx.thread = self:CreateThread(WaitDestroyFX, self, fx, actor, target) +end + +function ActionFXWindMod:DestroyFX(actor, target) + local fx = self:GetAssignedFX(actor, target) + if not fx then return end + local wind_mod_ids = self.wind_mod_ids + if wind_mod_ids then + if type(wind_mod_ids) == "number" then + terrain.RemoveWindModifier(wind_mod_ids) + else + for _, id in ipairs(wind_mod_ids) do + terrain.RemoveWindModifier(id) + end + end + fx.wind_mod_ids = nil + end + if fx.thread and fx.thread ~= CurrentThread() then + DeleteThread(fx.thread) + fx.thread = nil + end + return fx +end + +if FirstLoad then + l_windmod_test_id = false +end + +function TestActionFXWindMod(editor_obj, fx, prop_id) + StopActionFXWindMod() + local obj = selo() or SelectedObj + if not IsValid(obj) then + print("No object selected!") + return + end + local actor, target, count, spot + local x, y, z = obj:GetVisualPosXYZ() + l_windmod_test_id = fx:PlaceFXWindMod(actor, target, count, obj, spot, x, y, z, nil, nil, 1000) or false +end + +function StopActionFXWindMod() + if l_windmod_test_id then + terrain.RemoveWindModifier(l_windmod_test_id) + l_windmod_test_id = false + end +end + +---- + +DefineClass.ActionFXUIParticles = { + __parents = {"ActionFX"}, + properties = { + { id = "Particles", category = "Particles", default = "", editor = "combo", items = UIParticlesComboItems }, + { id = "Foreground", category = "Particles", default = false, editor = "bool" }, + { id = "HAlign", category = "Particles", default = "middle", editor = "choice", items = function() return GetUIParticleAlignmentItems(true) end }, + { id = "VAlign", category = "Particles", default = "middle", editor = "choice", items = function() return GetUIParticleAlignmentItems(false) end }, + { id = "TransferToParent", category = "Lifetime", default = false, editor = "bool", help = "Should particles continue to live after the host control dies?" }, + { id = "StopEmittersOnTransfer", category = "Lifetime", default = true, editor = "bool", no_edit = function(self) return not self.TransferToParent end }, + { id = "GameTime", editor = false }, + }, + Time = -1, + fx_type = "UI Particles", +} + +function ActionFXUIParticles:TrackFX() + return true +end + +function ActionFXUIParticles:PlayFX(actor, target, action_pos, action_dir) + assert(IsKindOf(actor, "XControl")) + + local stop_fx = self:GetAssignedFX(actor, target) + if stop_fx then + stop_fx() + end + + local create_particles = function(self, actor, target) + local id = UIL.PlaceUIParticles(self.Particles) + self:AssignFX(actor, target, function() + actor:StopParticle(id) + end) + actor:AddParSystem(id, self.Particles, UIParticleInstance:new({ + foreground = self.Foreground, + lifetime = self.Time, + transfer_to_parent = self.TransferToParent, + stop_on_transfer = self.StopEmittersOnTransfer, + halign = self.HAlign, + valign = self.VAlign, + })) + end + + if self.Delay > 0 then + local delay_thread = CreateRealTimeThread(function(self, actor, target) + Sleep(self.Delay) + if actor.window_state == "open" then + create_particles(self, actor, target) + end + end, self, actor, target) + self:AssignFX(actor, target, function() DeleteThread(delay_thread) end) + else + create_particles(self, actor, target) + end +end + + +function ActionFXUIParticles:DestroyFX(actor, target) + local stop_fx = self:GetAssignedFX(actor, target) + if stop_fx then + stop_fx() + end + return false +end + + +DefineClass.ActionFXUIShaderEffect = { + __parents = {"ActionFX"}, + properties = { + { id = "EffectId", category = "FX", default = "", editor = "preset_id", preset_class = "UIFxModifierPreset" }, + { id = "GameTime", editor = false }, + }, + Time = -1, + fx_type = "UI Effect", +} + +function ActionFXUIShaderEffect:TrackFX() + return true +end + +function ActionFXUIShaderEffect:PlayFX(actor, target, action_pos, action_dir) + assert(IsKindOf(actor, "XFxModifier")) + + local stop_fx = self:GetAssignedFX(actor, target) + if stop_fx then + stop_fx() + end + + local old_fx_id = actor.EffectId + local play_fx_impl = function(self, actor, target) + actor:SetUIEffectModifierId(self.EffectId) + if self.Time > 0 then + CreateRealTimeThread(function(self, actor, target) + Sleep(self.Time) + self:DestroyFX(actor, target) + end, self, actor, target) + end + end + + local delay_thread = false + if self.Delay > 0 then + delay_thread = CreateRealTimeThread(function(self, actor, target) + Sleep(self.Delay) + if actor.window_state == "open" then + play_fx_impl(self, actor, target) + end + end, self, actor, target) + else + play_fx_impl(self, actor, target) + end + self:AssignFX(actor, target, function() + if delay_thread then + DeleteThread(delay_thread) + end + if actor.UIEffectModifierId == self.EffectId then + actor:SetUIEffectModifierId(old_fx_id) + end + end) +end + + +function ActionFXUIShaderEffect:DestroyFX(actor, target) + local stop_fx = self:GetAssignedFX(actor, target) + if stop_fx then + stop_fx() + end + return false +end + + +--======================= Particles FX ======================= + +DefineClass.ActionFXParticles = { + __parents = { "ActionFX" }, + properties = { + { id = "Particles", category = "Particles", default = "", editor = "combo", items = ParticlesComboItems, buttons = {{name = "Test", func = "TestActionFXParticles"}, {name = "Edit", func = "ActionEditParticles"}}}, + { id = "Particles2", category = "Particles", default = "", editor = "combo", items = ParticlesComboItems, buttons = {{name = "Test", func = "TestActionFXParticles"}, {name = "Edit", func = "ActionEditParticles"}}}, + { id = "Particles3", category = "Particles", default = "", editor = "combo", items = ParticlesComboItems, buttons = {{name = "Test", func = "TestActionFXParticles"}, {name = "Edit", func = "ActionEditParticles"}}}, + { id = "Particles4", category = "Particles", default = "", editor = "combo", items = ParticlesComboItems, buttons = {{name = "Test", func = "TestActionFXParticles"}, {name = "Edit", func = "ActionEditParticles"}}}, + { id = "Flags", category = "Particles", default = "", editor = "dropdownlist", items = { "", "OnGround", "LockedOrientation", "Mirrored", "OnGroundTiltByGround" } }, + { id = "AlwaysVisible", category = "Particles", default = false, editor = "bool", }, + { id = "Scale", category = "Particles", default = 100, editor = "number" }, + { id = "ScaleMember", category = "Particles", default = "", editor = "text" }, + { id = "Source", category = "Placement", default = "Actor", editor = "dropdownlist", items = { "Actor", "ActorParent", "ActorOwner", "Target", "ActionPos", "Camera" }, help = "Particles source object or position" }, + { id = "SourceProp", category = "Placement", default = "", editor = "combo", items = function(fx) return ActionFXSourcePropCombo() end, help = "Source object property object" }, + { id = "Spot", category = "Placement", default = "Origin", editor = "combo", items = function(fx) return ActionFXSpotCombo(fx) end, help = "Particles source object spot" }, + { id = "SpotsPercent", category = "Placement", default = -1, editor = "number", help = "Percent of random spots that should be used. One random spot is used when the value is negative." }, + { id = "Attach", category = "Placement", default = false, editor = "bool", help = "Set true if the particles should move with the source" }, + { id = "SingleAttach", category = "Placement", default = false, editor = "bool", help = "When enabled the FX will not place a new particle on the same spot if there is already one attached there. Only valid with Attach enabled." }, + { id = "Offset", category = "Placement", default = point30, editor = "point", scale = "m", help = "Offset against source object" }, + { id = "OffsetDir", category = "Placement", default = "SourceAxisX", editor = "dropdownlist", items = function(fx) return ActionFXOrientationCombo end }, + { id = "Orientation", category = "Placement", default = "", editor = "dropdownlist", items = function(fx) return ActionFXOrientationCombo end }, + { id = "PresetOrientationAngle", category = "Placement", default = 0, editor = "number", }, + { id = "OrientationAxis", category = "Placement", default = 1, editor = "dropdownlist", items = function(fx) return OrientationAxisCombo end }, + { id = "FollowTick", category = "Particles", default = 100, editor = "number" }, + { id = "UseActorColorModifier", category = "Particles", default = false, editor = "bool", help = "If true, parsys:SetColorModifer(actor). If false, sets dynamic param 'color_modifier' to the actor's color" }, + }, + fx_type = "Particles", + Documentation = ActionFX.Documentation .. "\n\nThis mod item creates and places particle systems when an FX action is triggered. Inherits ActionFX. Read ActionFX first for the common properties.", + DocumentationLink = "Docs/ModItemActionFXParticles.md.html" +} + +local function no_dynamic(prop, param_type) + return function(self) + local name = self[prop] + if name == "" then return true end + local params = ParGetDynamicParams(self.Particles) + local par_type = params[name] + return not par_type or par_type.type ~= param_type + end +end + +local fx_particles_dynamic_params = 4 +local fx_particles_dynamic_names = {} +local fx_particles_dynamic_values = {} +local fx_particles_dynamic_colors = {} +local fx_particles_dynamic_points = {} + +for i = 1, fx_particles_dynamic_params do + local prop = "DynamicName"..i + fx_particles_dynamic_names[i] = prop + fx_particles_dynamic_values[i] = "DynamicValue"..i + fx_particles_dynamic_colors[i] = "DynamicColor"..i + fx_particles_dynamic_points[i] = "DynamicPoint"..i + table.insert(ActionFXParticles.properties, { id = prop, category = "Particles", name = "Name", editor = "text", default = "", read_only = true, no_edit = function(self) return self[prop] == "" end }) + table.insert(ActionFXParticles.properties, { id = fx_particles_dynamic_values[i], category = "Particles", name = "Value", editor = "number", default = 1, no_edit = no_dynamic(prop, "number")}) + table.insert(ActionFXParticles.properties, { id = fx_particles_dynamic_colors[i], category = "Particles", name = "Color", editor = "color", default = 0, no_edit = no_dynamic(prop, "color")}) + table.insert(ActionFXParticles.properties, { id = fx_particles_dynamic_points[i], category = "Particles", name = "Point", editor = "point", default = point(0,0), no_edit = no_dynamic(prop, "point")}) + +end + +function ActionFXParticles:OnEditorSetProperty(prop_id, old_value, ged) + ActionFX.OnEditorSetProperty(self, prop_id, old_value, ged) + if prop_id == "Particles" then + self:UpdateDynamicParams() + end +end + +function ActionFXParticles:OnEditorSelect(selected, ged) + if selected then + self:UpdateDynamicParams() + end +end + +function ActionFXParticles:UpdateDynamicParams() + g_DynamicParamsDefs = {} + local params = ParGetDynamicParams(self.Particles) + local n = 1 + for name, desc in sorted_pairs(params) do + self[ fx_particles_dynamic_names[n] ] = name + n = n + 1 + if n > fx_particles_dynamic_params then + break + end + end + for i = n, fx_particles_dynamic_params do + self[ fx_particles_dynamic_names[i] ] = nil + end +end + +function ActionFXParticles:IsEternal(par) + if IsValid(par) then + return IsParticleSystemEternal(par) + elseif IsValid(par[1]) then + return IsParticleSystemEternal(par[1]) + end +end + +function ActionFXParticles:GetDuration(par) + if IsValid(par) then + return GetParticleSystemDuration(par) + elseif par and IsValid(par[1]) then + return GetParticleSystemDuration(par[1]) + end + return 0 +end + +function ActionFXParticles:PlayFX(actor, target, action_pos, action_dir) + local count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + if self.SourceProp ~= "" then + printf("FX Particles %s (id %s) has invalid source %s with property: %s", self.Particles, self.id, self.Source, self.SourceProp) + else + printf("FX Particles %s (id %s) has invalid source: %s", self.Particles, self.id, self.Source) + end + return + end + local par + if self.Delay <= 0 then + par = self:PlaceFXParticles(count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + if not par then + return + end + self:TrackParticle(par, actor, target, action_pos, action_dir) + if self.Time <= 0 and self:IsEternal(par) then + return + end + end + local thread = self:CreateThread(function(self, actor, target, action_pos, action_dir, count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, par) + if self.Delay > 0 then + Sleep(self.Delay) + if self.Attach then + -- NOTE: spot here should be recalculated as the anim can change in the meanwhile + count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz = self:GetLoc(actor, target, action_pos, action_dir) + end + par = self:PlaceFXParticles(count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + if not par then + return + end + self:TrackParticle(par, actor, target, action_pos, action_dir) + end + if par and (self.Time > 0 or not self:IsEternal(par)) then + if self.Time > 0 then + Sleep(self.Time) + else + Sleep(self:GetDuration(par)) + end + if par == self:GetAssignedFX(actor, target) then + self:AssignFX(actor, target, nil) + end + if IsValid(par) then + StopParticles(par, true) + else + for _, p in ipairs(par) do + if IsValid(p) then + StopParticles(p, true) + end + end + end + end + end, self, actor, target, action_pos, action_dir, count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, par) + if not par and self:TrackFX() then + self:DestroyFX(actor, target) + self:AssignFX(actor, target, thread) + end +end + +function ActionFXParticles:HasDynamicParams() + local params = ParGetDynamicParams(self.Particles) + if next(params) then + for i = 1, fx_particles_dynamic_params do + local name = self[ fx_particles_dynamic_names[i] ] + if name == "" then break end + if params[name] then + return true + end + end + end +end + +local function IsAttachedAtSpot(att, parent, spot) + local att_spot = att:GetAttachSpot() + if att_spot == (spot or -1) then + return true + end + local att_spot_name = parent:GetSpotName(att_spot) + local spot_name = spot and parent:GetSpotName(spot) or "" + if spot_name == att_spot_name or (spot_name == "Origin" or spot_name == "") and (att_spot_name == "Origin" or att_spot_name == "") then + return true + end + return false +end + +function ActionFXParticles:PlaceFXParticles(count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + if self.Attach and (not obj or not IsValid(obj)) then + return + end + if count == 1 then + return self:PlaceSingleFXParticles(obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + end + local par + for i = 0, count - 1 do + local p = self:PlaceSingleFXParticles(obj, unpack_params(spot, 8*i+1, 8*i+8)) + if p then + par = par or {} + table.insert(par, p) + end + end + return par +end + +function ActionFXParticles:PlaceSingleFXParticles(obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + local particles, particles2, particles3, particles4 = self.Particles, self.Particles2, self.Particles3, self.Particles4 + local parVariations = {} + + if (particles or "")~="" then table.insert(parVariations,particles) end + if (particles2 or "")~="" then table.insert(parVariations,particles2) end + if (particles3 or "")~="" then table.insert(parVariations,particles3) end + if (particles4 or "")~="" then table.insert(parVariations,particles4) end + + particles = select(1,(table.rand(parVariations))) or "" + + if self.Attach and self.SingleAttach then + local count = obj:CountAttaches(particles, IsAttachedAtSpot, obj, spot) + if count > 0 then + return + end + end + if DebugFXParticles and (type(DebugFXParticles) ~= "string" or IsKindOf(obj, DebugFXParticles)) then + printf('FX particles %s', particles) + end + if DebugFXParticlesName and DebugMatch(particles, DebugFXParticlesName) then + printf('FX particles %s', particles) + end + local par = PlaceParticles(particles) + if not par then return end + if self.DetailLevel >= ParticleDetailLevelMax then + par:SetImportant(true) + end + NetTempObject(par) + local scale + local scale_member = self.ScaleMember + if scale_member ~= "" and obj and IsValid(obj) and obj:HasMember(scale_member) then + scale = obj[scale_member] + if scale and type(scale) == "function" then + scale = scale(obj) + end + end + scale = scale or self.Scale + if scale ~= 100 then + par:SetScale(scale) + end + local flags = self.Flags + if flags == "Mirrored" then + par:SetMirrored(true) + elseif flags == "LockedOrientation" then + par:SetGameFlags(const.gofLockedOrientation) + elseif flags == "OnGround" or flags == "OnGroundTiltByGround" then + par:SetGameFlags(const.gofAttachedOnGround) + end + + -- fill in dynamic parameters + local dynamic_params = ParGetDynamicParams(particles) + if next(dynamic_params) then + for i = 1, fx_particles_dynamic_params do + local name = self[ fx_particles_dynamic_names[i] ] + if name == "" then break end + local def = dynamic_params[name] + if def then + if def.type == "color" then + par:SetParamDef(def, self:GetProperty(fx_particles_dynamic_colors[i])) + elseif def.type == "point" then + par:SetParamDef(def, self:GetProperty(fx_particles_dynamic_points[i])) + else + par:SetParamDef(def, self:GetProperty(fx_particles_dynamic_values[i])) + end + end + end + end + + if self.AlwaysVisible then + local obj_iter = obj or par + while true do + local parent = obj_iter:GetParent() + if not parent then + obj_iter:SetGameFlags(const.gofAlwaysRenderable) + break + end + obj_iter = parent + end + end + + if obj then + if self.UseActorColorModifier then + par:SetColorModifier(obj:GetColorModifier()) + else + local def = dynamic_params["color_modifier"] + if def then + par:SetParamDef(def, obj:GetColorModifier()) + end + end + end + + FXOrient(par, posx, posy, posz, obj, spot, self.Attach, axisx, axisy, axisz, angle, self.Offset) + return par +end + +function ActionFXParticles:TrackParticle(par, actor, target, action_pos, action_dir) + if self:TrackFX() then + self:AssignFX(actor, target, par) + end + if self.Behavior ~= "" and self.BehaviorMoment == "" then + self[self.Behavior](self, actor, target, action_pos, action_dir) + end +end + +function ActionFXParticles:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValidThread(fx) then + DeleteThread(fx) + elseif IsValid(fx) then + StopParticles(fx) + elseif type(fx) == "table" and not getmetatable(fx) then + for i = 1, #fx do + local p = fx[i] + if IsValid(p) then + StopParticles(p) + end + end + end +end + +function ActionFXParticles:BehaviorDetach(actor, target) + local fx = self:GetAssignedFX(actor, target) + if not fx then + return + elseif IsValidThread(fx) then + printf("FX Particles %s Detach Behavior can not be run before particle placing", self.Particles, self.Delay) + elseif IsValid(fx) then + PreciseDetachObj(fx) + elseif type(fx) == "table" and not getmetatable(fx) then + for i = 1, #fx do + local p = fx[i] + if IsValid(p) then + PreciseDetachObj(p) + end + end + end +end + +function ActionFXParticles:BehaviorDetachAndDestroy(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValidThread(fx) then + DeleteThread(fx) + elseif IsValid(fx) then + PreciseDetachObj(fx) + StopParticles(fx) + elseif type(fx) == "table" and not getmetatable(fx) then + for i = 1, #fx do + local p = fx[i] + if IsValid(p) then + PreciseDetachObj(p) + StopParticles(p) + end + end + end +end + +function ActionFXParticles:BehaviorFollow(actor, target, action_pos, action_dir) + local fx = self:GetAssignedFX(actor, target) + if not fx then return end + local obj = self:GetLocObj(actor, target) + if not obj then + printf("FX Particles %s uses unsupported behavior/source combination: %s/%s", self.Particles, self.Behavior, self.Source) + return + end + self:CreateThread(function(self, fx, actor, target, obj, tick) + while IsValid(obj) and IsValid(fx) and self:GetAssignedFX(actor, target) == fx do + local x, y, z = obj:GetSpotLocPosXYZ(-1) + fx:SetPos(x, y, z, tick) + Sleep(tick) + end + end, self, fx, actor, target, obj, self.FollowTick) +end + +function ActionEditParticles(editor_obj, fx, prop_id) + EditParticleSystem(fx.Particles) +end + +function TestActionFXParticles(editor_obj, fx, prop_id) + TestActionFXObjectEnd() + local obj = PlaceParticles(fx.Particles) + if not obj then + return + end + LastTestActionFXObject = obj + obj:SetScale(fx.Scale) + if fx.Flags == "Mirrored" then + obj:SetMirrored(true) + elseif fx.Flags == "OnGround" then + obj:SetGameFlags(const.gofAttachedOnGround) + end + + -- fill in dynamic parameters + local params = ParGetDynamicParams(fx.Particles) + if next(params) then + for i = 1, fx_particles_dynamic_params do + local name = fx[ fx_particles_dynamic_names[i] ] + if name == "" then break end + if params[name] then + local prop = (params[name].type == "color") and "DynamicColor" or "DynamicValue" + local value = fx:GetProperty(prop .. i) + obj:SetParam(name, value) + end + end + end + local eye_pos, look_at + if camera3p.IsActive() then + eye_pos, look_at = camera.GetEye(), camera3p.GetLookAt() + elseif cameraMax.IsActive() then + eye_pos, look_at = cameraMax.GetPosLookAt() + else + look_at = GetTerrainGamepadCursor() + end + local posx, posy, posz = look_at:xyz() + FXOrient(obj, posx, posy, posz) + + editor_obj:CreateThread(function(obj) + Sleep(5000) + StopParticles(obj, true) + TestActionFXObjectEnd(obj) + end, obj) +end + +--============================= Camera Shake FX ======================= + +DefineClass.ActionFXCameraShake = { + __parents = { "ActionFX" }, + properties = { + { id = "Preset", category = "Camera Shake", default = "Custom", editor = "dropdownlist", + items = function(self) return table.keys2(self.presets) end, buttons = {{ name = "Test", func = "TestActionFXCameraShake" }}, + }, + { id = "Duration", category = "Camera Shake", default = 700, editor = "number", min = 100, max = 2000, slider = true, }, + { id = "Frequency", category = "Camera Shake", default = 25, editor = "number", min = 1, max = 100, slider = true, }, + { id = "ShakeOffset", category = "Camera Shake", default = 30*guic, editor = "number", min = 1*guic, max = 100*guic, slider = true, scale = "cm", }, + { id = "RollAngle", category = "Camera Shake", default = 0, editor = "number", min = 0, max = 30, slider = true, }, + { id = "Source", category = "Camera Shake", default = "Actor", editor = "dropdownlist", items = { "Actor", "Target", "ActionPos" }, help = "Shake position or object position" }, + { id = "Spot", category = "Camera Shake", default = "Origin", editor = "combo", items = function(fx) return ActionFXSpotCombo(fx) end, help = "Shake position object spot" }, + { id = "Offset", category = "Camera Shake", default = point30, editor = "point", scale = "m", help = "Shake position offset" }, + { id = "ShakeRadiusInSight", category = "Camera Shake", default = const.ShakeRadiusInSight, editor = "number", scale = "m", + name = "Fade radius (in sight)", help = "The distance from the source at which the camera shake fades out completely, if the source is in the camera view", + }, + { id = "ShakeRadiusOutOfSight", category = "Camera Shake", default = const.ShakeRadiusOutOfSight, editor = "number", scale = "m", + name = "Fade radius (out of sight)", help = "The distance from the source at which the camera shake fades out completely, if the source is out of the camera view", + }, + { id = "Time", editor = false }, + { id = "Behavior", editor = false }, + { id = "BehaviorMoment", editor = false }, + }, + presets = { + Custom = {}, + Light = { Duration = 380, Frequency = 25, ShakeOffset = 6*guic, RollAngle = 3 }, + Medium = { Duration = 460, Frequency = 25, ShakeOffset = 12*guic, RollAngle = 6 }, + Strong = { Duration = 950, Frequency = 25, ShakeOffset = 15*guic, RollAngle = 9 }, + }, + fx_type = "Camera Shake", +} + +function ActionFXCameraShake:PlayFX(actor, target, action_pos, action_dir) + if IsEditorActive() or EngineOptions.CameraShake == "Off" then return end + + local count, obj, spot, posx, posy, posz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + printf("FX Camera Shake has invalid source: %s", self.Source) + return + end + local power + if obj then + if NetIsRemote(obj) then + return -- camera shake FX is not applied for remote objects + end + if camera3p.IsActive() and camera3p.IsAttachedToObject(obj:GetParent() or obj) then + power = 100 + end + end + power = power or posx and CameraShake_GetEffectPower(point(posx, posy, posz or const.InvalidZ), self.ShakeRadiusInSight, self.ShakeRadiusOutOfSight) or 0 + if power == 0 then + return + end + if self.Delay <= 0 then + self:Shake(actor, target, power) + return + end + local thread = self:CreateThread(function(self, actor, target, power) + Sleep(self.Delay) + self:Shake(actor, target, power) + end, self, actor, target, power) + if self:TrackFX() then + self:DestroyFX(actor, target) + self:AssignFX(actor, target, thread) + end +end + +function ActionFXCameraShake:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValidThread(fx) then + DeleteThread(fx) + local preset = self.presets[self.Preset] + local frequency = preset and preset.Frequency or self.Frequency + local shake_duration = frequency > 0 and Min(frequency, 200) or 0 + camera.ShakeStop(shake_duration) + end +end + +function ActionFXCameraShake:Shake(actor, target, power) + local preset = self.presets[self.Preset] + local duration = self.Duration >= 0 and (preset and preset.Duration or self.Duration) * power / 100 or -1 + local frequency = preset and preset.Frequency or self.Frequency + if frequency <= 0 then return end + local shake_offset = (preset and preset.ShakeOffset or self.ShakeOffset) * power / 100 + local shake_roll = (preset and preset.RollAngle or self.RollAngle) * power / 100 + camera.Shake(duration, frequency, shake_offset, shake_roll) + if self:TrackFX() then + self:AssignFX(actor, target, camera3p_shake_thread ) + end +end + +function ActionFXCameraShake:SetPreset(value) + self.Preset = value + local preset = self.presets[self.Preset] + self.Duration = preset and preset.Duration or self.Duration + self.Frequency = preset and preset.Frequency or self.Frequency + self.ShakeOffset = preset and preset.ShakeOffset or self.ShakeOffset + self.RollAngle = preset and preset.RollAngle or self.RollAngle +end + +function ActionFXCameraShake:OnEditorSetProperty(prop_id, old_value, ged) + ActionFX.OnEditorSetProperty(self, prop_id, old_value, ged) + if self.Preset ~= "Custom" and (prop_id == "Duration" or prop_id == "Frequency" or prop_id == "ShakeOffset" or prop_id == "RollAngle") then + local preset = self.presets[self.Preset] + if preset and preset[prop_id] and preset[prop_id] ~= self[prop_id] then + self.Preset = "Custom" + end + end +end + +function TestActionFXCameraShake(editor_obj, fx, prop_id) + local preset = fx.presets[fx.Preset] + local duration = preset and preset.Duration or fx.Duration + local frequency = preset and preset.Frequency or fx.Frequency + local shake_offset = preset and preset.ShakeOffset or fx.ShakeOffset + local shake_roll = preset and preset.RollAngle or fx.RollAngle + camera.Shake(duration, frequency, shake_offset, shake_roll) +end + +--============================= Radial Blur ======================= + +DefineClass.ActionFXRadialBlur = { + __parents = { "ActionFX" }, + properties = { + { id = "Strength", category = "Radial Blur", default = 300, editor = "number", buttons = {{name = "Test", func = "TestActionFXRadialBlur"}}}, + { id = "Duration", category = "Radial Blur", default = 800, editor = "number" }, + { id = "FadeIn", category = "Radial Blur", default = 30, editor = "number" }, + { id = "FadeOut", category = "Radial Blur", default = 350, editor = "number" }, + { id = "Source", category = "Placement", default = "Actor", editor = "dropdownlist", items = { "Actor", "ActorParent", "ActorOwner", "Target", "ActionPos" }, help = "Radial Blur position" }, + }, + fx_type = "Radial Blur", +} + +if FirstLoad then + RadialBlurThread = false + g_RadiualBlurIsPaused = false + g_RadiualBlurPauseReasons = {} +end +--blatant copy paste from Pause(reason) +function PauseRadialBlur(reason) + reason = reason or false + if next(g_RadiualBlurPauseReasons) == nil then + g_RadiualBlurIsPaused = true + SetPostProcPredicate( "radial_blur", false ) + g_RadiualBlurPauseReasons[reason] = true + else + g_RadiualBlurPauseReasons[reason] = true + end +end + +function ResumeRadialBlur(reason) + reason = reason or false + if g_RadiualBlurPauseReasons[reason] ~= nil then + g_RadiualBlurPauseReasons[reason] = nil + if next(g_RadiualBlurPauseReasons) == nil then + g_RadiualBlurIsPaused = false + end + end +end + +function OnMsg.DoneMap() + DeleteThread(RadialBlurThread) + RadialBlurThread = false + hr.RadialBlurStrength = 0 + SetPostProcPredicate( "radial_blur", false ) +end + +function RadialBlur( duration, fadein, fadeout, strength ) + DeleteThread(RadialBlurThread) + RadialBlurThread = self:CreateThread( function(duration, fadein, fadeout, strength) + SetPostProcPredicate( "radial_blur", not g_RadiualBlurIsPaused ) + local time_step = 5 + local t = 0 + while t < fadein do + hr.RadialBlurStrength = strength * t / fadein + Sleep(time_step) + t = t + time_step + end + if t < duration - fadeout then + hr.RadialBlurStrength = strength + Sleep(duration - fadeout - t) + t = duration - fadeout + end + while t < duration do + hr.RadialBlurStrength = strength * (duration - t) / fadeout + Sleep(time_step) + t = t + time_step + end + hr.RadialBlurStrength = 0 + SetPostProcPredicate( "radial_blur", false ) + RadialBlurThread = false + end, duration, fadein, fadeout, strength) +end + +function ActionFXRadialBlur:TrackFX() + return (self.behaviors or self.Time > 0) and true or false +end + +function ActionFXRadialBlur:PlayFX(actor, target, action_pos, action_dir) + local count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + printf("FX Radial Blur has invalid source: %s", self.Source) + return + end + if NetIsRemote(obj) then + return -- radial blur FX is not applied for remote objects + end + if self.Delay <= 0 then + RadialBlur(self.Duration, self.FadeIn, self.FadeOut, self.Strength) + if self:TrackFX() then + self:AssignFX(actor, target, RadialBlurThread) + end + else + self:CreateThread(function(self, actor, target) + Sleep(self.Delay) + RadialBlur(self.Duration, self.FadeIn, self.FadeOut, self.Strength) + if self:TrackFX() then + self:AssignFX(actor, target, RadialBlurThread) + end + end, self, actor, target) + end +end + +function ActionFXRadialBlur:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx or fx ~= RadialBlurThread then + return + end + DeleteThread(RadialBlurThread) + RadialBlurThread = false + hr.RadialBlurStrength = 0 + SetPostProcPredicate( "radial_blur", false ) +end + +function TestActionFXRadialBlur(editor_obj, fx, prop_id) + RadialBlur(fx.Duration, fx.FadeIn, fx.FadeOut, fx.Strength) +end + +local function ActionFXObjectCombo(o) + local list = ClassDescendantsList("CObject", function (name, class) + return IsValidEntity(class:GetEntity()) or class.fx_spawn_enable + end) + table.sort(list, CmpLower) + return list +end + +local function ActionFXObjectAnimationCombo(o) + local cls = g_Classes[o.Object] + local entity = cls and cls:GetEntity() + local list + if IsValidEntity(entity) then + list = GetStates(entity) + else + list = {"idle"} -- GetClassDescendantsStates("CObject") + end + table.sort(list, CmpLower) + return list +end + +local function ActionFXObjectAnimationHelp(o) + local cls = g_Classes[o.Object] + local entity = cls and cls:GetEntity() + if IsValidEntity(entity) then + local help = {} + help[#help+1] = entity + local anim = o.Animation + if anim ~= "" and HasState(entity, anim) and not IsErrorState(entity, anim) then + help[#help+1] = "Duration: " .. GetAnimDuration(entity, anim) + local moments = GetStateMoments(entity, anim) + if #moments > 0 then + help[#help+1] = "Moments:" + for i = 1, #moments do + help[#help+1] = string.format(" %s = %d", moments[i].type, moments[i].time) + end + else + help[#help+1] = "No Moments" + end + end + return table.concat(help, "\n") + end + return "" +end + +--============================= Object FX ======================= + +DefineClass.ActionFXObject = { + __parents = { "ActionFX", "ColorizableObject" }, + properties = { + { id = "AnimationLoops", category = "Lifetime", default = 0, editor = "number", help = "Additional time" }, + { id = "Object", name = "Object1", category = "Object", default = "", editor = "combo", items = function(fx) return ActionFXObjectCombo(fx) end, buttons = {{name = "Test", func = "TestActionFXObject"}}}, + { id = "Object2", category = "Object", default = "", editor = "combo", items = function(fx) return ActionFXObjectCombo(fx) end, buttons = {{name = "Test", func = "TestActionFXObject"}}}, + { id = "Object3", category = "Object", default = "", editor = "combo", items = function(fx) return ActionFXObjectCombo(fx) end, buttons = {{name = "Test", func = "TestActionFXObject"}}}, + { id = "Object4", category = "Object", default = "", editor = "combo", items = function(fx) return ActionFXObjectCombo(fx) end, buttons = {{name = "Test", func = "TestActionFXObject"}}}, + { id = "Animation", category = "Object", default = "idle", editor = "combo", items = function(fx) return ActionFXObjectAnimationCombo(fx) end, help = ActionFXObjectAnimationHelp }, + { id = "AnimationPhase", category = "Object", default = 0, editor = "number" }, + { id = "FadeIn", category = "Object", default = 0, editor = "number", help = "Included in the overall time" }, + { id = "FadeOut", category = "Object", default = 0, editor = "number", help = "Included in the overall time" }, + { id = "Flags", category = "Object", default = "", editor = "dropdownlist", items = { "", "OnGround", "LockedOrientation", "Mirrored", "OnGroundTiltByGround", "SyncWithParent" } }, + { id = "Scale", category = "Object", default = 100, editor = "number" }, + { id = "ScaleMember", category = "Object", default = "", editor = "text" }, + { id = "Opacity", category = "Object", default = 100, editor = "number", min = 0, max = 100, slider = true }, + { id = "ColorModifier", category = "Object", editor = "color", default = RGBA(100, 100, 100, 0), buttons = {{name = "Reset", func = "ResetColorModifier"}}}, + { id = "UseActorColorization", category = "Object", default = false, editor = "bool" }, + { id = "Source", category = "Placement", default = "Actor", editor = "dropdownlist", items = { "Actor", "ActorParent", "ActorOwner", "Target", "ActionPos" } }, + { id = "Spot", category = "Placement", default = "Origin", editor = "combo", items = function(fx) return ActionFXSpotCombo(fx) end }, + { id = "Attach", category = "Placement", default = false, editor = "bool", help = "Set true if the object should move with the source" }, + { id = "Offset", category = "Placement", default = point30, editor = "point", scale = "m" }, + { id = "OffsetDir", category = "Placement", default = "SourceAxisX", editor = "dropdownlist", items = function(fx) return ActionFXOrientationCombo end }, + { id = "Orientation", category = "Placement", default = "", editor = "dropdownlist", items = function(fx) return ActionFXOrientationCombo end }, + { id = "PresetOrientationAngle", category = "Placement", default = 0, editor = "number", }, + { id = "OrientationAxis", category = "Placement", default = 1, editor = "dropdownlist", items = function() return OrientationAxisCombo end }, + { id = "AlwaysVisible", category = "Object", default = false, editor = "bool", }, + + { id = "anim_type", name = "Pick frame by", editor = "choice", items = function() return AnimatedTextureObjectTypes end, default = 0, help = "UV Scroll Animation playback type" }, + { id = "anim_speed", name = "Speed Multiplier", editor = "number", max = 4095, min = 0, default = 1000, help = "UV Scroll Animation playback speed" }, + { id = "sequence_time_remap", name = "Sequence time", editor = "curve4", max = 63, scale = 63, max_x = 15, scale_x = 15, default = MakeLine(0, 63, 15), help = "UV Scroll Animation playback time curve" }, + + { id = "SortPriority", category = "Object", default = 0, editor = "number", min = -4, max = 3, no_edit = function(o) return not IsKindOf(rawget(_G, o.Object), "Decal") end }, + }, + fx_type = "Object", + variations_props = { "Object", "Object2", "Object3", "Object4" }, + DocumentationLink = "Docs/ModItemActionFXObject.md.html", + Documentation = ActionFX.Documentation .. "\n\nThis mod item creates and places an object when an FX action is triggered. Inherits ActionFX. Read ActionFX first for the common properties." +} + +function ActionFXObject:SetObject(value) + self.Object = value + local cls = g_Classes[self.Object] + local entity = cls and cls:GetEntity() + local anim = self.Animation + if (entity or "") == "" or not IsValidEntity(entity) or not HasState(entity, anim) or IsErrorState(entity, anim) then + anim = "idle" + end + self.Animation = anim +end + +function ActionFXObject:PlayFX(actor, target, action_pos, action_dir) + local count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + printf("FX Object %s has invalid source: %s", self.Object, self.Source) + return + end + local fx, wait_anim, wait_time, duration + if obj and self.Flags == "SyncWithParent" and self.AnimationPhase > obj:GetAnimPhase() then + wait_anim = obj:GetAnim(1) + wait_time = obj:TimeToPhase(1, self.AnimationPhase) + end + if self.Delay <= 0 and (wait_time or 0) <= 0 then + fx = self:PlaceFXObject(count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if not fx then return end + self:TrackObject(fx, actor, target, action_pos, action_dir) + duration = self.Time + self.AnimationLoops * fx:GetAnimDuration() + if duration <= 0 then + return + end + end + local thread = self:CreateThread(function(self, fx, wait_anim, wait_time, duration, actor, target, count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if self.Delay > 0 then + Sleep(self.Delay) + end + if wait_time and IsValid(obj) and obj:GetAnim(1) == wait_anim then + if not obj:IsKindOf("StateObject") then + Sleep(wait_time) + elseif not obj:WaitPhase(self.AnimationPhase) then + return + end + if not IsValid(obj) or obj:GetAnim(1) ~= wait_anim then + return + end + end + if not fx then + fx = self:PlaceFXObject(count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if not fx then return end + self:TrackObject(fx, actor, target, action_pos, action_dir) + duration = self.Time + self.AnimationLoops * fx:GetAnimDuration() + if duration <= 0 then + return + end + end + local fadeout = self.FadeOut > 0 and Min(duration, self.FadeOut) or 0 + Sleep(duration-fadeout) + if not IsValid(fx) then return end + if fx == self:GetAssignedFX(actor, target) then + self:AssignFX(actor, target, nil) + end + if fadeout > 0 then + if fx:GetOpacity() > 0 then + fx:SetOpacity(0, fadeout) + end + Sleep(fadeout) + end + DoneObject(fx) + end, self, fx, wait_anim, wait_time, duration, actor, target, count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if not fx and self:TrackFX() then + self:DestroyFX(actor, target) + self:AssignFX(actor, target, thread) + end +end + +function ActionFXObject:GetMaxColorizationMaterials() + return const.MaxColorizationMaterials +end + +function ActionFXObject:PlaceFXObject(count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + if self.Attach and (not obj or not IsValid(obj)) then + return + end + if count == 1 then + return self:PlaceSingleFXObject(obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + end + local list + for i = 0, count - 1 do + local o = self:PlaceSingleFXObject(obj, unpack_params(spot, 8*i+1, 8*i+8)) + if o then + list = list or {} + table.insert(list, o) + end + end + return list +end + +function ActionFXObject:CreateSingleFXObject(components) + local name = self:GetVariation(self.variations_props) + return PlaceObject(name, nil, components) +end + +function ActionFXObject:PlaceSingleFXObject(obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + local components = const.cofComponentAnim | const.cofComponentColorizationMaterial + if obj and self.Attach then + components = components | const.cofComponentAttach + end + if self.FadeIn > 0 or self.FadeOut > 0 then + components = components | const.cofComponentInterpolation + end + local fx = self:CreateSingleFXObject(components) + if not fx then + return + end + NetTempObject(fx) + fx:SetColorModifier(self.ColorModifier) + + local fx_scm = fx.SetColorizationMaterial + local color_src = self.UseActorColorization and obj and obj:GetMaxColorizationMaterials() > 0 and obj or self + fx_scm(fx, 1, color_src:GetEditableColor1(), color_src:GetEditableRoughness1(), color_src:GetEditableMetallic1()) + fx_scm(fx, 2, color_src:GetEditableColor2(), color_src:GetEditableRoughness2(), color_src:GetEditableMetallic2()) + fx_scm(fx, 3, color_src:GetEditableColor3(), color_src:GetEditableRoughness3(), color_src:GetEditableMetallic3()) + + local scale + local scale_member = self.ScaleMember + if scale_member ~= "" and IsValid(obj) and obj:HasMember(scale_member) then + scale = obj[scale_member] + if type(scale) == "function" then + scale = scale(obj) + if type(scale) ~= "number" then + assert(false, "invalid return value from ScaleMember function, scale will be set to 100") + scale = 100 + end + end + end + scale = scale or self.Scale + fx:SetScale(scale) + fx:SetState(self.Animation, 0, 0) + if self.Flags == "OnGroundTiltByGround" then + fx:SetAnim(1, self.Animation, const.eOnGround + const.eTiltByGround, 0) + end + fx:SetAnimPhase(1, self.AnimationPhase) + if self.Flags == "Mirrored" then + fx:SetMirrored(true) + elseif self.Flags == "LockedOrientation" then + fx:SetGameFlags(const.gofLockedOrientation) + elseif self.Flags == "OnGround" or self.Flags == "OnGroundTiltByGround" then + fx:SetGameFlags(const.gofAttachedOnGround) + elseif self.Flags == "SyncWithParent" then + fx:SetGameFlags(const.gofSyncState) + end + if self.AlwaysVisible then + fx:SetGameFlags(const.gofAlwaysRenderable) + end + if not self.GameTime or self.Attach and obj:GetGameFlags(const.gofRealTimeAnim) ~= 0 then + fx:SetGameFlags(const.gofRealTimeAnim) + end + if self.FadeIn > 0 then + fx:SetOpacity(0) + fx:SetOpacity(self.Opacity, self.FadeIn) + else + fx:SetOpacity(self.Opacity) + end + if self.SortPriority ~= 0 and fx:IsKindOf("Decal") then + fx:Setsort_priority(self.SortPriority) + end + FXOrient(fx, posx, posy, posz, obj, spot, self.Attach, axisx, axisy, axisz, angle, self.Offset) + + if IsKindOf(fx, "AnimatedTextureObject") then + fx:Setanim_speed(self.anim_speed) + fx:Setanim_type(self.anim_type) + fx:Setsequence_time_remap(self.sequence_time_remap) + end + return fx +end + +function ActionFXObject:TrackObject(fx, actor, target, action_pos, action_dir) + if self:TrackFX() then + self:AssignFX(actor, target, fx) + end + if self.Behavior ~= "" and self.BehaviorMoment == "" then + self[self.Behavior](self, actor, target, action_pos, action_dir) + end +end + +function ActionFXObject:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValidThread(fx) then + DeleteThread(fx) + elseif IsValid(fx) then + local fadeout = self.FadeOut + if fadeout <= 0 then + DoneObject(fx) + else + fx:SetOpacity(0, fadeout) + self:CreateThread(function(self, fx) + Sleep(self.FadeOut) + DoneObject(fx) + end, self, fx) + end + elseif type(fx) == "table" and not getmetatable(fx) then + local fadeout = self.FadeOut + if fadeout <= 0 then + DoneObjects(fx) + else + for _, o in ipairs(fx) do + if IsValid(o) then + o:SetOpacity(0, fadeout) + end + end + self:CreateThread(function(self, fx) + Sleep(self.FadeOut) + DoneObjects(fx) + end, self, fx) + end + end +end + +function ActionFXObject:BehaviorDetach(actor, target) + local fx = self:GetAssignedFX(actor, target) + if not fx then + return + elseif IsValid(fx) then + PreciseDetachObj(fx) + elseif IsValidThread(fx) then + printf("FX Object %s Detach Behavior can not be run before the object is placed (Delay %d is very large)", self.Object, self.Delay) + end +end + +DefineClass.ActionFXPassTypeObject = { + __parents = { "ActionFXObject" }, + properties = { + { id = "Object", editor = false, default = "PassTypeMarker", }, + { id = "Object2", editor = false, }, + { id = "Object3", editor = false, }, + { id = "Object4", editor = false, }, + { id = "Chance", editor = false, }, + { category = "Pass Type", id = "pass_type_radius", name = "Pass Radius", editor = "number", default = 0, scale = "m" }, + { category = "Pass Type", id = "pass_type_name", name = "Pass Type", editor = "choice", default = false, items = function() return PassTypesCombo end, }, + }, + fx_type = "Pass Type Object", + Chance = 100, +} + +function ActionFXPassTypeObject:CreateSingleFXObject(components) + return PlaceObject(self.Object, { + PassTypeRadius = self.pass_type_radius, + PassTypeName = self.pass_type_name, + }, components) +end + +function ActionFXPassTypeObject:PlaceSingleFXObject(obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + assert(not IsAsyncCode() or IsEditorActive()) + local pass_type_fx = ActionFXObject.PlaceSingleFXObject(self, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz) + if not pass_type_fx then return end + if not pass_type_fx:IsValidPos() then + DoneObject(pass_type_fx) + return + end + local x, y, z = pass_type_fx:GetPosXYZ() + local max_below = guim + local max_above = guim + z = terrain.FindPassableZ(pass_type_fx, 0, max_below, max_above) + pass_type_fx:MakeSync() + pass_type_fx:SetPos(x, y, z) + pass_type_fx:SetCostRadius() + return pass_type_fx +end + +function TestActionFXObject(editor_obj, fx, prop_id) + TestActionFXObjectEnd() + local obj = PlaceObject(fx.Object) + if not obj then + return + end + LastTestActionFXObject = obj + obj:SetScale(fx.Scale) + obj:SetState(fx.Animation, 0, 0) + if fx.Orientation == "OnGroundTiltByGround" then + obj:SetAnim(1, fx.Animation, const.eOnGround + const.eTiltByGround, 0) + end + obj:SetAnimPhase(1, fx.AnimationPhase) + if fx.Flags == "Mirrored" then + obj:SetMirrored(true) + elseif fx.Flags == "OnGround" or fx.Flags == "OnGroundTiltByGround" then + obj:SetGameFlags(const.gofAttachedOnGround) + end + if fx.FadeIn > 0 then + obj:SetOpacity(0) + obj:SetOpacity(100, fx.FadeIn) + end + local time = fx.Time > 0 and fx.Time or 0 + if fx.AnimationLoops > 0 then + time = time + fx.AnimationLoops * obj:GetAnimDuration() + end + local eye_pos, look_at + if camera3p.IsActive() then + eye_pos, look_at = camera.GetEye(), camera3p.GetLookAt() + elseif cameraMax.IsActive() then + eye_pos, look_at = cameraMax.GetPosLookAt() + else + look_at = GetTerrainGamepadCursor() + end + local posx, posy, posz = look_at:xyz() + FXOrient(obj, posx, posy, posz) + if time <= 0 then time = fx.FadeIn + fx.FadeOut + 2000 end + fx:CreateThread(function(fx, obj, time) + if fx.FadeOut > 0 then + local t = Min(time, fx.FadeOut) + Sleep(time-t) + if IsValid(obj) and t > 0 then + obj:SetOpacity(0, t) + Sleep(t) + end + else + Sleep(time) + end + TestActionFXObjectEnd(obj) + end, fx, obj, time) +end + +-------- Backwards compat ---------- +DefineClass.ActionFXDecal = { + __parents = { "ActionFXObject" }, + fx_type = "Decal", + DocumentationLink = "Docs/ModItemActionFXDecal.md.html", + Documentation = ActionFX.Documentation .. "\n\nPlaces a decal when an FX action is triggered. Inherits ActionFX. Read ActionFX first for the common properties." +} + +--============================= FX Controller Rumble ======================= + +local function AddValuesInComboTexts(values) + local list = {} + for k, v in pairs(values) do + list[#list+1] = k + end + table.sort(list, function(a,b) return values[a] < values[b] end) + local res = {} + for i = 1, #list do + list[i] = { text = string.format("%s : %d", list[i], values[list[i]]), value = list[i] } + end + return list +end + +DefineClass.ActionFXControllerRumble = { + __parents = { "ActionFX" }, + properties = { + { id = "Power", category = "Vibration", default = "Medium", editor = "combo", items = function(fx) return AddValuesInComboTexts(fx.powers) end, help = "Controller left and right motors speed", buttons = {{name = "Test", func = "TestActionFXControllerRumble"}}}, + { id = "Duration", category = "Vibration", default = "Medium", editor = "combo", items = function(fx) return AddValuesInComboTexts(fx.durations) end, help = "Vibration duration in game time" }, + { id = "Controller", category = "Vibration", default = "Actor", editor = "dropdownlist", items = { "Actor", "Target" }, help = "Whose controller should vibrate" }, + { id = "GameTime", editor = false }, + }, + powers = { + Slight = 6000, + Light = 16000, + Medium = 24000, + FullSpeed = 65535, + }, + durations = { + Short = 125, + Medium = 230, + }, + fx_type = "Controller Rumble", +} + +if FirstLoad then + ControllerRumbleThreads = {} +end + +local function StopControllersRumble() + for i = #ControllerRumbleThreads, 0, -1 do + if ControllerRumbleThreads[i] then + DeleteThread(ControllerRumbleThreads[i]) + ControllerRumbleThreads[i] = nil + XInput.SetRumble(i, 0, 0) + end + end +end + +OnMsg.MsgPreControllersAssign = StopControllersRumble +OnMsg.DoneMap = StopControllersRumble +OnMsg.Pause = StopControllersRumble + +function ControllerRumble(controller_id, duration, power_left, power_right) + if not GetAccountStorageOptionValue("ControllerRumble") or not duration or duration <= 0 then + power_left = 0 + power_right = 0 + end + XInput.SetRumble(controller_id, power_left, power_right) + DeleteThread(ControllerRumbleThreads[controller_id]) + ControllerRumbleThreads[controller_id] = nil + if power_left > 0 or power_right > 0 then + ControllerRumbleThreads[controller_id] = CreateRealTimeThread(function(controller_id, duration) + Sleep(duration or 230) + XInput.SetRumble(controller_id, 0, 0) + ControllerRumbleThreads[controller_id] = nil + end, controller_id, duration) + end +end + +function ActionFXControllerRumble:PlayFX(actor, target, action_pos, action_dir) + local obj + if self.Controller == "Actor" then + obj = IsValid(actor) and GetTopmostParent(actor) + elseif self.Controller == "Target" then + obj = IsValid(target) and target + end + if not obj then + printf("FX Rumble controller invalid source %s", self.Controller) + return + end + local controller_id + for loc_player = 1, LocalPlayersCount do + if obj == GetLocalHero(loc_player) or obj == PlayerControlObjects[loc_player] then + controller_id = GetActiveXboxControllerId(loc_player) + break + end + end + if controller_id then + self:VibrateController(controller_id, actor, target, action_pos, action_dir) + end +end + +function ActionFXControllerRumble:VibrateController(controller_id, ...) + if self.Behavior ~= "" and self.BehaviorMoment == "" then + self[self.Behavior](self, ...) + else + local power = self.powers[self.Power] or tonumber(self.Power) + local duration = self.durations[self.Duration] or tonumber(self.Duration) + ControllerRumble(controller_id, duration, power, power) + end +end + +function TestActionFXControllerRumble(editor_obj, fx, prop_id) + fx:VibrateController(0, "Test") +end + +--============================= FX Light ======================= + +DefineClass.ActionFXLight = { + __parents = { "ActionFX" }, + properties = { + { category = "Light", id = "Type", editor = "combo", default = "PointLight", items = { "PointLight", "PointLightFlicker", "SpotLight", "SpotLightFlicker" } }, + { category = "Light", id = "CastShadows", editor = "bool", default = false, }, + { category = "Light", id = "DetailedShadows", editor = "bool", default = false, }, + { category = "Light", id = "Color", editor = "color", default = RGB(255,255,255), buttons = {{name = "Test", func = "TestActionFXLight"}}, no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "Intensity", editor = "number", default = 100, min = 0, max = 255, slider = true, no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "Color0", editor = "color", default = RGB(255,255,255), buttons = {{name = "Test", func = "TestActionFXLight"}}, no_edit = function(self) return self.Type == "PointLight" or self.Type == "StopLight" end }, + { category = "Light", id = "Intensity0", editor = "number", default = 100, min = 0, max = 255, slider = true, no_edit = function(self) return self.Type == "PointLight" or self.Type == "StopLight" end }, + { category = "Light", id = "Color1", editor = "color", default = RGB(255,255,255), buttons = {{name = "Test", func = "TestActionFXLight"}}, no_edit = function(self) return self.Type == "PointLight" or self.Type == "StopLight" end }, + { category = "Light", id = "Intensity1", editor = "number", default = 100, min = 0, max = 255, slider = true, no_edit = function(self) return self.Type == "PointLight" or self.Type == "StopLight" end }, + { category = "Light", id = "Period", editor = "number", default = 40000, min = 0, max = 100000, scale = 1000, slider = true, no_edit = function(self) return self.Type == "PointLight" or self.Type == "StopLight" end }, + { category = "Light", id = "Radius", editor = "number", default = 20, min = 0, max = 500*guim, color = RGB(255,50,50), color2 = RGB(50,50,255), slider = true, scale = "m" }, + { category = "Light", id = "FadeIn", editor = "number", default = 0, no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "StartIntensity", editor = "number", default = 0, min = 0, max = 255, slider = true, no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "StartColor", editor = "color", default = RGB(0,0,0), no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "FadeOut", editor = "number", default = 0, no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "FadeOutIntensity", editor = "number", default = 0, min = 0, max = 255, slider = true, no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "FadeOutColor", editor = "color", default = RGB(0,0,0), no_edit = function(self) return self.Type ~= "PointLight" and self.Type ~= "SpotLight" end }, + { category = "Light", id = "ConeInnerAngle", editor = "number", default = 45, min = 5, max = (180 - 5), slider = true, no_edit = function(self) return self.Type ~= "SpotLight" and self.Type ~= "SpotLightFlicker" end }, + { category = "Light", id = "ConeOuterAngle", editor = "number", default = 45, min = 5, max = (180 - 5), slider = true, no_edit = function(self) return self.Type ~= "SpotLight" and self.Type ~= "SpotLightFlicker" end }, + { category = "Light", id = "LookAngle", editor = "number", default = 0, min = 0, max = 360*60 - 1, slider = true, scale = "deg", no_edit = function(self) return self.Type ~= "SpotLight" and self.Type ~= "SpotLightFlicker" end, }, + { category = "Light", id = "LookAxis", editor = "point", default = axis_z, scale = 4096, no_edit = function(self) return self.Type ~= "SpotLight" and self.Type ~= "SpotLightFlicker" end, }, + { category = "Light", id = "Interior", editor = "bool", default = true, }, + { category = "Light", id = "Exterior", editor = "bool", default = true, }, + { category = "Light", id = "InteriorAndExteriorWhenHasShadowmap", editor = "bool", default = true, }, + { category = "Light", id = "Always Renderable",editor = "bool", default = false }, + { category = "Light", id = "SourceRadius", name = "Source Radius (cm)", editor = "number", min = guic, max=20*guim, default = 10*guic, scale = guic, slider = true, color = RGB(200, 200, 0), autoattach_prop = true, help = "Radius of the light source in cm." }, + + { category = "Placement", id = "Source", editor = "dropdownlist", default = "Actor", items = { "Actor", "ActorParent", "ActorOwner", "Target", "ActionPos" } }, + { category = "Placement", id = "Spot", editor = "combo", default = "Origin", items = function(fx) return ActionFXSpotCombo(fx) end }, + { category = "Placement", id = "Attach", editor = "bool", default = false, help = "Set true if the decal should move with the source" }, + { category = "Placement", id = "Offset", editor = "point", default = point30, scale = "m" }, + { category = "Placement", id = "OffsetDir", editor = "dropdownlist", default = "SourceAxisX", items = function(fx) return ActionFXOrientationCombo end }, + { category = "Placement", id = "Helper", editor = "bool", default = false, dont_save = true }, + }, + fx_type = "Light", + DocumentationLink = "Docs/ModItemActionFXLight.md.html", + Documentation = ActionFX.Documentation .. "\n\nThis mod item places light sources when an FX action is triggered. Inherits ActionFX. Read ActionFX first for the common properties." +} + +function ActionFXLight:PlayFX(actor, target, action_pos, action_dir) + local count, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz = self:GetLoc(actor, target, action_pos, action_dir) + if count == 0 then + printf("FX Light has invalid source: %s", self.Source) + return + end + local fx + if self.Delay <= 0 then + fx = self:PlaceFXLight(actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if not fx or self.Time <= 0 then + return + end + end + local thread = self:CreateThread(function(self, fx, actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if self.Delay > 0 then + Sleep(self.Delay) + fx = self:PlaceFXLight(actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if not fx or self.Time <= 0 then + return + end + end + local fadeout = (self.Type ~= "PointLightFlicker" and self.Type ~= "PointLightFlicker") and self.FadeOut > 0 and Min(self.Time, self.FadeOut) or 0 + Sleep(self.Time-fadeout) + if not IsValid(fx) then return end + if fx == self:GetAssignedFX(actor, target) then + self:AssignFX(actor, target, nil) + end + if fadeout > 0 then + fx:Fade(self.FadeOutColor, self.FadeOutIntensity, fadeout) + Sleep(fadeout) + end + DoneObject(fx) + end, self, fx, actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if not fx and self:TrackFX() then + self:DestroyFX(actor, target) + self:AssignFX(actor, target, thread) + end +end + +function ActionFXLight:PlaceFXLight(actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + if self.Attach and not IsValid(obj) then + return + end + local fx = PlaceObject(self.Type) + NetTempObject(fx) + fx:SetCastShadows(self.CastShadows) + fx:SetDetailedShadows(self.DetailedShadows) + fx:SetAttenuationRadius(self.Radius) + fx:SetInterior(self.Interior) + fx:SetExterior(self.Exterior) + fx:SetInteriorAndExteriorWhenHasShadowmap(self.InteriorAndExteriorWhenHasShadowmap) + if self.AlwaysRenderable then + fx:SetGameFlags(const.gofAlwaysRenderable) + end + local detail_level = table.find_value(ActionFXDetailLevel, "value", self.DetailLevel) + if not detail_level then + local max_lower, min + for _, detail in ipairs(ActionFXDetailLevel) do + min = (not min or detail.value < min.value) and detail or min + if detail.value <= self.DetailLevel and (not max_lower or max_lower.value < detail.value) then + max_lower = detail + end + end + detail_level = max_lower or min + end + fx:SetDetailClass(detail_level.text) + if self.Helper then + fx:Attach(PointLight:new(), fx:GetSpotBeginIndex(self.Spot)) + end + FXOrient(fx, posx, posy, posz, obj, spot, self.Attach, axisx, axisy, axisz, angle, self.Offset) + if self.GameTime then + fx:ClearGameFlags(const.gofRealTimeAnim) + end + if self.Type == "PointLight" or self.Type == "PointLightFlicker" then + fx:SetSourceRadius(self.SourceRadius) + end + if self.Type == "SpotLight" or self.Type == "SpotLightFlicker" then + fx:SetConeOuterAngle(self.ConeOuterAngle) + fx:SetConeInnerAngle(self.ConeInnerAngle) + fx:SetAxis(self.LookAxis) + fx:SetAngle(self.LookAngle) + end + if self.Type == "PointLightFlicker" or self.Type == "SpotLightFlicker" then + fx:SetColor0(self.Color0) + fx:SetIntensity0(self.Intensity0) + fx:SetColor1(self.Color1) + fx:SetIntensity1(self.Intensity1) + fx:SetPeriod(self.Period) + elseif self.FadeIn > 0 then + fx:SetColor(self.StartColor) + fx:SetIntensity(self.StartIntensity) + fx:Fade(self.Color, self.Intensity, self.FadeIn) + else + fx:SetColor(self.Color) + fx:SetIntensity(self.Intensity) + end + if self:TrackFX() then + self:AssignFX(actor, target, fx) + end + if self.Behavior ~= "" and self.BehaviorMoment == "" then + self[self.Behavior](self, actor, target, action_pos, action_dir) + end + self:OnLightPlaced(fx, actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + return fx +end + +function ActionFXLight:OnLightPlaced(fx, actor, target, obj, spot, posx, posy, posz, angle, axisx, axisy, axisz, action_pos, action_dir) + --project specific cb +end + +function ActionFXLight:OnLightDone(fx) + --project specific cb +end + +function ActionFXLight:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValid(fx) then + if (self.Type ~= "PointLightFlicker" and self.Type ~= "SpotLightFlicker") and self.FadeOut > 0 then + fx:Fade(self.FadeOutColor, self.FadeOutIntensity, self.FadeOut) + self:CreateThread(function(self, fx) + Sleep(self.FadeOut) + DoneObject(fx) + self:OnLightDone(fx) + end, self, fx) + else + DoneObject(fx) + self:OnLightDone(fx) + end + elseif IsValidThread(fx) then + DeleteThread(fx) + end +end + +function ActionFXLight:BehaviorDetach(actor, target) + local fx = self:GetAssignedFX(actor, target) + if not fx then return end + if IsValidThread(fx) then + printf("FX Light Detach Behavior can not be run before the light is placed (Delay %d is very large)", self.Delay) + elseif IsValid(fx) then + PreciseDetachObj(fx) + end +end + +function TestActionFXLight(editor_obj, fx, prop_id) + TestActionFXObjectEnd() + if (fx[prop_id] or "") == "" then + return + end + local obj = PlaceObject("PointLight") + if not obj then + return + end + LastTestActionFXObject = obj + local eye_pos, look_at + if camera3p.IsActive() then + eye_pos, look_at = camera.GetEye(), camera3p.GetLookAt() + elseif cameraMax.IsActive() then + eye_pos, look_at = cameraMax.GetPosLookAt() + else + look_at = GetTerrainGamepadCursor() + end + look_at = look_at:SetZ(terrain.GetHeight(look_at)+2*guim) + local posx, posy = look_at:xy() + local posz = terrain.GetHeight(look_at) + 2*guim + FXOrient(obj, posx, posy, posz) + obj:SetCastShadows(fx.CastShadows) + obj:SetDetailedShadows(fx.DetailedShadows) + obj:SetAttenuationRadius(fx.Radius) + obj:SetInterior(fx.Interior) + obj:SetExterior(fx.Exterior) + obj:SetInteriorAndExteriorWhenHasShadowmap(fx.InteriorAndExteriorWhenHasShadowmap) + if self.AlwaysRenderable then + obj:SetGameFlags(const.gofAlwaysRenderable) + end + if fx.Type == "PointLightFlicker" or fx.Type == "SpotLightFlicker" then + obj:SetColor0(fx.Color0) + obj:SetIntensity0(fx.Intensity0) + obj:SetColor1(fx.Color1) + obj:SetIntensity1(fx.Intensity1) + obj:SetPeriod(fx.Period) + elseif fx.FadeIn > 0 then + obj:SetColor(fx.StartColor) + obj:SetIntensity(fx.StartIntensity) + obj:Fade(fx.Color, fx.Intensity, fx.FadeIn) + else + obj:SetColor(fx.Color) + obj:SetIntensity(fx.Intensity) + end + if fx.Time >= 0 then + self:CreateThread(function(fx, obj) + local time = fx.Time + if fx.FadeOut > 0 then + local t = Min(time, fx.FadeOut) + Sleep(time-t) + if IsValid(obj) then + obj:Fade(fx.FadeOutColor, fx.FadeOutIntensity, t) + Sleep(t) + end + else + Sleep(time) + end + TestActionFXObjectEnd(obj) + end, fx, obj) + end +end + +--============================= FX Colorization ======================= + +DefineClass.ActionFXColorization = { + __parents = { "ActionFX" }, + properties = { + { id = "Color1", category = "Colorization", editor = "color", default = RGB(255,255,255) }, + { id = "Color2_Enable", category = "Colorization", editor = "bool", default = false }, + { id = "Color2", category = "Colorization", editor = "color", default = RGB(255,255,255), read_only = function(self) return not self.Color2_Enable end }, + { id = "Color3_Enable", category = "Colorization", editor = "bool", default = false }, + { id = "Color3", category = "Colorization", editor = "color", default = RGB(255,255,255), read_only = function(self) return not self.Color3_Enable end }, + { id = "Color4_Enable", category = "Colorization", editor = "bool", default = false }, + { id = "Color4", category = "Colorization", editor = "color", default = RGB(255,255,255), read_only = function(self) return not self.Color4_Enable end }, + { id = "Source", category = "Colorization", default = "Actor", editor = "dropdownlist", items = { "Actor", "ActorParent", "ActorOwner", "Target" } }, + }, + fx_type = "Colorization", +} + +_ColorizationFunc = function(self, color_modifier, actor, target, obj) + if self.Delay > 0 then + Sleep(self.Delay) + end + local fx = PlaceFX_Colorization(obj, color_modifier) + if self:TrackFX() then + self:AssignFX(actor, target, fx) + end + if fx and self.Time > 0 then + Sleep(self.Time) + RemoveFX_Colorization(obj, fx) + end +end + +function ActionFXColorization:PlayFX(actor, target) + local obj = self:GetLocObj(actor, target) + if not IsValid(obj) then + printf("FX Colorization has invalid object: %s", self.Source) + return + end + local color_modifier = self:ChooseColor() + if self.Delay <= 0 and self.Time <= 0 then + local fx = PlaceFX_Colorization(obj, color_modifier) + if fx and self:TrackFX() then + self:AssignFX(actor, target, fx) + end + return + end + local thread = self:CreateThread(_ColorizationFunc, self, color_modifier, actor, target, obj) + if self:TrackFX() then + self:DestroyFX(actor, target) + self:AssignFX(actor, target, thread) + end +end + +function ActionFXColorization:DestroyFX(actor, target) + local fx = self:AssignFX(actor, target, nil) + if not fx then + return + elseif IsValidThread(fx) then + DeleteThread(fx) + else + local obj = self:GetLocObj(actor, target) + RemoveFX_Colorization(obj, fx) + end +end + +function ActionFXColorization:ChooseColor() + local color_variations = 1 + if self.Color2_Enable then color_variations = color_variations + 1 end + if self.Color3_Enable then color_variations = color_variations + 1 end + if self.Color4_Enable then color_variations = color_variations + 1 end + if color_variations == 1 then + return self.Color1 + end + local idx = AsyncRand(color_variations) + if idx == 0 then + return self.Color1 + end + if self.Color2_Enable then + idx = idx - 1 + if idx == 0 then + return self.Color2 + end + end + if self.Color3_Enable then + idx = idx - 1 + if idx == 0 then + return self.Color3 + end + end + return self.Color4 +end + +DefineClass.ActionFXInitialColorization = { + __parents = { "ActionFXColorization" }, + fx_type = "ColorizationInitial", + properties = { + { id = "Target", }, + { id = "Delay", }, + { id = "Id", }, + { id = "Disabled", }, + { id = "Time", }, + { id = "EndRules", }, + { id = "Behavior", }, + { id = "BehaviorMoment", }, + }, +} + +local default_color_modifier = RGBA(100, 100, 100, 0) + +function ActionFXInitialColorization:PlayFX(actor, target, action_pos, action_dir) + local obj = self:GetLocObj(actor, target) + if not IsValid(obj) then + printf("FX Colorization has invalid object: %s", self.Source) + return + end + if obj:GetColorModifier() == default_color_modifier then + local color = self:ChooseColor() + obj:SetColorModifier(color) + end +end + +MapVar("fx_colorization", {}, weak_keys_meta) + +function PlaceFX_Colorization(obj, color_modifier) + if not IsValid(obj) then + return + end + local fx = { color_modifier } + local list = fx_colorization[obj] + if not list then + list = { obj:GetColorModifier() } + fx_colorization[obj] = list + end + table.insert(list, fx) + obj:SetColorModifier(color_modifier) + return fx +end + +function RemoveFX_Colorization(obj, fx) + local list = fx_colorization[obj] + if not list then return end + if not IsValid(obj) then + fx_colorization[obj] = nil + return + end + local len = #list + if list[len] ~= fx then + table.remove_value(list, fx) + elseif len == 2 then + fx_colorization[obj] = nil + obj:SetColorModifier(list[1]) + else + list[len] = nil + obj:SetColorModifier(list[len-1][1]) + end +end + + +--============================= + +DefineClass.SpawnFXObject = { + __parents = { "Object", "ComponentAttach" }, + __hierarchy_cache = true, + + fx_actor_base_class = "", +} + +function SpawnFXObject:GameInit() + PlayFX("Spawn", "start", self) +end + +function SpawnFXObject:Done() + if IsValid(self) and self:IsValidPos() then + PlayFX("Spawn", "end", self) + end +end + +function OnMsg.GatherFXActions(list) + table.insert(list, "Spawn") +end + +--============================= + +function OnMsg.OptionsApply() + FXCache = false +end + +function GetPlayFXList(actionFXClass, actionFXMoment, actorFXClass, targetFXClass, list) + local remove_ids + local inherit_actions = actionFXClass and (FXInheritRules_Actions or RebuildFXInheritActionRules())[actionFXClass] + local inherit_moments = actionFXMoment and (FXInheritRules_Moments or RebuildFXInheritMomentRules())[actionFXMoment] + local inherit_actors = actorFXClass and (FXInheritRules_Actors or RebuildFXInheritActorRules() )[actorFXClass] + local inherit_targets = targetFXClass and (FXInheritRules_Actors or RebuildFXInheritActorRules() )[targetFXClass] + local i, action = 0, actionFXClass + while true do + local rules = action and FXRules[action] + if rules then + local i, moment = 0, actionFXMoment + while true do + local rules = moment and rules[moment] + if rules then + local i, actor = 0, actorFXClass + while true do + local rules = actor and rules[actor] + if rules then + local i, target = 0, targetFXClass + while true do + local rules = target and rules[target] + if rules then + for i = 1, #rules do + local fx = rules[i] + if not fx.Disabled and fx.Chance > 0 and + fx.DetailLevel >= hr.FXDetailThreshold and + (not IsKindOf(fx, "ActionFX") or MatchGameState(fx.GameStatesFilter)) + then + if fx.fx_type == "FX Remove" then + if fx.FxId ~= "" then + remove_ids = remove_ids or {} + remove_ids[fx.FxId] = "remove" + end + elseif fx.Action == "any" and fx.Moment == "any" then + -- invalid, probably just created FX + else + list = list or {} + list[#list+1] = fx + end + end + end + end + if target == "any" then break end + i = i + 1 + target = inherit_targets and inherit_targets[i] or "any" + end + end + if actor == "any" then break end + i = i + 1 + actor = inherit_actors and inherit_actors[i] or "any" + end + end + if moment == "any" then break end + i = i + 1 + moment = inherit_moments and inherit_moments[i] or "any" + end + end + if action == "any" then break end + i = i + 1 + action = inherit_actions and inherit_actions[i] or "any" + end + if list and remove_ids then + for i = #list, 1, -1 do + if remove_ids[list[i].FxId] == "remove" then + table.remove(list, i) + if i == 1 and #list == 0 then + list = nil + end + end + end + end + return list +end + +if Platform.developer then +local old_GetPlayFXList = GetPlayFXList +function GetPlayFXList(...) + local list = old_GetPlayFXList(...) + if g_SoloFX_count > 0 and list then + for i = #list, 1, -1 do + local fx = list[i] + local solo + if fx.class == "ActionFXBehavior" then + solo = fx.fx.Solo + else + solo = fx.Solo + end + if solo then + table.remove(list, i) + end + end + end + + return list +end +end + +local function ListCopyMembersOnce(list, added, source, member) + if not source then return end + for i = 1, #source do + local v = source[i][member] + if not added[v] then + added[v] = true + list[#list+1] = v + end + end +end + +local function ListCopyOnce(list, added, source) + if not source then return end + for i = 1, #source do + local v = source[i] + if not added[v] then + added[v] = true + list[#list+1] = v + end + end +end + +StaticFXActionsCache = false + +function GetStaticFXActionsCached() + if StaticFXActionsCache then + return StaticFXActionsCache + end + + local list = {} + Msg("GatherFXActions", list) + local added = { any = true, [""] = true } + for i = #list, 1, -1 do + if not added[list[i]] then + added[list[i]] = true + else + list[i], list[#list] = list[#list], nil + end + end + ListCopyMembersOnce(list, added, FXLists.ActionFXInherit_Action, "Action") + ClassDescendants("FXObject", function(classname, class) + if class.fx_action_base then + local name = class.fx_action or classname + if not added[name] then + list[#list+1] = name + added[name] = true + end + end + end) + table.sort(list, CmpLower) + table.insert(list, 1, "any") + StaticFXActionsCache = list + return StaticFXActionsCache +end + +function ActionFXClassCombo(fx) + local list = {} + local entity = fx and rawget(fx, "AnimEntity") or "" + if IsValidEntity(entity) then + list[#list + 1] = "" + for _, anim in ipairs(GetStates(entity)) do + list[#list + 1] = FXAnimToAction(anim) + end + list[#list + 1] = "----------" + end + table.iappend(list, GetStaticFXActionsCached()) + return list +end + +function ActionMomentFXCombo(fx) + local default_list = { + "any", + "", + "start", + "end", + "hit", + "interrupted", + "recharge", + "new_target", + "target_lost", + "channeling-start", + "channeling-end", + } + local list = {} + local added = { any = true } + for i = 1, #default_list do + added[default_list[i]] = true + end + for classname, fxlist in pairs(FXLists) do + if g_Classes[classname]:HasMember("Moment") then + ListCopyMembersOnce(list, added, fxlist, "Moment") + end + end + local list2 = {} + Msg("GatherFXMoments", list2, fx) + ListCopyOnce(list, added, list2) + for i = 1, #default_list do + table.insert(list, i, default_list[i]) + end + added = {} + for i = #list, 1, -1 do + if not added[list[i]] then + added[list[i]] = true + else + list[i], list[#list] = list[#list], nil + end + end + table.sort(list, CmpLower) + return list +end + +local function GatherFXActors(list) + Msg("GatherFXActors", list) + local added = { any = true } + for i = #list, 1, -1 do + if not added[list[i]] then + added[list[i]] = true + else + list[i], list[#list] = list[#list], nil + end + end + ListCopyMembersOnce(list, added, FXLists.ActionFXInherit_Actor, "Actor") + ClassDescendants("FXObject", function(classname, class) + if class.fx_actor_base_class then + local name = class.fx_actor_class or classname + if name and not added[name] then + list[#list+1] = name + added[name] = true + end + end + end) + table.sort(list, CmpLower) + table.insert(list, 1, "any") +end + +StaticFXActorsCache = false + +function ActorFXClassCombo() + if not StaticFXActorsCache then + local list = {} + GatherFXActors(list) + StaticFXActorsCache = list + end + return StaticFXActorsCache +end + +StaticFXTargetsCache = false + +function TargetFXClassCombo() + if not StaticFXTargetsCache then + local list = {} + Msg("GatherFXTargets", list) + GatherFXActors(list) + table.insert(list, 2, "ignore") + StaticFXTargetsCache = list + end + return StaticFXTargetsCache +end + +function HookActionFXCombo(fx) + local actions = ActionFXClassCombo(fx) + table.remove_value(actions, "any") + table.insert(actions, 1, "") + return actions +end + +function HookMomentFXCombo(fx) + local actions = ActionMomentFXCombo(fx) + table.remove_value(actions, "any") + table.insert(actions, 1, "") + return actions +end + +function ActionMomentNamesCombo(fx) + local actions = ActionMomentFXCombo(fx) + table.remove_value(actions, "any") + table.remove_value(actions, "") + return actions +end + +local class_to_behavior_items + +function ActionFXBehaviorCombo(fx) + local class = fx.class + class_to_behavior_items = class_to_behavior_items or {} + local list = class_to_behavior_items[class] + if not list then + list = { { text = "Destroy", value = "DestroyFX" } } + for name, func in fx:__enum() do + if type(func) == "function" and type(name) == "string" then + local text + if string.starts_with(name, "Behavior") then + text = string.sub(name, 9) + end + if text then + list[#list + 1] = { text = text, value = name } + end + end + end + table.sort(list, function(a, b) return CmpLower(a.text, b.text) end) + table.insert(list, 1, { text = "", value = "" }) + class_to_behavior_items[class] = list + end + return list +end + +function ActionFXSpotCombo() + local list, added = {}, { Origin = true, [""] = true } + Msg("GatherFXSpots", list) + for i = #list, 1, -1 do + if not added[list[i]] then + added[list[i]] = true + else + list[i], list[#list] = list[#list], nil + end + end + for _, t1 in pairs(FXRules) do + for _, t2 in pairs(t1) do + for _, t3 in pairs(t2) do + for i = 1, #t3 do + local spot = rawget(t3[i], "Spot") + if spot and not added[spot] then + list[#list+1] = spot + added[spot] = true + end + end + end + end + end + table.sort(list, CmpLower) + table.insert(list, 1, "Origin") + return list +end + +function ActionFXSourcePropCombo() + local list, added = {}, { [""] = true } + for _, t1 in pairs(FXRules) do + for _, t2 in pairs(t1) do + for _, t3 in pairs(t2) do + for i = 1, #t3 do + local spot = rawget(t3[i], "SourceProp") + if spot and not added[spot] then + list[#list+1] = spot + added[spot] = true + end + end + end + end + end + table.sort(list, CmpLower) + return list +end + +DefineClass.CameraObj = { + __parents = { "SpawnFXObject", "CObject", "ComponentInterpolation" }, + entity = "InvisibleObject", + flags = { gofAlwaysRenderable = true, efSelectable = false, cofComponentCollider = false }, +} + +MapVar("g_CameraObj", function() + local cam = CameraObj:new() + cam:SetSpecialOrientation(const.soUseCameraTransform) + return cam +end) + +local IsValid = IsValid +local SnapToCamera + +function OnMsg.OnRender() + local obj = g_CameraObj + SnapToCamera = SnapToCamera or IsEditorActive() and empty_func or CObject.SnapToCamera + if IsValid(obj) then + SnapToCamera(obj) + end +end + +function OnMsg.GameEnterEditor() + if IsValid(g_CameraObj) then + g_CameraObj:ClearEnumFlags(const.efVisible) + end + SnapToCamera = empty_func +end + +function OnMsg.GameExitEditor() + if IsValid(g_CameraObj) then + g_CameraObj:SetEnumFlags(const.efVisible) + end + SnapToCamera = CObject.SnapToCamera +end + +if Platform.asserts then + +if FirstLoad then + ObjToSoundInfo = false +end +local ObjSoundErrorHash = false + +function OnMsg.ChangeMap() + ObjToSoundInfo = false + ObjSoundErrorHash = false +end + +local function GetFXInfo(fx) + return string.format("%s-%s-%s-%s", tostring(fx.Action), tostring(fx.Moment), tostring(fx.Actor), tostring(fx.Target)) +end +local function GetSoundInfo(fx, sound) + return string.format("'%s' from [%s]", sound, GetFXInfo(fx)) +end + +MarkObjSound = function(fx, obj, sound) + local time = RealTime() + GameTime() + + ObjToSoundInfo = ObjToSoundInfo or setmetatable({}, weak_keys_meta) + local info = ObjToSoundInfo[obj] + if not info then + ObjToSoundInfo[obj] = { sound, fx, time } + return + end + --print(gt, rt, GetSoundInfo(fx, sound)) + local prev_sound, prev_fx, prev_time = info[1], info[2], info[3] + if time == prev_time then + local sname, sbank, stype, shandle, sduration, stime = obj:GetSound() + if sbank == prev_sound then + local str = GetSoundInfo(fx, sound) + local str_prev = GetSoundInfo(prev_fx, prev_sound) + local err_hash = xxhash(str, str_prev) + ObjSoundErrorHash = ObjSoundErrorHash or {} + if not ObjSoundErrorHash[err_hash] then + ObjSoundErrorHash[err_hash] = err_hash + StoreErrorSource(obj, "Sound", str, "replaced", str_prev) + end + end + end + info[1], info[2], info[3] = sound, fx, time +end + +end -- Platform.asserts \ No newline at end of file diff --git a/CommonLua/Classes/AnimMomentHook.lua b/CommonLua/Classes/AnimMomentHook.lua new file mode 100644 index 0000000000000000000000000000000000000000..1356176a5d448cf9f7b80e618861d9a84bc71e50 --- /dev/null +++ b/CommonLua/Classes/AnimMomentHook.lua @@ -0,0 +1,409 @@ +DefineClass.AnimChangeHook = +{ + __parents = { "Object", "Movable" }, +} + +function AnimChangeHook:AnimationChanged(channel, old_anim, flags, crossfade) +end + +function AnimChangeHook:SetState(anim, flags, crossfade, ...) + local old_anim = self:GetStateText() + if IsValid(self) and self:IsAnimEnd() then + self:OnAnimMoment("end") + end + Object.SetState(self, anim, flags, crossfade, ...) + self:AnimationChanged(1, old_anim, flags, crossfade) +end + +local pfStep = pf.Step +local pfSleep = Sleep +function AnimChangeHook:Step(...) + local old_state = self:GetState() + local status, new_path = pfStep(self, ...) + if old_state ~= self:GetState() then + self:AnimationChanged(1, GetStateName(old_state), 0, nil) + end + return status, new_path +end + +function AnimChangeHook:SetAnim(channel, anim, flags, crossfade, ...) + local old_anim = self:GetStateText() + Object.SetAnim(self, channel, anim, flags, crossfade, ...) + self:AnimationChanged(channel, old_anim, flags, crossfade) +end + +-- AnimMomentHook +DefineClass.AnimMomentHook = +{ + __parents = { "AnimChangeHook" }, + anim_moments_hook = false, -- list with moments which have registered callback in the class + anim_moments_single_thread = false, -- if false every moment will have its own thread launched + anim_moments_hook_threads = false, + anim_moment_fx_target = false, +} + +function AnimMomentHook:Init() + self:StartAnimMomentHook() +end + +function AnimMomentHook:Done() + self:StopAnimMomentHook() +end + +function AnimMomentHook:IsStartedAnimMomentHook() + return self.anim_moments_hook_threads and true or false +end + +function AnimMomentHook:WaitAnimMoment(moment) + repeat + local t = self:TimeToMoment(1, moment) + local index = 1 + while t == 0 do + index = index + 1 + t = self:TimeToMoment(1, moment, index) + end + until not WaitWakeup(t) -- if someone wakes us up we need to measure again +end + +moment_hooks = {} + +function AnimMomentHook:OnAnimMoment(moment, anim) + anim = anim or GetStateName(self) + PlayFX(FXAnimToAction(anim), moment, self, self.anim_moment_fx_target or nil) + local anim_moments_hook = self.anim_moments_hook + if type(anim_moments_hook) == "table" and anim_moments_hook[moment] then + local method = moment_hooks[moment] + return self[method](self, anim) + end +end + +function WaitTrackMoments(obj, callback, ...) + callback = callback or obj.OnAnimMoment + local last_state, last_phase, state_name, time, moment + while true do + local state, phase = obj:GetState(), obj:GetAnimPhase() + if state ~= last_state then + state_name = GetStateName(state) + if phase == 0 then + callback(obj, "start", state_name, ...) + end + time = nil + end + last_state, last_phase = state, phase + if not time then + moment, time = obj:TimeToNextMoment(1, 1) + end + if time then + local time_to_end = obj:TimeToAnimEnd() + if time_to_end <= time then + if not WaitWakeup(time_to_end) then + assert(IsValid(obj)) + callback(obj, "end", state_name, ...) + if obj:IsAnimLooping(1) then + callback(obj, "start", state_name, ...) + end + time = time - time_to_end + else + time = false + end + end + -- if someone wakes us we need to query for a new moment + if time then + if time > 0 and WaitWakeup(time) then + time = nil + else + assert(IsValid(obj)) + local index = 1 + repeat + callback(obj, moment, state_name, ...) + index = index + 1 + moment, time = obj:TimeToNextMoment(1, index) + until time ~= 0 + if not time then + WaitWakeup() + end + end + end + else + WaitWakeup() + end + end +end + +local gofRealTimeAnim = const.gofRealTimeAnim + +function AnimMomentHook:StartAnimMomentHook() + local moments = self.anim_moments_hook + if not moments or self.anim_moments_hook_threads then + return + end + if not IsValidEntity(self:GetEntity()) then + return + end + local create_thread = self:GetGameFlags(gofRealTimeAnim) ~= 0 and CreateMapRealTimeThread or CreateGameTimeThread + local threads + if self.anim_moments_single_thread then + threads = { create_thread(WaitTrackMoments, self) } + ThreadsSetThreadSource(threads[1], "AnimMoment") + else + threads = { table.unpack(moments) } + for _, moment in ipairs(moments) do + threads[i] = create_thread(function(self, moment) + local method = moment_hooks[moment] + while true do + self:WaitAnimMoment(moment) + assert(IsValid(self)) + self[method](self) + end + end, self, moment) + ThreadsSetThreadSource(threads[i], "AnimMoment") + end + end + self.anim_moments_hook_threads = threads +end + +function AnimMomentHook:StopAnimMomentHook() + local thread_list = self.anim_moments_hook_threads or "" + for i = 1, #thread_list do + DeleteThread(thread_list[i]) + end + self.anim_moments_hook_threads = nil +end + +function AnimMomentHook:AnimMomentHookUpdate() + for i, thread in ipairs(self.anim_moments_hook_threads) do + Wakeup(thread) + end +end + +AnimMomentHook.AnimationChanged = AnimMomentHook.AnimMomentHookUpdate + +function OnMsg.ClassesPostprocess() + local str_to_moment_list = {} -- optimized to have one copy of each unique moment list + + ClassDescendants("AnimMomentHook", function(class_name, class, remove_prefix, str_to_moment_list) + local moment_list + for name, func in pairs(class) do + local moment = remove_prefix(name, "OnMoment") + if type(func) == "function" and moment and moment ~= "" then + moment_list = moment_list or {} + moment_list[#moment_list + 1] = moment + end + end + for name, func in pairs(getmetatable(class)) do + local moment = remove_prefix(name, "OnMoment") + if type(func) == "function" and moment and moment ~= "" then + moment_list = moment_list or {} + moment_list[#moment_list + 1] = moment + end + end + if moment_list then + table.sort(moment_list) + for _, moment in ipairs(moment_list) do + moment_list[moment] = true + moment_hooks[moment] = moment_hooks[moment] or ("OnMoment" .. moment) + end + local str = table.concat(moment_list, " ") + moment_list = str_to_moment_list[str] or moment_list + str_to_moment_list[str] = moment_list + rawset(class, "anim_moments_hook", moment_list) + end + end, remove_prefix, str_to_moment_list) +end + +--- +DefineClass.StepObjectBase = +{ + __parents = { "AnimMomentHook" }, +} + +function StepObjectBase:StopAnimMomentHook() + AnimMomentHook.StopAnimMomentHook(self) +end + +if not Platform.ged then + function OnMsg.ClassesGenerate() + AppendClass.EntitySpecProperties = { + properties = { + { id = "FXTargetOverride", name = "FX target override", category = "Misc", default = false, + editor = "combo", items = function(fx) return ActionFXClassCombo(fx) end, entitydata = true, + }, + { id = "FXTargetSecondary", name = "FX target secondary", category = "Misc", default = false, + editor = "combo", items = function(fx) return ActionFXClassCombo(fx) end, entitydata = true, + }, + }, + } + end +end + +function GetObjMaterialFXTarget(obj) + local entity_data = obj and EntityData[obj:GetEntity()] + entity_data = entity_data and entity_data.entity + if entity_data and entity_data.FXTargetOverride then + return entity_data.FXTargetOverride, entity_data.FXTargetSecondary + end + + local mat_type = obj and obj:GetMaterialType() + local material_preset = mat_type and (Presets.ObjMaterial.Default or empty_table)[mat_type] + local fx_target = (material_preset and material_preset.FXTarget ~= "") and material_preset.FXTarget or mat_type + + return fx_target, entity_data and entity_data.FXTargetSecondary +end + +local surface_fx_types = {} +local enum_decal_water_radius = const.AnimMomentHookEnumDecalWaterRadius + +function GetObjMaterial(pos, obj, surfaceType, fx_target_secondary) + local surfacePos = pos + if not surfaceType and obj then + surfaceType, fx_target_secondary = GetObjMaterialFXTarget(obj) + end + + local propagate_above + if pos and not surfaceType then + propagate_above = true + if terrain.IsWater(pos) then + local water_z = terrain.GetWaterHeight(pos) + local dz = (pos:z() or terrain.GetHeight(pos)) - water_z + if dz >= const.FXWaterMinOffsetZ and dz <= const.FXWaterMaxOffsetZ then + if const.FXShallowWaterOffsetZ > 0 and dz > -const.FXShallowWaterOffsetZ then + surfaceType = "ShallowWater" + else + surfaceType = "Water" + end + surfacePos = pos:SetZ(water_z) + end + end + if not surfaceType and enum_decal_water_radius then + local decal = MapFindNearest(pos, pos, enum_decal_water_radius, "TerrainDecal", function (obj, pos) + if pos:InBox2D(obj) then + local dz = (pos:z() or terrain.GetHeight(pos)) - select(3, obj:GetVisualPosXYZ()) + if dz <= const.FXDecalMaxOffsetZ and dz >= const.FXDecalMinOffsetZ then + return true + end + end + end, pos) + if decal then + surfaceType = decal:GetMaterialType() + if surfaceType then + surfacePos = pos:SetZ(select(3, decal:GetVisualPosXYZ())) + end + end + end + if not surfaceType then + -- get the surface type + local walkable_slab = const.SlabSizeX and WalkableSlabByPoint(pos) or GetWalkableObject(pos) + if walkable_slab then + surfaceType = walkable_slab:GetMaterialType() + if surfaceType then + surfacePos = pos:SetZ(select(3, walkable_slab:GetVisualPosXYZ())) + end + else + local terrain_preset = TerrainTextures[terrain.GetTerrainType(pos)] + surfaceType = terrain_preset and terrain_preset.type + if surfaceType then + surfacePos = pos:SetTerrainZ() + end + end + end + end + + local fx_type + if surfaceType then + fx_type = surface_fx_types[surfaceType] + if not fx_type then -- cache it for later use + fx_type = "Surface:" .. surfaceType + surface_fx_types[surfaceType] = fx_type + end + end + local fx_type_secondary + if fx_target_secondary then + fx_type_secondary = surface_fx_types[fx_target_secondary] + if not fx_type_secondary then -- cache it for later use + fx_type_secondary = "Surface:" .. fx_target_secondary + surface_fx_types[fx_target_secondary] = fx_type_secondary + end + end + + return fx_type, surfacePos, propagate_above, fx_type_secondary +end + + +local enum_bush_radius = const.AnimMomentHookTraverseVegetationRadius + +function StepObjectBase:PlayStepSurfaceFX(foot, spot_name) + local spot = self:GetRandomSpot(spot_name) + local pos = self:GetSpotLocPos(spot) + local surface_fx_type, surface_pos, propagate_above = GetObjMaterial(pos) + + if surface_fx_type then + local angle, axis = self:GetSpotVisualRotation(spot) + local dir = RotateAxis(axis_x, axis, angle) + local actionFX = self:GetStepActionFX() + PlayFX(actionFX, foot, self, surface_fx_type, surface_pos, dir) + end + + if propagate_above and enum_bush_radius then + local bushes = MapGet(pos, enum_bush_radius, "TraverseVegetation", function(obj, pos) return pos:InBox(obj) end, pos) + if bushes and bushes[1] then + local veg_event = PlaceObject("VegetationTraverseEvent") + veg_event:SetPos(pos) + veg_event:SetActors(self, bushes) + end + end +end + +function StepObjectBase:GetStepActionFX() + return "Step" +end + +DefineClass.StepObject = { + __parents = { "StepObjectBase" }, +} + +function StepObject:OnMomentFootLeft() + self:PlayStepSurfaceFX("FootLeft", "Leftfoot") +end + +function StepObject:OnMomentFootRight() + self:PlayStepSurfaceFX("FootRight", "Rightfoot") +end + +function OnMsg.GatherFXActions(list) + list[#list+1] = "Step" +end + +function OnMsg.GatherFXTargets(list) + local added = {} + ForEachPreset("TerrainObj", function(terrain_preset) + local type = terrain_preset.type + if type ~= "" and not added[type] then + list[#list+1] = "Surface:" .. type + added[type] = true + end + end) + local material_types = PresetsCombo("ObjMaterial")() + for i = 2, #material_types do + local type = material_types[i] + if not added[type] then + list[#list+1] = "Surface:" .. type + added[type] = true + end + end +end + +DefineClass.AutoAttachAnimMomentHookObject = { + __parents = {"AutoAttachObject", "AnimMomentHook"}, + + anim_moments_single_thread = true, + anim_moments_hook = true, +} + +function AutoAttachAnimMomentHookObject:SetState(...) + AutoAttachObject.SetState(self, ...) + AnimMomentHook.SetState(self, ...) +end + +function AutoAttachAnimMomentHookObject:OnAnimMoment(moment, anim) + return AnimMomentHook.OnAnimMoment(self, moment, anim) +end \ No newline at end of file diff --git a/CommonLua/Classes/AppearanceObject.lua b/CommonLua/Classes/AppearanceObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..64a7423015439fc48bdd4f04ce550a166af48620 --- /dev/null +++ b/CommonLua/Classes/AppearanceObject.lua @@ -0,0 +1,274 @@ +DefineClass.AppearanceObjectPart = { + __parents = { "CObject", "ComponentAnim", "ComponentAttach", "ComponentCustomData" }, + flags = { gofSyncState = true, cofComponentColorizationMaterial = true }, +} + +function ValidAnimationsCombo(character) + local all_anims = character:GetStatesTextTable() + + local valid_anims = {} + for _, anim in ipairs(all_anims) do + if anim:sub(-1, -1) ~= "*" then + table.insert(valid_anims, anim) + end + end + + return valid_anims +end + +DefineClass.AppearanceObject = { + __parents = { "Shapeshifter", "StripComponentAttachProperties", "ComponentAnim"}, + flags = { gofSyncState = true, cofComponentColorizationMaterial = true }, + + properties = + { + { category = "Animation", id = "Appearance", name = "Appearance", editor = "preset_id", preset_class = "AppearancePreset", default = "" }, + { category = "Animation", id = "anim", name = "Animation", editor = "dropdownlist", items = ValidAnimationsCombo, default = "idle" }, + { category = "Animation Blending", id = "animWeight", name = "Animation Weight", editor = "number", slider = true, min = 0, max = 100, default = 100, help = "100 means only Animation is played, 0 means only Animation 2 is played, 50 means both animations are blended equally" }, + { category = "Animation Blending", id = "animBlendTime", name = "Animation Blend Time", editor = "number", min = 0, default = 0 }, + { category = "Animation Blending", id = "anim2", name = "Animation 2", editor = "dropdownlist", items = function(character) local list = character:GetStatesTextTable() table.insert(list, 1, "") return list end, default = "" }, + { category = "Animation Blending", id = "anim2BlendTime", name = "Animation 2 Blend Time", editor = "number", min = 0, default = 0 }, + }, + + fallback_body = config.DefaultAppearanceBody, + parts = false, + + animFlags = 0, + animCrossfade = 0, + anim2Flags = 0, + anim2Crossfade = 0, + + attached_parts = { "Head", "Pants", "Shirt", "Armor", "Hat", "Hat2", "Hair", "Chest", "Hip" }, + animated_parts = { "Head", "Pants", "Shirt", "Armor" }, + appearance_applied = false, + + anim_speed = 1000, +} + +function AppearanceObject:PostLoad() + self:ApplyAppearance() +end + +function AppearanceObject:OnEditorSetProperty(prop_id) + if prop_id == "Appearance" then + self:ApplyAppearance() + end +end + +function AppearanceObject:Setanim(anim) + self.anim = anim + self:SetAnimHighLevel() +end + +function AppearanceObject:Setanim2(anim) + self.anim2 = anim + self:SetAnimHighLevel() +end + +function AppearanceObject:SetanimFlags(anim_flags) + self.animFlags = anim_flags +end + +function AppearanceObject:SetanimCrossfade(crossfade) + self.animCrossfade = crossfade +end + +function AppearanceObject:Setanim2Flags(anim_flags) + self.anim2Flags = anim_flags +end + +function AppearanceObject:Setanim2Crossfade(crossfade) + self.anim2Crossfade = crossfade +end + +function AppearanceObject:SetanimWeight(weight) + self.animWeight = weight + self:SetAnimHighLevel() +end + +function AppearanceObject:SetAnimChannel(channel, anim, anim_flags, crossfade, weight, blend_time) + if not self:HasState(anim) then + if self:GetEntity() ~= "" then + StoreErrorSource(self, "Missing object state " .. self:GetEntity() .. "." .. anim) + end + return + end + Shapeshifter.SetAnim(self, channel, anim, anim_flags, crossfade) + Shapeshifter.SetAnimWeight(self, channel, 100) + Shapeshifter.SetAnimWeight(self, channel, weight, blend_time) + self:SetAnimSpeed(channel, self.anim_speed) + local parts = self.parts + if parts then + for _, part_name in ipairs(self.animated_parts) do + local part = parts[part_name] + if part then + part:SetAnim(channel, anim, anim_flags, crossfade) + part:SetAnimWeight(channel, 100) + part:SetAnimWeight(channel, weight, blend_time) + part:SetAnimSpeed(channel, self.anim_speed) + end + end + end + return GetAnimDuration(self:GetEntity(), self:GetAnim(channel)) +end + +function AppearanceObject:SetAnimLowLevel() + self:ApplyAppearance() + local time = self:SetAnimChannel(1, self.anim, self.animFlags, self.animCrossfade, self.animWeight, self.animBlendTime) + if self.anim2 ~= "" then + local time2, duration2 = self:SetAnimChannel(2, self.anim2, self.anim2Flags, self.anim2Crossfade, 100 - self.animWeight, self.anim2BlendTime) + time = Max(time, time2) + end + return time +end + +function AppearanceObject:SetAnimHighLevel() + self:SetAnimLowLevel() +end + +function AppearanceObject:SetEntity() +end + +local function get_part_offset_angle(appearance, prop_name) + local x = appearance[prop_name .. "AttachOffsetX"] or 0 + local y = appearance[prop_name .. "AttachOffsetY"] or 0 + local z = appearance[prop_name .. "AttachOffsetZ"] or 0 + local angle = appearance[prop_name .. "AttachOffsetAngle"] or 0 + if x ~= 0 or y ~= 0 or z ~= 0 or angle ~= 0 then + return point(x, y, z), angle + end +end + +-- overload this in project +config.DefaultAppearanceBody = "ErrorAnimatedMesh" + +function AppearanceObject:ColorizePart(part_name) + local appearance = AppearancePresets[self.Appearance] + local prop_color_name = string.format("%sColor", part_name) + if not appearance:HasMember(prop_color_name) then return end + + local part = self.parts[part_name] + local color_member = appearance[prop_color_name] + if not color_member then + print("once", string.format("[WARNING] No color specified for %s in %s", part_name, self.Appearance)) + return + end + local palette = color_member["ColorizationPalette"] + part:SetColorizationPalette(palette) + for i = 1, const.MaxColorizationMaterials do + if string.match(part_name, "Hair") then + local custom = {} + for i = 1, 4 do + custom[i] = appearance["HairParam" .. i] + end + part:SetHairCustomParams(custom) + end + local color = color_member[string.format("EditableColor%d", i)] + local roughness = color_member[string.format("EditableRoughness%d", i)] + local metallic = color_member[string.format("EditableMetallic%d", i)] + part:SetColorizationMaterial(i, color, roughness, metallic) + end +end + +function AppearanceObject:ApplyPartSpotAttachments(part_name) + local appearance = AppearancePresets[self.Appearance] + local part = self.parts[part_name] + local spot_prop = part_name .. "Spot" + local prop_spot = appearance:HasMember(spot_prop) and appearance[spot_prop] + local spot_name = prop_spot or "Origin" + self:Attach(part, self:GetSpotBeginIndex(spot_name)) + if part_name == "Hat" or part_name == "Hat2" then + local offset, angle = get_part_offset_angle(appearance, part_name) + if offset and angle then + part:SetAttachOffset(offset) + part:SetAttachAngle(angle) + end + end +end + +function AppearanceObject:ApplyAppearance(appearance, force) + appearance = appearance or self.Appearance + + if not appearance then return end + if not force and self.appearance_applied == appearance then return end + + self.appearance_applied = appearance + if type(appearance) == "string" then + self.Appearance = appearance + appearance = AppearancePresets[appearance] + else + self.Appearance = appearance.id + end + if not appearance then + local prop_meta = AppearanceObject:GetPropertyMetadata("Appearance") + appearance = AppearancePresets[prop_meta.default] + if not appearance then + StoreErrorSource(self, "Default Appearance can't be invalid!") + end + appearance = AppearancePresets[self.Appearance] + if not appearance then + StoreErrorSource(self, string.format("Invalid appearance '%s'", self.Appearance)) + return + end + end + for _, part in pairs(self.parts) do + DoneObject(part) + end + self:ChangeEntity(appearance.Body or self.fallback_body or config.DefaultAppearanceBody) + if not IsValidEntity(self:GetEntity()) then + StoreErrorSource(self, string.format("Invalid entity '%s'(%s) for Appearance '%s'", self:GetEntity(), appearance.Body, appearance.id)) + printf("Invalid entity '%s'(%s) for Appearance '%s'", self:GetEntity(), appearance.Body, appearance.id) + end + if appearance:HasMember("BodyColor") and appearance.BodyColor then + self:SetColorization(appearance.BodyColor, true) + end + self.parts = {} + local real_time_animated = self:GetGameFlags(const.gofRealTimeAnim) ~= 0 + for _, part_name in ipairs(self.attached_parts) do + if IsValidEntity(appearance[part_name]) then + local part = PlaceObject("AppearanceObjectPart") + if real_time_animated then + part:SetGameFlags(const.gofRealTimeAnim) + end + part:ChangeEntity(appearance[part_name]) + if not IsValidEntity(part:GetEntity()) then + StoreErrorSource(part, string.format("Invalid entity part '%s'(%s) for Appearance '%s'", part:GetEntity(), appearance[part_name], appearance.id)) + printf("Invalid entity part '%s'(%s) for Appearance '%s'", part:GetEntity(), appearance[part_name], appearance.id) + end + self.parts[part_name] = part + self:ColorizePart(part_name) + self:ApplyPartSpotAttachments(part_name) + end + end + self:Setanim(self.anim) +end + +function AppearanceObject:PlayAnim(anim) + self:Setanim(anim) + local vec = self:GetStepVector() + local time = self:GetAnimDuration() + if vec:Len() > 0 then + self:SetPos(self:GetPos() + vec, time) + end + Sleep(time) +end + +function AppearanceObject:SetPhaseHighLevel(phase) + self:SetAnimPhase(1, phase) + local parts = self.parts + if parts then + for _, part_name in ipairs(self.attached_parts) do + local part = parts[part_name] + if part then + part:SetAnimPhase(1, phase) + end + end + end +end + +function AppearanceObject:SetAnimPose(anim, phase) + self:Setanim(anim) + self.anim_speed = 0 + self:SetPhaseHighLevel(phase) +end + diff --git a/CommonLua/Classes/AttachViaProp.lua b/CommonLua/Classes/AttachViaProp.lua new file mode 100644 index 0000000000000000000000000000000000000000..55d7c0bb767fcbe6c01bca9284cccb58336b1326 --- /dev/null +++ b/CommonLua/Classes/AttachViaProp.lua @@ -0,0 +1,262 @@ +local EDITOR = Platform.editor + +local function SpotEntry(obj, idx) + local name = idx >= 0 and obj:GetSpotName(idx) or "" + if name == "" then + return false + end + local offset = idx - obj:GetSpotBeginIndex(name) + return offset == 0 and name or {name, offset} +end + +DefineClass.AttachViaProp = { + __parents = { "ComponentAttach" }, + properties = { + { category = "Attach-Via-Prop", id = "AttachList", name = "Attach List", editor = "prop_table", default = "", no_edit = true }, + { category = "Attach-Via-Prop", id = "AttachCounter", name = "Attach Counter", editor = "number", default = 0, dont_save = true, read_only = true }, + }, +} + +function AttachViaProp:SetAttachList(value) + self.AttachList = value + self:UpdatePropAttaches() +end + +function AttachViaProp:ResolveSpotIdx(entry) + local spot = entry.spot + local offset + if type(spot) == "table" then + spot, offset = unpack_params(spot) + assert(type(spot) == "string") + assert(type(offset) == "number") + end + if type(spot) == "number" then + -- support for older version + entry.spot = SpotEntry(self, spot) + elseif type(spot) == "string" then + spot = self:HasSpot(spot) and self:GetSpotBeginIndex(spot) + end + return (spot or -1) + (offset or 0) +end + +function AttachViaProp:UpdatePropAttaches() + if self.AttachCounter > 0 then + self:ForEachAttach(function(obj) + if rawget(obj, "attach_via_prop") then + DoneObject(obj) + end + end) + self.AttachCounter = nil + end + local list = self.AttachList + for i=#list,1,-1 do + local entry = list[i] + local spot = self:ResolveSpotIdx(entry) + if not spot then + table.remove(list, i) + else + local class = entry.class or "" + local particles = entry.particles or "" + local obj, err + if particles ~= "" then + obj = PlaceParticles(particles) + else + obj = PlaceObject(class) + end + if obj then + rawset(obj, "attach_via_prop", true) + if entry.offset then + obj:SetAttachOffset(entry.offset) + end + if entry.axis then + obj:SetAttachAxis(entry.axis) + end + if entry.angle then + obj:SetAttachAngle(entry.angle) + end + err = self:Attach(obj, spot) + end + if not IsValid(obj) or obj:GetAttachSpot() ~= spot or err then + StoreErrorSource(self, "Failed to attach via props!") + DoneObject(obj) + else + self.AttachCounter = self.AttachCounter + 1 + end + end + end + if not EDITOR then + self.AttachList = nil + end +end + +---- +if EDITOR then + +local function SpotName(obj, idx) + local name = obj:GetSpotName(idx) + local anot = obj:GetSpotAnnotation(idx) + return name .. (anot and (" (" .. anot .. ")") or "") +end + +local function SpotNamesCombo(obj) + local start_idx, end_idx = obj:GetAllSpots( obj:GetState() ) + local items = {} + local names = {} + for idx = start_idx, end_idx do + items[#items + 1] = {value = SpotEntry(obj, idx), text = SpotName(obj, idx)} + end + table.sortby_field(items, "text") + table.insert(items, 1, {value = false, text = ""}) + return items +end + +if FirstLoad then + l_used_entries = false +end + +local function SmartAttachCombo() + local items = {} + local classes = ClassDescendantsList("ComponentAttach") + local tbl = {"", " - Object"} + for i = 1, #classes do + local class = classes[i] + tbl[1] = class + local text = table.concat(tbl) + items[#items + 1] = {value = { class, 1, text }, text = text} + end + local particles = ParticlesComboItems() + local tbl = {"", " - Particle"} + for i = 1, #particles do + local particle = particles[i] + tbl[1] = particle + local text = table.concat(tbl) + items[#items + 1] = {value = { particle, 2, text }, text = text} + end + table.sortby_field(items, "text") + if l_used_entries then + local idx = 1 + local tbl = {"> ", ""} + for text, entry in sorted_pairs(l_used_entries) do + tbl[2] = text + table.insert(items, idx, {value = entry, text = table.concat(tbl)}) + idx = idx + 1 + end + table.insert(items, idx, {value = "", text = ""}) + end + table.insert(items, 1, {value = false, text = ""}) + return items +end + +table.iappend(AttachViaProp.properties, { + { category = "Attach-Via-Prop", id = "AttachPreview", name = "Attach List", editor = "text", lines = 5, default = "", dont_save = true, read_only = true, min = 10 }, + { category = "Attach-Via-Prop", id = "AttachToSpot", name = "Attach At", editor = "combo", default = false, dont_save = true, items = SpotNamesCombo, min = 0, buttons = {{name = "Add", func = "ButtonAddAttach"}, {name = "Rem", func = "ButtonRemAttach"}, {name = "Rem All", func = "ButtonRemAllAttaches"}}}, + { category = "Attach-Via-Prop", id = "AttachObject", name = "Attach Object", editor = "combo", default = false, dont_save = true, items = SmartAttachCombo }, + { category = "Attach-Via-Prop", id = "AttachWithOffset", name = "Attach Offset ", editor = "point", default = point30, dont_save = true, scale = "m" }, + { category = "Attach-Via-Prop", id = "AttachWithAxis", name = "Attach Axis ", editor = "point", default = axis_z, dont_save = true }, + { category = "Attach-Via-Prop", id = "AttachWithAngle", name = "Attach Angle ", editor = "number", default = 0, dont_save = true, scale = "deg" }, +}) + +function AttachViaProp:GetAttachListCombo() + local items = {} + local list = self.AttachList + for i=#list,1,-1 do + local entry = list[i] + local spot = self:ResolveSpotIdx(entry) + if not spot then + table.remove(list, i) + else + local str = SpotName(self, spot) .. " -- " .. (entry.particles or entry.class or "?") + if entry.offset then + str = str .. " o" .. tostring(entry.offset) + end + if entry.axis then + str = str .. " x" .. tostring(entry.axis) + end + if entry.angle then + str = str .. " a" .. tostring(entry.angle) + end + items[#items + 1] = str + end + end + table.sort(items) + return items +end + +function AttachViaProp:GetAttachPreview() + local list = self:GetAttachListCombo() + return table.concat(list, "\n") +end + +function ButtonAddAttach(main_obj, object, prop_id) + if type(object.AttachObject) ~= "table" then + return + end + local oname, otype, otext = unpack_params(object.AttachObject) + local class = otype == 1 and oname or "" + local particles = otype == 2 and oname or "" + if class == "" and particles == "" then + return + end + l_used_entries = l_used_entries or {} + l_used_entries[otext] = object.AttachObject + local spot = object.AttachToSpot or nil + local list = object.AttachList + if #list == 0 then + list = {} + object.AttachList = list + end + local offset = object.AttachWithOffset + if offset == point30 then + offset = nil + end + local axis = object.AttachWithAxis + if axis == axis_z then + axis = nil + end + local angle = object.AttachWithAngle + if angle == 0 then + angle = nil + end + if class ~= "" then + list[#list + 1] = {spot = spot, offset = offset, axis = axis, angle = angle, class = class, } + end + if particles ~= "" then + list[#list + 1] = {spot = spot, offset = offset, axis = axis, angle = angle, particles = particles, } + end + object:UpdatePropAttaches() +end + +function ButtonRemAllAttaches(main_obj, object, prop_id) + if not object then + return + end + object.AttachList = nil + object:UpdatePropAttaches() +end + +function ButtonRemAttach(main_obj, object, prop_id) + if not object then + return + end + local list = object:GetAttachListCombo() + if #list == 0 then + return + end + local entry, err = PropEditorWaitUserInput(main_obj, list[#list], "Select attach to remove", list) + if not entry then + assert(false, err) + return + end + local idx = table.find(list, entry) + if not idx then + return + end + table.remove(object.AttachList, idx) + if #object.AttachList == 0 then + object.AttachList = nil + end + object:UpdatePropAttaches() +end + +end -- EDITOR +---- \ No newline at end of file diff --git a/CommonLua/Classes/AutoAttach.lua b/CommonLua/Classes/AutoAttach.lua new file mode 100644 index 0000000000000000000000000000000000000000..d3f8859e1552ec016a9545b1d50e08804da084dd --- /dev/null +++ b/CommonLua/Classes/AutoAttach.lua @@ -0,0 +1,1355 @@ +function GetObjStateAttaches(obj, entity) + entity = entity or (obj:GetEntity() or obj.entity) + + local state = obj and GetStateName(obj:GetState()) or "idle" + local entity_attaches = Attaches[entity] + + return entity_attaches and entity_attaches[state] +end + +function GetEntityAutoAttachModes(obj, entity) + local attaches = GetObjStateAttaches(obj, entity) + local modes = {""} + for _, attach in ipairs(attaches or empty_table) do + if attach.required_state then + local mode = string.trim_spaces(attach.required_state) + table.insert_unique(modes, mode) + end + end + + return modes +end + +--[[@@@ +@class AutoAttachCallback +Inherit this if you want a callback when an objects is autoattached to its parent +--]] + +DefineClass.AutoAttachCallback = { + __parents = {"InitDone"}, +} + +function AutoAttachCallback:OnAttachToParent(parent, spot) +end + +--[[@@@ +@class AutoAttachObject +Objects from this type are able to attach a preset of objects on their creation based on their spot annotations. +--]] + +DefineClass.AutoAttachObject = +{ + __parents = { "Object", "ComponentAttach" }, + auto_attach_props_description = false, + + properties = { + { id = "AutoAttachMode", editor = "choice", default = "", items = function(obj) return GetEntityAutoAttachModes(obj) or {} end }, + { id = "AllAttachedLightsToDetailLevel", editor = "choice", default = false, items = {"Essential", "Optional", "Eye Candy"}}, + }, + + auto_attach_at_init = true, + auto_attach_mode = false, + is_forced_lod_min = false, + + max_colorization_materials_attaches = 0, +} + +local gofAutoAttach = const.gofAutoAttach + +function IsAutoAttach(attach) + return attach:GetGameFlags(gofAutoAttach) ~= 0 +end + +function AutoAttachObject:DestroyAutoAttaches() + self:DestroyAttaches(IsAutoAttach) +end + +function AutoAttachObject:ClearAttachMembers() + local attaches = GetObjStateAttaches(self) + for _, attach in ipairs(attaches) do + if attach.member then + self[attach.member] = nil + end + end +end + +function AutoAttachObject:SetAutoAttachMode(value) + self.auto_attach_mode = value + self:DestroyAutoAttaches() + self:ClearAttachMembers() + self:AutoAttachObjects() +end + +function AutoAttachObject:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "AllAttachedLightsToDetailLevel" or prop_id == "StateText" then + self:SetAutoAttachMode(self:GetAutoAttachMode()) + end + Object.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +function AutoAttachObject:GetAutoAttachMode(mode) + local mode_set = GetEntityAutoAttachModes(self) + if not mode_set then + return "" + end + if table.find(mode_set, mode or self.auto_attach_mode) then + return self.auto_attach_mode + end + return mode_set[1] or "" +end + +function AutoAttachObject:GetAttachModeSet() + return GetEntityAutoAttachModes(self) +end + +if FirstLoad then + s_AutoAttachedLightDetailsBaseObject = false +end + +function AutoAttachObjects(obj, context) + if not s_AutoAttachedLightDetailsBaseObject and obj.AllAttachedLightsToDetailLevel then + s_AutoAttachedLightDetailsBaseObject = obj + end + + local selectable = obj:GetEnumFlags(const.efSelectable) ~= 0 + local attaches = GetObjStateAttaches(obj) + local max_colorization_materials = 0 + for i = 1, #(attaches or "") do + local attach = attaches[i] + local class = GetAttachClass(obj, attach[2]) + + local spot_attaches = {} + local place, detail_class = PlaceCheck(obj, attach, class, context) + if place then + local o = PlaceAtSpot(obj, attach.spot_idx, class, context) + if o then + if attach.mirrored then + o:SetMirrored(true) + end + if attach.offset then + o:SetAttachOffset(attach.offset) + end + if attach.axis and attach.angle and attach.angle ~= 0 then + o:SetAttachAxis(attach.axis) + o:SetAttachAngle(attach.angle) + end + if selectable then + o:SetEnumFlags(const.efSelectable) + end + if attach.inherited_properties then + for key, value in sorted_pairs(attach.inherited_properties) do + o:SetProperty(key, value) + end + end + if IsKindOf(o, "SubstituteByRandomChildEntity") then + -- NOTE: when substituting entity the object is still not attached so it can't be + -- destroyed in SubstituteByRandomChildEntity and we have to do it manually here + if o:IsForcedLODMinAttach() and o:GetDetailClass() ~= "Essential" then + DoneObject(o) + o = nil + else + local top_parent = GetTopmostParent(o) + ApplyCurrentEnvColorizedToObj(top_parent) -- entity changed, possibly colorization too. + top_parent:DestroyRenderObj(true) + end + else + o:SetDetailClass(detail_class) + end + if o then + if attach.inherit_colorization then + o:SetGameFlags(const.gofInheritColorization) + max_colorization_materials = Max(max_colorization_materials, o:GetMaxColorizationMaterials()) + end + o:SetForcedLODMin(rawget(obj, "is_forced_lod_min") or obj:GetForcedLODMin()) + spot_attaches[#spot_attaches+1] = o + end + end + end + if context ~= "placementcursor" then + SetObjMembers(obj, attach, spot_attaches) + end + end + if max_colorization_materials > AutoAttachObject.max_colorization_materials_attaches then + obj.max_colorization_materials_attaches = max_colorization_materials + end + + if s_AutoAttachedLightDetailsBaseObject == obj then + s_AutoAttachedLightDetailsBaseObject = false + end +end + +function AutoAttachObject:GetMaxColorizationMaterials() + return Max(self.max_colorization_materials_attaches, CObject.GetMaxColorizationMaterials(self)) +end + +function AutoAttachObject:CanBeColorized() + return self.max_colorization_materials_attaches and self.max_colorization_materials_attaches > 1 or CObject.CanBeColorized(self) +end + +AutoAttachObject.AutoAttachObjects = AutoAttachObjects + +function RemoveObjMembers(obj, attach, list) + if attach.member then + local o = obj[attach.member] + for i = 1, #list do + if o == list[i] then + obj[attach.member] = false + break + end + end + end + if attach.memberlist and obj[attach.memberlist] and type(obj[attach.memberlist]) == "table" then + table.remove_entry(obj[attach.memberlist], list) + end +end + +-- local functions used in the class methods +local AutoAttachObjects, RemoveObjMembers = AutoAttachObjects, RemoveObjMembers + +function AutoAttachObject:Init() + if self.auto_attach_at_init then + AutoAttachObjects(self, "init") + end +end + +function AutoAttachObject:__fromluacode(props, arr, handle) + local obj = ResolveHandle(handle) + + if obj and obj[true] then + StoreErrorSource(obj, "Duplicate handle", handle) + assert(false, string.format("Duplicate handle %d: new '%s', prev '%s'", handle, self.class, obj.class)) + obj = nil + end + + local idx = table.find(props, "AllAttachedLightsToDetailLevel") + local attached_lights_detail = idx and props[idx + 1] + if attached_lights_detail then + obj.AllAttachedLightsToDetailLevel = attached_lights_detail + end + + local idx = table.find(props, "LowerLOD") + + if idx ~= nil then + obj.is_forced_lod_min = props[idx + 1] + else + idx = table.find(props, "ForcedLODState") + obj.is_forced_lod_min = idx and (props[idx + 1] == "Minimum") + end + + obj = self:new(obj) + SetObjPropertyList(obj, props) + SetArray(obj, arr) + obj.is_forced_lod_min = nil + + return obj +end + +AutoAttachObject.ShouldAttach = return_true +AutoResolveMethods.ShouldAttach = "and" + +function AutoAttachObject:OnAttachCreated(attach, spot) +end + +function AutoAttachObject:MarkAttachEntities(entities) + if not IsValid(self) then return entities end + + entities = entities or {} + + self:__MarkEntities(entities) + + local cur_mode = self.auto_attach_mode + local modes = self:GetAttachModeSet() + for _, mode in ipairs(modes) do + self:SetAutoAttachMode(mode) + self:__MarkEntities(entities) + end + self:SetAutoAttachMode(cur_mode) + + return entities +end + +function SetObjMembers(obj, attach, list) + if attach.member then + local name = attach.member + if #list == 0 then + if not rawget(obj, name) then + obj[name] = false -- initialize on init + end + else + assert(#list == 1 and not rawget(obj, name), 'Duplicate member "'..name..'" in the auto-attaches of class "'..obj.class..'"') + obj[name] = list[1] + end + end + if attach.memberlist then + local name = attach.memberlist + if not rawget(obj, name) or not type(obj[name]) == "table" or not IsValid(obj[name][1]) then + obj[name] = {} + end + if #list > 0 then + obj[name][#obj[name] + 1] = list + end + end +end + +function GetAttachClass(self, classes) + if type(classes) == "string" then + return classes + end + assert(type(classes) == "table") + local rnd = self:Random(100) + local cur_prob = 0 + for class, prob in pairs(classes) do + cur_prob = cur_prob + prob + if rnd <= cur_prob then + return class + end + end + -- probability of nothing left + return false +end + +local IsKindOf = IsKindOf +local shapeshifter_class_whitelist = { "Light", "AutoAttachSIModulator", "ParSystem" } -- classes that are alowed to be instantiated even in shapeshifters +local function IsObjectClassAllowedInShapeshifter(class_to_spawn) + for _, class_name in ipairs(shapeshifter_class_whitelist) do + if IsKindOf(class_to_spawn, class_name) then + return true + end + end + return false +end + +local gofDetailClassMask = const.gofDetailClassMask + +function PlaceCheck(obj, attach, class, context) + if not obj:ShouldAttach(attach) then + return false + end + + -- placement cursor check + if context == "placementcursor" then + if not attach.show_at_placement and not attach.placement_only then + return false + end + elseif attach.placement_only then + return false + end + if attach.required_state and IsKindOf(obj, "AutoAttachObject") and attach.required_state ~= obj.auto_attach_mode then + return false + end + + -- condition check + local condition = attach.condition + if condition then + assert(type(condition) == "function" or type(condition) == "string") + if type(condition) == "function" then + if not condition(obj, attach) then + return false + end + else + if obj:HasMember(condition) and not obj[condition] then + return false + end + end + end + + local detail_class = s_AutoAttachedLightDetailsBaseObject and + IsKindOf(g_Classes[class], "Light") and + s_AutoAttachedLightDetailsBaseObject.AllAttachedLightsToDetailLevel + detail_class = detail_class or (attach.DetailClass ~= "Default" and attach.DetailClass) + if not detail_class then + -- try to extract from the class + local detail_mask = GetClassGameFlags(class, gofDetailClassMask) + local detail_from_class = GetDetailClassMaskName(detail_mask) + detail_class = detail_from_class ~= "Default" and detail_from_class + end + local forced_lod_min = rawget(obj, "is_forced_lod_min") or obj:GetForcedLODMin() + if forced_lod_min and detail_class ~= "Essential" then + return false + end + + return true, detail_class +end + +function PlaceAtSpot(obj, spot, class, context) + local o + if g_Classes[class] then + if context == "placementcursor" then + if g_Classes[class]:IsKindOfClasses("TerrainDecal", "BakedTerrainDecal") then + o = PlaceObject("PlacementCursorAttachmentTerrainDecal") + else + o = PlaceObject("PlacementCursorAttachment") + end + o:ChangeClass(class) + AutoAttachObjects(o, "placementcursor") + elseif context == "shapeshifter" and not IsObjectClassAllowedInShapeshifter(g_Classes[class]) then + o = PlaceObject("Shapeshifter", nil, const.cofComponentAttach) + if IsValidEntity(class) then + o:ChangeEntity(class) + end + else + o = PlaceObject(class, nil, const.cofComponentAttach) + end + else + print("once", 'AutoAttach: unknown class/particle "' .. class .. '" for [object "' .. obj.class .. '", spot "' .. obj:GetSpotName(spot) .. '"]') + end + if not o then + return + end + local err = obj:Attach(o, spot) + if err then + print("once", "Error attaching", o.class, "to", obj.class, ":", err) + return + end + o:SetGameFlags(const.gofAutoAttach) + if not IsKindOf(obj, "Shapeshifter") then + obj:OnAttachCreated(o, spot) + end + if IsKindOf(o, "AutoAttachCallback") then + o:OnAttachToParent(obj, spot) + end + + return o +end + +if FirstLoad then + Attaches = {} -- global table that keeps the inherited auto_attaches +end + +function AutoAttachObjectsToPlacementCursor(obj) + AutoAttachObjects(obj, "placementcursor") +end + +--[Deprecated] +function AutoAttachObjectsToShapeshifter(obj) + AutoAttachObjects(obj) +end + +function AutoAttachShapeshifterObjects(obj) + AutoAttachObjects(obj, "shapeshifter") +end + +local function CanInheritColorization(parent_entity, child_entity) + return true +end + +function GetEntityAutoAttachTable(entity, auto_attach) + auto_attach = auto_attach or false + + local states = GetStates(entity) + for _, state in ipairs(states) do + local spbeg, spend = GetAllSpots(entity, state) + for spot = spbeg, spend do + local str = GetSpotAnnotation(entity, spot) + if str and #str > 0 then + local item + for w in string.gmatch(str,"%s*(.[^,]+)[, ]?") do + local lw = string.lower(w) + if not item then + -- auto attach description + if lw ~= "att" and lw~="autoattach" then + break + end + item = {} + item.spot_idx = spot + elseif lw=="show at placement" or lw=="show_at_placement" or lw=="show" then -- show at placement + item.show_at_placement = true + elseif lw=="placement only" or lw=="placement_only" then -- placement only + item.placement_only = true + elseif lw=="mirrored" or lw=="mirror" then + item.mirrored = true + elseif not item[2] then + item[2] = w + if not g_Classes[w] then + print("once", "Invalid autoattach", w, "for entity", entity) + end + end + end + if item then + item.inherit_colorization = CanInheritColorization(entity, item[2]) + auto_attach = auto_attach or {} + auto_attach[state] = auto_attach[state] or {} + table.insert(auto_attach[state], item) + end + end + end + end + + return auto_attach +end + +local function IsAutoAttachObject(entity) + local entity_data = EntityData and EntityData[entity] and EntityData[entity].entity + local classes = entity_data and entity_data.class_parent and entity_data.class_parent or "" + for class in string.gmatch(classes, "[^%s,]+%s*") do + if IsKindOf(g_Classes[class], "AutoAttachObject") then + return true + end + end +end + +local function TransferMatchingIdleAttachesToAllState(auto_attach, states) + local idle_attaches = auto_attach["idle"] + if not idle_attaches then return end + + local attach_modes + for _, attach in ipairs(idle_attaches) do + if attach.required_state then + attach_modes = true + break + end + end + if not attach_modes then return end + + for _, state in ipairs(states) do + auto_attach[state] = auto_attach[state] or {} + table.iappend(auto_attach[state], idle_attaches) + end +end + +-- build autoattach table +function RebuildAutoattach() + if not config.LoadAutoAttachData then return end + + local ae = GetAllEntities() + for entity, _ in sorted_pairs(ae) do + local auto_attach = IsAutoAttachObject(entity) and GetEntityAutoAttachTable(entity) + auto_attach = GetEntityAutoAttachTableFromPresets(entity, auto_attach) + if auto_attach then + local states = GetStates(entity) + table.remove_value(states, "idle") + if #states > 0 then + -- transfer auto attaches to all states since AutoAttachEditor can define only in "idle" state + TransferMatchingIdleAttachesToAllState(auto_attach, states) + end + Attaches[entity] = auto_attach + else + Attaches[entity] = nil + end + end +end + +OnMsg.EntitiesLoaded = RebuildAutoattach + +function OnMsg.PresetSave(name) + local class = g_Classes[name] + if IsKindOf(class, "AutoAttachPreset") then + RebuildAutoattach() + end +end + +local function PlaceFadingObjects(category, init_pos) + local ae = GetAllEntities() + local init_pos = init_pos or GetTerrainCursor() + local pos = init_pos + for k,v in pairs(ae) do + if EntityData[k] and EntityData[k].entity and EntityData[k].entity.fade_category == category then + local o = PlaceObject(k) + o:ChangeEntity(k) + o:SetPos(pos) + o:SetGameFlags(const.gofPermanent) + pos = pos + point(10*guim, 0) + if (pos:x() / (600*guim) > 0) then + pos = point(init_pos:x(), pos:y() + 20*guim) + end + elseif not EntityData[k] then + print("No EntityData for: ", k) + elseif EntityData[k] and not EntityData[k].entity then + print("No EntityData[].entity for: ", k) + end + end +end + +function TestFadeCategories() + local cat = { + "PropsUltraSmall", + "PropsSmall", + "PropsMedium", + "PropsBig", + } + local pos = point(100*guim, 100*guim) + for i=1, #cat do + PlaceFadingObjects(cat[i], pos) + pos = pos + point(0, 100*guim) + end +end + +function GetEntitiesAutoattachCount(filter_count) + local el = GetAllEntities() + local filter_count = filter_count or 30 + for k,v in pairs(el) do + local s,e = GetSpotRange(k, EntityStates["idle"], "Autoattach") + if (e-s) > filter_count then + print(k, e-s) + end + end +end + +function ListEntityAutoattaches(entity) + local s,e = GetSpotRange(entity, EntityStates["idle"], "Autoattach") + for i=s, e do + local annotation = GetSpotAnnotation(entity, i) + print(i, annotation) + end +end + +---------------------- AutoAttach editor ---------------------- + + +local function FindArtSpecById(id) + local spec = EntitySpecPresets[id] + if not spec then + local idx = string.find(id, "_[0-9]+$") + if idx then + spec = EntitySpecPresets[string.sub(id, 0, idx - 1)] + end + end + return spec +end + +local function GenerateMissingEntities() + local all_entities = GetAllEntities() + local to_create = {} + for entity in pairs(all_entities) do + local spec = FindArtSpecById(entity) + if spec and not AutoAttachPresets[entity] and string.find(spec.class_parent, "AutoAttachObject", 1, true) then + table.insert(to_create, entity) + end + end + + if #to_create > 0 then + for _, entity in ipairs(to_create) do + local preset = AutoAttachPreset:new({id = entity}) + preset:Register() + preset:UpdateSpotData() + Sleep(1) + end + + AutoAttachPreset:SortPresets() + ObjModified(Presets.AutoAttachPreset) + end +end + +function GetEntitySpots(entity) + if not IsValidEntity(entity) then return {} end + local states = GetStates(entity) + local idle = table.find(states, "idle") + if not idle then + print("WARNING: No idle state for", entity, "cannot fetch spots.") + return {} + end + + local spots = {} + local spbeg, spend = GetAllSpots(entity, "idle") + for spot = spbeg, spend do + local str = GetSpotName(entity, spot) + spots[str] = spots[str] or {} + table.insert(spots[str], spot) + end + + return spots +end + +local zeropoint = point(0, 0, 0) +function GetEntityAutoAttachTableFromPresets(entity, attach_table) + local preset = AutoAttachPresets[entity] + if not preset then return attach_table end + + local spots + for _, spot in ipairs(preset) do + for _, rule in ipairs(spot) do + attach_table = rule:FillAutoAttachTable(attach_table, entity, preset) + end + end + + return attach_table +end + +DefineClass.AutoAttachRuleBase = { + __parents = { "PropertyObject" }, + parent = false, +} + +function AutoAttachRuleBase:FillAutoAttachTable(attach_table, entity, preset) + return attach_table +end + +function AutoAttachRuleBase:IsActive() + return false +end + +function AutoAttachRuleBase:OnEditorNew(parent, ged, is_paste) + self.parent = parent +end + +local function GetSpotsCombo(entity_name) + local t = {} + local spots = GetEntitySpots(entity_name) + for spot_name, indices in sorted_pairs(spots) do + for i = 1, #indices do + table.insert(t, spot_name .. " " .. i) + end + end + return t +end + + +DefineClass.AutoAttachRuleInherit = { + __parents = { "AutoAttachRuleBase" }, + properties = { + { id = "parent_entity", category = "Rule", name = "Parent Entity", editor = "combo", items = function() return ClassDescendantsCombo("AutoAttachObject") end, default = "", }, + { id = "spot", category = "Rule", name = "Spot", editor = "combo", items = function(obj) return GetSpotsCombo(obj:GetParentEntity()) end, default = "", }, + }, +} + +function AutoAttachRuleInherit:GetParentEntity() + return self.parent_entity +end + +function AutoAttachRuleInherit:GetSpotAndIdx() + local spot = self.spot + local break_idx = string.find(spot, "%d+$") + if not break_idx then return end + local spot_name = string.sub(spot, 1, break_idx - 2) + local spot_idx = tonumber(string.sub(spot, break_idx)) + if spot_name and spot_idx then + return spot_name, spot_idx + end +end + +function AutoAttachRuleInherit:GetEditorView() + local str = string.format("Inherit %s from %s", self.spot or "[SPOT]", self:GetParentEntity() or "[ENTITY]") + if not self:FindInheritedSpot() then + str = "" .. str .. "" + end + return str +end + +function AutoAttachRuleInherit:FindInheritedSpot() + local entity = self:GetParentEntity() + local parent_preset = AutoAttachPresets[entity] + if not parent_preset then return end + local spot_name, spot_idx = self:GetSpotAndIdx() + if not spot_name or not spot_idx then return end + local aaspot_idx, aapost_obj = parent_preset:GetSpot(spot_name, spot_idx) + if not aapost_obj then return end + + return aapost_obj, entity, parent_preset +end + +function AutoAttachRuleInherit:FillAutoAttachTable(attach_table, entity, preset) + local spot, parent_entity, parent_preset = self:FindInheritedSpot() + if not spot then return attach_table end + + for _, rule in ipairs(spot) do + attach_table = rule:FillAutoAttachTable(attach_table, parent_entity, parent_preset, self.parent) + end + return attach_table +end + +function AutoAttachRuleInherit:IsActive() + local spot, _, _ = self:FindInheritedSpot() + return not not spot +end + +DefineClass.AutoAttachRule = { + __parents = { "AutoAttachRuleBase" }, + properties = { + { id = "attach_class", category = "Rule", name = "Object Class", editor = "combo", items = function() return ClassDescendantsCombo("CObject") end, default = "", }, + { id = "quick_modes", default = false, no_save = true, editor = "buttons", category = "Rule", buttons = { + {name = "ParSystem", func = "QuickSetToParSystem"}, + }}, + { id = "offset", category = "Rule", name = "Offset", editor = "point", default = point(0, 0, 0), }, + { id = "axis" , category = "Rule", name = "Axis", editor = "point", default = point(0, 0, 0), }, + { id = "angle", category = "Rule", name = "Angle", editor = "number", default = 0, scale = "deg" }, + { id = "member", category = "Rule", name = "Member", help = "The name of the property of the parent object that should be pointing to the attach object.", editor = "text", default = "", }, + { id = "required_state", category = "Rule", name = "Attach State", help = "Conditional attachment", default = "", editor = "combo", items = function(obj) + return obj and obj.parent and obj.parent.parent and obj.parent.parent:GuessPossibleAutoattachStates() or {} + end, }, + { id = "GameStatesFilter", name="Game State", category = "Rule", editor = "set", default = set(), three_state = true, items = function() return GetGameStateFilter() end }, + { id = "DetailClass", category = "Rule", name = "Detail Class Override", editor = "dropdownlist", + items = {"Default", "Essential", "Optional", "Eye Candy"}, default = "Default", + }, + { id = "inherited_values", no_edit = true, editor = "prop_table", default = false, }, + }, + parent = false, +} + +function AutoAttachRule:IsActive() + return self.attach_class ~= "" +end + + +function AutoAttachRule:QuickSetToParSystem() + self.attach_class = "ParSystem" + ObjModified(self) +end + +function AutoAttachRule:ResolveConditionFunc() + local gamestates_filters = self.GameStatesFilter + if not gamestates_filters or not next(gamestates_filters) then + return false + end + + return function(obj, attach) + if gamestates_filters then + for key, value in pairs(gamestates_filters) do + if value then + if not GameState[key] then return false end + else + if GameState[key] then return false end + end + end + end + + return true + end +end + +function AutoAttachRule:GetCleanInheritedPropertyValues() + local inherited_values = self.inherited_values + if not inherited_values then + return false + end + local inherited_props = self:GetInheritedProps() + if not inherited_props or #inherited_props == 0 then + return false + end + local clean_value_list = {} + for _, prop in ipairs(inherited_props) do + local value = inherited_values[prop.id] + if value ~= nil then + clean_value_list[prop.id] = value + end + end + return clean_value_list +end + +function AutoAttachRule:FillAutoAttachTable(attach_table, entity, preset, spot) + if self.attach_class == "" then + return attach_table + end + spot = spot or self.parent + attach_table = attach_table or {} + + local attach_table_idle = attach_table["idle"] or {} + attach_table["idle"] = attach_table_idle + + local istart, iend = GetSpotRange(spot.parent.id, "idle", spot.name) + if istart < 0 then + print(string.format("Warning: Could not find '%s' spot range for '%s'", spot.name, entity)) + else + table.insert(attach_table_idle, { + spot_idx = istart + spot.idx - 1, + [2] = self.attach_class, + offset = self.offset, + axis = self.axis ~= zeropoint and self.axis, + angle = self.angle ~= 0 and self.angle, + member = self.member ~= "" and self.member, + required_state = self.required_state ~= "" and self.required_state or false, + condition = self:ResolveConditionFunc() or false, + DetailClass = self.DetailClass ~= "Default" and self.DetailClass, + inherited_properties = self:GetCleanInheritedPropertyValues(), + inherit_colorization = preset.PropagateColorization and CanInheritColorization(entity, self.attach_class), + }) + end + + return attach_table +end + +function AutoAttachRule:GetEditorView() + local str + if self.attach_class == "ParSystem" then + str = "Particles " .. (self.inherited_values and self.inherited_values["ParticlesName"] or "?") .. "" + else + str = "Attach " .. (self.attach_class or "?") .. "" + end + str = str .. " (" .. self.DetailClass .. ")" + if self.required_state ~= "" then + str = str .. " : " .. self.required_state .. "" + end + if self.attach_class == "" then + str = "" .. str .. "" + end + return str +end + +function AutoAttachRule:Setattach_class(value) + if self.parent and self.parent.parent and self.parent.parent.id == value then + value = "" + return false + end + self.attach_class = value +end + +function AutoAttachRule:GetInheritedProps() + local properties = {} + local class_obj = g_Classes[self.attach_class] + if not class_obj then + return properties + end + + local orig_properties = PropertyObject.GetProperties(self) + local properties_of_target_entity = class_obj:GetProperties() + for _, prop in ipairs(properties_of_target_entity) do + if prop.autoattach_prop then + assert(not table.find(orig_properties, "id", prop.id), + string.format("Property %s conflict between AutoAttachRule and %s", prop.id, self.attach_class)) + prop = table.copy(prop) + prop.dont_save = true + table.insert(properties, prop) + end + end + return properties +end + +function AutoAttachRule:GetProperties() + local properties = PropertyObject.GetProperties(self) + local class_obj = g_Classes[self.attach_class] + if not class_obj then + return properties + end + + properties = table.copy(properties) + properties = table.iappend(properties, self:GetInheritedProps()) + return properties +end + +function AutoAttachRule:SetProperty(id, value) + if table.find(self:GetInheritedProps(), "id", id) then + self.inherited_values = self.inherited_values or {} + self.inherited_values[id] = value + return + end + PropertyObject.SetProperty(self, id, value) +end + +function AutoAttachRule:GetProperty(id) + if self.inherited_values and self.inherited_values[id] ~= nil then + return self.inherited_values[id] + end + + return PropertyObject.GetProperty(self, id) +end + +function AutoAttachRule:OnEditorSetProperty(prop_id, old_value, ged) + RebuildAutoattach() + ged:ResolveObj("SelectedPreset"):RecreateDemoObject(ged) + local id = self.parent.parent.id + local class = rawget(_G, id) + if class and not class:IsKindOf("AutoAttachObject") then + return false + end + MapForEach("map", id, function(obj) + obj:SetAutoAttachMode(obj:GetAutoAttachMode()) + end) +end + +function AutoAttachRule:GetMaxColorizationMaterials() + if IsKindOf(_G[self.attach_class], "WaterObj") then return 3 end + return self.attach_class ~= "" and IsValidEntity(self.attach_class) and ColorizationMaterialsCount(self.attach_class) or 0 +end + +function AutoAttachRule:ColorizationReadOnlyReason() + return false +end + +function AutoAttachRule:ColorizationPropsNoEdit(i) + if self.parent.parent.PropagateColorization then + return true + end + return self:GetMaxColorizationMaterials() < i +end + +DefineClass.AutoAttachSpot = { + __parents = { "PropertyObject", "Container" }, + + properties = { + { id = "name", name = "Spot Name", editor = "text", default = "", read_only = true }, + { id = "idx", name = "Number", editor = "number", default = -1, read_only = true, }, + { id = "original_index", name = "Original Index", editor = "number", default = -1, read_only = true, }, + }, + + annotated_autoattach = false, + EditorView = Untranslated(" ')> "), + parent = false, + ContainerClass = "AutoAttachRuleBase", +} + +function AutoAttachSpot:Color() + return not self:HasSomethingAttached() and "" or "" +end + +function AutoAttachSpot:HasSomethingAttached() + if #self == 0 then return false end + for _, rule in ipairs(self) do + if rule:IsActive() then + return true + end + end + return false +end + +function AutoAttachSpot:AnnotatedAutoattachMsg() + if not self.annotated_autoattach then return "" end + return "" .. self.annotated_autoattach +end + +function AutoAttachSpot.CreateRule(root, obj) + obj[#obj + 1] = AutoAttachRule:new({parent = obj}) + ObjModified(root) + ObjModified(obj) +end + +function CommonlyUsedAttachItems() + local ret = {} + ForEachPreset("AutoAttachPreset", function(preset) + for _, rule in ipairs(preset) do + for _, subrule in ipairs(rule) do + local class = rawget(subrule, "attach_class") + if class and class ~= "" then + ret[class] = (ret[class] or 0) + 1 + end + end + end + end) + for class, count in pairs(ret) do + if count == 1 then + ret[class] = nil + end + end + return table.keys2(ret, "sorted") +end + +DefineClass.AutoAttachPresetFilter = { + __parents = { "GedFilter" }, + properties = { + { id = "NonEmpty", name = "Only show non-empty entries", default = false, editor = "bool" }, + { id = "HasAttach", name = "Has attach of class", default = false, editor = "combo", items = CommonlyUsedAttachItems }, + { id = "_", editor = "buttons", default = false, buttons = { { name = "Add new AutoAttach entity", func = "AddEntity" } } }, + }, +} + +function AutoAttachPresetFilter:FilterObject(obj) + if self.NonEmpty then + for _, rule in ipairs(obj) do + for _, subrule in ipairs(rule) do + if subrule:IsKindOf("AutoAttachRule") and subrule.attach_class ~= "" then + return true + end + end + end + return false + end + local class = self.HasAttach + if class then + for _, rule in ipairs(obj) do + for _, subrule in ipairs(rule) do + if subrule:IsKindOf("AutoAttachRule") and subrule.attach_class == class then + return true + end + end + end + return false + end + return true +end + +function AutoAttachPresetFilter:AddEntity(root, prop_id, ged) + local entities = {} + ForEachPreset("EntitySpec", function(preset) + if not string.find(preset.class_parent, "AutoAttachObject", 1, true) and not preset.id:starts_with("#") then + entities[#entities + 1] = preset.id + end + end) + + local entity = ged:WaitListChoice(entities, "Choose entity to add:") + if not entity then + return + end + + local spec = EntitySpecPresets[entity] + if spec.class_parent == "" then + spec.class_parent = "AutoAttachObject" + else + spec.class_parent = spec.class_parent .. ",AutoAttachObject" + end + + GedSetUiStatus("add_autoattach_entity", "Saving ArtSpec...") + EntitySpec:SaveAll() + self.NonEmpty = false + self.HasAttach = false + GenerateMissingEntities() + ged:SetSelection("root", { 1, table.find(Presets.AutoAttachPreset.Default, "id", entity) }) + GedSetUiStatus("add_autoattach_entity") + + ged:ShowMessage(Untranslated("Attention!"), Untranslated("You need to commit both the assets and the project folder!")) +end + +DefineClass.AutoAttachPreset = { + __parents = { "Preset" }, + + properties = { + { id = "Id", read_only = true, }, + { id = "SaveIn", read_only = true, }, + { id = "help", editor = "buttons", buttons = {{name = "Go to ArtSpec", func = "GotoArtSpec"}}, default = false,}, + { id = "PropagateColorization", editor = "bool", default = true }, + }, + + GlobalMap = "AutoAttachPresets", + ContainerClass = "AutoAttachSpot", + GedEditor = "GedAutoAttachEditor", + EditorMenubar = "Editors.Art", + EditorMenubarName = "AutoAttach Editor", + EditorIcon = "CommonAssets/UI/Icons/attach attachment paperclip.png", + FilterClass = "AutoAttachPresetFilter", + + EnableReloading = false, +} + +function AutoAttachPreset:GuessPossibleAutoattachStates() + return GetEntityAutoAttachModes(nil, self.id) +end + +function AutoAttachPreset:EditorContext() + local context = Preset.EditorContext(self) + context.Classes = {} + context.ContainerTree = true + return context +end + +function AutoAttachPreset:EditorItemsMenu() + return {} +end + +function AutoAttachPreset:GotoArtSpec(root) + local editor = OpenPresetEditor("EntitySpec") + local spec = self:GetEntitySpec() + local root = editor:ResolveObj("root") + local group_idx = table.find(root, root[spec.group]) + local idx = table.find(root[spec.group], spec) + editor:SetSelection("root", {group_idx, idx}) +end + +function AutoAttachPreset:PostLoad() + for idx, item in ipairs(self) do + item.parent = self + for _, subitem in ipairs(item) do + subitem.parent = item + end + end + Preset.PostLoad(self) +end + +function AutoAttachPreset:GenerateCode(code) + self:UpdateSpotData() -- to read save_in + + -- drop redundant/unneeded data + local has_something_attached = false + for i = #self, 1, -1 do + local spot = self[i] + if not spot:HasSomethingAttached() then + table.remove(self, i) + else + spot.original_index = nil + spot.annotated_autoattach = nil + has_something_attached = true + if not spot[#spot]:IsActive() then + table.remove(spot, #spot) + end + end + end + if has_something_attached then + Preset.GenerateCode(self, code) + end + + self:UpdateSpotData() -- to write original_index and annotated_autoattach back to the structure +end + +function AutoAttachPreset:GetSpot(name, idx) + for i, value in ipairs(self) do + if value.name == name and value.idx == idx then + return i, value + end + end +end + +function AutoAttachPreset:UpdateSpotData() + local spec = self:GetEntitySpec() + if not spec then + return + end + self.save_in = spec:GetSaveIn() + local spots = GetEntitySpots(self.id) + + -- drop additional spots + for i = #self, 1, -1 do + local entry = self[i] + if entry.idx > (spots[entry.name] and #spots[entry.name] or -1) then + table.remove(self, i) + end + end + + for spot_name, indices in pairs(spots) do + for idx = 1, #indices do + local internal_idx, spot = self:GetSpot(spot_name, idx) + if spot then + spot.original_index = indices[idx] + spot.annotated_autoattach = GetSpotAnnotation(self.id, indices[idx]) + else + spot = AutoAttachSpot:new({ + name = spot_name, + idx = idx, + original_index = indices[idx], + annotated_autoattach = GetSpotAnnotation(self.id, indices[idx]), + }) + table.insert(self, spot) + end + spot.parent = self + end + end + + table.sort(self, function(a, b) + if a.name < b.name then return true end + if a.name > b.name then return false end + if a.idx < b.idx then return true end + return false + end) +end + +if FirstLoad then + GedAutoAttachEditorLockedObject = {} + GedAutoAttachDemos = {} +end + +DefineClass.AutoAttachPresetDemoObject = { + __parents = {"Shapeshifter", "AutoAttachObject"} +} + +AutoAttachPresetDemoObject.ShouldAttach = return_true + +function AutoAttachPresetDemoObject:ChangeEntity(entity) + self:DestroyAutoAttaches() + self:ClearAttachMembers() + Shapeshifter.ChangeEntity(self, entity) + self:DestroyAutoAttaches() + self:ClearAttachMembers() + AutoAttachShapeshifterObjects(self) +end + +function AutoAttachPresetDemoObject:CreateLightHelpers() + self:ForEachAttach(function(attach) + if IsKindOf(attach, "Light") then + PropertyHelpers_Init(attach) + end + end) +end + +function AutoAttachPresetDemoObject:AutoAttachObjects() + AutoAttachShapeshifterObjects(self) + self:CreateLightHelpers() +end + +function AutoAttachPreset:ViewDemoObject(ged) + local demo_obj = GedAutoAttachDemos[ged] + if demo_obj and IsValid(demo_obj) then + ViewObject(demo_obj) + end +end + +function AutoAttachPreset:RecreateDemoObject(ged) + if CurrentMap == "" then + return + end + if ged and ged.context.lock_preset then + local obj = GedAutoAttachEditorLockedObject[ged] + obj:DestroyAutoAttaches() + obj:ClearAttachMembers() + AutoAttachObjects(GedAutoAttachEditorLockedObject[ged], "init") + return + end + + local demo_obj = GedAutoAttachDemos[ged] + if not demo_obj or not IsValid(demo_obj) then + demo_obj = PlaceObject("AutoAttachPresetDemoObject") + local look_at = GetTerrainGamepadCursor() + look_at = look_at:SetZ(terrain.GetSurfaceHeight(look_at)) + demo_obj:SetPos(look_at) + end + GedAutoAttachDemos[ged] = demo_obj + demo_obj:ChangeEntity(self.id) +end + +function OnMsg.GedClosing(ged_id) + local demo_obj = GedAutoAttachDemos[GedConnections[ged_id]] + DoneObject(demo_obj) + GedAutoAttachDemos[GedConnections[ged_id]] = nil +end + +function AutoAttachPreset:OnEditorSelect(selected, ged) + if selected then + self:UpdateSpotData() + self:RecreateDemoObject(ged) + end +end + +function AutoAttachPreset:GetError() + if not self:GetEntitySpec() then + return "Could not find the ArtSpec." + end +end + +function AutoAttachPreset:GetEntitySpec() + return FindArtSpecById(self.id) +end + +function OnMsg.GedOpened(ged_id) + local ged = GedConnections[ged_id] + if ged and ged:ResolveObj("root") == Presets.AutoAttachPreset then + CreateRealTimeThread(GenerateMissingEntities) + end +end + +function OpenAutoattachEditor(objlist, lock_entity) + if not IsRealTimeThread() then + CreateRealTimeThread(OpenAutoattachEditor, entity) + return + end + lock_entity = not not lock_entity + local target_entity + if objlist and objlist[1] and IsValid(objlist[1]) then + target_entity = objlist[1] + end + + if not target_entity and lock_entity then + print("No entity selected.") + return + end + + if target_entity then + GenerateMissingEntities() -- make sure all entities are generated. Otherwise the selection may fail + end + + local context = AutoAttachPreset:EditorContext() + context.lock_preset = lock_entity + local ged = OpenPresetEditor("AutoAttachPreset", context) + if target_entity then + ged:SetSelection("root", PresetGetPath(AutoAttachPresets[target_entity:GetEntity()])) + GedAutoAttachEditorLockedObject[ged] = target_entity + end +end + +DefineClass.AutoAttachSIModulator = +{ + __parents = {"CObject", "PropertyObject"}, + properties = { + { id = "SIModulation", editor = "number", default = 100, min = 0, max = 255, slider = true, autoattach_prop = true }, + } +} + +function AutoAttachSIModulator:SetSIModulation(value) + local parent = self:GetParent() + if not parent.SIModulationManual then + parent:SetSIModulation(value) + end +end \ No newline at end of file diff --git a/CommonLua/Classes/BaseObjects.lua b/CommonLua/Classes/BaseObjects.lua new file mode 100644 index 0000000000000000000000000000000000000000..85bc29cefbd4807cfacd10b0947aa0f6452193a7 --- /dev/null +++ b/CommonLua/Classes/BaseObjects.lua @@ -0,0 +1,376 @@ +----- UpdateObject + +DefineClass.UpdateObject = { + __parents = {"Object"}, + + update_thread_on_init = true, + update_interval = 10000, + update_thread = false, +} + +RecursiveCallMethods.OnObjUpdate = "call" + +local Sleep = Sleep +local procall = procall +local GameTime = GameTime +function UpdateObject:Init() + if self.update_thread_on_init then + self:StartObjUpdateThread() + end +end + +function UpdateObject:ObjUpdateProc(update_interval) + self:InitObjUpdate(update_interval) + while true do + procall(self.OnObjUpdate, self, GameTime(), update_interval) + Sleep(update_interval) + end +end + +function UpdateObject:StartObjUpdateThread() + if not self:IsSyncObject() or not mapdata.GameLogic or not self.update_interval then + return + end + DeleteThread(self.update_thread) + self.update_thread = CreateGameTimeThread(self.ObjUpdateProc, self, self.update_interval) + if Platform.developer then + ThreadsSetThreadSource(self.update_thread, "ObjUpdateThread", self.ObjUpdateProc) + end +end + +function UpdateObject:StopObjUpdateThread() + DeleteThread(self.update_thread) + self.update_thread = nil +end + +function UpdateObject:InitObjUpdate(update_interval) + Sleep(1 + self:Random(update_interval, "InitObjUpdate")) +end + +function UpdateObject:Done() + self:StopObjUpdateThread() +end + + +----- ReservedObject + +DefineClass.ReservedObject = { + __parents = { "InitDone" }, + properties = { + { id = "reserved_by", editor = "object", default = false, no_edit = true }, + }, +} + +function TryInterruptReserved(reserved_obj) + local reserved_by = reserved_obj.reserved_by + if IsValid(reserved_by) then + return reserved_by:OnReservationInterrupted() + end + reserved_obj.reserved_by = nil +end + +ReservedObject.Disown = TryInterruptReserved + +AutoResolveMethods.CanReserverBeInterrupted = "or" +ReservedObject.CanReserverBeInterrupted = empty_func + +function ReservedObject:CanBeReservedBy(obj) + return not self.reserved_by or self.reserved_by == obj or self:CanReserverBeInterrupted(obj) +end + +function ReservedObject:TryReserve(reserved_by) + if not self:CanBeReservedBy(reserved_by) then return false end + if self.reserved_by and self.reserved_by ~= reserved_by then + if not TryInterruptReserved(self) then return end + end + return self:Reserve(reserved_by) +end + +function ReservedObject:Reserve(reserved_by) + assert(IsKindOf(reserved_by, "ReserverObject")) + local previous_reservation = reserved_by.reserved_obj + if previous_reservation and previous_reservation ~= self then + --assert(not previous_reservation, "Reserver trying to reserve two objects at once!") + previous_reservation:CancelReservation(reserved_by) + end + self.reserved_by = reserved_by + reserved_by.reserved_obj = self + self:OnReserved(reserved_by) + return true +end + +ReservedObject.OnReserved = empty_func +ReservedObject.OnReservationCanceled = empty_func + +function ReservedObject:CancelReservation(reserved_by) + if self.reserved_by == reserved_by then + self.reserved_by = nil + reserved_by.reserved_obj = nil + self:OnReservationCanceled() + return true + end +end + +function ReservedObject:Done() + self:Disown() +end + +DefineClass.ReserverObject = { + __parents = { "CommandObject" }, + + reserved_obj = false, +} + +function ReserverObject:OnReservationInterrupted() + return self:TrySetCommand("CmdInterrupt") +end + +----- OwnedObject + +DefineClass.OwnershipStateBase = { + OnStateTick = empty_func, + OnStateExit = empty_func, + + CanDisown = empty_func, + CanBeOwnedBy = empty_func, +} + +DefineClass("ConcreteOwnership", "OwnershipStateBase") + +local function SetOwnerObject(owned_obj, owner) + assert(not owner or IsKindOf(owner, "OwnerObject")) + owner = owner or false + local prev_owner = owned_obj.owner + if owner ~= prev_owner then + owned_obj.owner = owner + + local notify_owner = not prev_owner or prev_owner:GetOwnedObject(owned_obj.ownership_class) == owned_obj + if notify_owner then + if prev_owner then + prev_owner:SetOwnedObject(false, owned_obj.ownership_class) + end + if owner then + owner:SetOwnedObject(owned_obj) + end + end + end +end + +function ConcreteOwnership.OnStateTick(owned_obj, owner) + return SetOwnerObject(owned_obj, owner) +end + +function ConcreteOwnership.OnStateExit(owned_obj) + return SetOwnerObject(owned_obj, false) +end + +function ConcreteOwnership.CanDisown(owned_obj, owner, reason) + return owned_obj.owner == owner +end + +function ConcreteOwnership.CanBeOwnedBy(owned_obj, owner, ...) + return owned_obj.owner == owner +end + +DefineClass("SharedOwnership", "OwnershipStateBase") +SharedOwnership.CanBeOwnedBy = return_true + +DefineClass("ForbiddenOwnership", "OwnershipStateBase") + +DefineClass.OwnedObject = { + __parents = { "ReservedObject" }, + properties = { + { id = "owner", editor = "object", default = false, no_edit = true }, + { id = "can_change_ownership", name = "Can change ownership", editor = "bool", default = true, help = "If true, the player can change who owns the object", }, + { id = "ownership_class", name = "Ownership class", editor = "combo", default = false, items = GatherComboItems("GatherOwnershipClasses"), }, + }, + + ownership = "SharedOwnership", +} + +AutoResolveMethods.CanDisown = "and" +function OwnedObject:CanDisown(owner, reason) + return g_Classes[self.ownership].CanDisown(self, owner, reason) +end + +function OwnedObject:Disown() + ReservedObject.Disown(self) + self:TrySetSharedOwnership() +end + +AutoResolveMethods.CanBeOwnedBy = "and" +function OwnedObject:CanBeOwnedBy(obj, ...) + if not self:CanBeReservedBy(obj) then return end + return g_Classes[self.ownership].CanBeOwnedBy(self, obj, ...) +end + +AutoResolveMethods.CanChangeOwnership = "and" +function OwnedObject:CanChangeOwnership() + return self.can_change_ownership +end + +function OwnedObject:GetReservedByOrOwner() + return self.reserved_by or self.owner +end + +OwnedObject.OnOwnershipChanged = empty_func + +function OwnedObject:TrySetOwnership(ownership, forced, ...) + assert(ownership) + if not ownership or not forced and not self:CanChangeOwnership() then return end + + local prev_owner = self.owner + local prev_ownership = self.ownership + self.ownership = ownership + if prev_ownership ~= ownership then + g_Classes[prev_ownership].OnStateExit(self, ...) + end + g_Classes[ownership].OnStateTick(self, ...) + self:OnOwnershipChanged(prev_ownership, prev_owner) +end + +local function TryInterruptReservedOnDifferentOwner(owned_obj) + local reserved_by = owned_obj.reserved_by + if IsValid(reserved_by) and reserved_by ~= owned_obj.owner then + reserved_by:OnReservationInterrupted() + owned_obj.reserved_by = nil + end +end + +local OwnershipChangedReactions = { + ConcreteOwnership = { + ConcreteOwnership = TryInterruptReservedOnDifferentOwner, + ForbiddenOwnership = TryInterruptReserved, + }, + SharedOwnership = { + ConcreteOwnership = TryInterruptReservedOnDifferentOwner, + ForbiddenOwnership = TryInterruptReserved, + } +} + +function OwnedObject:OnOwnershipChanged(prev_ownership, prev_owner) + local transition = table.get(OwnershipChangedReactions, prev_ownership, self.ownership) + if transition then + transition(self) + end +end + +----- OwnedObject helper functions + +function OwnedObject:TrySetConcreteOwnership(forced, owner) + return self:TrySetOwnership("ConcreteOwnership", forced, owner) +end + +function OwnedObject:SetConcreteOwnership(...) + return self:TrySetConcreteOwnership("forced", ...) +end + +function OwnedObject:HasConcreteOwnership() + return self.ownership == "ConcreteOwnership" +end + +function OwnedObject:TrySetSharedOwnership(forced, ...) + return self:TrySetOwnership("SharedOwnership", forced, ...) +end + +function OwnedObject:SetSharedOwnership(...) + return self:TrySetSharedOwnership("forced", ...) +end + +function OwnedObject:HasSharedOwnership() + return self.ownership == "SharedOwnership" +end + +function OwnedObject:TrySetForbiddenOwnership(forced, ...) + return self:TrySetOwnership("ForbiddenOwnership", forced, ...) +end + +function OwnedObject:SetForbiddenOwnership(...) + return self:TrySetForbiddenOwnership("forced", ...) +end + +function OwnedObject:HasForbiddenOwnership() + return self.ownership == "ForbiddenOwnership" +end + +----- OwnedObject helper functions end + +DefineClass.OwnedByUnit = { + __parents = { "OwnedObject" }, + properties = { + { id = "can_have_dead_owners", name = "Can have dead owners", editor = "bool", default = false, help = "If true, the object can have dead units as owners", }, + } +} + +function OwnedByUnit:CanBeOwnedBy(obj, ...) + if not self.can_have_dead_owners and obj:IsDead() then return end + return OwnedObject.CanBeOwnedBy(self, obj, ...) +end + +DefineClass.OwnerObject = { + __parents = { "ReserverObject" }, + owned_objects = false, +} + +function OwnerObject:Init() + self.owned_objects = {} +end + +function OwnerObject:Owns(object) + local ownership_class = object.ownership_class + if not ownership_class then return end + return self.owned_objects[ownership_class] == object +end + +function OwnerObject:DisownObjects(reason) + local owned_objects = self.owned_objects + for _, ownership_class in ipairs(owned_objects) do + local owned_object = owned_objects[ownership_class] + if owned_object and owned_object:CanDisown(self, reason) then + owned_object:Disown() + end + end +end + +function OwnerObject:GetOwnedObject(ownership_class) + assert(ownership_class) + return self.owned_objects[ownership_class] +end + +function OwnerObject:SetOwnedObject(owned_obj, ownership_class) + assert(not owned_obj or owned_obj:IsKindOf("OwnedObject")) + if owned_obj then + ownership_class = ownership_class or owned_obj.ownership_class + assert(ownership_class == owned_obj.ownership_class) + end + assert(ownership_class) + if not ownership_class then + return false + end + local prev_owned_obj = self:GetOwnedObject(ownership_class) + if prev_owned_obj == owned_obj then + return false + end + local owned_objects = self.owned_objects + + owned_objects[ownership_class] = owned_obj + table.remove_entry(owned_objects, ownership_class) + if owned_obj then + table.insert(owned_objects, ownership_class) + if prev_owned_obj then + prev_owned_obj:TrySetSharedOwnership() + end + owned_obj:TrySetConcreteOwnership(nil, self) + end + return true +end + +if Platform.developer then + +function OwnedObject:GetTestData(data) + data.ReservedBy = self.reserved_by +end + +end + +----- \ No newline at end of file diff --git a/CommonLua/Classes/BlendEntityObj.lua b/CommonLua/Classes/BlendEntityObj.lua new file mode 100644 index 0000000000000000000000000000000000000000..154c822360d095d09fb29ad348157d538d97de4a --- /dev/null +++ b/CommonLua/Classes/BlendEntityObj.lua @@ -0,0 +1,115 @@ +DefineClass.BlendEntityObj = { + __parents = { "Object" }, + properties = { + { category = "Blend", id = "BlendEntity1", name = "Entity 1", editor = "choice", default = "Human_Head_M_As_01", items = function (obj) return obj:GetBlendEntityList() end }, + { category = "Blend", id = "BlendWeight1", name = "Weight 1", editor = "number", default = 50, slider = true, min = 0, max = 100 }, + { category = "Blend", id = "BlendEntity2", name = "Entity 2", editor = "choice", default = "", items = function (obj) return obj:GetBlendEntityList() end }, + { category = "Blend", id = "BlendWeight2", name = "Weight 2", editor = "number", default = 0, slider = true, min = 0, max = 100 }, + { category = "Blend", id = "BlendEntity3", name = "Entity 3", editor = "choice", default = "", items = function (obj) return obj:GetBlendEntityList() end }, + { category = "Blend", id = "BlendWeight3", name = "Weight 3", editor = "number", default = 0, slider = true, min = 0, max = 100 }, + }, + entity = "Human_Head_M_Placeholder_01", +} + +function BlendEntityObj:GetBlendEntityList() + return { "" } +end + +local g_UpdateBlendObjs = {} +local g_UpdateBlendEntityThread = false + +function GetEntityIdleMaterial(entity) + return entity and entity ~= "" and GetStateMaterial(entity, "idle") or "" +end + +function BlendEntityObj:UpdateBlendInternal() + if (not self.BlendEntity1 or self.BlendWeight1 == 0) and + (not self.BlendEntity2 or self.BlendWeight2 == 0) and + (not self.BlendEntity3 or self.BlendWeight3 == 0) then + return + end + + local err = AsyncMeshBlend(self.entity, 0, + self.BlendEntity1, self.BlendWeight1, + self.BlendEntity2, self.BlendWeight2, + self.BlendEntity3, self.BlendWeight3) + if err then print("Failed to blend meshes: ", err) end + + do + local mat0 = GetEntityIdleMaterial(self.entity) + local mat1 = GetEntityIdleMaterial(self.BlendEntity1) + local mat2 = GetEntityIdleMaterial(self.BlendEntity2) + local mat3 = GetEntityIdleMaterial(self.BlendEntity3) + assert(mat0 ~= mat1 and mat0 ~= mat2 and mat0 ~= mat3) + end + + local sumBlends = self.BlendWeight1 + self.BlendWeight2 + self.BlendWeight2 + local blend2, blend3 = 0, 0 + if sumBlends ~= self.BlendWeight1 then + blend2 = self.BlendWeight2 * 100 / (sumBlends - self.BlendWeight1) + blend3 = self.BlendWeight3 * 100 / sumBlends + end + SetMaterialBlendMaterials(GetEntityIdleMaterial(self.entity), + GetEntityIdleMaterial(self.BlendEntity1), blend2, + GetEntityIdleMaterial(self.BlendEntity2), blend3, + GetEntityIdleMaterial(self.BlendEntity3)) + + self:ChangeEntity(self.entity) +end + +function BlendEntityObj:UpdateBlend() + g_UpdateBlendObjs[self] = true + if not g_UpdateBlendEntityThread then + g_UpdateBlendEntityThread = CreateRealTimeThread(function() + while true do + local obj, v = next(g_UpdateBlendObjs) + if obj == nil then + break + end + g_UpdateBlendObjs[obj] = nil + obj:UpdateBlendInternal() + end + g_UpdateBlendEntityThread = false + end) + end +end + +function BlendEntityObj:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "BlendEntity1" or prop_id == "BlendEntity2" or prop_id == "BlendEntity3" + or prop_id == "BlendWeight1" or prop_id == "BlendWeight2" or prop_id == "BlendWeight3" + then + self:UpdateBlend() + end +end + +function BlendTest() + local obj = BlendEntityObj:new() + obj:SetPos(GetTerrainCursor()) + ViewObject(obj) + editor.ClearSel() + editor.AddToSel({obj}) + OpenGedGameObjectEditor(editor.GetSel()) + return obj +end + +function BlendMatTest(weight2, weight3) + local obj = PlaceObj("Jacket_Nylon_M_Slim_01") + obj:SetPos(GetTerrainCursor()) + ViewObject(obj) + editor.ClearSel() + editor.AddToSel({obj}) + + local blendEntity1 = "Jacket_Nylon_M_Slim_01" + local blendEntity2 = "Jacket_Nylon_M_Skinny_01" + local blendEntity3 = "Jacket_Nylon_M_Chubby_01" + + weight2 = weight2 or 50 + weight3 = weight3 or 25 + + SetMaterialBlendMaterials(GetEntityIdleMaterial(obj:GetEntity()), + GetEntityIdleMaterial(blendEntity1), weight2, + GetEntityIdleMaterial(blendEntity2), weight3, + GetEntityIdleMaterial(blendEntity3)) + + return obj +end diff --git a/CommonLua/Classes/CameraEditor.lua b/CommonLua/Classes/CameraEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..b77a9f6aca0d32da274854cfd5cd50d49ef7e8fe --- /dev/null +++ b/CommonLua/Classes/CameraEditor.lua @@ -0,0 +1,261 @@ +if FirstLoad then + s_CameraFadeThread = false +end + +function DeleteCameraFadeThread() + if s_CameraFadeThread then + DeleteThread(s_CameraFadeThread) + s_CameraFadeThread = false + end +end + +function CameraShowClose(last_camera) + DeleteCameraFadeThread() + if last_camera then + last_camera:RevertProperties() + end + UnlockCamera("CameraPreset") +end + +function SwitchToCamera(camera, old_camera, in_between_callback, dont_lock, ged) + if not CanYield() then + DeleteCameraFadeThread() + s_CameraFadeThread = CreateRealTimeThread(function() + SwitchToCamera(camera, old_camera, in_between_callback, dont_lock, ged) + end) + return + end + + if IsEditorActive() then + editor.ClearSel() + editor.AddToSel({camera}) + end + if old_camera then + old_camera:RevertProperties(not(camera.flip_to_adjacent and old_camera.flip_to_adjacent)) + end + if in_between_callback then + in_between_callback() + end + camera:ApplyProperties(dont_lock, not(camera.flip_to_adjacent and old_camera.flip_to_adjacent), ged) +end + +function ShowPredefinedCamera(id) + local cam = PredefinedCameras[id] + if not cam then + print("No such camera preset: ", id) + return + end + CreateRealTimeThread(cam.ApplyProperties, cam, "dont_lock") +end + +function GedOpCreateCameraDest(ged, selected_camera) + if not selected_camera or not IsKindOf(selected_camera, "Camera") then return end + selected_camera:SetDest(selected_camera) + GedObjectModified(selected_camera) +end + +function GedOpUpdateCamera(ged, selected_camera) + if not selected_camera or not IsKindOf(selected_camera, "Camera") then return end + selected_camera:QueryProperties() + GedObjectModified(selected_camera) +end + +function GedOpViewMovement(ged, selected_camera) + if not selected_camera or not IsKindOf(selected_camera, "Camera") then return end + SwitchToCamera(selected_camera, nil, nil, "don't lock") +end + +function GedOpIsViewMovementToggled() + return not not GetDialog("Showcase") +end + +local function TakeCameraScreenshot(ged, path, sector, camera) + if GetMapName() ~= camera.map then + ChangeMap(camera.map) + end + + camera:ApplyProperties() + local oldInterfaceInScreenshot = hr.InterfaceInScreenshot + hr.InterfaceInScreenshot = camera.interface and 1 or 0 + + local image = string.format("%s/%s.png", path, sector) + AsyncFileDelete(image) + WaitNextFrame(3) + local store = {} + Msg("BeforeUpsampledScreenshot", store) + WaitNextFrame() + MovieWriteScreenshot(image, 0, 64, false, 3840, 2160) + WaitNextFrame() + Msg("AfterUpsampledScreenshot", store) + + hr.InterfaceInScreenshot = oldInterfaceInScreenshot + camera:RevertProperties() + return image +end + +function GedOpTakeScreenshots(ged, camera) + if not camera then return end + + local campaign = Game and Game.Campaign or rawget(_G, "DefaultCampaign") or "HotDiamonds" + local campaign_presets = rawget(_G, "CampaignPresets") or empty_table + local sectors = campaign_presets[campaign] and campaign_presets[campaign].Sectors or empty_table + local map_to_sector = {[false] = ""} + for _, sector in ipairs(sectors) do + if sector.Map then + map_to_sector[sector.Map] = sector.Id + end + end + + local path = string.format("svnAssets/Source/UI/LoadingScreens/%s", campaign) + local err = AsyncCreatePath(path) + if err then + local os_path = ConvertToOSPath(path) + ged:ShowMessage("Error", string.format("Can't create '%s' folder!", os_path)) + return + end + local ok, result = SVNAddFile(path) + if not ok then + ged:ShowMessage("SVN Error", result) + end + + StopAllHiding("CameraEditorScreenshots", 0, 0) + local size = UIL.GetScreenSize() + ChangeVideoMode(3840, 2160, 0, false, true) + WaitChangeVideoMode() + LockCamera("Screenshot") + + local images = {} + if IsKindOf(camera, "Camera") then + images[1] = TakeCameraScreenshot(ged, path, map_to_sector[camera.map], camera) + else + local cameras = IsKindOf(camera, "GedMultiSelectAdapter") and camera.__objects or camera + table.sort(cameras, function(a, b) return a.map < b.map end) + for _, cam in ipairs(cameras) do + table.insert(images, TakeCameraScreenshot(ged, path, map_to_sector[cam.map], cam)) + end + end + + UnlockCamera("Screenshot") + ChangeVideoMode(size:x(), size:y(), 0, false, true) + WaitChangeVideoMode() + ResumeAllHiding("CameraEditorScreenshots") + + local ok, result = SVNAddFile(images) + if not ok then + ged:ShowMessage("SVN Error", result) + end + print("Taking screenshots and adding to SubVersion done.") +end + +function OnMsg.GedOnEditorSelect(obj, selected, ged_editor) + if obj and IsKindOf(obj, "Camera") and selected then + SwitchToCamera(obj, IsKindOf(ged_editor.selected_object, "Camera") and ged_editor.selected_object, nil, "don't lock", ged_editor) + end +end + +function GedOpUnlockCamera() + camera.Unlock() +end + +function GedOpMaxCamera() + cameraMax.Activate(1) +end + +function GedOpTacCamera() + cameraTac.Activate(1) +end + +function GedOpRTSCamera() + cameraRTS.Activate(1) +end + +function GedOpSaveCameras() + local class = _G["Camera"] + class:SaveAll("save all", "user request") +end + +function GedOpCreateReferenceImages() + CreateReferenceImages() +end + +-- run to save a screenshot with every camera at correct video mode! +function CreateReferenceImages() + if not IsRealTimeThread() then + CreateRealTimeThread(CreateReferenceImages) + return + end + + local folder = "svnAssets/Tests/ReferenceImages" + local cameras = Presets.Camera["reference"] + SetMouseDeltaMode(true) + SetLightmodel(0, LightmodelPresets.ArtPreview, 0) + local size = UIL.GetScreenSize() + ChangeVideoMode(512, 512, 0, false, true) + WaitChangeVideoMode() + local created = 0 + for _, cam in ipairs(cameras) do + if GetMapName() ~= cam.map then + ChangeMap(cam.map) + end + cam:ApplyProperties() + Sleep(3000) + AsyncCreatePath(folder) + local image = string.format("%s/%s.png", folder, cam.id) + AsyncFileDelete(image) + if not WriteScreenshot(image, 512, 512) then + print(string.format("Failed to create screenshot '%s'", image)) + else + created = created + 1 + end + Sleep(300) + cam:RevertProperties() + end + SetMouseDeltaMode(false) + ChangeVideoMode(size:x(), size:y(), 0, false, true) + WaitChangeVideoMode() + print(string.format("Creating %d reference images in '%s' finished.", created, folder)) +end + +function GetShowcaseCameras(context) + local cameras = Presets.Camera[context and context.group or "reference"] or {} + table.sort(cameras, function(a, b) + if a.map==b.map then + return a.order < b.order + else + return a.map best_value:Len2() then + best_value = value + end + end + end + end + return best_value +end + +function CharacterControl:CallBindingsDown(bindings, param, time) + for i = 1, #bindings do + local binding = bindings[i] + if self:BindingModifiersActive(binding) then + local result = binding.func(self.character, self, param, time) + if result ~= "continue" then + return result + end + end + end + return "continue" +end + +function CharacterControl:CallBindingsUp(bindings) + for i = 1, #bindings do + local binding = bindings[i] + local value = self:GetBindingsCombinedValue(binding.action) + if not value then + local result = binding.func(self.character, self) + if result ~= "continue" then + return result + end + end + end + return "continue" +end + +function CharacterControl:SyncBindingsWithCharacter(bindings) + for i = 1, #bindings do + local binding = bindings[i] + local value = binding.action and self:GetBindingsCombinedValue(binding.action) + binding.func(self.character, self, value) + end +end + +function CharacterControl:SyncWithCharacter() +end + +function BindToKeyboardAndMouseSync(action) + local class = _G["CCA_"..action] + assert(class) + if class then + class:BindToControllerSync(action, CC_KeyboardAndMouseSync) + end +end + +function BindToXboxControllerSync(action) + local class = _G["CCA_"..action] + assert(class) + if class then + class:BindToControllerSync(action, CC_XboxControllerSync) + end +end + +-- CharacterControlAction + +DefineClass.CharacterControlAction = { + __parents = {}, + ActionStop = false, + IsKindOf = IsKindOf, + HasMember = PropObjHasMember, +} + +function CharacterControlAction:Action(character) + print("No Action defined: " .. self.class) + return "continue" +end +function OnMsg.ClassesPostprocess() + ClassDescendants("CharacterControlAction", function(class_name, class) + if class.GetAction == CharacterControlAction.GetAction and class.Action then + local f = function(...) + return class:Action(...) + end + class.GetAction = function() return f end + end + if class.GetActionSync == CharacterControlAction.GetActionSync and class.ActionStop then + local action_name = string.sub(class_name, #"CCA_" + 1) + local function f(character, controller) + local value = controller:GetBindingsCombinedValue(action_name) + if not value then + class:ActionStop(character, controller) + end + return "continue" + end + class.GetActionSync = function() return f end + end + end) +end +function CharacterControlAction:GetAction() +end +function CharacterControlAction:GetActionSync() +end + +function CharacterControlAction:BindToControllerSync(action, bindings) + local f = self:GetActionSync() + if f and not table.find(bindings, "func", f) then + table.insert(bindings, { action = action, func = f }) + end +end + +function CharacterControlAction:BindKey(action, key, mod1, mod2) + local f = self:GetAction() + if f then + if mod1 == "double-click" then + BindToKeyboardEvent(action, "double-click", f, key, mod2) + elseif mod1 == "hold" then + BindToKeyboardEvent(action, "hold", f, key, mod2) + else + BindToKeyboardEvent(action, "down", f, key, mod1, mod2) + end + end + if mod1 == "double-click" or mod1 == "hold" then + mod1, mod2 = mod2, nil + end + f = self:GetActionSync() + if f then + BindToKeyboardEvent(action, "up", f, key) + if mod1 then + BindToKeyboardEvent(action, "up", f, mod1) + end + if mod2 then + BindToKeyboardEvent(action, "up", f, mod2) + end + end +end + +function CharacterControlAction:BindMouse(action, button, key_mod) + assert(key_mod ~= "double-click" and key_mod ~= "hold", "Not supported mouse modifiers") + local f = self:GetAction() + if f then + if button == "MouseMove" then + BindToMouseEvent(action, "mouse_move", f, nil, key_mod) + else + BindToMouseEvent(action, "down", f, button, key_mod) + end + end + f = self:GetActionSync() + if f then + if button ~= "MouseMove" then + BindToMouseEvent(action, "up", f, button) + end + if key_mod then + BindToKeyboardEvent(action, "up", f, key_mod) + end + end +end + +function CharacterControlAction:BindXboxController(action, button, mod1, mod2) + local f = self:GetAction() + if f then + if mod1 == "hold" then + BindToXboxControllerEvent(action, "hold", f, button, mod2) + else + BindToXboxControllerEvent(action, "down", f, button, mod1, mod2) + end + end + if mod1 == "hold" then + mod1, mod2 = mod2, nil + end + f = self:GetActionSync() + if f then + BindToXboxControllerEvent(action, "up", f, button) + if mod1 then + BindToXboxControllerEvent(action, "up", f, mod1) + end + if mod2 then + BindToXboxControllerEvent(action, "up", f, mod2) + end + end +end + +-- Navigation + +if FirstLoad then + UpdateCharacterNavigationThread = false +end + +function OnMsg.DoneMap() + UpdateCharacterNavigationThread = false +end + +local function CalcNavigationVector(controller, camera_view) + local pt = controller:GetBindingsCombinedValue("Move_Direction") + if pt then + return Rotate(pt:SetX(-pt:x()), XControlCameraGetYaw(camera_view) - 90*60) + end + local x = (controller:GetBindingsCombinedValue("Move_CameraRight") and 32767 or 0) + (controller:GetBindingsCombinedValue("Move_CameraLeft") and -32767 or 0) + local y = (controller:GetBindingsCombinedValue("Move_CameraForward") and 32767 or 0) + (controller:GetBindingsCombinedValue("Move_CameraBackward") and -32767 or 0) + if x ~= 0 or y ~= 0 then + return Rotate(point(-x,y), XControlCameraGetYaw(camera_view) - 90*60) + end +end + +function UpdateCharacterNavigation(character, controller) + local dir = CalcNavigationVector(controller, character.camera_view) + character:SetStateContext("navigation_vector", dir) + if dir and not IsValidThread(UpdateCharacterNavigationThread) then + UpdateCharacterNavigationThread = CreateMapRealTimeThread(function() + repeat + Sleep(20) + if IsPaused() then break end + local update + for loc_player = 1, LocalPlayersCount do + local o = PlayerControlObjects[loc_player] + if o and o.controller then + local dir = CalcNavigationVector(o.controller, o.camera_view) + o:SetStateContext("navigation_vector", dir) + update = update or dir and true + end + end + until not update + UpdateCharacterNavigationThread = false + end) + end + return "continue" +end + +DefineClass("CCA_Navigation", "CharacterControlAction") +DefineClass("CCA_Move_CameraForward", "CCA_Navigation") +DefineClass("CCA_Move_CameraBackward", "CCA_Navigation") +DefineClass("CCA_Move_CameraLeft", "CCA_Navigation") +DefineClass("CCA_Move_CameraRight", "CCA_Navigation") + +function CCA_Navigation:BindKey(action, key) + BindToKeyboardEvent(action, "down", UpdateCharacterNavigation, key) + BindToKeyboardEvent(action, "up", UpdateCharacterNavigation, key) +end +function CCA_Navigation:BindXboxController(action, button) + BindToXboxControllerEvent(action, "down", UpdateCharacterNavigation, button) + BindToXboxControllerEvent(action, "up", UpdateCharacterNavigation, button) +end +function CCA_Navigation:GetActionSync() + return UpdateCharacterNavigation +end + +-- Move_Direction +DefineClass("CCA_Move_Direction", "CharacterControlAction") +function CCA_Move_Direction:BindKey(action, key, mod1, mod2) + assert(false, "Can't bind 2D direction to a key") +end +function CCA_Move_Direction:BindMouse(action, button, key_mod) + assert(false, "Mouse cursor could be converted to a direction. Not implemented.") +end +function CCA_Move_Direction:BindXboxController(action, button) + assert(button == "LeftThumb" or button == "RightThumb") + BindToXboxControllerEvent(action, "change", UpdateCharacterNavigation, button) +end +function CCA_Move_Direction:GetActionSync() + return UpdateCharacterNavigation +end + +-- RotateCamera +function UpdateCameraRotate(character, controller) + if not g_LookAtObjectSA then + local dir = (controller:GetBindingsCombinedValue("CameraRotate_Left") and -1 or 0) + (controller:GetBindingsCombinedValue("CameraRotate_Right") and 1 or 0) + camera3p.SetAutoRotate(90*60*dir) + end + return "continue" +end + +DefineClass("CCA_CameraRotate", "CharacterControlAction") +DefineClass("CCA_CameraRotate_Left", "CCA_CameraRotate") +DefineClass("CCA_CameraRotate_Right", "CCA_CameraRotate") + +function CCA_CameraRotate:BindKey(action, key) + BindToKeyboardEvent(action, "down", UpdateCameraRotate, key) + BindToKeyboardEvent(action, "up", UpdateCameraRotate, key) +end +function CCA_CameraRotate:BindXboxController(action, button) + BindToXboxControllerEvent(action, "down", UpdateCameraRotate, button) + BindToXboxControllerEvent(action, "up", UpdateCameraRotate, button) +end +function CCA_CameraRotate:GetActionSync() + return UpdateCameraRotate +end + +if FirstLoad then + InGameMouseCursor = false +end + +-- CameraRotate_Mouse +DefineClass("CCA_CameraRotate_Mouse", "CharacterControlAction") +function CCA_CameraRotate_Mouse:Action(character) + if not (character and character.controller and character.controller.camera_active) then + return "continue" + end + if InGameMouseCursor then + HideMouseCursor("InGameCursor") -- although MouseRotate(true) hides the mouse, IsMouseCursorHidden() depends on it + else + SetMouseDeltaMode(false) + end + MouseRotate(true) + Msg("CameraRotateStart", "mouse") + return "break" +end +function CCA_CameraRotate_Mouse:ActionStop(character) + MouseRotate(false) + if InGameMouseCursor then + ShowMouseCursor("InGameCursor") + else + HideMouseCursor("InGameCursor") + SetMouseDeltaMode(true) -- prevents the mouse to leave the game window + end + Msg("CameraRotateStop", "mouse") + return "continue" +end +function CCA_CameraRotate_Mouse:GetActionSync(character, controller) + local function f(character, controller) + local value = not CameraLocked and (MouseRotateCamera == "always" or controller:GetBindingsCombinedValue("CameraRotate_Mouse")) + if value then + return self:Action(character, controller) + else + return self:ActionStop(character, controller) + end + end + return f +end + +-- KeyboardAndMouse Control + +DefineClass.CC_KeyboardAndMouse = { + __parents = { "CharacterControl" }, + KeyHoldButtonTime = 350, + KeyDoubleClickTime = 300, + key_hold_thread = false, + key_last_double_click = false, + key_last_double_click_time = 0, +} + +function CC_KeyboardAndMouse:OnActivate() + CharacterControl.OnActivate(self) + if InGameMouseCursor then + ShowMouseCursor("InGameCursor") + end +end + +function CC_KeyboardAndMouse:OnInactivate() + CharacterControl.OnInactivate(self) + DeleteThread(self.key_hold_thread) + self.key_hold_thread = nil + self.key_last_double_click = nil + self.key_last_double_click_time = nil + HideMouseCursor("InGameCursor") + MouseRotate(false) +end + +function CC_KeyboardAndMouse:SetCameraActive(active) + CharacterControl.SetCameraActive(self, active) + if self.active and not self.camera_active then + MouseRotate(false) + end +end + +function CC_KeyboardAndMouse:GetActionBindings(action) + return CC_KeyboardAndMouse_ActionBindings[action] +end + +function CC_KeyboardAndMouse:GetBindingValue(binding) + if not self.active or binding.key and not terminal.IsKeyPressed(binding.key) then + return false + end + if binding.mouse_button then + local pressed = self:IsMouseButtonPressed(binding.mouse_button) + if pressed == false then + return false + end + end + if not self:BindingModifiersActive(binding) then + return false + end + return true +end + +function CC_KeyboardAndMouse:IsMouseButtonPressed(button) + local pressed, _ + if button == "LButton" then + pressed = terminal.IsLRMX1X2MouseButtonPressed() + elseif button == "RButton" then + _, pressed = terminal.IsLRMX1X2MouseButtonPressed() + elseif button == "MButton" then + _, _, pressed = terminal.IsLRMX1X2MouseButtonPressed() + elseif button == "XButton1" then + _, _, _, pressed = terminal.IsLRMX1X2MouseButtonPressed() + elseif button == "XButton2" then + _, _, _, _, pressed = terminal.IsLRMX1X2MouseButtonPressed() + elseif button == "MouseWheelFwd" or button == "MouseWheelBack" then + return false + end + return pressed +end + +function CC_KeyboardAndMouse:BindingModifiersActive(binding) + local keys = binding.key_modifiers + if keys then + for i = 1, #keys do + local key_or_button = keys[i] + if key_or_button == "MouseWheelFwd" or key_or_button == "MouseWheelBack" then + return false + end + local pressed = self:IsMouseButtonPressed(key_or_button) + if pressed == nil then + pressed = terminal.IsKeyPressed(key_or_button) + end + if not pressed then + return false + end + end + end + return true +end + +-- keyboard events +function CC_KeyboardAndMouse:OnKbdKeyDown(virtual_key, repeated, time) + if repeated or not self.active then + return "continue" + end + -- double click + if CC_KeyboardKeyDoubleClick[virtual_key] then + if self.key_last_double_click == virtual_key and RealTime() - self.key_last_double_click_time < self.KeyDoubleClickTime then + self.key_last_double_click = false + self:CallBindingsDown(CC_KeyboardKeyDoubleClick[virtual_key], true, time) + else + self.key_last_double_click = virtual_key + self.key_last_double_click_time = RealTime() + end + end + -- hold + if CC_KeyboardKeyHold[virtual_key] then + DeleteThread(self.key_hold_thread) + self.key_hold_thread = CreateRealTimeThread(function(self, virtual_key, time) + Sleep(self.KeyHoldButtonTime) + self.key_hold_thread = false + if terminal.IsKeyPressed(virtual_key) then + self:CallBindingsDown(CC_KeyboardKeyHold[virtual_key], true, time) + end + end, self, virtual_key, time) + end + -- down + local result + if CC_KeyboardKeyDown[virtual_key] then + result = self:CallBindingsDown(CC_KeyboardKeyDown[virtual_key], true, time) + end + return result or "continue" +end + +function CC_KeyboardAndMouse:OnKbdKeyUp(virtual_key) + if not self.active then + return "continue" + end + if CC_KeyboardKeyHold[virtual_key] and self.key_hold_thread then + DeleteThread(self.key_hold_thread) + self.key_hold_thread = false + end + if CC_KeyboardKeyUp[virtual_key] then + local result = self:CallBindingsUp(CC_KeyboardKeyUp[virtual_key]) + return result + end + return "continue" +end + +-- mouse events + +function CC_KeyboardAndMouse:OnMouseButtonDown(button, pt, time) + if not self.active then + return "continue" + end + if CC_MouseButtonDown[button] then + local result = self:CallBindingsDown(CC_MouseButtonDown[button], true, time) + if result ~= "continue" then + return result + end + end + return "continue" +end + +function CC_KeyboardAndMouse:OnMouseButtonUp(button, pt, time) + if not self.active then + return "continue" + end + if CC_MouseButtonUp[button] then + local result = self:CallBindingsUp(CC_MouseButtonUp[button], false, time) + if result ~= "continue" then + return result + end + end + return "continue" +end + +function CC_KeyboardAndMouse:OnLButtonDown(...) + return self:OnMouseButtonDown("LButton", ...) +end +function CC_KeyboardAndMouse:OnLButtonUp(...) + return self:OnMouseButtonUp("LButton", ...) +end +function CC_KeyboardAndMouse:OnLButtonDoubleClick(...) + return self:OnMouseButtonDown("LButton", ...) +end +function CC_KeyboardAndMouse:OnRButtonDown(...) + return self:OnMouseButtonDown("RButton", ...) +end +function CC_KeyboardAndMouse:OnRButtonUp(...) + return self:OnMouseButtonUp("RButton", ...) +end +function CC_KeyboardAndMouse:OnRButtonDoubleClick(...) + return self:OnMouseButtonDown("RButton", ...) +end +function CC_KeyboardAndMouse:OnMButtonDown(...) + return self:OnMouseButtonDown("MButton", ...) +end +function CC_KeyboardAndMouse:OnMButtonUp(...) + return self:OnMouseButtonUp("MButton", ...) +end +function CC_KeyboardAndMouse:OnMButtonDoubleClick(...) + return self:OnMouseButtonDown("MButton", ...) +end +function CC_KeyboardAndMouse:OnXButton1Down(...) + return self:OnMouseButtonDown("XButton1", ...) +end +function CC_KeyboardAndMouse:OnXButton1Up(...) + return self:OnMouseButtonUp("XButton1", ...) +end +function CC_KeyboardAndMouse:OnXButton1DoubleClick(...) + return self:OnMouseButtonDown("XButton1", ...) +end +function CC_KeyboardAndMouse:OnXButton2Down(...) + return self:OnMouseButtonDown("XButton2", ...) +end +function CC_KeyboardAndMouse:OnXButton2Up(...) + return self:OnMouseButtonUp("XButton2", ...) +end +function CC_KeyboardAndMouse:OnXButton2DoubleClick(...) + return self:OnMouseButtonDown("XButton2", ...) +end + +function CC_KeyboardAndMouse:OnMouseWheelForward(pt, time) + if not self.active then + return "continue" + end + local result = self:CallBindingsDown(CC_MouseWheelFwd, true, time) + if result ~= "break" then + result = self:CallBindingsDown(CC_MouseWheel, 1, time) + end + return result +end +function CC_KeyboardAndMouse:OnMouseWheelBack(pt, time) + if not self.active then + return "continue" + end + local result = self:CallBindingsDown(CC_MouseWheelBack, true, time) + if result ~= "break" then + result = self:CallBindingsDown(CC_MouseWheel, -1, time) + end + return result +end + +function CC_KeyboardAndMouse:OnMousePos(pt, time) + if not self.active then + return "continue" + end + local result = self:CallBindingsDown(CC_MouseMove, pt, time) + return result +end + +function CC_KeyboardAndMouse:SyncWithCharacter() + self:SyncBindingsWithCharacter(CC_KeyboardAndMouseSync) +end + +local function ResetKeyboardAndMouseBindings() + CC_KeyboardKeyDown = {} + CC_KeyboardKeyUp = {} + CC_KeyboardKeyHold = {} + CC_KeyboardKeyDoubleClick = {} + CC_MouseButtonDown = {} + CC_MouseButtonUp = {} + CC_MouseWheel = {} + CC_MouseWheelFwd = {} + CC_MouseWheelBack = {} + CC_MouseMove = {} + CC_KeyboardAndMouse_ActionBindings = {} + CC_KeyboardAndMouseSync = {} +end + +if FirstLoad then + ResetKeyboardAndMouseBindings() +end + +function BindKey(action, key, mod1, mod2) + local class = _G["CCA_"..action] + assert(class) + if class then + class:BindKey(action, key, mod1, mod2) + end +end + +function BindMouse(action, button, key_mod) + local class = _G["CCA_"..action] + assert(class) + if class then + class:BindMouse(action, button, key_mod) + end +end + +local function ResolveRefBindings(list, bindings) + for i = 1, #list do + local action = list[i][1] + local blist = bindings[action] + for j = #blist, 1, -1 do + local binding = blist[j] + for k = #binding, 1, -1 do + local ref = bindings[binding[k]] + if ref then + if #ref == 0 then + table.remove(blist,j) + else + table.remove(binding, k) + for m = 2, #ref do + table.insert(blist, j, table.copy(binding)) + end + for m = 1, #ref do + local rt = ref[m] + local binding_mod = blist[j+m-1] + for n = #rt, 1, -1 do + table.insert(binding_mod, k+n-1, rt[n]) + end + end + end + end + end + end + end +end + +function ReloadKeyboardAndMouseBindings(default_bindings, predefined_bindings) + ResetKeyboardAndMouseBindings() + if not default_bindings then + return + end + local bindings = {} + for i = 1, #default_bindings do + local default_list = default_bindings[i] + local action = default_list[1] + bindings[action] = {} + local predefined_list = predefined_bindings and predefined_bindings[action] + for j = 1, Max(predefined_list and #predefined_list or 0, #default_list-1) do + local binding = predefined_list and predefined_list[j] or nil + if binding == nil then + binding = default_list and default_list[j+1] + end + if binding and #binding > 0 then + local t = {} + for k = 1, #binding do + t[k] = type(binding[k]) == "string" and const["vk"..binding[k]] or binding[k] + end + table.insert(bindings[action], t) + end + end + end + ResolveRefBindings(default_bindings, bindings) + for i = 1, #default_bindings do + local action = default_bindings[i][1] + local blist = bindings[action] + for j = 1, #blist do + local binding = blist[j] + if type(binding[1]) == "number" then + BindKey(action, binding[1], binding[2], binding[3]) + else + BindMouse(action, binding[1], binding[2], binding[3]) + end + if binding[2] then + if type(binding[2]) == "number" then + BindKey(action, binding[2], binding[1], binding[3]) + else + BindMouse(action, binding[2], binding[1], binding[3]) + end + end + if binding[3] then + if type(binding[3]) == "number" then + BindKey(action, binding[3], binding[1], binding[2]) + else + BindMouse(action, binding[3], binding[1], binding[2]) + end + end + end + BindToKeyboardAndMouseSync(action) + end +end + +function BindToKeyboardEvent(action, event, func, key, mod1, mod2) + local binding = { action = action, key = key, func = func } + if mod1 or mod2 then + binding.key_modifiers = {} + binding.key_modifiers[#binding.key_modifiers+1] = mod1 + binding.key_modifiers[#binding.key_modifiers+1] = mod2 + end + local list + if event == "down" then + list = CC_KeyboardKeyDown + CC_KeyboardAndMouse_ActionBindings[action] = CC_KeyboardAndMouse_ActionBindings[action] or {} + table.insert(CC_KeyboardAndMouse_ActionBindings[action], binding) + elseif event == "up" then + list = CC_KeyboardKeyUp + elseif event == "hold" then + list = CC_KeyboardKeyHold + elseif event == "double-click" then + list = CC_KeyboardKeyDoubleClick + end + list[key] = list[key] or {} + table.insert(list[key], binding) +end + +function BindToMouseEvent(action, event, func, button, key_mod) + local binding = { action = action, mouse_button = button, func = func } + if key_mod then + binding.key_modifiers = {} + binding.key_modifiers[#binding.key_modifiers+1] = key_mod + end + if event == "down" or button == "MouseWheel" then + CC_KeyboardAndMouse_ActionBindings[action] = CC_KeyboardAndMouse_ActionBindings[action] or {} + table.insert(CC_KeyboardAndMouse_ActionBindings[action], binding) + end + if button == "MouseWheel" then + table.insert(CC_MouseWheel, binding) + elseif button == "MouseWheelFwd" then + table.insert(CC_MouseWheelFwd, binding) + elseif button == "MouseWheelBack" then + table.insert(CC_MouseWheelBack, binding) + elseif event == "down" then + CC_MouseButtonDown[button] = CC_MouseButtonDown[button] or {} + table.insert(CC_MouseButtonDown[button], binding) + elseif event == "up" then + CC_MouseButtonUp[button] = CC_MouseButtonUp[button] or {} + table.insert(CC_MouseButtonUp[button], binding) + elseif event == "mouse_move" then + table.insert(CC_MouseMove, binding) + end +end + + +-- XboxController + +DefineClass.CC_XboxController = { + __parents = { "CharacterControl" }, + xbox_controller_id = false, + XboxHoldButtonTime = 350, + xbox_hold_thread = false, + XBoxComboButtonsDelay = 100, + xbox_last_combo_button = false, + xbox_last_combo_button_time = 0, +} + +function CC_XboxController:Init(character, controller_id) + self.xbox_controller_id = controller_id +end + +function CC_XboxController:OnActivate() + CharacterControl.OnActivate(self) + if self.xbox_controller_id and self.camera_active then + camera3p.EnableController(self.xbox_controller_id) + end +end + +function CC_XboxController:SetCameraActive(active) + CharacterControl.SetCameraActive(self, active) + if self.xbox_controller_id and self.active then + if self.camera_active then + camera3p.EnableController(self.xbox_controller_id) + else + camera3p.DisableController(self.xbox_controller_id) + end + end +end + +function CC_XboxController:OnInactivate() + CharacterControl.OnInactivate(self) + DeleteThread(self.xbox_hold_thread) + self.xbox_hold_thread = nil + if self.xbox_controller_id then + XInput.SetRumble(self.xbox_controller_id, 0, 0) + camera3p.DisableController(self.xbox_controller_id) + end +end + +function CC_XboxController:GetActionBindings(action) + return CC_XboxController_ActionBindings[action] +end + +function CC_XboxController:GetBindingValue(binding) + if not self.active then + return + end + local button = binding.xbutton + if button and not XInput.IsCtrlButtonPressed(self.xbox_controller_id, button) then + return + end + if not self:BindingModifiersActive(binding) then + return + end + local value = XInput.CurrentState[self.xbox_controller_id][button] + return value +end + +function CC_XboxController:BindingModifiersActive(binding) + local buttons = binding.x_modifiers + if buttons then + for i = 1, #buttons do + if not XInput.IsCtrlButtonPressed(self.xbox_controller_id, buttons[i]) then + return false + end + end + end + return true +end + +function CC_XboxController:OnXButtonDown(button, controller_id) + if not self.active or controller_id ~= self.xbox_controller_id then + return "continue" + end + -- hold + if CC_XboxButtonHold[button] then + DeleteThread(self.xbox_hold_thread) + self.xbox_hold_thread = CreateRealTimeThread(function(self, button, controller_id) + Sleep(self.XboxHoldButtonTime) + self.xbox_hold_thread = false + if XInput.IsCtrlButtonPressed(self.xbox_controller_id, button) then + local xstate = XInput.CurrentState[controller_id] + self:CallBindingsDown(CC_XboxButtonHold[button], xstate[button]) + end + end, self, button, controller_id) + end + local result + if CC_XboxButtonDown[button] then + result = self:CallBindingsDown(CC_XboxButtonDown[button], true) + end + if CC_XboxButtonCombo[button] then + local handlers = self.xbox_last_combo_button and RealTime() - self.xbox_last_combo_button_time < self.XBoxComboButtonsDelay and CC_XboxButtonCombo[button][self.xbox_last_combo_button] + if handlers then + local result = self:CallBindingsDown(handlers, true) + if result and result ~= "continue" then + self.xbox_last_combo_button = false + return result + end + end + self.xbox_last_combo_button = button + self.xbox_last_combo_button_time = RealTime() + end + return result or "continue" +end + +function CC_XboxController:OnXButtonUp(button, controller_id) + if not self.active or controller_id ~= self.xbox_controller_id then + return "continue" + end + if self.xbox_last_combo_button == button then + self.xbox_last_combo_button = false + end + if CC_XboxButtonHold[button] and self.xbox_hold_thread then + DeleteThread(self.xbox_hold_thread) + self.xbox_hold_thread = false + end + if CC_XboxButtonUp[button] then + local result = self:CallBindingsUp(CC_XboxButtonUp[button]) + if result ~= "continue" then + return result + end + end + return "continue" +end + +function CC_XboxController:OnXNewPacket(_, controller_id, last_state, current_state) + if not self.active or controller_id ~= self.xbox_controller_id then + return "continue" + end + for i = 1, #CC_XboxControllerNewPacket do + local button = CC_XboxControllerNewPacket[i] + self:CallBindingsDown(CC_XboxControllerNewPacket[button], current_state[button]) + end + return "continue" +end + +function CC_XboxController:SyncWithCharacter() + self:SyncBindingsWithCharacter(CC_XboxControllerSync) +end + +local function ResetXboxControllerBindings() + CC_XboxButtonDown = {} + CC_XboxButtonUp = {} + CC_XboxButtonHold = {} + CC_XboxButtonCombo = {} + CC_XboxControllerNewPacket = {} + CC_XboxController_ActionBindings = {} + CC_XboxControllerSync = {} + table.insert(CC_XboxControllerSync,{ func = function() MouseRotate(false) end}) +end + +if FirstLoad then + ResetXboxControllerBindings() +end + +function ReloadXboxControllerBindings(default_bindings, predefined_bindings) + ResetXboxControllerBindings() + if not default_bindings then + return + end + local bindings = {} + for i = 1, #default_bindings do + local default_list = default_bindings[i] + local action = default_list[1] + bindings[action] = {} + local predefined_list = predefined_bindings and predefined_bindings[action] + for i = 1, Max(predefined_list and #predefined_list or 0, #default_list-1) do + local binding = predefined_list and predefined_list[i] or nil + if binding == nil then + binding = default_list and default_list[i+1] + end + if binding and #binding > 0 then + local t = {} + for k = 1, #binding do + t[k] = binding[k] + end + table.insert(bindings[action], t) + end + end + end + ResolveRefBindings(default_bindings, bindings) + for i = 1, #default_bindings do + local action = default_bindings[i][1] + local blist = bindings[action] + for j = 1, #blist do + local binding = blist[j] + BindXboxController(action, unpack_params(binding)) + end + BindToXboxControllerSync(action) + end +end + +function BindXboxController(action, button, mod1, mod2) + local class = _G["CCA_"..action] + assert(class) + if class then + class:BindXboxController(action, button, mod1, mod2) + end +end + +function BindToXboxControllerEvent(action, event, func, button, mod1, mod2) + if event == "sync" then + if action or not table.find(CC_XboxControllerSync, "func", func) then + local binding = { action = action, func = func } + table.insert(CC_XboxControllerSync, binding) + end + return + end + local binding = { action = action, xbutton = button, func = func } + if mod1 or mod2 then + binding.x_modifiers = {} + binding.x_modifiers[#binding.x_modifiers+1] = mod1 + binding.x_modifiers[#binding.x_modifiers+1] = mod2 + end + local list + if event == "down" then + CC_XboxController_ActionBindings[action] = CC_XboxController_ActionBindings[action] or {} + table.insert(CC_XboxController_ActionBindings[action], binding) + list = CC_XboxButtonDown + elseif event == "up" then + list = CC_XboxButtonUp + table.insert_unique(CC_XboxButtonUp, button) + elseif event == "hold" then + list = CC_XboxButtonHold + elseif event == "combo" then + list = CC_XboxButtonCombo + elseif event == "change" then + CC_XboxController_ActionBindings[action] = CC_XboxController_ActionBindings[action] or {} + table.insert(CC_XboxController_ActionBindings[action], binding) + table.insert_unique(CC_XboxControllerNewPacket, button) + list = CC_XboxControllerNewPacket + else + return + end + if not list[button] then + list[button] = {} + end + table.insert(list[button], binding) +end diff --git a/CommonLua/Classes/ClassDef.lua b/CommonLua/Classes/ClassDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..2f80f652bee9fe7d7b043fa04767be9d419297e3 --- /dev/null +++ b/CommonLua/Classes/ClassDef.lua @@ -0,0 +1,336 @@ +DefineClass.PropertyTabDef = { + __parents = { "PropertyObject" }, + properties = { + { id = "TabName", editor = "text", default = "" }, + { id = "Categories", editor = "set", default = {}, items = function(self) + local class_def = GetParentTableOfKind(self, "ClassDef") + local categories = {} + for _, classname in ipairs(class_def.DefParentClassList) do + local base = g_Classes[classname] + for _, prop_meta in ipairs(base and base:GetProperties()) do + categories[prop_meta.category or "Misc"] = true + end + end + for _, subitem in ipairs(class_def) do + if IsKindOf(subitem, "PropertyDef") then + categories[subitem.category or "Misc"] = true + end + end + return table.keys2(categories, "sorted") + end + } + }, + GetEditorView = function(self) + return string.format("%s - %s", self.TabName, table.concat(table.keys2(self.Categories or empty_table), ", ")) + end, +} + +DefineClass.ClassDef = { + __parents = { "Preset" }, + properties = { + { id = "DefParentClassList", name = "Parent classes", editor = "string_list", items = function(obj, prop_meta, validate_fn) + if validate_fn == "validate_fn" then + -- function for preset validation, checks whether the property value is from "items" + return "validate_fn", function(value, obj, prop_meta) + return value == "" or g_Classes[value] + end + end + return table.keys2(g_Classes, true, "") + end + }, + { id = "DefPropertyTranslation", name = "Translate property names", editor = "bool", default = false, }, + { id = "DefStoreAsTable", name = "Store as table", editor = "choice", default = "inherit", items = { "inherit", "true", "false" } }, + { id = "DefPropertyTabs", name = "Property tabs", editor = "nested_list", base_class = "PropertyTabDef", inclusive = true, default = false, }, + { id = "DefUndefineClass", name = "Undefine class", editor = "bool", default = false, }, + }, + DefParentClassList = { "PropertyObject" }, + + ContainerClass = "ClassDefSubItem", + PresetClass = "ClassDef", + FilePerGroup = true, + HasCompanionFile = true, + GeneratesClass = true, + DefineKeyword = "DefineClass", + + GedEditor = "ClassDefEditor", + EditorMenubarName = "Class definitions", + EditorIcon = "CommonAssets/UI/Icons/cpu.png", + EditorMenubar = "Editors.Engine", + EditorShortcut = "Ctrl-Alt-F3", + EditorViewPresetPrefix = "[Class] ", +} + +function ClassDef:FindSubitem(name) + for _, subitem in ipairs(self) do + if subitem:HasMember("name") and subitem.name == name or subitem:IsKindOf("PropertyDef") and subitem.id == name then + return subitem + end + end +end + +function ClassDef:GetDefaultPropertyValue(prop_id, prop_meta) + if prop_id:starts_with("Def") then + local class_prop_id = prop_id:sub(4) + -- try to find the default property value from the parent list + -- this is not correct if there are multiple parent classes that have different default values for the property + for i, class_name in ipairs(self.DefParentClassList) do + local class = g_Classes[class_name] + if class then + local default = class:GetDefaultPropertyValue(class_prop_id) + if default ~= nil then + return default + end + end + end + end + return Preset.GetDefaultPropertyValue(self, prop_id, prop_meta) +end + +function ClassDef:PostLoad() + for key, prop_def in ipairs(self) do + prop_def.translate_in_ged = self.DefPropertyTranslation + end + Preset.PostLoad(self) +end + +function ClassDef:OnPreSave() + -- convert texts to/from Ts if the 'translated' value changed + local translate = self.DefPropertyTranslation + for key, prop_def in ipairs(self) do + if IsKindOf(prop_def, "PropertyDef") then + local convert_text = function(value) + local prop_translated = not value or IsT(value) + if prop_translated and not translate then + return value and TDevModeGetEnglishText(value) or false + elseif not prop_translated and translate then + return value and value ~= "" and T(value) or false + end + return value + end + prop_def.name = convert_text(prop_def.name) + prop_def.help = convert_text(prop_def.help) + prop_def.translate_in_ged = translate + end + end +end + +function ClassDef:GenerateCompanionFileCode(code) + if self.DefUndefineClass then + code:append("UndefineClass('", self.id, "')\n") + end + code:append(self.DefineKeyword, ".", self.id, " = {\n") + self:GenerateParents(code) + self:AppendGeneratedByProps(code) + self:GenerateProps(code) + self:GenerateConsts(code) + code:append("}\n\n") + self:GenerateMethods(code) + self:GenerateGlobalCode(code) +end + +function ClassDef:GenerateParents(code) + local parents = self.DefParentClassList + if #(parents or "") > 0 then + code:append("\t__parents = { \"", table.concat(parents, "\", \""), "\", },\n") + end +end + +function ClassDef:GenerateProps(code) + local extra_code_fn = self.GeneratePropExtraCode ~= ClassDef.GeneratePropExtraCode and + function(prop_def) return self:GeneratePropExtraCode(prop_def) end + self:GenerateSubItemsCode(code, "PropertyDef", "\tproperties = {\n", "\t},\n", self.DefPropertyTranslation, extra_code_fn ) +end + +function ClassDef:GeneratePropExtraCode(prop_def) +end + +function ClassDef:AppendConst(code, prop_id, alternative_default, def_prop_id) + def_prop_id = def_prop_id or "Def" .. prop_id + local value = rawget(self, def_prop_id) + if value == nil then return end + local def_value = self:GetDefaultPropertyValue(def_prop_id) + if value ~= alternative_default and value ~= def_value then + code:append("\t", prop_id, " = ") + code:appendv(value) + code:append(",\n") + end +end + +function ClassDef:GenerateConsts(code) + if self.DefStoreAsTable ~= "inherit" then + code:append("\tStoreAsTable = ", self.DefStoreAsTable, ",\n") + end + if self.DefPropertyTabs then + code:append("\tPropertyTabs = ") + code:appendv(self.DefPropertyTabs, "\t") + code:append(",\n") + end + self:GenerateSubItemsCode(code, "ClassConstDef") +end + +function ClassDef:GenerateMethods(code) + self:GenerateSubItemsCode(code, "ClassMethodDef", "", "", self.id) +end + +function ClassDef:GenerateGlobalCode(code) + self:GenerateSubItemsCode(code, "ClassGlobalCodeDef", "", "", self.id) +end + +function ClassDef:GenerateSubItemsCode(code, subitem_class, prefix, suffix, ...) + local has_subitems + for i, prop in ipairs(self) do + if prop:IsKindOf(subitem_class) then + has_subitems = true + break + end + end + + if has_subitems then + if prefix then code:append(prefix) end + for i, prop in ipairs(self) do + if prop:IsKindOf(subitem_class) then + prop:GenerateCode(code, ...) + end + end + if suffix then code:append(suffix) end + end +end + +function ClassDef:GetCompanionFileSavePath(path) + if path:starts_with("Data") then + path = path:gsub("^Data", "Lua/ClassDefs") -- save in the game folder + elseif path:starts_with("CommonLua/Data") then + path = path:gsub("^CommonLua/Data", "CommonLua/Classes/ClassDefs") -- save in common lua + elseif path:starts_with("CommonLua/Libs/") then -- lib + path = path:gsub("/Data/", "/ClassDefs/") + else + path = path:gsub("^(svnProject/Dlc/[^/]*)/Presets", "%1/Code/ClassDefs") -- save in a DLC + end + return path:gsub(".lua$", ".generated.lua") +end + + +function ClassDef:GetError() + local names = {} + for _, element in ipairs(self or empty_table) do + local id = rawget(element, "id") or rawget(element, "id") + if id then + if names[id] then + return "Some class members have matching ids - '"..element.id.."'" + else + names[id] = true + end + end + end +end + +function GetTextFilePreview(path, lines_count, filter_func) + if lines_count and lines_count > 0 then + local file, err = io.open(path, "r") + if not err then + local count = 1 + local lines = {} + local line + while count <= lines_count do + line = file:read() + if line == nil then break end + for subline in line:gmatch("[^%\r?~%\n?]+") do + if count == lines_count + 1 or (filter_func and filter_func(subline)) then + break + end + lines[#lines + 1] = subline + count = count + 1 + end + end + lines[#lines + 1] = "" + lines[#lines + 1] = "..." + file:close() + return table.concat(lines, "\n") + end + end +end + +local function CleanUpHTMLTags(text) + text = text:gsub("
", "\n") + text = text:gsub("
", "\n") + text = text:gsub("", "") + text = text:gsub("", "") + text = text:gsub("", "") + text = text:gsub("", "") + return text +end + +function GetDocumentation(obj) + if type(obj) == "table" and PropObjHasMember(obj, "Documentation") and obj.Documentation and obj.Documentation ~= "" then + return obj.Documentation + end +end + +function GetDocumentationLink(obj) + if type(obj) == "table" and PropObjHasMember(obj, "DocumentationLink") and obj.DocumentationLink and obj.DocumentationLink ~= "" then + local link = obj.DocumentationLink + assert(link:starts_with("Docs/")) + if not link:starts_with("http") then + link = ConvertToOSPath(link) + end + link = string.gsub(link, "[\n\r]", "") + link = string.gsub(link, " ", "%%20") + return link + end +end + +function GedOpenDocumentationLink(root, obj, prop_id, ged, btn_param, idx) + OpenUrl(GetDocumentationLink(obj), "force external browser") +end + + +----- AppendClassDef + +DefineClass.AppendClassDef = { + __parents = { "ClassDef" }, + properties = { + { id = "DefUndefineClass", editor = false, }, + + }, + GeneratesClass = false, + DefParentClassList = false, + DefineKeyword = "AppendClass", +} + + +----- ListPreset + +DefineClass.ListPreset = { + __parents = { "Preset", }, + HasGroups = false, + HasSortKey = true, + EditorMenubar = "Editors.Lists", +} + +-- deprecated and left for compatibility reasons, to be removed +DefineClass.ListItem = { + __parents = { "Preset", }, + properties = { + { id = "Group", no_edit = false, }, + }, + HasSortKey = true, + PresetClass = "ListItem", +} + + +----- + +if Platform.developer and not Platform.ged then + function RemoveUnversionedClassdefs() + local err, files = AsyncListFiles("svnProject/../", "*.lua", "recursive") + local removed = 0 + for _, file in ipairs(files) do + if string.match(file, "ClassDef%-.*%.lua$") and not SVNLocalInfo(file) then + print("removing", file) + os.remove(file) + removed = removed + 1 + end + end + print(removed, "files removed") + end +end \ No newline at end of file diff --git a/CommonLua/Classes/ClassDefFunctionObjects.lua b/CommonLua/Classes/ClassDefFunctionObjects.lua new file mode 100644 index 0000000000000000000000000000000000000000..fcb758daf80fab3347d2911cc986de782182999b --- /dev/null +++ b/CommonLua/Classes/ClassDefFunctionObjects.lua @@ -0,0 +1,930 @@ +local hintColor = RGB(210, 255, 210) +local procall = procall + +----- FunctionObject (with paremeters specified in properties, used as building block in game content editors, e.g. story bits) + +DefineClass.FunctionObject = { + __parents = { "PropertyObject" }, + RequiredObjClasses = false, + ForbiddenObjClasses = false, + Description = "", + ComboFormat = T(623739770783, ""), + EditorNestedObjCategory = "General", + StoreAsTable = true, +} + +function FunctionObject:GetDescription() + return self.Description +end + +function FunctionObject:GetEditorView() + return self.EditorView ~= PropertyObject.EditorView and self.EditorView or self:GetDescription() +end + +function FunctionObject:GetRequiredClassesFormatted() + if not self.RequiredObjClasses then return end + local classes = {} + for _, id in ipairs(self.RequiredObjClasses) do + classes[#classes + 1] = id:lower() + end + return Untranslated("(" .. table.concat(classes, ", ") .. ")") +end + +function FunctionObject:ValidateObject(obj, parentobj_text, ...) + if not self.RequiredObjClasses and not self.ForbiddenObjClasses then return true end + local valid = obj and type(obj) == "table" + if valid then + if self.RequiredObjClasses and not obj:IsKindOfClasses(self.RequiredObjClasses) then + valid = false + parentobj_text = string.concat("", parentobj_text, ...) or "Unknown" + assert(valid, string.format("%s: Object for %s must be of class %s!\n(Current class is %s)", + parentobj_text, self.class, table.concat(self.RequiredObjClasses, " or "), obj.class)) + end + if self.ForbiddenObjClasses and obj:IsKindOfClasses(self.ForbiddenObjClasses) then + valid = false + parentobj_text = string.concat("", parentobj_text, ...) or "Unknown" + assert(valid, string.format("%s: Object for %s must not be of class %s!", + parentobj_text, self.class, table.concat(self.ForbiddenObjClasses, " or "))) + end + end + return valid +end + +function FunctionObject:HasNonPropertyMembers() + local properties = self:GetProperties() + for key, value in pairs(self) do + if key ~= "container" and key ~= "CreateInstance" and key ~= "StoreAsTable" and key ~= "param_bindings" and not table.find(properties, "id", key) then + return key + end + end +end + +function FunctionObject:GetError() + if self:HasNonPropertyMembers() then + return "An Effect or Condition object must NOT keep internal state. For ContinuousEffects that need to have dynamic members, please set the CreateInstance class constant to 'true'." + end +end + +function FunctionObject:TestInGed(subject, ged, context) + if self.RequiredObjClasses or self.ForbiddenObjClasses then + if self.RequiredObjClasses and not IsKindOfClasses(subject, self.RequiredObjClasses) then + local msg = string.format("%s requires an object of class %s!\n(Current class is '%s')", + self.class, table.concat(self.RequiredObjClasses, " or "), subject and subject.class or "") + ged:ShowMessage("Test Result", msg) + return + end + if self.ForbiddenObjClasses and IsKindOfClasses(subject, self.ForbiddenObjClasses) then + local msg = string.format("%s requires an object not of class %s!\n", + self.class, table.concat(self.ForbiddenObjClasses, " or ")) + ged:ShowMessage("Test Result", msg) + return + end + end + local result, err, ok + if self:HasMember("Evaluate") then + result, err = self:Evaluate(subject, context) + ok = true + else + ok, result = self:Execute(subject, context) + end + if err then + ged:ShowMessage("Test Result", string.format("%s returned an error %s.", self.class, tostring(err))) + elseif not ok then + ged:ShowMessage("Test Result", string.format("%s returned an error %s.", self.class, tostring(result))) + elseif type(result) == "table" then + Inspect(result) + ged:ShowMessage("Test Result", string.format("%s returned a %s.\n\nCheck the newly opened Inspector window in-game.", self.class, result.class or "table")) + else + ged:ShowMessage("Test Result", string.format("%s returned '%s'.", self.class, result)) + end +end + +DefineClass.FunctionObjectDef = { + __parents = { "ClassDef" }, + properties = { + { id = "DefPropertyTranslation", no_edit = true, }, + }, + GedEditor = false, + EditorViewPresetPrefix = "", +} + +function FunctionObjectDef:OnEditorNew(parent, ged, is_paste) + -- remove test harness on paste (the test object there is of the "old" class) + for i, obj in ipairs(self) do + if IsKindOf(obj, "TestHarness") then + table.remove(self, i) + break + end + end +end + +local IsKindOf = IsKindOf +function FunctionObjectDef:PostLoad() + for _, obj in ipairs(self) do + if IsKindOf(obj, "TestHarness") then + if type(obj.TestObject) == "table" and not obj.TestObject.class then + obj.TestObject = g_Classes[self.id]:new(obj.TestObject) + end + end + end + ClassDef.PostLoad(self) +end + +local save_to_continue_message = { "Please save your new creation to continue.", hintColor } +local missing_harness_message = "Missing Test Harness object, force resave (Ctrl-Shift-S) to create one." + +function FunctionObjectDef:GenerateCode(...) + if config.GedFunctionObjectsTestHarness then + local harness = self:FindSubitem("TestHarness") + if not harness and g_Classes[self.id] then + local error = self:GetError() + if error == missing_harness_message or error == save_to_continue_message then + local obj = TestHarness:new{ name = "TestHarness", TestObject = g_Classes[self.id]:new() } + obj:OnEditorNew() + self[#self + 1] = obj + UpdateParentTable(obj, self) + PopulateParentTableCache(obj) + ObjModified(self) + end + end + end + return ClassDef.GenerateCode(self, ...) +end + +function FunctionObjectDef:DocumentationWarning(class, verb) + local documentation = self:FindSubitem("Documentation") + if not (documentation and documentation.class == "ClassConstDef" and documentation.value ~= ClassConstDef.value) then + return { +string.format([[--== Documentation ==-- +What does your %s %s? + +Explain behavior not apparent from the %s's name and specific terms a new modder might not know.]], class, verb, class), + hintColor, table.find(self, documentation) } + end +end + +function FunctionObjectDef:GetError() + if self:FindSubitem("Init") then + return "An Init method has no effect - Effect/Condition objects are not of class InitDone." + end + + if config.GedFunctionObjectsTestHarness then + local harness = self:FindSubitem("TestHarness") + if self:IsDirty() and not harness then + return save_to_continue_message -- see a bit up + elseif not harness then + return missing_harness_message -- see a bit up + elseif not harness.Tested then + if not harness.TestedOnce then + return { [[--== Testing ==-- +1. In Test Harness edit TestObject, test properties & warnings, and define a good test case. + +2. If your class requires an object, edit GetTestSubject to fetch one. + +3. Click Test to run Evaluate/Execute and check the results.]], hintColor, table.find(self, harness) } + else + return self:IsDirty() + and { [[--== Testing ==-- +Please save and test your changes using the Test Harness.]], hintColor, table.find(self, harness) } + or { [[--== Testing ==-- +Please test your changes using the Test Harness.]], hintColor, table.find(self, harness) } + end + end + end +end + +function FunctionObjectDef:OnEditorDirty(dirty) + local harness = self:FindSubitem("TestHarness") + if harness then + if dirty and not harness.TestFlagsChanged then + harness.Tested = false + ObjModified(self) + end + harness.TestFlagsChanged = false + end +end + + +----- TestHarness + +DefineClass.TestHarness = { + __parents = { "PropertyObject" }, + properties = { + { id = "name", name = "Name", editor = "text", default = false }, + { id = "TestedOnce", editor = "bool", default = false, no_edit = true, }, + { id = "Tested", editor = "bool", default = false, no_edit = true, }, + { id = "GetTestSubject", editor = "func", default = function() end, }, + { id = "TestObject", editor = "nested_obj", base_class = "FunctionObject", auto_expand = true, default = false, }, + { id = "Buttons", editor = "buttons", buttons = {{name = "Test this object!", func = "Test" }}, default = false, + no_edit = function(obj) return not obj.TestObject or IsKindOf(obj.TestObject, "ContinuousEffect") end }, + { id = "ButtonsContinuous", editor = "buttons", buttons = {{name = "Start effect!", func = "Test"}, {name = "Stop Effect!", func = "Stop"}}, default = false, + no_edit = function(obj) return not obj.TestObject or not IsKindOf(obj.TestObject, "ContinuousEffect") end }, + }, + EditorView = "[Test Harness]", + TestFlagsChanged = false, +} + +function TestHarness:OnEditorNew() + self.GetTestSubject = function() return SelectedObj end +end + +function TestHarness:Test(parent, prop_id, ged) + if parent:IsDirty() then + ged:ShowMessage("Please Save", "Please save before testing, unsaved changes won't apply before that.") + return + end + self.TestObject:TestInGed(self:GetTestSubject(), ged) + self.TestedOnce = true + self.Tested = true + self.TestFlagsChanged = true + ObjModified(parent) + ObjModified(ged:ResolveObj("root")) +end + +function TestHarness:Stop(parent, prop_id, ged) + local fnobj, subject = self.TestObject, self:GetTestSubject() + if not fnobj.Id or fnobj.Id == "" then + ged:ShowMessage("Stop Effect", "You must specify an effect Id in order to use the Stop method!") + return + end + if fnobj:HasMember("RequiredObjClasses") and fnobj.RequiredObjClasses then + subject:StopEffect(fnobj.Id) + else + UIPlayer:StopEffect(fnobj.Id) + end + ged:ShowMessage("Stop Effect", "The effect was stopped.") +end + +if not config.GedFunctionObjectsTestHarness then + TestHarness.GetDiagnosticMessage = empty_func +end + + +----- Condition (a predicate that can be used in, e.g. prerequisites for a game event) + +DefineClass.Condition = { + __parents = { "FunctionObject" }, + Negate = false, + EditorViewNeg = false, + DescriptionNeg = "", + EditorExcludeAsNested = true, + __eval = function(self, obj, context) return false end, +} + +function Condition:GetDescription() -- deprecated + return self.Negate and self.DescriptionNeg or self.Description +end + +function Condition:GetEditorView() + return self.Negate and self.EditorViewNeg or FunctionObject.GetEditorView(self) +end + +-- protected call - prevent game break when a condition crashes +function Condition:Evaluate(...) + local ok, err_res = procall(self.__eval, self, ...) + if ok then + if err_res then + return not self.Negate + end + return self.Negate + end + return false, err_res +end + +DefineClass.ConditionsWithParams = { + __parents = { "Condition" }, + properties = { + { id = "__params", name = "Parameters", editor = "expression", params = "self, obj, context, ...", default = function (self, obj, context, ...) return obj, context, ... end, }, + { id = "Conditions", name = "Conditions", editor = "nested_list", default = false, base_class = "Condition", }, + }, + EditorView = Untranslated("Conditions with parameters"), +} + +function ConditionsWithParams:__eval(...) + return _EvalConditionList(self.Conditions, self:__params(...)) +end + +DefineClass.ConditionDef = { + __parents = { "FunctionObjectDef" }, + group = "Conditions", + DefParentClassList = { "Condition" }, + GedEditor = "ClassDefEditor", +} + +function ConditionDef:OnEditorNew(parent, ged, is_paste) + if is_paste then return end + self[1] = self[1] or PropertyDefBool:new{ id = "Negate", name = "Negate Condition", default = false, } + self[2] = self[2] or ClassConstDef:new{ name = "RequiredObjClasses", type = "string_list", } + self[3] = self[3] or ClassConstDef:new{ name = "EditorView", type = "translate", untranslated = true, } + self[4] = self[4] or ClassConstDef:new{ name = "EditorViewNeg", type = "translate", untranslated = true, } + self[5] = self[5] or ClassConstDef:new{ name = "Documentation", type = "text" } + self[6] = self[6] or ClassMethodDef:new{ name = "__eval", params = "obj, context", code = function(self, obj, context) return false end, } + self[7] = self[7] or ClassConstDef:new{ name = "EditorNestedObjCategory", type = "text" } +end + +function ConditionDef:GetError() + local required = self:FindSubitem("RequiredObjClasses") + if required and #(required.value or "") == 0 then + return {[[--== RequiredObjClasses ==-- +Please define the classes expected in __eval's 'obj' parameter, or delete if unused.]], hintColor, table.find(self, required) } + end + + local description = self:FindSubitem("Description") -- deprecated + local description_fn = self:FindSubitem("GetDescription") -- deprecated + local editor_view = self:FindSubitem("EditorView") + local editor_view_fn = self:FindSubitem("GetEditorView") + if not (description and description.class == "ClassConstDef" and description.value ~= ClassConstDef.value) and + not (description and description.class == "PropertyDefText") and + not (description_fn and description_fn.class == "ClassMethodDef" and description_fn.code ~= ClassMethodDef.code) and + not (editor_view and editor_view.class == "ClassConstDef" and editor_view.value ~= ClassConstDef.value) and + not (editor_view_fn and editor_view_fn.class == "ClassMethodDef" and editor_view_fn.code ~= ClassMethodDef.code) then + return {[[--== Add Properties & EditorView ==-- +Add the Condition's properties and EditorView to format it in Ged. + +Sample: "Building is ".]], hintColor, table.find(self, editor_view) } + end + + local editor_view_neg_fn = self:FindSubitem("GetEditorViewNeg") + if editor_view_neg_fn then + return {"You can't use a GetEditorViewNeg method. Please implement GetEditorView only and check for self.Negate inside.", nil, table.find(self, editor_view_neg_fn) } + end + + local negate = self:FindSubitem("Negate") + local eval = self:FindSubitem("__eval") + if negate and eval and eval.class == "ClassMethodDef" and eval:ContainsCode("self.Negate") then + return {"The value of Negate is taken into account automatically - you should not access self.Negate in __eval.", nil, table.find(self, eval) } + end + + local editor_view_neg = self:FindSubitem("EditorViewNeg") + local description_neg = self:FindSubitem("DescriptionNeg") -- deprecated + + if negate or editor_view_neg or description_neg then + if negate and editor_view_fn and editor_view_fn.class == "ClassMethodDef" and editor_view_fn.code ~= ClassMethodDef.code then + if not editor_view_fn:ContainsCode("self.Negate") then + return {[[--== Negate & GetEditorView ==-- +If negating the makes sense for this Condition, check for self.Negate in GetEditorView to display it accordingly. + +Otherwise, delete the Negate property.]], hintColor, table.find(self, negate), table.find(self, editor_view_fn) } + elseif editor_view_neg or description_neg then + return {[[--== Negate & GetEditorView ==-- +Please delete EditorViewNeg, as you already check for self.Negate in GetEditorView.]], hintColor, table.find(self, editor_view_neg or description_neg) } + end + elseif not (negate and (editor_view_neg and editor_view_neg.class == "ClassConstDef" and editor_view_neg.value ~= ClassConstDef.value or + description_neg and description_neg.class == "ClassConstDef" and description_neg.value ~= ClassConstDef.value)) then + return {[[--== Negate & EditorViewNeg ==-- +If negating the makes sense for this Condition, define EditorViewNeg, otherwise delete EditorViewNeg and Negate. + +Sample: "Building is not ".]], hintColor, table.find(self, negate), table.find(self, editor_view_neg) } + end + end + + local doc_warning = self:DocumentationWarning("Condition", "check") + if not doc_warning then + local __eval = self:FindSubitem("__eval") + if not (__eval and __eval.class == "ClassMethodDef" and __eval.code ~= ClassMethodDef.code) and + not (__eval and __eval.class == "PropertyDefFunc") + then + return {[[--== __eval & GetError ==-- + Implement __eval, thinking about potential circumstances in which it might not work. + + Perform edit-time property validity checks in GetError. Thanks!]], hintColor, table.find(self, __eval) } + end + end +end + +function ConditionDef:GetWarning() + return self:DocumentationWarning("Condition", "check") +end + +function Condition:CompareOp(value, context, amount) + local op = self.Condition + local amount = amount or self.Amount + if op == ">=" then + return value >= amount + elseif op == "<=" then + return value <= amount + elseif op == ">" then + return value > amount + elseif op == "<" then + return value < amount + elseif op == "==" then + return value == amount + else -- "~=" + return value ~= amount + end +end + +DefineClass.ConditionComparisonDef = { + __parents = { "ConditionDef" }, +} + +function ConditionComparisonDef:OnEditorNew(parent, ged, is_paste) + if is_paste then return end + self[1] = self[1] or PropertyDefChoice:new{ id = "Condition", help = "The comparison to perform", items = function (self) return { ">=", "<=", ">", "<", "==", "~=" } end, default = false, } + self[2] = self[2] or PropertyDefNumber:new{ id = "Amount", help = "The value to compare against", default = false, } + self[3] = self[3] or ClassConstDef:new{ name = "RequiredObjClasses", type = "string_list", } + self[4] = self[4] or ClassConstDef:new{ name = "EditorView", type = "translate", untranslated = true, } + self[5] = self[5] or ClassConstDef:new{ name = "Documentation", type = "text" } + self[6] = self[6] or ClassMethodDef:new{ name = "__eval", params = "obj, context", code = function(self, obj, context) +-- Calculate the value to compare in 'count' here +return self:CompareOp(count, context) end, } + self[7] = self[7] or ClassMethodDef:new{ name = "GetError", params = "", code = function() +if not self.Condition then + return "Missing Condition" +elseif not self.Amount then + return "Missing Amount" +end + end } +end + + +----- Effect (an action that has an effect on the game, e.g. providing resources) + +DefineClass.Effect = { + __parents = { "FunctionObject" }, + NoIngameDescription = false, + EditorExcludeAsNested = true, + __exec = function(self, obj, context) end, +} + +function Effect:Execute(...) + return procall(self.__exec, self, ...) +end + + +DefineClass.EffectsWithParams = { + __parents = { "Effect" }, + properties = { + { id = "__params", name = "Parameters", editor = "expression", params = "self, obj, context, ...", default = function (self, obj, context, ...) return obj, context, ... end, }, + { id = "Effects", name = "Effects", editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + }, + EditorView = Untranslated("Effects with parameters"), +} + +function EffectsWithParams:__exec(...) + _ExecuteEffectList(self.Effects, self:__params(...)) +end + + +DefineClass.EffectDef = { + __parents = { "FunctionObjectDef" }, + group = "Effects", + DefParentClassList = { "Effect" }, + GedEditor = "ClassDefEditor", +} + +function EffectDef:OnEditorNew(parent, ged, is_paste) + if is_paste then return end + self[1] = self[1] or ClassConstDef:new{ name = "RequiredObjClasses", type = "string_list", } + self[2] = self[2] or ClassConstDef:new{ name = "ForbiddenObjClasses", type = "string_list", } + self[3] = self[3] or ClassConstDef:new{ name = "ReturnClass", type = "text", } + self[4] = self[4] or ClassConstDef:new{ name = "EditorView", type = "translate", untranslated = true, } + self[5] = self[5] or ClassConstDef:new{ name = "Documentation", type = "text", } + self[6] = self[6] or ClassMethodDef:new{ name = "__exec", params = "obj, context", } + self[7] = self[7] or ClassConstDef:new{ name = "EditorNestedObjCategory", type = "text" } +end + +function EffectDef:GetError() + local required = self:FindSubitem("RequiredObjClasses") + local forbidden = self:FindSubitem("ForbiddenObjClasses") + if required and #(required.value or "") == 0 or forbidden and #(forbidden.value or "") == 0 then + return {[[--== RequiredObjClasses & ForbiddenObjClasses ==-- +Please define the expected classes, or delete if unused.]], hintColor, table.find(self, required), table.find(self, forbidden) } + end + +--[=[ local return_class = self:FindSubitem("ReturnClass") + local return_class_fn = self:FindSubitem("GetReturnClass") + if return_class and (return_class.class ~= "ClassConstDef" or return_class.value == ClassConstDef.value) or + return_class_fn and (return_class_fn.class ~= "ClassMethodDef" or return_class_fn.code == ClassMethodDef.code) then + return {[[--== ReturnClass / GetReturnClass ==-- +Please specify your Effect's return value class, or delete if no return value. + +Effects that associate a new object to a StoryBit must return the object.]], hintColor, table.find(self, return_class), table.find(self, return_class_fn) } + end]=] + + local description = self:FindSubitem("Description") -- deprecated + local description_fn = self:FindSubitem("GetDescription") -- deprecated + local editor_view = self:FindSubitem("EditorView") + local editor_view_fn = self:FindSubitem("GetEditorView") + if not (description and description.class == "ClassConstDef" and description.value ~= ClassConstDef.value) and + not (description and description.class == "PropertyDefText") and + not (description_fn and description_fn.class == "ClassMethodDef" and description_fn.code ~= ClassMethodDef.code) and + not (editor_view and editor_view.class == "ClassConstDef" and editor_view.value ~= ClassConstDef.value) and + not (editor_view_fn and editor_view_fn.class == "ClassMethodDef" and editor_view_fn.code ~= ClassMethodDef.code) then + return {[[--== Add Properties & EditorView ==-- +Add the Effect's properties and EditorView/GetEditorView() to format it in Ged. + +Sample: "Increase trade price of by %".]], hintColor, table.find(self, editor_view), table.find(self, editor_view_fn) } + end + + local doc_warning = self:DocumentationWarning("Effect", "do") + if doc_warning then + return + end + return self:CheckExecMethod() +end + +function EffectDef:CheckExecMethod() + local execute = self:FindSubitem("__exec") + if not (execute and execute.class == "ClassMethodDef" and execute.code ~= ClassMethodDef.code) then + return {[[--== Execute ==-- +Implement __exec, thinking about potential circumstances in which it might not work. + +Perform edit-time property validity checks in GetError. Thanks! +]], hintColor, table.find(self, execute) } + end +end + +function EffectDef:GetWarning() + return self:DocumentationWarning("Effect", "do") +end + +function GetEditorConditionsAndEffectsText(texts, obj) + local trigger = rawget(obj,"Trigger") or "" + for _, condition in ipairs(obj.Conditions or empty_table) do + if trigger == "once" then + texts[#texts+1] = "\t\t" .. Untranslated( "once ") .. Untranslated(_InternalTranslate(condition:GetEditorView(), condition, false)) + elseif trigger == "always" then + texts[#texts+1] = "\t\t" .. Untranslated( "always ") .. Untranslated(_InternalTranslate(condition:GetEditorView(), condition, false)) + elseif trigger == "activation" then + texts[#texts+1] = "\t\t" .. Untranslated(_InternalTranslate(condition:GetEditorView(), condition, false)) .. Untranslated( " starts") + elseif trigger == "deactivation" then + texts[#texts+1] = "\t\t" .. Untranslated(_InternalTranslate(condition:GetEditorView(), condition, false)) .. Untranslated( " ends") + else + texts[#texts+1] = "\t\t" .. Untranslated(_InternalTranslate(condition:GetEditorView(), condition, false)) + end + end + for _, effect in ipairs(obj.Effects or empty_table) do + texts[#texts+1] = "\t\t\t" .. Untranslated(_InternalTranslate(effect:GetEditorView(), effect, false)) + end +end + +function GetEditorStringListPropText(texts, obj, Prop) + if not obj[Prop] or not next(obj[Prop]) then + return + end + local string_list = {} + for _, str in ipairs(obj[Prop]) do + string_list[#string_list+1]= Untranslated(str) + end + string_list = table.concat(string_list, ", ") + texts[#texts+1] = "\t\t\t" .. Untranslated(Prop)..": "..string_list +end + +function EvalConditionList(list, ...) + if list and #list > 0 then + local ok, result = procall(_EvalConditionList, list, ...) + if not ok then + return false + end + if not result then + return false + end + end + return true +end + +-- unprotected call - used in already protected calls +function _EvalConditionList(list, ...) + for _, cond in ipairs(list) do + if cond:__eval(...) then + if cond.Negate then + return false + end + else + if not cond.Negate then + return false + end + end + end + return true +end + +function ExecuteEffectList(list, ...) + if list and #list > 0 then + procall(_ExecuteEffectList, list, ...) + end +end + +-- unprotected call - used in already protected calls +function _ExecuteEffectList(list, ...) + for _, effect in ipairs(list) do + effect:__exec(...) + end +end + +function ComposeSubobjectName(parents) + local ids = {} + for i = 1, #parents do + local parent = parents[i] + local parent_id + if IsKindOfClasses(parent, "Condition", "Effect") then + parent_id = parent.class + else + parent_id = parent:HasMember("id") and parent.id or (parent:HasMember("ParamId") and parent.ParamId) or parent.class or "?" + end + ids[#ids + 1] = parent_id or "?" + end + return table.concat(ids, ".") +end + + +----- New scripting + +DefineClass("ScriptTestHarnessProgram", "ScriptProgram") -- this class displays a Test button in the place of the Save button in the Script Editor + +function ScriptTestHarnessProgram:GetEditedScriptStatusText() + return "
This is a test script, press Ctrl-T to run it." +end + +function ScriptDomainsCombo() + local items = { { text = "", value = false } } + for name, class in pairs(ClassDescendants("ScriptBlock")) do + if class.ScriptDomain then + if not table.find(items, "value", class.ScriptDomain) then + table.insert(items, { text = class.ScriptDomain, value = class.ScriptDomain }) + end + end + end + return items +end + +DefineClass.ScriptComponentDef = { + __parents = { "ClassDef" }, + properties = { + { id = "DefPropertyTranslation", no_edit = true, }, + { id = "DefStoreAsTable", no_edit = true, }, + { id = "DefPropertyTabs", no_edit = true, }, + { id = "DefUndefineClass", no_edit = true, }, + { category = "Script Component", id = "DefParentClassList", name = "Parent classes", editor = "string_list", items = function(obj, prop_meta, validate_fn) + if validate_fn == "validate_fn" then + -- function for preset validation, checks whether the property value is from "items" + return "validate_fn", function(value, obj, prop_meta) + return value == "" or g_Classes[value] + end + end + return table.keys2(g_Classes, true, "") + end + }, + { category = "Script Component", id = "EditorName", name = "Menu name", editor = "text", default = "", }, + { category = "Script Component", id = "EditorSubmenu", name = "Menu category", editor = "combo", default = "", items = PresetsPropCombo("ScriptComponentDef", "EditorSubmenu", "") }, + { category = "Script Component", id = "Documentation", editor = "text", lines = 1, default = "", }, + { category = "Script Component", id = "ScriptDomain", name = "Script domain", editor = "combo", default = false, items = function() return ScriptDomainsCombo() end }, + + { category = "Code", id = "Params", name = "Parameters", editor = "text", default = "", }, + { category = "Code", id = "Param1Help", name = "Param1 help", editor = "text", default = "", + no_edit = function(self) local _, num = string.gsub(self.Params .. ",", "([%w_]+)%s*,%s*", "") return num < 1 end, + }, + { category = "Code", id = "Param2Help", name = "Param2 help", editor = "text", default = "", + no_edit = function(self) local _, num = string.gsub(self.Params .. ",", "([%w_]+)%s*,%s*", "") return num < 2 end, + }, + { category = "Code", id = "Param3Help", name = "Param3 help", editor = "text", default = "", + no_edit = function(self) local _, num = string.gsub(self.Params .. ",", "([%w_]+)%s*,%s*", "") return num < 3 end, + }, + { category = "Code", id = "HasGenerateCode", editor = "bool", default = false, }, + { category = "Code", id = "CodeTemplate", name = "Code template", editor = "text", lines = 1, default = "", + help = "Here, self.Prop gets replaced with Prop's Lua value.\n$self.Prop omits the quotes, e.g. for variable names.", + no_edit = function(self) return self.HasGenerateCode end, dont_save = function(self) return self.HasGenerateCode end, }, + { category = "Code", id = "DefGenerateCode", name = "GenerateCode", editor = "func", params = "self, pstr, indent", default = empty_func, + no_edit = function(self) return not self.HasGenerateCode end, dont_save = function(self) return not self.HasGenerateCode end,}, + + { category = "Test Harness", sort_order = 10000, id = "GetTestParams", editor = "func", default = function(self) return SelectedObj end, dont_save = true, }, + { category = "Test Harness", sort_order = 10000, id = "TestHarness", name = "Test harness", editor = "script", default = false, dont_save = true, + params = function(self) return self.Params end, + }, + { category = "Test Harness", sort_order = 10000, id = "_", editor = "buttons", buttons = { + { name = "Create", is_hidden = function(self) return self.TestHarness end, func = "CreateTestHarness" }, + { name = "Recreate", is_hidden = function(self) return not self.TestHarness end, func = "CreateTestHarness" }, + { name = "Test", is_hidden = function(self) return not self.TestHarness end, func = "Test" }, + }}, + }, + GedEditor = false, + EditorViewPresetPrefix = "", +} + +-- Will replace instances of the parameter names - whole words only, as listed in the Params property. +-- (for example, making Object become $self.Param1 in CodeTemplate, if Object is the 1st parameter) +function ScriptComponentDef:SubstituteParamNames(str, prefix, in_tag) + local from_to, n = {}, 1 + for param in string.gmatch(self.Params .. ",", "([%w_]+)%s*,%s*") do + from_to[param] = (prefix or "") .. "Param" .. n + n = n + 1 + end + + local t = {} + for word, other in str:gmatch("([%a%d_]*)([^%a%d_]*)") do + if not in_tag or other:starts_with(">") then + word = from_to[word] or word + end + t[#t + 1] = word + t[#t + 1] = other + end + return table.concat(t) +end + +function ScriptComponentDef:GenerateConsts(code) + code:append("\tEditorName = \"", self.EditorName, "\",\n") + code:append("\tEditorSubmenu = \"", self.EditorSubmenu, "\",\n") + code:append("\tDocumentation = \"", self.Documentation, "\",\n") + if self.ScriptDomain then + code:append("\tScriptDomain = \"", self.ScriptDomain, "\",\n") + end + + local code_template = self:SubstituteParamNames(self.CodeTemplate, "$self.") -- allows using parameter names from Params instead of $self.Param1, etc. + code:append("\tCodeTemplate = ") + code:append(ValueToLuaCode(code_template)) + code:append(",\n") + + local n = 1 + for param in string.gmatch(self.Params .. ",", "([%w_]+)%s*,%s*") do + code:appendf("\tParam%dName = \"%s\",\n", n, param) + n = n + 1 + end + if self.Param1Help ~= "" then + code:append("\tParam1Help = \"", self.Param1Help, "\",\n") + end + if self.Param2Help ~= "" then + code:append("\tParam2Help = \"", self.Param2Help, "\",\n") + end + if self.Param3Help ~= "" then + code:append("\tParam3Help = \"", self.Param3Help, "\",\n") + end + ClassDef.GenerateConsts(self, code) +end + +function ScriptComponentDef:GenerateMethods(code) + if self.HasGenerateCode then + local method_def = ClassMethodDef:new{ name = "GenerateCode", params = "pstr, indent", code = self.DefGenerateCode } + method_def:GenerateCode(code, self.id) + end + ClassDef.GenerateMethods(self, code) +end + +function ScriptComponentDef:CreateTestHarness(root, prop_id, ged) + CreateRealTimeThread(function() + if self:IsDirty() then + GedSetUiStatus("lua_reload", "Saving...") + self:Save() + WaitMsg("Autorun") + end + + self.TestHarness = self:CreateHarnessScriptProgram() + GedCreateOrEditScript(ged, self, "TestHarness", self.TestHarness) + PopulateParentTableCache(self) + ObjModified(self) + end) +end + +function ScriptComponentDef:Test(root, prop_id, ged) + CreateRealTimeThread(function() + if self:IsDirty() then + GedSetUiStatus("lua_reload", "Saving...") + self:Save() + WaitMsg("Autorun") + end + + local eval, msg = self.TestHarness:Compile() + if not msg then -- compilation successful + local ok, result = procall(eval, self.GetTestParams()) + if not ok then + msg = string.format("%s returned an error %s.", self.id, tostring(result)) + elseif type(result) == "table" then + msg = string.format("%s returned a %s.\n\nCheck the newly opened Inspector window in-game.", self.id, result.class or "table") + Inspect(result) + else + msg = string.format("%s returned '%s'.", self.id, tostring(result)) + end + end + ged:ShowMessage("Test Result", msg) + ObjModified(self.TestHarness) + end) +end + +function ScriptComponentDef:GetError() + if self.EditorName == "" then + return { "Please set Menu name.", hintColor } + elseif self.EditorSubmenu == "" then + return { "Please set Menu category.", hintColor } + elseif self.CodeTemplate == "" and self.DefGenerateCode == empty_func then + return { "Please set either a CodeTemplate string, or a GenerateCode function.", hintColor } + end +end + + +DefineClass.ScriptConditionDef = { + __parents = { "ScriptComponentDef" }, + properties = { + { category = "Condition", id = "DefHasNegate", name = "Has Negate", editor = "bool", default = false, }, + { category = "Condition", id = "DefHasGetEditorView", name = "Has GetEditorView", editor = "bool", default = false, }, + { category = "Condition", id = "DefAutoPrependParam1", name = "Auto-prepend ':'", editor = "bool", default = true, + no_edit = function(self) return self.DefHasGetEditorView or self.Params == "" end }, + { category = "Condition", id = "DefEditorView", name = "EditorView", editor = "text", translate = false, default = "", + no_edit = function(self) return self.DefHasGetEditorView end, dont_save = function(self) return self.DefHasGetEditorView end, }, + { category = "Condition", id = "DefEditorViewNeg", name = "EditorViewNeg", editor = "text", translate = false, default = "", + no_edit = function(self) return self.DefHasGetEditorView or not self.DefHasNegate end, dont_save = function(self) return self.DefHasGetEditorView or not self.DefHasNegate end, }, + { category = "Condition", id = "DefGetEditorView", name = "GetEditorView", editor = "func", params = "self", default = empty_func, + no_edit = function(self) return not self.DefHasGetEditorView end, dont_save = function(self) return not self.DefHasGetEditorView end }, + }, + group = "Conditions", + DefParentClassList = { "ScriptCondition" }, + GedEditor = "ClassDefEditor", +} + +function ScriptConditionDef:GenerateConsts(code) + if self.DefHasNegate then + code:append("\tHasNegate = true,\n") + end + if not self.DefHasGetEditorView then + local ev, evneg = self.DefEditorView, self.DefEditorViewNeg + if self.DefAutoPrependParam1 and self.Params ~= "" then + ev = ": " .. ev + evneg = ": " .. evneg + end + code:append("\tEditorView = Untranslated(\"", self:SubstituteParamNames(ev, "", "in_tag"), "\"),\n") + if self.DefHasNegate then + code:append("\tEditorViewNeg = Untranslated(\"", self:SubstituteParamNames(evneg, "", "in_tag"), "\"),\n") + end + end + ScriptComponentDef.GenerateConsts(self, code) +end + +function ScriptConditionDef:GenerateMethods(code) + if self.DefHasGetEditorView then + local method_def = ClassMethodDef:new{ name = "GetEditorView", code = self.DefGetEditorView } + method_def:GenerateCode(code, self.id) + end + ScriptComponentDef.GenerateMethods(self, code) +end + +function ScriptConditionDef:CreateHarnessScriptProgram() + local test_obj = g_Classes[self.id]:new() + local program = ScriptTestHarnessProgram:new{ + Params = self.Params, + ScriptReturn:new{ test_obj } + } + PopulateParentTableCache(program) + test_obj:OnAfterEditorNew() + return program +end + +function ScriptConditionDef:GetError() + if self.DefHasNegate then + if (self.DefEditorView == "" or self.DefEditorViewNeg == "") and self.DefGetEditorView == empty_func then + return { "Please either set EditorView and EditorViewNeg, or define a GetEditorView method.", hintColor } + end + else + if self.DefEditorView == "" and self.DefGetEditorView == empty_func then + return { "Please either set EditorView, or define a GetEditorView method.", hintColor } + end + end +end + + +DefineClass.ScriptEffectDef = { + __parents = { "ScriptComponentDef" }, + properties = { + { category = "Condition", id = "DefHasGetEditorView", name = "Has GetEditorView", editor = "bool", default = false, }, + { category = "Condition", id = "DefAutoPrependParam1", name = "Auto-prepend ':'", editor = "bool", default = true, + no_edit = function(self) return self.DefHasGetEditorView or self.Params == "" end }, + { category = "Condition", id = "DefEditorView", name = "EditorView", editor = "text", translate = false, default = "", + no_edit = function(self) return self.DefHasGetEditorView end, dont_save = function(self) return self.DefHasGetEditorView end, }, + { category = "Condition", id = "DefGetEditorView", name = "GetEditorView", editor = "func", params = "self", default = empty_func, + no_edit = function(self) return not self.DefHasGetEditorView end, dont_save = function(self) return not self.DefHasGetEditorView end, }, + }, + group = "Effects", + DefParentClassList = { "ScriptSimpleStatement" }, + GedEditor = "ClassDefEditor", +} + +function ScriptEffectDef:GenerateConsts(code) + if not self.DefHasGetEditorView then + local ev = self.DefEditorView + if self.DefAutoPrependParam1 and self.Params ~= "" then + ev = ": " .. ev + end + code:append("\tEditorView = Untranslated(\"", self:SubstituteParamNames(ev, "", "in_tag"), "\"),\n") + end + ScriptComponentDef.GenerateConsts(self, code) +end + +function ScriptEffectDef:GenerateMethods(code) + if self.DefHasGetEditorView then + local method_def = ClassMethodDef:new{ name = "GetEditorView", code = self.DefGetEditorView } + method_def:GenerateCode(code, self.id) + end + ScriptComponentDef.GenerateMethods(self, code) +end + +function ScriptEffectDef:CreateHarnessScriptProgram() + local test_obj = g_Classes[self.id]:new() + local program = ScriptTestHarnessProgram:new{ [1] = test_obj, Params = self.Params } + PopulateParentTableCache(program) + test_obj:OnAfterEditorNew() + return program +end + +function ScriptEffectDef:GetError() + if self.DefEditorView == "" and self.DefGetEditorView == empty_func then + return { "Please either set EditorView, or define a GetEditorView method.", hintColor } + end +end diff --git a/CommonLua/Classes/ClassDefSubItem.lua b/CommonLua/Classes/ClassDefSubItem.lua new file mode 100644 index 0000000000000000000000000000000000000000..9f4968883a848a8d4363132e588702e397e972db --- /dev/null +++ b/CommonLua/Classes/ClassDefSubItem.lua @@ -0,0 +1,1476 @@ +DefineClass.ClassDefSubItem = { + __parents = { "PropertyObject" }, +} + +function ClassDefSubItem:ToStringWithColor(value, t) + local text = t and value or ValueToLuaCode(value) + t = t or type(value) + local color + if t == "string" or IsT(value) then + color = RGB(60, 140, 40) + elseif t == "boolean" or t == "nil" then + color = RGB(75, 105, 198) + elseif t == "number" then + color = RGB(150, 50, 20) + elseif t == "function" then + text = string.gsub(text, "^function ", "function") + text = string.gsub(text, "end$", "end") + end + if not color then + return text + end + local r, g, b = GetRGB(color) + return string.format("%s", r, g, b, text) +end + + +----- PropertyDef + +local function GetCategoryItems(self) + local categories = PresetGroupCombo("PropertyCategory", "Default")() + local parent + ForEachPreset("ClassDef", function(preset) + parent = parent or table.find(preset, self) and preset + end) + if parent then + local tmp = table.invert(categories) + for _, prop in ipairs(parent) do + if IsKindOf(prop, "PropertyDef") then + tmp[prop.category or ""] = true + end + end + categories = table.keys(tmp) + end + table.sort(categories, function(a, b) + if a and b then + return a < b + else + return b + end + end) + return categories +end + +local reusable_expressions = { + dont_save = "Don't save", + read_only = "Read only", + no_edit = "Hidden", + no_validate = "No validation", +} + +local function reusable_expressions_combo(self) + local ret = { + { text = "true", value = true }, + { text = "false", value = false }, + { text = "expression", value = "expression"}, + } + local preset = GetParentTableOfKind(self, "ClassDef") + for _, property_def in ipairs(preset) do + if IsKindOf(property_def, "PropertyDef") then + for id, name in pairs(reusable_expressions) do + if property_def[id] == "expression" then + table.insert(ret, { + text = "Reuse " .. name .. " from " .. property_def.id, + value = property_def.id .. "." .. id + }) + end + end + end + end + return ret +end + +function ValidateIdentifier(self, value) + return (type(value) ~= "string" or not value:match("^[%a_][%w_]*$")) and "Please enter a valid identifier" +end + +DefineClass.PropertyDef = { + __parents = { "ClassDefSubItem" }, + properties = { + { category = "Property", id = "category", name = "Category", editor = "combo", items = GetCategoryItems, default = false, }, + { category = "Property", id = "id", name = "Id", editor = "text", default = "", validate = ValidateIdentifier }, + { category = "Property", id = "name", name = "Name", editor = "text", translate = function(self) return self.translate_in_ged end, default = false }, + { category = "Property", id = "help", name = "Help", editor = "text", translate = function(self) return self.translate_in_ged end, lines = 1, max_lines = 3, default = false }, + + { category = "Property", id = "dont_save", name = "Don't save", editor = "choice", default = false, items = reusable_expressions_combo }, + { category = "Property", id = "dont_save_expression", name = "Don't save", editor = "expression", default = return_true, params = "self, prop_meta", + no_edit = function(self) return type(self.dont_save) == "boolean" end, + read_only = function(self) return self.dont_save ~= "expression" end, + dont_save = function(self) return self.dont_save ~= "expression" end, }, + { category = "Property", id = "read_only", name = "Read only", editor = "choice", default = false, items = reusable_expressions_combo }, + { category = "Property", id = "read_only_expression", name = "Read only", editor = "expression", default = return_true, params = "self, prop_meta", + no_edit = function(self) return type(self.read_only) == "boolean" end, + read_only = function(self) return self.read_only ~= "expression" end, + dont_save = function(self) return self.read_only ~= "expression" end, }, + { category = "Property", id = "no_edit", name = "Hidden", editor = "choice", default = false, items = reusable_expressions_combo }, + { category = "Property", id = "no_edit_expression", name = "Hidden", editor = "expression", default = return_true, params = "self, prop_meta", + no_edit = function(self) return type(self.no_edit) == "boolean" end, + read_only = function(self) return self.no_edit ~= "expression" end, + dont_save = function(self) return self.no_edit ~= "expression" end, }, + { category = "Property", id = "no_validate", name = "No validation", editor = "choice", default = false, items = reusable_expressions_combo }, + { category = "Property", id = "no_validate_expression", name = "No validation", editor = "expression", default = return_true, params = "self, prop_meta", + no_edit = function(self) return type(self.no_validate) == "boolean" end, + read_only = function(self) return self.no_validate ~= "expression" end, + dont_save = function(self) return self.no_validate ~= "expression" end, }, + + { category = "Property", id = "buttons", name = "Buttons", editor = "nested_list", base_class = "PropertyDefPropButton", default = false, inclusive = true, + help = "Button function is searched by name in the object, the root parent (Preset?), and then globally.\n\nParameters are (self, root, prop_id, ged) for the object method, (root, obj, prop_id, ged) otherwise." }, + { category = "Property", id = "template", name = "Template", editor = "bool", default = false, help = "Marks template properties for classes which inherit 'ClassTemplate'"}, + { category = "Property", id = "validate", name = "Validate", editor = "expression", params = "self, value", help = "A function called by Ged when changing the value. Returns error, updated_value."}, + { category = "Property", id = "extra_code", name = "Extra Code", editor = "text", lines = 1, max_lines = 5, default = false, help = "Additional code to insert in the property metadata" }, + }, + editor = false, + validate = false, + context = false, + gender = false, + os_path = false, + translate_in_ged = false, +} + +function PropertyDef:GetEditorView() + local category = "" + if self.category then + category = string.format("[%s] ", self.category) + end + return string.format("%s%s %s = %s", + category, self.editor, self.id, self:ToStringWithColor(self.default)) +end + +local function getTranslatableValue(text, translate) + if not text or text == "" then return end + if text then + assert(IsT(text) == translate) + end + return text +end + +local reuse_error_fn = function() return "Unable to locate expression to reuse." end +local reuse_prop_ids = { dont_save_expression = "dont_save", read_only_expression = "read_only", no_edit_expression = "no_edit", no_validate_expression = "no_validate" } + +function PropertyDef:GetProperty(prop) + local main_prop_id = reuse_prop_ids[prop] + if main_prop_id then + local value = self:GetProperty(main_prop_id) + if type(value) == "string" and value ~= "expression" then + local reuse_prop_id, reuse = value:match("([%w_]+)%.([%w_]+)") + if reuse then + local preset = GetParentTableOfKind(self, "ClassDef") + local property_def = table.find_value(preset, "id", reuse_prop_id) + return property_def and property_def[reuse .. "_expression"] or reuse_error_fn + end + end + end + return ClassDefSubItem.GetProperty(self, prop) +end + +function PropertyDef:GenerateExpressionSettingCode(code, id) + local value = self[id] + if type(value) ~= "boolean" then + local expr = self:GetProperty(id .. "_expression") + if expr ~= reuse_error_fn then + code:appendf("%s = function(self) %s end, ", id, GetFuncBody(expr)) + end + elseif value then + code:appendf("%s = true, ", id) + end +end + +function PropertyDef:GenerateCode(code, translate, extra_code_fn) + if self.id == "" then return end + code:append("\t\t{ ") + if self.category and self.category ~= "" then + code:appendf("category = \"%s\", ", self.category) + end + code:append("id = \"", self.id, "\", ") + local name, help = getTranslatableValue(self.name, translate), getTranslatableValue(self.help, translate) + if name then + code:append("name = ", ValueToLuaCode(name), ", ") + end + if help then + code:append("help = ", ValueToLuaCode(help), ", ") + end + code:append("\n\t\t\t") + code:appendf("editor = \"%s\", default = %s, ", self.editor, self:GenerateDefaultValueCode()) + + self:GenerateExpressionSettingCode(code, "dont_save") + self:GenerateExpressionSettingCode(code, "read_only") + self:GenerateExpressionSettingCode(code, "no_edit") + self:GenerateExpressionSettingCode(code, "no_validate") + if self.validate then + code:appendf("validate = function(self, value) %s end, ", GetFuncBody(self.validate)) + end + + if self.buttons and #self.buttons > 0 then + code:append("buttons = {") + for _, data in ipairs(self.buttons) do + if data.Name ~= "" then + if data.IsHidden ~= empty_func then + code:appendf([[ {name = "%s", func = "%s", is_hidden = function(self) %s end }, ]], data.Name, data.FuncName, GetFuncBody(data.IsHidden)) + else + code:appendf([[ {name = "%s", func = "%s"}, ]], data.Name, data.FuncName) + end + end + end + code:append("}, ") + end + if self.template then code:append("template = true, ") end + if self.extra_code and self.extra_code ~= "" or extra_code_fn then + local ext_code = self.extra_code and self.extra_code:gsub(",$", "") + if extra_code_fn then + ext_code = ext_code and (ext_code .. ", " .. extra_code_fn(self)) or extra_code_fn(self) + ext_code = ext_code:gsub(",$", "") + end + if ext_code and ext_code ~= "" then + code:append("\n\t\t\t", ext_code) + code:append(", ") + end + end + self:GenerateAdditionalPropCode(code, translate) + code:append("},\n") +end + +function PropertyDef:GenerateDefaultValueCode() + return ValueToLuaCode(self.default, ' ', nil, {} --[[ enable property injection ]]) +end + +function PropertyDef:ValidateProperty(prop_meta) + if not self.no_validate then + return PropertyObject.ValidateProperty(self, prop_meta) + end +end + +function PropertyDef:GenerateAdditionalPropCode(code, translate) +end + +function PropertyDef:AppendFunctionCode(code, prop_name) + if not self[prop_name] then return end + local name, params, body = GetFuncSource(self[prop_name]) + code:appendf("%s = function (%s)\n", prop_name, params) + if type(body) == "string" then + body = string.split(body, "\n") + end + code:append("\t", body and table.concat(body, "\n\t") or "", "\n") + code:append("end, \n") +end + +function PropertyDef:GetError() + if not self.no_validate and self.extra_code and (self.extra_code:find("[^%w_]items%s*=") or self.extra_code:find("^items%s*=")) then + return "Please don't define 'items' as extra code. Use the dedicated Items property instead.\nThis is to make items appear in the default value property." + end +end + +function PropertyDef:CleanupForSave() +end + +function PropertyDef:EmulatePropEval(metadata_id, default, prop_meta, validate_fn) + local prop_meta = self + local classdef_preset = GetParentTableOfKind(self, "ClassDef") + if not classdef_preset then return default end + local obj_class = g_Classes[classdef_preset.id] + local instance = obj_class and not obj_class:IsKindOf("CObject") and obj_class:new() or {} + if validate_fn then + return eval_items(prop_meta[metadata_id], instance, prop_meta) + end + return prop_eval(prop_meta[metadata_id], instance, prop_meta, default) +end + +local function EmulatePropEval(metadata_id, default) + return function(self, prop_meta, validate_fn) + return self:EmulatePropEval(metadata_id, default, prop_meta, validate_fn) + end +end + +DefineClass.PropertyDefPropButton = { + __parents = { "PropertyObject" }, + properties = { + { id = "Name", editor = "text", default = ""}, + { id = "FuncName", editor = "text", default = ""}, + { id = "IsHidden", editor = "expression", default = empty_func}, + }, + EditorView = Untranslated("[] = "), +} + +DefineClass.PropertyDefButtons = { + __parents = { "PropertyDef" }, + properties = { + { id = "default", name = "Default value", editor = false, default = false, }, + }, + editor = "buttons", + EditorName = "Buttons property", + EditorSubmenu = "Extras", +} + +DefineClass.PropertyDefBool = { + __parents = { "PropertyDef" }, + properties = { + { category = "Bool", id = "default", name = "Default value", editor = "bool", default = false, }, + }, + editor = "bool", + EditorName = "Bool property", + EditorSubmenu = "Basic property", +} + +DefineClass.PropertyDefTable = { + __parents = { "PropertyDef" }, + properties = { + { category = "Table", id = "default", name = "Default value", editor = "prop_table", default = false, }, + { category = "Table", id = "lines", name = "Lines", editor = "number", default = 1, }, + }, + editor = "prop_table", + EditorName = "Table property", + EditorSubmenu = "Objects", +} + +function PropertyDefTable:GenerateAdditionalPropCode(code, translate) + if self.lines > 1 then + code:append("indent = \"\", lines = 1, max_lines = ", self.lines, ", ") + end +end + +DefineClass.PropertyDefPoint = { + __parents = { "PropertyDef" }, + properties = { + { category = "Point", id = "default", name = "Default value", editor = "point", default = false, }, + }, + editor = "point", + EditorName = "Point property", + EditorSubmenu = "Basic property", +} + +DefineClass.PropertyDefPoint2D = { + __parents = { "PropertyDef" }, + properties = { + { category = "Point2D", id = "default", name = "Default value", editor = "point2d", default = false, }, + }, + editor = "point2d", + EditorName = "Point2D property", + EditorSubmenu = "Basic property", +} + +DefineClass.PropertyDefRect = { + __parents = { "PropertyDef" }, + properties = { + { category = "Rect", id = "default", name = "Default value", editor = "rect", default = false, }, + }, + editor = "rect", + EditorName = "Rect property", + EditorSubmenu = "Basic property", +} + +DefineClass.PropertyDefMargins = { + __parents = { "PropertyDef" }, + properties = { + { category = "Margins", id = "default", name = "Default value", editor = "margins", default = false, }, + }, + editor = "margins", + EditorName = "Margins property", + EditorSubmenu = "Extras", +} + +DefineClass.PropertyDefPadding = { + __parents = { "PropertyDef" }, + properties = { + { category = "Padding", id = "default", name = "Default value", editor = "padding", default = false, }, + }, + editor = "padding", + EditorName = "Padding property", + EditorSubmenu = "Extras", +} + +DefineClass.PropertyDefBox = { + __parents = { "PropertyDef" }, + properties = { + { category = "Box", id = "default", name = "Default value", editor = "box", default = false, }, + }, + editor = "box", + EditorName = "Box property", + EditorSubmenu = "Basic property", +} + +DefineClass.PropertyDefNumber = { + __parents = { "PropertyDef" }, + properties = { + { category = "Number", id = "default", name = "Default value", editor = "number", default = false, scale = function(obj) return obj.scale end, min = function(obj) return obj.min end, max = function(obj) return obj.max end, }, + { category = "Number", id = "scale", name = "Scale", editor = "choice", default = 1, items = function() return table.keys2(const.Scale, true, 1, 10, 100, 1000, 1000000) end, }, + { category = "Number", id = "step", name = "Step", editor = "number", default = 1, scale = function(obj) return obj.scale end, }, + { category = "Number", id = "float", name = "Float", editor = "bool", default = false, }, + { category = "Number", id = "slider", name = "Slider", editor = "bool", default = false, }, + { category = "Number", id = "min", name = "Min", editor = "number", default = min_int64, scale = function(obj) return obj.scale end, no_edit = PropChecker("custom_lims", true)}, + { category = "Number", id = "max", name = "Max", editor = "number", default = max_int64, scale = function(obj) return obj.scale end, no_edit = PropChecker("custom_lims", true) }, + { category = "Number", id = "custom_min", name = "Min", editor = "expression", default = false, no_edit = PropChecker("custom_lims", false) }, + { category = "Number", id = "custom_max", name = "Max", editor = "expression", default = false, no_edit = PropChecker("custom_lims", false) }, + { category = "Number", id = "custom_lims", name = "Custom Lims", editor = "bool", default = false, help = "Use custom limits"}, + { category = "Number", id = "modifiable", name = "Modifiable", editor = "bool", default = false, help = "Marks modifiable properties for classes which inherit 'Modifiable' class"}, + }, + editor = "number", + EditorName = "Number property", + EditorSubmenu = "Basic property", +} + +function PropertyDefNumber:GenerateAdditionalPropCode(code, translate) + local scale = self.scale + if scale ~= 1 then + code:appendf(type(scale) == "number" and "scale = %d, " or "scale = \"%s\", ", scale) + end + if self.step ~= 1 then code:appendf("step = %d, ", self.step) end + if self.slider then code:append("slider = true, ") end + if self.custom_lims then + if self.custom_min ~= PropertyDefNumber.custom_min then + local name, params, body = GetFuncSource(self.custom_min) + body = type(body) == "table" and table.concat(body, "\n") or body + code:appendf("min = function(self) %s end, ", body) + end + if self.custom_max ~= PropertyDefNumber.custom_max then + local name, params, body = GetFuncSource(self.custom_max) + body = type(body) == "table" and table.concat(body, "\n") or body + code:appendf("max = function(self) %s end, ", body) + end + else + if self.min ~= PropertyDefNumber.min then code:appendf("min = %d, ", self.min) end + if self.max ~= PropertyDefNumber.max then code:appendf("max = %d, ", self.max) end + end + if self.modifiable then code:append("modifiable = true, ") end +end + +function PropertyDefNumber:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "slider" and self.slider then + if self.min == PropertyDefNumber.min then self.min = 0 end + if self.max == PropertyDefNumber.max then self.max = 100 * (const.Scale[self.scale] or self.scale) end + end +end + +DefineClass.PropertyDefRange = { + __parents = { "PropertyDef" }, + properties = { + { category = "Range", id = "default", name = "Default value", editor = "range", scale = function(obj) return obj.scale end, min = function(obj) return obj.min end, max = function(obj) return obj.max end, default = false, }, + { category = "Range", id = "scale", name = "Scale", editor = "choice", default = 1, items = function() return table.keys2(const.Scale, true, 1, 10, 100, 1000, 1000000) end, }, + { category = "Range", id = "step", name = "Step", editor = "number", default = 1, scale = function(obj) return obj.scale end, }, + { category = "Range", id = "slider", name = "Slider", editor = "bool", default = false, }, + { category = "Range", id = "min", name = "Min", editor = "number", default = min_int64 }, + { category = "Range", id = "max", name = "Max", editor = "number", default = max_int64 }, + }, + editor = "range", + EditorName = "Range property", + EditorSubmenu = "Basic property", +} + +function PropertyDefRange:GenerateAdditionalPropCode(code, translate) + if self.scale ~= 1 then + code:appendf(type(self.scale) == "number" and "scale = %d, " or "scale = \"%s\", ", self.scale) + end + if self.step ~= 1 then code:appendf("step = %d, ", self.step) end + if self.slider then code:append("slider = true, ") end + if self.min ~= PropertyDefRange.min then code:appendf("min = %d, ", self.min) end + if self.max ~= PropertyDefRange.max then code:appendf("max = %d, ", self.max) end +end + +function PropertyDefRange:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "slider" and self.slider then + if self.min == PropertyDefRange.min then self.min = 0 end + if self.max == PropertyDefRange.max then self.max = 100 * (const.Scale[self.scale] or self.scale) end + end +end + +local function translate_only(obj) return not obj.translate end +TextGenderOptions = { + {value = false, text = "None"}, + {value = "ask", text = "Request translation gender for each language (for nouns)"}, + {value = "variants", text = "Generate separate translation texts for each gender"}, +} + +DefineClass.PropertyDefText = { + __parents = { "PropertyDef" }, + properties = { + { category = "Text", id = "default", name = "Default value", editor = "text", default = false, translate = function (obj) return obj.translate end, }, + { category = "Text", id = "translate", name = "Translate", editor = "bool", default = true, }, + { category = "Text", id = "wordwrap", name = "Wordwrap", editor = "bool", default = false, }, + { category = "Text", id = "gender", name = "Gramatical gender", editor = "choice", default = false, items = TextGenderOptions, + no_edit = translate_only, dont_save = translate_only }, + { category = "Text", id = "lines", name = "Lines", editor = "number", default = false, }, + { category = "Text", id = "max_lines", name = "Max lines", editor = "number", default = false, }, + { category = "Text", id = "trim_spaces", name = "Trim Spaces", editor = "bool", default = true, }, + { category = "Text", id = "context", name = "Context", editor = "text", default = "", + no_edit = translate_only, dont_save = translate_only } + }, + editor = "text", + EditorName = "Text property", + EditorSubmenu = "Basic property", +} + +function PropertyDefText:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "translate" then + self:UpdateLocalizedProperty("default", self.translate) + end +end + +function PropertyDefText:GenerateAdditionalPropCode(code, translate) + if self.translate then code:append("translate = true, ") end + if self.wordwrap then code:append("wordwrap = true, ") end + if self.lines then code:appendf("lines = %d, ", self.lines) end + if self.max_lines then code:appendf("max_lines = %d, ", self.max_lines) end + if self.trim_spaces==false then code:append("trim_spaces = false, ") end + local context = self.translate and self.context or "" + if context ~= "" then + assert(not self.gender, "Custom context code is not compatible with gender specification") -- you should include |gender-ask or |gender-variants in the function result + code:append("context = ", context, ", ") + elseif self.gender then + code:append('context = "|gender-', self.gender, '", ') + end +end + +DefineClass.PropertyDefChoice = { + __parents = { "PropertyDef" }, + properties = { + { category = "Choice", id = "default", name = "Default value", editor = "choice", default = false, items = EmulatePropEval("items", {""}) }, + { category = "Choice", id = "items", name = "Items", editor = "expression", default = false, }, + { category = "Choice", id = "show_recent_items", name = "Show recent items", editor = "number", default = 0, }, + }, + translate = false, + editor = "choice", + EditorName = "Choice property", + EditorSubmenu = "Basic property", +} + +function PropertyDefChoice:GenerateAdditionalPropCode(code, translate) + if self.items then + code:append("items = ") + ValueToLuaCode(self.items, nil, code) + code:append(", ") + end + if self.show_recent_items and self.show_recent_items ~= 0 then + code:appendf("show_recent_items = %d,", self.show_recent_items) + end +end + +function PropertyDefChoice:GetConvertToPresetIdClass() + local preset_class + if self.items then + local src = GetFuncSourceString(self.items) + local _, _, capture = string.find(src, 'PresetsCombo%("([%w_+-]*)"%)') + preset_class = capture + end + return preset_class +end + +DefineClass.PropertyDefCombo = { + __parents = { "PropertyDef" }, + properties = { + { category = "Combo", id = "default", name = "Default value", editor = "combo", default = false, items = EmulatePropEval("items", {""}) }, + { category = "Combo", id = "items", name = "Items", editor = "expression", default = false, }, + { category = "Combo", id = "translate", name = "Translate", editor = "bool", default = false, }, + { category = "Combo", id = "show_recent_items", name = "Show recent items", editor = "number", default = 0, }, + }, + editor = "combo", + EditorName = "Combo property", + EditorSubmenu = "Basic property", +} + +PropertyDefCombo.GenerateAdditionalPropCode = PropertyDefChoice.GenerateAdditionalPropCode + +DefineClass.PropertyDefPickerBase = { + __parents = { "PropertyDef" }, + properties = { + { category = "Picker", id = "default", name = "Default value", editor = "combo", default = false, items = EmulatePropEval("items", {""}) }, + { category = "Picker", id = "items", name = "Items", editor = "expression", default = false, }, + { category = "Picker", id = "max_rows", name = "Max rows", editor = "number", default = false, help = "Maximum number of rows displayed." }, + { category = "Picker", id = "multiple", name = "Multiple selection", editor = "bool", default = false, }, + { category = "Picker", id = "small_font", name = "Small font", editor = "bool", default = false, }, + { category = "Picker", id = "filter_by_prop", name = "Filter by prop", editor = "text", default = false, help = "Links this property to a text property with the specified id that serves as a filter." }, + }, +} + +function PropertyDefPickerBase:GenerateAdditionalPropCode(code, translate) + PropertyDefChoice.GenerateAdditionalPropCode(self, code, translate) + if self.max_rows then code:appendf("max_rows = %d, ", self.max_rows) end + if self.multiple then code:append ("multiple = true, ") end + if self.small_font then code:append ("small_font = true, ") end + if self.filter_by_prop then code:appendf("filter_by_prop = \"%s\", ", self.filter_by_prop) end +end + +DefineClass.PropertyDefTextPicker = { + __parents = { "PropertyDefPickerBase" }, + properties = { + { category = "Picker", id = "horizontal", name = "Horizontal", editor = "bool", default = false, help = "Display items horizontally." }, + }, + editor = "text_picker", + EditorName = "Text picker property", + EditorSubmenu = "Extras", +} + +function PropertyDefTextPicker:GenerateAdditionalPropCode(code, translate) + PropertyDefPickerBase.GenerateAdditionalPropCode(self, code, translate) + if self.horizontal then code:append("horizontal = true, ") end +end + +DefineClass.PropertyDefTexturePicker = { + __parents = { "PropertyDefPickerBase" }, + properties = { + { category = "Picker", id = "thumb_width", name = "Width", editor = "number", default = false, }, + { category = "Picker", id = "thumb_height", name = "Height", editor = "number", default = false, }, + { category = "Picker", id = "thumb_zoom", name = "Zoom", editor = "number", min = 100, max = 200, slider = true, default = false, help = "Scale the texture up, displaying only the middle part with Width x Height dimensions.", }, + { category = "Picker", id = "alt_prop", name = "Alt prop", editor = "text", default = false, help = "Id of another property that gets set by Alt-click on this property.", }, + { category = "Picker", id = "base_color_map", name = "Base color map", editor = "bool", default = false, help = "Use to display a base color map texture in the correct colors.", }, + }, + editor = "texture_picker", + EditorName = "Texture picker property", + EditorSubmenu = "Extras", +} + +function PropertyDefTexturePicker:GenerateAdditionalPropCode(code, translate) + PropertyDefPickerBase.GenerateAdditionalPropCode(self, code, translate) + if self.thumb_width then code:appendf("thumb_width = %d, ", self.thumb_width) end + if self.thumb_height then code:appendf("thumb_height = %d, ", self.thumb_height) end + if self.thumb_zoom then code:appendf("thumb_zoom = %d, ", self.thumb_zoom) end + if self.alt_prop then code:appendf("alt_prop = \"%s\", ", self.alt_prop) end + if self.base_color_map then code:append ("base_color_map = true, ") end +end + +DefineClass.PropertyDefSet = { + __parents = { "PropertyDef" }, + properties = { + { category = "Set", id = "default", name = "Default value", editor = "set", default = false, items = EmulatePropEval("items", {""}), three_state = function(obj) return obj.three_state end, max_items_in_set = function(obj) return obj.max_items_in_set end, }, + { category = "Set", id = "items", name = "Items", editor = "expression", default = false, }, + { category = "Set", id = "arbitrary_value", name = "Allow arbitrary value", editor = "bool", default = false, }, + { category = "Set", id = "three_state", name = "Three-state", editor = "bool", default = false, help = "Each set item can have one of the three value 'nil', 'true', or 'false'." }, + { category = "Set", id = "max_items_in_set", name = "Max items", editor = "number", default = 0, help = "Max number of items in the set (0 = no limit)." }, + }, + translate = false, + editor = "set", + EditorName = "Set property", + EditorSubmenu = "Basic property", +} + +function PropertyDefSet:GenerateAdditionalPropCode(code, translate) + if self.three_state then code:append("three_state = true, ") end + if self.arbitrary_value then code:append("arbitrary_value = true, ") end + if self.max_items_in_set ~= 0 then code:appendf("max_items_in_set = %d, ", self.max_items_in_set) end + if self.items then + code:append("items = ") + ValueToLuaCode(self.items, nil, code) + code:append(", ") + end +end + +DefineClass.PropertyDefGameStatefSet = { + __parents = { "PropertyDef" }, + properties = { + { category = "Set", id = "default", name = "Default value", editor = "set", default = false, items = function() return GetGameStateFilter() end, three_state = true, }, + }, + translate = false, + editor = "set", + EditorName = "GameState Set property", + EditorSubmenu = "Basic property", +} + +function PropertyDefGameStatefSet:GenerateAdditionalPropCode(code, translate) + code:append("three_state = true, items = function (self) return GetGameStateFilter() end, ") + code:append('buttons = { {name = "Check Game States", func = "PropertyDefGameStatefSetCheck"}, },') +end + +function PropertyDefGameStatefSetCheck(_, obj, prop_id, ged) + ged:ShowMessage("Test Result", GetMismatchGameStates(obj[prop_id])) +end + +DefineClass.PropertyDefColor = { + __parents = { "PropertyDef" }, + properties = { + { category = "Color", id = "default", name = "Default value", editor = "color", default = RGB(0, 0, 0), }, + }, + editor = "color", + EditorName = "Color property", + EditorSubmenu = "Basic property", +} + +DefineClass.PropertyDefImage = { + __parents = { "PropertyDef" }, + properties = { + { category = "Image", id = "default", name = "Default value", editor = "image", default = false, }, + { category = "Image", id = "img_size", name = "Image box", editor = "number", default = 200, }, + { category = "Image", id = "img_box", name = "Image border", editor = "number", default = 1, }, + { category = "Image", id = "base_color_map", name = "Base color", editor = "bool", default = false, }, + }, + editor = "image", + EditorName = "Image preview property", + EditorSubmenu = "Extras", +} + +function PropertyDefImage:GenerateAdditionalPropCode(code, translate) + code:appendf("img_size = %d, img_box = %d, base_color_map = %s, ", self.img_size, self.img_box, tostring(self.base_color_map)) +end + +DefineClass.PropertyDefGrid = { + __parents = { "PropertyDef" }, + properties = { + { category = "Grid", id = "default", name = "Default value", editor = "grid", default = false, }, + { category = "Grid", id = "frame", name = "Frame", editor = "number", default = 0, }, + { category = "Grid", id = "color", name = "Color", editor = "bool", default = false }, + { category = "Grid", id = "min", name = "Min Size", editor = "number", default = 0, }, + { category = "Grid", id = "max", name = "Max Size", editor = "number", default = 0 }, + }, + editor = "grid", + EditorName = "Grid property", + EditorSubmenu = "Extras", +} + +function PropertyDefGrid:GenerateAdditionalPropCode(code, translate) + if self.frame > 0 then code:appendf("frame = %d, ", self.frame) end + if self.min > 0 then code:appendf("min = %d, ", self.min) end + if self.max > 0 then code:appendf("max = %d, ", self.max) end + if self.color then code:append("color = true, ") end +end + +DefineClass.PropertyDefMaterial = { + __parents = { "PropertyDef" }, + properties = { + { category = "Color", id = "default", name = "Default value", editor = "rgbrm", default = RGBRM(200, 200, 200, 0, 0) }, + }, + editor = "rgbrm", + EditorName = "Material property", + EditorSubmenu = "Extras", +} + +DefineClass.PropertyDefBrowse = { + __parents = { "PropertyDef" }, + properties = { + { category = "Browse", id = "default", name = "Default value", editor = "browse", default = false, }, + { category = "Browse", id = "folder", name = "Folder", editor = "text", default = "UI", }, + { category = "Browse", id = "filter", name = "Filter", editor = "text", default = "Image files|*.tga", }, + { category = "Browse", id = "extension", name = "Force extension", editor = "text", default = false, + buttons = { { name = "No extension", func = function(self) self.extension = "" ObjModified(self) end, } }, + }, + { category = "Browse", id = "image_preview_size", name = "Image preview size", editor = "number", default = 0, }, + }, + editor = "browse", + EditorName = "Browse property", + EditorSubmenu = "Basic property", +} + +function PropertyDefBrowse:GenerateAdditionalPropCode(code, translate) + local folder, filter = self.folder or "", self.filter or "" + if folder ~= "" then + -- detect if we have a table or a single string for self.folder + if folder:match("^%s*[{\"]") then + code:appendf("folder = %s, ", self.folder) + else + code:appendf("folder = \"%s\", ", self.folder) + end + end + if self.filter ~= "" then code:appendf("filter = \"%s\", ", self.filter) end + if self.extension ~= PropertyDefBrowse.extension then code:appendf("force_extension = \"%s\", ", self.extension) end + if self.image_preview_size > 0 then code:appendf("image_preview_size = %i, ", self.image_preview_size) end +end + + +DefineClass.PropertyDefUIImage = { + __parents = { "PropertyDef" }, + properties = { + { category = "Browse", id = "default", name = "Default value", editor = "ui_image", default = false, }, + { category = "Browse", id = "filter", name = "Filter", editor = "text", default = "All files|*.*", }, + { category = "Browse", id = "extension", name = "Force extension", editor = "text", default = false, + buttons = { { name = "No extension", func = function(self) self.extension = "" ObjModified(self) end, } }, + }, + { category = "Browse", id = "image_preview_size", name = "Image preview size", editor = "number", default = 0, }, + }, + editor = "ui_image", + EditorName = "UI image property", + EditorSubmenu = "Basic property", +} + +function PropertyDefUIImage:GenerateAdditionalPropCode(code, translate) + if self.filter ~= PropertyDefUIImage.filter then code:appendf("filter = \"%s\", ", self.filter) end + if self.extension ~= PropertyDefUIImage.extension then code:appendf("force_extension = \"%s\", ", self.extension) end + if self.image_preview_size > 0 then code:appendf("image_preview_size = %i, ", self.image_preview_size) end +end + +DefineClass.PropertyDefFunc = { + __parents = { "PropertyDef" }, + properties = { + { category = "Func", id = "params", name = "Params", editor = "text", default = "self", }, + { category = "Func", id = "default", name = "Default value", editor = "func", default = empty_func, lines = 1, max_lines = 20, params = function (self) return self.params end, }, + }, + editor = "func", + EditorName = "Func property", + EditorSubmenu = "Code", +} + +function PropertyDefFunc:ToStringWithColor(value) + if value == empty_func then + return PropertyDef.ToStringWithColor(self, string.format("function (%s) end", self.params), "function") + end + return PropertyDef.ToStringWithColor(self, value) +end + + +function PropertyDefFunc:GenerateDefaultValueCode() + if not self.default then return "false" end + return GetFuncSourceString(self.default, "", self.params or "self") +end + +function PropertyDefFunc:GenerateAdditionalPropCode(code, translate) + if self.params and self.params ~= "self" then + code:appendf("params = \"%s\", ", self.params) + end +end + +DefineClass.PropertyDefExpression = { + __parents = { "PropertyDef" }, + properties = { + { category = "Expression", id = "params", name = "Params", editor = "text", default = "self", }, + { category = "Expression", id = "default", name = "Default value", editor = "expression", default = false, params = function(self) return self.params end, }, + }, + editor = "expression", + EditorName = "Expression property", + EditorSubmenu = "Code", +} + +function PropertyDefExpression:GenerateDefaultValueCode() + if not self.default then return "false" end + return GetFuncSourceString(self.default, "", self.params or "self") +end + +function PropertyDefExpression:GenerateAdditionalPropCode(code, translate) + if self.params and self.params ~= "self" then + code:appendf("params = \"%s\", ", self.params) + end +end + +DefineClass.PropertyDefShortcut = { + __parents = { "PropertyDef" }, + properties = { + { category = "Expression", id = "shortcut_type", name = "Shortcut type", editor = "choice", default = "keyboard&mouse", items = { + "keyboard&mouse", + "keyboard", + "gamepad" + }, }, + { category = "Expression", id = "default", name = "Default value", editor = "text", default = "", no_edit = true, }, + }, + editor = "shortcut", + EditorName = "Shortcut property", + EditorSubmenu = "Extras", +} + +function PropertyDefShortcut:GenerateAdditionalPropCode(code, translate) + code:appendf("shortcut_type = \"%s\", ", self.shortcut_type) +end + +----- Presets - preset_id & preset_id_list + +function IsPresetWithConstantGroup(classdef) + if not classdef then return end + local prop = classdef:GetPropertyMetadata("Group") + return not prop or prop_eval(prop.no_edit, classdef, prop, true) or prop_eval(prop.read_only, classdef, prop, true) +end + +DefineClass.PropertyDefPresetIdBase = { + __parents = { "PropertyDef" }, + properties = { + { category = "PresetId", id = "preset_class", name = "Preset class", editor = "choice", default = "", + items = ClassDescendantsCombo("Preset", false, function(name, class) + return not IsKindOfClasses(class, "ClassDef", "ClassDefSubItem") + end), + }, + { category = "PresetId", id = "preset_group", name = "Preset group", editor = "choice", default = "", + help = "Restricts the choice to the specified group of the preset class.", + items = function(obj) + local class = g_Classes[obj.preset_class] + local preset_class = class.PresetClass or obj.preset_class + return class and PresetGroupsCombo(preset_class) or empty_table + end, + no_edit = function(obj) -- disable group restriction for classes with uneditable "Group" property + local class = g_Classes[obj.preset_class] + return not class or IsPresetWithConstantGroup(class) + end, }, + { category = "PresetId", id = "preset_filter", name = "PresetFilter", editor = "func", params = "preset, obj, prop_meta", lines = 1, max_lines = 20, default = false }, + { category = "PresetId", id = "extra_item", name = "Extra item", editor = "text", default = false }, + { category = "PresetId", id = "default", name = "Default value", editor = "choice", + items = function(obj) + local class = g_Classes[obj.preset_class] + if class and (class.GlobalMap or IsPresetWithConstantGroup(class) or obj.preset_group ~= "") then + return PresetsCombo(obj.preset_class, obj.preset_group ~= "" and obj.preset_group, {"", obj.extra_item or nil}) + end + return { false } + end, default = false }, + }, + editor = "preset_id", + EditorName = "PresetId property", + EditorSubmenu = "Basic property", +} + +function PropertyDefPresetIdBase:GenerateAdditionalPropCode(code, translate) + if self.preset_class ~= "" then code:appendf("preset_class = \"%s\", ", self.preset_class) end + if self.preset_group ~= "" then code:appendf("preset_group = \"%s\", ", self.preset_group) end + if self.extra_item then code:appendf("extra_item = \"%s\", ", self.extra_item) end + self:AppendFunctionCode(code, "preset_filter") +end + +function PropertyDefPresetIdBase:OnEditorSetProperty(prop_id, old_value, ...) + if prop_id == "preset_class" then + self.preset_group = nil + self.default = nil + elseif prop_id == "preset_group" then + self.default = nil + end + ObjModified(self) +end + +function PropertyDefPresetIdBase:GetError() + local class = g_Classes[self.preset_class] + if class and not (class.GlobalMap or IsPresetWithConstantGroup(class) or self.preset_group ~= "") then + return string.format("%s doesn't have GlobalMap - all presets can't be listed. Please specify a Preset group.\n\nIf you want all presets to be selectable, either add GlobalMap, or create two properties - one for selecting a preset group and another for selecting a preset from that group.", self.preset_class) + end +end + +DefineClass.PropertyDefPresetId = { + __parents = { "PropertyDefPresetIdBase" }, +} + +DefineClass.PropertyDefPresetIdList = { + __parents = { "PropertyDefPresetIdBase", "WeightedListProps" }, + properties = { + { category = "PresetId", id = "default", name = "Default value", editor = "preset_id_list", + preset_class = function(obj) return obj.preset_class end, + preset_group = function(obj) return obj.preset_group end, + extra_item = function(obj) return obj.extra_item end, + default = {}, }, + }, + editor = "preset_id_list", + EditorName = "PresetId list property", + EditorSubmenu = "Lists", +} + +function PropertyDefPresetIdList:GenerateAdditionalPropCode(code, translate) + PropertyDefPresetIdBase.GenerateAdditionalPropCode(self, code, translate) + code:append("item_default = \"\"") + self:GenerateWeightPropCode(code) + code:append(", ") +end + + +----- Nested object & nested list + +local function BaseClassCombo(obj, prop_meta, validate_fn) + if validate_fn == "validate_fn" then + -- function for preset validation, checks whether the property value is from "items" + return "validate_fn", function(value, obj, prop_meta) + local class = g_Classes[value] + return value == "" or IsKindOf(class, "PropertyObject") and not IsKindOf(class, "CObject") + end + end + return ClassDescendantsList("PropertyObject", function(name, def) + return not def.__ancestors.CObject and name ~= "CObject" + end, "") +end + +DefineClass.PropertyDefObject = { + __parents = { "PropertyDef" }, + default = false, + editor = "object", + properties = { + { category = "Property", id = "base_class", name = "Base class", editor = "choice", items = function() return ClassDescendantsListInclusive("Object") end, default = "Object" }, + { category = "Property", id = "format_func", name = "Format", editor = "func", default = GetObjectPropEditorFormatFuncDefault, lines = 1, max_lines = 10, params = "gameobj"}, + }, + EditorName = "Object property", + EditorSubmenu = "Objects", +} + +function PropertyDefObject:GenerateAdditionalPropCode(code, translate) + code:appendf("base_class = \"%s\", ", self.base_class) + self:AppendFunctionCode(code, "format_func") +end + +DefineClass.PropertyDefNestedObj = { + __parents = { "PropertyDef" }, + properties = { + { category = "Nested Object", id = "base_class", name = "Base class", editor = "choice", items = BaseClassCombo, default = "PropertyObject" }, + { category = "Nested Object", id = "inclusive", name = "Allow base class", editor = "bool", default = false, }, + { category = "Nested Object", id = "no_descendants", name = "No descendants", editor = "bool", default = false, }, + { category = "Nested Object", id = "all_descendants", name = "Allow all descendants", editor = "bool", default = false, no_edit = function (self) return self.no_descendants end, }, + { category = "Nested Object", id = "class_filter", name = "ClassFilter", editor = "func", default = false, lines = 1, max_lines = 20, params = "name, class, obj"}, + { category = "Nested Object", id = "format", name = "Format in Ged", editor = "text", default = "", }, + { category = "Nested Object", id = "auto_expand", name = "Auto Expand", editor = "bool", default = false, }, + { category = "Nested Object", id = "default", name = "Default value", editor = "bool", default = false, no_edit = true, }, + }, + editor = "nested_obj", + EditorName = "Nested object property", + EditorSubmenu = "Objects", +} + +function PropertyDefNestedObj:GenerateAdditionalPropCode(code, translate) + code:appendf("base_class = \"%s\", ", self.base_class) + if self.inclusive then code:append("inclusive = true, ") end + if self.no_descendants then code:append("no_descendants = true, ") + elseif self.all_descendants then code:append("all_descendants = true, ") end + if self.auto_expand then code:appendf("auto_expand = true, ") end + if self.format ~= PropertyDefNestedObj.format then code:appendf("format = \"%s\"", self.format) end + self:AppendFunctionCode(code, "class_filter") +end + +function PropertyDefNestedObj:GetError() + if self.base_class == "PropertyObject" then + return "Please specify base class for the nested object(s)." + end +end + +DefineClass.PropertyDefNestedList = { + __parents = { "PropertyDef" }, + properties = { + { category = "Nested List", id = "base_class", name = "Base class", editor = "choice", items = BaseClassCombo, default = "PropertyObject" }, + { category = "Nested List", id = "inclusive", name = "Allow base class", editor = "bool", default = false, }, + { category = "Nested List", id = "no_descendants", name = "No descendants", editor = "bool", default = false, }, + { category = "Nested List", id = "all_descendants", name = "Allow all descendants", editor = "bool", default = false, no_edit = function (self) return self.no_descendants end, }, + { category = "Nested List", id = "class_filter", name = "ClassFilter", editor = "func", default = false, lines = 1, max_lines = 20, params = "name, class, obj"}, + { category = "Nested List", id = "format", name = "Format in Ged", editor = "text", default = "", }, + { category = "Nested List", id = "auto_expand", name = "Auto Expand", editor = "bool", default = false, }, + { category = "Nested List", id = "default", name = "Default value", editor = "bool", default = false, no_edit = true, }, + }, + editor = "nested_list", + EditorName = "Nested list property", + EditorSubmenu = "Lists", +} + +PropertyDefNestedList.GenerateAdditionalPropCode = PropertyDefNestedObj.GenerateAdditionalPropCode +PropertyDefNestedList.GetError = PropertyDefNestedObj.GetError + +DefineClass.PropertyDefPropertyArray = { + __parents = { "PropertyDef" }, + properties = { + { category = "Dynamic Props", id = "from", name = "Generate id from", editor = "choice", default = false, + items = { "Table keys", "Table values", "Table field values", "Preset ids" }, + }, + { category = "Dynamic Props", id = "field", name = "Table field", editor = "text", translate = false, default = "", + no_edit = function(self) return self.from ~= "Table field values" end, + }, + { category = "Dynamic Props", id = "items", name = "Items", editor = "expression", default = false, + no_edit = function(self) return self.from == "Preset ids" end, + }, + { category = "Dynamic Props", id = "preset", name = "Preset class", editor = "choice", default = "", + items = ClassDescendantsCombo("Preset"), + no_edit = function(self) return self.from ~= "Preset ids" end, + }, + { category = "Dynamic Props", id = "prop_meta_update", name = "Update prop_meta", editor = "func", params = "self, prop_meta", default = false, + help = "Update the prop_meta of each generated property from prop_meta.id, prop_meta.index (consecutive number), and prop_meta.prest/value.", + }, + { category = "Dynamic Props", id = "prop", name = "Property template", editor = "nested_obj", base_class = "PropertyDef", auto_expand = true, default = false, + suppress_props = { id = true, name = true, category = true, dont_save = true, no_edit = true, template = true, }, + }, + }, + editor = "property_array", + EditorName = "Property array", + EditorSubmenu = "Objects", + default = false, +} + +function PropertyDefPropertyArray:GenerateAdditionalPropCode(code, translate) + local from_preset = self.from == "Preset ids" and self.preset ~= "" and self.preset + if self.prop and (from_preset or not from_preset and self.items ~= false) then + if from_preset then + code:appendf("from = '%s', ", self.preset) + else + code:append("items = ") + ValueToLuaCode(self.items, nil, code) + code:appendf(", from = '%s', ", self.from) + if self.from == "Table field values" then + code:appendf("field = '%s', ", self.field) + end + if self.prop_meta_update then + code:appendf("prop_meta_update = ") + ValueToLuaCode(self.prop_meta_update, nil, code) + code:appendf(", ") + end + end + code:append("\nprop_meta =\n") + self.prop.id = " " + self.prop:GenerateCode(code, translate) + end +end + +DefineClass.PropertyDefScript = { + __parents = { "PropertyDef" }, + properties = { + { category = "Script", id = "condition", name = "Is condition list", editor = "bool", default = false, }, + { category = "Script", id = "params_exp", name = "Params is an expression", editor = "bool", default = false, }, + { category = "Script", id = "params", name = "Params", editor = "text", default = "self", }, + { category = "Script", id = "script_domain", name = "Script domain", editor = "choice", default = false, items = function() return ScriptDomainsCombo() end }, + }, + default = false, + editor = "script", + EditorName = "Script", + EditorSubmenu = "Code", +} + +function PropertyDefScript:GenerateAdditionalPropCode(code, translate) + if self.condition then + code:append('class = "ScriptConditionList", ') + end + if self.params_exp then + code:appendf('params = function(self) return %s end, ', self.params) + else + code:appendf('params = "%s", ', self.params) + end + if self.script_domain then + code:appendf('script_domain = "%s", ', self.script_domain) + end +end + + +----- Primitive lists + +DefineClass.WeightedListProps = { + __parents = { "PropertyObject" }, + properties = { + { category = "Weights", id = "weights", name = "Weights", editor = "bool", default = false, + no_edit = function(obj) return obj:DisableWeights() end, help = "Associates weights to the list items"}, + { category = "Weights", id = "weight_default", name = "Default Weight", editor = "number", default = 100, + no_edit = function(obj) return not obj.weights end, help = "Default weight for each list item" }, + { category = "Weights", id = "value_key", name = "Value Key", editor = "text", default = "value", + no_edit = function(obj) return not obj.weights end, help = "Name of the 'value' key in each list item. Can be a number too." }, + { category = "Weights", id = "weight_key", name = "Weight Key", editor = "text", default = "weight", + no_edit = function(obj) return not obj.weights end, help = "Name of the 'weight' key in each list item. Can be a number too." }, + }, + DisableWeights = empty_func, +} + +function WeightedListProps:GetItemKeys() + local value_key = self.value_key + if value_key == "" then + value_key = "value" + end + value_key = tonumber(value_key) or value_key + + local weight_key = self.weight_key + if weight_key == "" then + weight_key = "weight" + end + weight_key = tonumber(weight_key) or weight_key + + return value_key, weight_key +end + +function WeightedListProps:GenerateWeightPropCode(code) + if not self.weights then + return + end + code:append(", weights = true") + local value_key, weight_key = self:GetItemKeys() + if value_key ~= "value" then + code:append(", value_key = ") + ValueToLuaCode(value_key, nil, code) + end + if weight_key ~= "weight" then + code:append(", weight_key = ") + ValueToLuaCode(weight_key, nil, code) + end + if self.weight_default ~= 100 then + code:append(", weight_default = ") + ValueToLuaCode(self.weight_default, nil, code) + end +end + +DefineClass.PropertyDefPrimitiveList = { + __parents = { "PropertyDef", "WeightedListProps" }, + properties = { + { category = "List", id = "item_default", name = "Item Default", editor = "text", default = false, }, + { category = "List", id = "items", name = "Items", editor = "expression", default = false, }, + { category = "List", id = "max_items", name = "Max number of items", editor = "number", default = -1, }, + }, + editor = "", + EditorName = "", +} + +function PropertyDefPrimitiveList:DisableWeights() + return self.editor ~= "number_list" and self.editor ~= "string_list" +end + +function PropertyDefPrimitiveList:GenerateAdditionalPropCode(code, translate) + code:append("item_default = ") + ValueToLuaCode(self.item_default, nil, code) + code:append(", items = ") + ValueToLuaCode(self.items, nil, code) + if self.arbitrary_value then + code:append(", arbitrary_value = true") + end + if self.max_items >= 0 then + code:append(", max_items = ") + ValueToLuaCode(self.max_items, nil, code) + end + self:GenerateWeightPropCode(code) + code:append(", ") +end + +DefineClass.PropertyDefNumberList = { + __parents = { "PropertyDefPrimitiveList" }, + + properties = { + { category = "List", id = "default", name = "Default value", editor = "number_list", + default = {}, item_default = function(self) return self.item_default end, items = EmulatePropEval("items", {0}) }, + { category = "List", id = "item_default", name = "Item Default", editor = "number", default = 0, }, + }, + + editor = "number_list", + EditorName = "Number list property", + EditorSubmenu = "Lists", +} + +DefineClass.PropertyDefStringList = { + __parents = { "PropertyDefPrimitiveList" }, + + properties = { + { category = "List", id = "default", name = "Default value", editor = "string_list", + default = {}, items = EmulatePropEval("items", {""}), + item_default = function(self) return self.item_default end, + arbitrary_value = function(self) return self.arbitrary_value end, }, + { category = "List", id = "item_default", name = "Item default", editor = "combo", + default = "", items = EmulatePropEval("items", {""}) }, + { category = "List", id = "arbitrary_value", name = "Allow arbitrary value", editor = "bool", default = false, }, + }, + + editor = "string_list", + EditorName = "String list property", + EditorSubmenu = "Lists", +} + +DefineClass.PropertyDefTList = { + __parents = { "PropertyDefPrimitiveList" }, + + properties = { + { category = "List", id = "default", name = "Default value", editor = "T_list", + default = {}, item_default = function(self) return self.item_default end, items = EmulatePropEval("items", {""}) }, + { category = "List", id = "item_default", name = "Item default", editor = "text", default = "", translate = true}, + { category = "Text", id = "context", name = "Context", editor = "text", default = "", } + }, + + editor = "T_list", + EditorName = "Translated list property", + EditorSubmenu = "Lists", +} + +function PropertyDefTList:GenerateAdditionalPropCode(code, translate) + PropertyDefPrimitiveList.GenerateAdditionalPropCode(self, code, translate) + if self.context and self.context ~= "" then + code:append("context = " .. self.context .. ", ") + end +end + +DefineClass.PropertyDefHelp = { + __parents = { "PropertyDef" }, + default = false, + editor = "help", + EditorName = "Help text", + EditorSubmenu = "Extras", +} + + +----- ClassConstDef + +local const_items = { + { text = "Bool", value = "bool" }, + { text = "Number", value = "number" }, + { text = "Text", value = "text" }, + { text = "Translated Text", value = "translate" }, + { text = "Point", value = "point" }, + { text = "Box", value = "rect" }, + { text = "Color", value = "color" }, + { text = "Range", value = "range" }, + { text = "Image", value = "browse" }, + { text = "Table", value = "prop_table" }, + { text = "String List", value = "string_list" }, + { text = "Number List", value = "number_list" }, +} + +DefineClass.ClassConstDef = { + __parents = { "ClassDefSubItem" }, + properties = { + { category = "Const", id = "name", name = "Name", editor = "text", default = "", validate = ValidateIdentifier }, + { category = "Const", id = "type", name = "Type", editor = "choice", default = "bool", items = const_items, }, + { category = "Const", id = "value", name = "Value", editor = function(self) return self.type == "translate" and "text" or self.type end, + translate = function(self) return self.type == "translate" end, default = false, + lines = function(self) return self.type == "prop_table" and 3 or self.type == "text" and 1 end, + max_lines = function(self) return self.type == "text" and 256 end, }, + { category = "Const", id = "untranslated", name = "Untranslated", editor = "bool", + no_edit = function(self) return self.type ~= "translate" and self.type ~= "text" end, default = false, } + }, + EditorName = "Class member", + EditorSubmenu = "Code", +} + +function ClassConstDef:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "type" then + local value = self.type + if value == "text" and old_value == "translate" then + self:UpdateLocalizedProperty("value", false) + elseif value == "translate" and old_value == "text" then + self:UpdateLocalizedProperty("value", true) + else + self.value = nil + end + end +end + +function ClassConstDef:GetValue() + if not self.value and (self.type == "text" or self.type == "translate") then + return "" + end + return self.value +end + +function ClassConstDef:GetEditorView() + local result = "Class." + if self.type == "translate" then + result = result .. string.format(self.untranslated and '%s = Untranslated(%s)' or '%s = T(%s)', self.name, self:ToStringWithColor(TDevModeGetEnglishText(self:GetValue()))) + else + result = result .. string.format("%s = %s", self.name, self:ToStringWithColor(self:GetValue())) + end + return result +end + +function ClassConstDef:GenerateCode(code) + if not self.name:match("^[%w_]+$") then return end + if self.untranslated then + code:append("\t", self.name, " = Untranslated(" ) + if self.type == "text" then + ValueToLuaCode(self:GetValue(), nil, code) + elseif self.type == "translate" then + ValueToLuaCode(TDevModeGetEnglishText(self:GetValue()), nil, code) + end + code:append("),\n") + return + end + code:append("\t", self.name, " = ") + ValueToLuaCode(self:GetValue(), nil, code) + code:append(",\n") +end + + +----- ClassMethodDef + +local default_methods = { + "", + "GetEditorView()", + "GetError()", + "GetWarning()", + "OnEditorSetProperty(prop_id, old_value, ged)", + "OnEditorNew(parent, ged, is_paste)", + "OnEditorDelete(parent, ged)", + "OnEditorSelect(selected, ged)", +} + +function ClassMethodDefKnownMethodsCombo(method_def) + local defaults = { delete = true } + for _, method in ipairs(default_methods) do + local name = method:match("(.+)%(") + if name then defaults[name] = true end + end + + local methods = {} + local class_def = GetParentTableOfKind(method_def, "ClassDef") + for _, parent in ipairs(class_def.DefParentClassList) do + local class = g_Classes[parent] + while class and class.class ~= "PropertyObject" and class.class ~= "Preset" do + for k, v in pairs(class) do + if type(v) == "function" then + local name, params, body = GetFuncSource(v) + local sep_idx = name and name:find(":", 1, true) + if sep_idx then + name = name:sub(sep_idx + 1) + if not defaults[name] then + methods[#methods + 1] = string.format("%s(%s)", name, params:trim_spaces()) + end + end + end + end + class = getmetatable(class) + end + end + table.sort(methods) + return #methods == 0 and default_methods or table.iappend(table.iappend(table.copy(default_methods), {"---"}), methods) +end + +DefineClass.ClassMethodDef = { + __parents = { "ClassDefSubItem" }, + properties = { + { category = "Method", id = "name", name = "Name", editor = "combo", default = "", + items = ClassMethodDefKnownMethodsCombo, + validate = function (self, value) + local sep_idx = type(value) == "string" and value:find("(", 1, true) + local name = sep_idx and value:sub(1, sep_idx - 1) or value + if type(value) ~= "string" or not name:match("^[%w_]*$") then + return "Value must be a valid identifier or a function prototype." + end + end, }, + { category = "Method", id = "params", name = "Params", editor = "text", default = "", }, + { category = "Method", id = "comment", name = "Comment", editor = "text", default = "", lines = 1, max_lines = 5, }, + { category = "Method", id = "code", name = "Code", editor = "func", default = false, lines = 1, max_lines = 100, + params = function (self) return self.params == "" and "self" or "self, " .. self.params end, }, + }, + EditorName = "Method", + EditorSubmenu = "Code", +} + +function ClassMethodDef:GetEditorView() + local ret = string.format("function Class:%s(%s)", self.name, self.params) + if self.comment ~= "" then + ret = string.format("%s -- %s", ret, self.comment) + end + return ret +end + +function ClassMethodDef:GenerateCode(code, class_name) + if not self.name:match("^[%w_]+$") then return end + code:appendf("function %s:%s(%s)\n", class_name, self.name, self.params) + local name, params, body = GetFuncSource(self.code) + if type(body) == "string" then + body = string.split(body, "\n") + end + code:append("\t", body and table.concat(body, "\n\t") or "", "\n") + code:append("end\n\n") +end + +function ClassMethodDef:ContainsCode(snippet) + local name, params, body = GetFuncSource(self.code) + if type(body) == "table" then + body = table.concat(body, "\n") + end + return body and body:find(snippet, 1, true) +end + +function ClassMethodDef:OnEditorSetProperty(prop_id, old_value, ged) + local method = self.name + if prop_id == "name" and method:find("(", 1, true) then + self.name = method:match("(.+)%(") + self.params = method:sub(#self.name + 2, -2) + end +end + + +----- ClassGlobalCodeDef + +DefineClass.ClassGlobalCodeDef = { + __parents = { "ClassDefSubItem" }, + properties = { + { id = "comment", name = "Comment", editor = "text", default = "", }, + { id = "code", name = "Code", editor = "func", default = false, lines = 1, max_lines = 100, params = "", }, + }, + EditorName = "Code", + EditorSubmenu = "Code", +} + +function ClassGlobalCodeDef:GetEditorView() + if self.comment == "" then + return "code" + end + return string.format("code -- %s", self.comment) +end + +function ClassGlobalCodeDef:GenerateCode(code, class_name) + local name, params, body = GetFuncSource(self.code) + if not body then return end + code:append("----- ", class_name, " ", self.comment, "\n\n") + if type(body) == "table" then + for _, line in ipairs(body) do + code:append(line, "\n") + end + else + code:append(body) + end + code:append("\n") +end diff --git a/CommonLua/Classes/ClassDefs/ClassDef-Common.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-Common.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..bb4563085176e28ee62bde083c7367ed94adbc9b --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-Common.generated.lua @@ -0,0 +1,68 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +--- Defines a class `StoryBitWithWeight` that inherits from `PropertyObject`. +--- +--- This class represents a story bit with a weight value that determines its probability of being selected. +--- +--- @class StoryBitWithWeight +--- @field StoryBitId string The ID of the story bit. +--- @field NoCooldown boolean Whether to skip cooldowns for subsequent story bit activations. +--- @field ForcePopup boolean Whether to directly display the popup without a notification phase. +--- @field Weight number The weight of the story bit, used to determine its probability of being selected. +--- @field StorybitSets string A comma-separated list of the story bit sets this story bit belongs to. +--- @field OneTime boolean Whether this story bit can only be activated once. +DefineClass.StoryBitWithWeight = {__parents={"PropertyObject"}, __generated_by_class="ClassDef", + + properties={{id="StoryBitId", name="Id", editor="preset_id", default=false, preset_class="StoryBit"}, + {id="NoCooldown", help="Don't activate any cooldowns for subsequent StoryBit activations", editor="bool", + default=false}, {id="ForcePopup", name="Force Popup", + help="Specifying true skips the notification phase, and directly displays the popup", editor="bool", + default=true}, {id="Weight", name="Weight", editor="number", default=100, min=0}, + {id="StorybitSets", name="Storybit sets", editor="text", default="", dont_save=true, + read_only=true}, {id="OneTime", editor="bool", default=false, dont_save=true, read_only=true}}, + EditorView=Untranslated('"Activate StoryBit (weight: )"')} +--- Returns a comma-separated string of the story bit sets that the current story bit belongs to. +--- +--- If the story bit preset does not exist or has no sets defined, this function returns "None". +--- +--- @return string A comma-separated list of the story bit sets, or "None" if there are no sets. +function StoryBitWithWeight:GetStorybitSets() + local preset = StoryBits[self.StoryBitId] + if not preset or not next(preset.Sets) then + return "None" + end + local items = {} + for set in sorted_pairs(preset.Sets) do + items[#items + 1] = set + end + return table.concat(items, ", ") +end + +function StoryBitWithWeight:GetStorybitSets() + local preset = StoryBits[self.StoryBitId] + if not preset or not next(preset.Sets) then + return "None" + end + local items = {} + for set in sorted_pairs(preset.Sets) do + items[#items + 1] = set + end + return table.concat(items, ", ") +end +--- Returns whether the current story bit can only be activated once. +--- +--- @return boolean Whether the story bit can only be activated once. +function StoryBitWithWeight:GetOneTime() + local preset = StoryBits[self.StoryBitId] + return preset and preset.OneTime +end +--- Returns an error message if the StoryBit preset for the current StoryBitWithWeight instance is invalid. +--- +--- @return string An error message if the StoryBit preset is invalid, or nil if it is valid. +function StoryBitWithWeight:GetError() + local story_bit = StoryBits[self.StoryBitId] + if not story_bit then + return "Invalid StoryBit preset" + end +end + diff --git a/CommonLua/Classes/ClassDefs/ClassDef-Conditions.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-Conditions.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..b4ab8b9200e709475509c553bb27cb055b3b98c9 --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-Conditions.generated.lua @@ -0,0 +1,521 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +DefineClass.CheckAND = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Negate", name = "Negate", + editor = "bool", default = false, }, + { id = "Conditions", + editor = "nested_list", default = false, base_class = "Condition", }, + }, + EditorView = Untranslated("AND"), + EditorViewNeg = Untranslated("NOT AND"), + Documentation = "Checks if all of the nested conditions are true.", +} + +function CheckAND:__eval(obj, ...) + for _, cond in ipairs(self.Conditions) do + if cond:__eval(obj, ...) then + if cond.Negate then + return false + end + else + if not cond.Negate then + return false + end + end + end + return true +end + +function CheckAND:GetWarning() + if #(self.Conditions or empty_table) < 2 then + return "CheckAND should have at least 2 parameters" + end +end + +DefineClass.CheckCooldown = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Negate", name = "Negate Condition", help = "If true, checks for the opposite condition", + editor = "bool", default = false, }, + { id = "CooldownObj", name = "Cooldown object", + editor = "combo", default = "Game", items = function (self) return {"obj", "context", "Player", "Game"} end, }, + { id = "Cooldown", + editor = "preset_id", default = "Disabled", preset_class = "CooldownDef", }, + }, + EditorView = Untranslated(" cooldown is not active"), + EditorViewNeg = Untranslated(" cooldown is active"), + Documentation = "Checks if a given cooldown is active", + EditorNestedObjCategory = "", +} + +function CheckCooldown:__eval(obj, context) + local cooldown_obj = self.CooldownObj + if cooldown_obj == "Player" then + obj = ResolveEventPlayer(obj, context) + elseif cooldown_obj == "Game" then + obj = Game + elseif cooldown_obj == "context" then + obj = context + end + assert(not obj or IsKindOf(obj, "CooldownObj")) + return not IsKindOf(obj, "CooldownObj") or not obj:GetCooldown(self.Cooldown) +end + +function CheckCooldown:GetError() + if not CooldownDefs[self.Cooldown] then + return "No such cooldown" + end +end + +DefineClass.CheckDifficulty = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Negate", name = "Negate", + editor = "bool", default = false, }, + { id = "Difficulty", name = "Difficulty", + editor = "preset_id", default = "", preset_class = "GameDifficultyDef", }, + }, + EditorView = Untranslated("Difficulty "), + EditorViewNeg = Untranslated("Difficulty not "), + Documentation = "Checks game difficulty.", +} + +function CheckDifficulty:__eval(obj, context) + return GetGameDifficulty() == self.Difficulty +end + +DefineClass.CheckExpression = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "EditorViewComment", help = "Text that explains the expression and is shown in the editor view field.", + editor = "text", default = "Check expression", }, + { id = "Params", + editor = "text", default = "self, obj", }, + { id = "Expression", + editor = "expression", default = function (self) return true end, + params = function(self) return self.Params end, }, + }, + Documentation = "Checks expression (function) result.", +} + +function CheckExpression:GetEditorView() + return self.EditorViewComment and Untranslated(self.EditorViewComment) or Untranslated("Check expression") +end + +function CheckExpression:__eval(...) + return self:Expression(...) +end + +DefineClass.CheckGameRule = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Negate", name = "Negate", + editor = "bool", default = false, }, + { id = "Rule", name = "Rule", + editor = "preset_id", default = false, preset_class = "GameRuleDef", }, + }, + EditorView = Untranslated("Game rule is active"), + EditorViewNeg = Untranslated("Game rule is not active"), + Documentation = "Checks if a game rule is active.", +} + +function CheckGameRule:__eval(obj, context) + return IsGameRuleActive(self.Rule) +end + +DefineClass.CheckGameState = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Negate", name = "Negate", + editor = "bool", default = false, }, + { id = "GameState", name = "Game state", + editor = "preset_id", default = false, preset_class = "GameStateDef", }, + }, + EditorView = Untranslated("Game state is active"), + EditorViewNeg = Untranslated("Game state is not active"), + Documentation = "Checks if a game state is active.", +} + +function CheckGameState:__eval(obj, context) + return GameState[self.GameState] +end + +function CheckGameState:GetError() + if not GameStateDefs[self.GameState] then + return "No such GameState" + end +end + +DefineClass.CheckMapRandom = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Chance", + editor = "number", default = 10, scale = "%", min = 0, max = 100, }, + { id = "Seed", help = "Seed should be different on each instance.", + editor = "number", default = false, buttons = { {name = "Rand", func = "Rand"}, }, }, + }, + EditorView = Untranslated("Map chance "), + Documentation = "Checks a random chance which stays the same until the map changes.", +} + +function CheckMapRandom:__eval(obj, context) + return abs(MapLoadRandom + self.Seed) % 100 < self.Chance +end + +function CheckMapRandom:OnEditorNew(parent, ged, is_paste) + self.Seed = AsyncRand() +end + +function CheckMapRandom:Rand() + self.Seed = AsyncRand() + ObjModified(self) +end + +DefineClass.CheckOR = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Negate", name = "Negate", + editor = "bool", default = false, }, + { id = "Conditions", + editor = "nested_list", default = false, base_class = "Condition", }, + }, + EditorView = Untranslated("OR"), + EditorViewNeg = Untranslated("NOT OR"), + Documentation = "Checks if one of the nested conditions is true.", +} + +function CheckOR:__eval(obj, ...) + for _, cond in ipairs(self.Conditions) do + if cond:__eval(obj, ...) then + if not cond.Negate then + return true + end + else + if cond.Negate then + return true + end + end + end +end + +function CheckOR:GetWarning() + if #(self.Conditions or empty_table) < 2 then + return "CheckOR should have at least 2 parameters" + end +end + +DefineClass.CheckPropValue = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "BaseClass", name = "Class", + editor = "combo", default = false, items = function (self) return ClassDescendantsList("PropertyObject") end, }, + { id = "NonMatching", name = "Non-matching objects", help = "When the object does not match the provided class. \nnot IsKindOf(obj, Class)", + editor = "choice", default = "fail", items = function (self) return {"fail", "succeed"} end, }, + { id = "PropId", name = "Prop", + editor = "combo", default = false, items = function (self) return self:GetNumericProperties() end, }, + { id = "Condition", name = "Condition", + editor = "choice", default = "==", items = function (self) return { ">=", "<=", ">", "<", "==", "~=" } end, }, + { id = "Amount", name = "Amount", + editor = "number", default = 0, + scale = function(self) return self:GetAmountMeta("scale") end, }, + }, + EditorView = Untranslated(". "), + Documentation = "Checks the value of a property.", +} + +function CheckPropValue:__eval(obj, context) + if not obj or not IsKindOf(obj, self.BaseClass) then + return self.NonMatching ~= "fail" + end + local value = obj:GetProperty(self.PropId) or 0 + return self:CompareOp(value, context) +end + +function CheckPropValue:GetError() + local class = g_Classes[self.BaseClass] + if not class then + return "No such class" + end + local prop_meta = class:GetPropertyMetadata(self.PropId) + if not prop_meta then + return "No such property" + end +end + +function CheckPropValue:GetNumericProperties() + local class = g_Classes[self.BaseClass] + local properties = class and class:GetProperties() or empty_table + local props = {} + for i = #properties, 1, -1 do + if properties[i].editor == "number" then + props[#props + 1] = properties[i].id + end + end + return props +end + +function CheckPropValue:GetAmountMeta(meta, default) + local class = g_Classes[self.BaseClass] + local prop_meta = class and class:GetPropertyMetadata(self.PropId) + if prop_meta then return prop_meta[meta] end + return default +end + +DefineClass.CheckRandom = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "Chance", + editor = "number", default = 10, scale = "%", min = 0, max = 100, }, + }, + EditorView = Untranslated("Chance "), + Documentation = "Checks a random chance.", +} + +function CheckRandom:__eval(obj, context) + return InteractionRand(100, "CheckRandom") < self.Chance +end + +DefineClass.CheckTime = { + __parents = { "Condition", }, + __generated_by_class = "ConditionDef", + + properties = { + { id = "TimeScale", name = "Time Scale", + editor = "choice", default = "h", items = function (self) return GetTimeScalesCombo() end, }, + { id = "TimeMin", name = "Min Time", + editor = "number", default = false, }, + { id = "TimeMax", name = "Max Time", + editor = "number", default = false, }, + }, + EditorView = Untranslated("Time"), + Documentation = "Checks if the game time matches an interval.", +} + +function CheckTime:__eval(obj, context) + local scale = const.Scale[self.TimeScale] or 1 + local min, max = self.TimeMin, self.TimeMax + local time = GameTime() + return (not min or time >= min * scale) and (not max or time <= max * scale) +end + +function CheckTime:GetError() + if not self.TimeMin and not self.TimeMax then + return "No time restriction specified" + end +end + +DefineClass.ScriptAND = { + __parents = { "ScriptCondition", }, + __generated_by_class = "ScriptConditionDef", + + HasNegate = true, + EditorView = Untranslated("AND"), + EditorViewNeg = Untranslated("NOT AND"), + EditorName = "AND", + EditorSubmenu = "Conditions", + Documentation = "Checks if all of the nested conditions are true.", + CodeTemplate = "(self[and])", + ContainerClass = "ScriptValue", +} + +DefineClass.ScriptCheckCooldown = { + __parents = { "ScriptCondition", }, + __generated_by_class = "ScriptConditionDef", + + properties = { + { id = "CooldownObj", name = "Cooldown object", + editor = "combo", default = "Game", items = function (self) return {"parameter", "Player", "Game"} end, }, + { id = "Cooldown", + editor = "preset_id", default = "Disabled", preset_class = "CooldownDef", }, + }, + HasNegate = true, + EditorName = "Check cooldown", + EditorSubmenu = "Conditions", + Documentation = "Checks if a given cooldown is active.", + CodeTemplate = "", + Param1Name = "Object", +} + +function ScriptCheckCooldown:GetEditorView() + return string.format("%s%s cooldown %s is %sactive", + self.CooldownObj == "Game" and 'Game' or self.Param1, + self.CooldownObj == "Player" and "'s player" or "", + self.Cooldown or "false", + self.Negate and "not " or "") +end + +function ScriptCheckCooldown:GenerateCode(pstr, indent) + if self.Negate then pstr:append("not ") end + if self.CooldownObj == "Game" then + pstr:appendf('Game:GetCooldown("%s")', self.Cooldown) + elseif self.CooldownObj == "Player" then + pstr:appendf('ResolveEventPlayer(%s):GetCooldown("%s")', self.Param1, self.Cooldown) + else + pstr:appendf('(IsKindOf(%s, "CooldownObj") and %s:GetCooldown("%s"))', self.Param1, self.Param1, self.Cooldown) + end +end + +function ScriptCheckCooldown:GetError() + if not CooldownDefs[self.Cooldown] then + return "No such cooldown" + end +end + +DefineClass.ScriptCheckGameState = { + __parents = { "ScriptCondition", }, + __generated_by_class = "ScriptConditionDef", + + properties = { + { id = "GameState", name = "Game state", + editor = "preset_id", default = false, preset_class = "GameStateDef", }, + }, + HasNegate = true, + EditorView = Untranslated("Game state is active"), + EditorViewNeg = Untranslated("Game state not active"), + EditorName = "Check game state", + EditorSubmenu = "Conditions", + Documentation = "Checks if a game state is active.", + CodeTemplate = "GameState[self.GameState]", +} + +function ScriptCheckGameState:GetError() + if not GameStateDefs[self.GameState] then + return "No such GameState" + end +end + +DefineClass.ScriptCheckPropValue = { + __parents = { "ScriptCondition", }, + __generated_by_class = "ScriptConditionDef", + + properties = { + { id = "BaseClass", name = "Class", + editor = "combo", default = false, items = function (self) return ClassDescendantsList("PropertyObject") end, }, + { id = "PropId", name = "Prop", + editor = "combo", default = false, items = function (self) return self:GetNumericProperties() end, }, + { id = "NonMatchingValue", name = "Value for non-matching objects", help = "Value used when the object does not match the provided class: not IsKindOf(obj, Class)", + editor = "number", default = 0, + scale = function(self) return self:GetAmountMeta("scale") end, }, + { id = "Condition", name = "Condition", + editor = "choice", default = "==", items = function (self) return { ">=", "<=", ">", "<", "==", "~=" } end, }, + { id = "Amount", name = "Amount", + editor = "number", default = 0, + scale = function(self) return self:GetAmountMeta("scale") end, }, + }, + EditorView = Untranslated(": . "), + EditorName = "Check a property value", + EditorSubmenu = "Conditions", + Documentation = "Checks the value of a numeric property.", + CodeTemplate = "(IsKindOf($self.Param1, self.BaseClass) and $self.Param1:GetProperty(self.PropId) or self.NonMatchingValue) $self.Condition self.Amount", + Param1Name = "Object", +} + +function ScriptCheckPropValue:GetError() + local class = g_Classes[self.BaseClass] + if not class then + return "No such class" + end + local prop_meta = class:GetPropertyMetadata(self.PropId) + if not prop_meta then + return "No such property" + end +end + +function ScriptCheckPropValue:GetNumericProperties() + local class = g_Classes[self.BaseClass] + local properties = class and class:GetProperties() or empty_table + local props = {} + for i = #properties, 1, -1 do + if properties[i].editor == "number" then + props[#props + 1] = properties[i].id + end + end + return props +end + +function ScriptCheckPropValue:GetAmountMeta(meta, default) + local class = g_Classes[self.BaseClass] + local prop_meta = class and class:GetPropertyMetadata(self.PropId) + if prop_meta then return prop_meta[meta] end + return default +end + +DefineClass.ScriptCheckTime = { + __parents = { "ScriptCondition", }, + __generated_by_class = "ScriptConditionDef", + + properties = { + { id = "TimeScale", name = "Time Scale", + editor = "choice", default = "h", items = function (self) return GetTimeScalesCombo() end, }, + { id = "TimeMin", name = "Min Time", + editor = "number", default = false, }, + { id = "TimeMax", name = "Max Time", + editor = "number", default = false, }, + }, + EditorView = Untranslated("Time"), + EditorName = "Check time", + EditorSubmenu = "Conditions", + Documentation = "Checks if the game time matches an interval.", + CodeTemplate = "", +} + +function ScriptCheckTime:GenerateCode(pstr, indent) + local scale = self.TimeScale + if scale ~= "" then + scale = scale == "sec" and "000" or string.format('*const.Scale["%s"]', self.TimeScale) + end + local min, max = self.TimeMin, self.TimeMax + if min and max then + pstr:appendf("GameTime() >= %d%s and GameTime() <= %d%s", min, scale, max, scale) + elseif min then + pstr:appendf("GameTime() >= %d%s", min, scale) + elseif max then + pstr:appendf("GameTime() <= %d%s", max, scale) + end +end + +function ScriptCheckTime:GetError() + if not self.TimeMin and not self.TimeMax then + return "No time restriction specified." + end + if self.TimeMin and self.TimeMax and self.TimeMin > self.TimeMax then + return "TimeMin is greater than TimeMax." + end +end + +DefineClass.ScriptOR = { + __parents = { "ScriptCondition", }, + __generated_by_class = "ScriptConditionDef", + + HasNegate = true, + EditorView = Untranslated("OR"), + EditorViewNeg = Untranslated("NOT OR"), + EditorName = "OR", + EditorSubmenu = "Conditions", + Documentation = "Checks if at least one of the nested conditions is true.", + CodeTemplate = "(self[or])", + ContainerClass = "ScriptValue", +} + diff --git a/CommonLua/Classes/ClassDefs/ClassDef-Config.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-Config.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..6159476a0dd4484b642c3240719c6f6bdb6a1e42 --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-Config.generated.lua @@ -0,0 +1,1354 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +DefineClass.Achievement = { + __parents = { "MsgReactionsPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "display_name", name = "Display Name", + editor = "text", default = false, translate = true, }, + { id = "description", name = "Description", + editor = "text", default = false, translate = true, context = "(limited to 100 characters on XBOX)", }, + { id = "how_to", name = "How To", + editor = "text", default = false, translate = true, context = "(limited to 100 characters on XBOX)", }, + { id = "image", name = "Image", + editor = "ui_image", default = false, }, + { id = "secret", name = "Secret", + editor = "bool", default = false, }, + { id = "target", name = "Target", + editor = "number", default = 0, }, + { id = "time", name = "Time", + editor = "number", default = 0, }, + { id = "save_interval", name = "Save Interval", + editor = "number", default = false, }, + { category = "PS4", id = "ps4_trophy_group", name = "Trophy Group", + editor = "preset_id", default = "Auto", preset_class = "TrophyGroup", extra_item = "Auto", }, + { category = "PS4", id = "ps4_used_trophy_group", name = "Used Trophy Group", + editor = "preset_id", default = false, dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, no_validate = true, preset_class = "TrophyGroup", }, + { category = "PS4", id = "ps4_duplicate", name = "Duplicate", + editor = "buttons", default = false, dont_save = true, }, + { category = "PS4", id = "ps4_id", name = "Trophy Id", + editor = "number", default = -1, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, buttons = { {name = "Generate", func = "GenerateTrophyIDs"}, }, min = -1, max = 128, }, + { category = "PS4", id = "ps4_grade", name = "Grade", + editor = "choice", default = "bronze", no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, items = function (self) return PlayStationTrophyGrades end, }, + { category = "PS4", id = "ps4_points", name = "Points", + editor = "number", default = false, dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, }, + { category = "PS4", id = "ps4_grouppoints", name = "Group Points", help = "Total sum for the base game should be 950 - 1050. For each expansion <= 200.", + editor = "text", default = false, dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, }, + { category = "PS4", id = "ps4_icon", name = "Icon", + editor = "ui_image", default = "", dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, + no_validate = true, filter = "All files|*.png", }, + { category = "PS4", id = "ps4_platinum_linked", name = "Platinum Linked", + editor = "bool", default = true, no_edit = function(self) local trophy_group_0 = GetTrophyGroupById("ps4", 0) +return self:GetTrophyGroup("ps4") ~= trophy_group_0 or self.ps4_grade == "platinum" end, }, + { category = "PS5", id = "ps5_trophy_group", name = "Trophy Group", + editor = "preset_id", default = "Auto", preset_class = "TrophyGroup", extra_item = "Auto", }, + { category = "PS5", id = "ps5_used_trophy_group", name = "Used Trophy Group", + editor = "preset_id", default = false, dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps5") == "" end, no_validate = true, preset_class = "TrophyGroup", }, + { category = "PS5", id = "ps5_id", name = "Trophy Id", + editor = "number", default = -1, no_edit = function(self) return self:GetTrophyGroup("ps5") == "" end, buttons = { {name = "Generate", func = "GenerateTrophyIDs"}, }, min = -1, max = 128, }, + { category = "PS5", id = "ps5_grade", name = "Grade", + editor = "choice", default = "bronze", no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, items = function (self) return PlayStationTrophyGrades end, }, + { category = "PS5", id = "ps5_points", name = "Points", + editor = "number", default = false, dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, }, + { category = "PS5", id = "ps5_grouppoints", name = "Group Points", help = "Total sum for the base game should be 950 - 1050. For each expansion <= 200.", + editor = "number", default = false, dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, }, + { category = "PS5", id = "ps5_icon", name = "Icon", + editor = "ui_image", default = "", dont_save = true, read_only = true, no_edit = function(self) return self:GetTrophyGroup("ps4") == "" end, + no_validate = true, filter = "All files|*.png", }, + { category = "Xbox", id = "xbox_id", name = "Achievement Id", + editor = "number", default = -1, }, + { category = "Steam", id = "steam_id", name = "Steam Id", help = "If not specified, the id of the preset will be used.", + editor = "text", default = false, }, + { category = "Epic", id = "epic_id", name = "Epic Id", help = "If not specified, the id of the preset will be used.", + editor = "text", default = false, }, + { category = "Epic", id = "flavor_text", name = "Flavor text", + editor = "text", default = false, translate = true, }, + }, + HasSortKey = true, + GlobalMap = "AchievementPresets", + EditorMenubarName = "Achievements", + EditorIcon = "CommonAssets/UI/Icons/top trophy winner.png", + EditorMenubar = "Editors.Lists", +} + +function Achievement:GetCompleteText() + local unlocked = GetAchievementFlags(self.id) + return unlocked and self.description or self.how_to +end + +function Achievement:GetTrophyGroup(platform) + -- Use stored group if not Auto + local group_name_field = platform .. "_trophy_group" + if self[group_name_field] ~= "Auto" then + return self[group_name_field] + end + + -- Use last explicit trophy group in trophy's DLC + local trophies = PresetArray(Achievement, function(achievement) + return achievement.SaveIn == self.save_in + end) + if #trophies ~= 0 then + for i=#trophies,1,-1 do + local group = trophies[i][group_name_field] + if group ~= "" and group ~= "Auto" then + return group + end + end + end + + -- Fallback to a trophy group with id which matches the DLC's id + local group = FindPreset("TrophyGroup", self.save_in) + if group then return group.id end + + -- Fallback to inferring from DLC's handling + local dlc = FindPreset("DLCConfig", self.save_in) + local handling_field = platform .. "_handling" + if dlc and dlc[handling_field] ~= "Embed" then + -- We can not auto pick a group for "real" DLC trophy if the is non matching its id. + -- Do not include excluded DLCs' trophies. + return "" + end + + return GetTrophyGroupById(platform, 0) +end + +function Achievement:IsBaseGameTrophy(platform) + local group = self:GetTrophyGroup(platform) + return group ~= "" and TrophyGroupPresets[group]:IsBaseGameGroup(platform) +end + +function Achievement:IsPlatinumLinked(platform) + local trophy_group_0 = GetTrophyGroupById(platform, 0) + local trophy_group = self:GetTrophyGroup(platform) + local platinum_linked = trophy_group == trophy_group_0 and self[platform .. "_grade"] ~= "platinum" + if platform == "ps4" then + platinum_linked = platinum_linked and self.ps4_platinum_linked + end + return platinum_linked +end + +function Achievement:IsCurrentlyUsed() + return + (Platform.steam and self.steam_id) or + (Platform.epic and self.epic_id) or + (Platform.ps4 and self.ps4_id >= 0) or + (Platform.ps5 and self.ps5_id >= 0) or + (Platform.xbox and self.xbox_id > 0) or + (Platform.pc and (self.image or self.msg_reactions)) +end + +function Achievement:GenerateTrophyIDs(root, prop_id) + local platform = string.match(prop_id, "(.*)_id") + local trophy_id_field = prop_id + local group_id_field = platform .. "_gid" + + local trophies = PresetArray(Achievement, function(achievement) + return achievement:GetTrophyGroup(platform) ~= "" + end) + + local trophies_by_group = {} + for _, trophy in ipairs(trophies) do + local group = TrophyGroupPresets[trophy:GetTrophyGroup(platform)] + local group_id = group[group_id_field] + trophies_by_group[group_id] = trophies_by_group[group_id] or {} + local group_trophies = trophies_by_group[group_id] + group_trophies[#group_trophies + 1] = trophy + end + + local trophy_id = 0 + for _, group_trophies in sorted_pairs(trophies_by_group) do + for _, trophy in ipairs(group_trophies) do + if trophy[trophy_id_field] ~= trophy_id then + trophy[trophy_id_field] = trophy_id + trophy:MarkDirty() + end + trophy_id = trophy_id + 1 + end + end +end + +function Achievement:Getps4_grouppoints() + local group = self:GetTrophyGroup("ps4") + local group_points = CalcTrophyGroupPoints(group, "ps4") + local trophy_group_0 = GetTrophyGroupById("ps4", 0) + if group == trophy_group_0 then + local platinum_linked_points = CalcTrophyPlatinumLinkedPoints("ps4") + if platinum_linked_points ~= group_points then + return string.format("%d + %d", platinum_linked_points, group_points - platinum_linked_points) + end + end + + return tostring(group_points) +end + +function Achievement:Getps4_used_trophy_group() + return self:GetTrophyGroup("ps4") +end + +function Achievement:Getps4_points() + return TrophyGradesPlayStationPoints[self.ps4_grade] or 0 +end + +function Achievement:Getps4_icon() + local _, icon_path = GetPlayStationTrophyIcon(self, "ps4") + return icon_path +end + +function Achievement:Getps5_used_trophy_group() + return self:GetTrophyGroup("ps4") +end + +function Achievement:Getps5_points() + return TrophyGradesPlayStationPoints[self.ps5_grade] or 0 +end + +function Achievement:Getps5_grouppoints() + return CalcTrophyGroupPoints(self:GetTrophyGroup("ps5"), "ps5") +end + +function Achievement:Getps5_icon() + local _, icon_path = GetPlayStationTrophyIcon(self, "ps5") + return icon_path +end + +function Achievement:GetError(platform) + local errors = {} + + local ShouldTestPlatform = function(test_platform) + return (not platform or platform == test_platform) + end + + local trophies = PresetArray(Achievement) + local GetPlayStationErrors = function(platform) + local trophy_id_field = platform .. "_id" + local self_trophy_id = self[trophy_id_field] + if self.id == "PlatinumTrophy" and self_trophy_id ~= 0 then + errors[#errors + 1] = string.format("%s platinum trophy's id must be 0!", string.upper(platform)) + elseif self:GetTrophyGroup(platform) ~= "" and self_trophy_id < 0 then + errors[#errors + 1] = string.format("Missing %s trophy id!", platform) + elseif self_trophy_id >= 0 then + table.sortby_field(trophies, trophy_id_field) + local next_trophy_id = 0 + local trophy_id_holes = {} + for _, trophy in ipairs(trophies) do + local curr_trophy_id = trophy[trophy_id_field] + if next_trophy_id < self_trophy_id and curr_trophy_id >= 0 then + if curr_trophy_id > next_trophy_id then + if curr_trophy_id - next_trophy_id > 1 then + trophy_id_holes[#trophy_id_holes + 1] = string.format("%d-%d", next_trophy_id, curr_trophy_id) + else + trophy_id_holes[#trophy_id_holes + 1] = next_trophy_id + end + end + next_trophy_id = curr_trophy_id + 1 + end + if self ~= trophy and self_trophy_id == curr_trophy_id then + errors[#errors + 1] = string.format("Duplicated %s trophy id (%s)!", platform, trophy.id) + end + end + + if #trophy_id_holes ~= 0 then + errors[#errors + 1] = string.format("%s trophy ids are not consecutive, missing %s!", string.upper(platform), table.concat(trophy_id_holes, ", ")) + end + end + end + + if ShouldTestPlatform("ps4") then GetPlayStationErrors("ps4") end + if ShouldTestPlatform("ps5") then GetPlayStationErrors("ps5") end + + if ShouldTestPlatform("xbox_one") or ShouldTestPlatform("xbox_series") then + if self.description and #TDevModeGetEnglishText(self.description) > 100 then + errors[#errors + 1] = string.format("XBOX achievement description must be limited to 100 characters!") + end + if self.how_to and #TDevModeGetEnglishText(self.how_to) > 100 then + errors[#errors + 1] = string.format("XBOX achievement how to must be limited to 100 characters!") + end + end + + return #errors ~= 0 and table.concat(errors, "\n") +end + +function Achievement:GetWarning(platform) + local warnings = {} + + local ShouldTestPlatform = function(test_platform) + return (not platform or platform == test_platform) and self:GetTrophyGroup(test_platform) ~= "" + end + + local GetPlayStationWarnings = function(platform) + local is_placeholder, icon_path = GetPlayStationTrophyIcon(self, platform) + if is_placeholder then + warnings[#warnings + 1] = string.format("Missing %s trophy icon (placeholder used): %s", platform, icon_path) + end + + local group = self:GetTrophyGroup(platform) + local group_points = CalcTrophyGroupPoints(group, platform) + local trophy_group_0 = GetTrophyGroupById(platform, 0) + if trophy_group_0 == group then + local platinum_linked_points = CalcTrophyPlatinumLinkedPoints(platform) + local min, max = GetTrophyBaseGameNonPlatinumLinkedPointsRange(platform) + local non_platinum_linked_points = group_points - platinum_linked_points + if non_platinum_linked_points < min or max < non_platinum_linked_points then + warnings[#warnings + 1] = string.format( + "%s non platinum linked trophy points sum in base group is not between %d and %d.", + string.upper(platform), min, max + ) + end + group_points = platinum_linked_points + end + + local min, max = GetTrophyGroupPointsRange(group, platform) + if group_points < min or max < group_points then + warnings[#warnings + 1] = string.format( + "%s trophy group points sum is not between %d and %d.", + string.upper(platform), min, max + ) + end + end + + if ShouldTestPlatform("ps4") then GetPlayStationWarnings("ps4") end + if ShouldTestPlatform("ps5") then GetPlayStationWarnings("ps5") end + + if ShouldTestPlatform("ps5") then + if #self.id > 32 then + warnings[#warnings + 1] = string.format("Trophy Id maximum length is 32 (< %d)!", #self.id) + end + if string.find(self.id, "[^%w]") then + warnings[#warnings + 1] = "Trophy Id contains non-alphanumeric characters!" + end + end + + return #warnings ~= 0 and table.concat(warnings, "\n") +end + +function Achievement:SaveAll(...) + ForEachPreset(Achievement, function(trophy) + if trophy:GetTrophyGroup("ps4") == "" then + trophy:MarkDirty() + trophy.ps4_id = -1 + end + if trophy:GetTrophyGroup("ps5") == "" then + trophy:MarkDirty() + trophy.ps5_id = -1 + end + end) + Preset.SaveAll(self, ...) +end + +DefineClass.DLCConfig = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "display_name", name = "Display Name", + editor = "text", default = false, translate = true, }, + { category = "General", id = "description", name = "Description", + editor = "text", default = false, translate = true, }, + { category = "General", id = "required_lua_revision", + editor = "number", default = 237259, }, + { category = "General", id = "pre_load", + editor = "func", default = function (self) +if not IsDlcOwned(self) then + return "remove" +end +end, }, + { category = "General", id = "load_anyway", name = "Enable Loading Saves When Missing", help = "Set to true if you want to be able to load a save with this dlc missing. If the dlc was deleted, but still present in the save's metadata - it is consider as load_anyway = true (Developer only)", + editor = "bool", default = true, }, + { category = "General", id = "show_in_reports", name = "Show in Reports", + editor = "bool", default = false, }, + { category = "General", id = "post_load", + editor = "func", default = function (self) +g_AvailableDlc[self.name] = true +end, }, + { category = "Build Steam", id = "steam_dlc_id", name = "Steam DLC Id", + editor = "number", default = false, }, + { category = "Build Pops", id = "pops_dlc_id", name = "Pops DLC Id", + editor = "text", default = false, }, + { category = "Build Epic", id = "epic_dlc_id", name = "Artifact Id (Dev)", help = "Where the DLC pack will be pushed to. Can be looked up in the EpicGames dev portal.", + editor = "text", default = false, }, + { category = "Build Epic", id = "epic_catalog_dlc_id", name = "Catalog Id (Live)", help = "Which catalog item (seen in the Epic Launcher) the game will check ownership for. Can be in looked up in the .mancpn files after downloading from the Epic Launcher (AppName corresponds to ArtifactId, we need CatalogItemId of the live item).", + editor = "text", default = false, }, + { category = "Build", id = "generate_build_rule", name = "Generate Build Rule", help = "With name Dlc%Id%", + editor = "bool", default = false, }, + { category = "Build", id = "deprecated", name = "Deprecated", help = "Used only for compatibility", + editor = "bool", default = false, }, + { category = "Build", id = "dont_localize", name = "Dont localize contents", help = "Skip the DLC contents when running localization", + editor = "bool", default = false, }, + { category = "Build", id = "localization", name = "Has localization packs", help = "If the Dlc should include latest localization packfiles", + editor = "bool", default = false, }, + { category = "Build", id = "generate_art_folders", + editor = "buttons", default = false, buttons = { {name = "Locate PS4 Art", func = "LocatePS4Art"}, {name = "LocateXboxArt", func = "LocateXboxArt"}, }, }, + { category = "Build", id = "ext_name", help = "(optional) name of executables", + editor = "text", default = false, }, + { category = "Build", id = "lua", help = "If the Dlc should include Lua.hpk", + editor = "bool", default = false, }, + { category = "Build", id = "data", help = "If the Dlc should include Data.hpk", + editor = "bool", default = false, }, + { category = "Build", id = "nonentitytextures", help = "If the Dlc should include all post release non-entity textures, texture lists and bin assets", + editor = "bool", default = false, }, + { category = "Build", id = "entitytextures", help = "If the Dlc should include all post release entity textures and texture lists", + editor = "bool", default = false, }, + { category = "Build", id = "ui", help = "If the Dlc should include UI.hpk", + editor = "bool", default = false, }, + { category = "Build", id = "shaders", help = "If the Dlc should include lastest shader packs.", + editor = "bool", default = false, }, + { category = "Build", id = "sounds", help = "If the Dlc should include the latest Sounds.hpk", + editor = "bool", default = false, }, + { category = "Build", id = "resource_metadata", help = "Generate ressource metadata", + editor = "bool", default = true, }, + { category = "Build", id = "content_dep", help = "List of rules to be build before the content.hpk is packed (e.g. BinAssets)", + editor = "prop_table", default = {}, }, + { category = "Build", id = "content_files", help = "Files added to the dlc content.hpk (e.g. PatchTextures.hpk, etc.)", + editor = "nested_list", default = false, base_class = "DLCConfigContentFile", inclusive = true, }, + { category = "BuildPS4", id = "ps4_handling", name = "Handling", help = "Enable - Creates a full dlc package\nEmbed - Creates only a .hpk to be embedded in the main game package\nEmbedPatch - Creates a .hpk that gets embedded in the main game package and replaces an old dlc\nExclude - Not shipped on this platform", + editor = "combo", default = "Exclude", items = function (self) return { "Enable", "Embed", "EmbedPatch", "Exclude" } end, }, + { category = "BuildPS4", id = "ps4_label", name = "Label", + editor = "text", default = false, no_edit = function(self) return self.ps4_handling ~= "Enable" and self.ps4_handling ~= "EmbedPatch" end, }, + { category = "BuildPS4", id = "ps4_version", name = "Version", + editor = "text", default = "01.00", no_edit = function(self) return self.ps4_handling ~= "Enable" and self.ps4_handling ~= "EmbedPatch" end, }, + { category = "BuildPS4", id = "ps4_entitlement_key", name = "Entitlement Key", help = "Unique 16 byte key. Will be automatically generated. Must be kept secret and not regenerated after certification.", + editor = "text", default = false, read_only = true, no_edit = function(self) return self.ps4_handling ~= "Enable" and self.ps4_handling ~= "EmbedPatch" end, }, + { category = "BuildPS5", id = "ps5_handling", name = "Handling", help = "Enable - Creates a full dlc package\nEmbed - Creates only a .hpk to be embedded in the main game package\nEmbedPatch - Creates a .hpk that gets embedded in the main game package and replaces an old dlc\nExclude - Not shipped on this platform", + editor = "combo", default = "Exclude", items = function (self) return { "Enable", "Embed", "EmbedPatch", "Exclude" } end, }, + { category = "BuildPS5", id = "ps5_label", name = "Label", + editor = "text", default = false, no_edit = function(self) return self.ps5_handling ~= "Enable" and self.ps5_handling ~= "EmbedPatch" end, }, + { category = "BuildPS5", id = "ps5_master_version", name = "Master Version", + editor = "text", default = "01.00", no_edit = function(self) return self.ps5_handling ~= "Enable" and self.ps5_handling ~= "EmbedPatch" end, }, + { category = "BuildPS5", id = "ps5_content_version", name = "Content Version", + editor = "text", default = "01.000.000", no_edit = function(self) return self.ps5_handling ~= "Enable" and self.ps5_handling ~= "EmbedPatch" end, }, + { category = "BuildPS5", id = "ps5_entitlement_key", name = "Entitlement Key", help = "Unique 16 byte key. Will be automatically generated. Must be kept secret and not regenerated after certification.", + editor = "text", default = false, read_only = true, no_edit = function(self) return self.ps5_handling ~= "Enable" and self.ps5_handling ~= "EmbedPatch" end, }, + { category = "BuildXbox", id = "xbox_handling", name = "Handling", help = "Enable - Creates a full dlc package\nEmbed - Creates only a .hpk to be embedded in the main game package\nEmbedPatch - Creates a .hpk that gets embedded in the main game package and replaces an old dlc\nExclude - Not shipped on this platform", + editor = "combo", default = "Exclude", items = function (self) return { "Enable", "Embed", "EmbedPatch", "Exclude" } end, }, + { category = "BuildXbox", id = "xbox_name", + editor = "text", default = false, no_edit = function(self) return self.xbox_handling ~= "Enable" and self.xbox_handling ~= "EmbedPatch" end, }, + { category = "BuildXbox", id = "xbox_store_id", + editor = "text", default = false, no_edit = function(self) return self.xbox_handling ~= "Enable" and self.xbox_handling ~= "EmbedPatch" end, }, + { category = "BuildXbox", id = "xbox_display_name", + editor = "text", default = false, no_edit = function(self) return self.xbox_handling ~= "Enable" and self.xbox_handling ~= "EmbedPatch" end, }, + { category = "BuildXbox", id = "xbox_identity", + editor = "text", default = false, no_edit = function(self) return self.xbox_handling ~= "Enable" and self.xbox_handling ~= "EmbedPatch" end, }, + { category = "BuildXbox", id = "xbox_version", + editor = "text", default = "1.0.0.0", no_edit = function(self) return self.xbox_handling ~= "Enable" and self.xbox_handling ~= "EmbedPatch" end, }, + { category = "BuildWindowsStore", id = "ws_identity_name", + editor = "text", default = false, }, + { category = "BuildWindowsStore", id = "ws_version", + editor = "text", default = "1.0.0.0", }, + { category = "BuildWindowsStore", id = "ws_store_id", + editor = "text", default = false, }, + { id = "SaveIn", + editor = "text", default = false, read_only = true, no_edit = true, }, + { category = "Build", id = "public", help = "information from/about this DLC can be made public", + editor = "bool", default = false, }, + { category = "Build", id = "split_files", help = "These files will be split and added to the DLC.", + editor = "string_list", default = {}, item_default = "", items = false, arbitrary_value = true, }, + }, + HasCompanionFile = true, + SingleFile = false, + EditorMenubarName = "DLC config", + EditorIcon = "CommonAssets/UI/Icons/add buy cart plus.png", + EditorMenubar = "DLC", + save_in = "future", +} + +function DLCConfig:GetEditorView() + local str = self.id + if self.generate_build_rule then + str = str .. " build" + end + if self.deprecated then + str = str .. " deprecated" + end + if self.Comment ~= "" then + str = str .. " " .. self.Comment .. "" + end + return str +end + +function DLCConfig:LocatePS4Art(root) + local folder = "svnAssets/Source/ps4/" .. root.id .. "/" + local files = { "icon0.png" } + if not io.exists(folder) then + io.createpath(folder) + end + for _, file in ipairs(files) do + local path = folder .. file + if not io.exists(path) then + CopyFile("CommonAssets/Images/Achievements/PS4/ICON0.PNG", path) + end + end + OS_LocateFile(folder) +end + +function DLCConfig:LocateXboxArt(root) + local folder = "svnAssets/Source/xbox/" .. root.id .. "/" + local files = { "Logo.png", "SmallLogo.png", "WideLogo.png" } + if not io.exists(folder) then + io.createpath(folder) + end + for _, file in ipairs(files) do + local path = folder .. file + if not io.exists(path) then + CopyFile("CommonAssets/Images/Achievements/PS4/ICON0.PNG", path) + end + end + OS_LocateFile(folder) +end + +function DLCConfig:GetCompanionFileSavePath(save_path) + local dlc_id = string.match(save_path, "(%w+)%.lua") + assert(dlc_id) + if not dlc_id then dlc_id = "unknown" end + return "svnProject/Dlc/" .. self.id .. "/autorun.lua" +end + +function DLCConfig:GenerateCompanionFileCode(code) + -- generate autorun + local autorun_template = { + name = self.id, + deprecated = self.deprecated or nil, + display_name = self.display_name, + required_lua_revision = self.required_lua_revision, + ps4_trophy_group_description = self.ps4_trophy_group_description, + ps5_trophy_group_description = self.ps5_trophy_group_description, + steam_dlc_id = self.steam_dlc_id, + pops_dlc_id = self.pops_dlc_id, + epic_dlc_id = self.epic_dlc_id, + epic_catalog_dlc_id = self.epic_catalog_dlc_id, + ps4_label = self.ps4_label, + ps5_label = self.ps5_label, + xbox_store_id = self.xbox_store_id, + ps4_gid = self.ps4_gid, + ps5_gid = self.ps5_gid, + pre_load = self.pre_load, + post_load = self.post_load, + } + code:append("return ") + code:append(TableToLuaCode(autorun_template)) +end + +function DLCConfig:SaveAll(...) + local class = self.PresetClass or self.class + local dlcs = PresetArray(class) + + local PlayStationGenerateEntitlementKeys = function(additional_contents, platform) + local handling = platform .. "_handling" + local entitlement_key = platform .. "_entitlement_key" + + local used_entitlement_keys = {} + for _, additional_content in ipairs(additional_contents) do + if additional_content[entitlement_key] then + used_entitlement_keys[additional_content[entitlement_key]] = true + end + end + + for _, additional_content in ipairs(additional_contents) do + if additional_content[handling] == "Enable" and not additional_content[entitlement_key] then + repeat + additional_content[entitlement_key] = random_hex(128) + until not used_entitlement_keys[additional_content[entitlement_key]] + used_entitlement_keys[additional_content[entitlement_key]] = true + additional_content:MarkDirty() + end + end + end + + PlayStationGenerateEntitlementKeys(dlcs, "ps4") + PlayStationGenerateEntitlementKeys(dlcs, "ps5") + + Preset.SaveAll(self, ...) + + local epic_ids = {} + ForEachPreset(class, function(preset, group) + local epic_catalog_dlc_id = preset.epic_catalog_dlc_id + if (epic_catalog_dlc_id or "") ~= "" then + epic_ids[#epic_ids + 1] = epic_catalog_dlc_id + end + end) + local text = string.format("%sg_EpicDlcIds = %s", exported_files_header_warning, TableToLuaCode(epic_ids)) + local path = "svnProject/Lua/EpicDlcIds.lua" + local err = SaveSVNFile(path, text) + if err then + printf("Failed to save %s: %s", path, err); + end +end + +----- DLCConfig Class DLCConfigContentFile + +DefineClass.DLCConfigContentFile = { + __parents = { "PropertyObject" }, + properties = { + { id = "Source", editor = "text", default = ""}, + { id = "Destination", editor = "text", default = ""}, + }, +} + +DefineClass.GradingLUTSource = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "Input", id = "src_path", name = "Path", + editor = "browse", default = false, folder = "svnAssets/Source/Textures/LUTs", filter = "LUT (*.cube)|*.cube", force_extension = ".cube", }, + { category = "Input", id = "display_name", name = "Display name", + editor = "text", default = false, translate = true, }, + { category = "Output", id = "size", name = "Size", + editor = "number", default = false, dont_save = true, read_only = true, }, + { category = "Output", id = "color_space", name = "Color Space", + editor = "text", default = false, dont_save = true, read_only = true, }, + { category = "Output", id = "color_gamma", name = "Color Gamma", + editor = "text", default = false, dont_save = true, read_only = true, }, + { category = "Output", id = "dst_path", name = "Path", + editor = "text", default = false, dont_save = true, read_only = true, buttons = { {name = "Locate", func = "LUT_LocateFile"}, }, }, + }, + HasSortKey = true, + GlobalMap = "GradingLUTs", + EditorMenubarName = "Grading LUTs", + EditorMenubar = "Editors.Art", + dst_dir = "Textures/LUTs/", +} + +DefineModItemPreset("GradingLUTSource", { EditorName = "Photo Mode - Grading LUT", EditorSubmenu = "Other" }) + +function GradingLUTSource:OnPreSave() + if self:IsDirty() or self:IsDataDirty() then + self:OnSrcChange() + end +end + +function GradingLUTSource:Getsize() + return hr.ColorGradingLUTSize +end + +function GradingLUTSource:GetDstDir() + if self:IsModItem() then + return self.mod.content_path .. self.dst_dir + end + return self.dst_dir +end + +function GradingLUTSource:Getcolor_space() + return GetColorSpaceName(hr.ColorGradingLUTColorSpace) +end + +function GradingLUTSource:Getcolor_gamma() + return GetColorGammaName(hr.ColorGradingLUTColorGamma) +end + +function GradingLUTSource:OnSrcChange() + CreateRealTimeThread(function(self) + local dst_dir = self:GetDstDir() + if not io.exists(dst_dir) then + local err = AsyncCreatePath(dst_dir) + if err then + print(string.format("Could not create path %s: err", dst_dir, err)) + end + if not self:IsModItem() then + SVNAddFile(dst_dir) + end + end + + local dst_path = self:Getdst_path() + ImportColorGradingLUT(self:Getsize(), dst_path, self.src_path) + + Sleep(3000) + if not self:IsModItem() then + SVNAddFile(dst_path) + SVNAddFile(self.src_path) + end + end, self) +end + +function GradingLUTSource:Getdst_path() + return self:GetResourcePath() +end + +function GradingLUTSource:GetResourcePath() + return string.format("%s%s.dds", self:GetDstDir(), self.id) +end + +function GradingLUTSource:GetError() + local errors = {} + if not self.src_path then + errors[#errors + 1] = "Missing input path." + elseif not io.exists(self.src_path) then + errors[#errors + 1] = "Invalid input path." + end + return #errors ~= 0 and table.concat(errors, "\n") +end + +function GradingLUTSource:GetDisplayName() + return self.display_name +end + +function GradingLUTSource:GetEditorView() + if self:GetDisplayName() then + return self.id .. ' "' .. _InternalTranslate(self:GetDisplayName()) .. '"' + else + return self.id + end +end + +function GradingLUTSource:IsModItem() + return config.Mods and self:IsKindOf("ModItem") +end + +function GradingLUTSource:IsDataDirty() + if not IsFSUnpacked() and not self:IsModItem() then + return false + end + + local src_timestamp, src_err = io.getmetadata(self.src_path, "modification_time") + if src_err then + print(string.format("[GradingLUTs] Failed checking %s for modification: %s", self.src_path, src_err)) + return false + end + + local dst_timestamp, dst_err = io.getmetadata(self:Getdst_path(), "modification_time") + return dst_err or src_timestamp > dst_timestamp +end + +----- GradingLUTSource + +if Platform.pc and Platform.developer then + +function CleanGradingLUTsDir(luts_dir) + local err, processed_luts = AsyncListFiles(luts_dir, "*.dds", "relative") + if err then + print(string.format("[GradingLUTs] Failed listing processed LUTs: %s", err)) + end + for _,lut in pairs(GradingLUTs) do + if lut:GetDstDir() == luts_dir then + table.remove_entry(processed_luts, lut.id .. ".dds") + end + end + for _,lut in ipairs(processed_luts) do + local lut_path = luts_dir .. lut + local err = AsyncFileDelete(lut_path) + if err then + print(string.format("[GradingLUTs] Failed deleting %s: %s", lut_path, err)) + elseif luts_dir == GradingLUTSource.dst_dir then + SVNDeleteFile(lut_path) + end + end +end + +function CleanGradingLUTsDirs() + if not IsFSUnpacked() then + CleanGradingLUTsDir(GradingLUTSource.dst_dir) + end + for _, mod in ipairs(ModsList) do + CleanGradingLUTsDir(mod.content_path .. GradingLUTSource.dst_dir) + end +end + +function OnMsg.PresetSave(class) + if IsKindOf(class, "GradingLUTSource") then + CleanGradingLUTsDirs() + end +end + +function OnMsg.DataLoaded() + for _,lut in pairs(GradingLUTs) do + if lut:IsDataDirty() then + lut:OnSrcChange() + end + end + CleanGradingLUTsDirs() +end + +function LUT_LocateFile(preset) + OS_LocateFile(preset:Getdst_path()) +end + +end + +DefineClass.PlayStationActivities = { + __parents = { "MsgReactionsPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "title", name = "Title", help = "The name of the challenge. This field can be localized.", + editor = "text", default = false, translate = true, }, + { id = "description", name = "Description", help = "The description of the challenge. This field can be localized.", + editor = "text", default = false, translate = true, wordwrap = true, lines = 3, max_lines = 10, }, + { id = "_openEndedHelp", help = "An open-ended activity has no specific completion objective. It ends when the player chooses to end it. For example, batting practice in MLB® The Show™, build mode in Dreams, or realms in God of War.\n\nOpen-ended activities can contain tasks and subtasks that can be used to track optional objectives within the activity.\n\nThe system handles open-ended activities like progress activities, but when an open-ended activity ends, any result sent is ignored.\n\nJust like progress activities, results for single-player activities can be passed using UDS events. You must use the matches API to pass results for open-ended activities that are being played in multiplayer scenarios.", + editor = "help", default = false, dont_save = true, read_only = true, no_edit = function(self) return self.category ~= "openEnded" end, }, + { id = "_progressHelp", help = "A progress activity is defined as any activity that requires the player to complete an objective or series of objectives in order to complete the activity. For example, chapters in Uncharted, or quests in Horizon Zero Dawn.\n\nProgress activities can optionally contain tasks and subtasks that players can use to understand what they should do next and track how close they are to completing an activity. See Tasks and Subtasks for more information on how these can be used.\n\nProgress activities must have a result when ended. For single-player progress activities, the game can set the result to COMPLETED, FAILED, or ABANDONED and pass this result back to the platform by means of the UDS activityEnd event. If you end a progress activity as COMPLETED or FAILED, it is written into the player's history and progress is reset for the next instance.\n\nNote:\nThese outcomes are automatically tagged on any publicly available UGC that is created. In the case of successful completion, this UGC can be surfaced to other players who are at the same point in the game as a form of help or walkthrough.\n\nFor the multiplayer match case, SUCCESS or FAILED are the only supported results. You must use the matches API to pass these results. If you want to make an activity no longer active while retaining its progress, you must move the match to ONHOLD through its status property.", + editor = "help", default = false, dont_save = true, read_only = true, no_edit = function(self) return self.category ~= "progress" end, }, + { id = "category", name = "Category", + editor = "choice", default = "openEnded", items = function (self) return { "openEnded", "progress" } end, }, + { id = "default_playtime_estimate", name = "Default Playtime Estimate (minutes)", help = 'The default playtime estimate is displayed in System UI when the system has not determined the estimated playtime. Once the system determines the estimated playtime, the value may be switched over from the default playtime that is specified. You can specify the time in minute at the activity, task and subtask level.\n• When the category is not "challenge", allow the value at 5-minute intervals. (e.g. 5, 10, 15);\n• When the category is "challenge", allow the value at 1-minute intervals (e.g. 1, 2, 3);\n• When the type is "task" or "subTask", allow the value at 1-minute intervals (e.g. 1, 2, 3).', + editor = "number", default = false, step = 5, min = 0, }, + { id = "available_by_default", name = "Available By Default", help = 'When set to true, this automatically sets the availability of an activity to available. Use this for any activity that the player can play from the very first time they launch the game. For players who have the Spoiler Warning set to warn on "Everything You Haven\'t Seen Yet", this setting instructs the Spoiler service to ignore this activity as containing any spoilers, even when it hasn\'t yet been seen by the user.', + editor = "bool", default = true, }, + { id = "hidden_by_default", name = "Hidden By Default", help = "When set to true, this activity, task, or subtask is considered a spoiler throughout the UX of the platform, until it becomes available, started, or ended for the player. This means that players see a spoiler flag on any user-generated content containing this activity, task, or subtask if they have not encountered it in the game yet. Additionally, if a friend is playing a hidden activity that the player hasn't encountered yet, the card is obscured for the player when viewed on the friend's profile.", + editor = "bool", default = false, }, + { id = "is_required_for_completion", name = "Required For Completion", help = "This is used to determine if the player must complete the activity to complete the main story and to pass the activities TRC if your game has a main story. Primarily, this is used to determine the sorting of activities, as activities with isRequiredforCompletion set to true that the player has never completed are more likely to be suggested to the player. In addition, this can be set on tasks. When completed, those tasks are treated as part of the progress of the activity, ultimately controlling the completion percentage progress bars. If set to false, then tasks are ignored in the completion percentage progress bar giving you more granular control of those bars. You cannot set this value on subtasks. All subtasks are considered required for completion.", + editor = "bool", default = false, no_edit = function(self) return self.category ~= "progress" end, }, + { id = "abandon_on_done_map", name = "Abandon on DoneMap", + editor = "bool", default = false, }, + { id = "state", name = "Is Active", + editor = "bool", default = false, dont_save = true, read_only = true, buttons = { {name = "Start", func = "Start"}, {name = "Abandon", func = "Abandon"}, {name = "Complete", func = "Complete"}, {name = "Fail", func = "Fail"}, }, }, + { id = "_test_buttons", + editor = "buttons", default = false, buttons = { {name = "Test Launch Now", func = "Launch"}, {name = "Test Launch On Next Boot", func = "DbgLaunchOnBoot"}, }, }, + { id = "Launch", name = "Launch", + editor = "func", default = function (self) end, }, + { id = "fullscreen_image", name = "Fullscreen Image", help = "• Dimension : 3840x2160 px\n• Image Format : PNG\n• 24-bit non-Interlaced\n• Full screen image used", + editor = "ui_image", default = false, dont_save = true, read_only = true, no_validate = true, }, + { id = "card_image", name = "Card Image", help = "Image used on action cards representing the game or challenge and in notifications triggered for a challenge.\n• Dimension : 864x1040 px\n• Image Format : PNG\n• 24 bit non-Interlaced", + editor = "ui_image", default = false, dont_save = true, read_only = true, no_validate = true, }, + }, + GlobalMap = "ActivitiesPresets", + EditorMenubarName = "PlayStation Activities", + EditorMenubar = "Editors.Other", +} + +function PlayStationActivities:Getfullscreen_image() + return string.format("svnAssets/Source/Images/Activities/%s_fullscreen.png", self.id) +end + +function PlayStationActivities:Getcard_image() + return string.format("svnAssets/Source/Images/Activities/%s_card.png", self.id) +end + +function PlayStationActivities:Start() + if Platform.ps5 then + AsyncPlayStationActivityStart(self.id) + end + AccountStorage.PlayStationStartedActivities[self.id] = true + SaveAccountStorage(5000) +end + +function PlayStationActivities:DbgLaunchOnBoot() + if not Platform.ps5 then + AccountStorage.PlayStationActivityDbgLaunchOnBoot = self.id + SaveAccountStorage(1000) + end +end + +function PlayStationActivities:IsActive() + return AccountStorage.PlayStationStartedActivities[self.id] +end + +function PlayStationActivities:Getstate() + return AccountStorage.PlayStationStartedActivities[self.id] +end + +function PlayStationActivities:Complete() + if Platform.ps5 then + AsyncPlayStationActivityEnd(self.id, const.PlayStationActivityOutcomeCompleted) + end + AccountStorage.PlayStationStartedActivities[self.id] = nil + SaveAccountStorage(5000) +end + +function PlayStationActivities:Fail() + if Platform.ps5 then + AsyncPlayStationActivityEnd(self.id, const.PlayStationActivityOutcomeFailed) + end + AccountStorage.PlayStationStartedActivities[self.id] = nil + SaveAccountStorage(5000) +end + +function PlayStationActivities:Abandon() + if Platform.ps5 then + AsyncPlayStationActivityEnd(self.id, const.PlayStationActivityOutcomeAbandoned) + end + AccountStorage.PlayStationStartedActivities[self.id] = nil + SaveAccountStorage(5000) +end + +function PlayStationActivities:GetWarning() + local warnings = {} + + if not rawget(self, "Launch") then + warnings[#warnings + 1] = "Missing launch procedure!" + end + + return #warnings ~= 0 and table.concat(warnings, "\n") +end + +----- PlayStationActivities + +if Platform.developer or Platform.ps5 then + +if FirstLoad then + g_DelayedLaunchActivity = false + g_PauseLaunchActivityReasons = { + ["EngineStarted"] = true, + ["AccountStorage"] = true + } +end + +function PlayStationLaunchActivity(activity_id) + if g_PauseLaunchActivityReasons ~= empty_table then + g_DelayedLaunchActivity = activity_id + return + end + + local activity = ActivitiesPresets[activity_id] + if activity then + assert(rawget(activity, "Launch"), "Activity not launchable!") + activity:Launch() + return + end + assert(false, string.format("Missing activity '%s'!", activity_id)) +end + +function PauseLaunchActivity(reason) + g_PauseLaunchActivityReasons[reason] = true +end + +function ResumeLaunchActivity(reason) + g_PauseLaunchActivityReasons[reason] = nil + if g_DelayedLaunchActivity and g_PauseLaunchActivityReasons == empty_table then + PlayStationLaunchActivity(g_DelayedLaunchActivity) + g_DelayedLaunchActivity = false + end +end + +function PlayStationGetActiveActivities(account_ids) + if not account_ids then + local err, account_id = PlayStationGetUserAccountId() + if err then return err end + account_ids = { account_id } + end + + account_ids = table.map(account_ids, tostring) + local uri = string.format("/v1/users/activities?accountIds=%s&limit=10", table.concat(account_ids, ",")) + local err, http_code, request_result_json = AsyncOpWait(PSNAsyncOpTimeout, nil, "AsyncPlayStationWebApiRequest", "activities", uri, "", "GET", "", {}) + if err or http_code ~= 200 then + return err or "Failed", http_code + end + + local err, request_result = JSONToLua(request_result_json) + if err then return err end + + local result = {} + for _,account_id in ipairs(account_ids) do + local user = table.find_value(request_result.users, "accountId", account_id) + result[#result + 1] = user and user.activities or empty_table + end + + return nil, result +end + +function OnMsg.DoneMap() + for activity_id,_ in pairs(AccountStorage.PlayStationStartedActivities) do + if g_DelayedLaunchActivity == activity_id then + goto continue + end + + local activity = ActivitiesPresets[activity_id] + if activity.abandon_on_done_map then + activity:Abandon() + end + + ::continue:: + end +end + +function OnMsg.ChangeMap() + PauseLaunchActivity("ChangeMap") +end + +function OnMsg.ChangeMapDone() + ResumeLaunchActivity("ChangeMap") +end + +function OnMsg.EngineStarted() + ResumeLaunchActivity("EngineStarted") + CreateRealTimeThread(function() + -- The SDK does not provide any info on activities from last run. + -- Wait for AccountStorage because we store it there + while not AccountStorage do + WaitMsg("AccountStorageChanged") + end + + -- First run? Create started activities table. + AccountStorage.PlayStationStartedActivities = AccountStorage.PlayStationStartedActivities or {} + + -- If PSN is available use activity state from there. + if Platform.ps5 then + local err, psn_activities = PlayStationGetActiveActivities() + if not err and psn_activities[1] then + table.clear(AccountStorage.PlayStationStartedActivities) + for _,activity in ipairs(psn_activities[1]) do + AccountStorage.PlayStationStartedActivities[activity.activityId] = true + end + end + end + + if not Platform.ps5 and AccountStorage.PlayStationActivityDbgLaunchOnBoot then + PlayStationLaunchActivity(AccountStorage.PlayStationActivityDbgLaunchOnBoot) + AccountStorage.PlayStationActivityDbgLaunchOnBoot = false + SaveAccountStorage(1000) + end + + -- Abandon all activities that cannot persist between game runs. + for activity_id,_ in pairs(AccountStorage.PlayStationStartedActivities) do + local activity = ActivitiesPresets[activity_id] + if not activity.abandon_on_done_map and g_DelayedLaunchActivity == activity_id then + goto continue + end + + if activity.abandon_on_done_map then + activity:Abandon() + end + + ::continue:: + end + + ResumeLaunchActivity("AccountStorage") + end) +end + +end + +DefineClass.RichPresence = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "name", name = "Name", + editor = "text", default = false, translate = true, }, + { id = "desc", name = "Description", + editor = "text", default = false, translate = true, }, + { id = "xbox_id", name = "Xbox ID", + editor = "text", default = false, }, + }, + GlobalMap = "RichPresencePresets", + EditorMenubarName = "Rich Presence", + EditorMenubar = "Editors.Lists", +} + +DefineClass.TrophyGroup = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "BuildPS4", id = "ps4_gid", name = "Group ID", help = "Those must be consecutive and unique.", + editor = "number", default = -1, buttons = { {name = "Generate", func = "GenerateGroupIDs"}, }, min = -1, max = 128, }, + { category = "BuildPS4", id = "ps4_name", name = "Name", + editor = "text", default = false, translate = true, }, + { category = "BuildPS4", id = "ps4_description", name = "Trophy Group Description", + editor = "text", default = false, translate = true, }, + { category = "BuildPS4", id = "ps4_icon", name = "Icon", + editor = "ui_image", default = "", dont_save = true, read_only = true, + no_validate = true, filter = "All files|*.png", }, + { category = "BuildPS4", id = "ps4_trophies", name = "Trophies", + editor = "preset_id_list", default = {}, dont_save = true, read_only = true, no_validate = true, preset_class = "Achievement", item_default = "", }, + { category = "BuildPS5", id = "ps5_gid", name = "Group ID", help = "Those must be consecutive and unique.", + editor = "number", default = -1, buttons = { {name = "Generate", func = "GenerateGroupIDs"}, }, min = -1, max = 128, }, + { category = "BuildPS5", id = "ps5_name", name = "Name", + editor = "text", default = false, translate = true, }, + { category = "BuildPS5", id = "ps5_description", name = "Description", + editor = "text", default = false, translate = true, }, + { category = "BuildPS5", id = "ps5_icon", name = "Icon", + editor = "ui_image", default = "", dont_save = true, read_only = true, + no_validate = true, filter = "All files|*.png", }, + { category = "BuildPS5", id = "ps5_trophies", name = "Trophies", + editor = "preset_id_list", default = {}, dont_save = true, read_only = true, no_validate = true, preset_class = "Achievement", item_default = "", }, + }, + GlobalMap = "TrophyGroupPresets", + EditorMenubarName = "Trophy Groups", + EditorIcon = "CommonAssets/UI/Icons/top trophy winner.png", + EditorMenubar = "Editors.Lists", +} + +function TrophyGroup:Getps4_icon() + local _, icon_path = GetPlayStationTrophyGroupIcon(self.id, "ps4") + return icon_path +end + +function TrophyGroup:Getps4_trophies() + return self:GetTrophies("ps4") +end + +function TrophyGroup:Getps5_icon() + local _, icon_path = GetPlayStationTrophyGroupIcon(self.id, "ps5") + return icon_path +end + +function TrophyGroup:Getps5_trophies() + return self:GetTrophies("ps5") +end + +function TrophyGroup:GenerateGroupIDs(root, prop_id) + local platform = string.match(prop_id, "(.*)_gid") + local group_id_field = prop_id + local groups_counter = 0 + ForEachPreset(TrophyGroup, function(group) + local is_group_used = CalcTrophyGroupPoints(group.id, platform) ~= 0 + + local group_id = -1 + if is_group_used then + group_id = groups_counter + groups_counter = groups_counter + 1 + end + + if group[group_id_field] ~= group_id then + group[group_id_field] = group_id + group:MarkDirty() + end + end) +end + +function TrophyGroup:GetTrophies(platform) + local trophies = PresetArray(Achievement, function(achievement) + return achievement:GetTrophyGroup(platform) == self.id + end) + return table.imap(trophies, function(trophy) + return trophy.id + end) +end + +function TrophyGroup:IsBaseGameGroup(platform) + if self[platform .. "_gid"] < 0 then return false end + local dlc = FindPreset("DLCConfig", self.save_in) + return not dlc or dlc[platform .. "_handling"] == "Embed" +end + +function TrophyGroup:GetError(platform) + local errors = {} + + local ShouldTestPlatform = function(test_platform) + return (not platform or platform == test_platform) + end + + local groups = PresetArray(TrophyGroup) + local GetPlayStationErrors = function(platform) + local group_id_field = platform .. "_gid" + local self_group_id = self[group_id_field] + if CalcTrophyGroupPoints(self.id, platform) > 0 and self_group_id < 0 then + errors[#errors + 1] = string.format("Missing %s trophy group id!", platform) + elseif self_group_id >= 0 then + table.sortby_field(groups, group_id_field) + local next_group_id = 0 + local group_id_holes = {} + for _, group in ipairs(groups) do + local curr_group_id = group[group_id_field] + if next_group_id < self_group_id and curr_group_id >= 0 then + if curr_group_id > next_group_id then + if curr_group_id - next_group_id > 1 then + group_id_holes[#group_id_holes + 1] = string.format("%d-%d", next_group_id, curr_group_id) + else + group_id_holes[#group_id_holes + 1] = next_group_id + end + end + next_group_id = curr_group_id + 1 + end + if self ~= group and self_group_id == curr_group_id then + errors[#errors + 1] = string.format("Duplicated %s trophy group id (%s)!", platform, group.id) + end + end + + if #group_id_holes ~= 0 then + errors[#errors + 1] = string.format("%s group ids are not consecutive, missing %s!", string.upper(platform), table.concat(group_id_holes, ", ")) + end + end + end + + if ShouldTestPlatform("ps4") then GetPlayStationErrors("ps4") end + if ShouldTestPlatform("ps5") then GetPlayStationErrors("ps5") end + + return #errors ~= 0 and table.concat(errors, "\n") +end + +function TrophyGroup:GetWarning(platform) + local warnings = {} + + local ShouldTestPlatform = function(test_platform) + return (not platform or platform == test_platform) + end + + local GetPlayStationWarnings = function(platform) + local trophies = self:GetTrophies(platform) + if self[platform .. "_gid"] >= 0 then + if #trophies == 0 then + warnings[#warnings + 1] = string.format("Has %s group id but no trophies assigned!", platform) + end + local is_placeholder, icon_path = GetPlayStationTrophyGroupIcon(self.id, platform) + if is_placeholder then + warnings[#warnings + 1] = string.format("Missing %s trophy group icon (placeholder used): %s", platform, icon_path) + end + end + + local is_base_game_group = self:IsBaseGameGroup(platform) + for _, trophy_name in ipairs(trophies) do + local trophy = FindPreset("Achievement", trophy_name) + local is_base_game_trophy = trophy:IsBaseGameTrophy(platform) + if trophy.save_in ~= self.save_in and not (is_base_game_group and is_base_game_trophy) then + warnings[#warnings + 1] = string.format( + "%s trophy %s saved in %s while the group is saved in %s.", + string.upper(platform), trophy_name, trophy.save_in, self.save_in) + end + end + end + + if ShouldTestPlatform("ps4") then GetPlayStationWarnings("ps4") end + if ShouldTestPlatform("ps5") then GetPlayStationWarnings("ps5") end + + return #warnings ~= 0 and table.concat(warnings, "\n") +end + +DefineClass.VideoDef = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "Common", id = "source", + editor = "browse", default = false, folder = "svnAssets/Source/Movies", filter = "Video Files|*.avi|All Files|*.*", }, + { category = "Common", id = "ffmpeg_input_pattern", + editor = "text", default = '-i "$(source)"', }, + { category = "Common", id = "sound", + editor = "browse", default = false, folder = "svnAssets/Source/Movies", filter = "Audio Files|*.wav", }, + { category = "Desktop", id = "present_desktop", + editor = "bool", default = true, }, + { category = "Desktop", id = "extension_desktop", + editor = "text", default = "ivf", + no_edit = function(self) return not self.present_desktop end, }, + { category = "Desktop", id = "ffmpeg_commandline_desktop", + editor = "text", default = "-c:v vp8 -preset veryslow", + no_edit = function(self) return not self.present_desktop end, }, + { category = "Desktop", id = "bitrate_desktop", + editor = "number", default = 8000, + no_edit = function(self) return not self.present_desktop end, }, + { category = "Desktop", id = "framerate_desktop", + editor = "number", default = 30, + no_edit = function(self) return not self.present_desktop end, }, + { category = "Desktop", id = "resolution_desktop", + editor = "point", default = point(1920, 1080), + no_edit = function(self) return not self.present_desktop end, }, + { category = "PS4", id = "present_ps4", + editor = "bool", default = true, }, + { category = "PS4", id = "extension_ps4", + editor = "text", default = "bsf", + no_edit = function(self) return not self.present_ps4 end, }, + { category = "PS4", id = "ffmpeg_commandline_ps4", + editor = "text", default = "-c:v h264 -profile:v high422 -pix_fmt yuv420p -x264opts force-cfr -bsf h264_mp4toannexb -f h264 -r 30000/1001", + no_edit = function(self) return not self.present_ps4 end, }, + { category = "PS4", id = "bitrate_ps4", + editor = "number", default = 6000, + no_edit = function(self) return not self.present_ps4 end, }, + { category = "PS4", id = "framerate_ps4", + editor = "number", default = 30, + no_edit = function(self) return not self.present_ps4 end, }, + { category = "PS4", id = "resolution_ps4", + editor = "point", default = point(1920, 1080), + no_edit = function(self) return not self.present_ps4 end, }, + { category = "PS5", id = "present_ps5", + editor = "bool", default = true, }, + { category = "PS5", id = "extension_ps5", + editor = "text", default = "bsf", + no_edit = function(self) return not self.present_ps5 end, }, + { category = "PS5", id = "ffmpeg_commandline_ps5", + editor = "text", default = "-c:v h264 -profile:v high422 -pix_fmt yuv420p -x264opts force-cfr -bsf h264_mp4toannexb -f h264 -r 30000/1001", + no_edit = function(self) return not self.present_ps5 end, }, + { category = "PS5", id = "bitrate_ps5", + editor = "number", default = 6000, + no_edit = function(self) return not self.present_ps5 end, }, + { category = "PS5", id = "framerate_ps5", + editor = "number", default = 30, + no_edit = function(self) return not self.present_ps5 end, }, + { category = "PS5", id = "resolution_ps5", + editor = "point", default = point(1920, 1080), + no_edit = function(self) return not self.present_ps5 end, }, + { category = "Xbox One", id = "present_xbox_one", + editor = "bool", default = true, }, + { category = "Xbox One", id = "extension_xbox_one", + editor = "text", default = "mp4", + no_edit = function(self) return not self.present_xbox_one end, }, + { category = "Xbox One", id = "ffmpeg_commandline_xbox_one", + editor = "text", default = "-c:v h264 -preset veryslow -pix_fmt yuv420p", + no_edit = function(self) return not self.present_xbox_one end, }, + { category = "Xbox One", id = "bitrate_xbox_one", + editor = "number", default = 8000, + no_edit = function(self) return not self.present_xbox_one end, }, + { category = "Xbox One", id = "framerate_xbox_one", + editor = "number", default = 30, + no_edit = function(self) return not self.present_xbox_one end, }, + { category = "Xbox One", id = "resolution_xbox_one", + editor = "point", default = point(1920, 1080), + no_edit = function(self) return not self.present_xbox_one end, }, + { category = "Xbox Series", id = "present_xbox_series", + editor = "bool", default = true, }, + { category = "Xbox Series", id = "extension_xbox_series", + editor = "text", default = "mp4", + no_edit = function(self) return not self.present_xbox_series end, }, + { category = "Xbox Series", id = "ffmpeg_commandline_xbox_series", + editor = "text", default = "-c:v libx265 -tag:v hvc1 -preset veryslow -pix_fmt yuv420p", + no_edit = function(self) return not self.present_xbox_series end, }, + { category = "Xbox Series", id = "bitrate_xbox_series", + editor = "number", default = 8000, + no_edit = function(self) return not self.present_xbox_series end, }, + { category = "Xbox Series", id = "framerate_xbox_series", + editor = "number", default = 30, + no_edit = function(self) return not self.present_xbox_series end, }, + { category = "Xbox Series", id = "resolution_xbox_series", + editor = "point", default = point(1920, 1080), + no_edit = function(self) return not self.present_xbox_series end, }, + { category = "Switch", id = "present_switch", + editor = "bool", default = true, }, + { category = "Switch", id = "extension_switch", + editor = "text", default = "mp4", + no_edit = function(self) return not self.present_switch end, }, + { category = "Switch", id = "ffmpeg_commandline_switch", + editor = "text", default = "-c:v h264 -preset veryslow -pix_fmt yuv420p", + no_edit = function(self) return not self.present_switch end, }, + { category = "Switch", id = "bitrate_switch", + editor = "number", default = 700, + no_edit = function(self) return not self.present_switch end, }, + { category = "Switch", id = "framerate_switch", + editor = "number", default = 30, + no_edit = function(self) return not self.present_switch end, }, + { category = "Switch", id = "resolution_switch", + editor = "point", default = point(1280, 720), + no_edit = function(self) return not self.present_switch end, }, + }, + HasCompanionFile = true, + GlobalMap = "VideoDefs", + EditorMenubarName = "Video defs", + EditorIcon = "CommonAssets/UI/Icons/outline video.png", + EditorMenubar = "Editors.Engine", +} + +function VideoDef:GetPropsForPlatform(platform) + assert(table.find({ "desktop", "ps4", "ps5", "xbox_one", "xbox_series", "switch" }, platform)) + local result = {} + local props = { "extension", "ffmpeg_commandline", "bitrate", "framerate", "resolution", "present" } + for key, value in ipairs(props) do + result[value] = self[value .. "_" .. platform] + end + local video_path = string.match(self.source or "", "svnAssets/Source/(.+)") + if video_path then + local dir, name, ext = SplitPath(video_path) + result.video_game_path = dir .. name .. "." .. result.extension + end + + local sound_path = string.match(self.sound or "", "svnAssets/Source/(.+)") + if sound_path then + local dir, name, ext = SplitPath(sound_path) + result.sound_game_path = dir .. name + end + + return result +end + +DefineClass.VoiceActorDef = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "VoiceId", name = "VoiceID", + editor = "text", default = false, }, + }, + GlobalMap = "VoiceActors", + EditorMenubarName = "Voice Actors", + EditorIcon = "CommonAssets/UI/Icons/human male man people person.png", + EditorMenubar = "Editors.Audio", + EditorView = Untranslated(" "), +} + diff --git a/CommonLua/Classes/ClassDefs/ClassDef-Default.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-Default.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..957bf1392ce031d7ca5535767836f44fad7314e0 --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-Default.generated.lua @@ -0,0 +1,193 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +DefineClass.AnimComponentWeight = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "AnimComponent", + editor = "preset_id", default = false, preset_class = "AnimComponent", }, + { id = "BlendAfterChannel", help = "If false, the component will execute on the channel animation, if true, it will execute after the channel has beel blended with all before it.", + editor = "bool", default = false, }, + }, +} + +DefineClass.AnimLimbData = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "fit_bone", help = "Bone name to be fit to target", + editor = "text", default = false, }, + { id = "joint_bone", + editor = "text", default = false, }, + { id = "joint_companion_bone", + editor = "text", default = false, }, + { id = "top_bone", + editor = "text", default = false, }, + { id = "top_companion_bone", + editor = "text", default = false, }, + { id = "fit_normal", help = "Local bone space normal direction to be fit to target", + editor = "point", default = point(0, 1000, 0), }, + { id = "fit_offset", help = "Local bone space position offset to be fit to target", + editor = "point", default = point(0, 0, 0), }, + { id = "joint_axis", help = "Local bone space joint axis direction", + editor = "point", default = point(0, 0, 1000), }, + }, +} + +DefineClass.CommonGameSettings = { + __parents = { "InitDone", }, + __generated_by_class = "ClassDef", + + properties = { + { category = "Modifiers", id = "game_difficulty", name = T(607013881337, --[[ClassDef Default CommonGameSettings name]] "Game difficulty"), + editor = "preset_id", default = false, preset_class = "GameDifficultyDef", }, + { category = "Modifiers", id = "seed_text", name = T(968127954818, --[[ClassDef Default CommonGameSettings name]] "Seed"), help = T(657915341595, --[[ClassDef Default CommonGameSettings help]] "Text used to generate seed. If empty a random (async) seed will be used."), + editor = "text", default = "", }, + { category = "Modifiers", id = "game_rules", name = T(567633276594, --[[ClassDef Default CommonGameSettings name]] "Game Rules"), + editor = "prop_table", default = false, }, + { category = "Modifiers", id = "forced_game_rules", name = T(957452987296, --[[ClassDef Default CommonGameSettings name]] "Forced game rules"), help = T(745042998514, --[[ClassDef Default CommonGameSettings help]] "If a rule is set to true, it is force enabled, false means force disabled."), + editor = "prop_table", default = false, dont_save = true, no_edit = true, }, + }, + StoreAsTable = true, + id = false, + save_id = false, + loaded_from_id = false, +} + +function CommonGameSettings:Init() + self.id = self.id or random_encode64(96) + + local difficulty = table.get(Presets, "GameDifficultyDef", 1) or empty_table + difficulty = difficulty[(#difficulty + 1) / 2] + self.game_difficulty = self.game_difficulty or difficulty and difficulty.id or nil + + self.game_rules = self.game_rules or {} + self.forced_game_rules = self.forced_game_rules or {} + ForEachPreset("GameRule", function(rule) + if rule.init_as_active then + self:AddGameRule(rule.id) + end + end) +end + +function CommonGameSettings:ToggleListValue(prop_id, item_id) + if prop_id == "game_rules" then + self:ToggleGameRule(item_id) + return + end + local value = self[prop_id] + if value[item_id] then + value[item_id] = nil + else + value[item_id] = true + end +end + +function CommonGameSettings:ToggleGameRule(rule_id) + local value = self.game_rules[rule_id] + if value then + self:RemoveGameRule(rule_id) + else + self:AddGameRule(rule_id) + end +end + +function CommonGameSettings:AddGameRule(rule_id) + if self:CanAddGameRule(rule_id) then + self.game_rules[rule_id] = true + end +end + +function CommonGameSettings:RemoveGameRule(rule_id) + if self:CanRemoveGameRule(rule_id) then + self.game_rules[rule_id] = nil + end +end + +function CommonGameSettings:SetForceEnabledGameRule(rule_id, set) + if set then + self:AddGameRule(rule_id) + else + self:RemoveGameRule(rule_id) + end + self.forced_game_rules[rule_id] = set and true or nil +end + +function CommonGameSettings:SetForceDisabledGameRule(rule_id, set) + if set then + self:RemoveGameRule(rule_id) + end + if set then + self.forced_game_rules[rule_id] = false + else + self.forced_game_rules[rule_id] = nil + end +end + +function CommonGameSettings:CanAddGameRule(rule_id) + if self.forced_game_rules[rule_id] == false then + return + end + local rule = GameRuleDefs[rule_id] + return not self:IsGameRuleActive(rule_id) and rule and rule:IsCompatible(self.game_rules) +end + +function CommonGameSettings:CanRemoveGameRule(rule_id) + return self.forced_game_rules[rule_id] == nil +end + +function CommonGameSettings:IsGameRuleActive(rule_id) + return self.game_rules[rule_id] +end + +function CommonGameSettings:CopyCategoryTo(other, category) + for _, prop in ipairs(self:GetProperties()) do + if prop.category == category then + local value = self:GetProperty(prop.id) + value = type(value) == "table" and table.copy(value) or value + other:SetProperty(prop.id, value) + end + end +end + +function CommonGameSettings:Clone() + local obj = CooldownObj.Clone(self) + obj.id = self.id or nil + obj.save_id = self.save_id or nil + obj.loaded_from_id = self.loaded_from_id or nil + return obj +end + +DefineClass.Explanation = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "Text", + editor = "text", default = false, translate = true, }, + { id = "Id", + editor = "text", default = false, }, + { id = "ObjIsKindOf", name = "Object is of type", help = "The explanation is provided only if the parameter object inherits the specified class.", + editor = "combo", default = "", items = function (self) return ClassDescendantsList("PropertyObject") end, }, + { id = "Conditions", + editor = "nested_list", default = false, base_class = "Condition", }, + }, + EditorView = Untranslated("Explanation: "), +} + +----- Explanation + +function GetFirstExplanation(list, obj, ...) + local IsKindOf = IsKindOf + local EvalConditionList = EvalConditionList + for _, explanation in ipairs(list) do + local kind_of = explanation.ObjIsKindOf or "" + if (kind_of == "" or IsKindOf(obj, kind_of)) and EvalConditionList(explanation.Conditions, obj, ...) then + return explanation.Text, explanation.Id + end + end + return "" +end + diff --git a/CommonLua/Classes/ClassDefs/ClassDef-Effects.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-Effects.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..3558c730cea990642d49218ae0cc04f31729ad98 --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-Effects.generated.lua @@ -0,0 +1,495 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +DefineClass.ChangeGameStateEffect = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "GameState", name = "Game state", + editor = "preset_id", default = false, preset_class = "GameStateDef", }, + { id = "Value", name = "Value", + editor = "bool", default = false, }, + }, + EditorView = Untranslated(" game state "), + Documentation = "Changes a game state", +} + +function ChangeGameStateEffect:__exec(obj, context) + ChangeGameState(self.GameState, self.Value) +end + +function ChangeGameStateEffect:GetError() + if not GameStateDefs[self.GameState] then + return "No such GameState" + end +end + +DefineClass.ChangeLightmodel = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "Lightmodel", name = "Light model", help = "Specify a light model, or leave as 'false' to restore the previous one.", + editor = "preset_id", default = false, preset_class = "LightmodelPreset", }, + }, + EditorView = Untranslated("Change light model to .Restore last light model."), + Documentation = "Changes the current light model, or restores the last one if Light model is 'false'.", +} + +function ChangeLightmodel:__exec(obj, context) + SetLightmodelOverride(false, self.Lightmodel) +end + +DefineClass.EffectsWithCondition = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "Conditions", + editor = "nested_list", default = false, base_class = "Condition", }, + { id = "Effects", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + { id = "EffectsElse", name = "Else", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + }, + EditorView = Untranslated("Effects with condition"), + Documentation = "Executes different effects when a list of conditions is true or not.", +} + +function EffectsWithCondition:__exec(obj, ...) + if _EvalConditionList(self.Conditions, obj, ...) then + for _, effect in ipairs(self.Effects) do + effect:__exec(obj, ...) + end + return true + else + for _, effect in ipairs(self.EffectsElse) do + effect:__exec(obj, ...) + end + end +end + +DefineClass.ExecuteCode = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "Params", + editor = "text", default = "self, obj", }, + { id = "SaveAsText", + editor = "bool", default = false, }, + { id = "Code", + editor = "func", default = function (self) end, + params = function(self) return self.Params end, no_edit = function(self) return self.SaveAsText end, }, + { id = "FuncCode", + editor = "text", default = false, + params = function(self) return self.Params end, no_edit = function(self) return not self.SaveAsText end, lines = 1, }, + }, + EditorView = Untranslated("Execute Code"), + Documentation = "Execute arbitrary code.", +} + +function ExecuteCode:__exec(...) + if self.SaveAsText and self.FuncCode then + local prop_meta = self:GetPropertyMetadata("FuncCode") + local params = prop_meta.params(self, prop_meta) or "self" + local func, err = CompileFunc("FuncCode", params, self.FuncCode) + if not func then + assert(false, err) + return false + end + return func(self, ...) + end + return self.Code(self, ...) +end + +function ExecuteCode:__toluacode(...) + if not self.SaveAsText then + assert(not g_PresetForbidSerialize, "Attempt to save ExecuteCode not from Ged!") + end + + return Effect.__toluacode(self, ...) +end + +function ExecuteCode:GetError() + local code + if self.SaveAsText then + if self.FuncCode then + code = string.split(self.FuncCode, "\n") + end + else + if self.Code then + local _,_, body = GetFuncSource(self.Code) + code = body + end + end + if not code then return end + + code = (type(code) == "string") and {code} or code + for _, line in ipairs(code) do + if string.match(line, "T{") then + return "FuncCode can't use T{}" + end + if string.match(line, "Translated%(") then + return "FuncCode can't use Translated()" + end + if string.match(line, "Untranslated%(") then + return "FuncCode can't use Untranslated()" + end + end +end + +DefineClass.ModifyCooldownEffect = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "CooldownObj", name = "Cooldown object", + editor = "combo", default = "Game", items = function (self) return {"obj", "context", "Player", "Game"} end, }, + { id = "Cooldown", + editor = "preset_id", default = "Disabled", preset_class = "CooldownDef", }, + { id = "TimeScale", name = "Time Scale", + editor = "choice", default = "h", items = function (self) return GetTimeScalesCombo() end, }, + { id = "Time", help = "If time is not provided the default time from the cooldown definition is used.", + editor = "number", default = 0, + scale = function(self) return self.TimeScale end, }, + { id = "RandomTime", + editor = "number", default = 0, + scale = function(self) return self.TimeScale end, }, + }, + EditorView = Untranslated("Add to cooldown "), + Documentation = "Adds time to an existing cooldown", + EditorNestedObjCategory = "", +} + +function ModifyCooldownEffect:__exec(obj, context) + local cooldown_obj = self.CooldownObj + if cooldown_obj == "Player" then + obj = ResolveEventPlayer(obj, context) + elseif cooldown_obj == "Game" then + obj = Game + elseif cooldown_obj == "context" then + obj = context + end + assert(not obj or IsKindOf(obj, "CooldownObj")) + if IsKindOf(obj, "CooldownObj") then + local rand = self.RandomTime + local time = self.Time + (rand > 0 and InteractionRand(rand, self.Cooldown, obj) or 0) + obj:ModifyCooldown(self.Cooldown, time) + end +end + +function ModifyCooldownEffect:GetError() + if not CooldownDefs[self.Cooldown] then + return "No such cooldown" + end +end + +DefineClass.PlayActionFX = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "ActionFX", name = "ActionFX", + editor = "combo", default = "", items = function (self) return PresetsPropCombo("FXPreset", "Action", "") end, }, + { id = "ActionMoment", name = "ActionMoment", + editor = "combo", default = "start", items = function (self) return PresetsPropCombo("FXPreset", "Moment") end, }, + }, + EditorView = Untranslated("PlayFX "), + Documentation = "PlayFX", +} + +function PlayActionFX:__exec(obj, context) + if self.ActionFX ~= "" then + PlayFX(self.ActionFX, self.ActionMoment, obj, context) + end +end + +DefineClass.RemoveGameNotificationEffect = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "NotificationId", + editor = "text", default = false, }, + }, + EditorView = Untranslated("Remove notification "), + Documentation = "Removes the specified notification if it is present", +} + +function RemoveGameNotificationEffect:__exec(obj, context) + RemoveGameNotification(self.NotificationId) +end + +function RemoveGameNotificationEffect:GetError() + if not self.NotificationId then + return "No notification id set" + end +end + +DefineClass.ScriptStoryBitActivate = { + __parents = { "ScriptSimpleStatement", }, + __generated_by_class = "ScriptEffectDef", + + properties = { + { id = "StoryBitId", name = "Id", + editor = "preset_id", default = false, preset_class = "StoryBit", }, + { id = "NoCooldown", help = "Don't activate any cooldowns for subsequent StoryBit activations", + editor = "bool", default = false, }, + { id = "ForcePopup", name = "Force Popup", help = "Specifying true skips the notification phase, and directly displays the popup", + editor = "bool", default = true, }, + }, + EditorView = Untranslated("Activate story bit "), + EditorName = "Activate story bit", + EditorSubmenu = "Effects", + Documentation = "", + CodeTemplate = 'ForceActivateStoryBit(self.StoryBitId, $self.Param1, self.ForcePopup and "immediate", $self.Param2, self.NoCooldown)', + Param1Name = "obj", + Param2Name = "context", +} + +function ScriptStoryBitActivate:GetError() + local story_bit = StoryBits[self.StoryBitId] + if not story_bit then + return "Invalid StoryBit preset" + end +end + +DefineClass.SelectObjectEffect = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "SelectionIsEmpty", name = "Only if selection is empty", + editor = "bool", default = false, }, + { id = "ObjNonEmpty", name = "Only if obj is not empty", + editor = "bool", default = false, }, + }, + EditorView = Untranslated("Select object"), + Documentation = "Select the object", +} + +function SelectObjectEffect:__exec(obj, context) + if self.SelectionIsEmpty and SelectedObj then return end + if self.ObjNonEmpty and not obj then return end + SelectObj(obj) +end + +DefineClass.SetCooldownEffect = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "CooldownObj", name = "Cooldown object", + editor = "combo", default = "Game", items = function (self) return {"obj", "context", "Player", "Game"} end, }, + { id = "Cooldown", + editor = "preset_id", default = "Disabled", preset_class = "CooldownDef", }, + { id = "TimeScale", name = "Time Scale", + editor = "choice", default = "h", items = function (self) return GetTimeScalesCombo() end, }, + { id = "TimeMin", name = "Time min", help = "If time is not provided the default time from the cooldown definition is used.", + editor = "number", default = false, + scale = function(self) return self.TimeScale end, }, + { id = "TimeMax", name = "Time max", help = "If time is not provided the default time from the cooldown definition is used.", + editor = "number", default = false, + scale = function(self) return self.TimeScale end, no_edit = function(self) return not self.TimeMin end, }, + }, + EditorView = Untranslated("Set cooldown "), + Documentation = "Sets a cooldown", + EditorNestedObjCategory = "", +} + +function SetCooldownEffect:__exec(obj, context) + local cooldown_obj = self.CooldownObj + if cooldown_obj == "Player" then + obj = ResolveEventPlayer(obj, context) + elseif cooldown_obj == "Game" then + obj = Game + elseif cooldown_obj == "context" then + obj = context + end + assert(not obj or IsKindOf(obj, "CooldownObj")) + if IsKindOf(obj, "CooldownObj") then + local min, max = self.TimeMin, self.TimeMax + local time + if min then + time = min + if max then + time = InteractionRandRange(min, max, self.Cooldown, obj) + end + end + obj:SetCooldown(self.Cooldown, time) + end +end + +function SetCooldownEffect:GetError() + if not CooldownDefs[self.Cooldown] then + return "No such cooldown" + end +end + +DefineClass.StoryBitActivate = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "Id", name = "Id", + editor = "preset_id", default = false, preset_class = "StoryBit", }, + { id = "NoCooldown", help = "Don't activate any cooldowns for subsequent StoryBit activations", + editor = "bool", default = false, }, + { id = "ForcePopup", name = "Force Popup", help = "Specifying true skips the notification phase, and directly displays the popup", + editor = "bool", default = true, }, + { id = "StorybitSets", name = "Storybit sets", + editor = "text", default = "", dont_save = true, read_only = true, }, + { id = "OneTime", + editor = "bool", default = false, dont_save = true, read_only = true, }, + }, + EditorView = Untranslated('"Activate StoryBit "'), + Documentation = "Activates a StoryBit with the specified Id", + NoIngameDescription = true, + EditorNestedObjCategory = "Story Bits", +} + +function StoryBitActivate:GetStorybitSets() + local preset = StoryBits[self.Id] + if not preset or not next(preset.Sets) then return "None" end + local items = {} + for set in sorted_pairs(preset.Sets) do + items[#items + 1] = set + end + return table.concat(items, ", ") +end + +function StoryBitActivate:GetOneTime() + local preset = StoryBits[self.Id] + return preset and preset.OneTime +end + +function StoryBitActivate:__exec(obj, context) + ForceActivateStoryBit(self.Id, obj, self.ForcePopup and "immediate", context, self.NoCooldown) +end + +DefineClass.StoryBitActivateRandom = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "StoryBits", help = "A list of storybits with weight. One will be chosen and activated based on weight and met prerequisites.", + editor = "nested_list", default = false, base_class = "StoryBitWithWeight", all_descendants = true, }, + }, + Documentation = "Performs a weighted random on a list of story bits and activates the one that is picked (if any)", + EditorNestedObjCategory = "Story Bits", +} + +function StoryBitActivateRandom:GetEditorView(...) + local items = {} + for i, item in ipairs(self.StoryBits) do + local id = item.StoryBitId or "" + if item.Weight then + id = id .. " (" .. item.Weight .. ")" + end + items[i] = id + end + local names_text = next(items) and table.concat(items, ", ") or "None" + return Untranslated(string.format("Activate random event: %s", names_text)) +end + +function StoryBitActivateRandom:__exec(obj, context) + TryActivateRandomStoryBit(self.StoryBits, obj, context) +end + +function StoryBitActivateRandom:GetError() + if not next(StoryBits) then return end + if not next(self.StoryBits) then + return "No StoryBits to pick from " + else + for i, item in ipairs(self.StoryBits) do + local id = item.StoryBitId + if id ~= "" and not StoryBits[id] then + return string.format("No such storybit: %s", id) + end + end + end +end + +DefineClass.StoryBitEnableRandom = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + properties = { + { id = "StoryBits", name = "Story Bits", help = "List of StoryBit ids to pick from", + editor = "preset_id_list", default = {}, preset_class = "StoryBit", item_default = "", }, + { id = "Weights", name = "Weights", help = "Weights for the entries in StoryBits (default 100)", + editor = "number_list", default = {}, item_default = 100, items = false, }, + }, + Documentation = "Performs a weighted random on a list of story bits and enables the one that is picked (if any)", + EditorNestedObjCategory = "Story Bits", +} + +function StoryBitEnableRandom:GetEditorView(...) + local items = {} + local weights = self.Weights + for i, id in ipairs(self.StoryBits) do + local w = weights[i] + if w then + id = id .. " (" .. w .. ")" + end + items[i] = id + end + local names_text = next(items) and table.concat(items, ", ") or "None" + return Untranslated(string.format("Enable random event: %s", names_text)) +end + +function StoryBitEnableRandom:__exec(obj, context) + local items = {} + local weights = self.Weights + local states = g_StoryBitStates + for i, id in ipairs(self.StoryBits) do + local state = states[id] + if not state then + local def = StoryBits[id] + if def and not def.Enabled then + local weight = weights[i] or 100 + items[#items + 1] = {id, weight} + end + end + end + local item = table.weighted_rand(items, 2) + if not item then + return + end + local id = item[1] + local storybit = StoryBits[id] + StoryBitState:new{ + id = id, + object = storybit.InheritsObject and context and context.object or nil, + player = ResolveEventPlayer(obj, context), + inherited_title = context and context:GetTitle() or nil, + inherited_image = context and context:GetImage() or nil, + } +end + +function StoryBitEnableRandom:GetError() + if not next(StoryBits) then return end + if not next(self.StoryBits) then + return "No StoryBits to pick from " + else + for i, id in ipairs(self.StoryBits) do + if id ~= "" and not StoryBits[id] then + return string.format("No such storybit: %s", id) + end + end + end +end + +DefineClass.ViewObjectEffect = { + __parents = { "Effect", }, + __generated_by_class = "EffectDef", + + EditorView = Untranslated("View object"), + Documentation = "Move the camera to view the object", +} + +function ViewObjectEffect:__exec(obj, context) + ViewObject(obj) +end + diff --git a/CommonLua/Classes/ClassDefs/ClassDef-PresetDefs.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-PresetDefs.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..94024186e8931c0b998a699213654285fde709a4 --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-PresetDefs.generated.lua @@ -0,0 +1,2072 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +DefineClass.ActorFXClassDef = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "Preset", id = "help", help = T(757555070861, --[[PresetDef ActorFXClassDef help]] "Use the Group property to define the ActorFXClass parent - all FX from the parent are inherited.\n\nEntries in the Default group are considered top level and have no parents."), + editor = "help", default = false, }, + }, + PropertyTranslation = true, + GlobalMap = "ActorFXClassDefs", + EditorMenubarName = "FX Classes", + EditorMenubar = "Editors.Art", + StoreAsTable = true, +} + +----- ActorFXClassDef + +function OnMsg.GatherFXActors(list) + ForEachPreset("ActorFXClassDef", function(preset, group, list) + list[#list + 1] = preset.id + end, list) +end + +function OnMsg.GetCustomFXInheritActorRules(custom_inherit) + ForEachPreset("ActorFXClassDef", function(preset, group, custom_inherit) + if preset.group ~= "Default" then + assert(ActorFXClassDefs[preset.group]) -- any preset group other than 'Default' should have its own ActorFXClass definition; actor classes in 'Default' are top-level + custom_inherit[#custom_inherit + 1] = preset.id + custom_inherit[#custom_inherit + 1] = preset.group + end + end, custom_inherit) +end + +DefineClass.AnimComponent = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "label", name = "Label", help = "Label that identifies the kind of IK", + editor = "text", default = false, }, + }, + GlobalMap = "AnimComponents", + EditorMenubar = "Editors.Art", +} + +DefineClass.AnimIKLimbAdjust = { + __parents = { "AnimComponent", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "Limbs", + editor = "nested_list", default = false, base_class = "AnimLimbData", inclusive = true, auto_expand = true, }, + { id = "adjust_root_to_reach_targets", help = "Adjust root position along an axis in case limb targets are too far from the current position", + editor = "bool", default = false, }, + { id = "adjust_root_axis", help = "Axis to adjust root position to reach far out limb targets", + editor = "point", default = point(0, 0, 1000), }, + { id = "max_target_speed", help = "Maximum units per second the adjusted limb fit positions are allowed to move, 0 means no limit", + editor = "number", default = 5000, min = 0, }, + }, + PresetClass = "AnimComponent", +} + +DefineClass.AnimIKLookAt = { + __parents = { "AnimComponent", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "pivot_bone", + editor = "text", default = false, }, + { id = "pivot_parents", help = "Number of bones to distribute the rotation between", + editor = "number", default = 1, min = 1, }, + { id = "aim_bone", + editor = "text", default = false, }, + { id = "aim_forward", help = "Forward direction in local bone space", + editor = "point", default = point(-1000, 0, 0), }, + { id = "aim_up", help = "Up direction in local bone space", + editor = "point", default = point(0, -1000, 0), }, + { id = "max_vertical_angle", + editor = "number", default = 2700, scale = "deg", min = 0, max = 10800, }, + { id = "max_horizontal_angle", + editor = "number", default = 5400, scale = "deg", min = 0, max = 10800, }, + { id = "out_of_bound_vertical_snap", help = "Snap vertical angle to 0 if target is outside horizontal limits", + editor = "bool", default = false, }, + { id = "max_angular_speed", help = "Maximum local angle per second", + editor = "number", default = 5400, scale = "deg", min = 0, max = 43200, }, + }, + PresetClass = "AnimComponent", +} + +DefineClass.AnimMetadata = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "FX", id = "Action", name = "Test action", + editor = "combo", default = false, dont_save = true, items = function (self) +return table.ifilter(PresetsPropCombo("FXPreset", "Action")(), function(idx, item) + return FXActionToAnim(item) == item +end) +end, show_recent_items = 7,}, + { category = "FX", id = "Actor", name = "Test actor", + editor = "combo", default = false, dont_save = true, items = function (self) return ActionFXClassCombo() end, show_recent_items = 7,}, + { category = "FX", id = "Target", name = "Test target", + editor = "combo", default = false, dont_save = true, items = function (self) return TargetFXClassCombo end, show_recent_items = 7,}, + { category = "FX", id = "FXInherits", + editor = "string_list", default = {}, item_default = "idle", items = function (self) return IsValidEntity(self.group) and GetStates(self.group) or { "idle" } end, }, + { category = "Moments", id = "ReconfirmAll", + editor = "buttons", default = false, buttons = { {name = "Reconfirm", func = "ReconfirmMoments"}, }, }, + { category = "Moments", id = "Moments", + editor = "nested_list", default = false, base_class = "AnimMoment", inclusive = true, }, + { category = "Animation", id = "SpeedModifier", + editor = "number", default = 100, min = 10, max = 1000, }, + { category = "Animation", id = "StepModifier", + editor = "number", default = 100, min = 10, max = 1000, }, + { category = "Anim Components", id = "VariationWeight", + editor = "number", default = 100, slider = true, min = 0, max = 10000, }, + { category = "Anim Components", id = "RandomizePhase", + editor = "number", default = -1, min = -1, }, + { category = "Anim Components", id = "AnimComponents", + editor = "nested_list", default = false, base_class = "AnimComponentWeight", inclusive = true, auto_expand = true, }, + }, + GedEditor = "", +} + +function AnimMetadata:GetAction() + if not self.Action then + local obj = GetAnimationMomentsEditorObject() + if obj then + local anim = AnimationMomentsEditorMode == "selection" and obj:Getanim() or GetStateName(obj:GetState()) + return FXAnimToAction(anim) + end + end + return self.Action +end + +function AnimMetadata:GetActor() + if not self.Actor then + local obj = GetAnimationMomentsEditorObject() + if obj and g_Classes.Unit then + obj = rawget(obj, "obj") or obj + local obj_class = obj.class + if obj:IsKindOfClasses("Unit", "BaseObjectAME") or obj_class == "DummyUnit" then + obj_class = "Unit" + end + return g_Classes[obj_class].fx_actor_class or obj_class + end + end + return self.Actor +end + +function AnimMetadata:PostLoad() + local ent_speed_mod = const.AnimSpeedScale * self.SpeedModifier / 100 + local entity = self.group + local state = GetStateIdx(self.id) + SetStateSpeedModifier(entity, state, ent_speed_mod) + SetStateStepModifier(entity, state, self.StepModifier) +end + +function AnimMetadata:OnPreSave() + -- fixup revisions of files that were modified locally when animation moments were added + for _, moment in ipairs(self.Moments) do + if moment.AnimRevision == 999999999 then + moment.AnimRevision = EntitySpec:GetAnimRevision(self.group, self.id) + end + end +end + +function AnimMetadata:ReconfirmMoments(root, prop_id, ged) + local revision = GetAnimationMomentsEditorObject().AnimRevision + for _, moment in ipairs(self.Moments or empty_table) do + if moment.AnimRevision ~= revision then + moment.AnimRevision = revision + ObjModified(moment) + end + end + ObjModified(self) + ObjModified(ged:ResolveObj("Animations")) +end + +function AnimMetadata:GetError() + local entity = self.group + if not IsValidEntity(entity) then + return "No such entity " .. (entity or "") + end + local state = self.id + if not HasState(entity, state) then + return "No such anim " .. entity .. "." .. (state or "") + end +end + +----- AnimMetadata remove group and id properties + +table.insert(AnimMetadata.properties, { id = "Id", editor = "text", no_edit = true }) +table.insert(AnimMetadata.properties, { id = "Group", editor = "text", no_edit = true }) + +DefineClass.Appearance = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { category = "Body", id = "Body", name = "Body", + editor = "combo", default = false, items = function (self) return GetCharacterBodyComboItems() end, }, + { category = "Body", id = "BodyColor", name = "Body Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Head", id = "Head", name = "Head", + editor = "combo", default = false, items = function (self) return GetCharacterHeadComboItems(self) end, }, + { category = "Head", id = "HeadColor", name = "Head Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Shirt", id = "Shirt", name = "Shirt", + editor = "combo", default = false, items = function (self) return GetCharacterShirtComboItems(self) end, }, + { category = "Shirt", id = "ShirtColor", name = "Shirt Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Pants", id = "Pants", name = "Pants", + editor = "combo", default = false, items = function (self) return GetCharacterPantsComboItems(self) end, }, + { category = "Pants", id = "PantsColor", name = "Pants Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Armor", id = "Armor", name = "Armor", + editor = "combo", default = false, items = function (self) return GetCharacterArmorComboItems(self) end, }, + { category = "Armor", id = "ArmorColor", name = "Armor Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Chest", id = "Chest", name = "Chest", + editor = "combo", default = false, items = function (self) return GetCharacterChestComboItems(self) end, }, + { category = "Chest", id = "ChestSpot", name = "Chest Spot", help = "Where to attach the hat", + editor = "combo", default = "Torso", items = function (self) return {"Torso", "Origin"} end, }, + { category = "Chest", id = "ChestColor", name = "Chest Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Hip", id = "Hip", name = "Hip", + editor = "combo", default = false, items = function (self) return GetCharacterHipComboItems(self) end, }, + { category = "Hip", id = "HipSpot", name = "Hip Spot", help = "Where to attach the hat", + editor = "combo", default = "Groin", items = function (self) return {"Groin", "Origin"} end, }, + { category = "Hip", id = "HipColor", name = "Hip Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Hat", id = "Hat", name = "Hat", + editor = "combo", default = false, items = function (self) return GetCharacterHatComboItems() end, }, + { category = "Hat", id = "HatSpot", name = "Hat Spot", help = "Where to attach the hat", + editor = "combo", default = "Head", items = function (self) return {"Head", "Origin"} end, }, + { category = "Hat", id = "HatAttachOffsetX", name = "Hat Attach Offset X", + editor = "number", default = false, scale = "cm", slider = true, min = -50, max = 50, }, + { category = "Hat", id = "HatAttachOffsetY", name = "Hat Attach Offset Y", + editor = "number", default = false, scale = "cm", slider = true, min = -50, max = 50, }, + { category = "Hat", id = "HatAttachOffsetZ", name = "Hat Attach Offset Z", + editor = "number", default = false, scale = "cm", slider = true, min = -50, max = 50, }, + { category = "Hat", id = "HatAttachOffsetAngle", name = "Hat Attach Offset Angle", + editor = "number", default = false, scale = "deg", slider = true, min = -18000, max = 10800, }, + { category = "Hat", id = "HatColor", name = "Hat Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Hat", id = "Hat2", name = "Hat2", + editor = "combo", default = false, items = function (self) return GetCharacterHatComboItems() end, }, + { category = "Hat", id = "Hat2Spot", name = "Hat 2 Spot", help = "Where to attach the hat", + editor = "combo", default = "Head", items = function (self) return {"Head", "Origin"} end, }, + { category = "Hat", id = "Hat2AttachOffsetX", name = "Hat 2 Attach Offset X", + editor = "number", default = false, scale = "cm", slider = true, min = -50, max = 50, }, + { category = "Hat", id = "Hat2AttachOffsetY", name = "Hat 2 Attach Offset Y", + editor = "number", default = false, scale = "cm", slider = true, min = -50, max = 50, }, + { category = "Hat", id = "Hat2AttachOffsetZ", name = "Hat 2 Attach Offset Z", + editor = "number", default = false, scale = "cm", slider = true, min = -50, max = 50, }, + { category = "Hat", id = "Hat2AttachOffsetAngle", name = "Hat 2 Attach Offset Angle", + editor = "number", default = false, scale = "deg", slider = true, min = -18000, max = 10800, }, + { category = "Hat", id = "Hat2Color", name = "Hat 2 Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Hair", id = "Hair", name = "Hair", + editor = "combo", default = false, items = function (self) return GetCharacterHairComboItems(self) end, }, + { category = "Hair", id = "HairSpot", name = "Hair Spot", help = "Where to attach the hat", + editor = "combo", default = "Head", items = function (self) return {"Head"} end, }, + { category = "Hair", id = "HairColor", name = "Hair Color", + editor = "nested_obj", default = false, base_class = "ColorizationPropSet", }, + { category = "Hair", id = "HairParam1", name = "Hair Spec Strength", + editor = "number", default = 51, slider = true, min = 0, max = 255, }, + { category = "Hair", id = "HairParam2", name = "Hair Env Strength", + editor = "number", default = 51, slider = true, min = 0, max = 255, }, + { category = "Hair", id = "HairParam3", name = "Hair Light Softness", + editor = "number", default = 255, slider = true, min = 0, max = 255, }, + { category = "Hair", id = "HairParam4", name = "Hair Specular Colorization", + editor = "number", default = 0, no_edit = true, slider = true, min = 0, max = 255, }, + }, +} + +DefineClass.AppearancePreset = { + __parents = { "Preset", "Appearance", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "ViewInChararacterEditorButton", + editor = "buttons", default = false, buttons = { {name = "View in Anim Metadata Editor", func = "ViewInAnimMetadataEditor"}, }, }, + { id = "ViewInAnimMetadataEditor", + editor = "func", default = function (self) +CloseAnimationMomentsEditor() -- close if opened +OpenAnimationMomentsEditor(self.id) +end, no_edit = true, }, + }, + GlobalMap = "AppearancePresets", + EditorMenubarName = "Appearance Editor", + EditorIcon = "CommonAssets/UI/Icons/business compare decision direction marketing.png", + EditorMenubar = "Characters", + EditorCustomActions = { + { + FuncName = "RefreshApperanceToAllUnits", + Icon = "CommonAssets/UI/Ged/play", + Menubar = "Actions", + Name = "Apply to All", + Rollover = "Refreshes all units on map with this appearance", + Toolbar = "main", + }, +}, +} + +function AppearancePreset:GetError() + local parts = table.copy(AppearanceObject.attached_parts) + table.insert(parts, "Body") + local results = {} + for _, part in ipairs(parts) do + if self[part] and self[part] ~= "" and not IsValidEntity(self[part]) then + results[#results+1] = string.format("%s: invalid entity %s", part, self[part]) + end + end + if next(results) then + return table.concat(results, "\n") + end +end + +DefineClass.BadgePresetDef = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "HasArrow", name = "HasArrow", + editor = "bool", default = false, }, + { id = "ArrowTemplate", name = "ArrowTemplate", + editor = "combo", default = false, items = function (self) return XTemplateCombo("XBadgeArrow", false) end, }, + { id = "UITemplate", name = "UITemplate", help = "The UI template which defines how the badge will look above the object.", + editor = "combo", default = false, items = function (self) return XTemplateCombo() end, }, + { id = "ZoomUI", name = "Zoom UI", + editor = "bool", default = false, }, + { id = "EntityName", name = "EntityName", help = "The entity to spawn as a badge on the unit, if any.", + editor = "combo", default = false, items = function (self) +return table.keys2(table.filter(GetAllEntities(), function(e) return e:sub(1, 2) == "Iw" end, "none")) +end, }, + { id = "AttachSpotName", name = "Attach Spot Name", help = "If set the badge will attach to the spot with that name.", + editor = "text", default = false, }, + { id = "attachOffset", name = "Entity Attach Offset", help = "An offset from the specified point to attach the badge to. Will overwrite any such offsets from the entity class.", + editor = "point", default = false, }, + { id = "noRotate", name = "Don't Rotate Arrow", help = "If set the arrow template will not be rotated according to the direction of the target but just stick on the edge of the screen.", + editor = "bool", default = false, }, + { id = "noHide", name = "Don't Hide", help = "Don't hide this badge if there are other badges on the target. Badges marked as \"noHide\" also do not count towards \"other badges on the target\".", + editor = "bool", default = false, }, + { id = "handleMouse", name = "Handle Mouse", help = "If enabled the badge will have a thread running to make sure it can handle mouse events like a normal UI window.", + editor = "bool", default = false, }, + { id = "BadgePriority", name = "BadgePriority", + editor = "number", default = false, }, + }, + GlobalMap = "BadgePresetDefs", +} + +DefineClass.BindingsMenuCategory = { + __parents = { "ListPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "Name", + editor = "text", default = false, translate = true, }, + }, +} + +DefineClass.BugReportTag = { + __parents = { "ListPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "Platform", help = "Whether this is a Platform tag.", + editor = "bool", default = false, }, + { id = "Automatic", + editor = "bool", default = false, }, + { id = "ShowInExternal", name = "Show in External Bug Report", + editor = "bool", default = false, }, + }, +} + +DefineClass.Camera = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "Preset", id = "comment", name = "comment", + editor = "text", default = "Camera", }, + { category = "Preset", id = "display_name", name = "Display Name", + editor = "text", default = T(145449857928, --[[PresetDef Camera default]] "Camera"), translate = true, }, + { category = "Preset", id = "description", name = "Description", + editor = "text", default = false, translate = true, lines = 3, max_lines = 10, }, + { category = "Camera", id = "map", name = "Map", + editor = "combo", default = false, + cam_prop = true, items = function (self) return ListMaps() end, }, + { category = "Camera", id = "SavedGame", name = "Saved Game", + editor = "text", default = false, }, + { category = "Camera", id = "order", name = "Order", + editor = "number", default = 0, }, + { category = "Camera", id = "locked", name = "Locked", + editor = "bool", default = false, + cam_prop = true, }, + { category = "Camera", id = "flip_to_adjacent", name = "Flip to Adjacent", + editor = "bool", default = false, }, + { category = "Camera", id = "fade_in", name = "Fade In", + editor = "number", default = 200, }, + { category = "Camera", id = "fade_out", name = "Fade Out", + editor = "number", default = 200, }, + { category = "Camera", id = "movement", name = "Movement", + editor = "combo", default = "", items = function (self) return table.keys2(CameraMovementTypes, nil, "") end, }, + { category = "Camera", id = "interpolation", name = "Interpolation", + editor = "combo", default = "linear", items = function (self) return table.keys2(CameraInterpolationTypes) end, }, + { category = "Camera", id = "duration", name = "Duration", + editor = "number", default = 1000, }, + { category = "Camera", id = "buttonsSrc", + editor = "buttons", default = false, buttons = { {name = "View Start", func = "ViewStart"}, {name = "Set Start", func = "SetStart"}, }, }, + { category = "Camera", id = "cam_lookat", + editor = "point", default = false, + cam_prop = true, }, + { category = "Camera", id = "cam_pos", + editor = "point", default = false, + cam_prop = true, }, + { category = "Camera", id = "buttonsDest", + editor = "buttons", default = false, buttons = { {name = "View Dest", func = "ViewDest"}, {name = "Set Dest", func = "SetDest"}, }, }, + { category = "Camera", id = "cam_dest_lookat", + editor = "point", default = false, + cam_prop = true, }, + { category = "Camera", id = "cam_dest_pos", + editor = "point", default = false, + cam_prop = true, }, + { category = "Camera", id = "cam_type", + editor = "choice", default = "Max", items = function (self) return GetCameraTypesItems end, }, + { category = "Camera", id = "fovx", + editor = "number", default = 4200, + cam_prop = true, }, + { category = "Camera", id = "zoom", + editor = "number", default = 2000, + cam_prop = true, }, + { category = "Camera", id = "lightmodel", name = "Light Model", help = "Specify a light model, or leave as 'false' to restore the previous one.", + editor = "preset_id", default = false, preset_class = "LightmodelPreset", }, + { category = "Camera", id = "interface", name = "Interface in Screenshots", help = "Check this to include game interface in the screenshots", + editor = "bool", default = false, }, + { category = "Camera", id = "cam_props", + editor = "prop_table", default = false, + cam_prop = true, indent = "", lines = 1, max_lines = 20, }, + { category = "Camera", id = "camera_properties", name = "Camera Properties", + editor = "prop_table", default = false, no_edit = true, indent = "", lines = 1, max_lines = 20, }, + { category = "Functions", id = "beginFunc", name = "Begin() Function", + editor = "func", default = function (self) end, }, + { category = "Functions", id = "endFunc", name = "End() Function", + editor = "func", default = function (self) end, }, + }, + GlobalMap = "PredefinedCameras", + EditorMenubarName = "Camera Editor", + EditorIcon = "CommonAssets/UI/Icons/outline video.png", + EditorMenubar = "Map", + EditorCustomActions = { + { + FuncName = "OpenShowcase", + Icon = "CommonAssets/UI/Ged/play", + Menubar = "Actions", + Name = "ShowcaseUI", + Rollover = 'Showcase UI Toggles "Show Case" interface showing all cameras from a group, sorted by order', + Toolbar = "main", + }, + { + FuncName = "GedOpCreateCameraDest", + Icon = "CommonAssets/UI/Ged/create_camera_destination", + Menubar = "Actions", + Name = "CameraDest", + Rollover = "Create Camera Destination", + Toolbar = "main", + }, + { + FuncName = "GedOpUpdateCamera", + Icon = "CommonAssets/UI/Ged/update_current_camera", + Menubar = "Actions", + Name = "UpdateCamera", + Rollover = "Update Camera", + Toolbar = "main", + }, + { + FuncName = "GedOpViewMovement", + Icon = "CommonAssets/UI/Ged/preview", + IsToggledFuncName = "GedOpIsViewMovementToggled", + Menubar = "Actions", + Name = "ViewMovement", + Rollover = "View Movement", + Toolbar = "main", + }, + { + FuncName = "GedOpUnlockCamera", + Icon = "CommonAssets/UI/Ged/unlock_camera", + Menubar = "Actions", + Name = "UnlockCamera", + Rollover = "Unlock Camera", + Toolbar = "main", + }, + { + FuncName = "GedOpMaxCamera", + Icon = "CommonAssets/UI/Ged/max_camera", + Menubar = "Camera", + Name = "MaxCamera", + Rollover = "Max Camera", + Toolbar = "main", + }, + { + FuncName = "GedOpRTSCamera", + Icon = "CommonAssets/UI/Ged/rts_camera", + Menubar = "Camera", + Name = "RTSCamera", + Rollover = "RTS Camera", + Toolbar = "main", + }, + { + FuncName = "GedOpTacCamera", + Icon = "CommonAssets/UI/Ged/tac_camera", + Menubar = "Camera", + Name = "TacCamera", + Rollover = "Tac Camera", + Toolbar = "main", + }, + { + FuncName = "GedOpCreateReferenceImages", + Icon = "CommonAssets/UI/Ged/create_reference_images", + Menubar = "Actions", + Name = "CreateReferenceImages", + Rollover = "Create Reference Images(used during Night Build Game Tests)", + Toolbar = "main", + }, + { + FuncName = "GedOpTakeScreenshots", + Icon = "CommonAssets/UI/Ged/camera", + Menubar = "Actions", + Name = "TakeScreenshot", + Rollover = "Takes screenshot of the selected camera(s)", + Toolbar = "main", + }, + { + FuncName = "GedPrgPresetToggleStrips", + Icon = "CommonAssets/UI/Ged/explorer", + IsToggledFuncName = "GedPrgPresetBlackStripsVisible", + Menubar = "Actions", + Name = "ToggleBlackStrips", + Rollover = "Toggle black strips (Alt-T)", + Shortcut = "Alt-T", + SortKey = "strips", + Toolbar = "main", + }, +}, +} + +function Camera:ApplyProperties(dont_lock, should_fade, ged) + if (self.SavedGame or "") ~= "" then + if (SavegameMeta or empty_table).savename ~= self.SavedGame then + if not ged or ged:WaitQuestion("Load Game", "Load camera save?", "Yes", "No") == "ok" then + LoadGame(self.SavedGame) + end + end + elseif (self.map or "") ~= "" then + if GetMapName() ~= self.map then + if ged then + if ged:WaitQuestion("Change Map", "Change camera map?", "Yes", "No") == "ok" then + ChangeMap(self.map) + else + return + end + else + ChangeMap(self.map) + end + end + end + SetCamera(self.cam_pos, self.cam_lookat, self.cam_type, self.zoom, self.cam_props, self.fovx) + if self.locked and not dont_lock then + LockCamera("CameraPreset") + else + UnlockCamera("CameraPreset") + end + if should_fade and self.fade_in > 0 then + local fade_in = should_fade and self.fade_in or 0 + local fade = OpenDialog("Fade") + fade.idFade:SetVisible(true, "instant") + if should_fade then + WaitResourceManagerRequests(1000,1) + end + fade.idFade.FadeOutTime = should_fade and self.fade_in or 0 + fade.idFade:SetVisible(false) + end + if self.movement ~= "" then + local camera1 = { pos = self.cam_pos, lookat = self.cam_lookat } + local camera2 = { pos = self.cam_dest_pos, lookat = self.cam_dest_lookat } + InterpolateCameraMaxWakeup(camera1, camera2, self.duration, nil, self.interpolation, self.movement) + end + if self.lightmodel then + SetLightmodel(1, self.lightmodel) + end + self:beginFunc() + if should_fade and self.fade_in > 0 then + local fade_in = should_fade and self.fade_in or 0 + if self.movement == "" then + Sleep(fade_in) + elseif fade_in > self.duration then + Sleep(fade_in - self.duration) + end + end +end + +function Camera:RevertProperties(should_fade) + if should_fade and self.fade_out > 0 then + local fade_out = should_fade and self.fade_out or 0 + local fade = GetDialog("Fade") + if fade then + fade.idFade:SetVisible(false, "instant") + fade.idFade.FadeInTime = fade_out + fade.idFade:SetVisible(true) + Sleep(fade_out) + end + end + CloseDialog("Fade") + self:endFunc() +end + +function Camera:QueryProperties() + local cam_pos, cam_lookat, cam_type, zoom, cam_props, fovx = GetCamera() + if cam_type ~= "Max" then + self.movement = "" + end + self.cam_pos = cam_pos + self.cam_lookat = cam_lookat + self.cam_type = cam_type + self.zoom = zoom + self.cam_props = cam_props + self.locked = camera.IsLocked() + self.fovx = fovx + self.map = GetMapName() + GedObjectModified(self) +end + +function Camera:PostLoad() + Preset.PostLoad(self) + -- old format compatibility code: + local cam_props = self.camera_properties or empty_table + for _, prop in ipairs(self:GetProperties()) do + if prop.cam_prop then + local value = cam_props[prop.id] + if value ~= nil then + self[prop.id] = value + end + end + end + self.camera_properties = nil +end + +function Camera:SetStart() + local cam_pos, cam_lookat = GetCamera() + self:SetProperty("cam_pos", cam_pos) + self:SetProperty("cam_lookat", cam_lookat) + ObjModified(self) +end + +function Camera:ViewStart() + SetCamera(self.cam_pos, self.cam_lookat, self.cam_type, self.zoom, self.cam_props, self.fovx) +end + +function Camera:SetDest() + local cam_pos, cam_lookat = GetCamera() + self:SetProperty("cam_dest_pos", cam_pos) + self:SetProperty("cam_dest_lookat", cam_lookat) + self:SetProperty("cam_type", "Max") -- movement forces Max camera + ObjModified(self) +end + +function Camera:ViewDest(camera) + local pos = self.cam_dest_pos or self.cam_pos + local lookat = self.cam_dest_lookat or self.cam_lookat + SetCamera(pos, lookat, self.cam_type, self.zoom, self.cam_props, self.fovx) +end + +function Camera:GetEditorView() + if tonumber(self.order) then + return Untranslated(" (Showcase: #)") + else + return Untranslated(" ") + end +end + +function Camera:OnEditorNew(parent, ged, is_paste) + self.order = (#Presets.Camera[self.group] or 0) + 1 + self:SetId(self:GenerateUniquePresetId("Camera_" .. self.order)) + self:QueryProperties() +end + +DefineClass.CheatDef = { + __parents = { "DisplayPreset", "TODOPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "in_menu", name = "Show in menu", + editor = "combo", default = "", items = function (self) return {""} end, show_recent_items = 10,}, + { id = "modes", name = "Menu modes", + editor = "string_list", default = {}, dont_save = function(self) return self.in_menu == "" end, no_edit = function(self) return self.in_menu == "" end, item_default = "", items = false, arbitrary_value = true, }, + { category = "Execution", id = "params", name = "Parameters", help = 'Cheats without parameters or with parameters "Selection" appear in the game cheats menu', + editor = "combo", default = "", items = function (self) return {"", "SelectedObj", "Selection"} end, }, + { category = "Execution", id = "param_values", name = "Parameter values", + editor = "expression", default = false, dont_save = function(self) local params = self.params:trim_spaces() return params == "" or params == "Selection" or params == "SelectedObj" end, no_edit = function(self) local params = self.params:trim_spaces() return params == "" or params == "Selection" or params == "SelectedObj" end, }, + { category = "Execution", id = "sync", name = "Sync", + editor = "bool", default = true, }, + { category = "Execution", id = "run", name = "Run", + editor = "func", default = function (self) end, + params = function(obj) return obj:GetParamNames() end, }, + { category = "Execution", id = "state", name = "State", help = 'Can return "hidden", "disabled" or nil', + editor = "func", default = function (self) end, + params = function(obj) return obj:GetParamNames() end, }, + }, + GlobalMap = "CheatDefs", + EditorMenubarName = "Cheats", + EditorMenubar = "Editors.Engine", +} + +function CheatDef:GetParamNames() + local params = self.params:trim_spaces() + if params == "" then return "self" end + if params == "SelectedObj" then return "self, obj" end + if params == "Selection" then return "self, objs" end + return "self, " .. self.params +end + +function CheatDef:GetParams() + local params = self.params:trim_spaces() + if params == "" then return end + if params == "SelectedObj" then return SelectedObj end + if params == "Selection" then return Selection end + if self.param_values then + return self:param_values() + end +end + +function CheatDef:Exec() + if self.sync then + NetSyncEvent("CheatDef", self.id, self:GetParams()) + else + self:run(self:GetParams()) + end +end + +function CheatDef:SpawnAction(host) + XAction:new({ + ActionMode = table.concat(self.modes,","), + ActionMenubar = self.in_menu ~= "" and self.in_menu or nil, + ActionName = self:GetDisplayName(), + ActionDescription = self:GetDescription(), + ActionSortKey = string.format("%09d", self.SortKey or 0), + ActionTranslate = true, + ActionState = function(self, host) + return self.cheat:state(self.cheat:GetParams()) + end, + OnAction = function (self, host, source, ...) + self.cheat:Exec() + end, + cheat = self, + }, host) +end + +----- CheatDef + +function NetSyncEvents.CheatDef(id, ...) + local def = CheatDefs[id] + if not AreCheatsEnabled() or not def then return end + print("Cheat", def.id) + LogCheatUsed(def.id) + def:run(...) +end + +function OnMsg.Shortcuts(host) + ForEachPreset("CheatDef", function(preset, group, host) + if preset.in_menu ~= "" then + preset:SpawnAction(host) + end + end, host) +end + +function OnMsg.PresetSave(class) + if IsKindOf(g_Classes[class], "CheatDef") then + ReloadShortcuts() + end +end + +DefineClass.CommonTags = { + __parents = { "ListPreset", }, + __generated_by_class = "PresetDef", + +} + +DefineClass.DisplayPreset = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "display_name", name = "Display Name", + editor = "text", default = "", translate = true, }, + { category = "General", id = "display_name_caps", name = "Display Name Caps", + editor = "text", default = "", translate = true, }, + { category = "General", id = "description", name = "Description", + editor = "text", default = "", translate = true, lines = 3, max_lines = 20, }, + { category = "General", id = "description_gamepad", name = "Gamepad description", + editor = "text", default = "", translate = true, wordwrap = true, lines = 3, max_lines = 20, }, + { category = "General", id = "flavor_text", name = "Flavor text", + editor = "text", default = "", translate = true, lines = 3, max_lines = 20, }, + { category = "General", id = "new_in", name = "New in update", help = 'Update showing this preset with a "New!" tag', + editor = "text", default = "", no_edit = function(self) return not self.NewFeatureTag end, }, + }, + AltFormat = "", + EditorMenubarName = false, + __hierarchy_cache = true, + DescriptionFlavor = T(334322641039, --[[PresetDef DisplayPreset value]] ""), + NewFeatureTag = T(820790154429, --[[PresetDef DisplayPreset value]] "NEW! "), +} + +function DisplayPreset:GetDisplayName() + if self.new_in == config.NewFeaturesUpdate and self.NewFeatureTag then + return self.NewFeatureTag .. self.display_name + else + return self.display_name + end +end + +function DisplayPreset:GetDisplayNameCaps() + if self.new_in == config.NewFeaturesUpdate and self.NewFeatureTag then + return self.NewFeatureTag .. self.display_name_caps + else + return self.display_name_caps + end +end + +function DisplayPreset:GetDescription() + local description = self.description + if GetUIStyleGamepad() and self.description_gamepad ~= "" then + description = self.description_gamepad + end + + if self.flavor_text == "" or not self.DescriptionFlavor then + return description + else + return T{self.DescriptionFlavor, self, description = description} + end +end + +function DisplayPreset:OnEditorNew() + self.new_in = config.NewFeaturesUpdate or nil +end + +----- DisplayPreset DisplayPresetCombo + +function DisplayPresetCombo(class, filter, ...) + local params = pack_params(...) + return function() + return ForEachPreset(class, function(preset, group, items, filter, params) + if not filter or filter(preset, unpack_params(params)) then + items[#items + 1] = { value = preset.id, text = preset:GetDisplayName() } + end + end, {}, filter, params) + end +end + +DefineClass.GameDifficultyDef = { + __parents = { "MsgReactionsPreset", "DisplayPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "effects", name = "Effects on NewMapLoaded", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, auto_expand = true, }, + }, + HasGroups = false, + HasSortKey = true, + HasParameters = true, + GlobalMap = "GameDifficulties", + EditorMenubarName = "Game Difficulty", + EditorIcon = "CommonAssets/UI/Icons/bullet list.png", + EditorMenubar = "Editors.Lists", + Documentation = "Creates a custom game difficulty and sets custom effects on the loaded map.", +} + +DefineModItemPreset("GameDifficultyDef", { EditorName = "Game difficulty", EditorSubmenu = "Gameplay" }) + +----- GameDifficultyDef + +function GetGameDifficulty() + local game = Game + return game and game.game_difficulty +end + +function OnMsg.NewMapLoaded() + if not mapdata.GameLogic or not Game then return end + local difficulty = GameDifficulties[GetGameDifficulty()] + ExecuteEffectList(difficulty and difficulty.effects, Game) +end + +function AddDifficultyLootConditions() + ForEachPreset("GameDifficultyDef", function(preset, group) + local difficulty = preset.id + LootCondition["Difficulty " .. difficulty] = function() return (Game and Game.game_difficulty) == difficulty end + end) +end + +OnMsg.PresetSave = AddDifficultyLootConditions +OnMsg.DataLoaded = AddDifficultyLootConditions + +DefineClass.GameRuleDef = { + __parents = { "MsgReactionsPreset", "DisplayPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "init_as_active", name = "Active by default", + editor = "bool", default = false, }, + { category = "General", id = "option", name = "Add as option", + editor = "bool", default = false, }, + { id = "option_id", name = "Option Id", + editor = "text", default = false, + no_edit = function(self) return not self.option end, }, + { category = "General", id = "exclusionlist", name = "Exclusion List", help = "List of other game rules that are not compatible with this one. If this rule is active the player won't be able to enable the rules in the exclusion list.", + editor = "preset_id_list", default = {}, preset_class = "GameRuleDef", item_default = "", }, + { id = "effects", name = "Effects on NewMapLoaded", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + }, + HasGroups = false, + HasSortKey = true, + HasParameters = true, + GlobalMap = "GameRuleDefs", + EditorMenubarName = "Game Rules", + EditorIcon = "CommonAssets/UI/Icons/bullet list.png", + EditorMenubar = "Editors.Lists", + Documentation = "Defines a new game rule that can be activated by players when starting a new game.", +} + +DefineModItemPreset("GameRuleDef", { EditorName = "Game rule", EditorSubmenu = "Gameplay" }) + +function GameRuleDef:IsCompatible(active_rules) + local exclusions = table.invert(self.exclusionlist or empty_table) + local id = self.id + for rule_id in pairs(active_rules) do + if exclusions[rule_id] or table.find(GameRuleDefs[rule_id].exclusionlist or empty_table, id) then + return false + end + end + return true +end + +----- GameRuleDef + +function IsGameRuleActive(rule_id) + local game = Game + return (game and game.game_rules or empty_table)[rule_id] +end + +function OnMsg.NewMapLoaded() + if not mapdata.GameLogic or not Game then return end + ForEachPreset("GameRuleDef", function(rule) + if IsGameRuleActive(rule.id) then + ExecuteEffectList(rule.effects, Game) + end + end) +end + +DefineClass.GameStateDef = { + __parents = { "DisplayPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "ShowInFilter", name = "Show in filters", + editor = "bool", default = true, }, + { id = "PlayFX", name = "Play FX", + editor = "bool", default = true, }, + { id = "MapState", name = "Is map state", help = "Map states are removed when exiting a map", + editor = "bool", default = true, }, + { id = "PersistInSaveGame", name = "Persist in save game", + editor = "bool", default = false, }, + { id = "Gossip", name = "Gossip", help = "Send gossip on state change", + editor = "bool", default = false, }, + { id = "GroupExclusive", name = "Exclusive for the group", help = "When set, removes any other game states from the same group", + editor = "bool", default = false, }, + { id = "Color", name = "Color", help = "Used for easier visual identification of the game state in editors", + editor = "color", default = 4278190080, }, + { id = "Icon", name = "Icon", + editor = "ui_image", default = false, }, + { id = "AutoSet", name = "Auto set", help = "State is recalculated on every GameStateChange", + editor = "nested_list", default = false, base_class = "Condition", }, + { category = "Debug", id = "CurrentState", name = "Current state", + editor = "bool", default = false, dont_save = true, }, + }, + HasSortKey = true, + GlobalMap = "GameStateDefs", + EditorMenubarName = "Game States", + EditorMenubar = "Editors.Lists", + EditorViewPresetPostfix = Untranslated(""), +} + +function GameStateDef:GetCurrentState() + return GameState[self.id] +end + +function GameStateDef:SetCurrentState(state) + return ChangeGameState(self.id, state) +end + +function GameStateDef:OnDataUpdated() + RebuildAutoSetGameStates() +end + +----- GameStateDef PlayFX + +function OnMsg.GameStateChanged(changed) + local fx + for state, active in pairs(changed) do + local def = GameStateDefs[state] + if def and def.PlayFX then + fx = fx or {} + fx[#fx + 1] = state + fx[state] = active + end + end + if not fx then return end + table.sort(fx) + -- as certain FX depend on game states, delay the actual FX trigger to allow all of the game state changes to be invoked + CreateGameTimeThread(function(fx) + for _, state in ipairs(fx) do + if fx[state] then + PlayFX(state, "start") + else + PlayFX(state, "end") + end + end + end, fx) +end + +function OnMsg.GatherFXActions(list) + ForEachPreset("GameStateDef", function(state, group, list) + if state.PlayFX then + list[#list + 1] = state.id + end + end, list) +end + +----- GameStateDef GetGameStateFilter + +function GetGameStateFilter() + return ForEachPreset("GameStateDef", function(state, group, items) + if state.ShowInFilter then + items[#items + 1] = state.id + end + end, {}) +end + +----- GameStateDef MapState + +function OnMsg.DoneMap() + local map_states = ForEachPreset("GameStateDef", function(state, group, map_states, GameState) + if state.MapState and GameState[state.id] then + map_states[state.id] = false + end + end, {}, GameState) + ChangeGameState(map_states) +end + +----- GameStateDef Persist + +function OnMsg.PersistSave(data) + local persisted_gamestate = {} + ForEachPreset("GameStateDef", function(state, group, persisted_gamestate, GameState) + if state.PersistInSaveGame and GameState[state.id] then + persisted_gamestate[state.id] = true + end + end, persisted_gamestate, GameState) + if next(persisted_gamestate) then + data.GameState = persisted_gamestate + end +end + +function OnMsg.PersistLoad(data) + local persisted_gamestate = data.GameState or empty_table + ForEachPreset("GameStateDef", function(state, group, persisted_gamestate, GameState) + if state.PersistInSaveGame then + GameState[state.id] = persisted_gamestate[state.id] or false + end + end, persisted_gamestate, GameState) +end + +DefineClass.LootDef = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "Loot", id = "loot", name = "Loot", + editor = "choice", default = "random", items = function (self) return {"random", "all", "first", "cycle", "each then last"} end, }, + { category = "Test", id = "TestDlcs", name = "Test dlcs", + editor = "set", default = set(), dont_save = true, items = function (self) return DlcComboItems() end, }, + { category = "Test", id = "TestConditions", name = "Loot conditions", + editor = "set", default = set(), dont_save = true, items = function (self) return table.keys2(LootCondition, true) end, }, + { category = "Test", id = "TestGameConditions", name = "Test Additional Conditions", help = "If not set the additional conditions are ignored during testing. \nIf set, the additional conditions are evaluated against the current state of the game. Therefore test results can change when the current game state changes.", + editor = "bool", default = false, }, + { category = "Test", id = "TestFile", name = "Output CSV", + editor = "text", default = "svnProject/items.csv", dont_save = true, buttons = { {name = "Write", func = "WriteChancesCSV"}, }, }, + { category = "Test", id = "TestResults", name = "Test results", + editor = "text", default = false, dont_save = true, read_only = true, lines = 1, max_lines = 30, }, + }, + GlobalMap = "LootDefs", + ContainerClass = "LootDefEntry", + EditorMenubarName = "Loot Tables", + EditorShortcut = "Ctrl-L", + EditorIcon = "CommonAssets/UI/Icons/currency dollar finance money payment.png", + EditorView = Untranslated(""), + EditorPreview = Untranslated(""), + Documentation = "Creates a new loot definition that contains possible items to drop.", +} + +DefineModItemPreset("LootDef", { EditorName = "Loot definition", EditorSubmenu = "Gameplay" }) + +function LootDef:GenerateLootSeed(init_seed, looter, looted) + local loot, seed = self.loot + if loot == "cycle" or loot == "each then last" then + seed = (init_seed == -1) and InteractionRand(nil, "Loot", looter, looted) or (init_seed + 1) + seed = (loot == "each then last") and Min(seed, #self) or seed + else + seed = InteractionRand(nil, "Loot", looter, looted) + end + + return seed +end + +function LootDef:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + local rand, loot + seed = seed or self:GenerateLootSeed(nil, looter, looted) + NetUpdateHash("LootDef:GenerateLoot", seed) + + if self.loot == "random" then + local weight, none_weight = 0, 0 + for _, entry in ipairs(self) do + if entry:TestConditions(looter, looted) then + if entry.class == "LootEntryNoLoot" then + none_weight = none_weight + entry.weight + else + weight = weight + entry.weight + end + end + end + rand, seed = BraidRandom(seed, weight + none_weight) + if rand >= weight then return end + for _, entry in ipairs(self) do + if entry.class ~= "LootEntryNoLoot" and entry:TestConditions(looter, looted) then + rand = rand - entry.weight + if rand < 0 then + loot = entry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + break + end + end + end + elseif self.loot == "all" then + for _, entry in ipairs(self) do + rand, seed = BraidRandom(seed) + if entry:TestConditions(looter, looted) then + local entry_loot = entry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + loot = loot or entry_loot + end + end + elseif self.loot == "first" then + for _, entry in ipairs(self) do + if entry:TestConditions(looter, looted) then + loot = entry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + break + end + end + elseif self.loot == "cycle" then + local start_idx = 1 + seed % #self + local idx = start_idx + local entry = self[idx] + local entry_ok = entry:TestConditions(looter, looted) + while not entry_ok do + idx = (idx < #self) and (idx + 1) or 1 + entry = self[idx] + entry_ok = (idx ~= start_idx) and entry:TestConditions(looter, looted) + end + if entry_ok then + loot = entry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + end + else -- if self.loot == "each then last" then + if seed < #self then + for idx = seed, #self do + local entry = self[idx] + if entry:TestConditions(looter, looted) then + loot = entry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + break + end + end + else + for idx = #self, 1, -1 do + local entry = self[idx] + if entry:TestConditions(looter, looted) then + loot = entry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + break + end + end + end + end + Msg("GenerateLoot", self, items, loot) + return loot +end + +function LootDef:ListChances(items, env, chance, amount_modifier) + if self.loot == "random" then + local weight = 0 + for _, entry in ipairs(self) do + if entry:ListChancesTest(env) then + weight = weight + entry.weight + end + end + if weight <= 0 then return end + for _, entry in ipairs(self) do + if entry.class ~= "LootEntryNoLoot" and entry:ListChancesTest(env) then + entry:ListChances(items, env, chance * entry.weight / weight, amount_modifier) + end + end + elseif self.loot == "all" then + for _, entry in ipairs(self) do + if entry:ListChancesTest(env) then + entry:ListChances(items, env, chance, amount_modifier) + end + end + else -- self.loot == "first" + for _, entry in ipairs(self) do + if entry:ListChancesTest(env) then + return entry:ListChances(items, env, chance, amount_modifier) + end + end + end +end + +function LootDef:SetTestDlcs(v) + LootTestDlcs=v +end + +function LootDef:GetTestDlcs() + return LootTestDlcs +end + +function LootDef:SetTestConditions(v) + LootTestConditions=v +end + +function LootDef:GetTestConditions() + return LootTestConditions +end + +function LootDef:SetTestFile(v) + LootTestFile=v +end + +function LootDef:GetTestFile() + return LootTestFile +end + +function LootDef:WriteChancesCSV(root) + local item_list = root:GetTestItems() + SaveCSV(LootTestFile, item_list, nil, {"Chance (%)", "Item"}) +end + +function LootDef:GetTestItems() + local env = { + dlcs = {[""] = true}, + conditions = {[""] = true}, + } + for v in pairs(LootTestDlcs) do + env.dlcs[v] = true + end + for v in pairs(LootTestConditions) do + env.conditions[v] = true + end + env.game_conditions = self.TestGameConditions + + local items = {} + self:ListChances(items, env, 1.0) + local item_list = {} + for item, chance in pairs(items) do + if chance > 0.000000001 then + item_list[#item_list + 1] = { chance, item } + end + end + table.sort(item_list,function(a,b) + if a[1] == b[1] then + return a[2] < b[2] + end + return a[1]>b[1] + end) + return item_list +end + +function LootDef:GetTestResults() + local item_list = self:GetTestItems() + local nothing = 1.0 + for i, pair in ipairs(item_list) do + item_list[i] = string.format("%6.02f%% %s", pair[1] * 100, pair[2]) + nothing = nothing - pair[1] + end + if nothing > 0.0001 then + item_list[#item_list + 1] = string.format("%6.02f%% Nothing", nothing * 100) + end + return table.concat(item_list, "\n") +end + +function LootDef:GetPreview() + local texts = {} + for _, entry in ipairs(self) do + texts[#texts+1] = entry:GetEditorPreview() + end + return table.concat(texts, "; ") +end + +----- LootDef + +LootTestDlcs = {} +LootTestConditions = {} +LootTestFile = "svnProject/items.csv" + +----- LootDef mod item + +if config.Mods then + AppendClass.ModItemLootDef = { + properties = { + { id = "TestDlcs", }, + { id = "TestConditions", }, + { id = "TestGameConditions", }, + { id = "TestFile", }, + { id = "TestResults", }, + }, + } + + DefineClass.ModItemLootDefEdit = { + __parents = { "ModItemChangePropBase" }, + properties = { + { id = "TargetClass" }, + { id = "TargetProp" }, + { id = "EditType" }, + { id = "TargetFunc" }, + { category = "Mod", id = "TargetId", name = "Loot table", default = "", editor = "choice", items = function(self, prop_meta) return PresetsCombo(self.TargetClass)(self, prop_meta) end, no_edit = function(self) return not self:ResolveTargetMap() end, reapply = true }, + { category = "Change Property", id = "TargetValue", name = "Entries to append", default = false, editor = "bool", }, + }, + TargetClass = "LootDef", + TargetProp = "__children", + EditType = "Append To Table", + + EditorName = "Loot definition change", + EditorSubmenu = "Gameplay", + Documentation = "Appends entries to a specific Loot definition. The Test button will apply the change.", + } + + function ModItemLootDefEdit:GetModItemDescription() + if self.name == "" and self.TargetId ~= "" then + return Untranslated("[...]") + end + return self.ModItemDescription + end + + function ModItemLootDefEdit:ResolvePropTarget() + return { + id = "__children", + editor = "nested_list", + base_class = LootDef.ContainerClass, + default = false, + } + end + + function ModItemLootDefEdit:GetPropValue() + local preset = self:ResolveTargetPreset() + local result = {} + for i,child in ipairs(preset) do + result[#result + 1] = child:Clone() + end + return result + end + + function ModItemLootDefEdit:AssignValue(preset, value) + table.iclear(preset) + table.iappend(preset, value) + ModLogF("%s %s: %s[...] = %s", self.class, self.mod.title, self.TargetId, ValueToStr(value)) + end +end + +DefineClass.LootDefEntry = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { category = "Conditions", id = "disable", name = "Disable", + editor = "bool", default = false, }, + { category = "Conditions", id = "comment", name = "Comment", + editor = "text", default = false, }, + { category = "Conditions", id = "weight", name = "Weight", + editor = "number", default = 1000, scale = 1000, min = 0, max = 1000000000, }, + { category = "Conditions", id = "dlc", name = "Require dlc", + editor = "choice", default = "", items = function (self) return DlcComboItems() end, }, + { category = "Conditions", id = "negate", name = "Negate loot condition", + editor = "bool", default = false, }, + { category = "Conditions", id = "condition", name = "Loot condition", help = "Loot specific conditions (defined in LootConditions) such as game difficulty. These can be manipulated in Test section to simulate expected loot results.", + editor = "choice", default = "", items = function (self) return table.keys2(LootCondition, true) end, }, + { category = "Conditions", id = "game_conditions", name = "Additional conditions", + editor = "nested_list", default = false, base_class = "Condition", }, + }, + StoreAsTable = true, + EditorView = Untranslated("*** !','')>"), + EntryView = Untranslated(""), + EditorName = "Loot Entry", +} + +function LootDefEntry:GetEditorPreview() + local text =(T{self.EntryView, self}) + local txt + if self.weight~=1000 then + txt =(txt or "(")..Untranslated("w:"..self.weight) + end + if self.dlc~="" then + txt =(txt or "(")..Untranslated(" d:"..self.dlc) + end + + local condition_texts = {} + if self.condition~="" then + condition_texts[#condition_texts+1] = Untranslated((self.negate and " !" or " ") .. self.condition) + end + for _, condition in ipairs(self.game_conditions) do + condition_texts[#condition_texts+1] = Untranslated(_InternalTranslate(condition:GetEditorView(), condition, false)) + end + if next(condition_texts) then + txt = (txt or "(") ..table.concat(condition_texts, ";") + end + if txt then + text = text..txt..Untranslated(")") + end + return text +end + +function LootDefEntry:TestConditions(looter, looted) + if self.disable then return end + if not IsDlcAvailable(self.dlc) then + return false + end + local res = LootCondition[self.condition](looter, looted) + if self.negate then + res = not res + end + res = res and EvalConditionList(self.game_conditions, looter, looted) + return res +end + +function LootDefEntry:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + assert(false, self.class .. ":GenerateLoot() not implemented") +end + +function LootDefEntry:ListChancesTest(env) + if self.disable or not env.dlcs[self.dlc] then + return + end + local res = env.conditions[self.condition] + if self.negate then + res = not res + end + if env.game_conditions then + res = res and EvalConditionList(self.game_conditions) + end + return res +end + +function LootDefEntry:ListChances(items, env, chance, amount_modifier) + assert(false, self.class .. ":ListChances() not implemented") +end + +----- LootDefEntry LootCondition + +LootCondition = rawget(_G, "LootCondition") or { + [""] = function(looter, looted) return true end, +} +setmetatable(LootCondition, { + __index = function() return empty_func end, +}) + +DefineClass.LootEntryLootDef = { + __parents = { "LootDefEntry", }, + __generated_by_class = "ClassDef", + + properties = { + { category = "Loot", id = "loot_def", name = "Loot def", + editor = "preset_id", default = false, preset_class = "LootDef", }, + { category = "Loot", id = "amount_modifier", name = "Amount modifier", help = "Modifies the amount of resources generated from the loot", + editor = "number", default = 1000000, scale = 1000000, step = 100000, min = 1000, max = 1000000000, }, + }, + EntryView = Untranslated(" x"), + EditorName = "Invoke Loot Definition", +} + +function LootEntryLootDef:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + local loot_def = LootDefs[self.loot_def] + if not loot_def then return end + return loot_def:GenerateLoot(looter, looted, seed, items, modifiers, MulDivRound(amount_modifier or 1000000, self.amount_modifier, 1000000)) +end + +function LootEntryLootDef:ListChances(items, env, chance, amount_modifier) + local loot_def = LootDefs[self.loot_def] + if not loot_def then return end + local nesting = env.nesting or 0 + if nesting > 100 then + local item = "LootDef: " .. self.loot_def + items[item] = (items[item] or 0.0) + chance + return + end + env.nesting = nesting + 1 + loot_def:ListChances(items, env, chance, MulDivRound(amount_modifier or 1000000, self.amount_modifier, 1000000)) + assert(env.nesting == nesting + 1) + env.nesting = nesting +end + +function LootEntryLootDef:GetError() + local loot_def = GetParentTableOfKindNoCheck(self, "LootDef") + if loot_def and self.loot_def == loot_def.id then + return "Recursive LootDef!" + end +end + +DefineClass.LootEntryNoLoot = { + __parents = { "LootDefEntry", }, + __generated_by_class = "ClassDef", + + EntryView = Untranslated("No loot"), + EditorName = "No Loot", +} + +function LootEntryNoLoot:ListChances(items, env, chance, amount_modifier) + +end + +function LootEntryNoLoot:GenerateLoot(looter, looted, seed, items, modifiers, amount_modifier) + +end + +DefineClass.NoisePreset = { + __parents = { "Preset", "PerlinNoise", }, + __generated_by_class = "PresetDef", + + GlobalMap = "NoisePresets", + PresetClass = "NoisePreset", + EditorMenubarName = "Noise Editor", + EditorIcon = "CommonAssets/UI/Icons/bell message new notification sign.png", + EditorMenubar = "Map", +} + +DefineClass.ObjMaterial = { + __parents = { "ListPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "invulnerable", name = "Invulnerable", + editor = "bool", default = false, }, + { id = "impenetrable", name = "Impenetrable", + editor = "bool", default = false, }, + { id = "is_prop", name = "Prop Material", + editor = "bool", default = false, }, + { id = "max_hp", name = "Max HP", + editor = "number", default = 100, }, + { id = "breakdown_defense", name = "Breakdown Defense", help = "If the material is attached to a door, this defense is added to the break difficulty.", + editor = "number", default = 30, }, + { id = "destruction_propagation_strength", name = "Destruction Propagation Strength", help = "If the material is attached to a door, this defense is added to the break difficulty.", + editor = "number", default = 0, }, + { id = "FXTarget", name = "FX Target", + editor = "text", default = false, }, + { id = "noise_on_hit", name = "Noise On Hit", + editor = "number", default = 0, min = 0, }, + { id = "noise_on_break", name = "Noise On Break", + editor = "number", default = 0, min = 0, }, + }, + GlobalMap = "ObjMaterials", + EditorMenubarName = "ObjMaterial Editor", + FilterClass = "ObjMaterialFilter", +} + +DefineClass.ObjMaterialFilter = { + __parents = { "GedFilter", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "invulnerable", name = "Invulnerable", + editor = "set", default = false, max_items_in_set = 1, items = function (self) return { "true", "false" } end, }, + { id = "impenetrable", name = "Impenetrable", + editor = "set", default = false, max_items_in_set = 1, items = function (self) return { "true", "false" } end, }, + { id = "is_prop", name = "Prop Material", + editor = "set", default = false, max_items_in_set = 1, items = function (self) return { "true", "false" } end, }, + }, +} + +function ObjMaterialFilter:FilterObject(obj) + local function filter(prop) + local filter, value = self[prop], obj[prop] + return filter and (filter["true"] and not value or filter["false"] and value) + end + return not (filter("invulnerable") or filter("impenetrable") or filter("is_prop")) +end + +DefineClass.PhotoFilterPreset = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "display_name", name = "Display Name", + editor = "text", default = false, translate = true, }, + { category = "General", id = "description", name = "Description", + editor = "text", default = false, translate = true, }, + { category = "General", id = "shader_file", name = "Shader Filename", + editor = "browse", default = "", folder = "Shaders/", filter = "FX files|*.fx", }, + { category = "General", id = "shader_pass", name = "Shader Pass", + editor = "text", default = "Generic", }, + { category = "General", id = "texture1", name = "Texture 1", + editor = "browse", default = "", filter = "Image files|*.tga", }, + { category = "General", id = "texture2", name = "Texture 2", + editor = "browse", default = "", filter = "Image files|*.tga", }, + { category = "General", id = "activate", name = "Run on activation", + editor = "func", default = function (self) end, }, + { category = "General", id = "deactivate", name = "Run on deactivation", + editor = "func", default = function (self) end, }, + }, + HasSortKey = true, + GlobalMap = "PhotoFilterPresetMap", + EditorMenubarName = "Photo Filters", + EditorIcon = "CommonAssets/UI/Icons/camera digital image media photo photography picture.png", + EditorMenubar = "Editors.Other", +} + +function PhotoFilterPreset:GetShaderDescriptor() + return { + shader = self.shader_file, + pass = self.shader_pass, + tex1 = self.texture1, + tex2 = self.texture2, + activate = self.activate, + deactivate = self.deactivate, + } +end + +DefineClass.PhotoFramePreset = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "display_name", name = "Display Name", + editor = "text", default = false, translate = true, }, + { id = "frame_file", name = "Frame Filename", + editor = "ui_image", default = false, filter = "Image files|*.png;*.dds", force_extension = "", }, + { category = "General", id = "translate", name = "Translate", + editor = "bool", default = true, }, + }, + HasSortKey = true, + GlobalMap = "PhotoFramePresetMap", + EditorMenubarName = "Photo Frames", + EditorIcon = "CommonAssets/UI/Icons/camera digital image media photo photography picture.png", + EditorMenubar = "Editors.Other", + Documentation = "Allows adding new frames for the photo mode.", +} + +DefineModItemPreset("PhotoFramePreset", { EditorName = "Photo Mode - frame", EditorSubmenu = "Other" }) + +function PhotoFramePreset:GetName() + if self.translate then + return self.display_name + else + return Untranslated(self.display_name) + end +end + +DefineClass.RadioStationPreset = { + __parents = { "DisplayPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "Folder", name = "Folder", + editor = "browse", default = "Music", folder = "Music", filter = "folder", }, + { category = "General", id = "SilenceDuration", name = "Silence between tracks", + editor = "number", default = 1000, scale = "sec", }, + { category = "General", id = "Volume", name = "Volume", help = "Volume to play at each new track", + editor = "number", default = false, }, + { category = "General", id = "FadeOutTime", name = "Fade Out Time", help = "Time to fade out to a new volume", + editor = "number", default = false, + no_edit = function(self) return not self.Volume end, scale = "sec", }, + { category = "General", id = "FadeOutVolume", name = "FadeOutVolume", help = "Volume to fade out to after the fade out time", + editor = "number", default = false, + no_edit = function(self) return not self.FadeOutTime end, }, + { category = "General", id = "Mode", name = "Mode", help = 'Tracks play randomly by default but if "list" mode is set they play one after another', + editor = "choice", default = false, items = function (self) return {"list"} end, }, + }, + HasSortKey = true, + GlobalMap = "RadioStationPresets", + EditorMenubarName = "Radio Stations", + EditorIcon = "CommonAssets/UI/Icons/notes.png", + EditorMenubar = "Editors.Audio", + EditorCustomActions = { + { + FuncName = "TestPlay", + Icon = "CommonAssets/UI/Ged/play", + Name = "Play", + Toolbar = "main", + }, +}, + Documentation = "Adds a custom radio station, allowing to select folders with tracks to be implemented in the game instead of the soundtrack.", +} + +DefineModItemPreset("RadioStationPreset", { EditorName = "Radio station", EditorSubmenu = "Other" }) + +function RadioStationPreset:TestPlay() + StartRadioStation(self.id) +end + +function RadioStationPreset:Play() + local playlist = self:GetPlaylist() + Playlists.Radio = playlist + if not Music or Music.Playlist == "Radio" then + SetMusicPlaylist("Radio", true, "force") + end +end + +function RadioStationPreset:GetPlaylist() + local playlist = PlaylistCreate(self.Folder) + playlist.SilenceDuration = self.SilenceDuration + playlist.Volume = self.Volume + playlist.FadeOutTime = self.FadeOutTime + playlist.FadeOutVolume = self.FadeOutVolume + + return playlist +end + +----- RadioStationPreset + +if FirstLoad then + ActiveRadioStation = false + ActiveRadioStationThread = false + ActiveRadioStationStart = RealTime() +end + +function StartRadioStation(station_id, delay, force) + local station = RadioStationPresets[station_id or false] or RadioStationPresets[GetDefaultRadioStation() or false] + station_id = station and station.id or false + if force or (ActiveRadioStation ~= station_id and mapdata and mapdata.GameLogic) then + DbgMusicPrint(string.format("Start radio '%s' with %d delay%s", station_id, delay or 0, force and "[forced]" or "")) + if ActiveRadioStation and config.Radio then + local session_duration = (RealTime() - ActiveRadioStationStart) / 1000 + Msg("RadioStationSession", ActiveRadioStation, session_duration) + NetGossip("RadioStationSession", ActiveRadioStation, session_duration) + end + ActiveRadioStation = station_id + ActiveRadioStationStart = RealTime() + DeleteThread(ActiveRadioStationThread) + ActiveRadioStationThread = CreateRealTimeThread(function(station) + Sleep(delay or 0) + if station then station:Play() end + ActiveRadioStationThread = false + end, station) + Msg("RadioStationPlay", station_id, station) + end +end + +function OnMsg.QuitGame() + if config.Radio then + StartRadioStation(false) + end +end + +function OnMsg.LoadGame() + if config.Radio then + StartRadioStation(GetAccountStorageOptionValue("RadioStation")) + end +end + +function OnMsg.NewMapLoaded() + if config.Radio then + StartRadioStation(GetAccountStorageOptionValue("RadioStation")) + end +end + +function GetDefaultRadioStation() + return const.MusicDefaultRadioStation or "" +end + +----- RadioStationPreset mod item + +if rawget(_G, "ModItemRadioStationPreset") then + local properties = ModItemRadioStationPreset.properties + if not properties then + local properties = {} + ModItemRadioStationPreset.properties = properties + end + + local org_prop = table.find_value(RadioStationPreset.properties, "id", "Folder") + local prop = table.copy(org_prop, "deep") + prop.default = "RadioStations" + table.insert(properties, prop) + + local oldOnEditorNew = ModItemRadioStationPreset.OnEditorNew or empty_func + function ModItemRadioStationPreset.OnEditorNew(self, ...) + local radio_stations_path = self.mod.content_path .. "RadioStations/" + local radio_stations_os_path, err = ConvertToOSPath(radio_stations_path) + assert(not err) + AsyncCreatePath(radio_stations_os_path) + self:SetProperty("Folder", radio_stations_os_path) + return oldOnEditorNew(self, ...) + end +end + +DefineClass.RoofTypes = { + __parents = { "ListPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "display_name", name = "Display Name", + editor = "text", default = false, translate = true, }, + { id = "default_inclination", name = "Default Inclination", + editor = "number", default = 1200, scale = "deg", slider = true, min = 0, max = 2700, }, + }, +} + +DefineClass.RoomDecalData = { + __parents = { "ListPreset", }, + __generated_by_class = "PresetDef", + +} + +DefineClass.TODOPreset = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + TODOItems = { + "Implement", + "Review", + "Test", +}, + EditorMenubarName = false, +} + +DefineClass.TagsProperty = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { category = "General", id = "tags", name = "Tags", + editor = "set", default = false, buttons = { {name = "Edit", func = "OpenTagsEditor"}, }, items = function (self) return PresetsCombo(self.TagsListItem, "Default") end, }, + }, + TagsListItem = "CommonTags", +} + +function TagsProperty:HasTag(tag) + return (self.tags or empty_table)[tag] and true or false +end + +function TagsProperty:OpenTagsEditor() + g_Classes[self.TagsListItem]:OpenEditor() +end + +DefineClass.TerrainGrass = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "Classes", + editor = "string_list", default = {}, item_default = "", items = function (self) return ClassDescendantsCombo("Grass") end, }, + { id = "SizeFrom", name = "Size From", + editor = "number", default = 100, min = 50, max = 200, }, + { id = "SizeTo", name = "Size To", + editor = "number", default = 100, min = 50, max = 200, }, + { id = "Weight", name = "Weight", help = "For a weighted random selection between multiple grass definitions", + editor = "number", default = 100, slider = true, min = 0, max = 100, }, + { id = "NoiseWeight", name = "Noise Strength", help = "Weight of a random spatial noise modification to density", + editor = "number", default = 0, scale = 10, slider = true, min = -1000, max = 1000, }, + { id = "TiltWithTerrain", name = "TiltWithTerrain", help = "Orient with terrain normal", + editor = "bool", default = false, }, + { id = "PlaceOnWater", name = "PlaceOnWater", help = "Place on water surface", + editor = "bool", default = false, }, + { id = "ColorVarFrom", name = "Color Variation From", + editor = "color", default = 4284769380, }, + { id = "ColorVarTo", name = "Color Variation To", + editor = "color", default = 4284769380, }, + }, +} + +function TerrainGrass:GetEditorView() + local classes = self:GetClassList() or {""} + table.replace(classes, "", "No Grass") + return table.concat(classes, ", ") .. " (" .. self.Weight .. ")" +end + +function TerrainGrass:GetClassList() + local classes = table.keys(table.invert(self.Classes or empty_table), true) + return #classes > 0 and classes +end + +DefineClass.TerrainProps = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "TerrainName", name = "Terrain Name", + editor = "choice", default = false, items = function (self) return GetTerrainNamesCombo() end, }, + { id = "TerrainIndex", name = "Terrain Index", + editor = "number", default = false, dont_save = true, read_only = true, }, + { id = "TerrainPreview", name = "Terrain Preview", + editor = "image", default = false, dont_save = true, read_only = true, img_size = 100, img_box = 1, base_color_map = true, }, + }, +} + +function TerrainProps:GetTerrainPreview() + return GetTerrainTexturePreview(self.TerrainName) +end + +function TerrainProps:GetTerrainIndex() + return GetTerrainTextureIndex(self.TerrainName) +end + +DefineClass.TestCombatFilter = { + __parents = { "GedFilter", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "ShowInCheats", name = "Shown In Cheats", help = "Filters depending if shown in cheats or not", + editor = "choice", default = "", items = function (self) return {"", "true", "false"} end, }, + }, +} + +function TestCombatFilter:FilterObject(obj) + if self.ShowInCheats ~= "" and obj.show_in_cheats ~= (self.ShowInCheats == "true") then + return false + end + + return true +end + +DefineClass.TextStyle = { + __parents = { "Preset", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "Text", id = "TextFont", name = "Font", + editor = "text", default = T(202962508484, --[[PresetDef TextStyle default]] "droid, 12"), translate = true, }, + { category = "Text", id = "TextColor", name = "Color", + editor = "color", default = 4280295456, }, + { category = "Text", id = "RolloverTextColor", name = "Rollover color", + editor = "color", default = 4278190080, }, + { category = "Text", id = "DisabledTextColor", name = "Disabled color", + editor = "color", default = 2149589024, }, + { category = "Text", id = "DisabledRolloverTextColor", name = "Disabled rollover color", + editor = "color", default = 2147483648, }, + { category = "Text", id = "ShadowType", name = "Shadow type", + editor = "choice", default = "shadow", items = function (self) return {"shadow", "extrude", "outline", "glow"} end, }, + { category = "Text", id = "ShadowSize", name = "Shadow size", + editor = "number", default = 0, }, + { category = "Text", id = "ShadowColor", name = "Shadow color", + editor = "color", default = 805306368, }, + { category = "Text", id = "ShadowDir", name = "Shadow dir", + editor = "point", default = point(1, 1), }, + { id = "DarkMode", + editor = "preset_id", default = false, preset_class = "TextStyle", }, + { category = "Text", id = "DisabledShadowColor", name = "Disabled shadow color", + editor = "color", default = 805306368, }, + }, + HasSortKey = true, + GlobalMap = "TextStyles", + EditorMenubarName = "Text styles", + EditorShortcut = "Ctrl-Alt-T", + EditorIcon = "CommonAssets/UI/Icons/detail list view.png", + EditorMenubar = "Editors.UI", + EditorPreview = Untranslated(""), + Documentation = "Adds a new text style that can be used by XFontControl and other classes in XTemplate presets.", +} + +DefineModItemPreset("TextStyle", { EditorName = "Text style", EditorSubmenu = "Other" }) + +function TextStyle:GetFontIdHeightBaseline(scale) + local cache = TextStyleCache[self.id] + if not cache then + cache = {} + TextStyleCache[self.id] = cache + end + + scale = scale or 1000 + if cache[scale] then + return unpack_params(cache[scale]) + end + + local font = _InternalTranslate(self.TextFont) or _InternalTranslate(TextStyle.TextFont) + font = font:gsub("%d+", function(size) + return Max(MulDivRound(tonumber(size) or 1, scale, 1000), 1) + end) + + font = GetProjectConvertedFont(font) + + -- Mods can replace fonts with other fonts + if g_FontReplaceMap then + local font_name = string.match(font, "([^,]+),") + if font_name then + if g_FontReplaceMap[font_name] then + font_name = g_FontReplaceMap[font_name] + -- Replace the font name only, leave the size and style + font = font:gsub("[^,]+,", font_name .. ",") + else + -- Match the font if the used name is part of the actual full name + for replace, with in pairs(g_FontReplaceMap) do + if string.find(replace, font_name) then + font_name = g_FontReplaceMap[replace] + -- Replace the font name only, leave the size and style + font = font:gsub("[^,]+,", font_name .. ",") + break + end + end + end + end + end + + local id = UIL.GetFontID(font) + if not id or id < 0 then + print("once", "[WARNING] Invalid font", font, "in text style", self.id) + return -1, 0, 0 + end + local _, height = UIL.MeasureText("AQj", id) + local baseline = height * 8 / 10 -- there is currently no way to get the font's baseline + cache[scale] = { id, height, baseline } + return id, height, baseline +end + +function TextStyle:GetPreview() + return string.format("", self.id, _InternalTranslate(self.TextFont, nil, not "check")) +end + +----- TextStyle globals + +TextStyleCache = {} + +function ClearTextStyleCache() TextStyleCache = {} end +OnMsg.EngineOptionsSaved = ClearTextStyleCache +OnMsg.ClassesBuilt = ClearTextStyleCache +OnMsg.DataLoaded = ClearTextStyleCache +OnMsg.DataReload = ClearTextStyleCache + +function LoadTextStyles() + local old_text_styles = Presets.TextStyle + Presets.TextStyle = {} + TextStyles = {} + LoadPresets("CommonLua/Data/TextStyle.lua") + ForEachLib("Data/TextStyle.lua", function(lib, path) LoadPresets(path) end) + LoadPresets("Data/TextStyle.lua") + for _, dlc_folder in ipairs(DlcFolders or empty_table) do + LoadPresets(dlc_folder .. "/Presets/TextStyle.lua") + end + TextStyle:SortPresets() + for _, group in ipairs(Presets.TextStyle) do + for _, preset in ipairs(group) do + preset:PostLoad() + end + end + if Platform.developer and not Platform.ged then + LoadCollapsedPresetGroups() + end + GedRebindRoot(old_text_styles, Presets.TextStyle) +end + +if FirstLoad or ReloadForDlc then + OnMsg.ClassesBuilt = LoadTextStyles + OnMsg.DataLoaded = LoadTextStyles +end + +DefineClass.WangNoisePreset = { + __parents = { "NoisePreset", "WangPerlinNoise", }, + __generated_by_class = "PresetDef", + + EditorMenubarName = "Noise Editor", +} + diff --git a/CommonLua/Classes/ClassDefs/ClassDef-StoryBits.generated.lua b/CommonLua/Classes/ClassDefs/ClassDef-StoryBits.generated.lua new file mode 100644 index 0000000000000000000000000000000000000000..4746c32908c28c44d5d5ce70c543fb388204f723 --- /dev/null +++ b/CommonLua/Classes/ClassDefs/ClassDef-StoryBits.generated.lua @@ -0,0 +1,367 @@ +-- ========== GENERATED BY ClassDef Editor (Ctrl-Alt-F3) DO NOT EDIT MANUALLY! ========== + +DefineClass.StoryBit = { + __parents = { "PresetWithQA", }, + __generated_by_class = "PresetDef", + + properties = { + { category = "General", id = "ScriptDone", name = "Script done", + editor = "bool", default = false, no_edit = function(self) return IsKindOf(self, "ModItem") end, }, + { category = "General", id = "TextReadyForValidation", name = "Text ready for validation", + editor = "bool", default = false, no_edit = function(self) return IsKindOf(self, "ModItem") end, }, + { category = "General", id = "TextsDone", name = "Texts done", + editor = "bool", default = false, no_edit = function(self) return IsKindOf(self, "ModItem") end, }, + { category = "Activation", id = "Category", + editor = "preset_id", default = "FollowUp", preset_class = "StoryBitCategory", extra_item = "FollowUp", }, + { category = "Activation", id = "Trigger", + editor = "choice", default = "StoryBitTick", + read_only = function(self) return self.Category ~= "FollowUp" end, items = function (self) return StoryBitTriggersCombo end, }, + { category = "Activation", id = "Enabled", + editor = "bool", default = false, + read_only = function(self) return self.Category == "FollowUp" end, }, + { category = "Activation", id = "EnableChance", name = "Enable Chance", help = "Chance to be enabled in a specific playthrough (use sparingly for story bits that occur too often)", + editor = "number", default = 100, + no_edit = function(self) return not self.Enabled end, scale = "%", }, + { category = "Activation", id = "InheritsObject", name = "Inherits object", help = "Associate with the object of the story bit that enabled this one", + editor = "bool", default = true, + no_edit = function(self) return self.Enabled end, }, + { category = "Activation", id = "OneTime", name = "One-time", + editor = "bool", default = true, + read_only = function(self) return self.Category == "FollowUp" end, }, + { category = "Activation", id = "ExpirationTime", name = "Expiration time", + editor = "number", default = false, scale = "h", }, + { category = "Activation", id = "ExpirationModifier", name = "Expiration modifier", + editor = "expression", default = function (self, context, obj) return 100 end, params = "self, context, obj", }, + { category = "Activation", id = "SuppressTime", name = "Suppress for", help = "This StoryBit can't trigger for this period after it was enabled", + editor = "number", default = 0, scale = "h", }, + { category = "Activation", id = "Sets", name = "Sets", help = "Sets this story bit belongs to. These sets can be disabled by game-specific logic.", + editor = "set", default = false, items = function (self) return PresetsCombo("CooldownDef", "StoryBits") end, }, + { category = "Activation", id = "Prerequisites", + editor = "nested_list", default = false, base_class = "Condition", }, + { category = "Activation Effects", id = "Disables", help = "List of StoryBits to disable", + editor = "preset_id_list", default = {}, preset_class = "StoryBit", item_default = "", }, + { category = "Activation Effects", id = "Delay", help = "This StoryBit waits this long after triggering before it gets activated and displayed", + editor = "number", default = 0, scale = "h", }, + { category = "Activation Effects", id = "DetachObj", name = "Detach object", help = "Detach the story bit related object on delay.", + editor = "bool", default = false, }, + { category = "Activation Effects", id = "ActivationEffects", name = "Activation Effects", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + { category = "Notification", id = "HasNotification", name = "Has notification", + editor = "bool", default = true, }, + { category = "Notification", id = "NotificationPriority", name = "Notification priority", + editor = "choice", default = "StoryBit", + no_edit = function(self) return not self.HasNotification end, items = function (self) return GetGameNotificationPriorities() end, }, + { category = "Notification", id = "NotificationTitle", name = "Notification Title", help = "Leave empty to use the popup title", + editor = "text", default = "", + no_edit = function(self) return not self.HasNotification end, translate = true, lines = 1, }, + { category = "Notification", id = "NotificationText", name = "Notification Text", help = "Leave empty to use the popup title", + editor = "text", default = "", + no_edit = function(self) return not self.HasNotification end, translate = true, lines = 1, }, + { category = "Notification", id = "NotificationRolloverTitle", name = "Notification rollover title", + editor = "text", default = "", + no_edit = function(self) return not self.HasNotification end, translate = true, lines = 1, }, + { category = "Notification", id = "NotificationRolloverText", name = "Notification rollover text", + editor = "text", default = "", + no_edit = function(self) return not self.HasNotification end, translate = true, lines = 1, max_lines = 6, }, + { category = "Notification", id = "NotificationAction", name = "Notification action", + editor = "choice", default = "complete", + no_edit = function(self) return not self.HasNotification end, items = function (self) return {"complete", "select object", "callback", "nothing"} end, }, + { category = "Notification", id = "NotificationCallbackFunc", name = "Notification click callback", + editor = "func", default = function (self, state) +return true +end, no_edit = function(self) return self.NotificationAction ~= "callback" end, params = "self, state", }, + { category = "Notification", id = "NotificationExpirationBar", name = "Display expiration bar", + editor = "bool", default = false, + no_edit = function(self) return not self.HasNotification end, }, + { category = "Notification", id = "NotificationCanDismiss", name = "Can dismiss", + editor = "bool", default = true, + no_edit = function(self) return not self.HasNotification or self.HasPopup end, }, + { category = "Popup", id = "HasPopup", name = "Has popup", + editor = "bool", default = true, }, + { category = "Notification", id = "FxAction", name = "FX Action", help = "This is used for calling the FX system with given action. Leave it empty to have default notification FX actions.", + editor = "text", default = "", }, + { category = "Popup", id = "Actor", + editor = "combo", default = "narrator", + no_edit = function(self) return not self.HasPopup end, items = function (self) return VoiceActors end, }, + { category = "Popup", id = "Title", + editor = "text", default = false, + no_edit = function(self) return not self.HasPopup end, translate = true, lines = 1, }, + { category = "Popup", id = "VoicedText", name = "Voiced Text", + editor = "text", default = false, + no_edit = function(self) return not self.HasPopup end, translate = true, lines = 1, max_lines = 256, context = VoicedContextFromField("Actor"), }, + { category = "Popup", id = "Text", + editor = "text", default = false, + no_edit = function(self) return not self.HasPopup end, translate = true, lines = 4, max_lines = 256, }, + { category = "Popup", id = "Image", + editor = "ui_image", default = "", + no_edit = function(self) return not self.HasPopup end, image_preview_size = 200, }, + { category = "Popup", id = "UseObjectImage", name = "Use object image", help = "Extract a popup image from the context object", + editor = "bool", default = false, + no_edit = function(self) return not self.HasPopup end, }, + { category = "Popup", id = "SelectObject", name = "Select Object", help = "Select the object when opening the popup", + editor = "bool", default = true, + no_edit = function(self) return not self.HasPopup end, }, + { category = "Popup", id = "PopupFxAction", name = "FX Action", help = "This is used for calling the FX system with given action when opening Popup.", + editor = "text", default = "", }, + { category = "Completion Effects", id = "Enables", help = "List of StoryBits to enable", + editor = "preset_id_list", default = {}, preset_class = "StoryBit", item_default = "", }, + { category = "Completion Effects", id = "Effects", name = "Completion Effects", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + { id = "max_reply_id", name = "Max Reply Id", + editor = "number", default = 0, read_only = true, no_edit = true, }, + }, + HasParameters = true, + SingleFile = false, + GlobalMap = "StoryBits", + ContainerClass = "StoryBitSubItem", + EditorMenubarName = "Story Bits", + EditorShortcut = "Ctrl-Alt-E", + EditorIcon = "CommonAssets/UI/Icons/book 2.png", + EditorMenubar = "Scripting", + EditorCustomActions = { + { + Name = "Debug", + }, + { + FuncName = "GedRpcTestStoryBit", + Icon = "CommonAssets/UI/Ged/play.tga", + Menubar = "Debug", + Name = "TestStoryBit", + Toolbar = "main", + }, + { + FuncName = "GedRpcTestPrerequisitesStoryBit", + Icon = "CommonAssets/UI/Ged/preview.tga", + Menubar = "Debug", + Name = "Test prerequisites", + Toolbar = "main", + }, +}, + EditorView = Untranslated(" ()"), +} + +DefineModItemPreset("StoryBit", { EditorName = "Story bit", EditorSubmenu = "Gameplay" }) + +function StoryBit:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Category" then + if self.Category == "FollowUp" or self.Category == "" then + self.Enabled = false + self.OneTime = true + else + self.Trigger = StoryBitCategories[self.Category].Trigger + self.Enabled = true + end + end + if prop_id == "Enables" or prop_id == "Effects" or prop_id == "ActivationEffects" then + StoryBitResetEnabledReferences() + end +end + +function StoryBit:ChooseColor() + if not self.ScriptDone then return "" end + local color = + not self.TextsDone and (self.TextReadyForValidation and RGB(180, 0, 180) or RGB(205, 32, 32)) or + not self.TextReadyForValidation and RGB(220, 120, 0) or + not (self.Image and self.Image ~= "" or self.Category == "FollowUp") and RGB(50, 50, 200) or + RGB(32, 128, 32) + local r,g,b = GetRGB(color) + return string.format("", r, g ,b) +end + +----- StoryBit function ModItemStoryBit:TestModItem(ged) + +if config.Mods then + function ModItemStoryBit:TestModItem(ged) + if not GameState.gameplay then return end + CreateGameTimeThread(function() + ForceActivateStoryBit(self.id, SelectedObj, "immediate") + end) + end +end + +----- StoryBit Add ! marks + +if Platform.developer then + local referenced_storybits = false + + function StoryBitResetEnabledReferences() + referenced_storybits = false + ObjModified(Presets.StoryBit) + end + + local function add_effect_references(effects) + for _, effect in ipairs(effects or empty_table) do + if effect:IsKindOf("StoryBitActivate") then + referenced_storybits[effect.Id] = true + end + if effect:IsKindOf("StoryBitEnableRandom") then + for _, id in ipairs(effect.StoryBits or empty_table) do + referenced_storybits[id] = true + end + end + end + end + + function OnMsg.MarkReferencedStoryBits(referenced_storybits) + ForEachPreset(StoryBit, function(storybit) + for _, id in ipairs(storybit.Enables or empty_table) do + referenced_storybits[id] = true + end + add_effect_references(storybit.Effects) + add_effect_references(storybit.ActivationEffects) + for _, child in ipairs(storybit) do + if child:IsKindOf("StoryBitOutcome") then + for _, id in ipairs(child.Enables) do + referenced_storybits[id] = true + end + add_effect_references(child.Effects) + end + end + end) + end + + function StoryBit:GetError() + if not referenced_storybits then + referenced_storybits = {} + Msg("MarkReferencedStoryBits", referenced_storybits) + end + return not (self.Enabled or referenced_storybits[self.id]) and + "This story bit is not enabled by itself, or from anywhere else." + end +else + function StoryBitResetEnabledReferences() + end +end + +DefineClass.StoryBitOutcome = { + __parents = { "StoryBitSubItem", }, + __generated_by_class = "ClassDef", + + properties = { + { category = "Activation", id = "Prerequisites", + editor = "nested_list", default = false, base_class = "Condition", }, + { category = "Activation", id = "Weight", + editor = "number", default = 100, }, + { category = "Popup", id = "Title", + editor = "text", default = false, translate = true, lines = 1, }, + { category = "Popup", id = "VoicedText", name = "Voiced Text", + editor = "text", default = false, translate = true, lines = 1, max_lines = 256, context = VoicedContextFromField("Actor"), }, + { category = "Popup", id = "Text", + editor = "text", default = false, translate = true, lines = 4, max_lines = 256, }, + { category = "Popup", id = "Actor", + editor = "combo", default = "narrator", items = function (self) return VoiceActors end, }, + { category = "Popup", id = "Image", + editor = "ui_image", default = "", }, + { id = "Enables", help = "List of StoryBits to enable", + editor = "preset_id_list", default = {}, preset_class = "StoryBit", item_default = "", }, + { id = "Disables", help = "List of StoryBits to disable", + editor = "preset_id_list", default = {}, preset_class = "StoryBit", item_default = "", }, + { category = "Effect", id = "StoryBits", help = "A list of storybits with weight. One will be chosen and activated based on weight and met prerequisites.", + editor = "nested_list", default = false, base_class = "StoryBitWithWeight", all_descendants = true, }, + { category = "Effect", id = "Effects", help = "All effects in the list will be executed even if some storybits have been added to the StoryBits property.", + editor = "nested_list", default = false, base_class = "Effect", all_descendants = true, }, + }, + EditorName = "New Outcome", +} + +function StoryBitOutcome:GetEditorView() + local result = "Outcome (): " + if self.VoicedText then + result = result .. "" + elseif self.Text then + result = result .. "" + else + result = result .. "no display text" + end + for _, cond in ipairs(self.Prerequisites or empty_table) do + result = result .. "\n" .. _InternalTranslate(cond:GetProperty("EditorView"), cond, false) .. "" + end + for _, effect in ipairs(self.Effects or empty_table) do + result = result .. "\n" .. _InternalTranslate(effect:GetProperty("EditorView"), effect, false) .. "" + end + for _, effect in ipairs(self.StoryBits) do + result = result .. "\n" .. _InternalTranslate(effect:GetProperty("EditorView"), effect, false) .. "" + end + if next(self.Enables or empty_table) then + result = result .. "\nEnables: " .. table.concat(self.Enables, ", ") .. "" + end + if next(self.Disables or empty_table) then + result = result .. "\nDisables: " .. table.concat(self.Disables, ", ") .. "" + end + return T{Untranslated(result)} +end + +function StoryBitOutcome:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Enables" or prop_id == "Effects" then + StoryBitResetEnabledReferences() + end +end + +DefineClass.StoryBitReply = { + __parents = { "StoryBitSubItem", }, + __generated_by_class = "ClassDef", + + properties = { + { id = "Text", + editor = "text", default = false, translate = true, lines = 1, }, + { id = "PrerequisiteText", name = "Prerequisite text", + editor = "text", default = false, translate = true, }, + { id = "Prerequisites", + editor = "nested_list", default = false, base_class = "Condition", }, + { id = "Cost", + editor = "number", default = 0, }, + { id = "HideIfDisabled", name = "Hide if disabled", + editor = "bool", default = false, }, + { id = "OutcomeText", name = "Outcome Text", + editor = "choice", default = "none", items = function (self) return { { text = "None", value = "none" }, { text = "Auto (from 1st outcome effects)", value = "auto" }, { text = "Custom", value = "custom" } } end, }, + { id = "CustomOutcomeText", name = "Custom outcome text", + editor = "text", default = false, + no_edit = function(self) return self.OutcomeText ~= "custom" end, translate = true, lines = 1, }, + { category = "Comment", id = "Comment", + editor = "text", default = false, }, + { id = "unique_id", name = "Unique Id", + editor = "number", default = 0, read_only = true, no_edit = true, }, + }, + EditorName = "New Reply", +} + +function StoryBitReply:GetEditorView() + local conditions = {} + for _, cond in ipairs(self.Prerequisites) do + table.insert(conditions, _InternalTranslate(cond:GetProperty("EditorView"), cond, false)) + end + local cost = self.Cost + if cost ~= 0 then + table.insert(conditions, "Cost " .. _InternalTranslate(T(504461186435, ""), cost, false)) + end + + local cond_text = #conditions > 0 and "[" .. table.concat(conditions, ", ") .. "] " or "" + local result = "Reply: " .. cond_text .. "" + if self.OutcomeText == "custom" then + result = result .. "\n()" + elseif self.OutcomeText == "auto" then + result = result .. "\n - auto display of outcome text -" + end + if self.Comment and self.Comment ~= "" then + result = result .. "\n" .. self.Comment .. "" + end + if const.TagLookupTable["em"] then + result = result:gsub(const.TagLookupTable["em"],"") + result = result:gsub(const.TagLookupTable["/em"],"") + end + return T{Untranslated(result)} +end + +function StoryBitReply:OnEditorNew(parent, ged, is_paste) + parent.max_reply_id = parent.max_reply_id + 1 + self.unique_id = parent.max_reply_id +end + +DefineClass.StoryBitSubItem = { + __parents = { "PropertyObject", }, + __generated_by_class = "ClassDef", + + StoreAsTable = true, + EditorName = "StoryBit Element", +} + diff --git a/CommonLua/Classes/CodeRenderableObject.lua b/CommonLua/Classes/CodeRenderableObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..057a7a71686d34b782d5cd3b18ad7ad2b72b4780 --- /dev/null +++ b/CommonLua/Classes/CodeRenderableObject.lua @@ -0,0 +1,1375 @@ +local AppendVertex = pstr().AppendVertex +local GetHeight = terrain.GetHeight +local height_tile = const.HeightTileSize +local InvalidZ = const.InvalidZ +local KeepRefOneFrame = KeepRefOneFrame + +local SetCustomData +local GetCustomData +function OnMsg.Autorun() + SetCustomData = ComponentCustomData.SetCustomData + GetCustomData = ComponentCustomData.GetCustomData +end + +DefineClass.CodeRenderableObject = +{ + __parents = { "Object", "ComponentAttach", "ComponentCustomData" }, + entity = "", + flags = { + gofAlwaysRenderable = true, cfCodeRenderable = true, cofComponentInterpolation = true, cfConstructible = false, + efWalkable = false, efCollision = false, efApplyToGrids = false, efSelectable = false, efShadow = false, efSunShadow = false + }, + depth_test = false, + zwrite = true, +} + +DefineClass.Text = +{ + --[[ + Custom data layout: + const.CRTextCCDIndexColorMain - base color, RGBA + const.CRTextCCDIndexColorShadow - shadow color, RGBA + const.CRTextCCDIndexFlags - flags: 0: depth test, 1, center + const.CRTextCCDIndexText - text, as string - you must keep this string from being GCed while the Text object is alive + const.CRTextCCDIndexFont - font_id, integer as returned by UIL.GetFontID + const.CRTextCCDIndexShadowOffset - shadow offset + const.CRTextCCDIndexOpacity - opacity interpolation parameters + const.CRTextCCDIndexScale - scale interpolation parameters + const.CRTextCCDIndexZOffset - z offset interpolation parameters + ]] + __parents = { "CodeRenderableObject" }, + text = false, + text_style = false, + hide_in_editor = true, -- hide using the T button in the editor statusbar (or Alt-Shift-T shortcut) +} + +local TextFlag_DepthTest = 1 +local TextFlag_Center = 2 + +function Text:SetColor1(c) SetCustomData(self, const.CRTextCCDIndexColorMain, c) end +function Text:SetColor2(c) SetCustomData(self, const.CRTextCCDIndexColorShadow, c) end +function Text:GetColor1(c) return GetCustomData(self, const.CRTextCCDIndexColorMain) end +function Text:GetColor2(c) return GetCustomData(self, const.CRTextCCDIndexColorShadow) end + +function Text:SetColor(c) + self:SetColor1(c) + self:SetColor2(RGB(0,0,0)) +end + +function Text:GetColor(c) + return self:GetColor1() +end + +function Text:SetDepthTest(depth_test) + local flags = GetCustomData(self, const.CRTextCCDIndexFlags) + if depth_test then + SetCustomData(self, const.CRTextCCDIndexFlags, FlagSet(flags, TextFlag_DepthTest)) + else + SetCustomData(self, const.CRTextCCDIndexFlags, FlagClear(flags, TextFlag_DepthTest)) + end +end +function Text:GetDepthTest() + return IsFlagSet(GetCustomData(self, const.CRTextCCDIndexFlags), TextFlag_DepthTest) +end + +function Text:SetCenter(c) + local flags = GetCustomData(self, const.CRTextCCDIndexFlags) + if c then + SetCustomData(self, const.CRTextCCDIndexFlags, FlagSet(flags, TextFlag_Center)) + else + SetCustomData(self, const.CRTextCCDIndexFlags, FlagClear(flags, TextFlag_Center)) + end +end +function Text:GetCenter() + return IsFlagSet(GetCustomData(self, const.CRTextCCDIndexFlags), TextFlag_Center) +end + +function Text:SetText(txt) + KeepRefOneFrame(self.text) + self.text = txt + SetCustomData(self, const.CRTextCCDIndexText, self.text) +end +function Text:GetText() + return self.text +end + +function Text:SetFontId(id) SetCustomData(self, const.CRTextCCDIndexFont, id) end +function Text:GetFontId() return GetCustomData(self, const.CRTextCCDIndexFont) end +function Text:SetShadowOffset(so) SetCustomData(self, const.CRTextCCDIndexShadowOffset, so) end +function Text:GetShadowOffset() return GetCustomData(self, const.CRTextCCDIndexShadowOffset) end + +function Text:SetTextStyle(style, scale) + local style = TextStyles[style] + if not style then + assert(false, string.format("Invalid text style '%s'", style)) + return + end + scale = scale or terminal.desktop.scale:y() + local font, height, base_height = style:GetFontIdHeightBaseline(scale) + self:SetFontId(font, height, base_height) + self:SetColor(style.TextColor) + self:SetShadowOffset(style.ShadowSize) + self.text_style = style +end + +function Text:SetOpacityInterpolation(v0, t0, v1, t1) + -- opacities are 0..100, 7 bits + -- times are encoded as ms/10, 8 bits each + v0 = v0 or 100 + v1 = v1 or v0 + t0 = t0 or 0 + t1 = t1 or 0 + SetCustomData(self, const.CRTextCCDIndexOpacity, EncodeBits(v0, 7, v1, 7, t0/10, 8, t1/10, 8)) +end + +function Text:SetScaleInterpolation(v0, t0, v1, t1) + -- scales are encoded 0..127, scale in percent/4 - so range 0..500% + -- times are encoded as ms/10, 8 bits each + v0 = v0 or 100 + v1 = v1 or v0 + t0 = t0 or 0 + t1 = t1 or 0 + SetCustomData(self, const.CRTextCCDIndexScale, EncodeBits(v0/4, 7, v1/4, 7, t0/10, 8, t1/10, 8)) +end + +function Text:SetZOffsetInterpolation(v0, t0, v1, t1) + -- Z offsets are encoded 0..127, in guim/50 - so range 0..6.35 m + -- times are encoded as ms/10, 8 bits each + v0 = v0 or 0 + v1 = v1 or v0 + t0 = t0 or 0 + t1 = t1 or 0 + SetCustomData(self, const.CRTextCCDIndexZOffset, EncodeBits(v0/50, 7, v1/50, 7, t0/10, 8, t1/10, 8)) +end + +function Text:Init() + self:SetTextStyle(self.text_style or "EditorText") +end + +function Text:Done() + KeepRefOneFrame(self.text) + self.text = nil +end + +function Text:SetCustomData(idx, data) + assert(idx ~= const.CRTextCCDIndexText, "Use SetText instead") + return SetCustomData(self, idx, data) +end + +DefineClass.TextEditor = { + __parents = {"Text", "EditorVisibleObject"}, +} + +function PlaceText(text, pos, color, editor_visibile_only) + local obj = PlaceObject(editor_visibile_only and "TextEditor" or "Text") + if pos then + obj:SetPos(pos) + end + obj:SetText(text) + if color then + obj:SetColor(color) + end + return obj +end + +function RemoveAllTexts() + MapDelete("map", "Text") +end + +local function GetMeshFlags() + local flags = {} + for name, value in pairs(const) do + if string.starts_with(name, "mf") then + flags[value] = name + end + end + return flags +end + +DefineClass.MeshParamSet = { + __parents = { "PropertyObject" }, + properties = { + + }, + uniforms = false, + uniforms_size = 0, +} + +local uniform_sizes = { + integer = 4, + float = 4, + color = 4, + point2 = 8, + point3 = 12, +} + +function GetUniformMeta(properties) + local uniforms = {} + local offset = 0 + for _, prop in ipairs(properties) do + local uniform_type = prop.uniform + if uniform_type then + if type(uniform_type) ~= "string" then + if prop.editor == "number" then + uniform_type = prop.scale and prop.scale ~= 1 and "float" or "integer" + elseif prop.editor == "point" then + uniform_type = "point3" + else + uniform_type = prop.editor + end + end + local size = uniform_sizes[uniform_type] + if not size then + assert(false, "Unknown uniform type.") + end + local space = 16 - (offset % 16) + if space < size then + table.insert(uniforms, {id = false, type = "padding", offset = offset, size = space}) + offset = offset + space + end + table.insert(uniforms, {id = prop.id, type = uniform_type, offset = offset, size = size, scale = prop.scale}) + offset = offset + size + end + end + return uniforms, offset +end + +function OnMsg.ClassesPostprocess() + ClassDescendantsList("MeshParamSet", function(name, def) + def.uniforms, def.uniforms_size = GetUniformMeta(def:GetProperties()) + end) +end + +function MeshParamSet:WriteBuffer(param_pstr, offset, getter) + if not offset then + offset = 0 + end + if not getter then + getter = self.GetProperty + end + param_pstr = param_pstr or pstr("", self.uniforms_size) + param_pstr:resize(offset) + for _, prop in ipairs(self.uniforms) do + local value + if prop.type == "padding" then + value = prop.size + else + value = getter(self, prop.id) + end + + param_pstr:AppendUniform(prop.type, value, prop.scale) + end + return param_pstr +end + +function MeshParamSet:ComposeBuffer(param_pstr, getter) + return self:WriteBuffer(param_pstr, 0, getter) +end + +DefineClass.Mesh = { + __parents = { "CodeRenderableObject" }, + + properties = { + { id = "vertices_len", read_only = true, dont_save = true, editor = "number", default = 0,}, + { id = "CRMaterial", editor = "nested_obj", base_class = "CRMaterial", default = false, }, + { id = "MeshFlags", editor = "flags", default = 0, items = GetMeshFlags }, + { id = "DepthTest", editor = "bool", default = false, read_only = function(s) return not s.shader or s.shader.depth_test ~= "runtime" end, }, + { id = "ShaderName", editor = "choice", default = "default_mesh", items = function() return table.keys2(ProceduralMeshShaders, "sorted") end}, + }, + --[[ + Custom data layout: + const.CRMeshCCDIndexGeometry - vertices, packed in a pstr + const.CRMeshCCDIndexPipeline - shader; depth_test >> 31, 0 for off, 1 for on + const.CRMeshCCDIndexMeshFlags - mesh flags + const.CRMeshCCDIndexUniforms - uniforms, packed in a pstr + const.CRMeshCCDIndexTexture0, const.CRMeshCCDIndexTexture1 - textures + ]] + + vertices_pstr = false, + uniforms_pstr = false, + shader = false, + textstyle_id = false, +} + +function Mesh:GedTreeViewFormat() + return string.format("%s (%s)", self.class, self.CRMaterial and self.CRMaterial.id or self:GetShaderName()) +end + +function Mesh:Getvertices_len() + return self.vertices_pstr and #self.vertices_pstr or 0 +end + +function Mesh:GetShaderName() + return self.shader and self.shader.name or "" +end + +function Mesh:SetShaderName(value) + self:SetShader(ProceduralMeshShaders[value]) +end + +function Mesh:Init() + self:SetShader(ProceduralMeshShaders.default_mesh) +end + +function Mesh:SetMesh(vpstr) + KeepRefOneFrame(self.vertices_pstr) + local vertices_pstr = #(vpstr or "") > 0 and vpstr or nil -- an empty string would result in a crash :| + self.vertices_pstr = vertices_pstr + SetCustomData(self, const.CRMeshCCDIndexGeometry, vertices_pstr) +end + +function Mesh:SetUniformSet(uniform_set) + self:SetUniformsPstr(uniform_set:ComposeBuffer()) +end + +function Mesh:SetUniformsPstr(uniforms_pstr) + KeepRefOneFrame(self.uniforms_pstr) + self.uniforms_pstr = uniforms_pstr + SetCustomData(self, const.CRMeshCCDIndexUniforms, uniforms_pstr) +end + +function Mesh:SetUniformsList(uniforms, isDouble) + KeepRefOneFrame(self.uniforms_pstr) + local count = Max(8, #uniforms) + local uniforms_pstr = pstr("", count * 4) + self.uniforms_pstr = uniforms_pstr + for i = 1, count do + if isDouble then + uniforms_pstr:AppendUniform("double", uniforms[i] or 0) + else + uniforms_pstr:AppendUniform("float", uniforms[i] or 0, 1000) + end + end + SetCustomData(self, const.CRMeshCCDIndexUniforms, uniforms_pstr) +end + +function Mesh:SetUniforms(...) + return self:SetUniformsList{...} +end + +function Mesh:SetDoubleUniforms(...) + return self:SetUniformsList({...}, true) +end + +function Mesh:SetShader(shader, depth_test) + assert(shader.shaderid) + assert(shader.defines) + assert(shader.ref_id > 0) + if depth_test == nil then + if shader.depth_test == "always" then + depth_test = true + elseif shader.depth_test == "never" then + depth_test = false + else + depth_test = self:GetDepthTest() + end + end + local depth_test_int = 0 + if depth_test then + depth_test_int = 1 + assert(shader.depth_test == "runtime" or shader.depth_test == "always", "Tried to enable depth test for shader with depth_test = never") + else + depth_test_int = 0 + assert(shader.depth_test == "runtime" or shader.depth_test == "never", "Tried to disable depth test for shader with depth_test = always") + end + SetCustomData(self, const.CRMeshCCDIndexPipeline, shader.ref_id | (depth_test_int << 31)) + self.shader = shader +end + +function Mesh:SetDepthTest(depth_test) + assert(self.shader) + self:SetShader(self.shader, depth_test) +end + +function Mesh:SetCRMaterial(material) + if type(material) == "string" then + local new_material = CRMaterial:GetById(material, true) + assert(new_material, "CRMaterial not found.") + material = new_material + end + self.CRMaterial = material + local depth_test = material.depth_test + if depth_test == "default" then depth_test = nil end + + CodeRenderableLockCCD(self) + self:SetShader(material:GetShader(), depth_test) + self:SetUniformsPstr(material:GetDataPstr()) + CodeRenderableUnlockCCD(self) +end + +function Mesh:GetCRMaterial() + return self.CRMaterial +end + +if FirstLoad then + MeshTextureRefCount = {} +end + +local function ModifyMeshTextureRefCount(id, change) + if id == 0 then return end + local old = MeshTextureRefCount[id] or 0 + local new = old + change + if new == 0 then + MeshTextureRefCount[id] = nil + ProceduralMeshReleaseResource(id) + else + MeshTextureRefCount[id] = new + end +end + +function Mesh:SetTexture(idx, resource_id) + assert(idx >= 0 and idx <= 1) + if self:GetTexture(idx) == resource_id then return end + ModifyMeshTextureRefCount(self:GetTexture(idx), -1) + SetCustomData(self, const.CRMeshCCDIndexTexture0 + idx, resource_id or 0) + ModifyMeshTextureRefCount(resource_id, 1) +end + +function Mesh:GetTexture(idx) + assert(idx >= 0 and idx <= 1) + return GetCustomData(self, const.CRMeshCCDIndexTexture0 + idx) or 0 +end + +function Mesh:Done() + KeepRefOneFrame(self.vertices_pstr) + KeepRefOneFrame(self.uniforms_pstr) + self.vertices_pstr = nil + self.uniforms_pstr = nil + self:SetTexture(0, 0) + self:SetTexture(1, 0) +end + +function OnMsg.DoneMap() + for key, value in pairs(MeshTextureRefCount) do + ProceduralMeshReleaseResource(key) + end + MeshTextureRefCount = {} +end + +function Mesh:SetCustomData(idx, data) + assert(idx > const.CRMeshCCDIndexGeometry, "Use SetMesh instead!") + return SetCustomData(self, idx, data) +end +function Mesh:GetDepthTest() return (GetCustomData(self, const.CRMeshCCDIndexPipeline) >> 31) == 1 end +function Mesh:SetMeshFlags(flags) SetCustomData(self, const.CRMeshCCDIndexMeshFlags, flags) end +function Mesh:GetMeshFlags() return GetCustomData(self, const.CRMeshCCDIndexMeshFlags) end +function Mesh:AddMeshFlags(flags) self:SetMeshFlags(flags | self:GetMeshFlags()) end +function Mesh:ClearMeshFlags(flags) self:SetMeshFlags(~flags & self:GetMeshFlags()) end + +function Mesh.ColorFromTextStyle(id) + assert(TextStyles[id]) + return TextStyles[id].TextColor +end + +function AppendCircleVertices(vpstr, center, radius, color, strip) + local HSeg = 32 + vpstr = vpstr or pstr("", 1024) + color = color or RGB(254, 127, 156) + center = center or point30 + local x0, y0, z0 + for i = 0, HSeg do + local x, y, z = RotateRadius(radius, MulDivRound(360 * 60, i, HSeg), center, true) + AppendVertex(vpstr, x, y, z, color) + if not strip then + if i ~= 0 then + AppendVertex(vpstr, x, y, z, color) + if i == HSeg then + AppendVertex(vpstr, x0, y0, z0) + end + else + x0, y0, z0 = x, y, z + end + end + end + + return vpstr +end + +function AppendTileVertices(vstr, x, y, z, tile_size, color, offset_z, get_height) + offset_z = offset_z or 0 + z = z or InvalidZ + local d = tile_size / 2 + local x1, y1, z1 = x - d, y - d + local x2, y2, z2 = x + d, y - d + local x3, y3, z3 = x - d, y + d + local x4, y4, z4 = x + d, y + d + get_height = get_height or GetHeight + if z ~= InvalidZ and z ~= get_height(x, y) then + z = z + offset_z + z1, z2, z3, z4 = z, z, z, z + else + z1 = get_height(x1, y1) + offset_z + z2 = get_height(x2, y2) + offset_z + z3 = get_height(x3, y3) + offset_z + z4 = get_height(x4, y4) + offset_z + end + AppendVertex(vstr, x1, y1, z1, color) + AppendVertex(vstr, x2, y2, z2, color) + AppendVertex(vstr, x3, y3, z3, color) + AppendVertex(vstr, x4, y4, z4, color) + AppendVertex(vstr, x2, y2, z2, color) + AppendVertex(vstr, x3, y3, z3, color) +end + +function GetSizePstrTile() + return 6 * const.pstrVertexSize +end + +function AppendTorusVertices(vpstr, radius1, radius2, axis, color, normal) + local HSeg = 32 + local VSeg = 10 + vpstr = vpstr or pstr("", 1024) + local rad1 = Rotate(axis, 90 * 60) + rad1 = Cross(axis, rad1) + rad1 = Normalize(rad1) + rad1 = MulDivRound(rad1, radius1, 4096) + for i = 1, HSeg do + local localCenter1 = RotateAxis(rad1, axis, MulDivRound(360 * 60, i, HSeg)) + local localCenter2 = RotateAxis(rad1, axis, MulDivRound(360 * 60, i - 1, HSeg)) + local lastUpperPt, lastPt + if not normal or not IsPointInFrontOfPlane(point(0, 0, 0), normal, (localCenter1 + localCenter2) / 2) then + for j = 0, VSeg do + local rad2 = MulDivRound(localCenter1, radius2, radius1) + local localAxis = Cross(rad2, axis) + local pt = RotateAxis(rad2, localAxis, MulDivRound(360 * 60, j, VSeg)) + pt = localCenter1 + pt + rad2 = MulDivRound(localCenter2, radius2, radius1) + localAxis = Cross(rad2, axis) + local upperPt = RotateAxis(rad2, localAxis, MulDivRound(360 * 60, j, VSeg)) + upperPt = localCenter2 + upperPt + if j ~= 0 then + AppendVertex(vpstr, pt, color) + AppendVertex(vpstr, lastPt) + AppendVertex(vpstr, upperPt) + AppendVertex(vpstr, upperPt, color) + AppendVertex(vpstr, lastUpperPt) + AppendVertex(vpstr, lastPt) + end + lastPt = pt + lastUpperPt = upperPt + end + end + end + + return vpstr +end + +function AppendConeVertices(vpstr, center, displacement, radius1, radius2, axis, angle, color, offset) + local HSeg = 10 + vpstr = vpstr or pstr("", 1024) + center = center or point(0, 0, 0) + displacement = displacement or point(0, 0, 30 * guim) + axis = axis or axis_z + angle = angle or 0 + offset = offset or point(0, 0, 0) + color = color or RGB(254, 127, 156) + local lastPt, lastUpperPt + for i = 0, HSeg do + local rad = point(radius1, 0, 0) + local pt = center + Rotate(rad, MulDivRound(360 * 60, i, HSeg)) + local upperRad = point(radius2, 0, 0) + local upperPt = center + displacement + Rotate(upperRad, MulDivRound(360 * 60, i, HSeg)) + pt = RotateAxis(pt, axis, angle * 60) + offset + upperPt = RotateAxis(upperPt, axis, angle * 60) + offset + if i ~= 0 then + AppendVertex(vpstr, pt, color) + AppendVertex(vpstr, lastPt) + AppendVertex(vpstr, upperPt) + if radius2 ~= 0 then + AppendVertex(vpstr, upperPt, color) + AppendVertex(vpstr, lastUpperPt) + AppendVertex(vpstr, lastPt) + end + end + lastPt = pt + lastUpperPt = upperPt + end + + return vpstr +end + +DefineClass.Polyline = +{ + __parents = { "Mesh" }, +} + +function Polyline:Init() + self:SetMeshFlags(const.mfWorldSpace) + self:SetShader(ProceduralMeshShaders.default_polyline) +end + +DefineClass.Vector = { + __parents = {"Polyline"}, +} + +function Vector:Set (a, b, col) + col = col or RGB(255, 255, 255) + a = ValidateZ(a) + b = ValidateZ(b) + self:SetPos(a) + + local vpstr = pstr("", 1024) + + AppendVertex(vpstr, a, col) + AppendVertex(vpstr, b) + + local ab = b - a + local cb = (ab * 5) / 100 + local f = cb:Len() / 4 + local c = b - cb + + local n = 4 + local ps = GetRadialPoints (n, c, cb, f) + for i = 1 , n/2 do + AppendVertex(vpstr, ps[i]) + AppendVertex(vpstr, ps[i + n/2]) + AppendVertex(vpstr, b) + end + self:SetMesh(vpstr) +end + +function Vector:GetA() + return self:GetPos() +end + +function ShowVector(vector, origin, color, time) + local v = PlaceObject("Vector") + origin = origin:z() and origin or point(origin:x(), origin:y(), GetWalkableZ(origin)) + vector = vector:z() and vector or point(vector:x(), vector:y(), 0) + v:Set(origin, origin + vector, color) + if time then + CreateGameTimeThread(function() + Sleep(time) + DoneObject(v) + end) + end + + return v +end + +DefineClass.Segment = { + __parents = {"Polyline"}, +} + +function Segment:Init() + self:SetDepthTest(false) +end + +function Segment:Set (a, b, col) + col = col or RGB(255, 255, 255) + a = ValidateZ(a) + b = ValidateZ(b) + self:SetPos(a) + local vpstr = pstr("", 1024) + AppendVertex(vpstr, a, col) + AppendVertex(vpstr, b) + self:SetMesh(vpstr) +end + +-- After loading the code renderables from C, fix their string custom data in the Lua +function OnMsg.PersistLoad(_dummy_) + MapForEach(true, "Text", function(obj) + SetCustomData(obj, const.CRTextCCDIndexText, obj.text or 0) + end) + MapForEach(true, "Mesh", function(obj) + CodeRenderableLockCCD(obj) + SetCustomData(obj, const.CRMeshCCDIndexGeometry, obj.vertices_pstr or 0) + SetCustomData(obj, const.CRMeshCCDIndexUniforms, obj.uniforms_pstr or 0) + CodeRenderableUnlockCCD(obj) + end) +end + +---- + +function PlaceTerrainCircle(center, radius, color, step, offset, max_steps) + step = step or guim + offset = offset or guim + local steps = Min(Max(12, (44 * radius) / (7 * step)), max_steps or 360) + local last_pt + local mapw, maph = terrain.GetMapSize() + local vpstr = pstr("", 1024) + for i = 0,steps do + local x, y = RotateRadius(radius, MulDivRound(360*60, i, steps), center, true) + x = Clamp(x, 0, mapw - height_tile) + y = Clamp(y, 0, maph - height_tile) + AppendVertex(vpstr, x, y, offset, color) + end + + local line = PlaceObject("Polyline") + line:SetMesh(vpstr) + line:SetPos(center) + line:AddMeshFlags(const.mfTerrainDistorted) + return line +end + +local function GetTerrainPointsPStr(vpstr, pt1, pt2, step, offset, color) + step = step or guim + offset = offset or guim + local diff = pt2 - pt1 + local steps = Max(2, 1 + diff:Len2D() / step) + local mapw, maph = terrain.GetMapSize() + vpstr = vpstr or pstr("", 1024) + for i=1,steps do + local pos = pt1 + MulDivRound(diff, i - 1, steps - 1) + local x, y = pos:xy() + x = Clamp(x, 0, mapw - height_tile) + y = Clamp(y, 0, maph - height_tile) + AppendVertex(vpstr, x, y, offset, color) + end + return vpstr +end + +function PlaceTerrainLine(pt1, pt2, color, step, offset) + local vpstr = GetTerrainPointsPStr(false, pt1, pt2, step, offset, color) + local line = PlaceObject("Polyline") + line:SetMesh(vpstr) + line:SetPos((pt1 + pt2) / 2) + line:AddMeshFlags(const.mfTerrainDistorted) + return line +end + +function PlaceTerrainBox(box, color, step, offset, mesh_obj, depth_test) + local p = {box:ToPoints2D()} + local m + for i = 1, #p do + m = GetTerrainPointsPStr(m, p[i], p[i + 1] or p[1], step, offset, color) + end + mesh_obj = mesh_obj or PlaceObject("Polyline") + if depth_test ~= nil then + mesh_obj:SetDepthTest(depth_test) + end + mesh_obj:SetMesh(m) + mesh_obj:SetPos(box:Center()) + mesh_obj:AddMeshFlags(const.mfTerrainDistorted) + return mesh_obj +end + +function PlaceTerrainPoly(p, color, step, offset, mesh_obj) + local m + local center = p[1] + ((p[1] - p[3]) / 2) + for i = 1, #p do + m = GetTerrainPointsPStr(m, p[i], p[i + 1] or p[1], step, offset, color) + end + mesh_obj = mesh_obj or PlaceObject("Polyline") + mesh_obj:SetMesh(m) + mesh_obj:SetPos(center) + return mesh_obj +end + +function PlacePolyLine(pts, clrs, depth_test) + local line = PlaceObject("Polyline") + line:SetEnumFlags(const.efVisible) + if depth_test ~= nil then + line:SetDepthTest(depth_test) + end + local vpstr = pstr("", 1024) + local clr + local pt0 + for i, pt in ipairs(pts) do + if IsValidPos(pt) then + pt0 = pt0 or pt + clr = type(clrs) == "table" and clrs[i] or clrs or clr + AppendVertex(vpstr, pt, clr) + end + end + line:SetMesh(vpstr) + if pt0 then + line:SetPos(pt0) + end + return line +end + +function AppendSplineVertices(spline, color, step, min_steps, max_steps, vpstr) + step = step or guim + min_steps = min_steps or 7 + max_steps = max_steps or 1024 + local len = BS3_GetSplineLength3D(spline) + local steps = Clamp(len / step, min_steps, max_steps) + vpstr = vpstr or pstr("", (steps + 2) * const.pstrVertexSize) + local x, y, z + local x0, y0, z0 = BS3_GetSplinePos(spline, 0) + AppendVertex(vpstr, x0, y0, z0, color) + for i = 1,steps-1 do + local x, y, z = BS3_GetSplinePos(spline, i, steps) + AppendVertex(vpstr, x, y, z, color) + end + local x1, y1, z1 = BS3_GetSplinePos(spline, steps, steps) + AppendVertex(vpstr, x1, y1, z1, color) + return vpstr, point((x0 + x1) / 2, (y0 + y1) / 2, (z0 + z1) / 2) +end + +function PlaceSpline(spline, color, depth_test, step, min_steps, max_steps) + local line = PlaceObject("Polyline") + line:SetEnumFlags(const.efVisible) + if depth_test ~= nil then + line:SetDepthTest(depth_test) + end + local vpstr, pos = AppendSplineVertices(spline, color, step, min_steps, max_steps) + line:SetMesh(vpstr) + line:SetPos(pos) + return line +end + +function PlaceSplines(splines, color, depth_test, start_idx, step, min_steps, max_steps) + local line = PlaceObject("Polyline") + line:SetEnumFlags(const.efVisible) + if depth_test ~= nil then + line:SetDepthTest(depth_test) + end + local count = #(splines or "") + local pos = point30 + local vpstr = pstr("", count * 128 * const.pstrVertexSize) + for i = (start_idx or 1), count do + local _, posi = AppendSplineVertices(splines[i], color, step, min_steps, max_steps, vpstr) + pos = pos + posi + end + if count > 0 then + pos = pos / count + end + line:SetMesh(vpstr) + line:SetPos(pos) + return line +end + +function PlaceBox(box, color, mesh_obj, depth_test) + local p1, p2, p3, p4 = box:ToPoints2D() + local minz, maxz = box:minz(), box:maxz() + local vpstr = pstr("", 1024) + if minz and maxz then + if minz >= maxz - 1 then + for _, p in ipairs{p1, p2, p3, p4, p1} do + local x, y = p:xy() + AppendVertex(vpstr, x, y, minz, color) + end + else + for _, z in ipairs{minz, maxz} do + for _, p in ipairs{p1, p2, p3, p4, p1} do + local x, y = p:xy() + AppendVertex(vpstr, x, y, z, color) + end + end + AppendVertex(vpstr, p2:SetZ(maxz), color) + AppendVertex(vpstr, p2:SetZ(minz), color) + AppendVertex(vpstr, p3:SetZ(minz), color) + AppendVertex(vpstr, p3:SetZ(maxz), color) + AppendVertex(vpstr, p4:SetZ(maxz), color) + AppendVertex(vpstr, p4:SetZ(minz), color) + end + else + local z = terrain.GetHeight(p1) + for _, p in ipairs{p2, p3, p4} do + z = Max(z, terrain.GetHeight(p)) + end + for _, p in ipairs{p1, p2, p3, p4, p1} do + local x, y = p:xy() + AppendVertex(vpstr, x, y, z, color) + end + end + mesh_obj = mesh_obj or PlaceObject("Polyline") + if depth_test ~= nil then + mesh_obj:SetDepthTest(depth_test) + end + mesh_obj:SetMesh(vpstr) + mesh_obj:SetPos(box:Center()) + return mesh_obj +end + +function PlaceVector(pos, vec, color, depth_test) + vec = vec or 10*guim + vec = type(vec) == "number" and point(0, 0, vec) or vec + return PlacePolyLine({pos, pos + vec}, color, depth_test) +end + +function CreateTerrainCursorCircle(radius, color) + color = color or RGB(23, 34, 122) + radius = radius or 30 * guim + + local line = CreateCircleMesh(radius, color) + line:SetPos(GetTerrainCursor()) + line:SetMeshFlags(const.mfOffsetByTerrainCursor + const.mfTerrainDistorted + const.mfWorldSpace) + return line +end + +function CreateTerrainCursorSphere(radius, color) + color = color or RGB(23, 34, 122) + radius = radius or 30 * guim + + local line = PlaceObject("Mesh") + line:SetMesh(CreateSphereVertices(radius, color)) + line:SetShader(ProceduralMeshShaders.mesh_linelist) + line:SetPos(GetTerrainCursor()) + line:SetMeshFlags(const.mfOffsetByTerrainCursor + const.mfTerrainDistorted + const.mfWorldSpace) + return line +end + +function CreateOrientationMesh(pos) + local o_mesh = Mesh:new() + pos = pos or point(0, 0, 0) + o_mesh:SetShader(ProceduralMeshShaders.mesh_linelist) + local r = guim/4 + local vpstr = pstr("", 1024) + AppendVertex(vpstr, point(0, 0, 0), RGB(255, 0, 0)) + AppendVertex(vpstr, point(r, 0, 0)) + AppendVertex(vpstr, point(0, 0, 0), RGB(0, 255, 0)) + AppendVertex(vpstr, point(0, r, 0)) + AppendVertex(vpstr, point(0, 0, 0), RGB(0, 0, 255)) + AppendVertex(vpstr, point(0, 0, r)) + o_mesh:SetMesh(vpstr) + o_mesh:SetPos(pos) + return o_mesh +end + +function CreateSphereMesh(radius, color, precision) + local sphere_mesh = Mesh:new() + sphere_mesh:SetMesh(CreateSphereVertices(radius, color)) + sphere_mesh:SetShader(ProceduralMeshShaders.mesh_linelist) + return sphere_mesh +end + +function PlaceSphere(center, radius, color, depth_test) + local sphere = CreateSphereMesh(radius, color) + if depth_test ~= nil then + sphere:SetDepthTest(depth_test) + end + sphere:SetPos(center) + return sphere +end + +function ShowMesh(time, func, ...) + local ok, meshes = procall(func, ...) + if not ok or not meshes then + return + end + return CreateRealTimeThread(function(meshes, time) + Msg("ShowMesh") + WaitMsg("ShowMesh", time) + if IsValid(meshes) then + DoneObject(meshes) + else + DoneObjects(meshes) + end + end, meshes, time) +end + +function CreateCircleMesh(radius, color, center) + local circle_mesh = Mesh:new() + circle_mesh:SetMesh(AppendCircleVertices(nil, center, radius, color, true)) + circle_mesh:SetShader(ProceduralMeshShaders.default_polyline) + return circle_mesh +end + +function PlaceCircle(center, radius, color, depth_test) + local circle = CreateCircleMesh(radius, color) + if depth_test ~= nil then + circle:SetDepthTest(depth_test) + end + circle:SetPos(center) + return circle +end + +function CreateConeMesh(center, displacement, radius1, radius2, axis, angle, color) + local circle_mesh = Mesh:new() + circle_mesh:SetMesh(AppendConeVertices(nil, center, displacement, radius1, radius2, axis, angle, color)) + circle_mesh:SetShader(ProceduralMeshShaders.mesh_linelist) + return circle_mesh +end + +function CreateCylinderMesh(center, displacement, radius, axis, angle, color) + local circle_mesh = Mesh:new() + circle_mesh:SetMesh(AppendConeVertices(nil, center, displacement, radius, radius, axis, angle, color)) + circle_mesh:SetShader(ProceduralMeshShaders.default_mesh) + return circle_mesh +end + +function CreateMoveGizmo() + local g_MoveGizmo = MoveGizmo:new() + CreateRealTimeThread(function() + while true do + g_MoveGizmo:OnMousePos(GetTerrainCursor()) + Sleep(100) + end + end) +end + +function CreateTerrainCursorTorus(radius1, radius2, axis, angle, color) + color = color or RGB(255, 0, 0) + radius1 = radius1 or 2.3 * guim + radius2 = radius2 or 0.15 * guim + axis = axis or axis_y + angle = angle or 90 + + local line = PlaceObject("Mesh") + local vpstr = pstr("", 1024) + local normal = selo():GetPos() - camera.GetEye() + local b = selo():GetPos() + local bigTorusAxis, bigTorusAngle = GetAxisAngle(normal, axis_z) + bigTorusAxis = Normalize(bigTorusAxis) + bigTorusAngle = 180 - bigTorusAngle / 60 + vpstr = AppendTorusVertices(vpstr, point(0, 0, 0), 2.3 * guim, 0.15 * guim, bigTorusAxis, bigTorusAngle, RGB(128, 128, 128)) + vpstr = AppendTorusVertices(vpstr, point(0, 0, 0), 2.3 * guim, 0.15 * guim, axis_y, 90, RGB(255, 0, 0), normal, b) + vpstr = AppendTorusVertices(vpstr, point(0, 0, 0), 2.3 * guim, 0.15 * guim, axis_x, 90, RGB(0, 255, 0), normal, b) + vpstr = AppendTorusVertices(vpstr, point(0, 0, 0), 2.3 * guim, 0.15 * guim, axis_z, 0, RGB(0, 0, 255), normal, b) + vpstr = AppendTorusVertices(vpstr, point(0, 0, 0), 3.5 * guim, 0.15 * guim, bigTorusAxis, bigTorusAngle, RGB(0, 192, 192)) + line:SetMesh(vpstr) + line:SetPos(selo():GetPos()) + return line +end + +function CreateObjSurfaceMesh(obj, surface_flag, color1, color2) + if not IsValidPos(obj) then + return + end + local v_pstr = pstr("", 1024) + ForEachSurface(obj, surface_flag, function(pt1, pt2, pt3, v_pstr, color1, color2) + local color + if color1 and color2 then + local rand = xxhash(pt1, pt2, pt3) % 1024 + color = InterpolateRGB(color1, color2, rand, 1024) + end + v_pstr:AppendVertex(pt1, color) + v_pstr:AppendVertex(pt2, color) + v_pstr:AppendVertex(pt3, color) + end, v_pstr, color1, color2) + local mesh = PlaceObject("Mesh") + mesh:SetMesh(v_pstr) + mesh:SetPos(obj:GetPos()) + mesh:SetMeshFlags(const.mfWorldSpace) + mesh:SetDepthTest(true) + if color1 and not color2 then + mesh:SetColorModifier(color1) + end + return mesh +end + +function FlatImageMesh(texture, width, height, glow_size, glow_period, glow_color) + local text = PlaceObject("Mesh") + local vpstr = pstr("", 1024) + local color = RGB(255,255,255) + local half_size_x = width or 1000 + local half_size_y = height or 1000 + glow_size = glow_size or 0 + glow_period = glow_period or 0 + glow_color = glow_color or RGB(255,255,255) + + AppendVertex(vpstr, point(-half_size_x, -half_size_y, 0), color, 0, 0) + AppendVertex(vpstr, point(half_size_x, -half_size_y, 0), color, 1, 0) + AppendVertex(vpstr, point(-half_size_x, half_size_y, 0), color, 0, 1) + + AppendVertex(vpstr, point(half_size_x, -half_size_y, 0), color, 1, 0) + AppendVertex(vpstr, point(half_size_x, half_size_y, 0), color, 1, 1) + AppendVertex(vpstr, point(-half_size_x, half_size_y, 0), color, 0, 1) + + text:SetMesh(vpstr) + + if texture then + local use_sdf = false + local padding = 0 + local low_edge = 0 + local high_edge = 0 + if glow_size > 0 then + use_sdf = true + padding = 16 + low_edge = 490 + high_edge = 510 + end + text:SetTexture(0, ProceduralMeshBindResource("texture", texture, false, 0)) + if glow_size > 0 then + text:SetTexture(1, ProceduralMeshBindResource("texture", texture, true, 0, const.fmt_unorm16_c1)) + text:SetShader(ProceduralMeshShaders.default_ui_sdf) + else + text:SetShader(ProceduralMeshShaders.default_ui) + end + local r, g, b = GetRGB(glow_color) + text:SetUniforms(low_edge, high_edge, glow_size, glow_period, r, g, b) + end + + return text +end + +DefineClass.FlatTextMesh = { + __parents = { "Mesh" }, + properties = { + {id = "font_id", editor = "number", read_only = true, default = 0, category = "Rasterize" }, + {id = "text_style_id", editor = "preset_id", preset_class = "TextStyle", editor_preview = true, default = false, category = "Rasterize" }, + {id = "text_scale", editor = "number", default = 1000, category = "Rasterize" }, + {id = "text", editor = "text", default = "", category = "Rasterize" }, + {id = "padding", editor = "number", default = 0, category = "Rasterize", help = "How much pixels to leave around the text(for effects)"}, + + {id = "width", editor = "number", default = 0, category = "Present", help = "In meters. Leave 0 to calculate automatically"}, + {id = "height", editor = "number", default = 0, category = "Present", help = "In meters. Leave 0 to calculate automatically"}, + {id = "text_color", editor = "color", default = RGB(255,255,255), category = "Present"}, + {id = "effect_type", editor = "choice", items = {"none", "glow"}, default = "glow", category = "Present" }, + {id = "effect_color", editor = "color", default = RGB(255,255,255), category = "Present"}, + {id = "effect_size", editor = "number", default = 0, help = "In pixels from the rasterized image.", category = "Present" }, + {id = "effect_period", editor = "number", default = 0, help = "1 pulse per each period seconds. ", category = "Present"}, + } +} + +function FlatTextMesh:Init() + self:Recreate() +end + +function FlatTextMesh:FetchEffectsFromTextStyle() + local text_style = TextStyles[self.text_style_id] + if not text_style then return end + self.text_color = text_style.TextColor + self.effect_type = text_style.ShadowType == "glow" and "glow" or "none" + self.effect_color = text_style.ShadowColor + self.effect_size = text_style.ShadowSize + self.textstyle_id = self.text_style_id +end + +function FlatTextMesh:SetColorFromTextStyle(text_style_id) + self.text_style_id = text_style_id + self.textstyle_id = text_style_id + self:FetchEffectsFromTextStyle() + self:Recreate() +end + +function FlatTextMesh:CalculateSizes(max_width, max_height, default_scale) + local width_pixels, height_pixels = UIL.MeasureText(self.text, self.font_id) + local scale = 0 + if max_width == 0 and max_height == 0 then + scale = default_scale or 10000 + elseif max_width == 0 then + max_width = 1000000 + elseif max_height == 0 then + max_height = 1000000 + end + + if scale == 0 then + local scale1 = MulDivRound(max_width, 1000, width_pixels) + local scale2 = MulDivRound(max_height, 1000, height_pixels) + scale = Min(scale1, scale2) + end + + self.width = MulDivRound(width_pixels, scale, 1000) + self.height = MulDivRound(height_pixels, scale, 1000) +end + +function FlatTextMesh:Recreate() + local text_style = TextStyles[self.text_style_id] + if not text_style then return end + local font_id = text_style:GetFontIdHeightBaseline(self.text_scale) + self.font_id = font_id + + local effect_type = self.effect_type + local use_sdf = false + local padding = 0 + if effect_type == "glow" then + use_sdf = true + padding = 16 + end + + local width_pixels, height_pixels = UIL.MeasureText(self.text, font_id) + local width = self.width + local height = self.height + if width == 0 and height == 0 then + local default_scale = 10000 + width = MulDivRound(width_pixels, default_scale, 1000) + height = MulDivRound(height_pixels, default_scale, 1000) + end + if width == 0 then + width = MulDivRound(width_pixels, height, height_pixels) + end + if height == 0 then + height = MulDivRound(height_pixels, width, width_pixels) + end + + --add for padding + width = width + MulDivRound(width, padding * 2 * 1000, width_pixels * 1000) + height = height + MulDivRound(height, padding * 2 * 1000, height_pixels * 1000) + + + local vpstr = pstr("", 1024) + local half_size_x = (width or 1000) / 2 + local half_size_y = (height or 1000) / 2 + local color = self.text_color + AppendVertex(vpstr, point(-half_size_x, -half_size_y, 0), color, 0, 0) + AppendVertex(vpstr, point(half_size_x, -half_size_y, 0), color, 1, 0) + AppendVertex(vpstr, point(-half_size_x, half_size_y, 0), color, 0, 1) + + AppendVertex(vpstr, point(half_size_x, -half_size_y, 0), color, 1, 0) + AppendVertex(vpstr, point(half_size_x, half_size_y, 0), color, 1, 1) + AppendVertex(vpstr, point(-half_size_x, half_size_y, 0), color, 0, 1) + + self:SetMesh(vpstr) + + self:SetTexture(0, ProceduralMeshBindResource("text", self.text, font_id, use_sdf, padding)) + local r, g, b = GetRGB(self.effect_color) + self:SetUniforms(use_sdf and 1000 or 0, 0, self.effect_size * 1000, self.effect_period, r, g, b) + self:SetShader(ProceduralMeshShaders.default_ui) +end + +function TestUIRenderables() + local pt = GetTerrainCursor() + point(0, 0, 100) + for i = 0, 4 do + local height = 700 + local space = 5000 + + local text = FlatTextMesh:new({ + text_style_id = "ProcMeshDefault", + text_scale = 500 + 400 * i, + text = "Hello world", + height = height, + }) + text:SetPos(pt + point(i * space, 0, 0)) + + text = FlatTextMesh:new({ + text_style_id = "ProcMeshDefaultFX", + text_scale = 500 + 400 * i, + text = "Hello world", + height = height, + effect_type = "glow", + effect_size = 8, + effect_period = 200, + effect_color = RGB(255, 0, 0), + }) + text:SetPos(pt + point(i * space, 3000, 0)) + text:SetGameFlags(const.gofRealTimeAnim) + + local mesh = FlatImageMesh("UI/MercsPortraits/Buns", 1000, 1000, 200 * i, 1000, RGB(255, 255, 255)) + mesh:SetPos(pt + point(i * space, 6000, 0)) + + mesh = FlatImageMesh("UI/MercsPortraits/Buns", 1000, 1000) + mesh:SetPos(pt + point(i * space, 9000, 0)) + end +end + +function DebugShowMeshes() + local meshes = MapGet("map", "Mesh") + OpenGedGameObjectEditor(meshes, true) +end + +-- Represents combination of shader & the data the shader accepts. Provides "properties" interface for the underlying raw bits sent to the shader. +-- Inherits from PersistedRenderVars(is a preset) and provides minimalistic default logic for updating meshes on the fly. +local function depth_test_values(obj) + local tbl = {{value = "default", text = "default"}} + local shader_id = obj.shader_id + local shader_data = ProceduralMeshShaders[shader_id] + if shader_data then + if shader_data.depth_test == "runtime" or shader_data.depth_test == "never" then + table.insert(tbl, {value = false, text = "never"}) + end + if shader_data.depth_test == "runtime" or shader_data.depth_test == "always" then + table.insert(tbl, {value = true, text = "always"}) + end + end + return tbl +end +DefineClass.CRMaterial = { + __parents = {"PersistedRenderVars", "MeshParamSet"}, + + properties = { + { id = "ShaderName", editor = "choice", default = "default_mesh", items = function() return table.keys2(ProceduralMeshShaders, "sorted") end, read_only = true,}, + { id = "depth_test", editor = "choice", items = depth_test_values }, + }, + group = "CRMaterial", + depth_test = "default", + cloned_from = false, + shader_id = "default_mesh", + shader = false, + pstr_buffer = false, + dirty = false, +} + +function CRMaterial:GetError() + if not self.shader_id then + return "CRMaterial without a shader_id." + end + if not ProceduralMeshShaders[self.shader_id] then + return "ShaderID " .. self.shader_id .. " is not valid." + end +end + +function CRMaterial:GetShader() + if self.shader then + return self.shader + end + if self.shader_id then + return ProceduralMeshShaders[self.shader_id] + end + return false +end + +------- Prevent triggering Preset logic on cloned materials. Probably should be implemented smarter, maybe by __index table reference, so even clones are live updated by editor +function CRMaterial:SetId(value) + if self.cloned_from then + self.id = value + else + PersistedRenderVars.SetId(self, value) + end +end + +function CRMaterial:SetGroup(value) + if self.cloned_from then + self.group = value + else + PersistedRenderVars.SetGroup(self, value) + end +end + +function CRMaterial:Register(...) + if self.cloned_from then + return + end + return PersistedRenderVars.Register(self, ...) +end + +function CRMaterial:Clone() + local obj = _G[self.class]:new({cloned_from = self.id}) + obj:CopyProperties(self) + return obj +end + +function CRMaterial:GetDataPstr() + if self.dirty or not self.pstr_buffer then + self:Recreate() + end + return self.pstr_buffer +end + +function CRMaterial:GetShaderName() + return self.shader and self.shader.id or self.shader_id +end + +function CRMaterial:Recreate() + self.dirty = false + self.pstr_buffer = self:WriteBuffer() +end + +function CRMaterial:OnPreSave() + self.pstr_buffer = nil +end + +function CRMaterial:Apply() + self:Recreate() + if CurrentMap ~= "" then + MapGet("map", "Mesh", function(o) + local omtrl = o.CRMaterial + if omtrl == self then + o:SetCRMaterial(self) + elseif (omtrl and omtrl.id == self.id) then + for _, prop in ipairs(omtrl:GetProperties()) do + local value = rawget(omtrl, prop.id) + if value == nil or (not prop.read_only and not prop.no_edit) then + omtrl:SetProperty(prop.id, self:GetProperty(prop.id)) + end + end + omtrl:Recreate() + o:SetCRMaterial(omtrl) + end + end) + end +end + + + +DefineClass.CRM_DebugMeshMaterial = { + __parents = {"CRMaterial"}, + + shader_id = "debug_mesh", + properties = { + }, +} + diff --git a/CommonLua/Classes/CollectionAnimator.lua b/CommonLua/Classes/CollectionAnimator.lua new file mode 100644 index 0000000000000000000000000000000000000000..67272ebb8cd253b86ad081c80946d83eddf60558 --- /dev/null +++ b/CommonLua/Classes/CollectionAnimator.lua @@ -0,0 +1,172 @@ +DefineClass.CollectionAnimator = { + __parents = { "Object", "EditorEntityObject", "EditorObject" }, + --entity = "WayPoint", + editor_entity = "WayPoint", + properties = { + { name = "Rotate Speed", id = "rotate_speed", category = "Animator", editor = "number", default = 0, scale = 100, help = "Revolutions per minute" }, + { name = "Oscillate Offset", id = "oscillate_offset", category = "Animator", editor = "point", default = point30, scale = "m", help = "Map offset acceleration movement up and down (in meters)" }, + { name = "Oscillate Cycle", id = "oscillate_cycle", category = "Animator", editor = "number", default = 0, help = "Full cycle time in milliseconds" }, + { name = "Locked Orientation", id = "LockedOrientation", category = "Animator", editor = "bool", default = false }, + }, + animated_obj = false, + rotation_thread = false, + move_thread = false, +} + +DefineClass.CollectionAnimatorObj = { + __parents = { "Object", "ComponentAttach" }, + flags = { cofComponentInterpolation = true, efWalkable = false, efApplyToGrids = false, efCollision = false }, + properties = { + -- exclude properties to not copy them + { id = "Pos" }, + { id = "Angle" }, + { id = "Axis" }, + { id = "Walkable" }, + { id = "ApplyToGrids" }, + { id = "Collision" }, + { id = "OnCollisionWithCamera" }, + { id = "CollectionIndex" }, + { id = "CollectionName" }, + }, +} + +function CollectionAnimator:GameInit() + self:StartAnimate() +end + +function CollectionAnimator:Done() + self:StopAnimate() +end + +function CollectionAnimator:StartAnimate() + if self.animated_obj then + return -- already started + end + if not self:AttachObjects() then + return + end + -- rotation + if not self.rotation_thread and self.rotate_speed ~= 0 then + self.rotation_thread = CreateGameTimeThread(function() + local obj = self.animated_obj + obj:SetAxis(self:RotateAxis(0,0,4096)) + local a = 162*60*(self.rotate_speed < 0 and -1 or 1) + local t = 27000 * 100 / abs(self.rotate_speed) + while true do + obj:SetAngle(obj:GetAngle() + a, t) + Sleep(t) + end + end) + end + -- movement + if not self.move_thread and self.oscillate_cycle >= 100 and self.oscillate_offset:Len() > 0 then + self.move_thread = CreateGameTimeThread(function() + local obj = self.animated_obj + local pos = self:GetVisualPos() + local vec = self.oscillate_offset + local t = self.oscillate_cycle/4 + local acc = self:GetAccelerationAndStartSpeed(pos+vec, 0, t) + while true do + obj:SetAcceleration(acc) + obj:SetPos(pos+vec, t) + Sleep(t) + obj:SetAcceleration(-acc) + obj:SetPos(pos, t) + Sleep(t) + obj:SetAcceleration(acc) + obj:SetPos(pos-vec, t) + Sleep(t) + obj:SetAcceleration(-acc) + obj:SetPos(pos, t) + Sleep(t) + end + end) + end +end + +function CollectionAnimator:StopAnimate() + DeleteThread(self.rotation_thread) + self.rotation_thread = nil + DeleteThread(self.move_thread) + self.move_thread = nil + self:RestoreObjects() +end + +function CollectionAnimator:AttachObjects() + local col = self:GetCollection() + if not col then + return false + end + SuspendPassEdits("CollectionAnimator") + local obj = PlaceObject("CollectionAnimatorObj") + self.animated_obj = obj + local pos = self:GetPos() + local max_offset = 0 + MapForEach (col.Index, false, "map", "attached", false, function(o) + if o == self then return end + local o_pos, o_axis, o_angle = o:GetVisualPos(), o:GetAxis(), o:GetAngle() + local o_offset = o_pos - pos + --if o:IsKindOf("ComponentAttach") then + o:DetachFromMap() + o:SetAngle(0) -- fixes a problem when attaching + obj:Attach(o) + --else + -- local clone = PlaceObject("CollectionAnimatorObj") + -- clone:ChangeEntity(o:GetEntity()) + -- clone:CopyProperties(o) + --end + o:SetAttachAxis(o_axis) + o:SetAttachAngle(o_angle) + o:SetAttachOffset(o_offset) + max_offset = Max(max_offset, o_offset:Len()) + end) + if max_offset > 20*guim then + obj:SetGameFlags(const.gofAlwaysRenderable) + end + if self.LockedOrientation then + obj:SetHierarchyGameFlags(const.gofLockedOrientation) + end + obj:ClearHierarchyEnumFlags(const.efWalkable + const.efApplyToGrids + const.efCollision) + obj:SetPos(pos) + ResumePassEdits("CollectionAnimator") + return true +end + +function CollectionAnimator:RestoreObjects() + local obj = self.animated_obj + if not obj then + return + end + SuspendPassEdits("CollectionAnimator") + self.animated_obj = nil + obj:SetPos(self:GetPos()) + obj:SetAxis(axis_z) + obj:SetAngle(0) + for i = obj:GetNumAttaches(), 1, -1 do + local o = obj:GetAttach(i) + local o_pos, o_axis, o_angle = o:GetAttachOffset(), o:GetAttachAxis(), o:GetAttachAngle() + o:Detach() + o:SetPos(o:GetPos() + o_pos) + o:SetAxis(o_axis) + o:SetAngle(o_angle) + o:ClearGameFlags(const.gofLockedOrientation) + end + DoneObject(obj) + ResumePassEdits("CollectionAnimator") +end + +function CollectionAnimator:EditorEnter() + self:StopAnimate() +end + +function CollectionAnimator:EditorExit() + self:StartAnimate() +end + +function OnMsg.PreSaveMap() + MapForEach("map", "CollectionAnimator", function(obj) obj:StopAnimate() end) +end + +function OnMsg.PostSaveMap() + MapForEach("map", "CollectionAnimator", function(obj) obj:StartAnimate() end) +end diff --git a/CommonLua/Classes/ColorModifierReason.lua b/CommonLua/Classes/ColorModifierReason.lua new file mode 100644 index 0000000000000000000000000000000000000000..1ee522c04b3e4085685d937ef54eebb797e5da80 --- /dev/null +++ b/CommonLua/Classes/ColorModifierReason.lua @@ -0,0 +1,188 @@ +ColorModifierReasons = { + --override this in your project + --{id = "reason_name", weight = default reason weight number, color = default reason color}, +} + +MapVar("ColorModifierReasonsData", false) +local table_find = table.find +local SetColorModifier = CObject.SetColorModifier +local GetColorModifier = CObject.GetColorModifier +local clrNoModifier = const.clrNoModifier +local default_color_modifier = RGBA(100, 100, 100, 0) + +function SetColorModifierReason(obj, reason, color, weight, blend, skip_attaches) + assert(reason) + if not reason then + return + end + local color_value = color + + if obj:GetRadius() > 0 then + local data = ColorModifierReasonsData + if not data then + data = {} + ColorModifierReasonsData = data + end + local mrt = data[obj] + local orig_color + if not mrt then + orig_color = GetColorModifier(obj) + mrt = { orig_color = orig_color } + data[obj] = mrt + end + + local rt = ColorModifierReasons + local idx = table_find(rt, "id", reason) + local rdata = idx and rt[idx] or false + + color = color or rdata and rdata.color or nil + if not color then + printf("[WARNING] SetColorModifierReason no color! reason %s, color %s, weight %s", reason, tostring(color), tostring(weight)) + return + end + weight = weight or rdata and rdata.weight or const.DefaultColorModWeight + if not weight then + printf("[WARNING] SetColorModifierReason no weight! reason %s, color %s, weight %s", reason, tostring(color), tostring(weight)) + return + end + + if blend then + orig_color = orig_color or mrt.orig_color + if orig_color ~= clrNoModifier then + color = InterpolateRGB(orig_color, color, blend, 100) + end + end + local idx = table_find(mrt, "reason", reason) + local entry = idx and mrt[idx] + if entry then + entry.weight = weight + entry.color = color + else + entry = { reason = reason, weight = weight, color = color } + table.insert(mrt, entry) + end + table.stable_sort(mrt, function(a, b) + return a.weight < b.weight + end) + SetColorModifier(obj, mrt[#mrt].color) + end + if skip_attaches then + return + end + obj:ForEachAttach(SetColorModifierReason, reason, color_value, weight, blend) +end + +function SetOrigColorModifier(obj, color, skip_attaches) + local data = ColorModifierReasonsData + local mrt = data and data[obj] + if not mrt then + SetColorModifier(obj, color) + else + mrt.orig_color = color + end + if skip_attaches then return end + obj:ForEachAttach(SetOrigColorModifier, color) +end + +function GetOrigColorModifier(obj) + local modifier = GetColorModifier(obj) + return modifier == default_color_modifier and table.get(ColorModifierReasonsData, obj, "orig_color") or modifier +end + +function ValidateColorReasons() + table.validate_map(ColorModifierReasonsData) +end + +function ClearColorModifierReason(obj, reason, skip_color_change, skip_attaches) + assert(reason) + if not reason then + return + end + local data = ColorModifierReasonsData + local mrt = data and data[obj] + if mrt then + if not IsValid(obj) then + data[obj] = nil + return + end + local idx = table_find(mrt, "reason", reason) + if not idx then + return + end + local update = idx == #mrt + table.remove(mrt, idx) + if #mrt == 0 then + data[obj] = nil + DelayedCall(1000, ValidateColorReasons) + if not next(data) then + ColorModifierReasonsData = false + end + end + if update and not skip_color_change then + local active = mrt[#mrt] + local color = active and active.color or mrt.orig_color or const.clrNoModifier + SetColorModifier(obj, color) + end + end + if skip_attaches then + return + end + obj:ForEachAttach(ClearColorModifierReason, reason, skip_color_change) +end + +function ClearColorModifierReasons(obj) + local data = ColorModifierReasonsData + local mrt = data and data[obj] + if not mrt then + return + end + if IsValid(obj) then + SetColorModifier(obj, mrt.orig_color or const.clrNoModifier) + obj:ForEachAttach(ClearColorModifierReasons) + end + data[obj] = nil +end + +OnMsg.StartSaveGame = ValidateColorReasons + +---- + +MapVar("InvisibleReasons", {}, weak_keys_meta) + +local efVisible = const.efVisible + +function SetInvisibleReason(obj, reason) + local invisible_reasons = InvisibleReasons + local obj_reasons = invisible_reasons[obj] + if obj_reasons then + obj_reasons[reason] = true + return + end + invisible_reasons[obj] = { [reason] = true } + obj:ClearHierarchyEnumFlags(efVisible) +end + +function ClearInvisibleReason(obj, reason) + local invisible_reasons = InvisibleReasons + local obj_reasons = invisible_reasons[obj] + if not obj_reasons or not obj_reasons[reason] then + return + end + obj_reasons[reason] = nil + if next(obj_reasons) then + return + end + invisible_reasons[obj] = nil + obj:SetHierarchyEnumFlags(efVisible) +end + +function ClearInvisibleReasons(obj) + local invisible_reasons = InvisibleReasons + if not invisible_reasons[obj] then + return + end + invisible_reasons[obj] = nil + obj:SetHierarchyEnumFlags(efVisible) +end + +---- \ No newline at end of file diff --git a/CommonLua/Classes/Colorization.lua b/CommonLua/Classes/Colorization.lua new file mode 100644 index 0000000000000000000000000000000000000000..49301fdfdf5f0364dc5533033a707a3993649ec3 --- /dev/null +++ b/CommonLua/Classes/Colorization.lua @@ -0,0 +1,854 @@ +if FirstLoad then + g_DefaultColorsPalette = "Default colors" +end + +local prop_cat = "Colorization Palette" + +DefineClass.ColorizableObject = { + __parents = { "PropertyObject" }, + flags = { cofComponentColorizationMaterial = true }, + properties = { + { category = "Colorization Palette", id = "ColorizationPalette", name = "Colorization Palette", + editor = "choice", + default = g_DefaultColorsPalette, + preset_class = "ColorizationPalettePreset", -- For GedRpcEditPreset + items = function(self) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(self) then + return false + end + + local entity = self:GetEntity() ~= "" and self:GetEntity() or self.class + local palettes = g_EntityToColorPalettes_Cache[entity] + palettes = palettes and table.map(palettes, function(pal) return pal.PaletteName end) or {} + palettes[#palettes + 1] = g_DefaultColorsPalette + palettes[#palettes + 1] = "" + return palettes + end, + no_edit = function(self) + return self:ColorizationPropsNoEdit("palette") and true + end, + dont_save = function(self) + return self:ColorizationPropsDontSave("palette") and true + end, + read_only = function(self) + return self:ColorizationReadOnlyReason("palette") and true + end, + buttons = {{ + name = "Edit", + is_hidden = function(self) + return not IsValid(self) or self:GetColorizationPalette() == "" + end, + -- Open the editor which can edit the colors currently used on the object + func = function(self, root, prop_id, socket) + local palette_value = self:GetColorizationPalette() + local preset_obj + + if palette_value == "" then return end + + -- If palette is "Default colors" => open Art Spec editor + if palette_value == g_DefaultColorsPalette then + local entity = self:GetEntity() ~= "" and self:GetEntity() or self.class + + ForEachPreset("EntitySpec", function(preset) + if preset.id == entity then + preset_obj = preset + return "break" + end + end) + + local ged = OpenPresetEditor("EntitySpec") + if ged then + ged:SetSelection("root", PresetGetPath(preset_obj)) + end + + return + end + + -- If palette is something else => open Colorization Palette editor + local select_idx + ForEachPreset("ColorizationPalettePreset", function(preset) + for idx, entry in ipairs(preset) do + if entry.class == "CPPaletteEntry" and entry.PaletteName == palette_value then + preset_obj = preset + select_idx = idx + return "break" + end + end + end) + + if not preset_obj then + return + end + + GedRpcEditPreset(socket, "SelectedObject", prop_id, preset_obj.id) + + local ged = FindPresetEditor("ColorizationPalettePreset") + if ged then + ged:SetSelection("SelectedPreset", { select_idx }) + end + end + }} + }, + }, + env_colorized = false, +} + +-- Returns if a given entity (by name) can be colorized through the Object editor +local function CanEntityBeColorized(entity) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + local entity_data = EntityData[entity] + if not entity_data then + return false + end + + return ColorizationMaterialsCount(entity) > 0 and not (entity_data.entity and entity_data.entity.env_colorized) +end + +function ColorizableObject:CanBeColorized() + local entity = self:GetEntity() ~= "" and self:GetEntity() or self.class + return not self.env_colorized and CanEntityBeColorized(entity) +end + +function ColorizableObject:GetColorizationPalette() + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(self) then + return + end + + -- Get the value from C++ + return self:GetColorizationPaletteName() +end + +function ColorizableObject:SetColorsFromTable(colors) + if colors.EditableColor1 then + self:SetEditableColor1(colors.EditableColor1) + end + if colors.EditableColor2 then + self:SetEditableColor2(colors.EditableColor2) + end + if colors.EditableColor3 then + self:SetEditableColor3(colors.EditableColor3) + end + + if colors.EditableRoughness1 then + self:SetEditableRoughness1(colors.EditableRoughness1) + end + if colors.EditableRoughness2 then + self:SetEditableRoughness2(colors.EditableRoughness2) + end + if colors.EditableRoughness3 then + self:SetEditableRoughness3(colors.EditableRoughness3) + end + + if colors.EditableMetallic1 then + self:SetEditableMetallic1(colors.EditableMetallic1) + end + if colors.EditableMetallic2 then + self:SetEditableMetallic2(colors.EditableMetallic2) + end + if colors.EditableMetallic3 then + self:SetEditableMetallic3(colors.EditableMetallic3) + end +end + +-- Colorizes the object with the colors from the palette with the given name +-- name == "" or nil => apply previous palette colors +-- name == "Default colors" => apply default entity colors +function ColorizableObject:SetColorsByColorizationPaletteName(palette_name, previous_palette) + -- If we're removing the palette, set the colors to those from the previous palette so they can be easily adjusted + if not palette_name or palette_name == "" then + palette_name = previous_palette or "" + end + + if palette_name == g_DefaultColorsPalette then + -- Set to the Default entity colors defined in the Art Spec editor + local default_colors = self:GetDefaultColorizationSet() + if default_colors then + self:SetColorsFromTable(default_colors) + return + end + + -- Set all the color properties to their default values from the prop meta + self:SetEditableColor1(self:GetDefaultPropertyValue("EditableColor1")) + self:SetEditableColor2(self:GetDefaultPropertyValue("EditableColor2")) + self:SetEditableColor3(self:GetDefaultPropertyValue("EditableColor3")) + + self:SetEditableRoughness1(self:GetDefaultPropertyValue("EditableRoughness1")) + self:SetEditableRoughness2(self:GetDefaultPropertyValue("EditableRoughness2")) + self:SetEditableRoughness3(self:GetDefaultPropertyValue("EditableRoughness3")) + + self:SetEditableMetallic1(self:GetDefaultPropertyValue("EditableMetallic1")) + self:SetEditableMetallic2(self:GetDefaultPropertyValue("EditableMetallic2")) + self:SetEditableMetallic3(self:GetDefaultPropertyValue("EditableMetallic3")) + return + end + + -- If not empty or default => find the palette colors and apply them on the object + local entity = self:GetEntity() ~= "" and self:GetEntity() or self.class + for _, palette in ipairs(g_EntityToColorPalettes_Cache[entity]) do + if palette.PaletteName == palette_name and palette.PaletteColors then + self:SetColorsFromTable(palette.PaletteColors) + break + end + end +end + +local function real_set_modifier(object, setter, value, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if IsValid(object) then + object[setter](object, value, ...) + end +end + +function ColorizableObject:SetColorizationPalette(palette_name) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(self) then + return + end + + palette_name = palette_name or "" + + -- Set the palette name in C++ + self:SetColorizationPaletteName(palette_name) + + -- Apply the colors of the chosen palette + self:SetColorsByColorizationPaletteName(palette_name) +end + +function ColorizableObject:ColorizationPropsNoEdit(i) + if type(i) == "number" then return i > self:GetMaxColorizationMaterials() end + return false +end + +function ColorizableObject:GetMaxColorizationMaterials() + if not IsValid(self) or self.env_colorized then + return const.MaxColorizationMaterials + end + return Min(const.MaxColorizationMaterials, ColorizationMaterialsCount(self)) +end + +function ColorizableObject:OnEditorSetProperty(prop_id, old_value, ged, multi) + -- When pasting a color/roughness/metallic prop in the editor, remove the palette and keep the colors for edit + if string.match(prop_id, "Editable") and self:GetColorizationPalette() ~= "" then + local new_value = self:GetProperty(prop_id) + + -- Removing the palette will set the colors to the palette colors so we have to manually set the property again + self:SetColorizationPalette("") + self:SetProperty(prop_id, new_value) + end +end + +function ColorizableObject:ColorizationReadOnlyReason(usage) + if IsValid(self) and self:GetParent() then + return "Object is an attached one. AutoAttaches are not persisted and colorization is either inherited from the parent or set explicitly in the AutoAttach editor." + end + + local palette_value = self:GetColorizationPalette() + if palette_value and palette_value ~= "" and usage ~= "palette" then + return "A Colorization Palette preset is chosen and the colors are loaded from there." + end + + if IsKindOf(self, "AppearanceObject") then + return "AppearanceObjects are managed in the Appearance Editor." + end + + return false +end + +function ColorizableObject:ColorizationReadOnlyText() + local reason = self:ColorizationReadOnlyReason() + return reason and "Colorization is read only:\n"..reason +end + +function ColorizableObject:ColorizationPropsDontSave(i) + local no_edit_result = self:ColorizationPropsNoEdit(i) + if no_edit_result then + return true + end + if self:ColorizationReadOnlyReason() then + return true -- if they are readonly they probably don't have to be saved(and are initialized by someone else) + end + if type(i) == "number" then + local palette_value = self:GetColorizationPalette() + if palette_value and palette_value ~= "" then + return true + end + end + return false +end + +local default_color = const.ColorPaletteWhitePoint +local default_roughness = 0 +local default_metallic = 0 + +for i = 1, const.MaxColorizationMaterials or 0 do + local color = string.format("Color%d", i) + local roughness = string.format("Roughness%d", i) + local metallic = string.format("Metallic%d", i) + local color_prop = string.format("Editable%s", color) + local roughness_prop = string.format("Editable%s", roughness) + local metallic_prop = string.format("Editable%s", metallic) + local reset = string.format("ResetColorizationMaterial%d", i) + + _G[reset] = function(parentEditor, object, property, ...) + object:SetProperty(color_prop, default_color) + object:SetProperty(roughness_prop, default_roughness) + object:SetProperty(metallic_prop, default_metallic) + ObjModified(object) + end + + local no_edit = function(self) + return self:ColorizationPropsNoEdit(i) and true + end + local no_save = function(self) + return self:ColorizationPropsDontSave(i) and true + end + table.iappend( ColorizableObject.properties, { + { + id = color_prop, + category = prop_cat, + name = string.format("%d: Base Color", i), + editor = "color", default = default_color, + no_edit = no_edit, + dont_save = no_save, + alpha = false, + buttons = {{name = "Reset", func = reset, is_hidden = function(obj) + if IsKindOf(obj, "GedMultiSelectAdapter") then + for _, o in ipairs(obj.__objects) do + if IsKindOf(0, "ColorizableObject") and o:ColorizationReadOnlyReason() then + return true + end + end + return false + end + return obj:ColorizationReadOnlyReason() + end}}, + autoattach_prop = true, + read_only = function(obj) + return obj:ColorizationReadOnlyReason() and true + end, + help = ColorizableObject.ColorizationReadOnlyText, + }, + { + id = roughness_prop, + category = prop_cat, + name = string.format("%d: Roughness", i), + editor = "number", default = default_roughness, slider = true, + min = -128, max = 127, + no_edit = no_edit, + dont_save = no_save, + autoattach_prop = true, + read_only = function(obj) + return obj:ColorizationReadOnlyReason() and true + end, + help = ColorizableObject.ColorizationReadOnlyText, + }, + { + id = metallic_prop, + category = prop_cat, + name = string.format("%d: Metallic", i), + editor = "number", default = default_metallic, slider = true, + min = -128, max = 127, + no_edit = no_edit, + dont_save = no_save, + autoattach_prop = true, + read_only = function(obj) + return obj:ColorizationReadOnlyReason() and true + end, + help = ColorizableObject.ColorizationReadOnlyText, + }, + }) + ColorizableObject[color_prop] = default_color + ColorizableObject[roughness_prop] = default_roughness + ColorizableObject[metallic_prop] = default_metallic + + local set_func_color = string.format("Set%s", color) + ColorizableObject["Set" .. color_prop] = function(object, property, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(object) then + object[color_prop] = property + return + end + + return object[set_func_color](object, property, ...) + end + + local get_func_color = string.format("Get%s", color) + ColorizableObject["Get" .. color_prop] = function(object, property, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(object) then + return object[color_prop] + end + + return object[get_func_color](object) + end + + local set_func_roughness = string.format("Set%s", roughness) + ColorizableObject["Set" .. roughness_prop] = function(object, property, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(object) then + object[roughness_prop] = property + return + end + + return object[set_func_roughness](object, property, ...) + end + + local get_func_roughness = string.format("Get%s", roughness) + ColorizableObject["Get" .. roughness_prop] = function(object, property, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(object) then + return object[roughness_prop] + end + + return object[get_func_roughness](object) + end + + local set_func_metallic = string.format("Set%s", metallic) + ColorizableObject["Set" .. metallic_prop] = function(object, property, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(object) then + object[metallic_prop] = property + return + end + + return object[set_func_metallic](object, property, ...) + end + + local get_func_metallic = string.format("Get%s", metallic) + ColorizableObject["Get" .. metallic_prop] = function(object, property, ...) + -- Filters out ColorizationPropSet and EnvironmentColorEntry (and other descendants of ColorizableObject that are not CObjects) + if not IsValid(object) then + return object[metallic_prop] + end + + return object[get_func_metallic](object) + end +end + +local function GeneratePropNames(prefixes, count) + local t = {} + for i = 1, count do + for _, prefix in ipairs(prefixes) do + table.insert(t, prefix .. i) + end + end + return t +end + +local setter_names = GeneratePropNames({"SetEditableColor", "SetEditableRoughness", "SetEditableMetallic"}, const.MaxColorizationMaterials) +local getter_names = GeneratePropNames({"GetEditableColor", "GetEditableRoughness", "GetEditableMetallic"}, const.MaxColorizationMaterials) +local prop_names = GeneratePropNames({"EditableColor", "EditableRoughness", "EditableMetallic"}, const.MaxColorizationMaterials) +local defaults = {} +for i = 1, const.MaxColorizationMaterials do + table.iappend(defaults, {default_color, default_roughness, default_metallic}) +end + +function ColorizableObject:AreColorsModified() + local count = const.MaxColorizationMaterials + for i = 1, count * 3 do + if not self:IsPropertyDefault(prop_names[i]) then + return true + end + end + return false +end + +function SetColorizationNoSetter(dst, src) + local count = const.MaxColorizationMaterials + for i = 1, count * 3 do + local getter_name = getter_names[i] + local value = src[getter_name](src) + dst[prop_names[i]] = value + end +end + +function SetColorizationNoGetter(dst, src) + local count = const.MaxColorizationMaterials + for i = 1, count * 3 do + local setter_name = setter_names[i] + local value = src[prop_names[i]] + dst[setter_name](dst, value) + end +end + + +function ColorizableObject:GetColorsAsTable() + if not self[getter_names[1]] then + return + end + local ret = nil + local count = self:GetMaxColorizationMaterials() + for i = 1, count * 3 do + local getter_name = getter_names[i] + local prop_name = prop_names[i] + local value = self[getter_name](self) + if value ~= defaults[i] then + ret = ret or {} + ret[prop_name] = value + end + end + + return ret +end + + +function ColorizableObject:SetColorization(obj, ignore_his_max) + if obj then + if not obj[getter_names[1]] then + self:SetColorizationPalette(obj["ColorizationPalette"] or "") + SetColorizationNoGetter(self, obj) + return + end + local his_max = IsKindOf(obj, "ColorizableObject") and obj:GetMaxColorizationMaterials() or const.MaxColorizationMaterials + local count = not ignore_his_max and Min(self:GetMaxColorizationMaterials(), his_max) or self:GetMaxColorizationMaterials() + self:SetColorizationPalette(obj:GetColorizationPalette() or "") + for i = 1, count * 3 do + local setter_name = setter_names[i] + local getter_name = getter_names[i] + local value = obj[getter_name](obj) + self[setter_name](self, value) + end + else + self:SetColorizationPalette("") + local count = self:GetMaxColorizationMaterials() + for i = 1, count * 3 do + self[ setter_names[i] ] ( self, defaults[i] ) + end + end +end + +function ColorizableObject:SetMaterialColor(idx, value) self[setter_names[idx * 3 - 2]](self, value) end +function ColorizableObject:SetMaterialRougness(idx, value) self[setter_names[idx * 3 - 1]](self, value) end +function ColorizableObject:SetMaterialMetallic(idx, value) self[setter_names[idx * 3]] (self, value) end + +function ColorizableObject:GetMaterialColor(idx, value) return self[getter_names[idx * 3 - 2]](self, value) end +function ColorizableObject:GetMaterialRougness(idx, value) return self[getter_names[idx * 3 - 1]](self, value) end +function ColorizableObject:GetMaterialMetallic(idx, value) return self[getter_names[idx * 3]] (self, value) end + +if Platform.developer then + if FirstLoad then + ColorizationMatrixObjects = {} + end + function CreateGameObjectColorizationMatrix() + for key, value in ipairs(ColorizationMatrixObjects) do + DoneObject(value) + end + ColorizationMatrixObjects = {} + + local selected = editor.GetSel() + if not selected or #selected == 0 then + print("Please, select a valid object.") + return false + end + + local first = selected[1] + if not IsValid(first) then + print("Object was invalid.") + return false + end + local width = first:GetEntityBBox():sizex() + local length = first:GetEntityBBox():sizey() + + local start_pos = first:GetPos() + local colors = { RGB(0, 0, 0), RGB(200, 200, 200), RGB(100, 100, 100), RGB(120, 10, 10), RGB(10, 120, 10), RGB(10, 10, 120), RGB(90, 90, 30), RGB(90, 30, 90), RGB(30, 90, 90) } + local roughness_metallic = { point(0, 0), point(-80, 0), point(0, -80), point(80, 0), point(0, 80), point(80, 80), point(-80, -80) } + for x, rm in ipairs(roughness_metallic) do + for idx, color in ipairs(colors) do + local obj = PlaceObject("Shapeshifter") + obj:ChangeEntity(first:GetEntity()) + obj:SetPos(start_pos + point(x * width, idx * length)) + for c = 1, const.MaxColorizationMaterials do + local method_name = "SetEditableColor" .. c + obj[method_name](obj, colors[((idx + c - 2) % #colors) + 1]) + method_name = "SetEditableRoughness" .. c + obj[method_name](obj, rm:x()) + method_name = "SetEditableMetallic" .. c + obj[method_name](obj, rm:y()) + end + table.insert(ColorizationMatrixObjects, obj) + end + end + end +end + + + +DefineClass.ColorizationPropSet = { + __parents = {"ColorizableObject"}, +} + +function ColorizationPropSet:GetEditorView() + local clrs = {} + local count = self:GetMaxColorizationMaterials() + for i=1,count do + local color_get = string.format("GetEditableColor%d", i) + local color = self[color_get] and self[color_get](self) + local r, g, b = GetRGB(color) + clrs[#clrs + 1] = string.format("C%d", r, g, b, i) + end + return Untranslated(table.concat(clrs, " ")) +end + +function ColorizationPropSet:Clone() + local result = g_Classes[self.class]:new({}) + result:CopyProperties(self) + result:SetColorization(self) + return result +end + +function ColorizationPropSet:OnEditorSetProperty(prop_id, old_value, ged) + -- TODO: this should be a native ged functionality - modifying props with sub objects have to notify the prop owner as well + local parent = ged.selected_object + if not parent then return end + local list, parent_prop_id = parent:FindSubObjectLocation(self) + if list ~= parent then return end + return parent:OnEditorSetProperty(parent_prop_id, nil, ged) +end + +function ColorizationPropSet:GetError() + if not AreBinAssetsLoaded() then + return "Entities not loaded yet - load a map to edit colors." + end +end + +function ColorizationPropSet:EqualsByValue(other) + if rawequal(self, other) then return true end + + if not IsKindOf(self, "ColorizationPropSet") then + return false + end + if not IsKindOf(other, "ColorizationPropSet") then + return false + end + for i = 1, const.MaxColorizationMaterials or 0 do + local color_get = string.format("GetEditableColor%d", i) + local roughness_get = string.format("GetEditableRoughness%d", i) + local metallic_get = string.format("GetEditableMetallic%d", i) + + if self[color_get] and other[color_get] and self[color_get](self) ~= other[color_get](other) then + return false + end + if self[roughness_get] and other[roughness_get] and self[roughness_get](self) ~= other[roughness_get](other) then + return false + end + if self[metallic_get] and other[metallic_get] and self[metallic_get](self) ~= other[metallic_get](other) then + return false + end + end + return true +end + +ColorizationPropSet.__eq = ColorizationPropSet.EqualsByValue + + +function GetEnvColorizedGroups() -- Stub + return {} +end + +function EnvColorizedTerrainColor(terrain_obj) -- Called from C + local color_mod = terrain_obj.color_modifier + return color_mod +end + +local function GetDefaultColorizationSet(entity_name) + if not entity_name then return end + local entity_data = EntityData[entity_name] + if not entity_data then return end + local default_colors = entity_data.default_colors + if default_colors and next(default_colors) then + return default_colors + end +end +ColorizableObject.GetDefaultColorizationSet = function(obj) return GetDefaultColorizationSet(obj:GetEntity()) end + +if Platform.developer then + function OnMsg.EditorCallback(id, objects, reason) + if (id == "EditorCallbackPlace" or id == "EditorCallbackPlaceCursor") + and reason ~= "undo" + then + for i = 1, #objects do + local obj = objects[i] + -- NOTE: Light should not be ColorizableObject since it treats its Color properties differently + -- so we ignore them here because the palette will overwrite their copy/pasted colors + local colorizable = obj and IsKindOf(obj, "ColorizableObject") and not IsKindOf(obj, "Light") + if colorizable and not obj:ColorizationReadOnlyReason("palette") and obj:GetColorizationPaletteName() == g_DefaultColorsPalette then + -- Newly placed objects have the "Default colors" color palette + -- which inherits the Default colors from the Art spec editor + obj:SetColorizationPalette(g_DefaultColorsPalette) + end + end + end + end +end + +-- Applies the latest colors to objects with a chosen palette when the ColorizationPalettePreset is saved +-- This allows the person creating new palettes to immediately see how the latest colors look on the object +local function ApplyLatestColorPalettes() + if GetMap() == "" then return end + MapForEach("map", "CObject", function(obj) + local palette_value = obj:GetColorizationPalette() + if palette_value and palette_value ~= "" then + obj:SetColorsByColorizationPaletteName(palette_value) + end + end) +end + +if FirstLoad then + g_EntityToColorPalettes_Cache = {} -- for preset dropdown in entity object editor +end + +DefineClass.ColorizationPalettePreset = { + __parents = { "Preset" }, + properties = {}, + + GlobalMap = "ColorizationPalettePresets", + EditorMenubarName = "Colorization Palettes Editor", + EditorMenubar = "Editors.Art", + EditorIcon = "CommonAssets/UI/Icons/colour creativity palette.png", + + ContainerClass = "CPEntry", + --ValidateAfterSave = true, +} + +-- CP = ColorizationPalette +DefineClass.CPEntry = { + __parents = { "InitDone" }, +} + +DefineClass.CPPaletteEntry = { + __parents = { "CPEntry" }, + + properties = { + { id = "PaletteName", name = "Palette Name", editor = "text", default = false }, + { id = "PaletteColors", name = "Color Palette", editor = "nested_obj", base_class = "ColorizationPropSet", auto_expand = true, inclusive = true, default = false, }, + }, + + EditorView = Untranslated("Palette - ") +} + +function CPPaletteEntry:OnEditorSetProperty(prop_id, old_value, ged) + -- Called by ColorizationPropSet:OnEditorSetProperty + ApplyLatestColorPalettes() +end + +function CPPaletteEntry:GetColorsPreviewString() + if not self.PaletteColors then + return "" + end + + local c1, c2, c3 = "", "", "" + + if self.PaletteColors.EditableColor1 then + local r, g, b = GetRGB(self.PaletteColors.EditableColor1) + c1 = string.format("C1", r, g, b) + end + if self.PaletteColors.EditableColor2 then + local r, g, b = GetRGB(self.PaletteColors.EditableColor2) + c2 = string.format("C2", r, g, b) + end + if self.PaletteColors.EditableColor3 then + local r, g, b = GetRGB(self.PaletteColors.EditableColor3) + c3 = string.format("C3", r, g, b) + end + + return string.format("- %s %s %s", c1, c2, c3) +end + +local function GetColorizableEntities() + local result = {} + for entity_name, entity_data in pairs(EntityData) do + if CanEntityBeColorized(entity_name) then + result[#result + 1] = entity_name + end + end + return result +end + +DefineClass.CPEntityEntry = { + __parents = { "CPEntry" }, + + properties = { + { id = "ForEntity", name = "For Entity", editor = "choice", items = GetColorizableEntities, default = false }, + }, + + EditorView = Untranslated("Entity - ") +} + + +local function RebuildCPMappingCaches() + g_EntityToColorPalettes_Cache = {} + + -- Rebuild the mapping caches + ForEachPreset("ColorizationPalettePreset", function(preset) + local palette_names = {} + local palettes = {} + local entities = {} + for _, entry in ipairs(preset) do + if entry.class == "CPPaletteEntry" and entry.PaletteName and entry.PaletteColors then + palettes[#palettes + 1] = entry + elseif entry.class == "CPEntityEntry" and entry.ForEntity then + entities[#entities + 1] = entry + end + end + + for _, entity in ipairs(entities) do + g_EntityToColorPalettes_Cache[entity.ForEntity] = palettes + end + end) +end + +function OnMsg.PresetSave(class) + local classdef = g_Classes[class] + if IsKindOf(classdef, "ColorizationPalettePreset") then + RebuildCPMappingCaches() + ApplyLatestColorPalettes() + elseif IsKindOf(classdef, "EntitySpec") then + ApplyLatestColorPalettes() + end +end +-- Initial Presets load +OnMsg.DataLoaded = RebuildCPMappingCaches +-- Presets reload +OnMsg.DataReloadDone = RebuildCPMappingCaches + + +-- Colorizes objects on map load based on default colors as setters were not called! +function OnMsg.NewMapLoaded() + MapForEach("map", "Object", const.efRoot, function(obj) + -- Skip objects that can't be colorized (EnvColorized or have no Colorization Materials) + if not obj:CanBeColorized() then + return + end + -- Only g_DefaultColorsPalette colors were not updated! + local palette_value = obj:GetColorizationPalette() + if palette_value == g_DefaultColorsPalette then + obj:SetColorsByColorizationPaletteName(palette_value) + end + end) +end + +-- called by C when initializing CObjects with palettes +function GetColorsByColorizationPaletteName(entity, palette_value) + if palette_value == g_DefaultColorsPalette then + -- Set to the Default entity colors defined in the Art Spec editor + local default_colors = GetDefaultColorizationSet(entity) + if default_colors then + return RGBRM(default_colors.EditableColor1, default_colors.EditableRoughness1, default_colors.EditableMetallic1), + RGBRM(default_colors.EditableColor2, default_colors.EditableRoughness2, default_colors.EditableMetallic2), + RGBRM(default_colors.EditableColor3, default_colors.EditableRoughness3, default_colors.EditableMetallic3) + end + + end + + -- If not empty or default => find the palette colors and apply them on the object + for _, palette in ipairs(g_EntityToColorPalettes_Cache[entity] or empty_table) do + if palette.PaletteName == palette_name and palette.PaletteColors then + local colors = palette.PaletteColors + return RGBRM(colors.EditableColor1, colors.EditableRoughness1, colors.EditableMetallic1), + RGBRM(colors.EditableColor2, colors.EditableRoughness2, colors.EditableMetallic2), + RGBRM(colors.EditableColor3, colors.EditableRoughness3, colors.EditableMetallic3) + end + end +end \ No newline at end of file diff --git a/CommonLua/Classes/CommandObject.lua b/CommonLua/Classes/CommandObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..5900098c98c8d69a238f41a523f6c103618dd4f8 --- /dev/null +++ b/CommonLua/Classes/CommandObject.lua @@ -0,0 +1,704 @@ +local DebugCommand = (Platform.developer or Platform.asserts) and not Platform.console +local Trace_SetCommand = DebugCommand and "log" + +local CommandImportance = const.CommandImportance or empty_table +local WeakImportanceThreshold = CommandImportance.WeakImportanceThreshold + +--[[@@@ +@class CommandObject +It is often necessary to ensure that an object is doing one thing – and one thing only. The command system is used to accomplish just that. + +A CommandObject has a single thread executing its current command (if any). A command is just a function. When the current command finishes (the function returs), the current command changes to "Idle". A call to SetCommand (or a similar function) interrupts the currently executed command (deletes the thread) and creates a new thread to run the new command. + +For example, imagine a `Citizen` called Hulio who is walking to work and gets murdered by a `Soldier`. We'd like to have Hulio fall on the ground – dead – and interrupt his workday for good. + +~~~~ Lua + -- This sets Hulio's command to "CmdGoWork" + function Citizen:FindWork() + ... + self:SetCommand("CmdGoWork", workplace) + end + + -- This is called by the soldier who will kill Hulio + function Soldier:DoKill(obj) + ... + if not IsDead(obj) then + -- This cancels Hulio's "GoWork" command + obj:SetCommand("CmdGoDie", "Eliminated") + end + end +~~~~ + + +## Destructors + +When a command gets interrupted, the object can remain in an unpredictable state. Destructors solve that problem. + +Each command or a function called from a command can push one or more destructors and *must* later pop them. If the command gets interrupted, any active destructors get executed from the most recently pushed one to the oldest one. + +~~~~ Lua + function Citizen:CmdUseWaterDispenser(dispencer) + assert(dispencer.in_use == false) + dispencer.in_use = true + self:PushDestructor(function(self) -- run this in case someone interrupts the Citizen (e.g. kidnaps him) while using the dispenser + dispenser.in_use = false + end) + self:Goto(dispenser) -- Goto probably pushes (and pops) its own destructor + self:SetAnim("UseWaterDispenser") + Sleep(self:TimeToAnimEnd()) + self:PopAndCallDestructor() -- removes and executes the destuctor above, which will get also executed if the command is interrupted before reaching this code + end +~~~~ + + +## Importance of commands + +A CommandObject can execute hundreds of different commands it is quite difficult to figure out if an event should interrupt the current command or not. + +We assign *importance* to each command - a number in most cases taken from const.CommandImportance[command], although some functions take *importance* as a parameter. +This allows us to implement methods such as TrySetCommand(cmd, ...) and CanInterruptCommand(cmd). + +For example, if a stone hits a Citizen going to work, the Citizen should hold his head and scream with pain. If the Citizen is unconscious, nothing should happen. Command importance provides an elegant way to do that. + +~~~~ Lua + -- a stone has hit a citizen + citizen:TrySetCommand("CmdInPain") -- will be set only if running a less important command than CmdInPain +~~~~ + +In the example above, if CmdGoDie has higher importance than CmdInPain (as it should) it will not be interrupted while CmdUseWaterDispenser will be correctly interrupted. + +## Queue + +Commands can be queued for execution after the current command completes. + +For example, a unit should complete something important (run from an enemy) and then return to whatever it was doing. + +Another example is when the player has given a unit several commands to execute in order: kill this guy then kill that guy then return to the base for repairs. + +--]] +DefineClass.CommandObject = +{ + __parents = { "InitDone" }, + + command = false, + command_queue = false, + dont_clear_queue = false, + command_destructors = false, + command_thread = false, + thread_running_destructors = false, + command_call_stack = false, + forced_cmd_importance = false, + trace_setcmd = Trace_SetCommand, + last_error_time = false, + uninterruptable_importance = false, + + CreateThread = CreateGameTimeThread, + IsValid = IsValid, +} + +DefineClass.RealTimeCommandObject = +{ + __parents = { "CommandObject" }, + + CreateThread = CreateRealTimeThread, + IsValid = function() return true end, + NetUpdateHash = function () end, +} + +function RealTimeCommandObject:Done() + self.IsValid = empty_func +end + +--[[@@@ +When deleted, the command object interrupts the currently executed command. All present destructors will be called in another thread. +@function void CommandObject:Done() +--]] +function CommandObject:Done() + if self.command and CurrentThread() ~= self.command_thread then + self:SetCommand(false) + end + self.command_queue = nil +end + +function CommandObject:Idle() + self[false](self) +end + +function CommandObject:CmdInterrupt() +end + +CommandObject[false] = function(self) + self.command = nil + self.command_thread = nil + self.command_destructors = nil + self.thread_running_destructors = nil + Halt() +end + +--[[@@@ +Called whenever a new command starts executing. It might be faster to do some simple cleanup here instead of pushing a destructor often. +@function void CommandObject:OnCommandStart() +--]] +AutoResolveMethods.OnCommandStart = true +CommandObject.OnCommandStart = empty_func + +local SetCommandErrorChecks = empty_func +local SleepOnInfiniteLoop = empty_func + +local function GetNextDestructor(obj, destructors) + local count = destructors[1] + if count == 0 then + return empty_func + end + local dstor = destructors[count + 1] + destructors[count + 1] = false + destructors[1] = count - 1 + + if type(dstor) == "string" then + assert(obj[dstor], string.format("Missing destructor: %s.%s", obj.class, dstor)) + dstor = obj[dstor] or empty_func + elseif type(dstor) == "table" then + assert(type(dstor[1]) == "string") + assert(obj[dstor[1]], string.format("Missing destructor: %s.%s", obj.class, dstor[1])) + assert(#dstor == table.maxn(dstor)) -- make sure table.unpack works properly + return obj[dstor[1]] or empty_func, obj, table.unpack(dstor, 2) + end + return dstor, obj +end + +local function CommandThreadProc(self, command, ...) + dbg(SleepOnInfiniteLoop(self)) + + -- wait the thread calling destructors to finish + local destructors = self.command_destructors + local thread_running_destructors = self.thread_running_destructors + if thread_running_destructors then + while IsValidThread(self.thread_running_destructors) and not WaitMsg(destructors, 100) do + end + end + local thread = CurrentThread() + if self.command_thread ~= thread then return end + assert(not self.uninterruptable_importance) + assert(not self.thread_running_destructors) + + local command_func = type(command) == "function" and command or self[command] + local packed_command + while true do + + if destructors and destructors[1] > 0 then + self.thread_running_destructors = thread + while destructors[1] > 0 do + sprocall(GetNextDestructor(self, destructors)) + end + self.thread_running_destructors = false + if self.command_thread ~= thread then + Msg(destructors) + return + end + end + if not self:IsValid() then + return + end + + self:NetUpdateHash("Command", type(command) == "function" and "function" or command, ...) + self:OnCommandStart() + local success, err + if packed_command == nil then + success, err = sprocall(command_func, self, ...) + else + success, err = sprocall(command_func, self, unpack_params(packed_command, 3)) + end + assert(self.command_thread == thread) + if not success and not IsBeingDestructed(self) then + if self.last_error_time == now() then + -- throttle in case of an error right after another error to avoid infinite loops + Sleep(1000) + end + self.last_error_time = now() + end + local forced_cmd_importance + local queue = self.command_queue + packed_command = queue and table.remove(queue, 1) + if packed_command then + if type(packed_command) == "table" then + forced_cmd_importance = packed_command[1] or nil + command = packed_command[2] + else + command = packed_command + end + command_func = type(command) == "function" and command or self[command] + else + dbg(not success or SetCommandErrorChecks(self, "->Idle", ...)) + command = "Idle" + command_func = self.Idle + end + self.forced_cmd_importance = forced_cmd_importance + self.command = command + destructors = self.command_destructors + end + self.command_thread = nil +end + +--[[@@@ +Changes the current command unconditionally. Any present destructors form the previous command will be called before executing it. The method can fail if the current command thread cannot be deleted. When invoked, the self is passed as a first param. +@function bool CommandObject:SetCommand(string command, ...) +@function bool CommandObject:SetCommand(function command_func, ...) +@param string command - Name of the command. Should be an object's method name. +@param function command_func - Alternatively, the command to execute can be provided as a function param. +@result bool - Command change success. +--]] +function CommandObject:SetCommand(command, ...) + return self:DoSetCommand(nil, command, ...) +end + +-- Use with SetCommand or SetCommandImportance +function CommandObject:DoSetCommand(importance, command, ...) + self:NetUpdateHash("SetCommand", type(command) == "function" and "function" or command, ...) + dbg(SetCommandErrorChecks(self, command, ...)) + self.command = command or nil + if not self.dont_clear_queue then + self.command_queue = nil + end + self.dont_clear_queue = nil + local old_thread = self.command_thread + local new_thread = self.CreateThread(CommandThreadProc, self, command, ...) + self.command_thread = new_thread + self.forced_cmd_importance = importance or nil + ThreadsSetThreadSource(new_thread, "Command", command) + if old_thread == self.thread_running_destructors then + local uninterruptable_importance = self.uninterruptable_importance + if not uninterruptable_importance then + -- wait the current thread to finish destructor execution + return true + end + local test_importance = importance or CommandImportance[command or false] or 0 + if uninterruptable_importance >= test_importance then + -- wait the current thread to finish uninterruptable execution + return true + end + self.uninterruptable_importance = false + self.thread_running_destructors = false + end + + DeleteThread(old_thread, true) + if old_thread == CurrentThread() then + -- the old thread failed to be deleted, revert!!! + DeleteThread(new_thread) + self.command_thread = old_thread + return false + end + return true +end + +function CommandObject:TestInfiniteLoop() + self:SetCommand("TestInfiniteLoop2") +end + +function CommandObject:TestInfiniteLoop2() + self:SetCommand("TestInfiniteLoop") +end + +function CommandObject:GetCommandText() + return tostring(self.command) +end + +local function IsCommandThread(self, thread) + thread = thread or CurrentThread() + return thread and (thread == self.command_thread or thread == self.thread_running_destructors) +end +CommandObject.IsCommandThread = IsCommandThread + +--[[@@@ +Pushes a destructor to be executed if the command is interrupted. The destructor stack is a LIFO structure. When invoked, the self is passed as a first param. +@function int CommandObject:PushDestructor(function dtor) +@function int CommandObject:PushDestructor(string dtor) +@function int CommandObject:PushDestructor(table dtor) +@param function dtor - Destructor function. +@param string dtor - Destructor name. Should be an object's method name. +@param table dtor - Destructor table, containing a method name and the params to be passed. +@result number - The count of the destructors pushed in the destructor stack. +Example: +~~~~ + local orig_name = unit.name + unit:PushDestructor(function(unit) + unit.name = orig_name + end) +~~~~ +--]] +function CommandObject:PushDestructor(dtor) + assert(IsCommandThread(self)) + local destructors = self.command_destructors + if destructors then + destructors[1] = destructors[1] + 1 + destructors[destructors[1] + 1] = dtor + return destructors[1] + else + self.command_destructors = { 1, dtor } + return 1 + end +end + +--[[@@@ +Pops and calls the last pushed destructor to be executed if the command is interrupted. +@function void CommandObject:PopAndCallDestructor(int check_count = false) +@param int check_count - And optional param used to check for destructor stack consistency. +--]] +function CommandObject:PopAndCallDestructor(check_count) + local destructors = self.command_destructors + + assert(destructors and destructors[1] > 0) + assert(not check_count or check_count == destructors[1]) + assert(IsCommandThread(self)) + + local old_thread_running_destructors = self.thread_running_destructors + if not IsValidThread(old_thread_running_destructors) then + self.thread_running_destructors = CurrentThread() + assert(not old_thread_running_destructors) + old_thread_running_destructors = false + end + sprocall(GetNextDestructor(self, destructors)) + + if not old_thread_running_destructors then + self.thread_running_destructors = false + if self.command_thread ~= CurrentThread() then + Msg(destructors) + Halt() + end + end +end + +--[[@@@ +Same as PopAndCallDestructor but the destructor isn't invoked. +@function void CommandObject:PopDestructor(int check_count) +--]] +function CommandObject:PopDestructor(check_count) + local destructors = self.command_destructors + + assert(destructors and destructors[1] > 0) + assert(not check_count or check_count == destructors[1]) + assert(IsCommandThread(self)) + + destructors[destructors[1] + 1] = false + destructors[1] = destructors[1] - 1 +end + +function CommandObject:GetDestructorsCount() + local destructors = self.command_destructors + return destructors and destructors[1] or 0 +end + +--[[@@@ +Executes a function, interruptable only by commands with higher importance than the specified one. The execution immitates a destructor call, meaning that if the new command fails to interrupt, that will happen immediately after the uninterruptable execution terminates. The self is pased as a first param when called. +@function void CommandObject:ExecuteUninterruptableImportance(int importance, function func, ...) +@function void CommandObject:ExecuteUninterruptableImportance(int importance, string method_name, ...) +@param int importance - Command importance threshold. +@param function func - Function to be executed. +@param string method_name - Alternatively, the function to execute can be provided as a object's method name. +--]] +function CommandObject:ExecuteUninterruptableImportance(importance, func, ...) + local thread = CurrentThread() + local func_to_execute = type(func) == "function" and func or self[func] + + if self.command_thread ~= thread or self.thread_running_destructors then + assert((self.uninterruptable_importance or max_int) >= (importance or max_int)) + sprocall(func_to_execute, self, ...) + return + end + + local destructors = self.command_destructors + if not destructors then + -- the destructors table is needed to sync command threads + destructors = { 0 } + self.command_destructors = destructors + end + + self.uninterruptable_importance = importance + self.thread_running_destructors = thread + + sprocall(func_to_execute, self, ...) + + self.uninterruptable_importance = false + self.thread_running_destructors = false + + if self.command_thread == thread then + return + end + + Msg(destructors) + Halt() +end + +--[[@@@ +A shortcut to invoke [ExecuteUninterruptableImportance](#CommandObject:ExecuteUninterruptableImportance) with maximum importance, disallowing interruption by any commands +@function void CommandObject:ExecuteUninterruptable(function func, ...) +--]] +function CommandObject:ExecuteUninterruptable(func, ...) + return self:ExecuteUninterruptableImportance(nil, func, ...) +end + +--[[@@@ +A shortcut to invoke [ExecuteUninterruptableImportance](#CommandObject:ExecuteUninterruptableImportance) with WeakImportanceThreshold, allowing interruption by all commands with higher importance. +@function void CommandObject:ExecuteWeakUninterruptable(function func, ...) +--]] +function CommandObject:ExecuteWeakUninterruptable(func, ...) + assert(WeakImportanceThreshold) + return self:ExecuteUninterruptableImportance(WeakImportanceThreshold, func, ...) +end + +function CommandObject:IsIdleCommand() + return (self.command or "Idle") == "Idle" +end + +local function InsertCommand(self, index, forced_importance, command, ...) + if self:IsIdleCommand() then + return self:SetCommand(command, ...) + end + local packed_command = not forced_importance and count_params(...) == 0 and command or pack_params(forced_importance or false, command or false, ...) + local queue = self.command_queue + if not queue then + self.command_queue = { packed_command } + else + if index then + table.insert(queue, index, packed_command) + else + queue[#queue + 1] = packed_command + end + end +end + +-- queue command to be executed after the current and all other queued commands complete +function CommandObject:QueueCommand(command, ...) + return InsertCommand(self, false, false, command, ...) +end + +function CommandObject:QueueCommandImportance(forced_importance, command, ...) + return InsertCommand(self, false, forced_importance, command, ...) +end + +-- insert command at the specified place in the queue to be executed right after the current one completes +-- this is often used with 1 to place a command to be executed ASAP before continuing with the rest of the queue +function CommandObject:InsertCommand(index, forced_importance, command, ...) + return InsertCommand(self, index, forced_importance, command, ...) +end + +-- Like setcommand, but without clearing the queue. Useful when we want current command to terminate immediately, +-- regardless of current stack position, start the new command and preserve the queue. +function CommandObject:SetCommandKeepQueue(command, ...) + self.dont_clear_queue = true + self:SetCommand(command, ...) +end + +function CommandObject:HasCommandsInQueue() + return #(self.command_queue or "") > 0 +end + +function CommandObject:ClearCommandQueue() + self.command_queue = nil +end + + +----- Command importance + +function CommandObject:GetCommandImportance(command) + if not command then + return self.forced_cmd_importance or CommandImportance[self.command] + else + return CommandImportance[command or false] + end +end + +--[[@@@ +Checks if the current command can be changed by the given one. +@function bool CommandObject:CanSetCommand(string command, int importance = false) +@param string command - Name of the command to test. +@param int importance - Optional custom importance. +@result bool - Command change test success. +--]] +function CommandObject:CanSetCommand(command, importance) + assert(not importance or type(importance) == "number") + local current_importance = self.forced_cmd_importance or CommandImportance[self.command] or 0 + importance = importance or CommandImportance[command or false] or 0 + return current_importance <= importance +end + +--[[@@@ +Same as [SetCommand](#CommandObject:SetCommand) but may fail if the current command has a higher importance. +@function bool CommandObject:TrySetCommand(string command, ...) +--]] +function CommandObject:TrySetCommand(cmd, ...) + if not self:CanSetCommand(cmd) then + return + end + return self:SetCommand(cmd, ...) +end + +--[[@@@ +Same as [SetCommand](#CommandObject:SetCommand) but a custom importance is forced. The command importances are specified in the CommandImportance const group. +@function bool CommandObject:SetCommandImportance(int importance, string command, ...) +@param int importance - A custom importance to replace the default command importance. +--]] +function CommandObject:SetCommandImportance(importance, cmd, ...) + assert(not importance or type(importance) == "number") + return self:DoSetCommand(importance or nil, cmd, ...) +end + +--[[@@@ +See [SetCommandImportance](#CommandObject:SetCommandImportance), [TrySetCommand](#CommandObject:TrySetCommand) +@function bool CommandObject:TrySetCommandImportance(int importance, string command, ...) +--]] +function CommandObject:TrySetCommandImportance(importance, cmd, ...) + if not self:CanSetCommand(cmd, importance) then + return + end + return self:SetCommandImportance(importance, cmd, ...) +end + +function CommandObject:ExecuteInCommand(method_name, ...) + if CanYield() and IsCommandThread(self) then + self[method_name](self, ...) + return true + end + return self:TrySetCommand(method_name, ...) +end + +SuspendCommandObjectInfiniteChangeDetection = empty_func +ResumeCommandObjectInfiniteChangeDetection = empty_func + +---- + +if DebugCommand then + +CommandObject.command_change_prev = false +CommandObject.command_change_count = 0 +CommandObject.command_change_gtime = 0 +CommandObject.command_change_rtime = 0 +CommandObject.command_change_loops = 0 + +local lCommandChangeLoopDetection = true + +function SuspendCommandObjectInfiniteChangeDetection() + lCommandChangeLoopDetection = false +end + +function ResumeCommandObjectInfiniteChangeDetection() + lCommandChangeLoopDetection = true +end + +local infinite_command_changes = 10 + +SleepOnInfiniteLoop = function(self) + if not lCommandChangeLoopDetection then return end + + local rtime, gtime = RealTime(), GameTime() + if self.command_change_rtime ~= rtime or self.command_change_gtime ~= gtime then + self.command_change_rtime = rtime -- real time to avoid false positive on paused game + self.command_change_gtime = gtime -- game time to avoid false positive on falling behind gametime + self.command_change_count = nil + return + end + local command_change_count = self.command_change_count + if command_change_count <= infinite_command_changes then + self.command_change_count = command_change_count + 1 + return + end + self.command_change_loops = self.command_change_loops + 1 + Sleep(50 * self.command_change_loops) + self.command_change_count = nil +end + +SetCommandErrorChecks = function(self, command, ...) + local destructors = self.command_destructors + local prev_command = self.command + if command == "->Idle" and destructors and destructors[1] > 0 then -- the command should pop all its destructors + print("Command", self.class .. "." .. tostring(prev_command), "remaining destructors:") + for i = 1,destructors[1] do + local destructor = destructors[i + 1] + if type(destructor) == "string" then + printf("\t%d. %s.%s", i, self.class, destructor) + elseif type(destructor) == "table" then + printf("\t%d. %s.%s", i, self.class, destructor[1]) + else + local info = debug.getinfo(destructor, "S") or empty_table + local source = info.source or "Unknown" + local line = info.linedefined or -1 + printf("\t%d. %s(%d)", i, source, line) + end + end + error(string.format("Command %s.%s did not pop its destructors.", self.class, tostring(self.command)), 2) + -- remove the remaining destructors to avoid having the error all the time + while destructors[1] > 0 do + self:PopDestructor() + end + end + if command and command ~= "->Idle" then + if type(command) ~= "function" and not self:HasMember(command) then + error(string.format("Invalid command %s:%s", self.class, tostring(command)), 3) + end + if IsBeingDestructed(self) then + error(string.format("%s:SetCommand('%s') called from Done() or delete()", self.class, tostring(command)), 3) + end + end + if command ~= "->Idle" or prev_command ~= "Idle" then + self.command_call_stack = GetStack(3) + if self.trace_setcmd then + if self.trace_setcmd == "log" then + self:Trace("SetCommand {1}", tostring(command), self.command_call_stack, ...) + else + error(string.format("%s:SetCommand(%s) time %d, old command %s", self.class, concat_params(", ", tostring(command), ...), GameTime(), tostring(self.command)), 3) + end + end + end + if self.command_change_count == infinite_command_changes then + assert(false, string.format("Infinite command change in %s: %s -> %s -> %s", self.class, tostring(self.command_change_prev), tostring(prev_command), tostring(command))) + --StoreErrorSource(self, "Infinite command change") Pause("Debug") + end + self.command_change_prev = prev_command +end + +local function __DbgForEachMethod(passed, obj, callback, ...) + if not obj then + return + end + for name, value in pairs(obj) do + if type(value) == "function" and not passed[name] then + passed[name] = true + callback(name, value, ...) + end + end + return __DbgForEachMethod(passed, getmetatable(obj), callback, ...) +end + +function DbgForEachMethod(obj, callback, ...) + return __DbgForEachMethod({}, obj, callback, ...) +end + +function DbgBreakRemove(obj) + DbgForEachMethod(obj, function(name, value, obj) + obj[name] = nil + end, obj) +end + +function DbgBreakSchedule(obj, methods) + DbgBreakRemove(obj) + if methods == "string" then methods = { methods } end + DbgForEachMethod(obj, function(name, value, obj) + if not methods or table.find(methods, name) then + local new_value = function(...) + if IsCommandThread(obj) then + DbgBreakRemove(obj) + print("Break removed") + bp(true, 1) + end + return value(...) + end + obj[name] = new_value + end + end, obj) + print("Break schedule") +end + +function CommandObject:AsyncCheatDebugger() + DbgBreakSchedule(self) +end + +end -- DebugCommand \ No newline at end of file diff --git a/CommonLua/Classes/Common.lua b/CommonLua/Classes/Common.lua new file mode 100644 index 0000000000000000000000000000000000000000..09bdadbcfdec4cbe0b22391bc0becbd0c122ead5 --- /dev/null +++ b/CommonLua/Classes/Common.lua @@ -0,0 +1,74 @@ +DefineClass.CameraFacingObject = { + __parents = { "CObject", "ComponentExtraTransform" }, + + properties = { + { id = "CameraFacing", name="Camera facing", default = false, editor = "bool", help = "Let object use camera facing, specified in its class" }, + }, + SetCameraFacing = function( self, value ) + if value then + self:SetSpecialOrientation(const.soFacing) + else + self:SetSpecialOrientation() + end + end, + GetCameraFacing = function(self) + return self:GetSpecialOrientation() == const.soFacing + end, +} + +function DepositionTypesItems(obj) + local deposition = obj:GetDepositionSupported() + local items = { + { value = "", text = "None"}, + } + if deposition == "terrainchunk" or deposition == "all" then + table.insert(items, {value = "terrainchunk", text = "Terrain Chunk"}) + end + + if deposition == "terraintype" or deposition == "all" then + local subitems = { } + ForEachPreset("TerrainObj", function(preset) + table.insert(subitems, { value = preset.material_name, text = preset.material_name }) + end) + table.sort(subitems, function(a, b) return a.value < b.value end) + table.append(items, subitems) + end + + return items; +end + +DefineClass.Deposition = { + __parents = { "CObject", "ComponentCustomData" }, + flags = { efSelectable = false, }, + properties = { + { category = "Deposition", id = "DepositionType", editor = "dropdownlist", default = "", items = DepositionTypesItems, help = "The type of material that is going to be applied on top of this object." }, + { category = "Deposition", id = "DepositionScale", editor = "number", default = 10, min = 1, max = 100, scale = 10, slider = true, help = "The scale of all textures extracted from the material.", no_edit=function(obj) return obj:IsTerrainChunkDeposition() end }, + { category = "Deposition", id = "DepositionAxis", editor = "point", default = point(0, 0, 127), helper = "relative_pos", helper_origin = true, helper_outside_object = true, helper_scale_with_parent = true, help = "The axis used for determining where the deposition must be applied.", no_edit=function(obj) return obj:IsTerrainChunkDeposition() end }, + { category = "Deposition", id = "DepositionFadeStart", editor = "number", default = 40, min = 0, max = 100, scale = 1, slider = true, help = "At which point relative to the axis the deposition must be completely invisible." }, + { category = "Deposition", id = "DepositionFadeEnd", editor = "number", default = 60, min = 0, max = 100, scale = 1, slider = true, help = "At which point relative to the axis the deposition must be completely visible." }, + { category = "Deposition", id = "DepositionFadeCurve", editor = "number", default = 10, min = 1, max = 100, scale = 10, slider = true, help = "Determines the hardness of the transition between areas with and without deposition." }, + { category = "Deposition", id = "DepositionAlphaStart", editor = "number", default = 40, min = 0, max = 100, scale = 1, slider = true, help = "At which point relative to the axis the alpha of the diffuse texture is completely applied. You can use it to create sparse deposition or improve the transition between areas with and without deposition.", + no_edit=function(obj) return obj:IsTerrainChunkDeposition() end }, + { category = "Deposition", id = "DepositionAlphaEnd", editor = "number", default = 60, min = 0, max = 100, scale = 1, slider = true, help = "At which point relative to the axis the alpha of the diffuse texture is not applied. You can use it to create sparse deposition or improve the transition between areas with and without deposition.", + no_edit=function(obj) return obj:IsTerrainChunkDeposition() end }, + { category = "Deposition", id = "DepositionNoiseGamma", editor = "number", default = 0, min = 0, max = 255, scale = 128, slider = true, help = "How much of the noise to apply. 0 to disable.", }, + { category = "Deposition", id = "DepositionNoiseFreq", editor = "number", default = 0, min = 0, max = 255, scale = 64, slider = true, help = "Noise frequency", }, + } +} + +function Deposition:IsTerrainChunkDeposition() + local deposition = self:GetDepositionType() + return deposition == "terrainchunk" +end + +function OnMsg.BinAssetsLoaded() + UpdateDepositionMaterialLUT() + UpdateDustMaterial(const.DustMaterialExterior, "TerrainSand_01_mesh.mtl") + UpdateDustMaterial(const.DustMaterialInterior, "DustRust_mesh.mtl") +end + +DefineClass.Mirrorable = { + __parents = {"CObject"}, + properties = { + } +} \ No newline at end of file diff --git a/CommonLua/Classes/Components.lua b/CommonLua/Classes/Components.lua new file mode 100644 index 0000000000000000000000000000000000000000..461e8810c61644a8e59cb6e6c9ad5ee955b6282a --- /dev/null +++ b/CommonLua/Classes/Components.lua @@ -0,0 +1,130 @@ +local function not_attached(obj) + return not obj:GetParent() +end + +--[[@@@ +@class ComponentAttach +Objects inheriting this class can attach other objects or be attached to other objects +--]] + +DefineClass.ComponentAttach = { + __parents = { "CObject" }, + flags = { cofComponentAttach = true }, + properties = { + { category = "Child", id = "AttachOffset", name = "Attached Offset", editor = "point", default = point30, no_edit = not_attached, dont_save = true }, + { category = "Child", id = "AttachAxis", name = "Attached Axis", editor = "point", default = axis_z, no_edit = not_attached, dont_save = true }, + { category = "Child", id = "AttachAngle", name = "Attached Angle", editor = "number", default = 0, no_edit = not_attached, dont_save = true, min = -180*60, max = 180*60, slider = true, scale = "deg" }, + { category = "Child", id = "AttachSpotName", name = "Attached At", editor = "text", default = "", no_edit = not_attached, dont_save = true, read_only = true }, + { category = "Child", id = "Parent", name = "Attached To", editor = "object", default = false, no_edit = not_attached, dont_save = true, read_only = true }, + { category = "Child", id = "TopmostParent", name = "Topmost Parent", editor = "object", default = false, no_edit = not_attached, dont_save = true, read_only = true }, + { category = "Child", id = "AngleLocal", name = "Local Angle", editor = "number", default = 0, no_edit = not_attached, dont_save = true, min = -180*60, max = 180*60, slider = true, scale = "deg" }, + { category = "Child", id = "AxisLocal", name = "Local Axis", editor = "point", default = axis_z, no_edit = not_attached, dont_save = true }, + }, +} + +ComponentAttach.SetAngleLocal = CObject.SetAngle +ComponentAttach.SetAxisLocal = CObject.SetAxis + +DefineClass.StripComponentAttachProperties = { + __parents = { "ComponentAttach" }, + properties = { + { id = "AttachOffset", }, + { id = "AttachAxis", }, + { id = "AttachAngle", }, + { id = "AttachSpotName", }, + { id = "Parent", }, + }, +} + +function ComponentAttach:GetAttachSpotName() + local parent = self:GetParent() + return parent and parent:GetSpotName(self:GetAttachSpot()) +end + +DefineClass.ComponentCustomData = { + __parents = { "CObject" }, + flags = { cofComponentCustomData = true }, + -- when inheriting ComponentCustomData from multiple parents you have to: + -- 1. review if its use is not conflicting + -- 2. add member CustomDataType = "" to suppress the class system error + + GetCustomData = _GetCustomData, + SetCustomData = _SetCustomData, + GetCustomString = _GetCustomString, + SetCustomString = _SetCustomString, +} + +if Platform.developer then +function OnMsg.ClassesPreprocess(classdefs) + for name, class in pairs(classdefs) do + if table.find(class.__parents, "ComponentCustomData") then + if not class.CustomDataType then + class.CustomDataType = name + end + end + end +end +end + +function SpecialOrientationItems() + local SpecialOrientationNames = { "soTerrain", "soTerrainLarge", "soFacing", "soFacingY", "soFacingVertical", "soVelocity", "soZOffset", "soTerrainPitch", "soTerrainPitchLarge" } + table.sort(SpecialOrientationNames) + local items = {} + for i, name in ipairs(SpecialOrientationNames) do + items[i] = { text = name, value = const[name] } + end + table.insert(items, 1, { text = "", value = const.soNone }) + return items +end + +DefineClass.ComponentExtraTransform = { + __parents = { "CObject" }, + flags = { cofComponentExtraTransform = true }, + properties = { + { id = "SpecialOrientation", name = "Special Orientation", editor = "choice", default = const.soNone, items = SpecialOrientationItems }, + }, +} + +DefineClass.ComponentInterpolation = { + __parents = { "CObject" }, + flags = { cofComponentInterpolation = true }, +} + +DefineClass.ComponentCurvature = { + __parents = { "CObject" }, + flags = { cofComponentCurvature = true }, +} + +DefineClass.ComponentAnim = { + __parents = { "CObject" }, + flags = { cofComponentAnim = true }, +} + +DefineClass.ComponentSound = { + __parents = { "CObject" }, + flags = { cofComponentSound = true }, + properties = { + { category = "Sound", id = "SoundBank", name = "Bank", editor = "preset_id", default = "", preset_class = "SoundPreset", dont_save = true }, + { category = "Sound", id = "SoundType", name = "Type", editor = "preset_id", default = "", preset_class = "SoundTypePreset", dont_save = true, read_only = true }, + { category = "Sound", id = "Sound", name = "Sample", editor = "text", default = "", dont_save = true, read_only = true }, + { category = "Sound", id = "SoundDuration", name = "Duration", editor = "number", default = -1, dont_save = true, read_only = true }, + { category = "Sound", id = "SoundHandle", name = "Handle", editor = "number", default = -1, dont_save = true, read_only = true }, + }, +} + +function ComponentSound:GetSoundBank() + local sname, sbank, stype, shandle, sduration, stime = self:GetSound() + return sbank or "" +end +function ComponentSound:GetSoundType() + local sname, sbank, stype, shandle, sduration, stime = self:GetSound() + return stype or "" +end +function ComponentSound:GetSoundHandle() + local sname, sbank, stype, shandle, sduration, stime = self:GetSound() + return shandle or -1 +end +function ComponentSound:GetSoundDuration() + local sname, sbank, stype, shandle, sduration, stime = self:GetSound() + return sduration or -1 +end \ No newline at end of file diff --git a/CommonLua/Classes/Composite.lua b/CommonLua/Classes/Composite.lua new file mode 100644 index 0000000000000000000000000000000000000000..d53be86381fe38cb7219189bbf387e6eb2f6ec82 --- /dev/null +++ b/CommonLua/Classes/Composite.lua @@ -0,0 +1,503 @@ +----- Composite objects with components of base class CompositeClass that can be turned on and off +-- +-- create the specific classes, setting their components and properties, using the Ged editor that will appear +-- properties of all components that have template = true in their metadata are editable in the Ged editor +-- use AutoResolveMethod to defind how to combine methods present in multiple components + +const.ComponentsPropCategory = "Components" + +DefineClass.CompositeDef = { + __parents = { "Preset" }, + properties = { + { category = "Preset", id = "object_class", name = "Object Class", editor = "choice", default = "", items = function(self) return ClassDescendantsCombo(self.ObjectBaseClass, true) end, }, + { category = "Preset", id = "code", name = "Global Code", editor = "func", default = false, lines = 1, max_lines = 100, params = "", + no_edit = function(self) return IsKindOf(self, "ModItem") end, + }, + }, + + -- Preset settings + GeneratesClass = true, + SingleFile = false, + GedShowTemplateProps = true, + + -- CompositeDef settings + ObjectBaseClass = false, + ComponentClass = false, + + components_cache = false, + components_sorting = false, + properties_cache = false, + EditorMenubarName = false, + + EditorViewPresetPostfix = Untranslated(" "), + Documentation = "This is a preset that results in a composite class definition. You can look at it as a template from which objects are created.\n\nThe generated class will inherit the specified Object Class and all component classes.", +} + +function CompositeDef.new(class, obj) + local object = Preset.new(class, obj) + object.object_class = CompositeDef.GetObjectClass(object) + return object +end + +function CompositeDef:GetObjectClass() + return self.object_class ~= "" and self.object_class or self.ObjectBaseClass +end + +function CompositeDef:GetComponents(filter) + if not self.ComponentClass then return empty_table end + + local components_cache = self.components_cache + if not components_cache then + local sorting_keys = {} + local component_class = g_Classes[self.ComponentClass] + local blacklist = component_class.BlackListBaseClasses + components_cache = ClassDescendantsList(self.ComponentClass, function(classname, class, base_class, base_def, sorting_keys, blacklist) + if class:IsKindOf(base_class) or base_def:IsKindOf(classname) + or IsKindOf(g_Classes[class.__generated_by_class or false], "CompositeDef") + or class:IsKindOfClasses(blacklist) then + return + end + if (class.ComponentSortKey or 0) ~= 0 then + sorting_keys[classname] = class.ComponentSortKey + end + return true + end, self.ObjectBaseClass, g_Classes[self.ObjectBaseClass], sorting_keys, blacklist) + local classdef = g_Classes[self.class] + rawset(classdef, "components_cache", components_cache) + rawset(classdef, "components_sorting", sorting_keys) + end + if filter == "active" then + return table.ifilter(components_cache, function(_, classname) return self:GetProperty(classname) end) + elseif filter == "inactive" then + return table.ifilter(components_cache, function(_, classname) return not self:GetProperty(classname) end) + end + return components_cache +end + +function CompositeDef:GetProperties() + local object_class = self:GetObjectClass() + local object_def = g_Classes[object_class] + assert(not object_class or object_def) + if not object_def then + return self.properties + end + + local cache = self.properties_cache or {} + if not cache[object_class] then + local props, prop_data = {}, {} + local function add_prop(prop, default, class) + local added + if not prop_data[prop.id] then + added = true + if prop.default ~= default then + prop = table.copy(prop) + prop.default = default + end + props[#props + 1] = prop + else + assert(prop_data[prop.id].default == default, + string.format("Default value conflict for property '%s' in classes '%s' and '%s'", prop.id, prop_data[prop.id].class, class)) + end + prop_data[prop.id] = { default = default, class = class } + return added and prop or table.find_value(props, "id", prop.id) + end + + for _, prop in ipairs(self.properties) do + if prop.id ~= "code" then add_prop(prop, prop.default, self.class) end + end + for _, prop in ipairs(object_def.properties) do + if prop.template then + add_prop(prop, object_def:GetDefaultPropertyValue(prop.id), self.class) + end + end + + local components = self:GetComponents() + for _, classname in ipairs(components) do + local inherited = object_def:IsKindOf(classname) or false + local help = inherited and "Inherited from the base class" + local prop = { category = const.ComponentsPropCategory, id = classname, editor = "bool", default = inherited, read_only = inherited, help = help } + add_prop(prop, inherited, self.class) + end + add_prop(table.find_value(self.properties, "id", "code"), self:GetDefaultPropertyValue("code"), self.class) + for _, classname in ipairs(components) do + if not object_def:IsKindOf(classname) then + local component_def = g_Classes[classname] + for _, prop in ipairs(component_def.properties) do + local category = prop.category or classname + local no_edit = prop.no_edit + prop = table.copy(prop, "deep") + prop.category = category + prop = add_prop(prop, component_def:GetDefaultPropertyValue(prop.id), classname) + local composite_owner_classes = prop.composite_owner_classes or {} + composite_owner_classes[#composite_owner_classes + 1] = classname + prop.composite_owner_classes = composite_owner_classes + prop.no_edit = function(self, ...) + if no_edit == true or type(no_edit) == "function" and no_edit(self, ...) then return true end + local prop_meta = select(1, ...) + for _, name in ipairs(prop_meta.composite_owner_classes or empty_table) do + if rawget(self, name) then + return + end + end + return true + end + end + end + end + + -- store the cache in the class, this auto-invalidates it on Lua reload + rawset(g_Classes[self.class], "properties_cache", cache) + rawset(cache, object_class, props) + return props + end + + return cache[object_class] +end + +function CompositeDef:SetProperty(prop_id, value) + local prop_meta = self:GetPropertyMetadata(prop_id) + if prop_meta and prop_meta.template and prop_meta.setter then + return prop_meta.setter(self, value, prop_id, prop_meta) + end + if table.find(CompositeDef.properties, "id", prop_id) then + return Preset.SetProperty(self, prop_id, value) + end + if value and table.find(self:GetComponents(), prop_id) and _G[prop_id]:HasMember("OnEditorNew") then + _G[prop_id].OnEditorNew(self) -- OnEditorNew can initialize component property defaults of e.g. nested_obj/list component properties + end + rawset(self, prop_id, value) +end + +function CompositeDef:GetProperty(prop_id) + local prop_meta = self:GetPropertyMetadata(prop_id) + if prop_meta and prop_meta.template and prop_meta.getter then + return prop_meta.getter(self, prop_id, prop_meta) + end + local value = Preset.GetProperty(self, prop_id) + if value ~= nil then + return value + end + return prop_meta and prop_meta.default +end + +function CompositeDef:OnEditorSetProperty(prop_id, old_value, ged) + local prop_meta = self:GetPropertyMetadata(prop_id) + if prop_meta and prop_meta.template and prop_meta.edited then + return prop_meta.edited(self, old_value, prop_id, prop_meta) + end + return Preset.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +function CompositeDef:__toluacode(...) + -- clear properties of the inactive components + local properties = self:GetProperties() + local find = table.find + local rawget = rawget + for _, classname in ipairs(self:GetComponents("inactive")) do + for _, prop in ipairs(g_Classes[classname].properties) do + if rawget(self, prop.id) ~= nil and not find(properties, "id", prop.id) then + self[prop.id] = nil + end + end + end + return Preset.__toluacode(self, ...) +end + +-- supports generating a different class for each DLC, including property values for this DLC; see PresetDLCSplitting.lua +-- return a table with pairs to generate multiple companion files, where key = dlc +function CompositeDef:GetCompanionFilesList(save_path) + local files = { } + for _, prop in pairs(self:GetProperties()) do + local save_in = prop.dlc or "" + if not files[save_in] then + -- GetSavePath depends on self.group and self.id + files[save_in] = self:GetCompanionFileSavePath(prop.dlc and self:GetSavePath(prop.dlc) or save_path) + end + end + return files +end + +function CompositeDef:GenerateCompanionFileCode(code, dlc) + local class_exists_err = self:CheckIfIdExistsInGlobal() + if class_exists_err then + return class_exists_err + end + + code:appendf("UndefineClass('%s')\nDefineClass.%s = {\n", self.id, self.id) + self:GenerateParents(code) + self:AppendGeneratedByProps(code) + self:GenerateFlags(code) + self:GenerateConsts(code, dlc) + code:append("}\n\n") + self:GenerateGlobalCode(code) +end + +function CompositeDef:GenerateParents(code) + local object_class = self:GetObjectClass() + + local list = self:GetComponents("active") + if #list > 0 then + assert(list ~= self.components_cache) + local object_def = g_Classes[object_class] + assert(object_def) + if object_def then + list = table.ifilter(list, function(_, classname) return not object_def:IsKindOf(classname) end) + end + end + if #list == 0 then + code:appendf('\t__parents = { "%s" },\n', object_class) + return + end + + if next(self.components_sorting) then + table.insert(list, 1, object_class) + local sorting_keys = self.components_sorting + table.stable_sort(list, function(class1, class2) + return (sorting_keys[class1] or 0) < (sorting_keys[class2] or 0) + end) + code:append('\t__parents = { "', table.concat(list, '", "'), '" },\n') + else + code:appendf('\t__parents = { "%s", "', object_class) + code:append(table.concat(list, '", "')) + code:append('" },\n') + end +end + +ClassNonInheritableMembers.composite_flags = true + +function CompositeDef:GenerateFlags(code) + local object_def = g_Classes[self:GetObjectClass()] + assert(object_def) + if not object_def then return end + + local flags = table.copy(object_def.composite_flags or empty_table) + for _, component in ipairs(self:GetComponents("active")) do + for flag, set in pairs(g_Classes[component].composite_flags) do + assert(flags[flag] == nil) + flags[flag] = set + end + end + if not next(flags) then + return + end + code:append('\tflags = { ') + for flag, set in sorted_pairs(flags) do + code:appendf("%s = %s, ", flag, set and "true" or "false") + end + code:append('},\n') +end + +function CompositeDef:IncludePropAs(prop, dlc) + local id = prop.id + if Preset:GetPropertyMetadata(id) or id == "code" then + return false + end + if not prop.dlc and not (dlc ~= "" and prop.dlc_override) or prop.dlc == dlc then + return prop.maingame_prop_id or prop.id + end +end + +function CompositeDef:GenerateConsts(code, dlc) + local props = self:GetProperties() + code:append(#props > 0 and "\n" or "") + local has_embedded_objects = false + for _, prop in ipairs(props) do + local id = prop.id + local include_as = self:IncludePropAs(prop, dlc) + if include_as then + local value = rawget(self, id) + if not self:IsDefaultPropertyValue(id, prop, value) then + code:append("\t", include_as, " = ") + ValueToLuaCode(value, 1, code, {} --[[ enable property injection ]]) + code:append(",\n") + end + end + end + return has_embedded_objects +end + +function CompositeDef:GenerateGlobalCode(code) + if self.code and self.code ~= "" then + code:append("\n") + local name, params, body = GetFuncSource(self.code) + if type(body) == "table" then + for _, line in ipairs(body) do + code:append(line, "\n") + end + elseif type(body) == "string" then + code:append(body) + end + code:append("\n") + end +end + +function CompositeDef:GetObjectClassLuaFilePath(path) + if self.save_in == "" then + return string.format("Lua/%s/__%s.generated.lua", self.class, self.ObjectBaseClass) + elseif self.save_in == "Common" then + return string.format("CommonLua/Classes/%s/__%s.generated.lua", self.class, self.ObjectBaseClass) + elseif self.save_in:starts_with("Libs/") then -- lib + return string.format("CommonLua/%s/%s/__%s.generated.lua", self.save_in, self.class, self.ObjectBaseClass) + else -- save_in is a DLC name + return string.format("svnProject/Dlc/%s/Presets/%s/__%s.generated.lua", self.save_in, self.class, self.ObjectBaseClass) + end +end + +function CompositeDef:GetWarning() + if not g_Classes[self.id] then + return "The class for this preset has not been generated yet.\nIt needs to be saved before it can be used or referenced from elsewhere." + end +end + +function CompositeDef:GetError() + for _, component in ipairs(self:GetComponents()) do + if self[component] then + local err = g_Classes[component].GetError(self) + if err then + return err + end + end + end +end + +function OnMsg.ClassesPreprocess(classdefs) + for name, classdef in pairs(classdefs) do + if classdef.__parents and classdef.__parents[1] == "CompositeDef" then + classdefs[classdef.ObjectBaseClass].__hierarchy_cache = true + end + end +end + +function OnMsg.ClassesBuilt() + ClassDescendants("CompositeDef", function(class_name, class) + if IsKindOf(class, "ModItem") then return end + + local objclass = class.ObjectBaseClass + local path = class:GetObjectClassLuaFilePath() + + -- can't generate the file in packed builds, as we can't get Lua source for func properties + if config.RunUnpacked and Platform.developer and not Platform.console then + -- Map all component methods => list of components they are defined in + local methods = {} + for _, component in ipairs(class:GetComponents()) do + for name, member in pairs(g_Classes[component]) do + if type(member) == "function" and not RecursiveCallMethods[name] then + local classlist = methods[name] + if classlist then + classlist[#classlist + 1] = component + else + methods[name] = { component } + end + end + end + end + + -- Generate the code for the CompositeDef's object class here + local code = pstr(exported_files_header_warning, 16384) + code:appendf("function __%sExtraDefinitions()\n", objclass) + + -- a) make GetComponents callable from the object class + code:appendf("\t%s.components_cache = false\n", objclass) + code:appendf("\t%s.GetComponents = %s.GetComponents\n", objclass, class_name) + code:appendf("\t%s.ComponentClass = %s.ComponentClass\n", objclass, class_name) + code:appendf("\t%s.ObjectBaseClass = %s.ObjectBaseClass\n\n", objclass, class_name) + + -- b) add default property values for ALL component properites, so accessing them is fine from the object class + local objprops = _G[objclass].properties + for _, prop in ipairs(class:GetProperties()) do + if not table.find(class.properties, "id", prop.id) and not table.find(objprops, "id", prop.id) then + code:append("\t", objclass, ".", prop.id, " = ") + ValueToLuaCode(class:GetDefaultPropertyValue(prop.id, prop), nil, code, {} --[[ enable property injection ]]) + code:append("\n") + end + end + code:append("end\n\n") + code:appendf("function OnMsg.ClassesBuilt() __%sExtraDefinitions() end\n", objclass) + + -- Save the code and execute it now + local err = SaveSVNFile(path, code, class.LocalPreset) + if err then + printf("Error '%s' saving %s", tostring(err), path) + return + end + end + + if io.exists(path) then + dofile(path) + _G[string.format("__%sExtraDefinitions", objclass)]() + else + -- saved in a DLC folder, in a pack file mounted somewhere in DlcFolders + assert(path:starts_with("svnProject/Dlc/")) + for _, dlc_folder in ipairs(rawget(_G, "DlcFolders")) do + local path = string.format("%s/Presets/%s/__%s.generated.lua", dlc_folder, class_name, objclass) + if io.exists(path) then + dofile(path) + _G[string.format("__%sExtraDefinitions", objclass)]() + return + end + end + assert(false, "Unable to find and execute " .. path .. " from a DLC folder.") + end + end) +end + + +----- Test/sample code below + +--[[DefineClass.TestClass = { + __parents = { "PropertyObject" }, + properties = { + { category = "General", id = "BaseProp1", editor = "text", default = "", translate = true, lines = 1, max_lines = 10, }, + { category = "General", id = "BaseProp2", editor = "bool", default = true, }, + }, + Value = true, + TestMethod = true, +} + +DefineClass.TestClassComponent = { + __parents = { "PropertyObject" } +} + +DefineClass.TestClassComponent1 = { + __parents = { "TestClassComponent" }, + properties = { + { id = "Component1Prop1", editor = "text", default = "", translate = true, lines = 1, max_lines = 10 }, + { id = "Component1Prop2", editor = "bool", default = true }, + }, +} + +function TestClassComponent1:Value() + return 1 +end + +function TestClassComponent1:TestMethod() + return 1 +end + +DefineClass.TestClassComponent2 = { + __parents = { "TestClassComponent" }, + properties = { + { id = "Component2Prop", editor = "number", default = 0 }, + }, +} + +function TestClassComponent2:Value() + return 2 +end + +RecursiveCallMethods.Value = "+" +RecursiveCallMethods.TestMethod = "call" + +DefineClass.TestCompositeDef = { + __parents = { "CompositeDef" }, + + -- composite def + ObjectBaseClass = "TestClass", + ComponentClass = "TestClassComponent", + + -- preset + EditorMenubarName = "TestClass Composite Objects Editor", + EditorMenubar = "Editors", + EditorShortcut = "Ctrl-T", + GlobalMap = "TestCompositeDefs", +}]] \ No newline at end of file diff --git a/CommonLua/Classes/CompositeBody.lua b/CommonLua/Classes/CompositeBody.lua new file mode 100644 index 0000000000000000000000000000000000000000..25aaf997816b1c532adfb5528c897ae84f69ef14 --- /dev/null +++ b/CommonLua/Classes/CompositeBody.lua @@ -0,0 +1,1428 @@ +function GetSpotOffset(obj, name, idx, state, phase) + assert(obj) + if not IsValid(obj) then + return 0, 0, 0, "obj" + end + idx = idx or obj:GetSpotBeginIndex(name) or -1 + if idx == -1 then + return 0, 0, 0, "spot" + end + state = state or "idle" + phase = phase or 0 + local x, y, z = GetEntitySpotPos(obj, state, phase, idx, idx, true):xyz() + local s = obj:GetWorldScale() + if s ~= 100 then + x, y, z = x * s / 100, y * s / 100, z * s / 100 + end + return x, y, z +end + +function GetLocalAngleDiff(attach, local_angle) + return abs(AngleDiff(attach:GetVisualAngleLocal(), local_angle)) +end + +function GetLocalRotationTime(attach, local_angle, speed) + return MulDivRound(1000, GetLocalAngleDiff(attach, local_angle), speed) +end + +function GetLocalAngle(obj, angle) + return AngleDiff(angle, obj:GetAngle()) +end + +---- + +DefineClass.CompositeBodyPart = { + __parents = { "ComponentAnim", "ComponentAttach", "ColorizableObject" }, + flags = { gofSyncState = true, efWalkable = false, efApplyToGrids = false, efCollision = false, efSelectable = true }, +} + +function CompositeBodyPart:GetName() + local parent = self:GetParent() + while IsValid(parent) do + if IsKindOf(parent, "CompositeBody") then + for name, part in pairs(parent.attached_parts) do + if part == self then + return name + end + end + return + else + parent = parent:GetParent() + end + end +end + +local function RecomposeBody(obj) + for name, part in pairs(obj.attached_parts) do + if part ~= obj then + obj:RemoveBodyPart(part, name) + end + end + obj.attached_parts = nil + obj:ComposeBodyParts() +end + +local function EditorRecomposeBodiesOnMap(obj, root, prop_id, ged) + if IsValid(obj) then + RecomposeBody(obj) + elseif obj.object_class then + MapForEach("map", obj.object_class, RecomposeBody) + end +end + +local function get_body_parts_count(self) + local class_name = self.id + local class = g_Classes[class_name] or empty_table + local target = self.composite_part_target or class.composite_part_target or class_name + local composite_part_groups = self.composite_part_groups or class.composite_part_groups or { class_name } + local part_presets = Presets.CompositeBodyPreset + local count = 0 + for _, part_name in ipairs(self.composite_part_names or class.composite_part_names) do + for _, part_group in ipairs(composite_part_groups) do + for _, part_preset in ipairs(part_presets[part_group] or empty_table) do + if (not target or part_preset.Target == target) and (part_preset.Parts or empty_table)[part_name] then + count = count + 1 + end + end + end + end + return count +end + +-- Composite bodies change the entity, scale and colors of the unit." +DefineClass.CompositeBody = { + __parents = { "Object", "CompositeBodyPart" }, + + properties = { + { category = "Composite Body", id = "recompose", name = "Recompose", editor = "buttons", default = false, template = true, buttons = { { name = "Recompose", func = function(...) return EditorRecomposeBodiesOnMap(...) end, } } }, + { category = "Composite Body", id = "composite_part_names", name = "Parts", editor = "string_list", template = true, help = "Composite body parts. Each body preset may cover one or more parts. Each part may have another part as a parent and a custom attach spot.", body_part_match = true }, + { category = "Composite Body", id = "composite_part_main", name = "Main Part", editor = "choice", items = PropGetter("composite_part_names"), template = true, help = "Main body part to be applied directly to the composite object." }, + { category = "Composite Body", id = "composite_part_target", name = "Target", editor = "text", template = true, help = "Will match composite body presets having the same target. If not specified, the class name is used.", body_part_match = true }, + { category = "Composite Body", id = "composite_part_groups", name = "Groups", editor = "string_list", items = PresetGroupsCombo("CompositeBodyPreset"), template = true, help = "Will match composite body presets from those groups. If not specified, the class name is used as a group name.", body_part_match = true }, + { category = "Composite Body", id = "CompositePartCount", name = "Parts Found", editor = "number", template = true, default = 0, dont_save = true, read_only = 0, getter = get_body_parts_count }, + { category = "Composite Body", id = "composite_part_parent", name = "Parent", editor = "prop_table", read_only = true, template = true, help = "Defines custom parent for each body part." }, + { category = "Composite Body", id = "composite_part_spots", name = "Spots", editor = "prop_table", read_only = true, template = true, help = "Defines custom attach spots for each body part." }, + { category = "Composite Body", id = "cycle_colors", name = "Cycle Colors", editor = "bool", default = false, template = true, help = "If you can cycle through the composite body colors during construction.", }, + }, + + flags = { gofSyncState = false, gofPropagateState = true }, + + composite_seed = false, + colorization_offset = 0, + composite_part_target = false, + composite_part_names = { "Body" }, + composite_part_spots = false, + composite_part_parent = false, + composite_part_main = "Body", + composite_part_groups = false, + + attached_parts = false, + override_parts = false, + override_parts_spot = false, + + InitBodyParts = empty_func, + SetAutoAttachMode = empty_func, + ChangeEntityDisabled = empty_func, +} + +function CompositeBody:CheatCompose() + self:ComposeBodyParts() +end + +local props = CompositeBody.properties +for i=1,10 do + local category = "Composite Body Hierarchy" + local function no_edit(self) + local names = self:GetProperty("composite_part_names") or empty_table + local name = names[i] + return not name or name == self:GetProperty("composite_part_main") + end + local function GetPartName(self) + local names = self:GetProperty("composite_part_names") + return names[i] or "" + end + local function GetSpotName(self) + local name = GetPartName(self) + return name .. " Spot" + end + local function GetParentName(self) + local name = GetPartName(self) + return name .. " Parent" + end + local spot_id = "composite_part_spot_" .. i + local parent_id = "composite_part_parent_" .. i + local function getter(self, prop_id) + local target_id + if prop_id == spot_id then + target_id = "composite_part_spots" + elseif prop_id == parent_id then + target_id = "composite_part_parent" + else + return "" + end + local name = GetPartName(self) + local map = self:GetProperty(target_id) + return map and map[name] or "" + end + local function setter(self, value, prop_id) + local target_id + if prop_id == spot_id then + target_id = "composite_part_spots" + elseif prop_id == parent_id then + target_id = "composite_part_parent" + else + return + end + local name = GetPartName(self) + local map = self:GetProperty(target_id) or empty_table + map = table.raw_copy(map) + map[name] = (value or "") ~= "" and value or nil + rawset(self, target_id, map) + end + local function GetParentItems(self) + local names = self:GetProperty("composite_part_names") or empty_table + if names[i] then + names = table.icopy(names) + table.remove_value(names, names[i]) + end + return names, return_true + end + table.iappend(props, { + { category = category, id = spot_id, name = GetSpotName, editor = "text", default = "", dont_save = true, getter = getter, setter = setter, no_edit = no_edit, template = true }, + { category = category, id = parent_id, name = GetParentName, editor = "choice", default = "", items = GetParentItems, dont_save = true, getter = getter, setter = setter, no_edit = no_edit, template = true }, + }) + CompositeBody["Get" .. spot_id] = function(self) + return getter(self, spot_id) + end + CompositeBody["Get" .. parent_id] = function(self) + return getter(self, parent_id) + end + CompositeBody["Set" .. spot_id] = function(self, value) + return setter(self, spot_id, value) + end + CompositeBody["Set" .. parent_id] = function(self, value) + return setter(self, parent_id, value) + end +end + +function CompositeBody:Done() + -- allow garbage collection of CompositeBody objects which otherwise have a non-weak reference to themselves + self.attached_parts = nil + self.override_parts = nil +end + +function CompositeBody:GetPart(name) + local parts = self.attached_parts + return parts and parts[name] +end + +function CompositeBody:GetPartName(part_to_find) + for name, part in pairs(self.attached_parts) do + if part == part_to_find then + return name + end + end +end + +function CompositeBody:ForEachBodyPart(func, ...) + local attached_parts = self.attached_parts or empty_table + for _, name in ipairs(self.composite_part_names) do + local part = attached_parts[name] + if part then + func(part, self, ...) + end + end +end + +function CompositeBody:UpdateEntity() + return self:ComposeBodyParts() +end + +local function ResolveCompositeMainEntity(classdef) + if not classdef then return end + local composite_part_groups = classdef.composite_part_groups + local composite_part_group = composite_part_groups and composite_part_groups[1] or classdef.class + local part_presets = table.get(Presets, "CompositeBodyPreset", composite_part_group) + if next(part_presets) then + local composite_part_target = classdef.composite_part_target + local composite_part_main = classdef.composite_part_main or "Body" + for _, part_preset in ipairs(part_presets) do + if not composite_part_target or composite_part_target == part_preset.Target then + if (part_preset.Parts or empty_table)[composite_part_main] then + return part_preset.Entity + end + end + end + end + return classdef.entity or classdef.class +end + +function ResolveTemplateEntity(self) + local entity = IsValid(self) and self:GetEntity() + if IsValidEntity(entity) then + return entity + end + local class = self.id or self.class + local classdef = g_Classes[class] + if not classdef then return end + entity = ResolveCompositeMainEntity(classdef) + return IsValidEntity(entity) and entity +end + +function TemplateSpotItems(self) + local entity = ResolveTemplateEntity(self) + if not entity then return {} end + local spots = {{ value = false, text = "" }} + local seen = {} + local spbeg, spend = GetAllSpots(entity) + for spot = spbeg, spend do + local name = GetSpotName(entity, spot) + if not seen[name] then + seen[name] = true + spots[#spots + 1] = { value = name, text = name } + end + end + table.sortby_field(spots, "text") + return spots +end + +function CompositeBody:CollectBodyParts(part_to_preset, seed) + local target = self.composite_part_target or self.class + local composite_part_groups = self.composite_part_groups or { self.class } + local part_presets = Presets.CompositeBodyPreset + for _, part_name in ipairs(self.composite_part_names) do + if not part_to_preset[part_name] then + local matched_preset, matched_presets + for _, part_group in ipairs(composite_part_groups) do + for _, part_preset in ipairs(part_presets[part_group]) do + if (not target or part_preset.Target == target) and (part_preset.Parts or empty_table)[part_name] then + local matched = true + for _, filter in ipairs(part_preset.Filters) do + if not filter:Match(self) then + matched = false + break + end + end + if matched then + if not matched_preset or matched_preset.ZOrder < part_preset.ZOrder then + matched_preset = part_preset + matched_presets = nil + elseif matched_preset.ZOrder == part_preset.ZOrder then + if matched_presets then + matched_presets[#matched_presets + 1] = part_preset + else + matched_presets = { matched_preset, part_preset } + end + end + end + end + end + end + if matched_presets then + seed = self:ComposeBodyRand(seed) + matched_preset = table.weighted_rand(matched_presets, "Weight", seed) + end + if matched_preset then + part_to_preset[part_name] = matched_preset + end + end + end + return seed +end + +function CompositeBody:GetConstructionCopyObjectData(copy_data) + table.rawset_values(copy_data, self, "composite_seed", "colorization_offset") +end + +function CompositeBody:GetConstructionCursorDynamicData(controller, cursor_data) + table.rawset_values(cursor_data, controller, "composite_seed", "colorization_offset") +end + +function CompositeBody:GetConstructionControllerDynamicData(controller_data) + table.rawset_values(controller_data, self, "composite_seed", "colorization_offset") +end + +function OnMsg.GatherConstructionInitData(construction_init_data) + rawset(construction_init_data, "composite_seed", true) + rawset(construction_init_data, "colorization_offset", true) +end + +function CompositeBody:ComposeBodyRand(seed, ...) + seed = seed or self.composite_seed or self:RandSeed("Body") + self.composite_seed = self.composite_seed or seed + return BraidRandom(seed, ...) +end + +function CompositeBody:GetPartFXTarget(part) + return self +end + +function CompositeBody:ComposeBodyParts(seed) + if self:ChangeEntityDisabled() then + return + end + local part_to_preset = { } + -- collect the best matched body presets for the remaining parts without equipment + seed = self:CollectBodyParts(part_to_preset, seed) or seed + + -- apply the main body entity (all others are attached to this one) + local main_name = self.composite_part_main + local main_preset = main_name and part_to_preset[main_name] + if not main_preset and not IsValidEntity(self:GetEntity()) then + return + end + local applied_presets = {} + local changed + if main_preset then + local changed_i, seed_i = self:ApplyBodyPart(self, main_preset, main_name, seed) + assert(IsValidEntity(self:GetEntity())) + changed = changed_i or changed + seed = seed_i or seed + applied_presets = { [main_preset] = true } + end + + local last_part_class, part_def + + local override_parts = self.override_parts or empty_table + -- apply all the remaining as attaches (removing the unused ones from the previous procedure) + local attached_parts = self.attached_parts or {} + attached_parts[main_name] = self + self.attached_parts = attached_parts + for _, part_name in ipairs(self.composite_part_names) do + if part_name == main_name then + goto continue + end + local part_obj = attached_parts[part_name] + --body part overriding + local override = override_parts[part_name] + if override then + if override ~= part_obj then + if part_obj then + self:RemoveBodyPart(part_obj, part_name) + end + attached_parts[part_name] = override + local parent = self + if override:GetParent() ~= parent then + local spot = self.override_parts_spot and self.override_parts_spot[part_name] + spot = spot or self.composite_part_spots[part_name] + local spot_idx = spot and parent:GetSpotBeginIndex(spot) + parent:Attach(override, spot_idx) + end + end + goto continue + end + --preset search + local preset = part_to_preset[part_name] + if preset and not applied_presets[preset] then + applied_presets[preset] = true + if preset.Entity ~= "" then + local part_class = preset.PartClass or "CompositeBodyPart" + if not IsValid(part_obj) or part_obj.class ~= part_class then + if last_part_class ~= part_class then + last_part_class = part_class + part_def = g_Classes[part_class] + assert(part_def) + part_def = part_def or CompositeBodyPart + end + DoneObject(part_obj) + part_obj = part_def:new() + attached_parts[part_name] = part_obj + changed = true + end + local changed_i, seed_i = self:ApplyBodyPart(part_obj, preset, part_name, seed) + changed = changed_i or changed + seed = seed_i or seed + goto continue + end + end + -- 1) body part preset not found + -- 2) part already covered, should be removed + -- 3) part used to specify a missing part + if part_obj then + attached_parts[part_name] = nil + self:RemoveBodyPart(part_obj, part_name) + end + ::continue:: + end + if changed then + self:NetUpdateHash("BodyChanged", seed) + end + self:InitBodyParts() + return changed +end + +local def_scale = range(100, 100) + +function CompositeBody:ChangeBodyPartEntity(part, preset, name) + local entity = preset.Entity + if (preset.AffectedBy or "") ~= "" and (preset.EntityWhenAffected or "") ~= "" and self.attached_parts[preset.AffectedBy] then + entity = preset.EntityWhenAffected + end + + local current_entity = part:GetEntity() + if current_entity == entity or not IsValidEntity(entity) then + return + end + if current_entity ~= "" then + PlayFX("ApplyBodyPart", "end", part, self:GetPartFXTarget(part)) + end + local state = part:GetGameFlags(const.gofSyncState) == 0 and EntityStates.idle or nil + part:ChangeEntity(entity, state) + return true +end + +function CompositeBody:ChangeBodyPartScale(part, name, scale) + if part:GetScale() ~= scale then + part:SetScale(scale) + return true + end +end + +function CompositeBody:ApplyBodyPart(part, preset, name, seed) + -- entity + local changed_entity = self:ChangeBodyPartEntity(part, preset, name) + local changed = changed_entity + -- mirrored + if part:GetMirrored() ~= preset.Mirrored then + part:SetMirrored(preset.Mirrored) + changed = true + end + -- scale + local scale = 100 + local scale_range = preset.Scale + if scale_range ~= def_scale then + local scale_min, scale_max = scale_range.from, scale_range.to + if scale_min == scale_max then + scale = scale_min + else + scale, seed = self:ComposeBodyRand(seed, scale_min, scale_max) + end + end + if self:ChangeBodyPartScale(part, name, scale) then + changed = true + end + -- color + seed = self:ColorizeBodyPart(part, preset, name, seed) or seed + -- attach + if part ~= self then + local axis = preset.Axis + if axis and part:GetAxisLocal() ~= axis then + part:SetAxis(axis) + changed = true + end + local angle = preset.Angle + if angle and part:GetAngleLocal() ~= angle then + part:SetAngle(angle) + changed = true + end + local spot_name = preset.AttachSpot or "" + if spot_name == "" then + local spots = self.composite_part_spots + spot_name = spots and spots[name] or "" + if spot_name == "" then + spot_name = "Origin" + end + end + local sync_state = preset.SyncState + if sync_state == "auto" then + sync_state = spot_name == "Origin" + end + if not sync_state then + part:ClearGameFlags(const.gofSyncState) + else + part:SetGameFlags(const.gofSyncState) + end + local prev_parent, prev_spot_idx = part:GetParent(), part:GetAttachSpot() + local parents = self.composite_part_parent + local parent_part = preset.Parent or parents and parents[name] or "" + local parent = parent_part ~= "" and self.attached_parts[parent_part] or self + local spot_idx = parent:GetSpotBeginIndex(spot_name) + assert(spot_idx ~= -1, string.format("Failed to attach body part %s to spot %s of %s with state %s", name, spot_name, parent:GetEntity(), parent:GetStateText())) + if prev_parent ~= parent or prev_spot_idx ~= spot_idx then + parent:Attach(part, spot_idx) + changed = true + end + local attach_offset = preset.AttachOffset or point30 + local attach_axis = preset.AttachAxis or axis_z + local attach_angle = preset.AttachAngle or 0 + if attach_offset ~= part:GetAttachOffset() or attach_axis ~= part:GetAttachAxis() or attach_angle ~= part:GetAttachAngle() then + part:SetAttachOffset(attach_offset) + part:SetAttachAxis(attach_axis) + part:SetAttachAngle(attach_angle) + changed = true + end + end + + local changed_fx + local fx_actor_class = (preset.FxActor or "") ~= "" and preset.FxActor or nil + local current_fx_actor = rawget(part, "fx_actor_class") -- avoid clearing class fx actor with the default FxActor value + if current_fx_actor ~= fx_actor_class then + if current_fx_actor then + PlayFX("ApplyBodyPart", "end", part, self:GetPartFXTarget(part)) + end + part.fx_actor_class = fx_actor_class + changed_fx = true + end + + if changed_fx or changed_entity then + PlayFX("ApplyBodyPart", "start", part, self:GetPartFXTarget(part)) + end + + return changed, seed +end + +function CompositeBody:ColorizeBodyPart(part, preset, name, seed) + local inherit_from = preset.ColorInherit + local colorization = inherit_from ~= "" and table.get(self.attached_parts, inherit_from) + if not colorization then + seed = self:ComposeBodyRand(seed) + local colors = preset.Colors or empty_table + local idx + colorization, idx = table.weighted_rand(colors, "Weight", seed) + local offset = self.colorization_offset + if idx and offset then + idx = ((idx + offset - 1) % #colors) + 1 + colorization = colors[idx] + end + end + part:SetColorization(colorization) + return seed +end + +function CompositeBody:SetColorizationOffset(offset) + local part_to_preset = {} + local seed = self.composite_seed + self:CollectBodyParts(part_to_preset, seed) + local attached_parts = self.attached_parts + self.colorization_offset = offset + for _, part_name in ipairs(self.composite_part_names) do + local preset = part_to_preset[part_name] + if preset then + local part = attached_parts[part_name] + self:ColorizeBodyPart(part, preset, part_name, seed, offset) + end + end +end + +function CompositeBody:RemoveBodyPart(part, name) + DoneObject(part) +end + +function CompositeBody:OverridePart(name, obj, spot) + if not IsValid(self) or IsBeingDestructed(self) then + return + end + assert(table.find(self.composite_part_names, name), "Invalid part name") + if type(obj) == "string" and IsValidEntity(obj) then + local entity = obj + obj = CompositeBodyPart:new() + obj:ChangeEntity(entity) + AutoAttachObjects(obj) + end + if IsValid(obj) then + self.override_parts = self.override_parts or {} + assert(not self.override_parts[name], "Part already overridden") + self.override_parts[name] = obj + self.override_parts_spot = self.override_parts_spot or {} + self.override_parts_spot[name] = spot + elseif self.override_parts then + obj = self.override_parts[name] + if self.attached_parts[name] == obj then + self.attached_parts[name] = nil + end + self.override_parts[name] = nil + self.override_parts_spot[name] = nil + end + self:ComposeBodyParts() + return obj +end + +function CompositeBody:RemoveOverridePart(name) + local part = self:OverridePart(name, false) + if IsValid(part) then + self:RemoveBodyPart(part) + end +end + +local composite_body_targets, composite_body_filters, composite_body_parts, composite_body_defs + +function CompositeBody:OnEditorSetProperty(prop_id, old_value, ged) + local prop_meta = self:GetPropertyMetadata(prop_id) or empty_table + if prop_meta.body_part_match then + composite_body_targets = nil + end + if prop_meta.body_part_filter then + self:ComposeBodyParts() + end + return Object.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +---- +-- Editor only code: + +local function UpdateItems() + if composite_body_targets then + return + end + composite_body_filters, composite_body_parts, composite_body_defs = {}, {}, {} + ClassDescendantsList("CompositeBody", function(class, def) + local target = def.composite_part_target or class + + local filters = composite_body_filters[target] or {} + for _, prop in ipairs(def:GetProperties()) do + if prop.body_part_filter then + filters[prop.id] = filters[prop.id] or prop + end + end + composite_body_filters[target] = filters + + local defs = composite_body_defs[target] or {} + if not defs[class] then + defs[class] = true + table.insert(defs, def) + end + composite_body_defs[target] = defs + + local parts = composite_body_parts[target] or {} + for _, part in ipairs(def.composite_part_names) do + table.insert_unique(parts, part) + end + composite_body_parts[target] = parts + end, "") + composite_body_targets = table.keys2(composite_body_parts, true, "") +end + +function GetBodyPartEntityItems() + local items = {} + for entity in pairs(GetAllEntities()) do + local data = EntityData[entity] + if data then + items[#items + 1] = entity + end + end + table.sort(items) + table.insert(items, 1, "") + return items +end + +function GetBodyPartNameItems(preset) + UpdateItems() + return composite_body_parts[preset.Target] +end + +function GetBodyPartNameCombo(preset) + local items = table.copy(GetBodyPartNameItems(preset) or empty_table) + table.insert(items, 1, "") + return items +end + +function GetBodyPartTargetItems(preset) + UpdateItems() + return composite_body_targets +end + +function EntityStatesCombo(entity, ...) + entity = entity or "" + if entity == "" then + return { ... } + end + local anims = GetStates(entity) + table.sort(anims) + table.insert(anims, 1, "") + return anims +end + +function EntityStateMomentsCombo(entity, anim, ...) + entity = entity or "" + anim = anim or "" + if entity == "" or anim == "" then + return { ... } + end + local moments = GetStateMomentsNames(entity, anim) + table.insert(moments, 1, "") + return moments +end + +---- + +DefineClass.CompositeBodyPreset = { + __parents = { "Preset" }, + properties = { + { id = "Target", name = "Target", editor = "choice", default = "", items = GetBodyPartTargetItems }, + { id = "Parts", name = "Covered Parts", editor = "set", default = false, items = GetBodyPartNameItems }, + { id = "CustomMatch", name = "Custom Match", editor = "bool", default = false, }, + { id = "BodiesFound", name = "Bodies Found", editor = "text", default = "", dont_save = true, read_only = 0, lines = 1, max_lines = 3, no_edit = PropChecker("CustomMatch", true) }, + { id = "Parent", name = "Parent Part", editor = "choice", default = false, items = GetBodyPartNameItems }, + { id = "Entity", name = "Entity", editor = "choice", default = "", items = GetBodyPartEntityItems }, + { id = "PartClass", name = "Custom Class", editor = "text", default = false, translate = false, validate = function(self) return self.PartClass and not g_Classes[self.PartClass] and "Invalid class" end }, + { id = "AttachSpot", name = "Attach Spot", editor = "text", default = "", translate = false, help = "Force attach spot" }, + { id = "Scale", name = "Scale", editor = "range", default = def_scale }, + { id = "Axis", name = "Axis", editor = "point", default = false, help = "Force a specific axis" }, + { id = "Angle", name = "Angle", editor = "number", default = false, scale = "deg", min = -180*60, max = 180*60, slider = true, help = "Force a specific angle" }, + { id = "Mirrored", name = "Mirrored", editor = "bool", default = false }, + { id = "SyncState", name = "Sync State", editor = "choice", default = "auto", items = {true, false, "auto"}, help = "Force sync state" }, + { id = "ZOrder", name = "ZOrder", editor = "number", default = 0, }, + { id = "Weight", name = "Weight", editor = "number", default = 1000, min = 0, scale = 10 }, + { id = "FxActor", name = "Fx Actor", editor = "combo", default = "", items = ActorFXClassCombo }, + { id = "Filters", name = "Filters", editor = "nested_list", default = false, base_class = "CompositeBodyPresetFilter", inclusive = true }, + { id = "ColorInherit", name = "Color Inherit", editor = "choice", default = "", items = GetBodyPartNameCombo }, + { id = "Colors", name = "Colors", editor = "nested_list", default = false, base_class = "CompositeBodyPresetColor", inclusive = true, no_edit = function(self) return self.ColorInherit ~= "" end }, + { id = "Lights", name = "Lights", editor = "nested_list", default = false, base_class = "CompositeBodyPresetLight", inclusive = true }, + { id = "AffectedBy", name = "Affected by", editor = "choice", default = "", items = GetBodyPartNameCombo }, + { id = "EntityWhenAffected", name = "Entity when affected", editor = "choice", default = "", items = GetBodyPartEntityItems, no_edit = function(o) return not o.AffectedBy end }, + { id = "AttachOffset", name = "Attach Offset", editor = "point", default = point30, }, + { id = "AttachAxis", name = "Attach Axis", editor = "point", default = axis_z, }, + { id = "AttachAngle", name = "Attach Angle", editor = "number", default = 0, scale = "deg", min = -180*60, max = 180*60, slider = true }, + + { id = "ApplyAnim", name = "Apply Anim", editor = "choice", default = "", items = function(self) return EntityStatesCombo(self.AnimTestEntity, "") end }, + { id = "UnapplyAnim", name = "Unapply Anim", editor = "choice", default = "", items = function(self) return EntityStatesCombo(self.AnimTestEntity, "") end }, + { id = "ApplyAnimMoment", name = "Apply Anim Moment", editor = "choice", default = "hit", items = function(self) return EntityStateMomentsCombo(self.AnimTestEntity, self.ApplyAnim, "", "hit") end, }, + { id = "AnimTestEntity", name = "Anim Test Entity", editor = "text", default = false }, + }, + GlobalMap = "CompositeBodyPresets", + EditorMenubar = "Editors.Art", + EditorMenubarName = "Composite Body Parts", + EditorIcon = "CommonAssets/UI/Icons/atom molecule science.png", + + StoreAsTable = false, +} + +CompositeBodyPreset.Documentation = [[The composite body system is a matching system for attaching parts to a body. + +A body collects its potential parts not from all part presets, but from a specified preset . The matched parts are those having the same property as the body target property. + +If no matching information is specified in the body, then its class name is used instead for all matching. + +Each part can contain filters for additional conditions during the matching process. + +Each part covers a specific named location on the body specified by property. If several parts are matched for the same location, a single one is chosen based on the property. If there are still multiple parts with equal ZOrder, then a part is randomly selected based on the property.]] + +function CompositeBodyPreset:GetError() + if self.CustomMatch then + return + end + local parts = self.Parts + if not next(parts) then + return "No covered parts specified!" + end + UpdateItems() + local defs = composite_body_defs[self.Target] + if not defs then + return string.format("No composite bodies found with target '%s'", self.Target) + end + local group = self.group + local count_group = 0 + local count_part = 0 + for _, def in ipairs(defs) do + local composite_part_groups = def.composite_part_groups or { def.class } + if table.find(composite_part_groups, group) then + count_group = count_group + 1 + for _, part_name in ipairs(def.composite_part_names) do + if parts[part_name] then + count_part = count_part + 1 + break + end + end + end + end + if count_group == 0 then + return string.format("No composite bodies found with group '%s'", tostring(group)) + end + if count_part == 0 then + return string.format("No composite bodies found with parts %s", table.concat(table.keys(parts, true))) + end +end + +function CompositeBodyPreset:GetBodiesFound() + UpdateItems() + local parts = self.Parts + if not next(parts) then + return 0 + end + local found = {} + for _, def in ipairs(composite_body_defs[self.Target]) do + local composite_part_groups = def.composite_part_groups or { def.class } + if table.find(composite_part_groups, self.group) then + for _, part_name in ipairs(def.composite_part_names) do + if parts[part_name] then + found[def.class] = true + break + end + end + end + end + return table.concat(table.keys(found, true), ", ") +end + +function CompositeBodyPreset:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Entity" then + for _, obj in ipairs(self.Colors) do + ObjModified(obj) -- properties for modifiable colors have changed + end + end +end + +local function FindParentPreset(obj, member) + return GetParentTableOfKind(obj, "CompositeBodyPreset") +end + +function OnMsg.ClassesGenerate() + DefineModItemPreset("CompositeBodyPreset", { + EditorSubmenu = "Other", + EditorName = "Composite body", + EditorShortcut = false, + }) +end + +---- + +local function GetBodyFilters(filter) + UpdateItems() + local parent = FindParentPreset(filter) + local props = parent and composite_body_filters[parent.Target] + if not props then + return {} + end + local filters = {} + for _, def in ipairs(composite_body_defs[parent.Target]) do + for name, prop in pairs(props) do + local items + if prop.items then + items = prop_eval(prop.items, def, prop) + elseif prop.preset_class then + local filter = prop.preset_filter + items = {} + ForEachPreset(prop.preset_class, function(preset, group, items) + if not filter or filter(preset) then + items[#items + 1] = preset.id + end + end, items) + table.sort(items) + end + if items and #items > 0 then + local prev_filters = filters[name] + if not prev_filters then + filters[name] = items + else + for _, value in ipairs(items) do + table.insert_unique(prev_filters, value) + end + end + end + end + end + return filters +end + +local function GetFilterNameItems(filter) + local filters = GetBodyFilters(filter) + local items = filters and table.keys(filters, true) + if items[1] ~= "" then + table.insert(items, 1, "") + end + return items +end + +local function GetFilterValueItems(filter) + local filters = GetBodyFilters(filter) + return filters and filters[filter.Name] or {""} +end + +DefineClass.CompositeBodyPresetFilter = { + __parents = { "PropertyObject" }, + properties = { + { id = "Name", name = "Name", editor = "choice", default = "", items = GetFilterNameItems, }, + { id = "Value", name = "Value", editor = "choice", default = "", items = GetFilterValueItems, }, + { id = "Test", name = "Test", editor = "choice", default = "=", items = {"=", ">", "<"}, }, + }, + EditorView = Untranslated(" "), +} + +function CompositeBodyPresetFilter:Match(obj) + local obj_value, value, test = obj[self.Name], self.Value, self.Test + if test == '=' then + return obj_value == value + elseif test == '>' then + return obj_value > value + elseif test == '<' then + return obj_value < value + end +end + +---- + +DefineClass.CompositeBodyPresetColor = { + __parents = { "ColorizationPropSet" }, + properties = { + { id = "Weight", name = "Weight", editor = "number", default = 1000, min = 0, scale = 10 }, + }, +} + +function CompositeBodyPresetColor:GetMaxColorizationMaterials() + PopulateParentTableCache(self) + if not ParentTableCache[self] then + return ColorizationPropSet.GetMaxColorizationMaterials(self) + end + local parent = FindParentPreset(self) + return parent and ColorizationMaterialsCount(parent.Entity) or 0 +end + +function CompositeBodyPresetColor:GetError() + if self:GetMaxColorizationMaterials() == 0 then + local parent = FindParentPreset(self) + if not parent or parent.Entity == "" then + return "The composite body entity is not set." + else + return "There are no modifiable colors in the composite body entity." + end + end +end + +---- + +local light_props = {} +function OnMsg.ClassesBuilt() + local function RegisterProps(class, classdef) + local props = {} + for _, prop in ipairs(classdef:GetProperties()) do + if prop.category == "Visuals" + and not prop_eval(prop.no_edit, classdef, prop) + and not prop_eval(prop.read_only, classdef, prop) then + props[#props + 1] = prop + props[prop.id] = classdef:GetDefaultPropertyValue(prop.id, prop) + end + end + light_props[class] = props + end + RegisterProps("Light", Light) + ClassDescendants("Light", RegisterProps) +end + +function OnMsg.GatherFXActors(list) + for _, preset in pairs(CompositeBodyPresets) do + if (preset.FxActor or "") ~= "" then + list[#list + 1] = preset.FxActor + end + end +end + +function OnMsg.DataLoaded() + PopulateParentTableCache(Presets.CompositeBodyPreset) +end + +local function GetEntitySpotsItems(light) + local parent = FindParentPreset(light) + local entity = parent and parent.Entity or "" + local states = IsValidEntity(entity) and GetStates(entity) or "" + if #states == 0 then return empty_table end + local idx = table.find(states, "idle") + local spots = {} + local spbeg, spend = GetAllSpots(entity, states[idx] or states[1]) + for spot = spbeg, spend do + spots[GetSpotName(entity, spot)] = true + end + return table.keys(spots, true) +end + +DefineClass.CompositeBodyPresetLight = { + __parents = { "PropertyObject" }, + properties = { + { id = "LightType", name = "Light Type", editor = "choice", default = "Light", items = ToCombo(light_props) }, + { id = "LightSpot", name = "Light Spot", editor = "combo", default = "Origin", items = GetEntitySpotsItems }, + { id = "LightSIEnable", name = "SI Apply", editor = "bool", default = true }, + { id = "LightSIModulation", name = "SI Modulation", editor = "number", default = 255, min = 0, max = 255, slider = true, no_edit = function(self) return not self.LightSIEnable end }, + { id = "night_mode", name = "Night mode", editor = "dropdownlist", items = { "Off", "On" }, default = "On" }, + { id = "day_mode", name = "Day mode", editor = "dropdownlist", items = { "Off", "On" }, default = "Off" }, + }, + EditorView = Untranslated(": "), +} + +function CompositeBodyPresetLight:GetError() + if not light_props[self.LightType] then + return "Invalid light type selected!" + end +end + +function CompositeBodyPresetLight:ApplyToLight(light) + local props = light_props[self.LightType] or empty_table + for _, prop in ipairs(props) do + local prop_id = prop.id + local prop_value = rawget(self, prop_id) + if prop_value ~= nil then + light:SetProperty(prop_id, prop_value) + end + end +end + +function CompositeBodyPresetLight:GetProperties() + local props = table.icopy(self.properties) + table.iappend(props, light_props[self.LightType] or empty_table) + return props +end + +function CompositeBodyPresetLight:GetDefaultPropertyValue(prop_id, prop_meta) + local def = table.get(light_props, self.LightType, prop_id) + if def ~= nil then + return def + end + return PropertyObject.GetDefaultPropertyValue(self, prop_id, prop_meta) +end + +DefineClass.BaseLightObject = { + __parents = { "Object" }, +} + +function BaseLightObject:UpdateLight(lm, delayed) +end + +function BaseLightObject:GameInit() + Game:AddToLabel("Lights", self) +end + +function BaseLightObject:Done() + Game:RemoveFromLabel("Lights", self) +end + +if FirstLoad then + UpdateLightsThread = false +end + +function OnMsg.DoneMap() + UpdateLightsThread = false +end + +function UpdateLights(lm, delayed) + local lights = table.get(Game, "labels", "Lights") + for _, obj in ipairs(lights) do + obj:UpdateLight(lm, delayed) + end +end + +function UpdateLightsDelayed(lm, delayed_time) + DeleteThread(UpdateLightsThread) + UpdateLightsThread = false + if delayed_time > 0 then + UpdateLightsThread = CreateGameTimeThread(function(lm, delayed_time) + Sleep(delayed_time) + UpdateLights(lm, true) + UpdateLightsThread = false + end, lm, delayed_time) + else + UpdateLights(lm) + end +end + +function OnMsg.LightmodelChange(view, lm, time) + UpdateLightsDelayed(lm, time/2) +end + +function OnMsg.GatherAllLabels(labels) + labels.Lights = true +end + +DefineClass.CompositeLightObject = { + __parents = { "CompositeBody", "BaseLightObject" }, + + light_parts = false, + light_objs = false, +} + +function CompositeLightObject:ComposeBodyParts(seed) + self.light_parts = nil + + local changed = CompositeBody.ComposeBodyParts(self, seed) + + local light_parts = self.light_parts + local light_objs = self.light_objs + for i = #(light_objs or ""),1,-1 do + local config = light_objs[i] + local part = light_parts and light_parts[config] + if not part then + DoneObject(light_objs[config]) + light_objs[config] = nil + table.remove_value(light_objs, config) + end + end + for _, config in ipairs(light_parts) do + light_objs = light_objs or {} + if light_objs[config] == nil then + light_objs[config] = false + light_objs[#light_objs + 1] = config + end + end + self.light_objs = light_objs + + return changed +end + +function CompositeLightObject:ApplyBodyPart(part, preset, ...) + local light_parts = self.light_parts + for _, config in ipairs(preset.Lights) do + light_parts = light_parts or {} + light_parts[config] = part + light_parts[#light_parts + 1] = config + end + self.light_parts = light_parts + + return CompositeBody.ApplyBodyPart(self, part, preset, ...) +end + +function CompositeLightObject:IsBodyPartLightOn(config) + local mode = GameState.Night and config.night_mode or config.day_mode + return mode == "On" +end + +function CompositeLightObject:UpdateLight(delayed) + local light_objs = self.light_objs or empty_table + local IsBodyPartLightOn = self.IsBodyPartLightOn + for _, config in ipairs(light_objs) do + local light = light_objs[config] + local part = self.light_parts[config] + local turned_on = IsBodyPartLightOn(self, config) + if turned_on and not light then + light = PlaceObject(config.LightType) + config:ApplyToLight(light) + part:Attach(light, GetSpotBeginIndex(part, config.LightSpot)) + light_objs[config] = light + elseif not turned_on and light then + DoneObject(light) + light_objs[config] = false + end + if config.LightSIEnable then + part:SetSIModulation(turned_on and config.LightSIModulation or 0) + end + end +end + +---- + +DefineClass.BlendedCompositeBody = { + __parents = { "CompositeBody", "Object" }, + composite_part_blend = false, + + blended_body_parts_params = false, + blended_body_parts = false, +} + +function BlendedCompositeBody:Init() + self.blended_body_parts_params = { } + self.blended_body_parts = { } +end + +function BlendedCompositeBody:ForceComposeBlendedBodyParts() + self.blended_body_parts_params = { } + self.blended_body_parts = { } + self:ComposeBodyParts() +end + +function BlendedCompositeBody:ForceRevertBlendedBodyParts() + if next(self.attached_parts) then + local part_to_preset = {} + self:CollectBodyParts(part_to_preset) + for name,preset in sorted_pairs(part_to_preset) do + local part = self.attached_parts[name] + local entity = preset.Entity + if IsValid(part) and IsValidEntity(entity) then + Msg("RevertBlendedBodyPart", part) + part:ChangeEntity(entity) + end + end + end +end + +function BlendedCompositeBody:UpdateBlendPartParams(params, part, preset, name, seed) + return part:GetEntity() +end + +function BlendedCompositeBody:ShouldBlendPart(params, part, preset, name, seed) + return false +end + +if FirstLoad then + g_EntityBlendLocks = { } + --g_EntityBlendLog = { } +end + +local function BlendedEntityLocksGet(entity_name) + return g_EntityBlendLocks[entity_name] or 0 +end + +function BlendedEntityIsLocked(entity_name) + --table.insert(g_EntityBlendLog, GameTime() .. " lock " .. entity_name) + return BlendedEntityLocksGet(entity_name) > 0 +end + +function BlendedEntityLock(entity_name) + --table.insert(g_EntityBlendLog, GameTime() .. " unlock " .. entity_name) + g_EntityBlendLocks[entity_name] = BlendedEntityLocksGet(entity_name) + 1 +end + +function BlendedEntityUnlock(entity_name) + local locks_count = BlendedEntityLocksGet(entity_name) + assert(locks_count >= 1, "Unlocking a blended entity that isn't locked") + if locks_count > 1 then + g_EntityBlendLocks[entity_name] = locks_count - 1 + else + g_EntityBlendLocks[entity_name] = nil + end +end + +function WaitBlendEntityLocks(obj, entity_name) + while BlendedEntityIsLocked(entity_name) do + if obj and not IsValid(obj) then + return false + end + WaitNextFrame(1) + end + + return true +end + +function BlendedCompositeBody:BlendEntity(t, e1, e2, e3, w1, w2, w3, m2, m3) + --table.insert(g_EntityBlendLog, GameTime() .. " " .. self.class .. " blend " .. t) + assert(BlendedEntityIsLocked(t), "To blend an entity you must lock it using BlendedEntityLock") + assert(t ~= e1 and t ~= e2 and t ~= e3) + + SetMaterialBlendMaterials( + GetEntityIdleMaterial(t), --target + GetEntityIdleMaterial(e1), --base + m2, GetEntityIdleMaterial(e2), --weight 1, material + m3, GetEntityIdleMaterial(e3)) --weight 2, material + WaitNextFrame(1) + + local err = AsyncOpWait(nil, nil, "AsyncMeshBlend", + t, 0, --target, LOD + e1, w1, --entity 1, weight + e2, w2, --entity 2, weight + e3, w3) --entity 3, weight + if err then print("Failed to blend meshes: ", err) end +end + +function BlendedCompositeBody:AsyncBlendEntity(obj, t, e1, e2, e3, w1, w2, w3, m2, m3, callback) + return CreateRealTimeThread(function(self, obj, t, e1, e2, e3, w1, w2, w3, m2, m3, callback) + WaitBlendEntityLocks(obj, t) + BlendedEntityLock(t) + + self:BlendEntity(t, e1, e2, e3, w1, w2, w3, m2, m3) + + if callback then + callback(self, obj, t, e1, e2, e3, w1, w2, w3, m2, m3) + end + + BlendedEntityUnlock(t) + end, self, obj, t, e1, e2, e3, w1, w2, w3, m2, m3, callback) +end + +function BlendedCompositeBody:ApplyBlendBodyPart(blended_entity, part, preset, name, seed) + return CompositeBody.ApplyBodyPart(self, preset, name, seed) +end + +function BlendedCompositeBody:BlendBodyPartFailed(blended_entity, part, preset, name, seed) + return CompositeBody.ApplyBodyPart(self, part, preset, name, seed) +end + +-- if the body part is declared as "to be blended" +function BlendedCompositeBody:IsBlendBodyPart(name) + return self.composite_part_blend and self.composite_part_blend[name] +end + +-- if the body part is using a blended entity or is being blended at the moment +function BlendedCompositeBody:IsCurrentlyBlendedBodyPart(name) + return self.blended_body_parts and self.blended_body_parts[name] +end + +function BlendedCompositeBody:ColorizeBodyPart(part, preset, name, seed) + if self:IsCurrentlyBlendedBodyPart(name) then + return + end + return CompositeBody.ColorizeBodyPart(self, part, preset, name, seed) +end + +function BlendedCompositeBody:ChangeBodyPartEntity(part, preset, name) + if self:IsCurrentlyBlendedBodyPart(name) then + return + end + return CompositeBody.ChangeBodyPartEntity(self, part, preset, name) +end + +function BlendedCompositeBody:ApplyBodyPart(part, preset, name, seed) + if self:IsBlendBodyPart(name) then + self.blended_body_parts_params = self.blended_body_parts_params or { } + local params = self.blended_body_parts_params[name] + if not params or self:ShouldBlendPart(params, part, preset, name, seed) then + params = params or { } + local blended_entity = self:UpdateBlendPartParams(params, part, preset, name, seed) + if IsValidEntity(blended_entity) then + self.blended_body_parts_params[name] = params + self.blended_body_parts[name] = (self.blended_body_parts[name] or 0) + 1 + return self:ApplyBlendBodyPart(blended_entity, part, preset, name, seed) + else + self.blended_body_parts[name] = nil + return self:BlendBodyPartFailed(blended_entity, part, preset, name, seed) + end + end + end + + return CompositeBody.ApplyBodyPart(self, part, preset, name, seed) +end + +function BlendedCompositeBody:RemoveBodyPart(part, name) + if self:IsBlendBodyPart(name) and self.blended_body_parts_params then + self.blended_body_parts_params[name] = nil + end + return CompositeBody.RemoveBodyPart(self, part, name) +end + +function ForceRecomposeAllBlendedBodies() + local objs = MapGet("map", "BlendedCompositeBody") + for i,obj in ipairs(objs) do + obj:ForceRevertBlendedBodyParts() + end + for i,obj in ipairs(objs) do + obj:ForceComposeBlendedBodyParts() + end +end + +function OnMsg.PostLoadGame() + ForceRecomposeAllBlendedBodies() +end + +function OnMsg.AdditionalEntitiesLoaded() + if type(__cobjectToCObject) ~= "table" then return end + ForceRecomposeAllBlendedBodies() +end + +local body_to_states +function CompositeBodyAnims(classdef) + local id = classdef.id or classdef.class + body_to_states = body_to_states or {} + local states = body_to_states[id] + if not states then + local entity = ResolveTemplateEntity(classdef) + states = IsValidEntity(entity) and GetStates(entity) or empty_table + table.sort(states) + body_to_states[id] = states + end + return states +end + +function SavegameFixups.BlendedBodyPartsList() + MapForEach(true, "BlendedCompositeBody", function(obj) + obj.blended_body_parts = {} + end) +end + +function SavegameFixups.BlendedBodyBlendIDs() + MapForEach(true, "BlendedCompositeBody", function(obj) + for name in pairs(obj.blended_body_parts) do + obj.blended_body_parts[name] = 1 + end + end) +end + +function SavegameFixups.FixSyncStateFlag2() + MapForEach(true, "CompositeBody", "Building", function(obj) + obj:ClearGameFlags(const.gofSyncState) + obj:SetGameFlags(const.gofPropagateState) + end) +end diff --git a/CommonLua/Classes/Context.lua b/CommonLua/Classes/Context.lua new file mode 100644 index 0000000000000000000000000000000000000000..349dd11fa144d347aa5385c2ba49f682b08ef360 --- /dev/null +++ b/CommonLua/Classes/Context.lua @@ -0,0 +1,125 @@ +--- An object which can resolve a key to a value. +-- Context objects can be nested to create a complex value resolution structure. +-- The global function ResolveValue allows resolving a tuple to a value in an arbitrary context. + +DefineClass.Context = { + __parents = {}, + __hierarchy_cache = true, +} + +function Context:new(obj) + return setmetatable(obj or {}, self) +end + +function Context:ResolveValue(key) + local value = rawget(self, key) + if value ~= nil then return value end + for _, sub_context in ipairs(self) do + value = ResolveValue(sub_context, key) + if value ~= nil then return value end + end +end + +-- change __index method to allow full member resolution without warning +function OnMsg.ClassesBuilt() + local context_class = g_Classes.Context + context_class.__index = function (self, key) + if type(key) == "string" then + return rawget(context_class, key) or context_class.ResolveValue(self, key) + end + end +end + +function Context:IsKindOf(class) + if IsKindOf(self, class) then return true end + for _, sub_context in ipairs(self) do + if IsKindOf(sub_context, "Context") and sub_context:IsKindOf(class) or IsKindOf(sub_context, class) then + return true + end + end +end + +function Context:IsKindOfClasses(...) + if IsKindOfClasses(self, ...) then return true end + for _, sub_context in ipairs(self) do + if IsKindOf(sub_context, "Context") and sub_context:IsKindOfClasses(...) or IsKindOfClasses(sub_context, ...) then + return true + end + end +end + +function ForEachObjInContext(context, f, ...) + if not context then return end + if IsKindOf(context, "Context") then + for _, sub_context in ipairs(context) do + ForEachObjInContext(sub_context, f, ...) + end + else + f(context, ...) + end +end + +function SubContext(context, t) + assert(not IsKindOf(t, "PropertyObject")) + t = t or {} + if IsKindOf(context, "PropertyObject") or type(context) ~= "table" then + t[#t + 1] = context + elseif type(context) == "table" then + for _, obj in ipairs(context) do + t[#t + 1] = obj + end + for k, v in pairs(context) do + if rawget(t, k) == nil then + t[k] = v + end + end + end + return Context:new(t) +end + +function ResolveValue(context, key, ...) + if key == nil then return context end + if type(context) == "table" then + if IsKindOfClasses(context, "Context", "PropertyObject") then + return ResolveValue(context:ResolveValue(key), ...) + end + return ResolveValue(rawget(context, key), ...) + end +end + +function ResolveFunc(context, key) + if key == nil then return end + if type(context) == "table" then + if IsKindOf(context, "Context") then + local f = rawget(context, key) + if type(f) == "function" then + return f + end + for _, sub_context in ipairs(context) do + local f, obj = ResolveFunc(sub_context, key) + if f ~= nil then return f, obj end + end + return + end + if IsKindOf(context, "PropertyObject") and context:HasMember(key) then + local f = context[key] + if type(f) == "function" then return f, context end + else + local f = rawget(context, key) + if f == false or type(f) == "function" then return f end + end + end +end + +function ResolvePropObj(context) + if IsKindOf(context, "PropertyObject") then + return context + end + if IsKindOf(context, "Context") then + for _, sub_context in ipairs(context) do + local obj = ResolvePropObj(sub_context) + if obj then return obj end + end + end +end + diff --git a/CommonLua/Classes/ContinuousEffect.lua b/CommonLua/Classes/ContinuousEffect.lua new file mode 100644 index 0000000000000000000000000000000000000000..78c69f88f36683d450901cc1fc1c367e33643338 --- /dev/null +++ b/CommonLua/Classes/ContinuousEffect.lua @@ -0,0 +1,195 @@ +local hintColor = RGB(210, 255, 210) + + +----- ContinuousEffect + +DefineClass.ContinuousEffect = { + __parents = { "Effect" }, + properties = { + { id = "Id", editor = "text", help = "A unique Id allowing you to later stop this effect using StopEffect/StopGlobalEffect; optional", default = "", + no_edit = function(obj) return obj.Id:starts_with("autoid") end, + }, + }, + CreateInstance = false, + EditorExcludeAsNested = true, + container = false, -- restored in ModifiersPreset:PostLoad(), won't be valid if the ContinuousEffect is not stored in a ModifiersPreset +} + +function ContinuousEffect:Execute(object, ...) + self:ValidateObject(object) + assert(IsKindOf(object, "ContinuousEffectContainer")) + object:StartEffect(self, ...) +end + +if FirstLoad then + g_MaxContinuousEffectId = 0 +end + +function ContinuousEffect:OnEditorNew(parent, ged, is_paste) + -- ContinuousEffects embedded in a parent ContinuousEffect are managed by + -- the parent effect and have an auto-generated internal uneditable Id + local obj = ged:GetParentOfKind(parent, "PropertyObject") + if obj and (obj:IsKindOf("ContinuousEffect") or obj:HasMember("ManagesContinuousEffects") and obj.ManagesContinuousEffects) then + g_MaxContinuousEffectId = g_MaxContinuousEffectId + 1 + self.Id = "autoid" .. tostring(g_MaxContinuousEffectId) + elseif self.Id:starts_with("autoid") then + self.Id = "" + elseif ged.app_template:starts_with("Mod") then + local mod_item = IsKindOf(parent, "ModItem") and parent or ged:GetParentOfKind(parent, "ModItem") + local mod_def = mod_item.mod + self.Id = mod_def:GenerateModItemId(self) + end + self.container = obj +end + +function ContinuousEffect:__fromluacode(table) + local obj = Effect.__fromluacode(self, table) + local id = obj.Id + if id:starts_with("autoid") then + g_MaxContinuousEffectId = Max(g_MaxContinuousEffectId, tonumber(id:sub(7, -1))) + end + return obj +end + +function ContinuousEffect:__toluacode(...) + local old = self.container + self.container = nil -- restored in ModifiersPreset:PostLoad() + local ret = Effect.__toluacode(self, ...) + self.container = old + return ret +end + + +----- ContinuousEffectDef + +DefineClass.ContinuousEffectDef = { + __parents = { "EffectDef" }, + group = "ContinuousEffects", + DefParentClassList = { "ContinuousEffect" }, + GedEditor = "ClassDefEditor", +} + +function ContinuousEffectDef:OnEditorNew(parent, ged, is_paste) + if is_paste then return end + + -- remove Execute/__exec metod + for i = #self, 1, -1 do + if IsKindOf(self[i], "ClassMethodDef") and (self[i].name == "Execute" and self[i].name == "__exec" )then + table.remove(self, i) + break + end + end + -- add CreateInstance, Start, Stop, and Id + local idx = #self + 1 + self[idx] = self[idx] or ClassMethodDef:new{ name = "OnStart", params = "obj, context"} + idx = idx + 1 + self[idx] = self[idx] or ClassMethodDef:new{ name = "OnStop", params = "obj, context"} + table.insert(self, 1, ClassConstDef:new{ id = "CreateInstance", name = "CreateInstance" , type = "bool", }) +end + +function ContinuousEffectDef:CheckExecMethod() + local start = self:FindSubitem("Start") + local stop = self:FindSubitem("Stop") + if start and (start.class ~= "ClassMethodDef" or start.code == ClassMethodDef.code) or + stop and (stop.class ~= "ClassMethodDef" or stop.code == ClassMethodDef.code) then + return {[[--== Start & Stop ==-- +Add Start and Stop methods that implement the effect. +]], hintColor, table.find(self, start), table.find(self, stop) } + end +end + +function ContinuousEffectDef:GetError() + local id = self:FindSubitem("CreateInstance") + if not id then + return "The CreateInstance constant is required for ContinuousEffects." + end +end + + +----- ContinuousEffectContainer + +DefineClass.ContinuousEffectContainer = { + __parents = {"InitDone"}, + effects = false, +} + +function ContinuousEffectContainer:Done() + for _, effect in ipairs(self.effects or empty_table) do + effect:OnStop(self) + end + self.effects = false +end + +function ContinuousEffectContainer:StartEffect(effect, context) + self.effects = self.effects or {} + + local id = effect.Id or "" + if id == "" then + id = effect + end + if self.effects[id] then + -- TODO: Add an AllowReplace property and assert whether AllowReplace is true? + self:StopEffect(id) + end + if effect.CreateInstance then + effect = effect:Clone() + end + self.effects[id] = effect + self.effects[#self.effects + 1] = effect + effect:OnStart(self, context) + Msg("OnEffectStarted", self, effect) + assert(effect.CreateInstance or not effect:HasNonPropertyMembers()) -- please set the CreateInstance class constant to 'true' to use dynamic members +end + +function ContinuousEffectContainer:StopEffect(id) + if not self.effects then return end + local effect = self.effects[id] + if not effect then return end + effect:OnStop(self) + table.remove_entry(self.effects, effect) + self.effects[id] = nil + Msg("OnEffectEnded", self, effect) +end + +----- InfopanelMessage Effects + +MapVar("g_AdditionalInfopanelSectionText", {}) +function GetAdditionalInfopanelSectionText(sectionId, obj) + if not sectionId or sectionId=="" then + return "" + end + local section = g_AdditionalInfopanelSectionText[sectionId] + if not section or not next(section) then + return "" + end + local texts = {} + for label, text in pairs(section) do + if label== "__AllSections" or IsKindOf(obj, label) then + texts[#texts + 1] = text + end + end + if not next(texts)then + return "" + end + return table.concat(texts, "\n") +end + +function AddAdditionalInfopanelSectionText(sectionId, label, text, color, object, context) + local style = "Infopanel" + if color == "red" then + style = "InfopanelError" + elseif color == "green" then + style = "InfopanelBonus" + end + local section = g_AdditionalInfopanelSectionText[sectionId] or {} + label = label or "__AllSections" + section[label] = T{410957252932, "", textcolor = "", text = T{text, object, context}} + g_AdditionalInfopanelSectionText[sectionId] = section +end + +function RemoveAdditionalInfopanelSectionText(sectionId, label) + if g_AdditionalInfopanelSectionText[sectionId] then + label = label or "__AllSections" + g_AdditionalInfopanelSectionText[sectionId][label]= nil + end +end diff --git a/CommonLua/Classes/Decal.lua b/CommonLua/Classes/Decal.lua new file mode 100644 index 0000000000000000000000000000000000000000..9d1b1cc6be6ddca1467c5c2a771c59f73728c72b --- /dev/null +++ b/CommonLua/Classes/Decal.lua @@ -0,0 +1,50 @@ +-- base class required for filtering in map editor +DefineClass.Decal = { + __parents = { "CObject" }, + flags = { efSelectable = false, efSunShadow = false, efShadow = false, cofComponentColorizationMaterial = true, }, + + properties = { + { category = "Decal", id = "sort_priority", name = "SortPriority", editor = "number", default = 0, max = 3, min = -4, template = true } + } +} + +function Decal:SetShadowOnly(bSet) + if g_CMTPaused then return end + if bSet then + self:SetHierarchyGameFlags(const.gofSolidShadow) + else + self:ClearHierarchyGameFlags(const.gofSolidShadow) + end +end + +DefineClass.TerrainDecal = +{ + __parents = { "Decal", "EntityClass" }, + flags = { cfDecal = true }, +} + +DefineClass.BakedTerrainDecal = +{ + __parents = { "TerrainDecal", "InvisibleObject" }, + flags = { cfConstructible = false, efBakedTerrainDecal = true }, + max_allowed_radius = hr.TR_DecalSearchRadius * guim, +} + +function BakedTerrainDecal:ConfigureInvisibleObjectHelper(helper) + helper:SetColorModifier(RGBRM(60, 60, 60, 127, 127)) + helper:SetScale(35) + self:SetVisible(true) +end + +DefineClass.BakedTerrainDecalLarge = +{ + __parents = { "BakedTerrainDecal" }, + flags = { efBakedTerrainDecalLarge = true }, +} + +DefineClass.BakedTerrainDecalDetailed = +{ + __parents = { "BakedTerrainDecal" }, + flags = { gofDetailedDecal = true }, + max_allowed_radius = hr.TR_DetailedDecalSearchRadius * guim, +} diff --git a/CommonLua/Classes/DeveloperOptions.lua b/CommonLua/Classes/DeveloperOptions.lua new file mode 100644 index 0000000000000000000000000000000000000000..5ad66b558f6cc6d23d792f0317669605c7372371 --- /dev/null +++ b/CommonLua/Classes/DeveloperOptions.lua @@ -0,0 +1,73 @@ +DefineClass.DeveloperOptions = { + __parents = { "PropertyObject" }, + option_name = "", +} + +function DeveloperOptions:GetProperty(property) + local meta = table.find_value(self.properties, "id", property) + if meta and not prop_eval(meta.dont_save, self, meta) then + return GetDeveloperOption(property, self.class, self.option_name, meta.default) + end + return PropertyObject.GetProperty(self, property) +end + +function DeveloperOptions:SetProperty(property, value) + local meta = table.find_value(self.properties, "id", property) + if meta and not prop_eval(meta.dont_save, self, meta) then + return SetDeveloperOption(property, value, self.class, self.option_name) + end + return PropertyObject.SetProperty(self, property, value) +end + +function GetDeveloperOption(option, storage, substorage, default) + storage = storage or "Developer" + substorage = substorage or "General" + local ds = LocalStorage and LocalStorage[storage] + return ds and ds[substorage] and ds[substorage][option] or default or false +end + +function SetDeveloperOption(option, value, storage, substorage) + if not LocalStorage then + print("no local storage available!") + return + end + storage = storage or "Developer" + substorage = substorage or "General" + value = value or nil + local infos = LocalStorage[storage] or {} + local info = infos[substorage] or {} + info[option] = value + infos[substorage] = info + LocalStorage[storage] = infos + Msg("DeveloperOptionsChanged", storage, substorage, option, value) + DelayedCall(0, SaveLocalStorage) +end + +function GetDeveloperHistory(class, name) + if not LocalStorage then + return {} + end + + local history = LocalStorage.History or {} + LocalStorage.History = history + + history[class] = history[class] or {} + local list = history[class][name] or {} + history[class][name] = list + + return list +end + +function AddDeveloperHistory(class, name, entry, max_size, accept_empty) + max_size = max_size or 20 + if not LocalStorage or not accept_empty and (entry or "") == "" then + return + end + local history = GetDeveloperHistory(class, name) + table.remove_entry(history, entry) + table.insert(history, 1, entry) + while #history > max_size do + table.remove(history) + end + SaveLocalStorageDelayed() +end \ No newline at end of file diff --git a/CommonLua/Classes/DuckingParams.lua b/CommonLua/Classes/DuckingParams.lua new file mode 100644 index 0000000000000000000000000000000000000000..b98187a03995a6ad52ec7a3e116e8e2ba2c62c62 --- /dev/null +++ b/CommonLua/Classes/DuckingParams.lua @@ -0,0 +1,70 @@ +DefineClass.DuckingParam = { + __parents = { "Preset" }, + GlobalMap = "DuckingParams", + + properties = { + { id = "Name", name = "Name", editor = "text", default = "" , help = "The name with which this ducking tier will appear in the sound type editor." }, + { id = "Tier", name = "Tier", editor = "number", default = 0, min = -1, max = 100, help = "Which tiers will be affected by this one - lower tiers affect higher ones." }, + { id = "Strength", name = "Strength", editor = "number", default = 100, min = 0, max = 1000, scale = 1, slider = true, help = "How much will this tier duck the ones below it." }, + { id = "Attack", name = "Attack Duration", editor = "number", default = 100, min = 0, max = 1000, scale = 1, slider = true, help = "How long will this tier take to go from no effect to full ducking in ms." }, + { id = "Release", name = "Release Duration", editor = "number", default = 100, min = 0, max = 1000, scale = 1, slider = true, help = "How long will this tier take to go from full ducking to no effect in ms." }, + { id = "Hold", name = "Hold Duration", editor = "number", default = 100, min = 0, max = 5000, scale = 1, slider = true, help = "How long will this tier take, before starting to decay the ducking strength, after the sound strength decreases." }, + { id = "Envelope", name = "Use side chain", editor = "bool", default = true, help = "Should the sounds in this preset modify the other sounds based on the current strength of their sound, or apply a constant static effect." }, + }, + + OnEditorSetProperty = function(properties) + ReloadDucking() + end, + + Apply = function(self) + ReloadDucking() + end, + + EditorMenubarName = "Ducking Editor", + EditorMenubar = "Editors.Audio", + EditorIcon = "CommonAssets/UI/Icons/church.png", +} + +function ReloadDucking() + local names = {} + local tiers = {} + local strengths = {} + local attacks = {} + local releases = {} + local hold = {} + local envelopes = {} + local i = 1 + for _, p in pairs(DuckingParams) do + names[i] = p.id + tiers[i] = p.Tier + strengths[i] = p.Strength + attacks[i] = p.Attack + releases[i] = p.Release + hold[i] = p.Hold + envelopes[i] = p.Envelope and 1 or 0 + i = i + 1 + end + LoadDuckingParams(names, tiers, strengths, attacks, releases, hold, envelopes) + ReloadSoundTypes() +end + +function ChangeDuckingPreset(id, tier, str, attack, release, hold) + if tier then + DuckingParams[id].Tier = tier + end + if str then + DuckingParams[id].Strength = str + end + if attack then + DuckingParams[id].Attack = attack + end + if release then + DuckingParams[id].Release = release + end + if hold then + DuckingParams[id].Hold = hold + end + ReloadDucking() +end + +OnMsg.DataLoaded = ReloadDucking \ No newline at end of file diff --git a/CommonLua/Classes/DumbAI.lua b/CommonLua/Classes/DumbAI.lua new file mode 100644 index 0000000000000000000000000000000000000000..57a88ac3179c242816f285d312777fa7ee1651c8 --- /dev/null +++ b/CommonLua/Classes/DumbAI.lua @@ -0,0 +1,392 @@ +local ai_debug = Platform.developer and Platform.pc +local bias_base = 1000000 -- fixed point value equivalent to 1 or 100% + +DefineClass.DumbAIPlayer = { + __parents = { "InitDone" }, + + actions = false, + action_log = false, + log_size = 10, + running_actions = false, + biases = false, + resources = false, + display_name = false, + + absolute_actions = 10, + absolute_threshold = 10000, + relative_threshold = 50, -- percent of the highest eval + think_interval = 1000, + + seed = 0, + think_thread = false, + ai_start = 0, + + GedEditor = "DumbAIDebug", + + -- production + production_interval = 60000, + next_production = 0, + production_rules = false, + next_production_times = false, +} + +function DumbAIPlayer:Init() + self.actions = {} + self.action_log = {} + self.running_actions = {} + self.biases = {} + self.resources = {} + for _, def in ipairs(Presets.AIResource.Default) do + self.resources[def.id] = 0 + end + self.ai_start = GameTime() + self.production_rules = {} + self.next_production = GameTime() + self.next_production_times = setmetatable({}, weak_keys_meta) +end + +function DumbAIPlayer:Done() + DeleteThread(self.think_thread) + GedObjectDeleted(self) +end + +function DumbAIPlayer:AddAIDef(ai_def) + if not ai_def then return end + local actions = self.actions + for _, action in ipairs(ai_def) do + actions[#actions + 1] = action + end + local resources = self.resources + for _, res in ipairs(ai_def.initial_resources) do + local resource = res.resource + resources[resource] = resources[resource] + res:Amount() + end + local production_rules = self.production_rules + for _, rule in ipairs(ai_def.production_rules or empty_table) do + production_rules[#production_rules + 1] = rule + end + local label = "AIDef " .. ai_def.id + for _, bias in ipairs(ai_def.biases) do + self:AddBias(bias.tag, bias.bias, nil, label) + end +end + +function DumbAIPlayer:RemoveAIDef(ai_def) + if not ai_def then return end + local actions = self.actions + for _, action in ipairs(ai_def) do + table.remove_entry(actions, action) + end + local production_rules = self.production_rules + for _, rule in ipairs(ai_def.production_rules or empty_table) do + table.remove_entry(production_rules, rule) + end + local label = "AIDef " .. ai_def.id + for _, bias in ipairs(ai_def.biases) do + self:RemoveBias(bias.tag, nil, label) + end +end + + +-- AI bias + +local function recalc_bias(tag_biases) + local acc = bias_base + for _, bias in ipairs(tag_biases) do + acc = MulDivRound(acc, bias.change, bias_base) + end + tag_biases.acc = acc +end + +function DumbAIPlayer:AddBias(tag, change, source, label) + local tag_biases = self.biases[tag] + if not tag_biases then + tag_biases = { acc = bias_base } + self.biases[tag] = tag_biases + end + if label then + local idx = table.find(tag_biases, "label", label) + if idx then + table.remove(tag_biases, idx) + end + end + local bias = { + change = change, + label = label or nil, + source = ai_debug and source or nil, + } + tag_biases[#tag_biases + 1] = bias + recalc_bias(tag_biases) + return bias +end + +function DumbAIPlayer:RemoveBias(tag, bias, label) + local tag_biases = self.biases[tag] + if tag_biases then + table.remove_entry(tag_biases, bias) + local idx = table.find(tag_biases, "label", label) + if idx then + table.remove(tag_biases, idx) + end + recalc_bias(tag_biases) + end +end + +function DumbAIPlayer:BiasValue(value, tags) + local biases = self.biases + for _, tag in ipairs(tags or empty_table) do + local tag_biases = biases[tag] + if tag_biases then + value = MulDivRound(value, tag_biases.acc, bias_base) + end + end + return value +end + +function DumbAIPlayer:BiasValueByTag(value, tag) + local tag_biases = self.biases[tag] + if tag_biases then + value = MulDivRound(value, tag_biases.acc, bias_base) + end + return value +end + +-- AI main loop + +function DumbAIPlayer:AIUpdate(seed) + local resources = self.resources + for _, rule in ipairs(self.production_rules) do + local time = self.next_production_times[rule] or 0 + if GameTime() >= time then + self.next_production_times[rule] = time + rule.production_interval + procall(rule.Run, rule, resources, self) + end + end +end + +function DumbAIPlayer:LogAction(action) + table.insert(self.action_log, {action = action, time = GameTime()}) + while #self.action_log > self.log_size do + table.remove(self.action_log, 1) + end +end + +function DumbAIPlayer:GetDisplayName() + return self.display_name or "" +end + +function DumbAIPlayer:AIStartAction(action) + self.running_actions[action] = (self.running_actions[action] or 0) + 1 + local resources = self.resources + for _, res in ipairs(action.required_resources) do + local resource = res.resource + resources[resource] = resources[resource] - res.amount + end + CreateGameTimeThread(function(self, action, ai_debug) + sprocall(action.Run, action, self) + Sleep(self:BiasValueByTag(action.delay, "action_delay")) + if (action.log_entry or "") ~= "" then + self:LogAction(action) + end + local resources = self.resources + for _, res in ipairs(action.resulting_resources) do + local resource = res.resource + resources[resource] = resources[resource] + res:Amount() + end + sprocall(action.OnEnd, action, self) + assert((self.running_actions[action] or 0) > 0) + self.running_actions[action] = (self.running_actions[action] or 0) - 1 + if ai_debug then + ObjModified(self) + end + end, self, action, ai_debug) +end + +function DumbAIPlayer:AILimitActions(actions) + local active_actions = {} + local resources = self.resources + local running_actions = self.running_actions + for _, action in ipairs(actions) do + if (running_actions[action] or 0) < action.max_running then + for _, res in ipairs(action.required_resources) do + assert(res:Amount() == res.amount, "randomized amounts are not supported for required_resources") + if resources[res.resource] < res.amount then + action = nil + break + end + end + if action and action:IsAllowed(self) then + local eval = action:Eval(self) or action.base_eval + action.eval = self:BiasValue(eval, action.tags) + active_actions[#active_actions + 1] = action + end + end + end + table.sortby_field_descending(active_actions, "eval") + -- limit by number of actions + local count = self:BiasValueByTag(self.absolute_actions, "ai_absolute_actions") + count = Min(count, #active_actions) + if count < 1 then + return active_actions, 0 + end + -- limit by evaluation + local threshold = self:BiasValueByTag(self.absolute_threshold, "ai_absolute_threshold") + local rel_threshold = self:BiasValueByTag(self.relative_threshold, "ai_relative_threshold") + threshold = Max(threshold, MulDivRound(active_actions[1].eval, rel_threshold, 100)) + + while count > 0 + and active_actions[count].eval < threshold do + count = count - 1 + end + return active_actions, count +end + +function DumbAIPlayer:AIThink(seed) + seed = seed or AsyncRand() + self:AIUpdate(seed) + local actions, count = self:AILimitActions(self.actions) + local action = actions[BraidRandom(seed, count) + 1] + if action then + self:AIStartAction(action) + end + if ai_debug then + if #self > 40 then -- remove entries beyond 40 + for i = 1, #self do + self[i] = self[i + 1] + end + end + if #self > 0 and not self[#self][3] then + self[#self] = nil -- replace last entry if there was no action selected + end + self[#self + 1] = { + GameTime() - self.ai_start, + seed, + action or false, + actions, + count, + table.copy(self.resources), + action and action.eval, + } + ObjModified(self) + end + return action +end + +function DumbAIPlayer:CreateAIThinkThread() + DeleteThread(self.think_thread) + self.think_thread = CreateGameTimeThread(function(self) + local rand, think_seed = BraidRandom(self.seed) + while true do + Sleep(self:BiasValueByTag(self.think_interval, "ai_think_interval")) + rand, think_seed = BraidRandom(think_seed) + self:AIThink(rand) + end + end, self) +end + +-- AI Debug + +if ai_debug then + +local function format_bias(n) + return string.format("%d.%02d", n / bias_base, (n % bias_base) * 100 / bias_base) +end + +local function DumbAIDebugActions(texts, actions, count, eval) + texts[#texts + 1] = "" + for i, action in ipairs(actions) do + if i == count + 1 then + texts[#texts + 1] = "" + texts[#texts + 1] = "" + end + if eval then + texts[#texts + 1] = string.format("%s%s", action.id, format_bias(action.eval)) + else + texts[#texts + 1] = string.format("%s", action.id) + end + end +end + +local function DumbAIDebugResources(texts, resources) + texts[#texts + 1] = "" + for _, def in ipairs(Presets.AIResource.Default) do + local resource = def.id + texts[#texts + 1] = string.format("%s%d", resource, resources[resource]) + end +end + +function GedDumbAIDebugState(ai_player) + local texts = {} + DumbAIDebugResources(texts, ai_player.resources) + texts[#texts + 1] = "" + texts[#texts + 1] = "" + for _, def in ipairs(Presets.AITag.Default) do + local tag = def.id + local tag_biases = ai_player.biases[tag] + if tag_biases then + texts[#texts + 1] = string.format("%s%d%%", tag, MulDivRound(tag_biases.acc, 100, bias_base)) + end + end + + texts[#texts + 1] = "" + local actions, count = ai_player:AILimitActions(ai_player.actions) + DumbAIDebugActions(texts, actions, count, true) + return table.concat(texts, "\n") +end + +local function time(time) + time = tonumber(time) + if time then + local sign = time < 0 and "-" or "" + local sec = abs(time) / 1000 + local min = sec / 60 + local hours = min / 60 + local days = hours / 24 + if days > 0 then + return string.format("%s%dd%02dh%02dm%02ds", sign, days, hours % 24, min % 60, sec % 60) + else + return string.format("%s%dh%02dm%02ds", sign, hours, min % 60, sec % 60) + end + end +end + +function GedDumbAIDebugLog(ai_player) + local list = {} + for i, entry in ipairs(ai_player) do + local t, seed, action, actions, count, resources, eval = table.unpack(entry) + list[i] = string.format("%s %s %s", time(t) or "???", action and action.id or "---", action and format_bias(eval) or "") + end + return list +end + +function GedDumbAIDebugLogEntry(entry) + local texts = {} + local time, seed, action, actions, count, resources = table.unpack(entry) + DumbAIDebugResources(texts, resources) + texts[#texts + 1] = "" + DumbAIDebugActions(texts, actions, count) + return table.concat(texts, "\n") +end + +-- Test + +__TestAI = false + +function TestAI() + if __TestAI then __TestAI:delete() end + __TestAI = DumbAIPlayer:new{ + think_interval = const.HourDuration, + production_interval = const.DayDuration, + } + __TestAI:AddAIDef(Presets.DumbAIDef.Default.default) + __TestAI:AddAIDef(Presets.DumbAIDef.MissionSponsors.IMM) + __TestAI:CreateAIThinkThread() + __TestAI:OpenEditor() + Resume() +end + +end + +function DumbAIPlayer:GetCurrentStanding() + return self.resources.standing +end \ No newline at end of file diff --git a/CommonLua/Classes/EditorBase.lua b/CommonLua/Classes/EditorBase.lua new file mode 100644 index 0000000000000000000000000000000000000000..f4525a897731a7d1746be9b99d9b72910ca3719c --- /dev/null +++ b/CommonLua/Classes/EditorBase.lua @@ -0,0 +1,543 @@ +DefineClass.EditorObject = { + __parents = { "CObject" }, + + EditorEnter = empty_func, + EditorExit = empty_func, +} + +function EditorObject:PostLoad() + if IsEditorActive() then + self:EditorEnter() + end +end + +RecursiveCallMethods.EditorEnter = "procall" +RecursiveCallMethods.EditorExit = "procall_parents_last" + +DefineClass.EditorCallbackObject = { + __parents = { "CObject" }, + flags = { cfEditorCallback = true }, + + -- all callbacks receive no parameters, except EditorCallbackClone, which receives the original object + EditorCallbackPlace = empty_func, + EditorCallbackPlaceCursor = empty_func, + EditorCallbackDelete = empty_func, + EditorCallbackRotate = empty_func, + EditorCallbackMove = empty_func, + EditorCallbackScale = empty_func, + EditorCallbackClone = empty_func, -- function(orig) end, + EditorCallbackGenerate = empty_func, -- function(generator, object_source, placed_objects, prefab_list) end, +} + +AutoResolveMethods.EditorCallbackPlace = true +AutoResolveMethods.EditorCallbackPlaceCursor = true +AutoResolveMethods.EditorCallbackDelete = true +AutoResolveMethods.EditorCallbackRotate = true +AutoResolveMethods.EditorCallbackMove = true +AutoResolveMethods.EditorCallbackScale = true +AutoResolveMethods.EditorCallbackClone = true +AutoResolveMethods.EditorCallbackGenerate = true + +function OnMsg.ChangeMapDone() + --CObjects that are EditorVisibleObject will get saved as efVisible == true and pop up on first map load + if GetMap() == "" then return end + if not IsEditorActive() then + MapForEach("map", "EditorVisibleObject", const.efVisible, function(o) + o:ClearEnumFlags(const.efVisible) + end) + end +end + +DefineClass.EditorVisibleObject = { + __parents = { "EditorObject" }, + flags = { efVisible = false }, + properties = { + { id = "OnCollisionWithCamera" }, + }, +} + +function EditorVisibleObject:EditorEnter() + self:SetEnumFlags(const.efVisible) +end + +function EditorVisibleObject:EditorExit() + self:ClearEnumFlags(const.efVisible) +end + +---- + +DefineClass.EditorColorObject = { + __parents = { "EditorObject" }, + editor_color = false, + orig_color = false, +} + +function EditorColorObject:EditorGetColor() + return self.editor_color +end + +function EditorColorObject:EditorEnter() + local editor_color = self:EditorGetColor() + if editor_color then + self.orig_color = self:GetColorModifier() + self:SetColorModifier(editor_color) + end +end + +function EditorColorObject:EditorExit() + if self.orig_color then + self:SetColorModifier(self.orig_color) + self.orig_color = false + end +end + +function EditorColorObject:GetColorModifier() + if self.orig_color then + return self.orig_color + end + return EditorObject.GetColorModifier(self) +end + +---- + +DefineClass.EditorEntityObject = { + __parents = { "EditorCallbackObject", "EditorColorObject" }, + entity = "", + editor_entity = "", + orig_scale = false, + editor_scale = false, +} + +function EditorEntityObject:EditorCanPlace() + return true +end + +function EditorEntityObject:SetEditorEntity(set) + if (self.editor_entity or "") ~= "" then + self:ChangeEntity(set and self.editor_entity or g_Classes[self.class]:GetEntity()) + end + if self.editor_scale then + if set then + self.orig_scale = self:GetScale() + self:SetScale(self.editor_scale) + elseif self.orig_scale then + self:SetScale(self.orig_scale) + self.orig_scale = false + end + end +end +function EditorEntityObject:GetScale() + if self.orig_scale then + return self.orig_scale + end + return EditorObject.GetScale(self) +end + +function EditorEntityObject:EditorEnter() + self:SetEditorEntity(true) +end +function EditorEntityObject:EditorExit() + self:SetEditorEntity(false) +end +function OnMsg.EditorCallback(id, objects, ...) + if id == "EditorCallbackPlace" or id == "EditorCallbackPlaceCursor" then + for i = 1, #objects do + local obj = objects[i] + if obj:IsKindOf("EditorEntityObject") then + obj:SetEditorEntity(true) + end + end + end +end + +---- + +DefineClass.EditorTextObject = { + __parents = { "EditorObject", "ComponentAttach" }, + editor_text_spot = "Label", + editor_text_color = RGBA(255,255,255,255), + editor_text_offset = point(0,0,3*guim), + editor_text_style = false, + editor_text_depth_test = true, + editor_text_ctarget = "SetColor", + editor_text_obj = false, + editor_text_member = "class", + editor_text_class = "Text", +} + +function EditorTextObject:EditorEnter() + self:EditorTextUpdate(true) +end + +function EditorTextObject:EditorExit() + DoneObject(self.editor_text_obj) + self.editor_text_obj = nil +end + +AutoResolveMethods.EditorGetText = ".." + +function EditorTextObject:EditorGetText() + return self[self.editor_text_member] +end + +function EditorTextObject:EditorGetTextColor() + return self.editor_text_color +end + +function EditorTextObject:EditorGetTextStyle() + return self.editor_text_style +end + +function EditorTextObject:Clone(class, ...) + local clone = EditorObject.Clone(self, class or self.class, ...) + if IsKindOf(clone, "EditorTextObject") then + clone:EditorTextUpdate(true) + end + return clone +end + +function EditorTextObject:EditorTextUpdate(create) + if not IsValid(self) then + return + end + local obj = self.editor_text_obj + if not IsValid(obj) and not create then return end + local is_hidden = GetDeveloperOption("Hidden", "EditorHiddenTextOptions", self.class) + local text = not is_hidden and self:EditorGetText() + if not text then + DoneObject(obj) + return + end + if not IsValid(obj) then + obj = PlaceObject(self.editor_text_class, {text_style = self:EditorGetTextStyle()}) + obj:SetDepthTest(self.editor_text_depth_test) + local spot = self.editor_text_spot + if spot and self:HasSpot(spot) then + self:Attach(obj, self:GetSpotBeginIndex(spot)) + else + self:Attach(obj) + end + local offset = self.editor_text_offset + if offset then + obj:SetAttachOffset(offset) + end + self.editor_text_obj = obj + end + obj:SetText(text) + local color = self:EditorGetTextColor() + if color then + obj[self.editor_text_ctarget](obj, color) + end +end + +function EditorTextObject:OnEditorSetProperty(prop_id) + if prop_id == self.editor_text_member then + self:EditorTextUpdate(true) + end + return EditorObject.OnEditorSetProperty(self, prop_id) +end + +DefineClass.NoteMarker = { + __parents = { "Object", "EditorVisibleObject", "EditorTextObject" }, + properties = { + { id = "MantisID", editor = "number", default = 0, important = true , buttons = {{name = "OpenMantis", func = "OpenMantisFromMarker"}}}, + { id = "Text", editor = "text", lines = 5, default = "", important = true }, + { id = "TextColor", editor = "color", default = RGB(255,255,255), important = true }, + { id = "TextStyle", editor = "text", default = "InfoText", important = true }, + -- disabled properties + { id = "Angle", editor = false}, + { id = "Axis", editor = false}, + { id = "Opacity", editor = false}, + { id = "StateCategory", editor = false}, + { id = "StateText", editor = false}, + { id = "Groups", editor = false}, + { id = "Mirrored", editor = false}, + { id = "ColorModifier", editor = false}, + { id = "Occludes", editor = false}, + { id = "Walkable", editor = false}, + { id = "ApplyToGrids", editor = false}, + { id = "Collision", editor = false}, + { id = "OnCollisionWithCamera", editor = false}, + { id = "CollectionIndex", editor = false}, + { id = "CollectionName", editor = false}, + }, + editor_text_offset = point(0,0,4*guim), + editor_text_member = "Text", +} + +for i = 1, const.MaxColorizationMaterials do + table.iappend( NoteMarker.properties, { + { id = string.format("Color%d", i), editor = false }, + { id = string.format("Roughness%d", i), editor = false }, + { id = string.format("Metallic%d", i), editor = false }, + }) +end + +function NoteMarker:EditorGetTextColor() + return self.TextColor +end + +function NoteMarker:EditorGetTextStyle() + return self.TextStyle +end + +function OpenMantisFromMarker(parentEditor, object, prop_id, ...) + local mantisID = object:GetProperty(prop_id) + if mantisID and mantisID ~= "" and mantisID ~= 0 then + local url = "http://mantis.haemimontgames.com/view.php?id="..mantisID + OpenUrl(url, "force external browser") + end +end + +if not Platform.editor then + + function OnMsg.ClassesPreprocess(classdefs) + for name, class in pairs(classdefs) do + class.EditorCallbackPlace = nil + class.EditorCallbackPlaceCursor = nil + class.EditorCallbackDelete = nil + class.EditorCallbackRotate = nil + class.EditorCallbackMove = nil + class.EditorCallbackScale = nil + class.EditorCallbackClone = nil + class.EditorCallbackGenerate = nil + + class.EditorEnter = nil + class.EditorExit = nil + + class.EditorGetText = nil + class.EditorGetTextColor = nil + class.EditorGetTextStyle = nil + class.EditorGetTextFont = nil + + class.editor_text_obj = nil + class.editor_text_spot = nil + class.editor_text_color = nil + class.editor_text_offset = nil + class.editor_text_style = nil + end + end + + function OnMsg.Autorun() + MsgClear("EditorCallback") + MsgClear("GameEnterEditor") + MsgClear("GameExitEditor") + end + +end + +---- + +local update_thread +function UpdateEditorTexts() + if not IsEditorActive() or IsValidThread(update_thread) then + return + end + update_thread = CreateRealTimeThread(function() + MapForEach("map", "EditorTextObject", function(obj) + obj:EditorTextUpdate(true) + end) + end) +end + + +function OnMsg.DeveloperOptionsChanged(storage, name, id, value) + if storage == "EditorHiddenTextOptions" then + UpdateEditorTexts() + end +end + +---- + +DefineClass.ForcedTemplate = +{ + __parents = { "EditorObject" }, + template_class = "Template", +} + +function GetTemplateBase(class_name) + local class = g_Classes[class_name] + return class and class.template_class or "" +end + +MapVar("ForcedTemplateObjs", {}) + +function ForcedTemplate:EditorEnter() + if self:GetGameFlags(const.gofPermanent) == 0 and self:GetEnumFlags(const.efVisible) ~= 0 then + ForcedTemplateObjs[self] = true + self:ClearEnumFlags(const.efVisible) + end +end + +function ForcedTemplate:EditorExit() + if ForcedTemplateObjs[self] then + self:SetEnumFlags(const.efVisible) + end +end + + +---- EditorSelectedObject -------------------------------------- + +MapVar("l_editor_selection", empty_table) + +DefineClass.EditorSelectedObject = { + __parents = { "CObject" }, +} + +function EditorSelectedObject:EditorSelect(selected) +end + +function EditorSelectedObject:EditorIsSelected(check_helpers) + if l_editor_selection[self] then + return true + end + if check_helpers then + local helpers = PropertyHelpers and PropertyHelpers[self] or empty_table + for prop_id, helper in pairs(helpers) do + if editor.IsSelected(helper) then + return true + end + end + end + return false +end + +function UpdateEditorSelectedObjects(selection) + local new_selection = setmetatable({}, weak_keys_meta) + local old_selection = l_editor_selection + l_editor_selection = new_selection + for i=1,#(selection or "") do + local obj = selection[i] + if IsKindOf(obj, "EditorSelectedObject") then + new_selection[obj] = true + if not old_selection[obj] then + obj:EditorSelect(true) + end + end + end + for obj in pairs(old_selection or empty_table) do + if not new_selection[obj] then + obj:EditorSelect(false) + end + end +end + +function OnMsg.EditorSelectionChanged(selection) + UpdateEditorSelectedObjects(selection) +end + +function OnMsg.GameEnterEditor() + UpdateEditorSelectedObjects(editor.GetSel()) +end + +function OnMsg.GameExitEditor() + UpdateEditorSelectedObjects() +end + + +---- EditorSubVariantObject -------------------------------------- + +DefineClass.EditorSubVariantObject = { + __parents = { "PropertyObject" }, + properties = { + { name = "Subvariant", id = "subvariant", editor = "number", default = -1, + buttons = { + { name = "Next", func = "CycleEntityBtn" }, + }, + }, + }, +} + +function EditorSubVariantObject:CycleEntityBtn() + self:CycleEntity() +end + +function EditorSubVariantObject:Setsubvariant(val) + self.subvariant = val +end + +function EditorSubVariantObject:PreviousEntity() + self:CycleEntity(-1) +end + +function EditorSubVariantObject:NextEntity() + self:CycleEntity(-1) +end + +local maxEnt = 20 +function EditorSubVariantObject:CycleEntity(delta) + delta = delta or 1 + local curE = self:GetEntity() + local nxt = self.subvariant == -1 and (tonumber(string.match(curE, "%d+$")) or 1) or self.subvariant + nxt = nxt + delta + + local nxtE = string.gsub(curE, "%d+$", (nxt < 10 and "0" or "") .. tostring(nxt)) + if not IsValidEntity(nxtE) then + if delta > 0 then + --going up, reset to first + nxt = 1 + nxtE = string.gsub(curE, "%d+$", (nxt < 10 and "0" or "") .. tostring(nxt)) + else + --going down, reset to last, whichever that is.. + nxt = maxEnt + 1 + while not IsValidEntity(nxtE) and nxt > 0 do + nxt = nxt - 1 + nxtE = string.gsub(curE, "%d+$", (nxt < 10 and "0" or "") .. tostring(nxt)) + end + end + + if not IsValidEntity(nxtE) then + nxtE = curE + nxt = -1 + end + end + + if self.subvariant ~= nxt then + self.subvariant = nxt + self:ChangeEntity(nxtE) + ObjModified(self) + return true + end + return false +end + +function EditorSubVariantObject:ResetSubvariant() + self.subvariant = -1 +end + +function EditorSubVariantObject.OnShortcut(delta) + local sel = editor.GetSel() + if sel and #sel > 0 then + XEditorUndo:BeginOp{ objects = sel } + for i = 1, #sel do + if IsKindOf(sel[i], "EditorSubVariantObject") then + sel[i]:CycleEntity(delta) + end + end + XEditorUndo:EndOp(sel) + end +end + +function CycleObjSubvariant(obj, dir) + if IsKindOf(obj, "EditorSubVariantObject") then + obj:CycleEntity(dir) + else + local class = obj.class + local num = tonumber(class:sub(-2, -1)) + if num then + local list = {} + for i = 0, 99 do + local class_name = class:sub(1, -3) .. (i <= 9 and "0" or "") .. tostring(i) + if g_Classes[class_name] and IsValidEntity(g_Classes[class_name]:GetEntity()) then + list[#list + 1] = class_name + end + end + + local idx = table.find(list, class) + dir + if idx == 0 then idx = #list elseif idx > #list then idx = 1 end + obj = editor.ReplaceObject(obj, list[idx]) + end + end + + return obj +end \ No newline at end of file diff --git a/CommonLua/Classes/EntityClass.lua b/CommonLua/Classes/EntityClass.lua new file mode 100644 index 0000000000000000000000000000000000000000..300b4e2b4af846911b513078c318733394138665 --- /dev/null +++ b/CommonLua/Classes/EntityClass.lua @@ -0,0 +1,196 @@ +if Platform.ged then + DefineClass("EntityClass", "CObject") + return +end + +DefineClass.EntityClass = { + flags = { efCameraRepulse = true, efSelectable = false }, + __hierarchy_cache = true, + __parents = { "CObject" }, +} + +local detail_flags = { + ["Default"] = { gofDetailClass0 = false, gofDetailClass1 = false }, + ["Essential"] = { gofDetailClass0 = false, gofDetailClass1 = true }, + ["Optional"] = { gofDetailClass0 = true, gofDetailClass1 = false }, + ["Eye Candy"] = { gofDetailClass0 = true, gofDetailClass1 = true }, +} + +function CopyDetailFlagsFromEntity(class, entity, name) + local detail_class = entity and entity.DetailClass or "Essential" + local flags = detail_flags[detail_class] + if class.flags then + for k, v in pairs(flags or empty_table) do + class.flags[k] = v + end + else + class.flags = flags + end +end + +function OnMsg.ClassesGenerate(classdefs) + local all_entities = GetAllEntities() + local EntityData = EntityData + local CopyDetailFlagsFromEntity = CopyDetailFlagsFromEntity + local UseCameraCollision = not config.NoCameraCollision + local UseColliders = const.maxCollidersPerObject > 0 + + for name in pairs(EntityData) do + all_entities[name] = true + end + + for name, class in pairs(classdefs) do + local entity = class.entity or name + local cls_data = entity and (EntityData[entity] or empty_table).entity + for id, value in pairs(cls_data) do + if not rawget(class, id) and id ~= "class_parent" then + class[id] = value + end + end + local flags = class.flags + if not flags or flags.gofDetailClass0 == nil and flags.gofDetailClass1 == nil then + CopyDetailFlagsFromEntity(class, cls_data, name) + end + + all_entities[name] = nil -- exclude used entities + if rawget(class, "prevent_entity_class_creation") then + all_entities[entity or false] = nil + end + end + all_entities["StatesSpots"] = nil + all_entities["error"] = nil + all_entities[""] = nil + + local __parent_tables = { } + + for name in pairs(all_entities) do + local entity_data = EntityData[name] + local cls_data = entity_data and entity_data.entity + local class = cls_data and table.copy(cls_data) or { } + local parent = class.class_parent or "EntityClass" + if parent ~= "NoClass" then + class.class_parent = nil + + local __parents = __parent_tables[parent] + if not __parents then + if parent ~= "EntityClass" and parent:find(",") then + __parents = string.split(parent, "[^%w]+") + table.remove_value(__parents, "") + else + __parents = { parent } + end + __parent_tables[parent] = __parents + end + class.__parents = __parents + + CopyDetailFlagsFromEntity(class, cls_data, name) + + if UseCameraCollision then + local entity_occ = cls_data and cls_data.on_collision_with_camera or "no action" + local occ_flags = OCCtoFlags[entity_occ] + if occ_flags then + if class.flags then + for k, v in pairs(occ_flags) do + class.flags[k] = v + end + else + class.flags = occ_flags + end + end + end + + if UseColliders and IsValidEntity(name) and not HasColliders(name) then + class.flags = class.flags and table.copy(class.flags) or {} + class.flags.cofComponentCollider = false + end + class.entity = false + class.__generated_by_class = "EntityClass" + classdefs[name] = class + end + end + + Msg("BeforeClearEntityData") -- game specific hook to give the game the chance to do something with the entities + MsgClear("BeforeClearEntityData") + + CreateRealTimeThread(ReloadFadeCategories, true) +end + +function ReloadFadeCategories(apply_to_objects) + if const.UseDistanceFading and rawget(_G, "EntityData") then + for name,entity_data in pairs(EntityData) do + local fade = FadeCategories[entity_data.entity and entity_data.entity.fade_category or false] + SetEntityFadeDistances(name, fade and fade.min or 0, fade and fade.max or 0) + end + if apply_to_objects and __cobjectToCObject then + MapForEach("map", function(x) + x:GenerateFadeDistances() + end) + end + end +end + +AnimatedTextureObjectTypes = { + { value = pbo.Normal, text = "Normal" }, + { value = pbo.PingPong, text = "Ping-Pong" }, +} +DefineClass.AnimatedTextureObject = +{ + __parents = { "ComponentCustomData", "Object" }, + + properties = { + { category = "Animated Texture", id = "anim_type", name = "Pick frame by", editor = "choice", items = function() return AnimatedTextureObjectTypes end, template = true }, + { category = "Animated Texture", id = "anim_speed", name = "Speed Multiplier", editor = "number", max = 4095, min = 0, template = true }, + { category = "Animated Texture", id = "sequence_time_remap", name = "Sequence time", editor = "curve4", max = 63, scale = 63, max_x = 15, scale_x = 15, template = true }, + }, + + anim_type = pbo.Normal, + anim_speed = 1000, + sequence_time_remap = MakeLine(0, 63, 15), +} + +function AnimatedTextureObject:Setanim_type(value) + self:SetFrameAnimationPlaybackOrder(value) +end + +function AnimatedTextureObject:Getanim_type() + return self:GetFrameAnimationPlaybackOrder() +end + +function AnimatedTextureObject:Setanim_speed(value) + self:SetFrameAnimationSpeed(value) +end + +function AnimatedTextureObject:Getanim_speed() + return self:GetFrameAnimationSpeed() +end + +function AnimatedTextureObject:Setsequence_time_remap(curve) + local value = (curve[1]:y()) | + (curve[2]:y() << 6) | + (curve[3]:y() << 12) | + (curve[4]:y() << 18) | + (curve[2]:x() << 24) | + (curve[3]:x() << 28) + self:SetFrameAnimationPackedCurve(value) +end + +function AnimatedTextureObject:Getsequence_time_remap() + local value = self:GetFrameAnimationPackedCurve() + local curve = { + point(0, value & 0x3F), + point((value >> 24) & 0xF, (value >> 6) & 0x3F), + point((value >> 28) & 0xF, (value >> 12) & 0x3F), + point(15, (value >> 18) & 0x3F), + } + for i = 1, 4 do + curve[i] = point(curve[i]:x(), curve[i]:y(), curve[i]:y()) + end + return curve +end + +function AnimatedTextureObject:Init() + self:InitTextureAnimation() + self:Setanim_type(self.anim_type) + self:Setanim_speed(self.anim_speed) + self:Setsequence_time_remap(self.sequence_time_remap) +end \ No newline at end of file diff --git a/CommonLua/Classes/FXPreset.lua b/CommonLua/Classes/FXPreset.lua new file mode 100644 index 0000000000000000000000000000000000000000..625cf77034e3274ea24ac6a6f30a2cc977cc862c --- /dev/null +++ b/CommonLua/Classes/FXPreset.lua @@ -0,0 +1,628 @@ +local function ActionFXTypesCombo() + local list_back = { "Inherit Action", "Inherit Moment", "Inherit Actor", "FX Remove" } + local added = { [""] = true, ["any"] = true } + for i = 1, #list_back do + added[list_back[i]] = true + end + local list = {} + ClassDescendantsList("ActionFX", function(name, class) + if not added[class.fx_type] then + list[#list+1] = class.fx_type + added[class.fx_type] = true + end + end) + table.sort(list, CmpLower) + table.insert(list, 1, "any") + for i = 1, #list_back do + list[#list+1] = list_back[i] + end + return list +end + +local fx_class_list = false +function OnMsg.ClassesBuilt() + fx_class_list = {} + ClassDescendantsList("ActionFX", function(name, class) + if class.fx_type ~= "" and not class:IsKindOf("ModItem") then + fx_class_list[#fx_class_list+1] = class + end + end) + ClassDescendantsList("ActionFXInherit", function(name, class) + if class.fx_type ~= "" then + fx_class_list[#fx_class_list+1] = class + end + end) + table.sort(fx_class_list, function(c1, c2) return c1.fx_type < c2.fx_type end) +end + +local function GetInheritActionFX(action) + local fxlist = FXLists.ActionFXInherit_Action or {} + if action == "any" then + return table.copy(fxlist) + end + local rules = (FXInheritRules_Actions or RebuildFXInheritActionRules())[action] + if not rules then return end + local inherit = { [action] = true} + for i = 1, #rules do + inherit[rules[i]] = true + end + local list = {} + for i = 1, #fxlist do + local fx = fxlist[i] + if inherit[fx.Action] then + list[#list+1] = fx + end + end + return list +end + +local function GetInheritMomentFX(moment) + local fxlist = FXLists.ActionFXInherit_Moment or {} + if moment == "any" then + return table.copy(fxlist) + end + local rules = (FXInheritRules_Moments or RebuildFXInheritMomentRules())[moment] + if not rules then return end + local inherit = { [moment] = true} + for i = 1, #rules do + inherit[rules[i]] = true + end + local list = {} + for i = 1, #fxlist do + local fx = fxlist[i] + if inherit[fx.Moment] then + list[#list+1] = fx + end + end + return list +end + +local function GetInheritActorFX(actor) + local fxlist = FXLists.ActionFXInherit_Actor or {} + if actor == "any" then + return table.copy(fxlist) + end + local rules = (FXInheritRules_Actors or RebuildFXInheritActorRules())[actor] + if not rules then return end + local inherit = { [actor] = true} + for i = 1, #rules do + inherit[rules[i]] = true + end + local list = {} + for i = 1, #fxlist do + local fx = fxlist[i] + if inherit[fx.Actor] then + list[#list+1] = fx + end + end + return list +end + +if FirstLoad then + DuplicatedFX = {} +end + +local function MatchActionFX(actionFXClass, actionFXMoment, actorFXClass, targetFXClass, source, game_states, fx_type, match_type, detail_level, save_in, duplicates) + local list = {} + local remove_ids + local inherit_actions = actionFXClass and (FXInheritRules_Actions or RebuildFXInheritActionRules())[actionFXClass] + local inherit_moments = actionFXMoment and (FXInheritRules_Moments or RebuildFXInheritMomentRules())[actionFXMoment] + local inherit_actors = actorFXClass and (FXInheritRules_Actors or RebuildFXInheritActorRules() )[actorFXClass] + local inherit_targets = targetFXClass and (FXInheritRules_Actors or RebuildFXInheritActorRules() )[targetFXClass] + detail_level = detail_level or 0 + + local i, action + if actionFXClass == "any" then + action = next(FXRules) + else + i, action = 0, actionFXClass + end + local duplicated = DuplicatedFX + while action do + local rules1 = FXRules[action] + if rules1 then + local i, moment + if actionFXMoment == "any" then + moment = next(rules1) + else + i, moment = 0, actionFXMoment + end + while moment do + local rules2 = rules1[moment] + if rules2 then + local i, actor + if actorFXClass == "any" then + actor = next(rules2) + else + i, actor = 0, actorFXClass + end + while actor do + local rules3 = actor and rules2[actor] + if rules3 then + local i, target + if targetFXClass == "any" then + target = next(rules3) + else + i, target = 0, targetFXClass + end + while target do + local rules4 = target and rules3[target] + if rules4 then + for i = 1, #rules4 do + local fx = rules4[i] + local match = not IsKindOf(fx, "ActionFX") or fx:GameStatesMatched(game_states) + match = match and (not fx_type or fx_type == "any" or fx_type == fx.fx_type) + match = match and (detail_level == 0 or detail_level == fx.DetailLevel) + match = match and (not save_in or save_in == fx.save_in) + match = match and (not duplicates or duplicated[fx]) + match = match and (source == "any" or fx.Source == source) + if match then + list[fx] = true + end + end + end + if targetFXClass == "any" then + target = next(rules3, target) + else + if target == "any" or match_type == "Exact" then break end + i = i + 1 + target = inherit_targets and inherit_targets[i] or match_type ~= "NoAny" and "any" + end + end + end + if actorFXClass == "any" then + actor = next(rules2, actor) + else + if actor == "any" or match_type == "Exact" then break end + i = i + 1 + actor = inherit_actors and inherit_actors[i] or match_type ~= "NoAny" and "any" + end + end + end + if actionFXMoment == "any" then + moment = next(rules1, moment) + else + if moment == "any" or match_type == "Exact" then break end + i = i + 1 + moment = inherit_moments and inherit_moments[i] or match_type ~= "NoAny" and "any" + end + end + end + if actionFXClass == "any" then + action = next(FXRules, action) + else + if action == "any" or match_type == "Exact" then break end + i = i + 1 + action = inherit_actions and inherit_actions[i] or match_type ~= "NoAny" and "any" + end + end + return list +end + +local function GetFXListForEditor(filter) + filter = filter or ActionFXFilter -- to get defaults + filter:ResetDebugFX() + if filter.Type == "Inherit Action" then + return GetInheritActionFX(filter.Action) or {} + elseif filter.Type == "Inherit Moment" then + return GetInheritMomentFX(filter.Moment) or {} + elseif filter.Type == "Inherit Actor" then + return GetInheritActorFX(filter.Actor) or {} + else + return MatchActionFX( + filter.Action, filter.Moment, filter.Actor, filter.Target, filter.Source, + filter.GameStatesFilters, filter.Type, filter.MatchType, + filter.DetailLevel, filter.SaveIn, filter.Duplicates) + end +end + +if FirstLoad or ReloadForDlc then + FXLists = {} +end + +DefineClass.FXPreset = { + __parents = { "Preset", "InitDone"}, + properties = { + { id = "Id", editor = false, no_edit = true, }, + }, + + -- Preset + PresetClass = "FXPreset", + id = "", + EditorView = Untranslated(""), + GedEditor = "GedFXEditor", + EditorMenubarName = "FX Editor", + EditorShortcut = "Ctrl-Alt-F", + EditorMenubar = "Editors.Art", + EditorIcon = "CommonAssets/UI/Icons/atom electron molecule nuclear science.png", + FilterClass = "ActionFXFilter", +} + +function FXPreset:Init() + local list = FXLists[self.class] + if not list then + list = {} + FXLists[self.class] = list + end + list[#list+1] = self +end + +function FXPreset:Done() + table.remove_value(FXLists and FXLists[self.class], self) +end + +function FXPreset:GetError() + if self.Source == "UI" and self.GameTime then + return "UI FXs should not be GameTime" + end +end + +function FXPreset:GetPresetStatusText() + local ged = FindPresetEditor("FXPreset") + if not ged then return end + + local sel = ged:ResolveObj("SelectedPreset") + if IsKindOf(sel, "GedMultiSelectAdapter") then + local count_by_type = {} + for _, fx in ipairs(sel.__objects) do + local fx_type = fx.fx_type + count_by_type[fx_type] = (count_by_type[fx_type] or 0) + 1 + end + local t = {} + for _, fx_type in ipairs(ActionFXTypesCombo()) do + local count = count_by_type[fx_type] + if count then + t[#t + 1] = string.format("%d %s%s", count, fx_type, (count == 1 or fx_type:ends_with("s")) and "" or "s") + end + end + return table.concat(t, ", ") .. " selected" + end + return "" +end + +function FXPreset:SortPresets() + local presets = Presets[self.PresetClass or self.class] or empty_table + table.sort(presets, function(a, b) return a[1].group < b[1].group end) + + local keys = {} + for _, group in ipairs(presets) do + for _, preset in ipairs(group) do + keys[preset] = preset:DescribeForEditor() + end + end + + for _, group in ipairs(presets) do + table.stable_sort(group, function(a, b) + return keys[a] < keys[b] + end) + end + + ObjModified(presets) +end + +function FXPreset:GetSavePath() + local folder = self:GetSaveFolder() + if not folder then return end + + return string.format("%s/%s/%s.lua", folder, self.PresetClass, self.class) +end + +function FXPreset:SaveAll(...) + local used_handles = {} + ForEachPresetExtended(FXPreset, function(fx) + while used_handles[fx.id] do + fx.id = fx:GenerateUniquePresetId() + end + used_handles[fx.id] = true + end) + return Preset.SaveAll(self, ...) +end + +function FXPreset:GenerateUniquePresetId() + return random_encode64(48) +end + +function FXPreset:OnEditorNew() + if self:IsKindOf("ActionFX") then + self:AddInRules() + elseif self.class == "ActionFXInherit_Action" then + RebuildFXInheritActionRules() + elseif self.class == "ActionFXInherit_Moment" then + RebuildFXInheritMomentRules() + elseif self.class == "ActionFXInherit_Actor" then + RebuildFXInheritActorRules() + end +end + +function FXPreset:OnDataReloaded() + RebuildFXRules() +end + +local function format_match(action, moment, actor, target) + return string.format("%s-%s-%s-%s", action, moment, actor, target) +end + +function FXPreset:DescribeForEditor() + local str_desc = "" + local str_info = "" + local class = IsKindOf(self, "ModItem") and self.ModdedPresetClass or self.class + + if class == "ActionFXParticles" then + str_desc = string.format("%s", self.Particles) + elseif class == "ActionFXUIParticles" then + str_desc = string.format("%s", self.Particles) + elseif class == "ActionFXObject" or class == "ActionFXDecal" then + str_desc = string.format("%s", self.Object) + str_info = string.format("%s", self.Animation) + elseif class == "ActionFXSound" then + str_desc = string.format("%s", self.Sound) .. (self.DistantSound ~= "" and " "..self.DistantSound or "") + elseif class == "ActionFXLight" then + local r, g, b, a = GetRGBA(self.Color) + str_desc = string.format("%d %d %d %s", r, g, b, r, g, b, a ~= 255 and tostring(a) or "") + str_info = string.format("%d", self.Intensity) + elseif class == "ActionFXRadialBlur" then + str_desc = string.format("Strength %s", self.Strength) + str_info = string.format("Duration %s", self.Duration) + elseif class == "ActionFXControllerRumble" then + str_desc = string.format("%s", self.Power) + str_info = string.format("Duration %s", self.Duration) + elseif class == "ActionFXCameraShake" then + str_desc = string.format("%s", self.Preset) + elseif class == "ActionFXInherit_Action" then + local str_match = string.format("Inherit Action: %s -> %s", self.Action, self.Inherit) + return string.format("%s", str_match) + elseif class == "ActionFXInherit_Moment" then + local str_match = string.format("Inherit Moment: %s -> %s", self.Moment, self.Inherit) + return string.format("%s", str_match) + elseif class == "ActionFXInherit_Actor" then + local str_match = string.format("Inherit Actor: %s -> %s", self.Actor, self.Inherit) + return string.format("%s", str_match) + end + if self.Source ~= "" and self.Spot ~= "" then + local space = str_info ~= "" and " " or "" + str_info = str_info .. space .. string.format("%s.%s", self.Source, self.Spot) + end + + if self.Solo then + str_info = string.format("%s (Solo)", str_info) + end + + local str_match = format_match(self.Action, self.Moment, self.Actor, self.Target) + local clr_match = self.Disabled and "255 0 0" or "75 105 198" + local str_preset = self.Comment ~= "" and (" " .. self.Comment .. "") or "" + if self.save_in ~= "" and self.save_in ~= "none" then + str_preset = str_preset .. " - " .. self.save_in .. "" + end + + local fx_type = IsKindOf(self, "ModItem") and "" or string.format("%s ", self.fx_type) + str_desc = str_desc ~= "" and str_desc.." " or "" + if fx_type == "" and str_desc == "" and str_info == "" and (self.FxId or "") == "" then + return string.format("%s%s", clr_match, str_match, str_preset) + end + return string.format("%s%s\n%s%s%s %s", clr_match, str_match, str_preset, fx_type, str_desc, str_info, self.FxId or "") +end + +function FXPreset:delete() + Preset.delete(self) + InitDone.delete(self) +end + +function FXPreset:EditorContext() + local context = Preset.EditorContext(self) + table.remove_value(context.Classes, self.PresetClass) + table.remove_value(context.Classes, "ActionFX") + table.remove_value(context.Classes, "ActionFXInherit") + return context +end + +-- for ValidatePresetDataIntegrity +function FXPreset:GetIdentification() + return self:DescribeForEditor():strip_tags() +end + +DefineClass.ActionFXFilter = { + __parents = { "GedFilter" }, + + properties = { + { id = "DebugFX", category = "Match", default = false, editor = "bool", }, + { id = "Duplicates", category = "Match", default = false, editor = "bool", help = "Works only after using the tool 'Check duplicates'!" }, + { id = "Action", category = "Match", default = "any", editor = "combo", items = function(fx) return ActionFXClassCombo(fx) end }, + { id = "Moment", category = "Match", default = "any", editor = "combo", items = function(fx) return ActionMomentFXCombo(fx) end }, + { id = "Actor", category = "Match", default = "any", editor = "combo", items = function(fx) return ActorFXClassCombo(fx) end }, + { id = "Target", category = "Match", default = "any", editor = "combo", items = function(fx) return TargetFXClassCombo(fx) end }, + { id = "Source", category = "Match", default = "any", editor = "choice", items = { "UI", "Actor", "ActorParent", "ActorOwner", "Target", "ActionPos", "Camera" } }, + { id = "SaveIn", name = "Save in", category = "Match", editor = "choice", default = false, items = function(fx) + local locs = GetDefaultSaveLocations() + table.insert(locs, 1, { text = "All", value = false }) + return locs + end, }, + + { id = "GameStatesFilter", name="Game State", category = "Match", editor = "set", default = set(), three_state = true, + items = function() return GetGameStateFilter() end + }, + { id = "DetailLevel", category = "Match", default = 0, editor = "combo", items = function() + local levels = table.copy(ActionFXDetailLevelCombo()) + table.insert(levels, 1, {value = 0, text = "any"}) + return levels + end }, + { id = "Type", category = "Match", editor = "choice", items = ActionFXTypesCombo, default = "any", buttons = {{name = "Create New", func = "CreateNew"}}}, + { id = "MatchType", category = "Match", default = "Exact", editor = "choice", items = { "All", "Exact", "NoAny" }, }, + { id = "ResetButton", category = "Match", editor = "buttons", buttons = {{name = "Reset filter", func = "ResetAction"} }, default = false }, + + { id = "FxCounter", category = "Match", editor = "number", default = 0, read_only = true, }, + }, + + fx_counter = false, + last_lists = false, +} + +function ActionFXFilter:TryReset(ged, op, to_view) + if op == GedOpPresetDelete then + return + end + if to_view and #to_view == 2 and type(to_view[1]) == "table" then + -- check if the new item is hidden and only then reset the filter + local obj = ged:ResolveObj("root", table.unpack(to_view[1])) + local matched_fxs = GetFXListForEditor(self) + if not matched_fxs[obj] then + return GedFilter.TryReset(self, ged, op, to_view) + end + else + return GedFilter.TryReset(self, ged, op, to_view) + end +end + +function ActionFXFilter:ResetAction(root, prop_id, ged) + if self:TryReset(ged) then + self:ResetTarget(ged) + end +end + +function ActionFXFilter:CreateNew(root, prop_id, ged) + if self.Type == "any" then + print("Please specify the fx TYPE first") + return + end + + local idx = table.find(fx_class_list, "fx_type", self.Type) + if idx then + local old_value = self.Type + ged:Op(nil, "GedOpNewPreset", "root", { false, fx_class_list[idx].class }) + self.Type = old_value + ObjModified(self) + end +end + +function ActionFXFilter:GetFxCounter() + if not self.fx_counter then + local counter = 0 + for _, group in ipairs(Presets.FXPreset) do + counter = counter + #group + end + self.fx_counter = counter + end + return self.fx_counter +end + +function ActionFXFilter:FilterObject(obj) + if obj:IsKindOf("ActionFXInherit") then + return + obj:IsKindOf("ActionFXInherit_Action") and (self.Action == "any" or obj.Action == self.Action or obj.Inherit == self.Action) or + obj:IsKindOf("ActionFXInherit_Moment") and (self.Moment == "any" or obj.Moment == self.Moment or obj.Inherit == self.Moment) or + obj:IsKindOf("ActionFXInherit_Actor") and (self.Actor == "any" or obj.Actor == self.Actor or obj.Inherit == self.Actor ) + end + if self.last_lists then + return self.last_lists[obj] + end + return true +end + +function ActionFXFilter:ResetDebugFX() + if self.DebugFX then + DebugFX = self.Actor ~= "any" and self.Actor or true + DebugFXAction = self.Action ~= "any" and self.Action or false + DebugFXMoment = self.Moment ~= "any" and self.Moment or false + DebugFXTarget = self.Target ~= "any" and self.Target or false + else + DebugFX = false + DebugFXAction = false + DebugFXMoment = false + DebugFXTarget = false + end +end + +function ActionFXFilter:PrepareForFiltering() + self.last_lists = GetFXListForEditor(self) +end + +function ActionFXFilter:DoneFiltering(count) + if self.fx_counter ~= count then + self.fx_counter = count + ObjModified(self) + end +end + +function OnMsg.GedClosing(ged_id) + local ged = GedConnections[ged_id] + if ged.app_template == "GedFXEditor" then + local filter = ged:FindFilter("root") + filter.DebugFX = false + filter:ResetDebugFX() + end +end + +function GedOpFxUseAsFilter(ged, root, sel) + local preset = root[sel[1]][sel[2]] + if preset then + local filter = ged:FindFilter("root") + filter.Action = preset.Action + filter.Moment = preset.Moment + filter.Actor = preset.Actor + filter.Target = preset.Target + filter.SaveIn = preset.SaveIn + filter:ResetTarget(ged) + end +end + +function CheckForDuplicateFX() + local count = 0 + local type_to_props = {} + local ignore_classes = { + ActionFXBehavior = true, + } + local ignore_props = { + id = true, + } + local duplicated = {} + DuplicatedFX = duplicated + for action_id, actions in pairs(FXRules) do + for moment_id, moments in pairs(actions) do + for actor_id, actors in pairs(moments) do + for target_id, targets in pairs(actors) do + local str_to_fx = {} + for _, fx in ipairs(targets) do + local class = fx.class + if not ignore_classes[class] then + local str = pstr(class, 1024) + local props = type_to_props[class] + if not props then + props = {} + type_to_props[class] = props + for _, prop in ipairs(g_Classes[class]:GetProperties()) do + local id = prop.id + if not ignore_props[id] then + props[#props + 1] = id + end + end + end + for _, id in ipairs(props) do + str:append("\n") + ValueToLuaCode(fx:GetProperty(id), "", str) + end + local key = tostring(str) + local prev_fx = str_to_fx[key] + if prev_fx then + GameTestsError("Duplicate FX:", fx.fx_type, action_id, moment_id, actor_id, target_id) + count = count + 1 + duplicated[prev_fx] = true + duplicated[fx] = true + else + str_to_fx[key] = fx + end + end + end + end + end + end + end + GameTestsPrintf("%d duplicated FX found!", count) + return count +end + +function GameTests.TestActionFX() + CheckForDuplicateFX() +end + +function GedOpFxCheckDuplicates(ged, root, sel) + CheckForDuplicateFX() +end \ No newline at end of file diff --git a/CommonLua/Classes/Flight.lua b/CommonLua/Classes/Flight.lua new file mode 100644 index 0000000000000000000000000000000000000000..73b6d8fbf26687f632bd570e41cb3c2f41a0c744 --- /dev/null +++ b/CommonLua/Classes/Flight.lua @@ -0,0 +1,1450 @@ +local FlightTile = const.FlightTile +if not FlightTile then + return -- flight logic not supported +end + +FlightDbgResults = empty_func +FlightDbgMark = empty_func +FlightDbgBreak = empty_func + +local efResting = const.efResting + +local pfFinished = const.pfFinished +local pfFailed = const.pfFailed +local pfTunnel = const.pfTunnel +local pfDestLocked = const.pfDestLocked +local pfSmartDestlockDist = const.pfSmartDestlockDist + +local tfrPassClass = const.tfrPassClass +local tfrLimitDist = const.tfrLimitDist +local tfrCanDestlock = const.tfrCanDestlock +local tfrLuaFilter = const.tfrLuaFilter + +local Min, Max, Clamp, AngleDiff = Min, Max, Clamp, AngleDiff +local IsValid, IsValidPos = IsValid, IsValidPos +local ResolveZ = ResolveZ + +local InvalidZ = const.InvalidZ +local anim_min_time = 100 +local time_ahead = 10 +local tplCheck = const.tplCheck +local step_search_dist = 2*FlightTile +local dest_search_dist = 4*FlightTile +local max_search_dist = 10*FlightTile +local max_takeoff_dist = 64*guim + +local flight_default_flags = const.ffpSplines | const.ffpPhysics | const.ffpSmooth +local ffpAdjustTarget = const.ffpAdjustTarget + +local flight_flags_values = { + Splines = const.ffpSplines, + Physics = const.ffpPhysics, + Smooth = const.ffpSmooth, + AdjustTarget = const.ffpAdjustTarget, + Debug = const.ffpDebug, +} +local flight_flags_names = table.keys(flight_flags_values, true) +local function FlightFlagsToSet(flags) + local fset = {} + for name, flag in pairs(flight_flags_values) do + if (flags & flag) ~= 0 then + fset[name] = true + end + end + return fset +end +local function FlightSetToFlags(fset) + local flags = 0 + for name in pairs(fset) do + flags = flags | flight_flags_values[name] + end + return flags +end +local path_errors = { + invalid = const.fpsInvalid, + max_iters = const.fpsMaxIters, + max_steps = const.fpsMaxSteps, + max_loops = const.fpsMaxLoops, + max_stops = const.fpsMaxStops, +} +function FlightGetErrors(status) + status = status or 0 + local errors + for name, value in pairs(path_errors) do + if status & value ~= 0 then + errors = table.create_add(errors, name) + end + end + if errors then + table.sort(errors) + return errors + end +end + +function FlightInitVars() + FlightMap = false + FlightEnergy = false + FlightFrom = false + FlightTo = false + FlightFlags = 0 + FlightDestRange = 0 + FlightMarkFrom = false + FlightMarkTo = false + FlightMarkBorder = 0 + FlightMarkMinHeight = 0 + FlightMarkObjRadius = 0 + FlightMarkIdx = 0 + FlightArea = false + FlightEnergyMin = false + FlightSlopePenalty = 0 + FlightSmoothDist = 0 + FlightGrowObstacles = false + FlightTimestamp = 0 + FlightPassVersion = false +end + +if FirstLoad then + FlightInitVars() +end + +function OnMsg.DoneMap() + if FlightMap then + FlightMap:free() + end + if FlightEnergy then + FlightEnergy:free() + end + FlightInitVars() +end + +local StayAboveMapItems = { + { value = const.FlightRestrictNone, text = "None", help = "The object is allowed to fall under the flight map" }, + { value = const.FlightRestrictAboveTerrain, text = "Above Terrain", help = "The object is allowed to fall under the flight map, but not under the terrain" }, + { value = const.FlightRestrictAboveWalkable, text = "Above Walkable", help = "The object is allowed to fall under the flight map, but not under a walkable surface (inlcuding the terrain)" }, + { value = const.FlightRestrictAboveMap, text = "Above Flight Map", help = "The object is not allowed to fall under the flight map" }, +} + +---- + +MapVar("FlyingObjs", function() return sync_set() end) + +DefineClass.FlyingObj = { + __parents = { "Object" }, + flags = { cofComponentInterpolation = true, cofComponentCurvature = true }, + properties = { + { category = "Flight", id = "FlightMinPitch", name = "Pitch Min", editor = "number", default = -2700, scale = "deg", template = true }, + { category = "Flight", id = "FlightMaxPitch", name = "Pitch Max", editor = "number", default = 2700, scale = "deg", template = true }, + { category = "Flight", id = "FlightPitchSmooth", name = "Pitch Smooth", editor = "number", default = 100, min = 0, max = 500, scale = 100, slider = true, template = true, help = "Smooth the pitch angular speed changes" }, + { category = "Flight", id = "FlightMaxPitchSpeed", name = "Pitch Speed Limit (deg/s)",editor = "number", default = 90*60, scale = 60, template = true, help = "Smooth the pitch angular speed changes" }, + { category = "Flight", id = "FlightSpeedToPitch", name = "Speed to Pitch", editor = "number", default = 100, min = 0, max = 100, scale = "%", slider = true, template = true, help = "How much the flight speed affects the pitch angle" }, + { category = "Flight", id = "FlightMaxRoll", name = "Roll Max", editor = "number", default = 2700, min = 0, max = 180*60, scale = "deg", slider = true, template = true }, + { category = "Flight", id = "FlightMaxRollSpeed", name = "Roll Speed Limit (deg/s)", editor = "number", default = 90*60, scale = 60, template = true, help = "Smooth the row angular speed changes" }, + { category = "Flight", id = "FlightRollSmooth", name = "Roll Smooth", editor = "number", default = 100, min = 0, max = 500, scale = 100, slider = true, template = true, help = "Smooth the row angular speed changes" }, + { category = "Flight", id = "FlightSpeedToRoll", name = "Speed to Roll", editor = "number", default = 0, min = 0, max = 100, scale = "%", slider = true, template = true, help = "How much the flight speed affects the roll angle" }, + { category = "Flight", id = "FlightYawSmooth", name = "Yaw Smooth", editor = "number", default = 100, min = 0, max = 500, scale = 100, slider = true, template = true, help = "Smooth the yaw angular speed changes" }, + { category = "Flight", id = "FlightMaxYawSpeed", name = "Yaw Speed Limit (deg/s)", editor = "number", default = 360*60, scale = 60, template = true, help = "Smooth the yaw angular speed changes" }, + { category = "Flight", id = "FlightYawRotToRoll", name = "Yaw Rot to Roll", editor = "number", default = 100, min = 0, max = 300, scale = "%", slider = true, template = true, help = "Links the row angle to the yaw rotation speed" }, + { category = "Flight", id = "FlightYawRotFriction", name = "Yaw Rot Friction", editor = "number", default = 100, min = 0, max = 1000, scale = "%", slider = true, template = true, help = "Friction caused by 90 deg/s yaw rotation speed" }, + { category = "Flight", id = "FlightSpeedStop", name = "Speed Stop (m/s)", editor = "number", default = false, scale = guim, template = true, help = "Will use the min speed if not specified. Stopping is possible only if the deceleration distance is not zero" }, + { category = "Flight", id = "FlightSpeedMin", name = "Speed Min (m/s)", editor = "number", default = 6 * guim, scale = guim, template = true }, + { category = "Flight", id = "FlightSpeedMax", name = "Speed Max (m/s)", editor = "number", default = 15 * guim, scale = guim, template = true }, + { category = "Flight", id = "FlightFriction", name = "Friction", editor = "number", default = 30, min = 0, max = 300, slider = true, scale = "%", template = true, help = "Friction coefitient, affects the max achievable speed. Should be adjusted so that both the max speed and the achievable one are matching." }, + { category = "Flight", id = "FlightAccelMax", name = "Accel Max (m/s^2)", editor = "number", default = 10*guim, scale = guim, template = true }, + { category = "Flight", id = "FlightDecelMax", name = "Decel Max (m/s^2)", editor = "number", default = 20*guim, scale = guim, template = true }, + { category = "Flight", id = "FlightAccelDist", name = "Accel Dist", editor = "number", default = 20*guim, scale = "m", template = true }, + { category = "Flight", id = "FlightDecelDist", name = "Decel Dist", editor = "number", default = 20*guim, scale = "m", template = true }, + { category = "Flight", id = "FlightStopDist", name = "Force Stop Dist", editor = "number", default = 1*guim, scale = "m", template = true, help = "Critical distance where to dorce a stop animation even if the conditions for such are not met" }, + { category = "Flight", id = "FlightStopMinTime", name = "Min Stop Time", editor = "number", default = 50, min = 0, template = true, help = "Try to play stop anim only if enough time is available" }, + { category = "Flight", id = "FlightPathStepMax", name = "Path Step Max", editor = "number", default = 2*guim, scale = "m", template = true, help = "Step dist at max speed" }, + { category = "Flight", id = "FlightPathStepMin", name = "Path Step Min", editor = "number", default = guim, scale = "m", template = true, help = "Step dist at min speed" }, + { category = "Flight", id = "FlightAnimStart", name = "Anim Fly Start", editor = "text", default = false, template = true }, + { category = "Flight", id = "FlightAnim", name = "Anim Fly", editor = "text", default = false, template = true }, + { category = "Flight", id = "FlightAnimDecel", name = "Anim Fly Decel", editor = "text", default = false, template = true }, + { category = "Flight", id = "FlightAnimStop", name = "Anim Fly Stop", editor = "text", default = false, template = true }, + + { category = "Flight", id = "FlightAnimIdle", name = "Anim Fly Idle", editor = "text", default = false, template = true }, + { category = "Flight", id = "FlightAnimSpeedMin", name = "Anim Speed Min", editor = "number", default = 1000, min = 0, max = 1000, scale = 1000, slider = true, template = true }, + { category = "Flight", id = "FlightAnimSpeedMax", name = "Anim Speed Max", editor = "number", default = 1000, min = 1000, max = 3000, scale = 1000, slider = true, template = true }, + { category = "Flight", id = "FlightAnimStopFOV", name = "Anim Fly Stop FoV", editor = "number", default = 90*60, min = 0, max = 360*60, scale = "deg", slider = true, template = true, help = "Required FoV towards the target in order to switch to anim_stop/landing anim" }, + + { category = "Flight Path", id = "FlightSimHeightMin", name = "Min Height", editor = "number", default = 3*guim, min = guim, max = 50*guim, slider = true, scale = "m", template = true, sim = true, help = "Min flight height. If below, the flying obj will try to go up (lift)." }, + { category = "Flight Path", id = "FlightSimHeightMax", name = "Max Height", editor = "number", default = 5*guim, min = guim, max = 50*guim, slider = true, scale = "m", template = true, sim = true, help = "Max flight height. If above, the flying obj will try to go down (weight)." }, + { category = "Flight Path", id = "FlightSimHeightRestrict", name = "Height Restriction", editor = "choice", default = const.FlightRestrictNone, template = true, sim = true, items = StayAboveMapItems, help = "Avoid entering the height map. As the height map is not precise, this could lead to strange visual behavior." }, + { category = "Flight Path", id = "FlightSimSpeedLimit", name = "Speed Limit (m/s)", editor = "number", default = 10*guim, min = 1, max = 50*guim, slider = true, scale = guim, template = true, sim = true, help = "Max speed during simulation. Should be limited to ensure precision." }, + { category = "Flight Path", id = "FlightSimInertia", name = "Inertia", editor = "number", default = 100, min = 10, max = 1000, slider = true, exponent = 2, scale = 100, template = true, sim = true, help = "How inert is the object." }, + { category = "Flight Path", id = "FlightSimFrictionXY", name = "Friction XY", editor = "number", default = 20, min = 1, max = 300, slider = true, scale = "%", template = true, sim = true, help = "Horizontal friction min coefitient." }, + { category = "Flight Path", id = "FlightSimFrictionZ", name = "Friction Z", editor = "number", default = 50, min = 1, max = 300, slider = true, scale = "%", template = true, sim = true, help = "Vertical friction coefitient." }, + { category = "Flight Path", id = "FlightSimFrictionStop", name = "Friction Stop", editor = "number", default = 80, min = 1, max = 300, slider = true, scale = "%", template = true, sim = true, help = "Horizontal friction max coefitient." }, + { category = "Flight Path", id = "FlightSimAttract", name = "Attract", editor = "number", default = guim, min = 0, max = 30*guim, slider = true, scale = 1000, template = true, sim = true, help = "Attraction force per energy unit difference. The force pushing the unit towards its final destination." }, + { category = "Flight Path", id = "FlightSimLift", name = "Lift", editor = "number", default = guim/3, min = 0, max = 30*guim, slider = true, scale = 1000, template = true, sim = true, help = "Lift force per meter. The force trying to bring back UP the unit at its best height level." }, + { category = "Flight Path", id = "FlightSimMaxLift", name = "Max Lift", editor = "number", default = 10*guim, min = 0, max = 30*guim, slider = true, scale = 1000, template = true, sim = true, help = "Max lift force." }, + { category = "Flight Path", id = "FlightSimWeight", name = "Weight", editor = "number", default = guim/3, min = 0, max = 20*guim, slider = true, scale = 1000, template = true, sim = true, help = "Weight force per meter. The force trying to bring back DOWN the unit at its best height level." }, + { category = "Flight Path", id = "FlightSimMaxWeight", name = "Max Weight", editor = "number", default = 3*guim, min = 0, max = 20*guim, slider = true, scale = 1000, template = true, sim = true, help = "Max weight force." }, + { category = "Flight Path", id = "FlightSimMaxThrust", name = "Max Thrust", editor = "number", default = 10*guim, min = 0, max = 50*guim, slider = true, scale = 1000, template = true, sim = true, help = "Max cummulative thrust." }, + { category = "Flight Path", id = "FlightSimInterval", name = "Update Interval (ms)", editor = "number", default = 50, min = 1, max = 1000, slider = true, template = true, sim = true, help = "Simulation update interval. Lower values ensure better precision, but makes the sim more expensive" }, + { category = "Flight Path", id = "FlightSimMinStep", name = "Min Path Step", editor = "number", default = FlightTile, min = 0, max = 100*guim, scale = "m", slider = true, template = true, sim = true, help = "Min path step (approx)." }, + { category = "Flight Path", id = "FlightSimMaxStep", name = "Max Path Step", editor = "number", default = 8*FlightTile, min = 0, max = 100*guim, scale = "m", slider = true, template = true, sim = true, help = "Max path step (approx)." }, + { category = "Flight Path", id = "FlightSimDecelDist", name = "Decel Dist", editor = "number", default = 10*guim, min = 1, max = 300*guim, slider = true, scale = "m", template = true, sim = true, help = "At that distance to the target, the movement will try to go towards the target ignoring most considerations." }, + { category = "Flight Path", id = "FlightSimLookAhead", name = "Look Ahead", editor = "number", default = 4000, min = 0, max = 10000, scale = "sec", slider = true, template = true, sim = true, help = "Give some time to adjust the flight height before reaching a too high obstacle." }, + { category = "Flight Path", id = "FlightSimSplineAlpha", name = "Spline Alpha", editor = "number", default = 1365, min = 0, max = 4096, scale = 4096, slider = true, template = true, sim = true, help = "Defines the spline smoothness." }, + { category = "Flight Path", id = "FlightSimSplineErr", name = "Spline Tolerance", editor = "number", default = FlightTile/4, min = 0, max = FlightTile, scale = "m", slider = true, template = true, sim = true, help = "Max spline deviation form the precise trajectory. Lower values imply more path steps as the longer splines deviate stronger." }, + { category = "Flight Path", id = "FlightSimMaxIters", name = "Max Compute Iters", editor = "number", default = 16 * 1024, template = true, sim = true, help = "Max number of compute iterations. Used for a sanity check against infinite loops." }, + + { category = "Flight Path", id = "FlightSlopePenalty", name = "Slope Penalty", editor = "number", default = 300, scale = "%", template = true, sim = true, min = 10, max = 1000, slider = true, exponent = 2, help = "How difficult it is to flight over against going around obstacles." }, + { category = "Flight Path", id = "FlightSmoothDist", name = "Smooth Obstacles Dist",editor = "number", default = 0, template = true, sim = true, help = "Better obstacle avoidance withing that distance at the expense of more processing." }, + { category = "Flight Path", id = "FlightMinObstacleHeight", name = "Min Obstacle Height", editor = "number", default = 0, scale = "m", template = true, sim = true, step = const.FlightScale, help = "Ignored obstacle height." }, + { category = "Flight Path", id = "FlightObjRadius", name = "Object Radius", editor = "number", default = 0, scale = "m", template = true, sim = true, help = "To consider when avoiding obstacles." }, + + { category = "Flight Path", id = "FlightFlags", name = "Flight Flags", editor = "set", default = function(self) return FlightFlagsToSet(flight_default_flags) end, items = flight_flags_names }, + { category = "Flight Path", id = "FlightPathErrors", name = "Path Errors", editor = "set", default = set(), items = table.keys(path_errors, true), read_only = true, dont_save = true }, + { category = "Flight Path", id = "FlightPathSplines", name = "Path Splines", editor = "number", default = 0, read_only = true, dont_save = true }, + { category = "Flight Path", id = "flight_path_iters", name = "Path Iters", editor = "number", default = 0, read_only = true, dont_save = true }, + + }, + flight_target = false, + flight_target_range = 0, + flight_path = false, + flight_path_status = 0, + flight_path_flags = false, + flight_path_collision = false, + flight_spline_idx = 0, + flight_spline_dist = 0, + flight_spline_len = 0, + flight_spline_time = 0, + flight_stop_on_passable = false, -- in order to achieve landing + flight_flags = flight_default_flags, + + ResolveFlightTarget = pf.ResolveGotoTargetXYZ, + CanFlyTo = return_true, +} + +function FlyingObj:Init() + FlyingObjs:insert(self) +end + +function FlyingObj:Done() + FlyingObjs:remove(self) + self:UnlockFlightDest() +end + +function FlyingObj:GetFlightPathErrors() + return table.invert(FlightGetErrors(self.flight_path_status)) +end + +function FlyingObj:GetFlightPathSplines() + return #(self.flight_path or "") +end + +function FlyingObj:SetFlightFlag(flag, enable) + enable = enable or false + local flight_flags = self.flight_flags + local enabled = (flight_flags & flag) ~= 0 + if enable == enabled then + return + end + if enable then + self.flight_flags = flight_flags | flag + else + self.flight_flags = flight_flags & ~flag + end + return true +end + +function FlyingObj:GetFlightFlag(flag) + return (self.flight_flags & flag) ~= 0 +end + +function FlyingObj:SetFlightFlags(fset) + self.flight_flags = FlightSetToFlags(fset) +end + +function FlyingObj:GetFlightFlags() + return FlightFlagsToSet(self.flight_flags) +end + +function FlyingObj:SetAdjustFlightTarget(enable) + return self:SetFlightFlag(ffpAdjustTarget, enable) +end + +function FlyingObj:GetAdjustFlightTarget() + return self:GetFlightFlag(ffpAdjustTarget) +end + +function FlyingObj:FlightStop() + if self:TimeToPosInterpolationEnd() == 0 then + return + end + local a = -self.FlightDecelMax + local x, y, z, dt0 = self:GetFinalPosAndTime(0, a) + if not x then + return + end + self:SetPos(x, y, z, dt0) + self:SetAcceleration(a) + return dt0 +end + +function FlyingObj:FindFlightPath(target, range, flight_flags, debug_iter) + if not IsValidPos(target) then + return + end + flight_flags = flight_flags or self.flight_flags + local path, error_status, collision_pos, iters = FlightCalcPathBetween( + self, target, flight_flags, + self.FlightMinObstacleHeight, self.FlightObjRadius, self.FlightSlopePenalty, self.FlightSmoothDist, + range, debug_iter) + self.flight_path = path + self.flight_path_status = error_status + self.flight_path_iters = iters + self.flight_path_flags = flight_flags + self.flight_path_collision = collision_pos + self.flight_target = target + self.flight_target_range = range or nil + self.flight_spline_idx = nil + self.flight_spline_dist = nil + self.flight_spline_len = nil + self.flight_spline_time = nil + dbg(FlightDbgResults(self)) + return path, error_status, collision_pos +end + +function FlyingObj:RecalcFlightPath() + return self:FindFlightPath(self.flight_target, self.flight_target_range, self.flight_path_flags) +end + +function FlyingObj:MarkFlightArea(target) + return FlightMarkBetween(self, target or self, self.FlightMinObstacleHeight, self.FlightObjRadius) +end + +function FlyingObj:MarkFlightAround(target, border) + target = target or self + return FlightMarkBetween(target, target, self.FlightMinObstacleHeight, self.FlightObjRadius, border) +end + +function FlyingObj:LockFlightDest(x, y, z) + return x, y, z +end +FlyingObj.UnlockFlightDest = empty_func + +function FlyingObj:GetPathHash(seed) + local flight_path = self.flight_path + if not flight_path or #flight_path == 0 then return end + local start_idx = self.flight_spline_idx + local spline = flight_path[start_idx] + local hash = xxhash(seed, spline[1], spline[2], spline[3], spline[4]) + for i=start_idx + 1,#flight_path do + spline = flight_path[i] + hash = xxhash(hash, spline[2], spline[3], spline[4]) + end + return hash +end + +function FlyingObj:Step(pt, ...) + -- TODO: implement in C + local fx, fy, fz, range = self:ResolveFlightTarget(pt, ...) + local tx, ty, tz = self:LockFlightDest(fx, fy, fz) + if not tx then + return pfFailed + end + local visual_z = ResolveZ(tx, ty, tz) + if self:IsCloser(tx, ty, visual_z, range + 1) then + if range == 0 then + self:SetPos(tx, ty, tz) + self:SetAcceleration(0) + end + fz = fz or InvalidZ + tz = tz or InvalidZ + if fx ~= tx or fy ~= ty or fz ~= tz then + return pfDestLocked + end + return pfFinished + end + local v0 = self:GetVelocity() + local path = self.flight_path + local flight_target = self.flight_target + local prev_range = self.flight_target_range + local prev_flags = self.flight_path_flags + local find_path = not path or not flight_target or prev_flags ~= self.flight_flags + local time_now = GameTime() + local spline_idx, spline_dist, spline_len + local same_target = prev_range == range and flight_target and flight_target:Equal(tx, ty, tz) + if not find_path and not same_target then + -- recompute path only if the new target is far enough from the old target + local error_dist = flight_target:Dist(tx, ty, tz) + local retarget_offset_pct = 30 + local threshold_dist = error_dist * 100 / retarget_offset_pct + if v0 > 0 then + local min_retarget_time = 3000 + threshold_dist = Min(threshold_dist, v0 * min_retarget_time / 1000) + end + local x, y, z = ResolveVisualPosXYZ(flight_target) + find_path = self:IsCloser(x, y, z, 1 + threshold_dist) + end + local step_finished + if find_path then + flight_target = point(tx, ty, tz) + path = self:FindFlightPath(flight_target, range) + if not path or #path == 0 then + return pfFailed + end + assert(flight_target == self.flight_target) + spline_idx = 0 + spline_dist = 0 + spline_len = 0 + step_finished = true + same_target = true + else + spline_idx = self.flight_spline_idx + spline_dist = self.flight_spline_dist + spline_len = self.flight_spline_len + step_finished = time_now - self.flight_spline_time >= 0 + end + local spline + local last_step + local BS3_GetSplineLength3D = BS3_GetSplineLength3D + if spline_dist < spline_len or not step_finished then + spline = path[spline_idx] + else + while spline_dist >= spline_len do + spline_idx = spline_idx + 1 + spline = path[spline_idx] + if not spline then + return pfFailed + end + spline_dist = 0 + spline_len = BS3_GetSplineLength3D(spline) + end + self.flight_spline_idx = spline_idx + self.flight_spline_len = spline_len + end + assert(spline) + if not spline then + return pfFailed + end + local last_spline = path[#path] + local flight_dest = last_spline[4] + tx, ty, tz = flight_dest:xyz() + local speed_min, speed_max, speed_stop = self.FlightSpeedMin, self.FlightSpeedMax, self.FlightSpeedStop + if step_finished then + local min_step, max_step = self.FlightPathStepMin, self.FlightPathStepMax + assert(speed_min == speed_max and min_step == max_step or speed_min < speed_max and min_step < max_step) + local spline_step + if v0 <= speed_min then + spline_step = min_step + elseif v0 >= speed_max then + spline_step = max_step + else + spline_step = min_step + (max_step - min_step) * (v0 - speed_min) / (speed_max - speed_min) + end + spline_step = Min(spline_step, spline_len) + spline_dist = spline_dist + spline_step + if spline_dist + spline_step / 2 > spline_len then + spline_dist = spline_len + last_step = spline_idx == #path + end + self.flight_spline_dist = spline_dist + end + + speed_stop = speed_stop or speed_min + local max_roll, roll_max_speed = self.FlightMaxRoll, self.FlightMaxRollSpeed + local pitch_min, pitch_max = self.FlightMinPitch, self.FlightMaxPitch + local yaw_max_speed, pitch_max_speed = self.FlightMaxYawSpeed, self.FlightMaxPitchSpeed + local decel_dist = self.FlightDecelDist + local remaining_len = spline_len - spline_dist + local anim_stop + local fly_anim = self.FlightAnim + local x0, y0, z0 = self:GetVisualPosXYZ() + local speed_lim = speed_max + local x, y, z, dirx, diry, dirz, curvex, curvey, curvez + local roll, pitch, yaw, accel, v, dt + local max_dt = max_int + if decel_dist > 0 and self:IsCloser(flight_dest, decel_dist) and (not self.flight_stop_on_passable or terrain.FindPassableZ(flight_dest, self, 0, 0)) then + local total_remaining_len = remaining_len + local deceleration = true + for i = spline_idx + 1, #path do + if total_remaining_len >= decel_dist then + deceleration = false + break + end + total_remaining_len = total_remaining_len + BS3_GetSplineLength3D(path[i]) + end + if deceleration then + speed_lim = speed_stop + (speed_max - speed_stop) * total_remaining_len / decel_dist + end + fly_anim = self.FlightAnimDecel or fly_anim + + local use_velocity_fov = true + local tz1 = tz + 50 -- make LOS work for positions on a floor + local critical_stop = deceleration and total_remaining_len < self.FlightStopDist + local fly_anim_stop = self.FlightAnimStop + if fly_anim and fly_anim_stop and deceleration + and (critical_stop or self:HasFov(tx, ty, tz1, self.FlightAnimStopFOV, 0, use_velocity_fov) and TestPointsLOS(tx, ty, tz1, self, tplCheck)) then + dt = GetAnimDuration(self:GetEntity(), fly_anim_stop) -- as the anim speed may varry + dbg(ReportZeroAnimDuration(self, fly_anim_stop, dt)) + if dt == 0 then + dt = 1000 + end + x, y, z, dirx, diry, dirz = BS3_GetSplinePosDir(last_spline, 4096) + accel, v = self:GetAccelerationAndFinalSpeed(x, y, z, dt) + local speed_stop = Max(v0, speed_min) + if v <= speed_stop then + anim_stop = true + local anim_speed = 1000 + if v < 0 then + local stop_time + accel, stop_time = self:GetAccelerationAndTime(x, y, z, speed_stop) + if stop_time > self.FlightStopMinTime then + anim_speed = 1000 * dt / stop_time + else + anim_stop = false + end + end + if anim_stop then + if dirx == 0 and diry == 0 then + dirx, diry = x - x0, y - y0 + end + yaw = atan(diry, dirx) + roll, pitch = 0, 0 + self:SetState(fly_anim_stop) + self:SetAnimSpeed(1, anim_speed) + self.flight_spline_dist = spline_len + last_step = true + end + end + end + end + if not anim_stop then + local roll0, pitch0, yaw0 = self:GetRollPitchYaw() + x, y, z, dirx, diry, dirz, curvex, curvey, curvez = BS3_GetSplinePosDirCurve(spline, spline_dist, spline_len) + if dirx == 0 and diry == 0 and dirz == 0 then + dirx, diry, dirz = x - x0, y - y0, z - z0 + end + + pitch, yaw = GetPitchYaw(dirx, diry, dirz) + pitch, yaw = pitch or pitch0, yaw or yaw0 + + local step_len = self:GetVisualDist(x, y, z) + local friction = self.FlightFriction + local dyaw = AngleDiff(yaw, yaw0) * 100 / (100 + self.FlightYawSmooth) + dt = v0 > 0 and MulDivRound(1000, step_len, v0) or 0 -- step time estimate + local yaw_rot_est = dt == 0 and 0 or Clamp(1000 * dyaw / dt, -yaw_max_speed, yaw_max_speed) + if yaw_rot_est ~= 0 then + friction = friction + MulDivRound(self.FlightYawRotFriction, abs(yaw_rot_est), 90 * 60) + end + local speed_to_roll, speed_to_pitch = self.FlightSpeedToRoll, self.FlightSpeedToPitch + local accel_max = self.FlightAccelMax + local accel0 = accel_max - v0 * friction / 100 + v, dt = self:GetFinalSpeedAndTime(x, y, z, accel0, v0) + v = v or speed_min + v = Min(v, speed_lim) + v = Max(v, Min(speed_min, v0)) + local at_max_speed = v == speed_max + accel, dt = self:GetAccelerationAndTime(x, y, z, v) + if not at_max_speed and speed_to_pitch > 0 then + local mod_pitch = pitch * v / speed_max + if speed_to_pitch == 100 then + pitch = mod_pitch + else + pitch = pitch + (mod_pitch - pitch) * speed_to_pitch / 100 + end + end + pitch = Clamp(pitch, pitch_min, pitch_max) + local dpitch = AngleDiff(pitch, pitch0) * 100 / (100 + self.FlightPitchSmooth) + local pitch_rot = dt > 0 and Clamp(1000 * dpitch / dt, -pitch_max_speed, pitch_max_speed) or 0 + local yaw_rot = dt > 0 and Clamp(1000 * dyaw / dt, -yaw_max_speed, yaw_max_speed) or 0 + roll = -yaw_rot * self.FlightYawRotToRoll / 100 + if not at_max_speed and speed_to_roll > 0 then + local mod_roll = roll * v / speed_max + if speed_to_roll == 100 then + roll = mod_roll + else + roll = roll + (mod_roll - roll) * speed_to_roll / 100 + end + end + roll = Clamp(roll, -max_roll, max_roll) + local droll = AngleDiff(roll, roll0) * 100 / (100 + self.FlightRollSmooth) + local roll_rot = dt > 0 and Clamp(1000 * droll / dt, -roll_max_speed, roll_max_speed) or 0 + if dt > 0 then + -- limit the rotation speed + droll = roll_rot * dt / 1000 + dyaw = yaw_rot * dt / 1000 + dpitch = pitch_rot * dt / 1000 + end + roll = roll0 + droll + yaw = yaw0 + dyaw + pitch = pitch0 + dpitch + if fly_anim then + local anim = GetStateName(self) + if anim ~= fly_anim then + local fly_anim_start = self.FlightAnimStart + if anim ~= fly_anim_start then + self:SetState(fly_anim_start) + else + local remaining_time = self:TimeToAnimEnd() + if remaining_time > anim_min_time then + max_dt = remaining_time + else + self:SetState(fly_anim) + end + end + else + local min_anim_speed, max_anim_speed = self.FlightAnimSpeedMin, self.FlightAnimSpeedMax + if dt > 0 and min_anim_speed < max_anim_speed then + local curve = Max(GetLen(curvex, curvey, curvez), 1) + local coef = 1024 + 1024 * curvez / curve + 1024 * abs(accel0) / accel_max + local anim_speed = min_anim_speed + (max_anim_speed - min_anim_speed) * Clamp(coef, 0, 2048) / 2048 + self:SetAnimSpeed(1, anim_speed) + end + end + end + end + + self:SetRollPitchYaw(roll, pitch, yaw, dt) + self:SetPos(x, y, z, dt) + self:SetAcceleration(accel) + + --if self == SelectedObj then DbgSetText(self, print_format("v", v, "t", abs(rotation_speed)/60, "r", roll/60, "dt", dt)) else DbgSetText(self) end + if not last_step and not anim_stop and dt > time_ahead then + dt = dt - time_ahead -- fix the possibility of rendering the object immobile at the end of the interpolation + end + self.flight_spline_time = time_now + dt + local sleep = Min(dt, max_dt) + return sleep +end + +function FlyingObj:ClearFlightPath() + self.flight_path = nil + self.flight_path_status = nil + self.flight_path_iters = nil + self.flight_path_flags = nil + self.flight_path_collision = nil + self.flight_target = nil + self.flight_spline_idx = nil + self.flight_flags = nil + self.flight_stop_on_passable = nil + self:UnlockFlightDest() +end + +FlyingObj.ClearPath = FlyingObj.ClearFlightPath + +function FlyingObj:ResetOrientation(time) + local _, _, yaw = self:GetRollPitchYaw() + self:SetRollPitchYaw(0, 0, yaw, time) +end + +function FlyingObj:Face(target, time) + local pitch, yaw = GetPitchYaw(self, target) + self:SetRollPitchYaw(0, pitch, yaw, time) +end + +function FlyingObj:GetFlightDest() + local path = self.flight_path + local last_spline = path and path[#path] + return last_spline and last_spline[4] +end + +function FlyingObj:GetFinalFlightDirXYZ() + local path = self.flight_path + local last_spline = path and path[#path] + if not last_spline then + return self:GetVelocityVectorXYZ() + end + return BS3_GetSplineDir(last_spline, 4096, 4096) +end + +function FlyingObj:IsFlightAreaMarked(flight_target, mark_border) + flight_target = flight_target or self.flight_target + if not flight_target + or GameTime() ~= FlightTimestamp + or not FlightArea or not FlightMap + or FlightPassVersion ~= PassVersion + or FlightMarkMinHeight ~= self.FlightMinObstacleHeight + or FlightMarkObjRadius ~= self.FlightObjRadius then + return + end + return FlightIsMarked(FlightArea, FlightMarkFrom, FlightMarkTo, FlightMarkBorder, self, flight_target, mark_border) +end + +function FlightGetHeightAt(...) + return FlightGetHeight(FlightMap, FlightArea, ...) +end + +---- + +DefineClass("FlyingMovableAutoResolve") + +DefineClass.FlyingMovable = { + __parents = { "FlyingObj", "Movable", "FlyingMovableAutoResolve" }, + properties = { + { category = "Flight", id = "FlightPlanning", name = "Flight Planning", editor = "bool", default = false, template = true, help = "Complex flight planning" }, + { category = "Flight", id = "FlightMaxFailures", name = "Flight Plan Max Failures", editor = "number", default = 5, template = true, help = "How many times the flight plan can fail before giving up", no_edit = PropChecker("FlightPlanning", false) }, + { category = "Flight", id = "FlightFailureCooldown", name = "Flight Failure Cooldown", editor = "number", default = 333, template = true, scale = "sec", help = "How often the flight plan can fail before giving up", no_edit = PropChecker("FlightPlanning", false) }, + { category = "Flight", id = "FlightMaxWalkDist", name = "Max Walk Dist", editor = "number", default = 32 * guim, scale = "m", template = true, help = "Defines the max area where to use walking"}, + { category = "Flight", id = "FlightMinDist", name = "Min Flight Dist", editor = "number", default = 16 * guim, scale = "m", template = true, help = "Defines the min distance to use flying"}, + { category = "Flight", id = "FlightWalkExcess", name = "Walk To Fly Excess", editor = "number", default = 30, scale = "%", min = 0, template = true, help = "How much longer should be the walk path to prefer flying", }, + { category = "Flight", id = "FlightIsHovering", name = "Is Hovering", editor = "bool", default = false, template = true, help = "Is the walking above the ground" }, + }, + flying = false, + flight_stop_on_passable = true, + + flight_pf_ready = false, -- pf path found + flight_landed = false, + flight_land_pos = false, -- land pos found + flight_land_retry = -1, + flight_land_target_pos = false, + flight_takeoff_pos = false, -- take-off pos found + flight_takeoff_retry = -1, + flight_start_velocity = false, + + flight_plan_failed = 0, + flight_plan_failures = 0, + flight_plan_force_land = true, + + FlightSimHeightRestrict = const.FlightRestrictAboveWalkable, + + OnFlyingChanged = empty_func, + CanTakeOff = return_true, +} + +function FlyingMovable:IsOnPassable() + return terrain.FindPassableZ(self, 0, 0) +end + +function FlyingMovable:OnMoved() + if self.flying and terrain.FindPassableZ(self, 0, 0) then + self:SetFlying(false) + end +end + +function FlyingMovable:SetFlying(flying) + flying = flying or false + if self.flying == flying then + return + end + self:SetAnimSpeed(1, 1000) + if not flying then + self:ClearFlightPath() + self:SetAcceleration(0) + self:ResetOrientation(0) + self:UnlockFlightDest() + self:SetEnumFlags(efResting) + else + pf.ClearPath(self) + assert(self:GetPathPointCount() == 0) + self:SetGravity(0) + self:SetCurvature(false) + self:ClearEnumFlags(efResting) + local start_velocity = self.flight_start_velocity + if start_velocity then + if start_velocity == point30 then + self:StopInterpolation() + else + self:SetPos(self:GetVisualPos() + start_velocity, 1000) + end + self.flight_start_velocity = nil + end + end + self.flying = flying + self:OnFlyingChanged(flying) +end + +FlyingMovable.OnFlyingChanged = empty_func + +function FlyingMovableAutoResolve:OnStopMoving(pf_status) + if self.flying then + if pf_status and IsExactlyOnPassableLevel(self) then + -- fix flying status after landing for not planned paths + self:SetFlying(false) + else + self:ClearFlightPath() + end + end + self.flight_pf_ready = nil + self.flight_landed = nil + self.flight_land_pos = nil + self.flight_land_target_pos = nil + self.flight_takeoff_pos = nil + self.flight_start_velocity = nil + self.flight_takeoff_retry = nil + self.flight_land_retry = nil + self.FlightPlanning = nil +end + +local function CanFlyToFilter(x, y, z, self) + return self:CanFlyTo(x, y, z) +end + +function FlyingMovable:FindLandingPos(flight_dests) + if not next(flight_dests) then + return + end + self:MarkFlightArea(flight_dests[#flight_dests]) + local count = Min(4, #flight_dests) + for i=1,count do + local land_pos = FlightFindLandingAround(flight_dests[i], self, dest_search_dist) + if land_pos then + assert(IsPosOutside(land_pos)) + return land_pos + end + end + local land_pos = FlightFindReachableLanding(flight_dests, self) + if land_pos then + return land_pos + end + local has_passable + for _, pt in ipairs(flight_dests) do + if self:CheckPassable(pt) then + if self:CanFlyTo(pt) then + return pt + end + has_passable = true + end + end + if not has_passable then + return + end + for _, pt in ipairs(flight_dests) do + local land_pos = terrain.FindReachable(pt, + tfrPassClass, self, + tfrCanDestlock, self, + tfrLimitDist, max_search_dist, 0, + tfrLuaFilter, CanFlyToFilter, self) + if land_pos then + return land_pos + end + end +end + +function FlyingMovable:FindTakeoffPos() + self:MarkFlightAround(self, max_takeoff_dist) + --DbgClear(true) DbgAddCircle(self, max_takeoff_dist) FlightDbgShow{ show_flight_map = true } + + local takeoff_pos, takeoff_reached = FlightFindLandingAround(self, self, max_search_dist) + if not takeoff_pos then + takeoff_pos, takeoff_reached = FlightFindReachableLanding(self, self, "takeoff", max_takeoff_dist) + if not takeoff_pos and self:CanTakeOff() then + takeoff_pos, takeoff_reached = self, true + end + end + assert(IsPosOutside(takeoff_pos)) + return takeoff_pos, takeoff_reached +end + +function FlyingMovable:IsShortPath(walk_excess, max_walk_dist, min_flight_dist) + if self:IsPathPartial() then + return + end + local last = self:GetPathPointCount() > 0 and self:GetPathPoint(1) + if not last then + return true + end + local dist = pf.GetLinearDist(self, last) + if max_walk_dist and dist > max_walk_dist then + return + end + local short_path_len = Max(min_flight_dist or 0, Min(max_walk_dist or max_int, dist * (100 + (walk_excess or 0)) / 100)) + local ignore_tunnels = true + local path_len = self:GetPathLen(1, short_path_len, ignore_tunnels) + return path_len <= short_path_len +end + +function FlyingMovable:Step(dest, ...) + local flight_planning = self.FlightPlanning + if self.flying then + if not flight_planning or self.flight_land_retry > GameTime() then + return FlyingObj.Step(self, dest, ...) + end + local moving_target = IsValid(dest) and dest:TimeToPosInterpolationEnd() > 0 + if moving_target and self:CanFlyTo(dest) then + self.flight_land_pos = nil + return FlyingObj.Step(self, dest, ...) + end + local land_pos = self.flight_land_pos + if land_pos and moving_target then + local prev_target_pos = self.flight_land_target_pos + if not prev_target_pos or not dest:IsCloser(prev_target_pos, self.FlightMaxWalkDist / 2) then + land_pos = false + end + end + if not land_pos then + local dests = pf.ResolveGotoDests(self, dest, ...) + if not dests then + return pfFailed + end + land_pos = self:FindLandingPos(dests) + if not land_pos then + if self.flight_plan_force_land then + return pfFailed + end + self:SetAdjustFlightTarget(true) + self.flight_land_retry = GameTime() + 10000 -- try continue walking + return FlyingObj.Step(self, dest, ...) + end + self.flight_land_pos = land_pos + self.flight_land_retry = nil + self.flight_land_target_pos = moving_target and dest:GetVisualPos() + --DbgAddVector(land_pos, 10*guim, blue) DbgAddSegment(land_pos, self, blue) + end + local status = FlyingObj.Step(self, land_pos) + if status == pfFinished then + self.flight_land_pos = nil + self.flight_landed = true + self:SetFlying(false) + return self:Step(dest, ...) + end + return status + end + local walk_excess = self.FlightWalkExcess + if not walk_excess then + return Movable.Step(self, dest, ...) + end + local tx, ty, tz, max_range, min_range, dist, sl = self:ResolveFlightTarget(dest, ...) + if sl then + return Movable.Step(self, dest, ...) + end + if not tx then + return pfFailed + end + local max_walk_dist, min_flight_dist = self.FlightMaxWalkDist, self.FlightMinDist + if not self.FlightPlanning then + local flight_pf_ready = self.flight_pf_ready + local can_fly_to = self:CanFlyTo(tx, ty, tz) + if not flight_pf_ready and max_walk_dist and can_fly_to then + -- no flight planning: restrict the pf to find a path only if close enough + if dist > max_walk_dist then + self:SetFlying(true) + return self:Step(dest, ...) + end + self:RestrictArea(max_walk_dist) -- if the pf fails then force flying + end + self.flight_pf_ready = true + local status, new_path = Movable.Step(self, dest, ...) + if status == pfFinished or not can_fly_to or (status >= 0 or status == pfTunnel) and self:IsShortPath(walk_excess, max_walk_dist, min_flight_dist) then + return status + end + self:SetFlying(true) + return self:Step(dest, ...) + end + if self.flight_landed or self.flight_takeoff_retry > GameTime() then + return Movable.Step(self, dest, ...) + end + self.flight_start_velocity = self:GetVelocityVector(-1) + local takeoff_pos = self.flight_takeoff_pos + local takeoff_reached + if not takeoff_pos then + local pf_step = true + local flight_pf_ready = self.flight_pf_ready + if self:CheckPassable() then + if not flight_pf_ready then + pf_step = max_range == 0 and ConnectivityCheck(self, dest, ...) + else + pf_step = self:IsShortPath(walk_excess, max_walk_dist, min_flight_dist) + end + end + if pf_step then + self.flight_pf_ready = true + return Movable.Step(self, dest, ...) + end + takeoff_pos, takeoff_reached = self:FindTakeoffPos() + if not takeoff_pos then + self.flight_takeoff_retry = GameTime() + 10000 -- stop searching takeoff location and continue walking + --DbgDrawPath(self, yellow) + return self:Step(dest, ...) + elseif not takeoff_reached then + -- TODO: if the takeoff path + landing path is not quite shorter than the pf path ignore the flight + self.flight_pf_ready = nil + self.flight_takeoff_pos = takeoff_pos + --DbgAddVector(takeoff_pos, 10*guim, green) DbgAddSegment(takeoff_pos, self, green) + end + end + if not takeoff_reached then + local status = Movable.Step(self, takeoff_pos) + if status ~= pfFinished then + return status + end + end + self.flight_takeoff_pos = nil + if not terrain.IsPassable(tx, ty, tz, 0) then + -- the destination cannot be reached by walking + if not self:CanFlyTo(tx, ty, tz) then + return pfFailed + end + self:SetFlying(true) + else + local dests = pf.ResolveGotoDests(self, dest, ...) + local land_pos = self:FindLandingPos(dests) + if not land_pos or self:IsCloserWalkDist(land_pos, min_flight_dist) then + self.flight_takeoff_retry = GameTime() + 10000 -- try continue walking + else + self.flight_land_pos = land_pos + self:SetFlying(true) + end + end + return self:Step(dest, ...) +end + +function FlyingMovable:TryContinueMove(status, ...) + if status == pfFinished then + return + end + if not self.FlightPlanning then + return Movable.TryContinueMove(self, status, ...) + end + local success = Movable.TryContinueMove(self, status, ...) + if success then + return true + end + local take_off + if self.flying then + if not self.flight_land_pos then + return + end + self.flight_land_pos = nil -- try finding another land pos? + elseif self.flight_landed then + self.flight_landed = nil -- try to take-off again + elseif self.flight_takeoff_pos then + self.flight_takeoff_pos = nil -- try to find a new take-off position + elseif self:CanTakeOff() and (status ~= pfDestLocked or pf.GetLinearDist(self, ...) >= FlightTile) then + take_off = true + else + return + end + local time = GameTime() + if time - self.flight_plan_failed > self.FlightFailureCooldown then + self.flight_plan_failures = nil + elseif self.flight_plan_failures < self.FlightMaxFailures then + self.flight_plan_failures = self.flight_plan_failures + 1 + else + return -- give up + end + self.flight_plan_failed = time + if take_off then + self:TakeOff() + end + return true +end + +function FlyingMovable:ClearPath() + if self.flying then + return self:ClearFlightPath() + end + return Movable.ClearPath(self) +end + +function FlyingMovable:GetPathHash(seed) + if self.flying then + return FlyingObj.GetPathHash(self, seed) + end + return Movable.GetPathHash(self, seed) +end + +function FlyingMovable:LockFlightDest(x, y, z) + local visual_z = ResolveZ(x, y, z) + if not visual_z then + return + end + -- TODO: fying destlocks + if self.outside_pathfinder + or not self:IsCloser(x, y, visual_z, pfSmartDestlockDist) + or not self:CheckPassable(x, y, z) + or PlaceDestlock(self, x, y, z) then + return x, y, z + end + local flight_target = self.flight_target + if not flight_target or flight_target:Equal(x, y, z) or not PlaceDestlock(self, flight_target) then + -- previous target cannot be destlocked as well + flight_target = terrain.FindReachable(x, y, z, + tfrPassClass, self, + tfrCanDestlock, self) + if not flight_target then + return + end + local destlocked = PlaceDestlock(self, flight_target) + assert(destlocked) + end + return flight_target:xyz() +end + +function FlyingMovable:UnlockFlightDest() + if IsValid(self) then + return self:RemoveDestlock() + end +end + +function FlyingMovable:TryLand() + if not self.flying then + return + end + local z = terrain.FindPassableZ(self, 32*guim) -- TODO: should go to a suitable height first + if not z then + return + end + self:ClearPath() + local visual_z = z == InvalidZ and terrain.GetHeight(self) or z + local x, y, z0 = self:GetVisualPosXYZ() + local anim = self.FlightAnimStop + local dt = anim and self:GetAnimDuration(anim) or 0 + if dt > 0 then + self:SetState(anim) + else + dt = 1000 + end + self:SetPos(x, y, visual_z, dt) + self:SetAcceleration(0) + self:ResetOrientation(dt) + self:SetAnimSpeed(1, 1000) + self:SetFlying(false) +end + +function FlyingMovable:TryTakeOff() + self:TakeOff() + return true +end + +function FlyingMovable:TakeOff() + if self.flying then + return + end + self:ClearPath() + local x, y, z0 = self:GetVisualPosXYZ() + local z = z0 + self.FlightSimHeightMin + local anim = self.FlightAnimStart + local dt = anim and self:GetAnimDuration(anim) or 0 + if dt > 0 then + self:SetState(anim) + else + dt = 1000 + end + self:SetPos(x, y, z, dt) + self:SetAcceleration(0) + self:SetFlying(true) + return dt +end + +function FlyingMovable:Face(target, time) + if self.flying then + return FlyingObj.Face(self, target, time) + end + return Movable.Face(self, target, time) +end + +---- + +local efFlightObstacle = const.efFlightObstacle + +DefineClass.FlightObstacle = { + __parents = { "CObject" }, + flags = { cofComponentFlightObstacle = true, efFlightObstacle = true }, + FlightInitObstacle = FlightInitBox, +} + +function FlightObstacle:InitElementConstruction() + self:ClearEnumFlags(efFlightObstacle) +end + +function FlightObstacle:CompleteElementConstruction() + if self:GetComponentFlags(const.cofComponentFlightObstacle) == 0 then + return + end + self:SetEnumFlags(efFlightObstacle) + self:FlightInitObstacle() +end + +function FlightObstacle:OnMoved() + self:FlightInitObstacle() +end + +---- + +function FlightInitGrids() + local flight_map, energy_map = FlightMap, FlightEnergy + if not flight_map then + flight_map, energy_map = FlightCreateGrids(mapdata.PassBorder) + FlightMap, FlightEnergy = flight_map, energy_map + end + return flight_map, energy_map +end + +local test_box = box() + +function FlightMarkBetween(ptFrom, ptTo, min_height, obj_radius, mark_border) + min_height = min_height or 0 + obj_radius = obj_radius or 0 + local marked + local flight_area = FlightArea + local now = GameTime() + + if now ~= FlightTimestamp + or not flight_area + or FlightPassVersion ~= PassVersion + or FlightMarkMinHeight ~= min_height + or FlightMarkObjRadius ~= obj_radius + or not FlightIsMarked(flight_area, FlightMarkFrom, FlightMarkTo, FlightMarkBorder, ptFrom, ptTo, mark_border) then + local flight_border + local flight_map = FlightInitGrids() + --local st = GetPreciseTicks() + flight_area, flight_border = FlightMarkObstacles(flight_map, ptFrom, ptTo, min_height, obj_radius, mark_border) + if not flight_area then + return + end + --print("FlightMarkObstacles", GetPreciseTicks() - st) + FlightEnergyMin = false -- mark the energy map as invalid + FlightMarkMinHeight, FlightMarkObjRadius = min_height, obj_radius + FlightMarkFrom, FlightMarkTo = ResolveVisualPos(ptFrom), ResolveVisualPos(ptTo) + FlightArea = flight_area or false + FlightTimestamp = now + FlightPassVersion = PassVersion + FlightMarkBorder = flight_border + marked = true + end + --dbg(FlightDbgMark(ptFrom, ptTo)) + return flight_area, marked +end + +function FlightCalcEnergyTo(ptTo, flight_area, slope_penalty, grow_obstacles) + flight_area = flight_area or FlightArea + slope_penalty = slope_penalty or 0 + grow_obstacles = grow_obstacles or false + if not FlightEnergyMin + or FlightArea ~= flight_area + or FlightSlopePenalty ~= slope_penalty + or FlightGrowObstacles ~= grow_obstacles + or not FlightEnergyMin:Equal2D(GameToFlight(ptTo)) then + --local st = GetPreciseTicks() + FlightEnergyMin = FlightCalcEnergy(FlightMap, FlightEnergy, ptTo, flight_area, slope_penalty, grow_obstacles) or false + FlightSlopePenalty = slope_penalty + FlightGrowObstacles = grow_obstacles + --print("FlightCalcEnergy", GetPreciseTicks() - st) + if not FlightEnergyMin then + return + end + end + return FlightEnergyMin +end + +function FlightCalcPathBetween(ptFrom, ptTo, flags, min_height, obj_radius, slope_penalty, smooth_dist, range, debug_iter) + assert(ptTo and terrain.IsPointInBounds(ptTo, mapdata.PassBorder)) + --local st = GetPreciseTicks() + local flight_area, marked = FlightMarkBetween(ptFrom, ptTo, min_height, obj_radius) + if not flight_area then + return + end + local grow_obstacles = smooth_dist and IsCloser2D(ptFrom, ptTo, smooth_dist) + if not FlightCalcEnergyTo(ptTo, flight_area, slope_penalty, grow_obstacles) then + return + end + flags = flags or flight_default_flags + range = range or 0 + assert(flags ~= 0) + FlightFrom, FlightTo, FlightFlags, FlightSmoothDist, FlightDestRange = ptFrom, ptTo, flags, smooth_dist, range + return FlightFindPath(ptFrom, ptTo, FlightMap, FlightEnergy, flight_area, flags, range, debug_iter) +end + +---- + +function FlightInitObstacles() + local _, max_surf_radius = GetMapMaxObjRadius() + local ebox = GetPlayBox():grow(max_surf_radius) + MapForEach(ebox, efFlightObstacle, function(obj) + return obj:FlightInitObstacle() + end) +end + +function FlightInitObstaclesList(objs) + local GetEnumFlags = CObject.GetEnumFlags + for _, obj in ipairs(objs) do + if GetEnumFlags(obj, efFlightObstacle) ~= 0 then + obj:FlightInitObstacle(obj) + end + end +end + +function OnMsg.NewMap() + SuspendProcessing("FlightInitObstacle", "MapLoading", true) +end + +function OnMsg.PostNewMapLoaded() + ResumeProcessing("FlightInitObstacle", "MapLoading", true) + if not mapdata.GameLogic then + return + end + FlightInitObstacles() +end + +function OnMsg.PrefabPlaced(name, objs) + if not mapdata.GameLogic or IsProcessingSuspended("FlightInitObstacle") then + return + end + FlightInitObstaclesList(objs) +end + +function FlightInvalidatePaths(box) + local CheckPassable = pf.CheckPassable + local IsPosOutside = IsPosOutside or return_true + local Point2DInside = box and box.Point2DInside or return_true + local FlightPathIntersectEst = FlightPathIntersectEst + for _, obj in ipairs(FlyingObjs) do + local flight_path = obj.flight_path + if flight_path and #flight_path > 0 and (not box or FlightPathIntersectEst(flight_path, box, obj.flight_spline_idx)) then + obj.flight_path = nil + end + local flight_land_pos = obj.flight_land_pos + if flight_land_pos and Point2DInside(box, flight_land_pos) then + if not CheckPassable(obj, flight_land_pos) or not IsPosOutside(flight_land_pos) then + obj.flight_land_pos = nil + end + end + local flight_takeoff_pos = obj.flight_takeoff_pos + if flight_takeoff_pos and Point2DInside(box, flight_takeoff_pos) then + if not CheckPassable(obj, flight_takeoff_pos) or not IsPosOutside(flight_takeoff_pos) then + obj.flight_takeoff_pos = nil + end + end + end +end + +OnMsg.OnPassabilityChanged = FlightInvalidatePaths + +---- + +function GetSplineParams(start_pos, start_speed, end_pos, end_speed) + local v0 = start_speed:Len() + local v1 = end_speed:Len() + local dist = start_pos:Dist(end_pos) + assert((v0 > 0 or v1 > 0) and (v0 >= 0 and v1 >= 0)) + assert(dist >= 3) + local pa = (dist >= 3 and v0 > 0) and (start_pos + SetLen(start_speed, dist / 3)) or start_pos + local pb = (dist >= 3 and v1 > 0) and (end_pos - SetLen(end_speed, dist / 3)) or end_pos + local spline = { start_pos, pa, pb, end_pos } + local len = Max(BS3_GetSplineLength3D(spline), 1) + local time_est = MulDivRound(1000, 2 * len, v1 + v0) + return spline, len, v0, v1, time_est +end + +function WaitFollowSpline(obj, spline, len, v0, v1, step_time, min_step, max_step, orient, yaw_to_roll_pct) + if not IsValid(obj) then + return + end + len = len or S3_GetSplineLength3D(spline) + v0 = v0 or obj:GetVelocityVector() + v1 = v1 or v0 + step_time = step_time or 50 + min_step = min_step or Max(1, len/100) + max_step = max_step or Max(min_step, len/10) + local roll, pitch, yaw, yaw0 = 0 + if orient and (yaw_to_roll_pct or 0) ~= 0 then + roll, pitch, yaw0 = obj:GetRollPitchYaw() + end + local v = v0 + local dist = 0 + while true do + local step = Clamp(step_time * v / 1000, min_step, max_step) + dist = dist + step + if dist > len - step / 2 then + dist = len + end + local x, y, z, dirx, diry, dirz = BS3_GetSplinePosDir(spline, dist, len) + v = v0 + (v1 - v0) * dist / len + local accel, dt = obj:GetAccelerationAndTime(x, y, z, v) + if orient then + pitch, yaw = GetPitchYaw(dirx, diry, dirz) + if yaw0 then + roll = 10 * AngleDiff(yaw, yaw0) * yaw_to_roll_pct / dt + yaw0 = yaw + end + obj:SetRollPitchYaw(roll, pitch, yaw, dt) + end + obj:SetPos(x, y, z, dt) + obj:SetAcceleration(accel) + if dist == len then + Sleep(dt) + break + end + Sleep(dt - dt/10) + end + if IsValid(obj) then + obj:SetAcceleration(0) + end +end + +local tfpLanding = const.tfpPassClass | const.tfpCanDestlock | const.tfpLimitDist | const.tfpLuaFilter + +function FlightFindLandingAround(pos, unit, max_radius, min_radius) + local flight_map, flight_area = FlightMap, FlightArea + local landing, valid = FlightIsLandingPos(pos, flight_map, flight_area) + if not valid then + return + end + max_radius = max_radius or max_search_dist + min_radius = min_radius or 0 + if not unit:CheckPassable(pos) then + return terrain.FindPassableTile(pos, tfpLanding, max_radius, min_radius, unit, unit, FlightIsLandingPos, flight_map, flight_area) + end + if min_radius <= 0 and landing then + if not unit or unit:CheckPassable(pos, true) then + return pos, true + end + end + --DbgAddCircle(pt, FlightTile, red) DbgAddVector(pt, guim, red) + return terrain.FindReachable(pos, + tfrPassClass, unit, + tfrCanDestlock, unit, + tfrLimitDist, max_radius, min_radius, + tfrLuaFilter, FlightIsLandingPos, flight_map, flight_area) +end + +function FlightFindReachableLanding(target, unit, takeoff, radius) + local flight_map = FlightMap + if not flight_map then + return + end + local pfclass = unit and unit:GetPfClass() or 0 + local max_dist, min_dist = radius or max_int, 0 + local x, y, z, reached = FlightFindLanding(flight_map, target, max_dist, min_dist, unit, ConnectivityCheck, target, pfclass, 0, takeoff) + if not x then + return + end + assert(IsPosOutside(x, y, z)) + local landing = point(x, y, z) + if reached then + return landing, true + end + local src, dst + if takeoff then + src, dst = target, landing + else + src, dst = landing, target + end + local path, has_path = pf.GetPosPath(src, dst, pfclass) + if not path or not has_path then + return + end + local i1, i2, di + if takeoff then + i1, i2, di = #path - 1, 2, -1 + else + i1, i2, di = 2, #path - 1, 1 + end + local last_pt + for i=i1,i2,di do + local pt = path[i] + if not pt then break end + if IsValidPos(pt) then + local found = FlightFindLandingAround(pt, unit, step_search_dist) + --DbgAddVector(pt, guim, found and green or red) DbgAddCircle(pt, step_search_dist, found and green or red) DbgAddSegment(pt, last_pt or pt) DbgAddSegment(pt, found or pt, green) + if found then + assert(IsPosOutside(found)) + landing = found + break + end + last_pt = pt + end + end + return landing +end + +---- + +function FlyingObj:CheatRecalcPath() + self:RecalcFlightPath() +end \ No newline at end of file diff --git a/CommonLua/Classes/FluidGrid.lua b/CommonLua/Classes/FluidGrid.lua new file mode 100644 index 0000000000000000000000000000000000000000..c09393feff4cbfb4dc08bfa5882eb3aef0e7eb8e --- /dev/null +++ b/CommonLua/Classes/FluidGrid.lua @@ -0,0 +1,833 @@ +local remove_entry = table.remove_entry +local max_grid_updates = 10 +local MulDivRound = MulDivRound +local Min, Max, Clamp = Min, Max, Clamp +local HighestConsumePriority = config.FluidGridHighestConsumePriority or 1 +local LowestConsumePriority = config.FluidGridLowestConsumePriority or 1 +local BeyondHighestConsumePriority = HighestConsumePriority - 1 + + +----- FluidGrid + +DefineClass.FluidGrid = { + __parents = { "InitDone" }, + grid_resource = "electricity", + player = false, + -- arrays with the various elements for faster access + elements = false, -- all elements + producers = false, + consumers = false, + storages = false, + switches = false, + -- smart connections + smart_connections = 0, + -- aggregated for the entire grid + total_production = 0, + total_throttled_production = 0, + total_consumption = 0, + total_variable_consumption = 0, + total_charge = 0, + total_discharge = 0, + total_storage_capacity = 0, + -- current + current_storage_delta = 0, + current_production = 0, + current_production_delta = 0, + current_throttled_production = 0, + current_consumption = 0, + current_variable_consumption = 0, + current_storage = 0, + -- visuals + visual_mesh = false, + visuals_thread = false, + needs_visual_update = false, + -- update + update_thread = false, + needs_update = false, + update_consumers = false, + consumers_supplied = false, -- can be false or a priority index - consumers with higher or same priority are supplied + -- produce tick + production_thread = false, + production_interval = config.FluidGridProductionInterval or 10000, -- how often runs the production logic + restart_supply_delay = config.FluidGridRestartDelay or 10000, -- after turning off, the grid will wait this amount of time to start in order to avoid cyclical grid restarts + restart_supply_time = 0, + + LogChangedElements = empty_func, +} + +function FluidGrid:Init() + self.elements = {} + self.producers = {} + self.consumers = {} + assert(HighestConsumePriority <= LowestConsumePriority) + for priority = HighestConsumePriority, LowestConsumePriority do + self.consumers[priority] = { consumption = 0, variable_consumption = 0 } + end + self.storages = {} + self.switches = {} + self:RestartThreads() +end + +function FluidGrid:RestartThreads() + DeleteThread(self.update_thread) + self.update_thread = CreateGameTimeThread(function(self) + local updates, last_element_count, last_update = 0, #self.elements + while true do + local now = GameTime() + local elem_count = #self.elements + if last_update ~= now or elem_count ~= last_element_count then + last_update = now + last_element_count = elem_count + updates = 0 + end + while self.needs_update do + if updates == max_grid_updates then + self:LogChangedElements() + assert(false, "Infinite grid update recursion!") + break + end + self.needs_update = false + procall(self.UpdateGrid, self) + updates = updates + 1 + end + WaitWakeup() + end + end, self) + + DeleteThread(self.production_thread) + self.production_thread = CreateGameTimeThread(function(self, production_interval) + while true do + Sleep(production_interval) + procall(self.Production, self, production_interval) + end + end, self, self.production_interval) + + DeleteThread(self.visuals_thread) + self.visuals_thread = CreateGameTimeThread(function() + while true do + while self.needs_visual_update do + self.needs_visual_update = false + procall(self.UpdateVisuals, self) + end + WaitWakeup() + end + end) +end + +function FluidGrid:Done() + local grid_resource = self.grid_resource + for priority = HighestConsumePriority, LowestConsumePriority do + for _, consumer in ipairs(self.consumers[priority]) do + if consumer.current_consumption > 0 and consumer.grid == self then + local old = consumer.current_consumption + consumer.current_consumption = 0 + consumer.owner:SetConsumption(grid_resource, old, 0) + end + end + end + for _, element in ipairs(self.elements) do + if element.grid == self then + element.grid = false + end + end + DeleteThread(self.update_thread) + self.update_thread = false + DeleteThread(self.production_thread) + self.production_thread = false + DeleteThread(self.visuals_thread) + self.visuals_thread = false + DoneObject(self.visual_mesh) + self.visual_mesh = false + Msg("FluidGridDestroyed", self) +end + +function FluidGrid:AddElement(element, skip_update) + element.grid = self + self.elements[#self.elements + 1] = element + if element.production then + self.total_production = self.total_production + element.production + self.total_throttled_production = self.total_throttled_production + element.throttled_production + self.producers[#self.producers + 1] = element + end + local consumption = element.consumption + if consumption then + local consumer_list = self.consumers[element.consume_priority] + if element.variable_consumption then + self.total_variable_consumption = self.total_variable_consumption + consumption + consumer_list.variable_consumption = consumer_list.variable_consumption + consumption + else + self.total_consumption = self.total_consumption + consumption + consumer_list.consumption = consumer_list.consumption + consumption + end + consumer_list[#consumer_list + 1] = element + end + if element.charge then + self.total_charge = self.total_charge + element.charge + self.total_discharge = self.total_discharge + element.discharge + if element.discharge > 0 then + self.current_storage = self.current_storage + element.current_storage + end + self.total_storage_capacity = self.total_storage_capacity + element.storage_capacity + self.storages[#self.storages + 1] = element + end + if element.is_switch then + self.switches[#self.switches + 1] = element + self:UpdateSmartConnections() + end + + if not skip_update then + self:DelayedUpdateGrid(consumption) + self:DelayedUpdateVisuals() + end + Msg("FluidGridAddElement", self, element) +end + +function FluidGrid:RemoveElement(element, skip_update) + if element.grid ~= self then return end + if element.current_consumption > 0 then + local old = element.current_consumption + element.current_consumption = 0 + element.owner:SetConsumption(self.grid_resource, old, 0) + end + element.grid = false + remove_entry(self.elements, element) + if element.production then + self.total_production = self.total_production - element.production + self.total_throttled_production = self.total_throttled_production - element.throttled_production + remove_entry(self.producers, element) + end + local consumption = element.consumption + if consumption then + local consumer_list = self.consumers[element.consume_priority] + if element.variable_consumption then + self.total_variable_consumption = self.total_variable_consumption - consumption + consumer_list.variable_consumption = consumer_list.variable_consumption - consumption + else + self.total_consumption = self.total_consumption - consumption + consumer_list.consumption = consumer_list.consumption - consumption + end + remove_entry(consumer_list, element) + end + if element.current_storage then + self.total_charge = self.total_charge - element.charge + self.total_discharge = self.total_discharge - element.discharge + if element.discharge > 0 then + self.current_storage = self.current_storage - element.current_storage + end + self.total_storage_capacity = self.total_storage_capacity - element.storage_capacity + remove_entry(self.storages, element) + end + if element.is_switch then + remove_entry(self.switches, element) + self:UpdateSmartConnections() + end + + if #(self.elements or "") == 0 then + self:delete() + return + end + if not skip_update then + self:DelayedUpdateGrid() + self:DelayedUpdateVisuals() + end + Msg("FluidGridRemoveElement", self, element) +end + +function FluidGrid:CountConsumers(func, ...) + local count = 0 + func = func or return_true + for priority = HighestConsumePriority, LowestConsumePriority do + for _, consumer in ipairs(self.consumers[priority]) do + if func(consumer, ...) then + count = count + 1 + end + end + end + return count +end + +function FluidGrid:DelayedUpdateGrid(update_consumers) + self.update_consumers = self.update_consumers or update_consumers + self.needs_update = true + Wakeup(self.update_thread) +end + +function FluidGrid:UpdateGrid(update_consumers) + update_consumers = self.update_consumers or update_consumers + self.update_consumers = false + local total_production = self.total_production + local total_discharge = self.total_discharge + local current_consumption = 0 + local current_variable_consumption = 0 + local consumers_supplied = false + -- limit restarting supply to lower priority consumers for some time + local ConsumePriorityLimit = (GameTime() - self.restart_supply_time < 0) and (self.consumers_supplied or BeyondHighestConsumePriority) or LowestConsumePriority + -- find out which priority consumers can consume + local total_supply = total_production + total_discharge + for priority = HighestConsumePriority, ConsumePriorityLimit do + local consumer_list = self.consumers[priority] + -- consumers of certain priority will consume only if all consumption and variable consumtion of higher priorities can be supplied + assert(consumer_list) + if #(consumer_list or "") > 0 and current_consumption + current_variable_consumption + consumer_list.consumption <= total_supply then + consumers_supplied = priority + current_consumption = current_consumption + current_variable_consumption + consumer_list.consumption + current_variable_consumption = consumer_list.variable_consumption + if current_consumption + current_variable_consumption >= total_supply then -- all supply is used + current_variable_consumption = Min(current_variable_consumption, total_supply - current_consumption) + break + end + end + end + assert(current_consumption <= self.total_consumption) + current_consumption = current_consumption + current_variable_consumption + assert(current_consumption <= total_supply) + local storage_delta = Clamp(total_production - current_consumption, - total_discharge, self.total_charge) + self.current_throttled_production = Min(total_production - current_consumption - storage_delta, self.total_throttled_production) + self.current_storage_delta = storage_delta + self.current_consumption = current_consumption + self.current_production = total_production - self.current_throttled_production + self.current_production_delta = self.current_production - current_consumption + + local old_consumers_supplied = self.consumers_supplied + local old_current_variable_consumption = self.current_variable_consumption + self.consumers_supplied = consumers_supplied + self.current_variable_consumption = current_variable_consumption + if consumers_supplied ~= old_consumers_supplied then + update_consumers = true + if (consumers_supplied or BeyondHighestConsumePriority) < (old_consumers_supplied or BeyondHighestConsumePriority) then -- degradation of supply + self.restart_supply_time = GameTime() + self.restart_supply_delay + end + Msg("FluidGridConsumersSupplied", self, old_consumers_supplied, consumers_supplied) + end + if old_current_variable_consumption ~= current_variable_consumption then + update_consumers = true + Msg("FluidGridVariableConsumption", self, old_consumers_supplied, consumers_supplied) + end + + if update_consumers then + local grid_resource = self.grid_resource + local consumers_variable_consumption = consumers_supplied and self.consumers[consumers_supplied].variable_consumption + consumers_supplied = consumers_supplied or BeyondHighestConsumePriority + for priority = HighestConsumePriority, LowestConsumePriority do + for _, consumer in ipairs(self.consumers[priority]) do + local consumption = priority > consumers_supplied and 0 -- lower priority than supplied + or priority < consumers_supplied and consumer.consumption -- full supply to higher priority than supplied + or consumer.variable_consumption and MulDivRound(consumer.consumption, current_variable_consumption, consumers_variable_consumption) or consumer.consumption + local old_consumption = consumer.current_consumption + if old_consumption ~= consumption then + consumer.current_consumption = consumption + consumer.owner:SetConsumption(grid_resource, old_consumption, consumption) + end + end + end + end + ObjModifiedDelayed(self) +end + +function FluidGrid:DelayedUpdateVisuals() + self.needs_visual_update = true + Wakeup(self.visuals_thread) +end + +function FluidGrid:UpdateVisuals() + local active = self.consumers_supplied + local color = active and const.PowerGridActiveColor or const.PowerGridInactiveColor + local joint_color = active and const.PowerGridActiveJointColor or const.PowerGridInactiveJointColor + local mesh_pstr = pstr("") + local pos + for i, element in ipairs(self.elements) do + local owner = element.owner + assert(IsValid(owner)) + if IsValid(owner) then + pos = pos or owner:GetPos() + owner:AddFluidGridVisuals(self.grid_resource, pos, color, joint_color, mesh_pstr) + end + end + local mesh + if #mesh_pstr > 0 then + mesh = self.visual_mesh + mesh = IsValid(mesh) and mesh or PlaceObject("Mesh") + mesh:SetDepthTest(true) + mesh:SetMesh(mesh_pstr) + mesh:SetPos(pos) + end + if self.visual_mesh ~= mesh then + DoneObject(self.visual_mesh) + self.visual_mesh = mesh + end +end + +function FluidGrid:UpdateSmartConnections() + local smart_connections = 0 + for _, switch in ipairs(self.switches) do + smart_connections = smart_connections | switch.switch_mask + end + if self.smart_connections == smart_connections then return end + local changed_connections = self.smart_connections ~ smart_connections + self.smart_connections = smart_connections + local grid_resource = self.grid_resource + for _, producer in ipairs(self.producers) do + if (producer.smart_connection or 0) & changed_connections ~= 0 then + producer.owner:SmartConnectionChange(grid_resource) + end + end + for priority = HighestConsumePriority, LowestConsumePriority do + for _, consumer in ipairs(self.consumers[priority]) do + if (consumer.smart_connection or 0) & changed_connections ~= 0 then + consumer.owner:SmartConnectionChange(grid_resource) + end + end + end +end + +function FluidGrid:IsSmartConnectionOn(smart_connection) + if not smart_connection then return true end -- no smart_connection set, so it is on + return (self.smart_connections & smart_connection) ~= 0 +end + +function FluidGrid:Production(production_interval) + local grid_resource = self.grid_resource + -- producers + local current_throttled_production = self.current_throttled_production + local total_throttled_production = self.total_throttled_production + for _, producer in ipairs(self.producers) do + producer.current_throttled_production = total_throttled_production > 0 + and MulDivRound(producer.throttled_production, current_throttled_production, total_throttled_production) or 0 + local production = producer.production - producer.current_throttled_production + producer.owner:OnProduce(grid_resource, production, production_interval) + end + -- consumers + for priority = HighestConsumePriority, LowestConsumePriority do + for _, consumer in ipairs(self.consumers[priority]) do + consumer.owner:OnConsume(grid_resource, consumer.current_consumption, production_interval) + end + end + -- storages + local total_charge = self.total_charge + local total_discharge = self.total_discharge + local storage_delta = self.current_storage_delta + if storage_delta > 0 and total_charge > 0 then + for _, storage in ipairs(self.storages) do + storage:AddStoredCharge(MulDivRound(storage_delta, storage.charge_efficiency * storage.charge, 100 * total_charge), self) + end + elseif storage_delta < 0 and total_discharge > 0 then + for _, storage in ipairs(self.storages) do + storage:AddStoredCharge(MulDivRound(storage_delta, storage.discharge, total_discharge), self) + end + end + if Platform.developer then + -- aggregate storage values and verify they match + local current_storage, total_charge, total_discharge = 0, 0, 0 + for _, storage in ipairs(self.storages) do + if storage.discharge > 0 then + current_storage = current_storage + storage.current_storage + end + total_charge = total_charge + storage.charge + total_discharge = total_discharge + storage.discharge + end + assert(self.current_storage == current_storage) + assert(self.total_charge == total_charge) + assert(self.total_discharge == total_discharge) + self.current_storage = current_storage + self.total_charge = total_charge + self.total_discharge = total_discharge + end + self:DelayedUpdateGrid() +end + +function MergeGrids(new_grid, grid) -- merges grid into new_grid + if grid == new_grid then return end + for i, element in ipairs(grid.elements) do + new_grid:AddElement(element) + end + grid:delete() +end + + +----- FluidGridElementOwner + +DefineClass.FluidGridElementOwner = { + __parents = { "InitDone" }, +} + +-- callback when a resource consumption from the grid is modified +AutoResolveMethods.SetConsumption = true +function FluidGridElementOwner:SetConsumption(resource, old_amount, new_amount) +end + +-- callback when storage state changes - "empty", "full", "charging", "discharging" +function FluidGridElementOwner:SetStorageState(resource, state) +end + +-- callback called each production_interval with the actual amount produced for the grid +function FluidGridElementOwner:OnProduce(resource, amount, production_interval) +end + +-- callback called each production_interval with the actual amount consumed from the grid +function FluidGridElementOwner:OnConsume(resource, amount, production_interval) +end + +-- callback called when the stored amount of a storage has changed +AutoResolveMethods.ChangeStoredAmount = true +function FluidGridElementOwner:ChangeStoredAmount(resource, storage, old_storage) +end + +-- callback called when a smart connection relevant to the grid element has changed +function FluidGridElementOwner:SmartConnectionChange(resource) +end + +-- used to construct the visual mesh for the grid +function FluidGridElementOwner:AddFluidGridVisuals(grid_resource, origin, color, joint_color, mesh_pstr) +end + + +----- FluidGridElement + +DefineClass.FluidGridElement = { + __parents = { "InitDone" }, + grid = false, -- the grid this element belongs to + owner = false, -- inherits FluidGridElementOwner + smart_connection = false, -- used by switch, consumer and producer + smart_connection2 = false, + -- producer + production = false, + throttled_production = 0, -- how much of the production can be throttled(not produced) when there is no demand for it + current_throttled_production = 0, -- how much of the production is currently being throttled + -- consumer + consumption = false, + variable_consumption = false, -- whether or not the consumer can work with any amount between 0 and consumption + current_consumption = 0, + consume_priority = config.FluidGridDefaultConsumePriority or HighestConsumePriority, + -- storage + storage_active = false, + charge = false, + discharge = false, + storage_capacity = false, + current_storage = false, + max_charge = false, + max_discharge = false, + charge_efficiency = 100, + storage_state = "", -- "empty", "charging", "discharging", "full" + min_discharge_amount = 0, -- minumum stored amount before discharging + -- is_connector + is_connector = false, + -- is_switch + is_switch = false, + switch_state = 0, -- a bitfield indicating which of our 2 smart connections are on (all combinations are valid) + switch_mask = 0, -- a bitfield indicating which grid smart connections are on + + RegisterConsumptionChange = empty_func, +} + +function NewFluidConnector(owner) + assert(IsValid(owner)) + return FluidGridElement:new{ + owner = owner, + is_connector = true, + } +end + +function NewFluidSwitch(owner, consumption, variable_consumption) + assert(IsValid(owner)) + return FluidGridElement:new{ + owner = owner, + is_switch = true, + smart_connection = 1, + switch_mask = 1, + consumption = consumption or false, + variable_consumption = variable_consumption, + } +end + +function NewFluidProducer(owner, production, throttled_production) + assert(IsValid(owner)) + return FluidGridElement:new{ + owner = owner, + production = production or 0, + throttled_production = throttled_production or 0, + } +end + +function NewFluidConsumer(owner, consumption, variable_consumption) + assert(IsValid(owner)) + return FluidGridElement:new{ + owner = owner, + consumption = consumption or 0, + variable_consumption = variable_consumption, + } +end + +function NewFluidStorage(owner, storage_capacity, current_storage, max_charge, max_discharge, charge_efficiency, min_discharge_amount) + assert(IsValid(owner)) + return FluidGridElement:new{ + owner = owner, + charge = max_charge, + discharge = 0, + current_storage = current_storage, + storage_capacity = storage_capacity, + max_charge = max_charge, + max_discharge = max_discharge, + charge_efficiency = charge_efficiency, + storage_state = "empty", + min_discharge_amount = min_discharge_amount, + } +end + +function FluidGridElement:Done() + if self.grid then + self.grid:RemoveElement(self) + self.grid = nil + end +end + +function FluidGridElement:SetProduction(new_production, new_throttled_production, skip_update) + assert(self.production) -- the element should already be a producer + new_production = Max(new_production, 0) + new_throttled_production = Max(new_throttled_production, 0) + if self.production == new_production and self.throttled_production == new_throttled_production then return end + local grid = self.grid + if grid then + grid.total_production = grid.total_production + new_production - self.production + grid.total_throttled_production = grid.total_throttled_production - self.throttled_production + new_throttled_production + end + self.production = new_production + self.throttled_production = new_throttled_production + if grid and not skip_update then + grid:DelayedUpdateGrid() + end + return true +end + +function FluidGridElement:SetConsumption(new_consumption, skip_update) + assert(self.consumption) -- the element should already be a consumer + new_consumption = Max(new_consumption, 0) + if self.consumption == new_consumption then return end + self:RegisterConsumptionChange() + local grid = self.grid + if grid then + local delta = new_consumption - self.consumption + local consumer_list = grid.consumers[self.consume_priority] + if self.variable_consumption then + grid.total_variable_consumption = grid.total_variable_consumption + delta + consumer_list.variable_consumption = consumer_list.variable_consumption + delta + else + grid.total_consumption = grid.total_consumption + delta + consumer_list.consumption = consumer_list.consumption + delta + end + end + self.consumption = new_consumption + + if grid and not skip_update then + grid:DelayedUpdateGrid(true) + end + return true +end + +function FluidGridElement:SetConsumePriority(new_priority, skip_update) + assert(self.consumption) -- the element should already be a consumer + new_priority = Clamp(new_priority, HighestConsumePriority, LowestConsumePriority) + if self.consume_priority == new_priority then return end + self:RegisterConsumptionChange() + local grid = self.grid + if grid then + local old_consumer_list = grid.consumers[self.consume_priority] + local consumer_list = grid.consumers[new_priority] + if self.variable_consumption then + old_consumer_list.variable_consumption = old_consumer_list.variable_consumption - self.consumption + consumer_list.variable_consumption = consumer_list.variable_consumption + self.consumption + else + old_consumer_list.consumption = old_consumer_list.consumption - self.consumption + consumer_list.consumption = consumer_list.consumption + self.consumption + end + remove_entry(old_consumer_list, self) + consumer_list[#consumer_list + 1] = self + end + self.consume_priority = new_priority + + if grid and not skip_update then + grid:DelayedUpdateGrid(true) + end + return true +end + + +function FluidGridElement:SetStorageCapacity(new_storage_capacity) + if self.storage_capacity == new_storage_capacity then return end + local grid = self.grid + if grid then + grid.total_storage_capacity = grid.total_storage_capacity - self.storage_capacity + Max(new_storage_capacity, 0) + end + self.storage_capacity = new_storage_capacity + if grid then + grid:DelayedUpdateGrid() + end +end + +function FluidGridElement:SetStorage(max_charge, max_discharge) + if self.max_charge == max_charge and self.max_discharge == max_discharge then return end + self.max_charge = max_charge + self.max_discharge = max_discharge + local grid = self.grid + self:UpdateStorageChargeDischarge(grid) + if grid then + grid:DelayedUpdateGrid() + end +end + +function FluidGridElement:UpdateStorageChargeDischarge(grid) + local current_storage = self.current_storage + local new_charge = Min(self.storage_capacity - current_storage, self.max_charge) + local new_discharge = self.storage_state == "charging" and current_storage < self.min_discharge_amount and 0 + or Min(current_storage, self.max_discharge) + local old_charge, old_discharge = self.charge, self.discharge + if new_charge == old_charge and new_discharge == old_discharge then return end + self.charge = new_charge + self.discharge = new_discharge + if grid then + grid.total_charge = grid.total_charge - old_charge + new_charge + grid.total_discharge = grid.total_discharge - old_discharge + new_discharge + if new_discharge ~= old_discharge then + if new_discharge == 0 then + -- remove current storage if max discharge has become 0 + grid.current_storage = grid.current_storage - current_storage + elseif old_discharge == 0 then + -- add current storage if max discharge has become > 0 + grid.current_storage = grid.current_storage + current_storage + end + end + end +end + +function FluidGridElement:AddStoredCharge(delta, grid) + assert(self.current_storage) + local storage_capacity = self.storage_capacity + local old_storage = self.current_storage + local current_storage = Clamp(old_storage + delta, 0, storage_capacity) + if current_storage == old_storage then return end + self.current_storage = current_storage + if self.discharge > 0 then + grid.current_storage = grid.current_storage + current_storage - old_storage + end + self:UpdateStorageChargeDischarge(grid) + + local state + if current_storage >= storage_capacity then + state = "full" + elseif current_storage <= 0 then + state = "empty" + elseif current_storage < old_storage then + state = "discharging" + else + state = "charging" + end + if self.storage_state ~= state then + self.storage_state = state + self.owner:SetStorageState(grid.grid_resource, state) + end + self.owner:ChangeStoredAmount(grid.grid_resource, current_storage, old_storage) +end + +function FluidGridElement:SetStoredAmount(amount) + return self:AddStoredCharge(amount - self.current_storage, self.grid) +end + +function FluidGridElement:SetSmartConnection(smart_connection_index) + local smart_connection = smart_connection_index and (1 << (smart_connection_index - 1)) or false + local old_value = self.smart_connection + if old_value == smart_connection then return end + self.smart_connection = smart_connection + self:SetSwitchState(self.switch_state) +end + +function FluidGridElement:GetSmartConnection() + local smart_connection = self.smart_connection + local result = smart_connection and LastSetBit(smart_connection) + return result and result + 1 +end + +function FluidGridElement:SetSmartConnection2(smart_connection_index) + local smart_connection2 = smart_connection_index and (1 << (smart_connection_index - 1)) or 0 + local old_value = self.smart_connection2 + if old_value == smart_connection2 then return end + self.smart_connection2 = smart_connection2 + self:SetSwitchState(self.switch_state) +end + +function FluidGridElement:GetSmartConnection2() + local smart_connection2 = self.smart_connection2 + local result = smart_connection2 and LastSetBit(smart_connection2) + return result and result + 1 +end + +function FluidGridElement:GetSwitchState() + return self.switch_state +end + +function FluidGridElement:SetSwitchState(state) + self.switch_state = state + local mask = ((state & 1 == 1) and self.smart_connection or 0) + | ((state & 2 == 2) and self.smart_connection2 or 0) + if self.switch_mask == mask then return end + self.switch_mask = mask + if self.grid then + self.grid:UpdateSmartConnections() + end + return true +end + +function FluidGridElementOwner:AsyncCheatShowGrid() + DbgToggleFluidGrid(self:GetPowerGrid()) +end + +if Platform.developer then + +FluidGridElement.grid_changed = false +FluidGridElement.grid_changes = 0 +function FluidGridElement:RegisterConsumptionChange() + local now = GameTime() + if self.grid_changed == now then + self.grid_changes = self.grid_changes + 1 + else + self.grid_changed = now + self.grid_changes = nil + end +end + + +function FluidGrid:LogChangedElements() + local changed = {} + for _, element in ipairs(self.elements) do + if element.grid_changes > 0 then + changed[#changed + 1] = element + end + end + table.sortby_field_descending(changed, "grid_changes") + print("Most changed elements in the last", max_grid_updates, "grid updates:") + for i=1,Min(#changed, 10) do + local element = changed[i] + local owner = element.owner + print(owner and owner.class or "", element.grid_changes) + end +end + +end -- Platform.developer + +--[[ Test +function FluidTest() + local grid = FluidGrid:new() + local owner = FluidGridElementOwner:new() + local tc = NewFluidConsumer(owner, 1000) + local tp = NewFluidProducer(owner, 2000) + local ts = NewFluidStorage(owner, 100000, 0, 5000, 5000) + owner.SetStorageState = function (self, res, state) print("Storage", tostring(self), res, state, ts.current_storage) end + owner.SetConsumption = function (self, res, amount) print("Consumer", tostring(self), res, amount == 0 and "off" or amount) end + grid:AddElement(tc) + grid:AddElement(tp) + grid:AddElement(ts) + rawset(_G, "tg", grid) + rawset(_G, "tc", tc) + rawset(_G, "tp", tp) + rawset(_G, "ts", ts) + return grid +end +--]] \ No newline at end of file diff --git a/CommonLua/Classes/GameEffect.lua b/CommonLua/Classes/GameEffect.lua new file mode 100644 index 0000000000000000000000000000000000000000..89b6e678395a5df4ffc9a6c20457a760393e728e --- /dev/null +++ b/CommonLua/Classes/GameEffect.lua @@ -0,0 +1,52 @@ +DefineClass.GameEffect = { + __parents = { "PropertyObject" }, + StoreAsTable = true, + EditorName = false, + Description = "", + EditorView = Untranslated(" "), + properties = { + { category = "General", id = "comment", name = T(964541079092, "Comment"), default = "", editor = "text" }, + }, +} + +-- should be called early during the player setup; player structures not fully inited +function GameEffect:OnInitEffect(player, parent) +end + +-- should be called when the effect needs to be applied +function GameEffect:OnApplyEffect(player, parent) +end + + +----- GameEffectsContainer + +DefineClass.GameEffectsContainer = { + __parents = { "Container" }, + ContainerClass = "GameEffect", +} + +-- should be called early during the player setup; player structures not fully inited +function GameEffectsContainer:EffectsInit(player) + for _, effect in ipairs(self) do + procall(effect.OnInitEffect, effect, player, self) + end +end + +-- should be called when the effect needs to be applied +function GameEffectsContainer:EffectsApply(player) + for _, effect in ipairs(self) do + procall(effect.OnApplyEffect, effect, player, self) + end +end + +function GameEffectsContainer:EffectsGatherTech(map) + for _, effect in ipairs(self) do + if IsKindOf(effect, "Effect_GrantTech") then + map[effect.Research] = true + end + end +end + +function GameEffectsContainer:GetEffectIdentifier() + return "GameEffect" +end diff --git a/CommonLua/Classes/GedModEditor.lua b/CommonLua/Classes/GedModEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..d92f0ace83f666db6e3edb2b4ee4b49cdb4052a7 --- /dev/null +++ b/CommonLua/Classes/GedModEditor.lua @@ -0,0 +1,990 @@ +local function IsGedAppOpened(template_id) + if not rawget(_G, "GedConnections") then return false end + for key, conn in pairs(GedConnections) do + if conn.app_template == template_id then + return true + end + end + return false +end + +function IsModEditorOpened() + return IsGedAppOpened("ModEditor") +end + +function IsModManagerOpened() + return IsGedAppOpened("ModManager") +end + +ModEditorMapName = "ModEditor" +function IsModEditorMap(map_name) + map_name = map_name or GetMapName() + return map_name == ModEditorMapName or (table.get(MapData, map_name, "ModEditor") or false) +end + +function OnMsg.UnableToUnlockAchievementReasons(reasons, achievement) + if AreModdingToolsActive() then + reasons["modding tools active"] = true + end +end + +if not config.Mods then return end + +if FirstLoad then + ModUploadThread = false + LastEditedMod = false -- the last mod that was opened or edited in a Mod Editor Ged application +end + +function OpenModEditor(mod) + local editor = GedConnections[mod.mod_ged_id] + if editor then + local activated = editor:Call("rfnApp", "Activate") + if activated ~= "disconnected" then + return editor + end + end + + LoadLuaParticleSystemPresets() + for _, presets in pairs(Presets) do + if class ~= "ListItem" then + PopulateParentTableCache(presets) + end + end + + local mod_path = ModConvertSlashes(mod:GetModRootPath()) + local context = { + mod_items = GedItemsMenu("ModItem"), + dlcs = g_AvailableDlc or { }, + mod_path = mod_path, + mod_os_path = ConvertToOSPath(mod_path), + mod_content_path = mod:GetModContentPath(), + WarningsUpdateRoot = "root", + suppress_property_buttons = { + "GedOpPresetIdNewInstance", + "GedRpcEditPreset", + "OpenTagsEditor", + }, + } + Msg("GatherModEditorLogins", context) + local container = Container:new{ mod } + UpdateParentTable(mod, container) + editor = OpenGedApp("ModEditor", container, context) + if editor then + editor:Send("rfnApp", "SetSelection", "root", { 1 }) + editor:Send("rfnApp", "SetTitle", string.format("Mod Editor - %s", mod.title)) + mod.mod_ged_id = editor.ged_id + end + return editor +end + +function OnMsg.GedOpened(ged_id) + local conn = GedConnections[ged_id] + if conn and conn.app_template == "ModEditor" then + SetUnmuteSoundReason("ModEditor") -- disable mute when unfocused in order to be able to test sound mod items + end + + if conn and (conn.app_template == "ModEditor" or conn.app_template == "ModManager") then + ReloadShortcuts() + end +end + +function OnMsg.GedClosing(ged_id) + local conn = GedConnections[ged_id] + if conn and conn.app_template == "ModEditor" then + ClearUnmuteSoundReason("ModEditor") -- disable mute when unfocused in order to be able to test sound mod items + end +end + +function OnMsg.GedClosed(ged) + if ged and (ged.app_template == "ModEditor" or ged.app_template == "ModManager") then + DelayedCall(0, ReloadShortcuts) + end +end + +function WaitModEditorOpen(mod) + if not IsModEditorMap(CurrentMap) then + ChangeMap(ModEditorMapName) + CloseMenuDialogs() + end + if mod then + OpenModEditor(mod) + else + if IsModManagerOpened() then return end + local context = { + dlcs = g_AvailableDlc or { }, + } + SortModsList() + local ged = OpenGedApp("ModManager", ModsList, context) + if ged then ged:BindObj("log", ModMessageLog) end + if LocalStorage.OpenModdingDocs == nil or LocalStorage.OpenModdingDocs then + if not Platform.developer then + GedOpHelpMod() + end + end + end +end + +function ModEditorOpen(mod) + CreateRealTimeThread(WaitModEditorOpen) +end + +function GedModMessageLog(obj) + return table.concat(obj, "\n") +end + +function OnMsg.NewMapLoaded() + if config.Mods then + ReloadShortcuts() + end +end + +function OnMsg.ModsReloaded() + if IsModManagerOpened() then + SortModsList() + end +end + +function UpdateModEditorsPropPanels() + for id, ged in pairs(GedConnections) do + if ged.app_template == "ModEditor" then + local selected_obj = ged:ResolveObj("SelectedObject") + if selected_obj then + ObjModified(selected_obj) + end + end + end +end + + +----- Ged Ops (Mods) + +function GedOpNewMod(socket, obj) + local title = socket:WaitUserInput(T(200174645592, "Enter Mod Title"), "") + if not title then return end + title = title:trim_spaces() + if #title == 0 then + socket:ShowMessage(T(634182240966, "Error"), T(112659155240, "No name provided")) + return + end + local err, mod = CreateMod(title) + if err then + socket:ShowMessage(GetErrorTitle(err, "mods", mod), GetErrorText(err, "mods")) + return + end + return table.find(ModsList, mod) +end + +function GedOpLoadMod(socket, obj, item_idx) + local mod = ModsList[item_idx] + if mod.items then return end + table.insert_unique(AccountStorage.LoadMods, mod.id) + Msg("OnGedLoadMod", mod.id) + ModsReloadItems() + ObjModified(ModsList) +end + +function GedOpUnloadMod(socket, obj, item_idx) + local mod = ModsList[item_idx] + if not mod.items then return end + table.remove_value(AccountStorage.LoadMods, mod.id) + Msg("OnGedUnloadMod", mod.id) + -- close Mod editor for that mod (mod-editing assumes that the mod is loaded) + for id, ged in pairs(GedConnections) do + if ged.app_template == "ModEditor" then + local root = ged:ResolveObj("root") + if root and root[1] == mod then + ged:Close() + end + end + end + ModsReloadItems() + ObjModified(ModsList) +end + +function GedOpEditMod(socket, obj, item_idx) + if not IsRealTimeThread() then + return CreateRealTimeThread(GedOpEditMod, socket, obj, item_idx) + end + local mod = ModsList[item_idx] + if not mod or IsValidThread(mod.mod_opening) then return end + if not CanLoadUnpackedMods() then + ModLog(true, T{970080750583, "Error opening for editing: cannot open unpacked mods", mod}) + return + end + mod.mod_opening = CurrentThread() + local force_reload + -- copy if not in AppData or svnAssets + if (mod.source ~= "appdata" and mod.source ~= "additional") or mod.packed then + local mod_folder = mod.title:gsub('[/?<>\\:*|"]', "_") + local unpack_path = string.format("AppData/Mods/%s/", mod_folder) + unpack_path = string.gsub(ConvertToOSPath(unpack_path), "\\", "/") + + local base_unpack_path, i = string.sub(unpack_path, 1, -2), 0 + while io.exists(unpack_path) do + i = i + 1 + unpack_path = base_unpack_path .. " " .. tostring(i) .. "/" + end + + local res = socket:WaitQuestion(T(521819598348, "Confirm Copy"), T{814173350691, "Mod '' files will be copied to ", mod, path = unpack_path}) + if res ~= "ok" then + return + end + GedSetUiStatus("mod_unpack", "Copying...") + ModLog(T{348544010518, "Copying to ", mod, path = unpack_path}) + AsyncCreatePath(unpack_path) + local err + if mod.packed then + local pack_path = mod.path .. ModsPackFileName + err = AsyncUnpack(pack_path, unpack_path) + else + local folders + err, folders = AsyncListFiles(mod.content_path, "*", "recursive,relative,folders") + if not err then + --create folder structure + for _, folder in ipairs(folders) do + local err = AsyncCreatePath(unpack_path .. folder) + if err then + ModLog(true, T{311163830130, "Error creating folder : ", folder = folder, err = err}) + break + end + end + --copy all files + local files + err, files = AsyncListFiles(mod.content_path, "*", "recursive,relative") + if not err then + for _,file in ipairs(files) do + local err = AsyncCopyFile(mod.content_path .. file, unpack_path .. file, "raw") + if err then + ModLog(true, T{403285832388, "Error copying : ", file = file, err = err}) + end + end + else + ModLog(true, T{600384081290, "Error looking up files of : ", mod, err = err}) + end + else + ModLog(true, T{836115199867, "Error looking up folders of : ", mod, err = err}) + end + end + GedSetUiStatus("mod_unpack") + + if not err then + mod:UnmountContent() + mod:ChangePaths(unpack_path) + mod.packed = false + mod.source = "appdata" + mod:MountContent() + force_reload = true + mod:SaveDef("serialize_only") + else + ModLog(true, T{578088043400, "Error copying : ", mod, err = err}) + end + end + if force_reload or not mod:ItemsLoaded() then + table.insert_unique(AccountStorage.LoadMods, mod.id) + Msg("OnGedLoadMod", mod.id) + mod.force_reload = true + ModsReloadItems(nil, "force_reload") + ObjModified(ModsList) + end + if mod:ItemsLoaded() then + WaitModEditorOpen(mod) + end + mod.mod_opening = false +end + +function GedOpRemoveMod(socket, obj, item_idx) + local mod = ModsList[item_idx] + local reasons = { } + Msg("GatherModDeleteFailReasons", mod, reasons) + if next(reasons) then + socket:ShowMessage(T(634182240966, "Error"), table.concat(reasons, "\n")) + else + local res = socket:WaitQuestion(T(118482924523, "Are you sure?"), T{820846615088, "Do you want to delete all files?", mod}) + if res == "cancel" then return end + table.remove(ModsList, item_idx) + local err = DeleteMod(mod) + if err then + socket:ShowMessage(GetErrorTitle(err, "mods"), GetErrorText(err, "mods", mod)) + end + return Clamp(item_idx, 1, #ModsList) + end +end + +function GedOpHelpMod(socket, obj, document) + local help_file = string.format("%s", ConvertToOSPath(DocsRoot .. (document or "index.md.html"))) + help_file = string.gsub(help_file, "[\n\r]", "") + if io.exists(help_file) then + help_file = string.gsub(help_file, " ", "%%20") + OpenUrl("file:///" .. help_file, "force external browser") + end +end + +function GedOpDarkModeChange(socket, obj, choice) + SetProperty(XEditorSettings, "DarkMode", choice) + + for id, dlg in pairs(Dialogs) do + if IsKindOf(dlg, "XDarkModeAwareDialog") then + dlg:SetDarkMode(GetDarkModeSetting()) + end + end + for id, socket in pairs(GedConnections) do + socket:Send("rfnApp", "SetDarkMode", GetDarkModeSetting()) + end + ReloadShortcuts() +end + +function GedOpOpenDocsToggle(socket, obj, choice) + if LocalStorage.OpenModdingDocs ~= nil then + LocalStorage.OpenModdingDocs = not LocalStorage.OpenModdingDocs + else + LocalStorage.OpenModdingDocs = false + end + SaveLocalStorage() + socket:Send("rfnApp", "SetActionToggled", "OpenModdingDocs", LocalStorage.OpenModdingDocs) +end + +function OnMsg.GedActivated(ged, initial) + if initial and ged.app_template == "ModManager" then + ged:Send("rfnApp", "SetActionToggled", "OpenModdingDocs", LocalStorage.OpenModdingDocs == nil or LocalStorage.OpenModdingDocs) + end +end + +function GedOpTriggerCheat(socket, obj, cheat, ...) + if string.starts_with(cheat, "Cheat") then + local func = rawget(_G, cheat) + if func then + func(...) + end + end +end + +function CreateMod(title) + for _, mod in ipairs(ModsList) do + if mod.title == title then return "exists" end + end + local path = string.format("AppData/Mods/%s/", title:gsub('[/?<>\\:*|"]', "_")) + if io.exists(path .. "metadata.lua") then + return "exists" + end + AsyncCreatePath(path) + + local authors = {} + Msg("GatherModAuthorNames", authors) + local author + --choose from modding platform (except steam) + for platform, name in pairs(authors) do + if platform ~= "steam" then + author = name + break + end + end + --fallback to steam name or default + author = author or authors.steam or "unknown" + + local env = LuaModEnv() + local id = ModDef:GenerateId() + local mod = ModDef:new{ + title = title, + author = author, + id = id, + path = path, + content_path = ModContentPath .. id .. "/", + env = env, + } + Msg("ModDefCreated", mod) + mod:SetupEnv() + mod:MountContent() + + assert(Mods[mod.id] == nil) + Mods[mod.id] = mod + ModsList[#ModsList+1] = mod + SortModsList() + CacheModDependencyGraph() + + local items_err = AsyncStringToFile(path .. "items.lua", "return {}") + local def_err = mod:SaveDef() + return (def_err or items_err), mod +end + +function DeleteMod(mod) + local err = AsyncDeletePath(mod.path) + if err then return err end + Mods[mod.id] = nil + table.remove_entry(ModsList, mod) + table.remove_entry(ModsLoaded, mod) + table.remove_entry(AccountStorage.LoadMods, mod.id) + Msg("OnGedUnloadMod", mod.id) + ObjModified(ModsList) + mod:delete() +end + + +----- Ged Ops (Mod Items) + +function GedOpNewModItem(socket, root, path, class_or_instance) + if #path == 0 then path = { 1 } end + if #path == 1 then table.insert(path, #root[1].items) end + return GedOpTreeNewItem(socket, root, path, class_or_instance) +end + +local function GetSelectionBaseClass(root, selection) + return ParentNodeByPath(root, selection[1]).ContainerClass +end + +function GedOpDuplicateModItem(socket, root, selection) + local path = selection[1] + if not path or #path < 2 then return "error" end + assert(path[1] == 1) + return GedOpTreeDuplicate(socket, root, selection, GetSelectionBaseClass(root, selection)) +end + +function GedOpCutModItem(socket, root, selection) + local path = selection[1] + if not path or #path < 2 then return "error" end + assert(path[1] == 1) + return GedOpTreeCut(socket, root, selection, GetSelectionBaseClass(root, selection)) +end + +function GedOpCopyModItem(socket, root, selection) + local path = selection[1] + if not path or #path < 2 then return "error" end + assert(path[1] == 1) + return GedOpTreeCopy(socket, root, selection, GetSelectionBaseClass(root, selection)) +end + +function GedOpPasteModItem(socket, root, selection) + -- simulate select ModDef/root + if not selection[1] then + selection[1] = { 1 } + selection[2] = { 1 } + selection.n = 2 + end + -- simulate select last element of ModDef/root + if #selection[1] == 1 then + table.insert(selection[1], #root[1].items) + selection[2][1] = #root[1].items + end + + return GedOpTreePaste(socket, root, selection) +end + +function GedOpDeleteModItem(socket, root, selection) + local path = selection[1] + if not path or #path < 2 then return "error" end + assert(path[1] == 1) + + local items_name_string = "" + for idx = 1, #selection[2] do + local leaf = selection[2][idx] + local item = TreeNodeChildren(ParentNodeByPath(root, path))[leaf] + local item_name = item.id or item.name or item.__class or item.EditorName or item.class + items_name_string = idx == 1 and item_name or items_name_string .. "\n" .. item_name + end + + local confirm_text = T{435161105463, "Please confirm the deletion of item ''!", name = items_name_string} + if #selection[2] ~= 1 then + confirm_text = T{621296865915, "Are you sure you want to delete the following selected items?\n", number_of_items = #selection[2], items = items_name_string} + end + if "ok" ~= socket:WaitQuestion(T(986829419084, "Confirmation"), confirm_text) then + return + end + + return GedOpTreeDeleteItem(socket, root, selection) +end + +function GedSaveMod(ged) + local old_root = ged:ResolveObj("root") + local mod = old_root[1] + if mod:CanSaveMod(ged) then + mod:SaveWholeMod() + end +end + +-- reloads the mod to update function debug info, allowing the modder to debug their code after saving +-- (TODO: unused for now, consider adding a button for that when the debugging support is ready) +function GedReloadModItems(ged) + local old_root = ged:ResolveObj("root") + local mod = old_root[1] + GedSetUiStatus("mod_reload_items", "Reloading items...") + mod:UnloadItems() + mod:LoadItems() + local container = Container:new{ mod } + UpdateParentTable(mod, container) + GedRebindRoot(old_root, container) + GedSetUiStatus("mod_reload_items") +end + +function GedOpOpenModItemPresetEditor(socket, obj, selection, a, b, c) + if obj and obj.ModdedPresetClass then + OpenPresetEditor(obj.ModdedPresetClass) + end +end + +function GedGetModItemDockedActions(obj) + local actions = {} + Msg("GatherModItemDockedActions", obj, actions) -- use this msg to add more actions for mod item that are docked on the bottom right + return actions +end + +function OnMsg.GatherModItemDockedActions(obj, actions) + if IsKindOf(obj, "Preset") then + local preset_class = g_Classes[obj.ModdedPresetClass] + local class = preset_class.PresetClass or preset_class.class + actions["PresetEditor"] = { + name = "Open in " .. (preset_class.EditorMenubarName ~= "" and preset_class.EditorMenubarName or (class .. " editor")), + rolloverText = "Open the dedicated editor for this item,\nalongside the rest of the game content.", + op = "GedOpOpenModItemPresetEditor" + } + end +end + +function OnMsg.GatherModItemDockedActions(obj, actions) + if IsKindOf(obj, "ModItem") and obj.TestModItem ~= ModItem.TestModItem then + actions["TestModItem"] = { + name = "Test mod item", + rolloverText = obj.TestDescription, + op = "GedOpTestModItem" + } + end +end + +function GedGetEditableModsComboItems() + if not ModsLoaded then return empty_table end + + local ret = {} + for idx, mod in ipairs(ModsLoaded) do + if mod and mod:ItemsLoaded() and not mod:IsPacked() then + table.insert(ret, { text = mod.title or mod.id, value = mod.id }) + end + end + return ret +end + +-- Clones the selected Preset to the selected mod as a ModItemPreset so it can be modded +function GedOpClonePresetInMod(socket, root, selection_path, item_class, mod_id) + local mod = Mods and Mods[mod_id] + if not mod or not mod.items then return "Invalid mod selected" end + + local selected_preset = socket:ResolveObj("SelectedPreset") + local path = selection_path and selection_path[1] + + -- Check if the preset class has a corersponding mod item class + local class_or_instance = "ModItem" .. item_class + local mod_item_class = g_Classes[class_or_instance] + if not g_Classes[item_class] or not mod_item_class then return "No ModItemPreset class exists for this Preset type" end + + -- Create the new ModItemPreset and add it to the tree of the calling Preset Editor + local item_path, item_undo_fn = GedOpTreeNewItem(socket, root, path, class_or_instance, nil, mod_id) + if type(item_path) ~= "table" or type(item_undo_fn) ~= "function" then + return "Error creating the new mod item" + end + + -- Copy all properties from the chosen preset using the __copy mod item property (see ModItemPreset:OnEditorSetProperty) + local item = GetNodeByPath(root, item_path) + item["__copy_group"] = selected_preset.group + local prop_id = "__copy" + local id_value = selected_preset.id + GedSetProperty(socket, item, prop_id, id_value) + + -- Set the same group and id (unique one) like the selected preset and get the new path in the tree + item:SetGroup(selected_preset.group) + item:SetId(item:GenerateUniquePresetId(selected_preset.id)) + item_path = RecursiveFindTreeItemPath(root, item) + + return item_path, item_undo_fn +end + +function GedOpSetModdingBindings(socket) + -- Bind the editable mods combo in Preset Editors, it should contain only loaded mods + -- Note: Since all bindings require an "obj" whose reference can later be used to update the binding (with GedRebindRoot) + -- and there's no suitable "obj" to pass here we use empty_table as a kind of dummy constant reference that we can use for updates later + socket:BindObj("EditableModsCombo", empty_table, GedGetEditableModsComboItems) + + -- Don't bind LastEditedMod if that mod is currently not loaded or packed + if LastEditedMod and Mods then + local mod = Mods[LastEditedMod.id] + if mod and mod:ItemsLoaded() and not mod:IsPacked() then + socket:BindObj("LastEditedMod", mod.id, return_first) + end + end +end + +function OnMsg.OnGedLoadMod(mod_id) + GedRebindRoot(empty_table, empty_table, "EditableModsCombo", GedGetEditableModsComboItems, "dont_restore_app_state") +end + +function OnMsg.OnGedUnloadMod(mod_id) + GedRebindRoot(empty_table, empty_table, "EditableModsCombo", GedGetEditableModsComboItems, "dont_restore_app_state") +end + +-- Utility function for updating the Mod Editor tree panel for a given mod that changed +function ObjModifiedMod(mod) + if not mod then return end + local mod_container = ParentTableCache[mod] + -- Check if the given ModDef instance is not the original one + if not mod_container and mod.id and Mods and Mods[mod.id] then + mod_container = ParentTableCache[Mods[mod.id]] + end + if mod_container then + ObjModified(mod_container) + end +end + +local function CreatePackageForUpload(mod_def, params) + local content_path = mod_def.content_path + local temp_path = "TmpData/ModUpload/" + local pack_path = temp_path .. "Pack/" + local shots_path = temp_path .. "Screenshots/" + + --clean old files in ModUpload & recreate folder structure + AsyncDeletePath(temp_path) + AsyncCreatePath(pack_path) + AsyncCreatePath(shots_path) + + --copy & rename mod screenshots + params.screenshots = { } + for i=1,5 do + --copy & rename mod_def.screenshot1, mod_def.screenshot2, mod_def.screenshot3, mod_def.screenshot4, mod_def.screenshot5 + local screenshot = mod_def["screenshot"..i] + if io.exists(screenshot) then + local path, name, ext = SplitPath(screenshot) + local new_name = ModsScreenshotPrefix .. name .. ext + local new_path = shots_path .. new_name + local err = AsyncCopyFile(screenshot, new_path) + if not err then + local os_path = ConvertToOSPath(new_path) + table.insert(params.screenshots, os_path) + end + end + end + + local mod_entities = {} + for _, entity in ipairs(mod_def.entities) do + DelayedLoadEntity(mod_def, entity) + mod_entities[entity] = true + end + WaitDelayedLoadEntities() + + ReloadLua() + + EngineBinAssetsPrints = {} + + local materials_seen, used_tex, textures_data = CollapseEntitiesTextures(mod_entities) + + if next(EngineBinAssetsPrints) then + for _, log in ipairs(EngineBinAssetsPrints) do + ModLogF(log) + end + end + + local dest_path = ConvertToOSPath(mod_def.content_path .. "BinAssets/") + local res = SaveModMaterials(materials_seen, dest_path) + + --determine which files should to be packed and which ignored + local files_to_pack = { } + local substring_begin = #mod_def.content_path + 1 + local err, all_files = AsyncListFiles(content_path, nil, "recursive") + for i,file in ipairs(all_files) do + local ignore + + for j,filter in ipairs(mod_def.ignore_files) do + if MatchWildcard(file, filter) then + ignore = true + break + end + end + + local dir, filename, ext = SplitPath(file) + if ext == ".dds" and not used_tex[filename .. ext] then + ignore = true + end + + ignore = ignore or ext == ".mtl" + + if not ignore then + table.insert(files_to_pack, { src = file, dst = string.sub(file, substring_begin) }) + end + end + + --pack the mod content + local err = AsyncPack(pack_path .. ModsPackFileName, content_path, files_to_pack) + if err then + return false, T{243097197797, --[[Mod upload error]] "Failed creating content package file ()", err = err} + end + + params.os_pack_path = ConvertToOSPath(pack_path .. ModsPackFileName) + return true, nil +end + +function DbgPackMod(mod_def, show_file) + local params = {} + if mod_def:IsDirty() then + mod_def:SaveWholeMod() + end + CreatePackageForUpload(mod_def, params) + local dir = SplitPath(params.os_pack_path):gsub("/", "\\") + if show_file then + AsyncExec(string.format('explorer "%s"', dir)) + end + return dir +end + +function PackModForBugReporter(mod) + mod = IsKindOf(mod, "ModDef") and mod or (Mods and Mods[mod.id]) + if not mod then return end + local params = {} + if mod:IsDirty() then + mod:SaveWholeMod() + end + CreatePackageForUpload(mod, params) + return params.os_pack_path +end + +if FirstLoad then + ModUploadDeveloperWarningShown = false +end + +function UploadMod(ged_socket, mod, params, prepare_fn, upload_fn) + ModUploadThread = CreateRealTimeThread(function(ged_socket, mod, params, prepare_fn, upload_fn) + local function DoUpload() + --uploading is done in three steps + -- 1) the platform prepares the mod for uploading (generate IDs and others...) + -- 2) the mod is packaged into a .hpk file + -- 3) the mod is uploaded + -- every function returns at least two parameters: `success` and `message` + + local function ReportError(ged_socket, message) + ModLog(true, Untranslated{"Mod was not uploaded! Error: ", mod, err = message}) + ged_socket:ShowMessage("Error", message) + end + + local success, message + success, message = prepare_fn(ged_socket, mod, params) + if not success then + ReportError(ged_socket, message) + return + end + + success, message = CreatePackageForUpload(mod, params) + if not success then + ReportError(ged_socket, message) + return + end + + success, message = upload_fn(ged_socket, mod, params) + if not success then + ReportError(ged_socket, message) + else + local msg = T{561889745203, "Mod was successfully uploaded!", mod} + ModLog(msg) + ged_socket:ShowMessage(T(898871916829, "Success"), msg) + + if insideHG() then + if Platform.goldmaster then + ged_socket:ShowMessage("Reminder", "After publishing a mod, make sure to copy it to svnAssets/Source/Mods/ and commit.") + elseif Platform.developer and not ModUploadDeveloperWarningShown then + ged_socket:ShowMessage("Reminder", "Publishing sample mods should be done using the target GoldMaster version of the game.") + ModUploadDeveloperWarningShown = true + end + end + end + end + + PauseInfiniteLoopDetection("UploadMod") + GedSetUiStatus("mod_upload", "Uploading...") + DoUpload() + GedSetUiStatus("mod_upload") + ResumeInfiniteLoopDetection("UploadMod") + ModUploadThread = false + end, ged_socket, mod, params, prepare_fn, upload_fn) +end + +function ValidateModBeforeUpload(ged_socket, mod) + if IsValidThread(ModUploadThread) then + ged_socket:ShowMessage("Error", "Another mod is currently uploading.\n\nPlease wait for the upload to finish.") + return "upload in progress" + end + + if mod.last_changes == "" then + ged_socket:ShowMessage("Error", "Please fill in the 'Last Changes' field of your mod before uploading.") + return "no 'last changes'" + end + + if mod:IsDirty() then + if "ok" ~= ged_socket:WaitQuestion("Mod Upload", "The mod needs to be saved before uploading.\n\nContinue?", "Yes", "No") or + not mod:CanSaveMod(ged_socket) + then + return "mod saving failed" + end + mod:SaveWholeMod() + end +end + +function GedOpTestModItem(socket, root, path) + local item = IsKindOf(root, "ModItem") and root or GetNodeByPath(root, path) + if IsKindOf(item, "ModItem") then + item:TestModItem(socket) + end +end + +function GedOpOpenModFolder(socket, root) + local mod = root[1] + local path = ConvertToOSPath(SlashTerminate(mod.path)) + CreateRealTimeThread(function() + AsyncExec(string.format('cmd /c start /D "%s" .', path)) + end) +end + +function GedOpPackMod(socket, root) + local mod = root[1] + if not mod then return end + CreateRealTimeThread(function() + if socket:WaitQuestion("Pack mod", "Packing the mod will take more time the bigger it is.\nAre you sure you want to continue?", "Yes", "No") == "ok" then + GedSetUiStatus("mod_packing", "Packing mod...") + DbgPackMod(mod, true) + GedSetUiStatus("mod_packing") + end + end) +end + +function GedOpModItemHelp(socket, root, path) + local item = GetNodeByPath(root, path) + if IsKindOf(item, "ModItem") then + local filename = DocsRoot .. item.class .. ".md.html" + if io.exists(filename) then + local os_path = ConvertToOSPath(filename) + OpenAddress(os_path) + return + end + end + local path_to_index = ConvertToOSPath(DocsRoot .. "index.md.html") + if io.exists(path_to_index) then + OpenAddress(path_to_index) + end +end + +function GedOpGenTTableMod(socket, root) + local csv = {} + local modDef = root[1] + modDef:ForEachModItem(function(item) + item:ForEachSubObject("PropertyObject", function(obj, parents) + obj:GenerateLocalizationContext(obj) + for _, propMeta in ipairs(obj.GetProperties and obj:GetProperties()) do + local propVal = obj:GetProperty(propMeta.id) + if propVal ~= "" and IsT(propVal) then + local context, voice = match_and_remove(ContextCache[propVal], "voice:") + if getmetatable(propVal) == TConcatMeta then + for _, t in ipairs(propVal) do + csv[#csv+1] = { id = TGetID(t), text = TDevModeGetEnglishText(t), context = context, voice = voice } + end + else + csv[#csv+1] = { id = TGetID(propVal), text = TDevModeGetEnglishText(propVal), context = context, voice = voice } + end + end + end + end) + end) + + local csv_filename = modDef.path .. "/ModTexts.csv" + local fields = { "id", "text", "translation", "voice", "context" } -- translation is intentionally non-existent above, to create an empty column + local field_captions = { "ID", "Text", "Translation", "VoiceActor", "Context" } + local err = SaveCSV(csv_filename, csv, fields, field_captions, ",") + if err then + socket:ShowMessage("Error", "Failed to export a translation table to\n" .. ConvertToOSPath(csv_filename) .. "\nError: " .. err) + else + socket:ShowMessage("Success", "Successfully exported a translation table to\n" .. ConvertToOSPath(csv_filename)) + end +end + +local function GetDirSize(path) + local err, files = AsyncListFiles(path) + local size + if not err then + size = 0 + for _, filename in ipairs(files) do + size = size + io.getsize(filename) + end + end + return size +end + +local function GetModDetailsForBugReporter(modDef) + local mod_content_path = modDef:GetModContentPath() + local mod_root_path = modDef:GetModRootPath() + local is_packed = modDef:IsPacked() + local modSize = not is_packed and GetDirSize(ConvertToOSPath(mod_content_path)) or io.getsize(mod_root_path .. ModsPackFileName) + local estPackSizeReduction = is_packed and 1 or 2 + local maxSize = 100*1024*1024 --100mb + + local res = { + id = modDef.id, + title = modDef.title, + mod_path = mod_content_path, + mod_items_path = mod_root_path .. "items.lua", + mod_metadata_path = mod_root_path .. "metadata.lua", + mod_is_packed = is_packed and mod_root_path .. ModsPackFileName, + mod_size_check = modSize and (modSize / estPackSizeReduction <= maxSize), + mod_os_path = mod_root_path, + } + return res +end + +function GedGetMod(socket) + local mod = socket and socket.app_template == "ModEditor" and socket:ResolveObj("root") + mod = mod and IsKindOf(mod[1], "ModDef") and mod[1] + if not mod then return false end + + return GetModDetailsForBugReporter(mod) +end + +function GedGetLastEditedMod(socket) + return socket and GedGetMod(socket) or LastEditedMod +end + +function GedAreModdingToolsActive(socket) + return AreModdingToolsActive() +end + +function GedPackModForBugReport(socket, mod) + DebugPrint("Packing mod...") + local modDef = Mods and Mods[mod.id] + local packed_path + if modDef then + packed_path = PackModForBugReporter(modDef) + end + return packed_path +end + +local function UpdateLastEditedMod(mod) + local oldMod = LastEditedMod + LastEditedMod = GetModDetailsForBugReporter(mod) + if not oldMod or not LastEditedMod or oldMod.id ~= LastEditedMod.id then + Msg("LastEditedModChanged", LastEditedMod) + end +end + +function OnMsg.ObjModified(obj) + local mod = TryGetModDefFromObj(obj) + if mod then + UpdateLastEditedMod(mod) + end +end + +function OnMsg.GedOpened(app_id) + local conn = GedConnections[app_id] + if conn and conn.app_template == "ModEditor" then + local root = conn and conn:ResolveObj("root") + local mod = root and root[1] + if mod then + UpdateLastEditedMod(mod) + end + end +end + +function GedGetSteamBetaName() + local steam_beta, steam_branch + if Platform.steam then + steam_beta, steam_branch = SteamGetCurrentBetaName() + end + return steam_beta, steam_branch +end \ No newline at end of file diff --git a/CommonLua/Classes/InvisibleObject.lua b/CommonLua/Classes/InvisibleObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..ba47ac931a3b4c4b7d14113f07433ee5bf2b746b --- /dev/null +++ b/CommonLua/Classes/InvisibleObject.lua @@ -0,0 +1,91 @@ +DefineClass.InvisibleObject = { + __parents = { "CObject" }, + flags = {}, + HelperEntity = "PointLight", + HelperScale = 100, + HelperCursor = false, +} + +function InvisibleObject:ConfigureInvisibleObjectHelper(helper) + +end + +function ConfigureInvisibleObjectHelper(obj, helper) + if not obj.HelperEntity then return end + + if not helper then + helper = InvisibleObjectHelper:new() + end + if not helper:GetParent() then + obj:Attach(helper) + end + helper:ChangeEntity(obj.HelperEntity) + helper:SetScale(obj.HelperScale) + obj:ConfigureInvisibleObjectHelper(helper) +end + +local function CreateHelpers() + MapForEach("map", "attached", false, "InvisibleObject", + function(obj) + ConfigureInvisibleObjectHelper(obj) + end + ) +end + +local function DeleteHelpers() + MapDelete("map", "InvisibleObjectHelper") +end + +if FirstLoad then + InvisibleObjectHelpersEnabled = true +end + +function ToggleInvisibleObjectHelpers() + SetInvisibleObjectHelpersEnabled(not InvisibleObjectHelpersEnabled) +end + +function SetInvisibleObjectHelpersEnabled(value) + if not InvisibleObjectHelpersEnabled and value then + CreateHelpers() + elseif InvisibleObjectHelpersEnabled and not value then + DeleteHelpers() + end + InvisibleObjectHelpersEnabled = value +end + +DefineClass.InvisibleObjectHelper = { + __parents = { "CObject", "ComponentAttach" }, + entity = "PointLight", + flags = { efShadow = false, efSunShadow = false }, + properties = {}, +} + +if Platform.editor then + AppendClass.InvisibleObject = { + __parents = { "ComponentAttach" }, + flags = { cfEditorCallback = true, }, + } + + function OnMsg.GameEnteringEditor() -- called before GameEnterEditor, allowing XEditorFilters to catch these objects + if InvisibleObjectHelpersEnabled then + CreateHelpers() + end + end + function OnMsg.EditorCallback(id, objects, ...) + if id == "EditorCallbackPlace" or id == "EditorCallbackClone" or id == "EditorCallbackPlaceCursor" then + for i = 1, #objects do + local obj = objects[i] + if obj:IsKindOf("InvisibleObject") and not obj:GetParent() and not obj:GetAttach("InvisibleObjectHelper") and + (id ~= "EditorCallbackPlaceCursor" or obj.HelperCursor) and + InvisibleObjectHelpersEnabled then + ConfigureInvisibleObjectHelper(obj) + end + end + end + end + function OnMsg.GameExitEditor() + if InvisibleObjectHelpersEnabled then + DeleteHelpers() + end + end +end \ No newline at end of file diff --git a/CommonLua/Classes/Light.lua b/CommonLua/Classes/Light.lua new file mode 100644 index 0000000000000000000000000000000000000000..3a77e26114ef7d7d61eb9594378277c046fc0066 --- /dev/null +++ b/CommonLua/Classes/Light.lua @@ -0,0 +1,587 @@ +DefineClass.ComponentLight = { + __parents = { "CObject" }, + flags = { cfLight = true, cofComponentLight = true, } +} + +for name, func in pairs(ComponentLightFunctions) do + ComponentLight[name] = func +end + +local lightmodel_lights = {"A", "B", "C", "D"} +DefineClass.Light = { + __parents = { "Object", "InvisibleObject", "ComponentAttach", "ComponentLight" }, + flags = { cfConstructible = false, gofRealTimeAnim = true, efShadow = false, efSunShadow = false, + gofDetailClass0 = true, gofDetailClass1 = true, -- to match 'Eye Candy' default of DetailClass + }, -- ATTN: cfEditorCallback added below when Platform.editor + + -- Properties, editable in the editor's property window (Ctrl+O) + properties = { + { id = "DetailClass", name = "Detail Class", editor = "dropdownlist", + items = {"Default", "Essential", "Optional", "Eye Candy"}, default = "Eye Candy", + }, + { category = "Visuals", id = "Color", editor = "color", autoattach_prop = true, dont_save = function(obj) + if not IsValid(obj) then return false end + if obj:GetLightmodelColorIndexNumber() ~= 0 then return true end + return false + end, + read_only = function(obj) + if not IsValid(obj) then return false end + if obj:GetLightmodelColorIndexNumber() ~= 0 then return true end + return false + end, + }, + { category = "Visuals", id = "LightmodelColorIndex", editor = "set", items = lightmodel_lights, max_items_in_set = 1, default = {}, autoattach_prop = true,}, + { category = "Visuals", id = "OriginalColor", editor = "color", default = RGB(255, 255, 255), no_edit = true, dont_save = true }, + { category = "Visuals", id = "Intensity", editor = "number", min = 0, max = 255, slider = true, autoattach_prop = true, }, + { category = "Visuals", id = "Exterior", editor = "bool", autoattach_prop = true, }, + { category = "Visuals", id = "Interior", editor = "bool", autoattach_prop = true, }, + { category = "Visuals", id = "InteriorAndExteriorWhenHasShadowmap", editor = "bool", autoattach_prop = true, }, + { category = "Visuals", id = "Volume", helper = "volume", editor = "object", default = false, base_class = "Volume" }, + { category = "Visuals", id = "ConstantIntensity", editor = "number", default = 0, autoattach_prop = true, slider = true, max = 127, min = -128 }, + { category = "Visuals", id = "AttenuationShape", editor = "number", default = 0, autoattach_prop = true, slider = true, max = 255, min = 0 }, + { category = "Visuals", id = "CastShadows", editor = "bool", autoattach_prop = true }, + { category = "Visuals", id = "DetailedShadows", editor = "bool", autoattach_prop = true }, + { id = "ColorModifier", editor = false }, + { id = "Occludes", editor = false }, + { id = "Walkable", editor = false }, + { id = "ApplyToGrids", editor = false }, + { id = "Collision", editor = false }, + { id = "Color1", editor = false }, + { id = "ParentSIModulation", editor = "number", default = 100, min = 0, max = 255, slider = true, autoattach_prop = true, no_edit = function(o) + return IsKindOf(o, "CObject") + end, help = "To be used by the AutoAttach system."}, + }, + + Color = RGB(255,255,255), + Intensity = 100, + Interior = true, + Exterior = true, + InteriorAndExteriorWhenHasShadowmap = true, + CastShadows = false, + DetailedShadows = false, + + Init = function(self) + self:SetColor(self.Color) + self:SetIntensity(self.Intensity) + self:SetInterior(self.Interior) + self:SetExterior(self.Exterior) + self:SetInteriorAndExteriorWhenHasShadowmap(self.InteriorAndExteriorWhenHasShadowmap) + self:SetConstantIntensity(0) + self:SetAttenuationShape(0) + self:SetLightmodelColorIndex(empty_table) + self:SetCastShadows(self.CastShadows) + self:SetDetailedShadows(self.DetailedShadows) + end, + + GetCastShadows = function(self) return self:GetLightFlags(const.elfCastShadows) end, + SetCastShadows = function(self, value) + self.CastShadows = value + if value then + self:SetLightFlags(const.elfCastShadows) + else + self:ClearLightFlags(const.elfCastShadows) + end + end, + + GetDetailedShadows = function(self) return self:GetLightFlags(const.elfDetailedShadows) end, + SetDetailedShadows = function(self, value) + self.DetailedShadows = value + if value then + self:SetLightFlags(const.elfDetailedShadows) + else + self:ClearLightFlags(const.elfDetailedShadows) + end + end, + + GetColor = function(self) + local index = self:GetLightmodelColorIndexNumber() + if index ~= 0 then return GetSceneParamColor("LightColor" .. index) end + return self:GetColor0() + end, + SetColor = function(self, rgb) self:SetColor0(rgb) self:SetColor1(rgb) end, + GetColor0 = function(self) return self:GetColorAtIndex(0) end, + GetColor1 = function(self) return self:GetColorAtIndex(1) end, + SetColor0 = function(self, rgb) self:SetColorAtIndex(0, rgb) self:SetColorModifier(rgb or 0) end, + SetColor1 = function(self, rgb) self:SetColorAtIndex(1, rgb) end, + + GetExterior = function(self) + return self:GetLightFlags(const.elfExterior) + end, + + SetExterior = function(self, value) + self.Exterior = value + if value then + self:SetLightFlags(const.elfExterior) + else + self:ClearLightFlags(const.elfExterior) + end + end, + + GetInterior = function(self) + return self:GetLightFlags(const.elfInterior) + end, + + SetInterior = function(self, value) + self.Interior = value + if value then + self:SetLightFlags(const.elfInterior) + else + self:ClearLightFlags(const.elfInterior) + end + end, + + GetInteriorAndExteriorWhenHasShadowmap = function(self) + return self:GetLightFlags(const.elfInteriorAndExteriorWhenHasShadowmap) + end, + + SetInteriorAndExteriorWhenHasShadowmap = function(self, value) + self.InteriorAndExteriorWhenHasShadowmap = value + if value then + self:SetLightFlags(const.elfInteriorAndExteriorWhenHasShadowmap) + else + self:ClearLightFlags(const.elfInteriorAndExteriorWhenHasShadowmap) + end + end, + + SetLightmodelColorIndex = function(self, val) + local index = 0 + local activated_key = false + for key, value in pairs(val or empty_table) do + if value then activated_key = key end + end + if activated_key then + index = table.find(lightmodel_lights, activated_key) + end + assert(index >= 0 and index <= 4) + + -- maskset(old, const.elfColorIndexMask, index << const.elfColorIndexShift) + self:ClearLightFlags(const.elfColorIndexMask) + self:SetLightFlags(index << const.elfColorIndexShift) + end, + + GetLightmodelColorIndex = function(self) + local index = self:GetLightmodelColorIndexNumber() + if index == 0 then return {} end + return { [lightmodel_lights[index]] = true } + end, + + SetIntensity = function(self, value) self:SetIntensityAtIndex(0, value) self:SetIntensityAtIndex(1, value) end, + SetIntensity0 = function(self, value) self:SetIntensityAtIndex(0, value) end, + SetIntensity1 = function(self, value) self:SetIntensityAtIndex(1, value) end, + GetIntensity0 = function(self, value) return self:GetIntensityAtIndex(0) end, + GetIntensity1 = function(self, value) return self:GetIntensityAtIndex(1) end, + GetIntensity = function(self, value) return self:GetIntensityAtIndex(0), self:GetIntensityAtIndex(1) end, + + GetAlwaysRenderable = function(self) return self:GetGameFlags(const.gofAlwaysRenderable) end, + SetAlwaysRenderable = function(self, value) + if value == true then + self:SetGameFlags(const.gofAlwaysRenderable) + else + self:ClearGameFlags(const.gofAlwaysRenderable) + end + end, + + SetBehavior = function(self, b) + if b == "flicker" then + self:SetLightFlags(const.elfFlicker) + else + self:ClearLightFlags(const.elfFlicker) + end + end, + + CurrTime = function(self) + if self:GetGameFlags( const.gofRealTimeAnim ) > 0 then + return RealTime() + end + return GameTime() + end, + + Fade = function(self, color, intensity, time) + self:SetBehavior("fade") + self:SetTimes(self:CurrTime(), self:CurrTime() + time) + self:SetColor0(self:GetColor1()) + self:SetColor1(color) + self:SetIntensity0(self:GetIntensity1()) + self:SetIntensity1(intensity) + end, + + Flicker = function(self, color, intensity, period, phase) + self:SetBehavior("flicker") + phase = self:CurrTime() - (phase or AsyncRand(period)) + self:SetTimes(phase, phase + period * 300) + self:SetColor(color) + self:SetIntensity0(0) + self:SetIntensity1(intensity) + end, + + Steady = function(self, color, intensity) + self:SetColor(color) + self:SetBehavior("fade") + self:SetTimes(-1,-1) + self:SetIntensity(intensity) + end, + + SetParentSIModulation = function(self, value) + local parent = self:GetParent() + if parent then + parent:SetSIModulation(value) + end + end, + GetParentSIModulation = function(self) + local parent = self:GetParent() + if parent then + return parent:GetSIModulation() + end + return 100 + end, + + SetVolume = function(self, volume_obj) + self:SetTargetVolumeId(volume_obj and volume_obj.handle or 0) + end, + + GetVolume = function(self) + local handle = self:GetTargetVolumeId() + if not handle or handle == 0 then return false end + return HandleToObject[handle] + end, + + SetContourOuterID = empty_func, +} + +function Light:OnEditorSetProperty(prop_id) + if prop_id == "DetailClass" then + self:DestroyRenderObj() + end +end + +const.ShadowDirsComboItems = { + [LastSetBit(const.eLightDirX) + 1] = { name = "+X" }, + [LastSetBit(const.eLightDirNegX) + 1] = { name = "-X" }, + [LastSetBit(const.eLightDirY) + 1] = { name = "+Y" }, + [LastSetBit(const.eLightDirNegY) + 1] = { name = "-Y" }, + [LastSetBit(const.eLightDirZ) + 1] = { name = "+Z" }, + [LastSetBit(const.eLightDirNegZ) + 1] = { name = "-Z" }, +} +local shadowDirsDefault = 0 + +DefineClass.PointLight = { + __parents = { "Light" }, + entity = "PointLight", -- needed by the editor + + properties = { + { category = "Visuals", id = "SourceRadius", name = "Source Radius (cm)", editor = "number", min = guic, max=20*guim, default = 10*guic, scale = guic, slider = true, + helper = "sradius", color = RGB(200, 200, 0), autoattach_prop = true, }, + { category = "Visuals", id = "AttenuationRadius", name = "Attenuation Radius", editor = "number", min = 0*guim, max=500*guim, default = 10*guim, scale = "m", slider = true, + helper = "sradius", color = RGB(255, 0, 0), autoattach_prop = true, }, + { category = "Visuals", id = "ShadowDirs", name = "Shadow Dirs (To disable)", editor = "flags", items = const.ShadowDirsComboItems, default = shadowDirsDefault, size = 6, + autoattach_prop = true, }, + }, + + ShadowDirsDefault = shadowDirsDefault, + SourceRadius = 1*guic, + AttenuationRadius = 10*guim, + + Init = function(self) + self:SetSourceRadius(self.SourceRadius) + self:SetAttenuationRadius(self.AttenuationRadius) + self:SetLightType(const.eLightTypePoint) + self:SetShadowDirs(shadowDirsDefault) + end, +} + +DefineClass.LightFlicker = { + __parents = {"InitDone"}, + entity = "PointLight", -- needed by the editor + properties = { + { id = "Color", editor = false }, + { id = "Intensity", editor = false }, + { category = "Visuals", id = "Color0", editor = "color", default = RGB(255,255,255), autoattach_prop = true, }, + { category = "Visuals", id = "Intensity0", editor = "number", default = 0, min = 0, max = 255, slider = true, autoattach_prop = true, }, + { category = "Visuals", id = "Color1", editor = "color", default = RGB(255,255,255), autoattach_prop = true, }, + { category = "Visuals", id = "Intensity1", editor = "number", default = 100, min = 0, max = 255, slider = true, autoattach_prop = true, }, + { category = "Visuals", id = "Period", editor = "number", default = 500, min = 0, max = 100000, scale = 1000, slider = true, autoattach_prop = true, }, + }, + + -- defaults + Period = 40000, +} + +function LightFlicker:Init() + self:SetBehavior("flicker") + self:SetColor(self.Color) + self:SetIntensity0(0) + self:SetIntensity1(self.Intensity) + self:SetPeriod(self.Period) +end + +function LightFlicker:GetPeriod() + local t0,t1 = self:GetTimes() + return t1 - t0 +end + +function LightFlicker:SetPeriod(period) + period = Max(period, 1) + local phase = AsyncRand(period) + local time = self:CurrTime() + if self:CurrTime() < phase then + self:SetTimes(0, period) + else + self:SetTimes(time - phase, time - phase + period) + end +end + +DefineClass.PointLightFlicker = { + __parents = { "PointLight", "LightFlicker" }, +} + +DefineClass.SpotLightFlicker = { + __parents = { "SpotLight", "LightFlicker" }, +} + +DefineClass.MaskedLight = { + __parents = { "Light" }, + properties = { + { category = "Visuals", id = "Mask", editor = "browse", folder = "Textures/Misc/LightMasks", help = "Specifies the texture that is going to be applied to modify the light appearance" }, + { category = "Visuals", id = "AnimX", editor = "number", min = 1, max = 16, help = "How many cuts on the X axis are specified in the mask texture. The animation is traversed left to right." }, + { category = "Visuals", id = "AnimY", editor = "number", min = 1, max = 16, help = "How many cuts on the Y axis are specified in the mask texture. The animation is traversed top to bottom." }, + { category = "Visuals", id = "AnimPeriod", editor = "number", min = 0, max = 256, scale = 10, help = "The period of the animation. If zero is specified, the animation is not applied." }, + { category = "Visuals", id = "ScaleMask", editor = "bool", default = false, }, + }, + + -- defaults + Mask = "Textures/Misc/LightMasks/angle-attn.tga", + ScaleMask = false, + AnimX = 1, + AnimY = 1, + AnimPeriod = 256, + + Init = function(self) + self:SetMask(self.Mask) + self:SetScaleMask(self.ScaleMask) + self:SetAnimX(self.AnimX) + self:SetAnimY(self.AnimY) + self:SetAnimPeriod(self.AnimPeriod) + end, + + GetScaleMask = function(self) return self:GetLightFlags(const.elfScaleMask) end, + SetScaleMask = function(self, scale) + if scale then + self:SetLightFlags(const.elfScaleMask) + else + self:ClearLightFlags(const.elfScaleMask) + end + end, + + GetAnim = function(self, nshift) + local flags = self:GetAnimParams() + local anim_size = band(shift(flags, -nshift), const.elAnimMask) + 1 + return anim_size + end, + + SetAnim = function(self, nshift, num) + local flags = self:GetAnimParams() + local new_data = maskset(flags, shift(const.elAnimMask, nshift), shift(num-1, nshift)) + self:SetAnimParams(new_data) + end, + + GetAnimX = function(self) return self:GetAnim(const.elAnimXShift) end, + SetAnimX = function(self, num) self:SetAnim(const.elAnimXShift, num) end, + + GetAnimY = function(self) return self:GetAnim(const.elAnimYShift) end, + SetAnimY = function(self, num) self:SetAnim(const.elAnimYShift, num) end, + + GetMask = _GetCustomString, + SetMask = _SetCustomString, + + GetAnimPeriod = function(self) + return shift(band(self:GetAnimParams(), const.elAnimPeriodMask), -const.elAnimPeriodShift) + end, + SetAnimPeriod = function(self, period) + local params = self:GetAnimParams() + local new_params = maskset(params, const.elAnimPeriodMask, shift(period, const.elAnimPeriodShift)) + self:SetAnimParams(new_params ) + end, +} + +DefineClass.BoxLight = { + __parents = { "MaskedLight" }, + entity = "PointLight", -- needed by the editor + + properties = { + { category = "Visuals", id = "BoxWidth", editor = "number", min = guim/10, max = 50*guim, slider = true, helper = "box3" }, + { category = "Visuals", id = "BoxHeight", editor = "number", min = guim/10, max = 50*guim, slider = true, helper = "box3" }, + { category = "Visuals", id = "BoxDepth", editor = "number", min = guim/10, max = 50*guim, slider = true, helper = "box3" }, + }, + + -- defaults + BoxWidth = 5 * guim, + BoxHeight = 5 * guim, + BoxDepth = 5 * guim, + + Init = function(self) + self:SetBoxWidth(self.BoxWidth) + self:SetBoxHeight(self.BoxHeight) + self:SetBoxDepth(self.BoxDepth) + self:SetLightType(const.eLightTypeBox) + end, +} + +DefineClass.SpotLight = { + __parents = { "PointLight", "MaskedLight" }, + entity = "PointLight", -- needed by the editor + + properties = { + { category = "Visuals", id = "ConeInnerAngle", editor = "number", min = 5, max = (180 - 5), default = 45, slider = true, helper = "spotlighthelper", autoattach_prop = true, }, + { category = "Visuals", id = "ConeOuterAngle", editor = "number", min = 5, max = (180 - 5), default = 90, slider = true, helper = "spotlighthelper", autoattach_prop = true, }, + }, + + -- defaults + ConeInnerAngle = 45, + ConeOuterAngle = 90, + + target_helper = false, + + Init = function(self) + self:SetConeInnerAngle(self.ConeInnerAngle) + self:SetConeOuterAngle(self.ConeOuterAngle) + self:SetLightType(const.eLightTypeSpot) + end, + + GetConeInnerAngle = function(self) return self:GetInnerAngle() end, + GetConeOuterAngle = function(self) return self:GetOuterAngle() end, +} + +if Platform.developer then + function SpotLight:SetConeInnerAngle(v) + self:SetInnerAngle(v) + if (v > self:GetOuterAngle()) then + self:SetOuterAngle(v) + end + end + function SpotLight:SetConeOuterAngle(v) + self:SetOuterAngle(v) + if (v < self:GetInnerAngle()) then + self:SetInnerAngle(v) + end + end +else + function SpotLight:SetConeInnerAngle(v) self:SetInnerAngle(v) end + function SpotLight:SetConeOuterAngle(v) self:SetOuterAngle(v) end +end + +function SpotLight:OnEditorSetProperty(...) + Light.OnEditorSetProperty(self, ...) + PropertyHelpers_UpdateAllHelpers(self) +end + +function SpotLight:ConfigureTargetHelper() + if not self.target_helper or not IsValid(self.target_helper) then + self.target_helper = PlaceObject("SpotHelper") + self.target_helper.obj = self + end + + local axis = self:GetOrientation() + local pos = self:GetVisualPos() + local o, closest, normal = IntersectSegmentWithClosestObj(pos, pos - axis * guim) + if closest and normal and o ~= self.target_helper then + self.target_helper:SetPos(closest) + else + local newPos = terrain.IntersectRay(pos, pos + axis) + if newPos then + self.target_helper:SetPos(newPos:SetZ(const.InvalidZ)) + end + end +end + +function OnMsg.EditorSelectionChanged(objs) + local isSpotLight = false + for _, obj in ipairs(objs) do + if obj.class == "SpotLight" then + isSpotLight = true + obj:ConfigureTargetHelper() + elseif obj.class == "SpotHelper" then + isSpotLight = true + end + end + if not isSpotLight then + MapForEach(true, "SpotHelper", function(spot_helper) + DoneObject(spot_helper) + end) + end +end + +function OnMsg.EditorCallback(id, objects, ...) + if id == "EditorCallbackMove" or id == "EditorCallbackRotate" or id == "EditorCallbackPlace" then + for _, obj in ipairs(objects) do + if obj.class == "SpotLight" then + obj:ConfigureTargetHelper() + end + end + if id == "EditorCallbackMove" then + for _, obj in ipairs(objects) do + if obj.class == "SpotHelper" and obj.obj.class == "SpotLight" then + obj.obj:SetOrientation(Normalize(obj.obj:GetVisualPos() - obj:GetVisualPos()), 0) + end + end + end + elseif id == "EditorCallbackDelete" then + for _, obj in ipairs(objects) do + if obj.class == "SpotLight" then + DoneObject(obj.target_helper) + obj.target_helper = false + end + end + end +end + +if Platform.developer and false then + function OnMsg.NewMapLoaded() + -- check if used lightmaps are available + local masks = {} + MapForEach("map", "Light", function(light) if light:HasMember("GetMask") then masks[light:GetMask()] = true end end) + for mask, _ in pairs(masks) do + local id = ResourceManager.GetResourceID(mask) + if id == const.InvalidResourceID then + printf("once", "Light mask texture '%s' is not present", mask) + end + end + end +end + +function PointLight:ConfigureInvisibleObjectHelper(helper) + if not helper then return end + local important = self:GetDetailClass() == "Essential" + helper:SetScale(important and 100 or 60) + if important then + helper:SetColorModifier(self:GetCastShadows() and RGB(100, 10, 10) or RGB(20, 80, 100)) + else + helper:SetColorModifier(self:GetCastShadows() and RGB(100, 30, 30) or RGB(40, 80, 100)) + end +end + +DefineClass.AttachLightPropertyObject = { + __parents = {"PropertyObject"}, + + properties = { + {category = "Lights", id = "AttachLight", name = "Attach Light", editor = "bool", default = true}, + }, +} + +local detail_class_weight = {["Essential"] = 1, ["Optional"] = 2, ["Eye Candy"] = 3} + +function GetLights(filter) + if GetMap() == "" then return end + + local lights = MapGet("map", "Light", const.efVisible, filter) or empty_table + table.sort(lights, function(light1, light2) + local weight1 = detail_class_weight[light1:GetDetailClass()] or 4 + local weight2 = detail_class_weight[light2:GetDetailClass()] or 4 + if weight1 == weight2 then + return light1.handle < light2.handle + else + return weight1 < weight2 + end + end) + + return lights +end diff --git a/CommonLua/Classes/Lightmodel.lua b/CommonLua/Classes/Lightmodel.lua new file mode 100644 index 0000000000000000000000000000000000000000..07a5f4297a6475ec60e40f270c924d32a8a07501 --- /dev/null +++ b/CommonLua/Classes/Lightmodel.lua @@ -0,0 +1,1726 @@ +function LocalToEarthTime(time) + return time +end + +function EarthToLocalTime(time) + return time +end + +g_ClassesToHideInCubemaps = { "EditorVisibleObject", "EditorEntityObject", "ShaderBall" } + +function SetIceStrength() end + + +-------------------------------------- Lightmodel Features -------------------------------------- +LightmodelFeatureToProperties = false + + +local function GetFeatureValuesHash(lm, feature) + local prop_ids = LightmodelFeatureToProperties[feature] + if not prop_ids then return 0 end + + local values = {} + for _, prop in ipairs(prop_ids) do + values[prop.id] = lm:GetProperty(prop.id) + end + return table.hash(values) +end + +function CollectUniqueParts(feature) + local hash_to_lmlist = {} + -- fetch properties + local preset_prop_id = "preset_" .. feature + for _, lm in pairs(LightmodelPresets) do + local part_id = lm:GetProperty(preset_prop_id) + if not part_id or part_id == "" then + local hash = GetFeatureValuesHash(lm, feature) + local lm_list = hash_to_lmlist[hash] or {} + table.insert(lm_list, lm.id) + hash_to_lmlist[hash] = lm_list + end + end + + return hash_to_lmlist +end + +function LightmodelEquivalenceByValue(lm, feature) + local hash_to_list = CollectUniqueParts(feature) + local current_hash = GetFeatureValuesHash(lm, feature) + return hash_to_list[current_hash] or {} +end + +function LightmodelFeatures() + local p = {} + + for id in pairs(LightmodelFeatureToProperties) do + if type(id) == "string" then + table.insert(p, id) + end + end + return p +end + +DefineClass.LightmodelFeaturePreset = { + __parents = {"Preset"}, + PresetClass = "LightmodelFeaturePreset", + properties = { + {id = "Group", category = "Preset", editor = "choice", items = LightmodelFeatures, default = false, }, + {category = "Diagnostics", id = "LightmodelRefs", read_only = true, dont_save = true, editor="preset_id_list", preset_class = "LightmodelPreset", default = false, }, + {category = "Diagnostics", id = "EquivalentLMs", read_only = true, dont_save = true, editor="number", default = 0, buttons = { + { name = "List", func = "ListEquivalentLMs"} + } }, + }, + EditorMenubarName = "Lightmodel features", + EditorMenubar = "Editors.Art", +} + +function LightmodelFeaturePreset:GetFeature() + return self.group +end + +function LightmodelFeaturePreset:GetProperties() + local group = self.group + if not group then + return self.properties + end + local prop_ids = LightmodelFeatureToProperties[group] + if not prop_ids then + return self.properties + end + local properties = table.copy(self.properties) + table.iappend(properties, prop_ids) + return properties +end + + +function LightmodelFeaturePreset:GetLightmodelRefs() + local ids = {} + local prop_id = "preset_" .. self:GetFeature() + for lm_id, lm in pairs(LightmodelPresets) do + local referenced = lm:GetProperty(prop_id) + if referenced == self.id then + table.insert(ids, lm_id) + end + end + return ids +end + +function LightmodelFeaturePreset:GetEquivalentLMs() + local list = LightmodelEquivalenceByValue(self, self:GetFeature()) + return #list +end + +function LightmodelFeaturePreset:ListEquivalentLMs(root, prop_id, ged, param) + local list = LightmodelEquivalenceByValue(self, self:GetFeature()) + ged:ShowMessage("EquivalentLMs", table.concat(list, "\n")) +end + +function LightmodelFeaturePreset:IsLMProperty(prop_id) + local prop = self:GetPropertyMetadata(prop_id) + if prop and prop.feature and prop.feature == self:GetFeature() then + return prop + end + return false +end + +function LightmodelFeaturePreset:OnEditorSetProperty(prop_id, old_value, ged, multi) + if not self:IsLMProperty(prop_id) then + return false + end + local value = self:GetProperty(prop_id) + for _, lm_id in ipairs(self:GetLightmodelRefs()) do + local lm = LightmodelPresets[lm_id] + lm:SetProperty(prop_id, value) + ObjModified(lm) + if LightmodelOverride and LightmodelOverride == lm then + lm:OnEditorSetProperty(prop_id, old_value, ged) + end + end +end + +DefineClass.LightmodelPart = { + __parents = {"PropertyObject"}, + properties = { + }, + PresetClass = "LightmodelPart", +} + +-------------------------------------- END OF Lightmodel Features -------------------------------------- +-- generate migration properties, + +RainTypeItems = { + { value = "RainLight", text = "Light" }, + { value = "RainMedium", text = "Medium" }, + { value = "RainHeavy", text = "Heavy" }, +} + +DefineClass.LightmodelRain = { + __parents = {"LightmodelPart"}, + lightmodel_feature = "rain", + lightmodel_category = "Rain", + group = "LightmodelRain", + properties = { + { name = "Rain", id = "rain_enable", editor = "bool", default = false, help = "Switches on and off rain." }, + { name = "Rain Type", id = "rain_type", editor = "combo", default = "RainMedium", items = RainTypeItems, no_edit = PropChecker("rain_enable", false) }, + { name = "Rain Color", id = "rain_color", editor = "color", default = RGBA(255, 255, 255, 255), help = "Models the light properties of the comprising liquid." }, + { name = "Drops Count", id = "rain_drops_count", editor = "number", slider = true, min = 1, max = const.RainMaxDropsCount, default = 1, help = "Mean rain drops count per cubic meter." }, + { name = "Drop Radius", id = "rain_drop_radius", editor = "number", slider = true, min = const.RainMinDropRadius, max = const.RainMaxDropRadius, default = const.RainMinDropRadius, help = "Mean radius used to model properties such as terminal velocity, volume, sprey in microns." }, + { name = "Ground Wetness", id = "rain_ground_wetness", editor = "number", slider = true, min = 0, max = 100, default = 50, scale = 100, help = "How much material properties will be changed by rain shaders." }, + + { name = "Lightning", id = "lightning_enable", editor = "bool", default = false, blend = const.LightningBlendThreshold }, + { name = "Lightning Delay Start", id = "lightning_delay_start", editor = "number", default = 15000, scale = "sec", slider = true }, + { name = "Lightning Interval Min", id = "lightning_interval_min", editor = "number", default = 3000, scale = "sec", slider = true }, + { name = "Lightning Interval Max", id = "lightning_interval_max", editor = "number", default = 120000, scale = "sec", slider = true }, + { name = "Lightning Chance", id = "lightning_strike_chance", editor = "number", default = 40, scale = "%", min = 0, max = 100, slider = true, help = "out of 100, rest are distant thunder." }, + { name = "Lightning Chance Vertical", id = "lightning_vertical_chance", editor = "number", default = 60, scale = "%", min = 0, max = 100, slider = true, help = "If the lightning strike is vertical, otherwise it's horizontal." }, + }, +} + +function OnMsg.LightmodelSetSceneParams(view, lm_buf, time, start_offset) + SetSceneParam(view, "RainEnable", lm_buf.rain_enable and 1 or 0, 0, start_offset) + SetSceneParamColor(view, "RainColor", lm_buf.rain_color, time, start_offset) + SetSceneParam(view, "RainDropsCount", lm_buf.rain_drops_count, time, start_offset) + SetSceneParam(view, "RainDropRadius", lm_buf.rain_drop_radius, time, start_offset) + SetSceneParam(view, "RainGroundWetness", lm_buf.rain_ground_wetness, time, start_offset) +end + +local default_raw_path = insideHG() and ConvertToBenderProjectPath("/DaVinci Resolve/RAW Screenshots/") or ConvertToOSPath("AppData/RAW Screenshots") + +DefineClass.LightmodelColorGrading = { + __parents = {"LightmodelPart"}, + lightmodel_feature = "color_grading", + lightmodel_category = "Color Grading", + group = "LightmodelColorGrading", + properties = { + { name = "Gamma", id = "gamma", editor = "color", default = RGB(128, 128, 128), buttons = {{name = "Gray", func = "Gray"}}, help = "Performs nonlinear gamma correction." }, + { name = "Desaturation", id = "desaturation", editor = "number", default = 0, min = -100, max = 100, scale = 100, slider = true, help = "Performs LDR color desaturation as part of tone mapping." }, + { name = "Base Color Desat", id = "base_color_desat", editor = "number", default = 0, min = -1000, max = 1000, scale = 1000, slider = true, help = "Performs color desaturation on the diffuse texture" }, + { name = "LUT", id = "grading_lut", editor = "preset_id", default = "Default", preset_class = "GradingLUTSource", help = string.format("Grading LUT in %s %s.", GetColorSpaceName(hr.ColorGradingLUTColorSpace), GetColorGammaName(hr.ColorGradingLUTColorGamma)) }, + { name = "RAW Screenshot Path", id = "raw_screenshot_path", dont_save = true, editor = "browse", default = default_raw_path, filter = "OpenEXR (*.exr)|*.exr", os_path = true, allow_missing = true, folder = { { default_raw_path, os_path = true } }, buttons = {{name = "Screenshot", func = "CaptureRAWScreenshot"}}}, + { name = "Post Grading LUT Path", id = "post_grading_lut_path", editor = "browse", default = "", filter = "LUT (*.cube)|*.cube", folder = { "svnAssets/Source/Editor/DeVinci Resolve/Viewing LUTs/" }, allow_missing = true}, + { name = "Post Grading LUT Size", id = "post_grading_lut_size", editor = "choice", default = 65, items = { 16, 17, 32, 33, 64, 65 }, buttons = {{name = "Capture", func = "CapturePostGradingLUT"}} }, + }, +} + +function LightmodelColorGrading:CaptureRAWScreenshot(root, prop_id, ged) + hr.PostProcRAWOutputPath = self.raw_screenshot_path +end + +function LightmodelColorGrading:CapturePostGradingLUT(root, prop_id, ged) + if self.post_grading_lut_path == "" then + return + end + ExportToneMappingLUT(self.post_grading_lut_size, self.post_grading_lut_path) +end + +function LightmodelColorGrading:Setpost_grading_lut_path(value) + self.post_grading_lut_path = AppendDefaultExtension(value, ".cube") +end + +function LightmodelColorGrading:Setraw_screenshot_path(value) + self.raw_screenshot_path = AppendDefaultExtension(value, ".exr") +end + +function OnMsg.LightmodelSetSceneParams(view, lm_buf, time, start_offset) + SetSceneParamColor(view, "Gamma", lm_buf.gamma, time, start_offset, not "gamma-encoded") + SetSceneParam(view, "Desaturation", lm_buf.desaturation, time, start_offset) + SetSceneParam(view, "BaseColorDesat", lm_buf.base_color_desat, time, start_offset) + + local grading_lut_preset_id = lm_buf.grading_lut + if grading_lut_preset_id == "" or not GradingLUTs[lm_buf.grading_lut] then + grading_lut_preset_id = "Default" + end + + local grading_lut_resource_id = ResourceManager.GetResourceID(GradingLUTs[grading_lut_preset_id]:GetResourcePath()) + SetGradingLUT(view, grading_lut_resource_id, time, start_offset) +end + +DefineClass.LightmodelOpticalAnomalies = { + __parents = {"LightmodelPart"}, + lightmodel_feature = "optical_anomalies", + lightmodel_category = "Optical Anomalies", + group = "LightmodelOpticalAnomalies", + properties = { + { category = "Vignette", name = "Tint Color", id = "vignette_tint_color", editor = "color", default = RGBA(0,0,0,0), help = "Vignette tint color." }, + { category = "Vignette", name = "Tint Start", id = "vignette_tint_start", editor = "number", float = true, slider = true, step = 0.001, min = 0.0, max = 1.0, default = 0.7, help = "Start radius of the gradient towards pure color." }, + { category = "Vignette", name = "Tint Feather", id = "vignette_tint_feather", editor = "number", slider = true, float = true, step = 0.001, min = 0.0, max = 1.0, default = 1.0, help = "How large the gradient towards pure color is." }, + { category = "Vignette", name = "Darken Opacity", id = "vignette_darken_opacity", editor = "number", float = true, slider = true, step = 0.001, min = 0, max = 1.0, default = 0.0, help = "The opacity of the vignette layer." }, + { category = "Vignette", name = "Darken Start", id = "vignette_darken_start", editor = "number", float = true, slider = true, step = 0.001, min = 0.0, max = 1.0, default = 0.7, help = "Start radius of the gradient towards black." }, + { category = "Vignette", name = "Darken Feather", id = "vignette_darken_feather", editor = "number", float = true, slider = true, step = 0.001, min = 0.0, max = 1.0, default = 1.0, help = "How large the gradient towards black is." }, + { category = "Vignette", name = "Circularity", id = "vignette_circularity", editor = "number", float = true, slider = true, step = 0.01, min = 0.0, max = 1.0, default = 0.0, help = "Control gradient roundness." }, + + { category = "Chromatic Aberration", name = "Intensity", id = "chromatic_aberration_intensity", editor = "number", float = true, slider = true, step = 0.001, min = 0.0, max = 10.0, default = 0.0 }, + { category = "Chromatic Aberration", name = "Start", id = "chromatic_aberration_start", editor = "number", float = true, slider = true, step = 0.001, min = 0.0, max = 1.0, default = 0.0 }, + { category = "Chromatic Aberration", name = "Feather", id = "chromatic_aberration_feather", editor = "number", float = true, slider = true, step = 0.001, min = 0.0, max = 1.0, default = 1.0 }, + { category = "Chromatic Aberration", name = "Circularity", id = "chromatic_aberration_circularity", editor = "number", float = true, slider = true, step = 0.01, min = 0.0, max = 1.0, default = 0.0 }, + }, +} + +function OnMsg.LightmodelSetSceneParams(view, lm_buf, time, start_offset) + local vignette_tint_max = lm_buf:GetPropertyMetadata("vignette_tint_start").max + local vignette_tint_end = + lm_buf.vignette_tint_start + (vignette_tint_max - lm_buf.vignette_tint_start) * lm_buf.vignette_tint_feather + + SetSceneParamColor(view, "VignetteTintColor", lm_buf.vignette_tint_color, time, start_offset) + SetSceneParamFloat(view, "VignetteTintStart", lm_buf.vignette_tint_start, time, start_offset) + SetSceneParamFloat(view, "VignetteTintEnd", vignette_tint_end, time, start_offset) + SetSceneParamFloat(view, "VignetteCircularity", lm_buf.vignette_circularity, time, start_offset) + + local vignette_darken_max = lm_buf:GetPropertyMetadata("vignette_darken_start").max + local vignette_darken_end = + lm_buf.vignette_darken_start + (vignette_darken_max - lm_buf.vignette_darken_start) * lm_buf.vignette_darken_feather + + SetSceneParamFloat(view, "VignetteDarkenOpacity", lm_buf.vignette_darken_opacity, time, start_offset) + SetSceneParamFloat(view, "VignetteDarkenStart", lm_buf.vignette_darken_start, time, start_offset) + SetSceneParamFloat(view, "VignetteDarkenEnd", vignette_darken_end, time, start_offset) + + local chromatic_aberration_max = lm_buf:GetPropertyMetadata("chromatic_aberration_start").max + local chromatic_aberration_end = + lm_buf.chromatic_aberration_start + (chromatic_aberration_max - lm_buf.chromatic_aberration_start) * lm_buf.chromatic_aberration_feather + + SetSceneParamFloat(view, "ChromaticAberrationIntensity", lm_buf.chromatic_aberration_intensity, time, start_offset) + SetSceneParamFloat(view, "ChromaticAberrationStart", lm_buf.chromatic_aberration_start, time, start_offset) + SetSceneParamFloat(view, "ChromaticAberrationEnd", chromatic_aberration_end, time, start_offset) + SetSceneParamFloat(view, "ChromaticAberrationCircularity", lm_buf.chromatic_aberration_circularity, time, start_offset) +end + +DefineClass.LightmodelTranslucency = { + __parents = {"LightmodelPart"}, + lightmodel_feature = "translucency", + lightmodel_category = "Translucency", + group = "LightmodelTranslucency", + properties = { + { name = "Scale", id = "translucency_scale", editor = "number", float = true, slider = true, step = 0.001, default = 0.3, min = 0.0, max = 1.0, help = "Translucency overall scale" }, + { name = "Distort Sun Direction", id = "translucency_distort_sun_dir", editor = "number", float = true, slider = true, default = 0.12, min = 0.0, max = 2.0, help = "How much to distort the sun direction toward the material normal" }, + { name = "Sun Falloff", id = "translucency_sun_falloff", editor = "number", float = true, slider = true, step = 0.001, default = 30.0, min = 0.0, max = 50.0, help = "Power that controls how fast the sun contribution falls off when with the difference of direction between the view and the sun" }, + { name = "Sun Scale", id = "translucency_sun_scale", editor = "number", float = true, slider = true, step = 0.001, default = 0.2, min = 0.0, max = 1.0, help = "Sunlight contribution scale" }, + { name = "Ambient Scale", id = "translucency_ambient_scale", editor = "number", float = true, slider = true, step = 0.001, default = 0.02, min = 0.0, max = 1.0, help = "Ambient contribution scale" }, + { name = "Base Luminance", id = "translucency_base_luminance", editor = "number", float = true, slider = true, step = 0.001, default = 1.335, min = 0.0, max = 3.0, help = "Assumed base light luminance" }, + { name = "Base Color Temperature", id = "translucency_base_k", editor = "number", float = true, slider = true, step = 1.0, default = 3600.0, min = 1000.0, max = 6500.0, help = "Assumed sunlight base temperature in degrees K" }, + { name = "Reduce Color Temperature", id = "translucency_reduce_k", editor = "number", float = true, slider = true, step = 1.0, default = 1000.0, min = 0.0, max = 5000.0, help = "Color reduction temperature in degrees K" }, + { name = "Desaturation", id = "translucency_desaturation", editor = "number", float = true, slider = true, step = 0.001, default = 0.3, min = 0.0, max = 1.0, help = "Amount to desaturate the color of translucent light" }, + }, +} + +function OnMsg.LightmodelSetSceneParams(view, lm_buf, time, start_offset) + SetSceneParamFloat(view, "TranslucencyScale", lm_buf.translucency_scale, time, start_offset) + SetSceneParamFloat(view, "TranslucencySunDirDistort", lm_buf.translucency_distort_sun_dir, time, start_offset) + SetSceneParamFloat(view, "TranslucencySunFalloff", lm_buf.translucency_sun_falloff, time, start_offset) + SetSceneParamFloat(view, "TranslucencySunScale", lm_buf.translucency_sun_scale, time, start_offset) + SetSceneParamFloat(view, "TranslucencyAmbientScale", lm_buf.translucency_ambient_scale, time, start_offset) + SetSceneParamFloat(view, "TranslucencyBaseLuminance", lm_buf.translucency_base_luminance, time, start_offset) + SetSceneParamFloat(view, "TranslucencyBaseK", lm_buf.translucency_base_k, time, start_offset) + SetSceneParamFloat(view, "TranslucencyReduceK", lm_buf.translucency_reduce_k, time, start_offset) + SetSceneParamFloat(view, "TranslucencyDesaturate", lm_buf.translucency_desaturation, time, start_offset) +end + +DefineClass.LightmodelClouds = { + __parents = {"LightmodelPart"}, + lightmodel_feature = "clouds", + lightmodel_category = "Clouds", + group = "LightmodelClouds", + properties = { + { category = "Cloud shadows", feature = "clouds", name = "Clouds shadow strength", id = "clouds_strength", default = 0, editor = "number", slider = true, min = 0, max = 1000, scale = 1000, help = "Maximum clouds darkness. Clouds source texture path is CommonAssets/System/clouds.dds." }, + { category = "Cloud shadows", feature = "clouds", name = "Clouds shadow coverage", id = "clouds_coverage", default = 500, editor = "number", slider = true, min = 0, max = 1000, scale = 1000, help = "How much of the surface is covered with clouds." }, + { category = "Cloud shadows", feature = "clouds", name = "Clouds shadow smoothness", id = "clouds_smoothness", default = 300, editor = "number", slider = true, min = 0, max = 1000, scale = 1000, help = "How sharp are the edges of the clouds." }, + { category = "Cloud shadows", feature = "clouds", name = "Clouds shadow scrub", id = "clouds_phase", default = 0, editor = "number", slider = true, min = 0, max = 1000, scale = 1000, help = "Scrub back and forth to see clouds move.", dont_save = true }, + { category = "Cloud shadows", feature = "clouds", name = "Clouds shadow scale", id = "clouds_scale", default = 1000, editor = "number", slider = true, min = 300, max = 10000, scale = 1000, help = "When interpolating lightmodels the clouds scale should be the same, otherwise the clouds will appear to move rapidly." }, + { category = "Cloud shadows", feature = "clouds", name = "Oscillation period", id = "clouds_osci_period", default = 5000, editor = "number", slider = true, min = 2000, max = 3600 * 1000, scale = 1000, }, + { category = "Cloud shadows", feature = "clouds", name = "Oscillation amplitude", id = "clouds_osci_amplitude", default = 0, editor = "number", slider = true, min = 0, max = 2000, scale = 1000, }, + { category = "Cloud shadows", feature = "clouds", name = "Clouds direction", id = "clouds_dir", default = 0, editor = "number", scale = "deg", slider = true, min = 0, max = 360*60, help = "The direction of cloud movement in degrees." }, + { category = "Cloud shadows", feature = "clouds", name = "Wind strength", id = "clouds_wind_strength", default = 0, editor = "number", scale = 1000, slider = true, min = 0, max = 1000, help = "Should clouds be affected by wind?" }, + { category = "Cloud shadows", feature = "clouds", name = "Clouds speed (m/s)", id = "clouds_speed", default = 3000, editor = "number", scale = 1000, slider = true, min = 0, max = 50*guim, help = "Clouds movement in meters per second." }, + } +} + +function OnMsg.LightmodelSetSceneParams(view, lm_buf, time, start_offset) + if not config.LightModelUnusedFeatures["clouds"] then + SetSceneParam(view, "CloudsStrength", lm_buf.clouds_strength, time, start_offset) + SetSceneParam(view, "CloudsCoverage", lm_buf.clouds_coverage, time, start_offset) + SetSceneParam(view, "CloudsSmoothness", lm_buf.clouds_smoothness, time, start_offset) + SetSceneParam(view, "CloudsSpeed", lm_buf.clouds_speed, time, start_offset) + SetSceneParam(view, "CloudsPhase", lm_buf.clouds_phase, time, start_offset) + SetSceneParam(view, "CloudsDirectionSP", lm_buf.clouds_dir, time, start_offset) + SetSceneParam(view, "CloudsWindStrength", lm_buf.clouds_wind_strength, time, start_offset) + SetSceneParam(view, "CloudsScale", lm_buf.clouds_scale, time, start_offset) + SetSceneParam(view, "CloudsOsciPeriod", lm_buf.clouds_osci_period, time, start_offset) + SetSceneParam(view, "CloudsOsciAmplitude", lm_buf.clouds_osci_amplitude, time, start_offset) + end +end + + +AutoExposureInstructions = +[[To adjust exposure: +- turn off via button to the right +- view a representative mid-brightness scene +- reset Exposure to default value +- adjust brightness using sun intensity, envmaps exposure, and if all else fails, Exposure +- switch to split mode using button to the right +- adjust Auto Exposure until the two parts of the screen match in brightness +- verify by moving the camera to a dark and to a bright spot]] + +CubemapInstructions = [[Usually the sky is fetched from the cubemap, but when capturing the cubemap the sky is unavailable and approximated by a slower method. + - Please use the "Cubemap capture preview" toggle to see the result of the slower method in real time. + - Make sure to use this for "Bake" lightmodels. +]] + +local sky_custom_sun_ro = function(self) return not self.sky_custom_sun or self.use_time_of_day end +local shadow_range_ro = function(self) return not self.shadow end +local custom_sun_ro = function(self) return self.use_time_of_day end +local tod_ro = function(self) return not self.use_time_of_day end +DefineClass.Lightmodel = { + __parents = { "PropertyObject" }, + properties = { + { category = "Sun", feature = "phys_sky", name = "Sun diffuse color", id = "sun_diffuse_color", editor = "color", default = RGB(255, 255, 255), help = "The color of sunlight.\nAffects the diffuse contribution of the Sun." }, + { category = "Sun", feature = "phys_sky", name = "Sun diffuse intensity", id = "sun_intensity", editor = "number", slider=true, min = 0, max = 2500, default = 100, help = "The intensity of the sun diffuse contribution." }, + { category = "Sun", feature = "phys_sky", name = "Sun specular intensity", id = "sun_angular_radius", editor = "number", slider=true, min = 0, max = 2500, default = 100, help = "The intensity of the sun specular contribution." }, + { category = "Sun", feature = "shadow", name = "Shadow", id = "shadow", editor = "number", slider = true, min = 0, max = 1000, scale = 1000, default = 1000, help = "Shadow strength." }, + { category = "Sun", feature = "shadow", name = "Shadow range", id = "shadow_range", editor = "number", slider = true, min = 0, max = 10000 * guim, scale = "m", default = 500 * guim, help = "Limits the distance at which the shadows are visible.", read_only = shadow_range_ro }, + + { category = "Sun (dev tools)", feature = "sun_path", id = "_help", editor = "help", help = "These are not saved; use to tweak sun parameters for testing." }, + { category = "Sun (dev tools)", feature = "sun_path", name = "Sunrise time", id = "sunrise_time", editor = "number", default = 8*60, help = "", dont_save = true }, + { category = "Sun (dev tools)", feature = "sun_path", name = "Sunset time", id = "sunset_time", editor = "number", default = 20*60, help = "", dont_save = true }, + { category = "Sun (dev tools)", feature = "sun_path", name = "Earth time info", id = "sun_earthtime_info", editor = "text", default = "", help = "", dont_save = true, read_only = true}, + { category = "Sun (dev tools)", feature = "sun_path", name = "Sunrise azi", id = "sunrise_azi", editor = "number", default = 54*60, slider = true, min = 0*60, max = 360*60, scale = "deg", help = "Azimuth is from 0Deg North, 90Deg East ... 360Deg. Sunrise azi + Sunset azi generally should make 360Deg.", dont_save = true }, + { category = "Sun (dev tools)", feature = "sun_path", name = "Sunset azi", id = "sunset_azi", editor = "number", default = 306*60, help = "Azimuth is from 0Deg North, 90Deg East ... 360Deg. Sunrise azi + Sunset azi generally should make 360Deg.", slider = true, min = 0*60, max = 360*60, scale = "deg", dont_save = true }, + { category = "Sun (dev tools)", feature = "sun_path", name = "Sun max elevation", id = "sun_max_elevation", editor = "number", default = 70*60, help = "This is the maximum angle from the horizon to the center of the sun disk (reached at noon).", slider = true, min = 0, max = 89*60, scale = "deg", dont_save = true }, + { category = "Sun (dev tools)", feature = "sun_path", name = "Sun shadow min", id = "sun_shadow_min", editor = "number", default = 15*60, help = "This is the min elevation that the sun will cast shadows from to avoid super long shadows.", slider = true, min = 0, max = 89*60, scale = "deg", dont_save = true }, + { category = "Sun (dev tools)", feature = "sun_path", name = "North rotation", id = "sun_nr", editor = "number", default = 0, help = "Add this angle to all azimuths to effectively rotate where North is on the map.", slider = true, min = 0*60, max = 360*60, scale = "deg", dont_save = true }, + + { category = "Sun Pos", feature = "phys_sky", name = "Use time of day", id = "use_time_of_day", editor = "bool", default = true, help = "Sun position is determined by the current time of day setup." }, + { category = "Sun Pos", feature = "phys_sky", name = "Start Time (h)", id = "time", editor = "number", default = 12*60, scale = 60, min = 0, max = 24*60, slider = true, help = "This is the time when the blending to this model will start. View shows it ignoring blending.", read_only = tod_ro, buttons = {{name = "View", func = "PreviewStart"}}}, + { category = "Sun Pos", feature = "phys_sky", name = "End Time", id = "time_next", editor = "text", default = "", help = "This is the time when the blending to next model will start. View shows it ignoring blending.", read_only = true, dont_save = true, buttons = {{name = "View", func = "PreviewEnd"}}}, + { category = "Sun Pos", feature = "phys_sky", name = "Blend Duration (m)", id = "blend_time", editor = "number", default = 60, help = "Controls the duration of the blend. View buttons take into account the blending, i.e. 'Start' shows accurately the end of the previous model.", read_only = tod_ro, buttons = {{name = "Start", func = "PreviewBlendStart"}, {name = "End", func = "PreviewBlendEnd"}, {name = "Preview", func = "PreviewBlend"}}}, + { category = "Sun Pos", feature = "phys_sky", name = "Sun alt", id = "sun_alt", min = -1800, max = 1800, editor = "number", slider = true, scale = 10, default = 250, help = "At what angle (in 1/10 degrees) relative to the horizon (height) is the Sun.", read_only = custom_sun_ro }, + { category = "Sun Pos", feature = "phys_sky", name = "Sun shadow alt", id = "sun_shadow_height", editor = "number", default = 0, slider = true, scale = 10, min = 0, max = 1800, help = "0 = Use sun alt for shadow direction. > 0 forces shadows as if the sun is at this altitutude", read_only = custom_sun_ro}, + { category = "Sun Pos", feature = "phys_sky", name = "Sun azi", id = "sun_azi", min = 0, max = 360, editor = "number" , slider = true, default = 180, help = "The position of the Sun relative to the world, specified as an angle in degrees.", read_only = custom_sun_ro }, + { category = "Sun Pos", feature = "phys_sky", name = "Sky custom sun", id = "sky_custom_sun", editor = "bool" , default = false, help = "If checked allows overriding the disk sun position with a separate custom one for the sun in the sky.", read_only = custom_sun_ro }, + { category = "Sun Pos", feature = "phys_sky", name = "Sky custom sun azi", id = "sky_custom_sun_azi", min = 0, max = 360, editor = "number" , slider = true, default = 180, help = "The azimuth of Sun in the Sky, specified as an angle in degrees." , read_only = sky_custom_sun_ro }, + { category = "Sun Pos", feature = "phys_sky", name = "Sky custom sun alt", id = "sky_custom_sun_alt", min = -1800, max = 1800, editor = "number", slider = true, default = 0, help = "The altitude of Sun in the Sky, specified as an angle in degrees.", read_only = sky_custom_sun_ro}, + + { category = "Sky", feature = "phys_sky", id = "__", editor = "help", name = "Cubemap help", help = CubemapInstructions, }, + { category = "Sky", feature = "phys_sky", name = "Mie coefs", id = "mie_coefs", editor = "color", default = RGB(210, 210, 210), help = "Mie coefficients control sun color. It is also affected by the Rayleight param." }, + { category = "Sky", feature = "phys_sky", name = "Rayleigh coefs", id = "ray_coefs", editor = "color", default = RGB(55, 130, 221), help = "Rayleigh coefficient control sky color. It is also affected by the Mie param." }, + { category = "Sky", feature = "phys_sky", name = "Mie scale height", id = "mie_sh", editor = "number", slider = true, min=100, max = 10000, default = 1200, help = "Mie scale height controls the height at which the atmosphere is half dense for this scattering." }, + { category = "Sky", feature = "phys_sky", name = "Rayleigh scale height", id = "ray_sh", editor = "number", slider = true, min=1000, max = 16000, default = 7994, help = "Rayleigh scale height controls the height at which the atmosphere is half dense for this scattering." }, + { category = "Sky", feature = "phys_sky", name = "Mie Shape", id = "mie_mc", editor = "number", slider = true, min=750, max = 999, default = 860, help = "The G param (mean cosine) controls the asymmetry (shape) of the mie phase function." }, + { category = "Sky", feature = "phys_sky", name = "Exposure", id = "sky_exp", min = -1000, max = 1000, default = 0, editor = "number" , slider = true, help = "The exposure control in 1/100 EV. Intensity change is pow(2, E / 100)" }, + { category = "Sky", feature = "phys_sky", name = "Sky IS", id = "sky_is", editor=false, dont_save = true, default = false, help = "Toggles realtime update of sky contribution with importance sampling (for lightmodel tweak only)" }, + { category = "Sky", feature = "phys_sky", name = "Cubemap capture preview", editor = "bool", dont_save = true, id = "cubemap_capture_preview", default = false, help = "Mostly enables Sky importance sampling, among other things" }, + + { category = "Cubemap", feature = "phys_sky", name = "Exterior Env map", id = "exterior_envmap", default = "PainterStudioDaylight", editor = "dropdownlist", help = "The current exterior environment map texture.", items = function() return GetEnvMapsList("Exterior") end }, + { category = "Cubemap", feature = "phys_sky", name = "Exterior Env Exposure", id = "ext_env_exposure", min = -1000, max = 1000, default = 0, editor = "number" , slider = true, help = "The exterior env exposure control in 1/100 EV. Intensity change is pow(2, E / 100)" }, + { category = "Cubemap", feature = "phys_sky", name = "Exterior Env Map Image", id = "ExteriorEnvmapImage", editor = "image", default = "", dont_save = true, img_size = 128, img_box = 1 }, + { category = "Cubemap", feature = "phys_sky", name = "Interior Env map", id = "interior_envmap", default = "PainterStudioDaylight", editor = "dropdownlist", help = "The current interior environment map texture.", items = function() return GetEnvMapsList("Interior") end }, + { category = "Cubemap", feature = "phys_sky", name = "Interior Env Exposure", id = "int_env_exposure", min = -1000, max = 1000, default = 0, editor = "number" , slider = true, help = "The interior env exposure control in 1/100 EV. Intensity change is pow(2, E / 100)" }, + { category = "Cubemap", feature = "phys_sky", name = "Interior Env Map Image", id = "InteriorEnvmapImage", editor = "image", default = "", dont_save = true, img_size = 128, img_box = 1 }, + + { category = "Night Sky", feature = "phys_sky", name = "Stars Intensity", id = "stars_intensity", min = 0, max = 1000, scale = 1000, editor = "number", slider = true, default = 0, help = "Controls the brightness of the stars." }, + { category = "Night Sky", feature = "phys_sky", name = "Stars Blue tint", id = "stars_blue_tint", min = 0, max = 100, scale = 100, editor = "number", slider = true, default = 30, help = "Faint stars are tinted blue to simulate human perception in low-light conditions." }, + { category = "Night Sky", feature = "phys_sky", name = "MilkyWay Intensity", id = "mw_intensity", min = 0, max = 1000, scale = 1000, editor = "number", slider = true, default = 0, help = "Controls the brightness of the milky way texture." }, + { category = "Night Sky", feature = "phys_sky", name = "MilkyWay Blue tint", id = "mw_blue_tint", min = 0, max = 100, scale = 100, editor = "number", slider = true, default = 30, help = "MilkyWay is tinted blue to simulate human perception in low-light conditions." }, + { category = "Night Sky", feature = "phys_sky", name = "Rotation", id = "stars_rotation", min = 0, max = 3600, editor = "number", slider = true, default = 0, scale = 10, help = "Rotation angle of the stars around the celestial pole" }, + { category = "Night Sky", feature = "phys_sky", name = "Celestial Pole Altitude", id = "stars_pole_alt", min = 0, max = 1800, editor = "number", slider = true, default = 0, scale = 10, help = "Celestial pole altitude in degrees. Approximately equal to the observer's latitude position on Earth." }, + { category = "Night Sky", feature = "phys_sky", name = "Celestial Pole Azimuth", id = "stars_pole_azi", min = 0, max = 3600, editor = "number", slider = true, default = 0, scale = 10, help = "Celestial pole azimuth in degrees. Should point to North on Earth. Related to the Sun azimuth." }, + + { category = "Env Capture", feature = "phys_sky", name = "Sky Exp Exterior Adjust", id = "env_exterior_capture_sky_exp", editor="number", default = "", min = -1000, max = 1000, default = 0, editor = "number" , slider = true, help = "Adjusts the sky exposure in the captured exterior cubemap"}, + { category = "Env Capture", feature = "phys_sky", name = "Sun Int Exterior Adjust", id = "env_exterior_capture_sun_int", editor="number", default = "", min = -2500, max = 2500, default = 0, editor = "number" , slider = true, help = "Adjusts the sun intensity in the captured exterior cubemap"}, + { category = "Env Capture", feature = "phys_sky", name = "Exterior Capture Pos", id = "env_exterior_capture_pos", editor="point", default = InvalidPos(), helper = "absolute_pos", help = "The position to capture the exterior env map from.", buttons = {{name = "View", func = "ViewExteriorEnvPos" }, {name ="Use Shaderball", func = "UseSelectionAsExteriorEnvPos"}}, scale = "m"}, + { category = "Env Capture", feature = "phys_sky", name = "Sky Exp Interior Adjust", id = "env_interior_capture_sky_exp", editor="number", default = "", min = -1000, max = 1000, default = 0, editor = "number" , slider = true, help = "Adjusts the sky exposure in the captured interior cubemap"}, + { category = "Env Capture", feature = "phys_sky", name = "Sun Int Interior Adjust", id = "env_interior_capture_sun_int", editor="number", default = "", min = -2500, max = 2500, default = 0, editor = "number" , slider = true, help = "Adjusts the sun intensity in the captured interior cubemap"}, + { category = "Env Capture", feature = "phys_sky", name = "Interior Capture Pos", id = "env_interior_capture_pos", editor="point", default = InvalidPos(), helper = "absolute_pos", help = "The position to capture the interior env map from.", buttons = {{name = "View", func = "ViewInteriorEnvPos" }, {name ="Use Shaderball", func = "UseSelectionAsInteriorEnvPos"}}, scale = "m"}, + { category = "Env Capture", feature = "phys_sky", name = "Map for Cubemaps", id = "env_capture_map", editor="text", default = "", read_only=true, help = "The map name on which the position of the last capture is"}, + { category = "Env Capture", feature = "phys_sky", name = "Capture", id = "env_capture", editor="text", default = "", read_only=true, help = "Click to capture cubemaps", buttons = {{name = "Exterior", func = "CaptureExteriorEnvmap"}, {name = "Interior", func = "CaptureInteriorEnvmap"}, {name = "Both", func = "CaptureBothEnvmaps"}}}, + { category = "Env Capture", feature = "phys_sky", name = "View Map", id = "env_view_site", editor = "dropdownlist", items = {"Exterior", "Interior"}, default = "Exterior", dont_save = true, buttons = {{name = "View", func = "ViewEnv"}, {name = "Hide", func = "HideEnv"}}}, + { category = "Env Capture", feature = "phys_sky", name = "Convert HDR Pano", id = "hdr_pano", editor = "browse", default = false, filter = "Radiance HDR File|*.hdr", default = "", dont_save = true, buttons = {{name = "Exterior", func = "ConvertExteriorEnvmap"}, {name = "Interior", func = "ConvertInteriorEnvmap"}}}, + { category = "Env Capture", feature = "phys_sky", name = "Lightmodel for Capture", id = "lm_capture", editor = "preset_id", preset_class = "LightmodelPreset", default = "", help = "Indicates which light model should be used for the baking.", no_validate = true, }, + + { category = "Fog", feature = "fog", name = "Fog color", id = "fog_color", default = RGB(180, 180, 180), editor = "color", help = "The default color of the fog." }, + { category = "Fog", feature = "fog", name = "Fog density", id = "fog_density", default = 20, min = 0, max = 2000, scale = 100, editor = "number", slider = true, help = "The fog thickness." }, + { category = "Fog", feature = "fog", name = "Fog height falloff", id = "fog_height_falloff" , default = 1500, min = 1, max = 2500, editor = "number", slider = true, help = "The fog thickness change with height" }, + { category = "Fog", feature = "fog", name = "Fog start (m)", id = "fog_start", default = 0, min = 0, max = 1000 * 1000, scale = 1000, editor = "number" , slider = true, help = "The distance to fog start, in meters."}, + + { category = "Water", feature = "water", name = "Water Color", id = "water_color", editor = "color", default = RGB(127, 127, 127), help = "The color of the terrain water" }, + { category = "Water", feature = "water", name = "Opacity Modifier", id = "absorption_coef", min = 0, max = 100, default = 100, scale = 100, editor = "number", slider = true, help = "How much light does the water absorb" }, + { category = "Water", feature = "water", name = "Reflection Modifier", id = "minimum_depth", min = 0, max = 200, default = 100, scale = 100, editor = "number", slider = true, help = "How deep will the water appear minimally" }, + + { category = "Ice", feature = "ice", name = "Ice color", id = "ice_color", editor = "color", default = RGB(255, 255, 255), help = "The color of the ice that will affect the buildings and rocks" }, + { category = "Ice", feature = "ice", name = "Ice strength", id = "ice_strength", min = 0, max = 100, default = 0, scale = 100, editor = "number", slider = true, help = "How strong will be ice get" }, + + { category = "Snow", feature = "snow", name = "Snow color", id = "snow_color", editor = "color", default = RGB(167, 167, 167), help = "The color of the snow of the terrain" }, + { category = "Snow", feature = "snow", name = "Snow direction X", id = "snow_dir_x", editor = "number", slider = true, default = 0, min = -1000, max = 1000, scale = 1000, help = "Snowfall direction X" }, + { category = "Snow", feature = "snow", name = "Snow direction Y", id = "snow_dir_y", editor = "number", slider = true, default = 0, min = -1000, max = 1000, scale = 1000, help = "Snowfall direction Y" }, + { category = "Snow", feature = "snow", name = "Snow direction Z", id = "snow_dir_z", editor = "number", slider = true, default = 1000, min = -1000, max = 1000, scale = 1000, help = "Snowfall direction Z" }, + { category = "Snow", feature = "snow", name = "Snow strength", id = "snow_str", editor = "number", slider = true, default = 0, min = 0, max = 1000, scale = 1000, help = "The constant snow strength" }, + { category = "Snow", feature = "snow", name = "Snow", id = "snow_enable", editor = "bool", default = false, }, + + { category = "Wind", id = "wind", name = "Wind", editor = "preset_id", default = false, preset_class = "WindDef" }, + + { category = "Heat Haze", id = "enable_heat_haze", name = "Enable Heat Haze", editor = "bool", default = false }, + + { category = "Distance Blur and Desaturation", feature = "dist_blur_desat", name = "Blur", id = "pp_blur", min = 0, max = 100, default = 0, editor = "number",slider = true, help = "The intensity of the blur effect for distant objects." }, + { category = "Distance Blur and Desaturation", feature = "dist_blur_desat", name = "Blur distance", id = "pp_blur_distance", min = 0, max = 600, default = 0, editor = "number", slider = true, help = "The distance at which the distant objects start to get blurry." }, + { category = "Distance Blur and Desaturation", feature = "dist_blur_desat", name = "Desaturation", id = "pp_desaturation", min = 0, max = 100, default = 0, editor = "number", slider = true, help = "How intense is the desaturation of colors of the distant objects." }, + { category = "Distance Blur and Desaturation", feature = "dist_blur_desat", name = "Desaturation distance", id = "pp_desaturation_distance", min = 0, max = 600, default = 0, editor = "number", slider = true, help = "The distance at which the distant objects' colors get desaturated." }, + + { category = "Exposure", feature = "autoexposure", id="_", editor="help", default=false, help = AutoExposureInstructions, buttons = {{name = "[On]", func = "AutoExposureOn"},{name = "[Off]", func = "AutoExposureOff"}, {name = "[Split]", func = "AutoExposureSplit"}, {name="[Debug]", func="AutoExposureDebugToggle"}} }, + { category = "Exposure", feature = "autoexposure", name = "Exposure", id = "exposure", min = -200, max = 200, default = 0, editor = "number" , slider = true, help = "The global exposure control in 1/100 EV. Intensity change is pow(2, E / 100)" }, + { category = "Exposure", feature = "autoexposure", name = "Auto Exposure (AE)", id="ae_key_bias", min = -3000000, max = 3000000, default = 0, scale = 1000000, editor = "number" , slider = true, help = "Exposure key value multiplier." }, + { category = "Exposure", feature = "autoexposure", name = "Scene lum min", id="ae_lum_min", min = -14*10000, max = 20*10000, default = -14*10000, scale = 10000, editor = "number" , slider = true, help = "Clamps average scene luminance.", no_edit = true }, + { category = "Exposure", feature = "autoexposure", name = "Scene lum max", id="ae_lum_max", min = -14*10000, max = 20*10000, default = 20*10000, scale = 10000, editor = "number" , slider = true, help = "Clamps average scene luminance.", no_edit = true }, + { category = "Exposure", feature = "autoexposure", name = "Adaptation speed bright",id="ae_adapt_speed_bright", min = 1, max = 1500, default = 500, scale = 100, editor = "number" , slider = true, help = "How fast the eye adapts to brighter scenes.", no_edit = true }, + { category = "Exposure", feature = "autoexposure", name = "Adaptation speed dark", id="ae_adapt_speed_dark", min = 1, max = 1500, default = 500, scale = 100, editor = "number" , slider = true, help = "How fast the eye adapts to darker scenes.", no_edit = true }, + { category = "Exposure", feature = "autoexposure", name = "AE Lum Min", id="ae_darkness_ign", min = 0, max = 99, default = 20, scale = "%", editor = "number" , slider = true, help = "From what percentage of the luminance of the scene to consider." }, + { category = "Exposure", feature = "autoexposure", name = "AE Lum Max", id="ae_brightness_ign", min = 1, max = 100, default = 95, scale = "%", editor = "number" , slider = true, help = "Until what percentage of the luminance of the scene to consider." }, + { category = "Exposure", feature = "autoexposure", name = "Use Constant Exposure", id="ae_disable", editor="bool", default=false, help = "Turn autoexposure on/off", }, + + { category = "Bloom", feature = "hdr bloom", name = "Strength", id = "pp_bloom_strength", min = 0, max = 100, default = 0, scale = 100, editor = "number", slider = true, help = "How much Bloom affects the resulting picture." }, + { category = "Bloom", feature = "hdr bloom", name = "Threshold", id = "pp_bloom_threshold", min = 0, max = 100, default = 100, scale = 100, editor = "number", slider = true, help = "The luminance threshold after which colours bloom." }, + { category = "Bloom", feature = "hdr bloom", name = "Contrast", id = "pp_bloom_contrast", min = 0, max = 100, default = 0, scale = 100, editor = "number", slider = true, help = "The contrast of the final Bloom effect." }, + { category = "Bloom", feature = "hdr bloom", name = "Bloom colorization", id = "pp_bloom_colorization", min = 0, max = 100, scale = 100, default = 30, editor = "number", slider = true, help = "The mixing ratio between the original Bloom color and the tint." }, + { category = "Bloom", feature = "hdr bloom", name = "Inner tint", id = "pp_bloom_inner_tint", default = RGB(180, 180, 180), editor = "color", help = "Bloom effect's inner tint." }, + { category = "Bloom", feature = "hdr bloom", name = "Outer tint", id = "pp_bloom_outer_tint", default = RGB(180, 180, 180), editor = "color", help = "Bloom effect's outer tint." }, + { category = "Bloom", feature = "hdr bloom", name = "Mip2 radius", id = "pp_bloom_mip2_radius" , min = 1, max = 64, default = 16, editor = "number", slider = true, help = "Gauss blur radius for mip2." }, + { category = "Bloom", feature = "hdr bloom", name = "Mip3 radius", id = "pp_bloom_mip3_radius" , min = 4, max = 64, default = 16, editor = "number", slider = true, help = "Gauss blur radius for mip3." }, + { category = "Bloom", feature = "hdr bloom", name = "Mip4 radius", id = "pp_bloom_mip4_radius" , min = 8, max = 64, default = 32, editor = "number", slider = true, help = "Gauss blur radius for mip4." }, + { category = "Bloom", feature = "hdr bloom", name = "Mip5 radius", id = "pp_bloom_mip5_radius" , min = 8, max = 64, default = 32, editor = "number", slider = true, help = "Gauss blur radius for mip5." }, + { category = "Bloom", feature = "hdr bloom", name = "Mip6 radius", id = "pp_bloom_mip6_radius" , min = 8, max = 64, default = 32, editor = "number", slider = true, help = "Gauss blur radius for mip6." }, + + { category = "Exposure", name = "Emissive Boost", id = "emissive_boost", min = 100, max = 10000, default = 100, scale = 10000, editor = "number", slider = true }, + { category = "Exposure", name = "Particle Exposure Additive", id = "ps_exposure", min = -1000, max = 1000, default = 0, editor = "number" , slider = true, help = "EV Control in 1/100 EV to adjust particle brightness when blending. Intensity change is pow(2, E / 100)" }, + + { category = "Other", name = "AO texture darkness", id = "ao_lower_limit", editor = "number", slider = true, min = 0, max = 255, default = 0, scale = 255, help = "Fits the values of the Ambient Occlusion Map of all meshes in a new range that has the specified minimum value." }, + { category = "Other", name = "SSAO strength", id = "pp_ssao_strength", min = 0, max = 200, editor = "number" , slider = true, default = 0, scale = 100, help = "The intensity of Screen-Space Ambient Occlusion in percents." }, + + { category = "Other", feature = "three_point_lighting", name = "Three Point Lighting", id = "three_point_lighting", editor = "preset_id", default = "", preset_class = "ThreePointLighting" }, + { category = "Other", feature = "Unit_Lighting", name = "Unit Lighting Strength", id = "unit_lighting_strength", editor = "number", slider = true, min = 0, max = 100, default = 50, scale = 100, help = "The intensity of the Unit Lighting effect." }, + { category = "Other", feature = "Unit_Lighting", name = "Unit Lighting Contrast", id = "unit_lighting_contrast", editor = "number", slider = true, min = 0, max = 100, default = 0, scale = 100, help = "The contrast strength of the Unit Lighting effect." }, + + { category = "Lights", name = "Light Shadows", id = "light_shadows", editor = "number", slider = true, min = 0, max = 1000, scale = 1000, default = 1000, feature = "shadow", help = "Shadows from lights strength." }, + { category = "Lights", name = "LightColorA", id = "lightcolor1", editor = "color", default = RGB(255, 255, 255), alpha = false, help = "This color can be used by point&spot lights." }, + { category = "Lights", name = "LightColorB", id = "lightcolor2", editor = "color", default = RGB(255, 255, 255), alpha = false, help = "This color can be used by point&spot lights." }, + { category = "Lights", name = "LightColorC", id = "lightcolor3", editor = "color", default = RGB(255, 255, 255), alpha = false, help = "This color can be used by point&spot lights." }, + { category = "Lights", name = "LightColorD", id = "lightcolor4", editor = "color", default = RGB(255, 255, 255), alpha = false, help = "This color can be used by point&spot lights." }, + { category = "Lights", feature = "night", name = "Night Lights", id = "night", blend = const.NightBlendThreshold, editor = "bool", default = false, help = "Determines whether the lights should be switched on or off." }, + }, +} + +DefineClass.LightmodelPreset = { + __parents = { "Preset", "Lightmodel" }, + + GlobalMap = "LightmodelPresets", + GedEditor = "LightmodelEditor", + EditorMenubarName = "Lightmodels", + EditorMenubar = "Editors.Art", + EditorShortcut = "Ctrl-M", + EditorIcon = "CommonAssets/UI/Icons/bulb ecology energy lamp light power.png", + ValidateAfterSave = true, + PropertyTabs = { + { TabName = "Preset", Categories = { Preset = true }, }, + { TabName = "Sun", Categories = { Sun = true, ["Sun Path"] = true, ["Sun Pos"] = true, } }, + { TabName = "Sky", Categories = { Sky = true, ["Night Sky"] = true, ["Env Capture"] = true, ["Cubemap"] = true, } }, + { TabName = "Weather", Categories = { Fog = true, Rain = true, Clouds = true, ["Cloud shadows"] = true, ["Wind"] = true, } }, + { TabName = "Environment", Categories = { Water = true, Snow = true, Ice = true, Frost = true, } }, + { TabName = "Effects", Categories = { Exposure = true, Bloom = true, Other = true, Vignette = true, ["Chromatic Aberration"] = true, ["Color Grading"] = true, Lights = true, } }, + } +} + +DefineClass.SHDiffuseIrradiance = { + __parents = { "Preset" }, + properties = { + { name = "SH9 Coefficients", id = "sh9_coefficients", editor = "text", default = "", read_only = true }, + }, + EditorMenubarName = "", + EditorMenubar = false, +} + +function DoSetLightmodel(view, lm_buf, time, start_offset) + if view < 1 or view > camera.GetViewCount() then return end + start_offset = start_offset or 0 + + SetSceneParam(view, "UseTimeOfDay", lm_buf.use_time_of_day and 1 or 0, 0, start_offset) + if not lm_buf.use_time_of_day then + local prev_azi = (GetSceneParam(view, "SunWidth") / 1000 + 360) % 360 + local azi = (lm_buf.sun_azi + mapdata.MapOrientation) % 360 + if abs(azi - prev_azi) > 180 then -- we better interpolate through 360 + if azi > 180 then + prev_azi = prev_azi + 360 + else + azi = azi + 360 + end + end + SetSceneParam(view, "SunWidth", prev_azi * 1000, 0, start_offset) + SetSceneParam(view, "SunWidth", azi * 1000, time, start_offset) + SetSceneParam(view, "SunHeight", lm_buf.sun_alt * 100, time, start_offset) + if lm_buf.sky_custom_sun then + SetSceneParam(view, "SkySunAzi", lm_buf.sky_custom_sun_azi * 1000, time, start_offset) + SetSceneParam(view, "SkySunAlt", lm_buf.sky_custom_sun_alt * 100, time, start_offset) + else + SetSceneParam(view, "SkySunAzi", -1, 0, 0) + SetSceneParam(view, "SkySunAlt", -1, 0, 0) + end + SetSceneParam(view, "SunShadowHeight", lm_buf.sun_shadow_height * 100, time, start_offset) + end + + SetSceneParam(view, "Shadow", lm_buf.shadow, time, start_offset) + if lm_buf.shadow then + SetSceneParam(view, "ShadowRange", lm_buf.shadow_range, time, start_offset) + end + + SetSceneParamColor(view, "MieCoefs", lm_buf.mie_coefs, time, start_offset) + SetSceneParamColor(view, "RayCoefs", lm_buf.ray_coefs, time, start_offset) + SetSceneParam(view, "MieSH", lm_buf.mie_sh, time, start_offset) + SetSceneParam(view, "RaySH", lm_buf.ray_sh, time, start_offset) + SetSceneParam(view, "MieMC", lm_buf.mie_mc, time, start_offset) + SetSceneParam(view, "SkyExp", lm_buf.sky_exp, time, start_offset) + SetSceneParamColor(view, "SunDiffuseColor", lm_buf.sun_diffuse_color, time, start_offset, false) + SetSceneParam(view, "SunIntensity", lm_buf.sun_intensity, time, start_offset) + + SetSceneParam(view, "StarsIntensity", lm_buf.stars_intensity, time, start_offset) + SetSceneParam(view, "StarsBlueTint", lm_buf.stars_blue_tint, time, start_offset) + SetSceneParam(view, "StarsRotation", lm_buf.stars_rotation, time, start_offset) + SetSceneParam(view, "StarsPoleAlt", lm_buf.stars_pole_alt, time, start_offset) + SetSceneParam(view, "StarsPoleAzi", lm_buf.stars_pole_azi, time, start_offset) + SetSceneParam(view, "MilkyWayIntensity", lm_buf.mw_intensity, time, start_offset) + SetSceneParam(view, "MilkyWayBlueTint", lm_buf.mw_blue_tint, time, start_offset) + + SetSceneParam(view, "SunAngularRadius", lm_buf.sun_angular_radius, time, start_offset) + SetSceneParam(view, "GlobalExposure", lm_buf.exposure, time, start_offset) + SetSceneParam(view, "EmissiveBoost", lm_buf.emissive_boost, time, start_offset) + SetSceneParam(view, "ExtEnvExposure", lm_buf.ext_env_exposure, time, start_offset) + SetSceneParam(view, "IntEnvExposure", lm_buf.int_env_exposure, time, start_offset) + SetSceneParam(view, "ParticleExposure", lm_buf.ps_exposure, time, start_offset) + + SetSceneParamColor(view, "FogColor", lm_buf.fog_color, time, start_offset) + SetSceneParam(view, "FogGlobalDensity", lm_buf.fog_density, time, start_offset) + SetSceneParam(view, "FogHeightFalloff", lm_buf.fog_height_falloff, time, start_offset) + SetSceneParam(view, "FogStart", lm_buf.fog_start, time, start_offset) + + SetIceStrength(lm_buf.ice_strength, "Lightmodel", view, time, start_offset) + SetSceneParamColor(view, "IceColor", lm_buf.ice_color, time, start_offset) + SetSceneParamColor(view, "SnowColor", lm_buf.snow_color, time, start_offset) + + SetSceneParam(view, "SnowDirX", lm_buf.snow_dir_x, time, start_offset) + SetSceneParam(view, "SnowDirY", lm_buf.snow_dir_y, time, start_offset) + SetSceneParam(view, "SnowDirZ", lm_buf.snow_dir_z, time, start_offset) + SetSceneParam(view, "SnowStr", lm_buf.snow_str, time, start_offset) + + SetSceneParamColor(view, "WaterColor", lm_buf.water_color, time, start_offset) + SetSceneParam(view, "AbsorptionCoef", lm_buf.absorption_coef, time, start_offset) + SetSceneParam(view, "MinimumDepth", lm_buf.minimum_depth, time, start_offset) + + SetSceneParam(view, "AutoExposureKeyBias", lm_buf.ae_key_bias, time, start_offset) + SetSceneParam(view, "AutoExposureLumMin", lm_buf.ae_lum_min, time, start_offset) + SetSceneParam(view, "AutoExposureLumMax", lm_buf.ae_lum_max, time, start_offset) + SetSceneParam(view, "AutoExposureAdaptSpeedBright", lm_buf.ae_adapt_speed_bright, time, start_offset) + SetSceneParam(view, "AutoExposureAdaptSpeedDark", lm_buf.ae_adapt_speed_dark, time, start_offset) + SetSceneParam(view, "AutoExposureBrightnessIgnorance", 100 - lm_buf.ae_brightness_ign, time, start_offset) + SetSceneParam(view, "AutoExposureDarknessIgnorance", lm_buf.ae_darkness_ign, time, start_offset) + SetSceneParam(view, "AutoExposureDisable", lm_buf.ae_disable and 1 or 0, time, start_offset) + + if not config.LightModelUnusedFeatures["dist_blur_desat"] then + SetSceneParamVector(view, "PostProc", 0, lm_buf.pp_blur, time, start_offset) + SetSceneParamVector(view, "PostProc", 1, lm_buf.pp_desaturation, time, start_offset) + SetSceneParamVector(view, "PostProc", 2, lm_buf.pp_blur_distance, time, start_offset) + SetSceneParamVector(view, "PostProc", 3, lm_buf.pp_desaturation_distance, time, start_offset) + end + + SetSceneParamColor(view, "BloomInnerTint", lm_buf.pp_bloom_inner_tint, time, start_offset) + SetSceneParamColor(view, "BloomOuterTint", lm_buf.pp_bloom_outer_tint, time, start_offset) + SetSceneParamVector(view, "Bloom", 0, lm_buf.pp_bloom_strength, time, start_offset) + SetSceneParamVector(view, "Bloom", 1, lm_buf.pp_bloom_threshold, time, start_offset) + SetSceneParamVector(view, "Bloom", 2, lm_buf.pp_bloom_contrast, time, start_offset) + SetSceneParamVector(view, "Bloom", 3, lm_buf.pp_bloom_colorization, time, start_offset) + SetSceneParamVector(view, "BloomRadii", 0, lm_buf.pp_bloom_mip2_radius, time, start_offset) + SetSceneParamVector(view, "BloomRadii", 1, lm_buf.pp_bloom_mip3_radius, time, start_offset) + SetSceneParamVector(view, "BloomRadii", 2, lm_buf.pp_bloom_mip4_radius, time, start_offset) + SetSceneParamVector(view, "BloomRadii", 3, lm_buf.pp_bloom_mip5_radius, time, start_offset) + SetSceneParamVector(view, "BloomRadii", 4, lm_buf.pp_bloom_mip6_radius, time, start_offset) + + SetSceneParamVector(view, "AOLowerLimit", 0, lm_buf.ao_lower_limit, time, start_offset) + SetSceneParamVector(view, "SSAO", 0, lm_buf.pp_ssao_strength, time, start_offset) + + SetPostProcPredicate("heat_haze", lm_buf.enable_heat_haze) + + if not config.LightModelUnusedFeatures["three_point_lighting"] then + if lm_buf.three_point_lighting ~= "" then + Presets.ThreePointLighting.ThreePointLightingRenderVars[lm_buf.three_point_lighting]:Apply() + table.change_base(hr, { EnableThreePointLighting = 1 }) + else + table.change_base(hr, { EnableThreePointLighting = 0 }) + end + end + + -- set up the cube map for the env mapped objects + if lm_buf.exterior_envmap and lm_buf.exterior_envmap ~= "" then + local exterior_sh = Presets.SHDiffuseIrradiance.Default[lm_buf.exterior_envmap .. "Exterior"] + if not exterior_sh then print("once", "Lightmodel", lm_buf.exterior_envmap .. "Exterior", "needs to be recaptured!") end + local err = SetCubemap(view, string.format("Textures/Cubemaps/%sExterior", lm_buf.exterior_envmap), exterior_sh and Decode64(exterior_sh.sh9_coefficients) or "", 0, time, start_offset) + if err then + print("SetCubemap failed", err) + end + end + if lm_buf.interior_envmap and lm_buf.interior_envmap ~= "" then + local interior_sh = Presets.SHDiffuseIrradiance.Default[lm_buf.interior_envmap .. "Interior"] + if not interior_sh then print("once", "Lightmodel", lm_buf.interior_envmap .. "Interior", "needs to be recaptured!") end + local err = SetCubemap(view, string.format("Textures/Cubemaps/%sInterior", lm_buf.interior_envmap), interior_sh and Decode64(interior_sh.sh9_coefficients) or "", 1, time, start_offset) + if err then + print("SetCubemap failed", err) + end + end + + SetSceneParam(view, "LightShadows", lm_buf.light_shadows, time, start_offset) + + SetSceneParam(view, "GameSpecificData0", lm_buf.unit_lighting_strength, time, start_offset) + SetSceneParam(view, "GameSpecificData1", lm_buf.unit_lighting_contrast, time, start_offset) + + for i = 1, 4 do + SetSceneParamColor(view, "LightColor" .. i, lm_buf["lightcolor" .. i], time, start_offset) + end + + Msg("LightmodelSetSceneParams", view, lm_buf, time, start_offset) +end + +MapVar("CurrentLightmodel", {}) +MapVar("LastSetLightmodel", {}) + +if FirstLoad then + LightmodelOverride = false +end + +function GetEnvMapsList(site) + local maps = { "" } + for _,v in ipairs(io.listfiles("Textures/Cubemaps")) do + local map = string.match(v, "Textures/Cubemaps/(.*)" .. site .. "Env%.dds") + if map then + maps[#maps + 1] = map + end + end + table.sort(maps) + return maps +end + +function PreloadLightmodelCubemaps(lightmodel) + if not lightmodel or lightmodel == "" then + return + end + + local lm_buf = LightmodelPresets[lightmodel] + if not lm_buf then + return + end + + local function preload_path(path) + local image_id = ResourceManager.GetResourceID(path) + if image_id == const.InvalidResourceID then + printf("once", "Could not load image %s!", path or "") + return + end + + local image = AsyncGetResource(image_id) + return image + end + local exterior_map = string.format("Textures/Cubemaps/%sExterior", lm_buf.exterior_envmap) + local interior_map = string.format("Textures/Cubemaps/%sInterior", lm_buf.interior_envmap) + + local result = { + Done = function(self) + if self.exterior_specular then self.exterior_specular:ReleaseRef() end + if self.interior_specular then self.interior_specular:ReleaseRef() end + end, + exterior_specular = preload_path(exterior_map .. "Specular"), + interior_specular = preload_path(interior_map .. "Specular"), + } + return result +end + +function SetLightmodelOverride(view, lightmodel) + view = view or 1 + lightmodel = LightmodelPresets[lightmodel] or lightmodel + lightmodel = type(lightmodel) == "table" and lightmodel or false + if LightmodelOverride ~= lightmodel then + LightmodelOverride = lightmodel + SetLightmodel(view, LastSetLightmodel and LastSetLightmodel[1] or lightmodel, 0, "from_override") + end +end + +function SetLightmodel(view, lightmodel, time, from_override) + if not CurrentLightmodel then return end --not on map + view = view or 1 + time = time or 0 + lightmodel = LightmodelPresets[lightmodel] or lightmodel + if type(lightmodel) ~= "table" then + if type(lightmodel) == "string" then + assert(false, "lightmodel not found: " .. tostring(lightmodel)) + end + lightmodel = LightmodelPresets.ArtPreview + end + if view < 1 or view > camera.GetViewCount() then return end + if LastSetLightmodel then + LastSetLightmodel[view] = lightmodel + end + lightmodel = LightmodelOverride or lightmodel + local prev_lm = CurrentLightmodel[view] + if prev_lm and not IsKindOf(prev_lm, "LightmodelPreset") then + setmetatable(prev_lm, LightmodelPreset) -- !!! backwards compatibility + end + CurrentLightmodel[view] = lightmodel + + hr.TODForceTime = -1 + local override = LightmodelOverride == lightmodel + if override then + if lightmodel.use_time_of_day then + hr.TODForceTime = LocalToEarthTime(lightmodel.time*1000) + end + end + --override is true when overriding and false when restoring + --from_override is true for both calls + Msg("LightmodelChange", view, lightmodel, time, prev_lm, from_override) + DoSetLightmodel(view, lightmodel, time) + Msg("AfterLightmodelChange", view, lightmodel, time, prev_lm, from_override) +end + +do + local unused_features = config.LightModelUnusedFeatures or empty_table + for _, prop in ipairs(Lightmodel.properties) do + if prop.feature and unused_features[prop.feature] then + prop.no_edit = true + end + end +end + +function LightmodelPreset:ListEquivalentLMs(root, prop_id, ged, param) + local feature = string.match(prop_id, "preset_(.+)") + local list = LightmodelEquivalenceByValue(self, feature) + ged:ShowMessage("Equivalent Lightmodels", table.concat(list, "\n")) +end + +function LightmodelPreset:SetFeaturePreset(ref_preset) + if not ref_preset then return end + local feature = ref_preset.group + if not feature then return end + local feature_data = LightmodelFeatureToProperties[feature] + for _, prop in ipairs(feature_data) do + self:SetProperty(prop.id, ref_preset:GetProperty(prop.id)) + end +end + +function LightmodelPreset:SetFeaturePresetId(feature, feature_preset_id) + local presets = Presets.LightmodelFeaturePreset or empty_table + local feature_group = presets[feature] or empty_table + self:SetFeaturePreset(feature_group[feature_preset_id]) +end + +local function EarlyClassDescendants(classdefs, target_class, callback) + local cache = {} + + local function EarlyIsKindOf(obj_class, target_class) + local cache_hit = cache[obj_class] + if cache_hit ~= nil then + return cache_hit + end + + if obj_class == target_class then + cache[obj_class] = true + return true + end + local class = classdefs[obj_class] + for _, parent in ipairs(class and class.__parents) do + if(EarlyIsKindOf(parent, target_class)) then + return true + end + end + cache[obj_class] = false + return false + end + + + for class_name in pairs(classdefs) do + if EarlyIsKindOf(class_name, target_class) then + callback(class_name, classdefs[class_name]) + end + end +end + +function OnMsg.ClassesGenerate(classdefs) + LightmodelFeatureToProperties = {} + + local properties = {} + EarlyClassDescendants(classdefs, "LightmodelPart", function(class_name, classdef) + local classProperties = {} + + if classdef.GetLightmodelProperties then + classdef:GetLightmodelProperties(classProperties) + else + table.iappend(classProperties, classdef.properties) + end + + local uses_preset = function(chain_func) + return function(self, ...) + local preset_id = "preset_" .. classdef.lightmodel_feature + local uses_preset = self[preset_id] and self[preset_id] ~= "" + if uses_preset then return uses_preset end + if type(chain_func) == "function" then return chain_func(self, ...) end + if chain_func then return chain_func end + return false + end + end + for k, prop in ipairs(classProperties) do + if not prop.category then + prop.category = classdef.lightmodel_category + end + if not prop.feature then + prop.feature = classdef.lightmodel_feature + end + local lm_prop = table.copy(prop) + lm_prop.dont_save = uses_preset(lm_prop.dont_save) + lm_prop.read_only = uses_preset(lm_prop.read_only) + properties[#properties + 1] = lm_prop + + for _,button in ipairs(lm_prop.buttons or empty_table) do + if not Lightmodel[button.func] and classdef[button.func] then + Lightmodel[button.func] = classdef[button.func] + elseif Lightmodel[button.func] and not classdef[button.func] then + classdef[button.func] = Lightmodel[button.func] + end + end + end + end) + + for _, prop in ipairs(properties) do + local feature = prop.feature + if feature then + local feature_list = LightmodelFeatureToProperties[feature] + if not feature_list then + feature_list = {} + LightmodelFeatureToProperties[feature] = feature_list + end + table.insert(feature_list, prop) + end + end + + -- Generate Additional LM properties + for feature, feature_data in pairs(LightmodelFeatureToProperties) do + local preset_feature_prop_id = "preset_" .. feature + feature_data.preset_feature_prop_id = preset_feature_prop_id + table.insert(Lightmodel.properties, { + id = preset_feature_prop_id, + editor = "preset_id", + preset_class = "LightmodelFeaturePreset", + preset_group = feature, + default = "", + category = (feature_data[1] or empty_table).category, + buttons = {{ name = "List", func = "ListEquivalentLMs"}} + }) + + table.iappend(Lightmodel.properties, feature_data) + end +end + +function OnMsg.DataLoaded() + for _, lm in pairs(LightmodelPresets) do + for feature, data in pairs(LightmodelFeatureToProperties) do + local preset_id = lm:GetProperty(data.preset_feature_prop_id) + lm:SetFeaturePresetId(feature, preset_id) + end + end +end + +local lightmodel_properties +function OnMsg.ClassesBuilt() + lightmodel_properties = {} + local preset = LightmodelPreset + for _, prop_meta in ipairs(preset:GetProperties()) do + if prop_meta.category ~= "Preset" and not prop_eval(prop_meta.no_edit, preset, prop_meta) and not prop_eval(prop_meta.read_only, preset, prop_meta)then + lightmodel_properties[#lightmodel_properties + 1] = prop_meta + end + end +end + + +function BlendLightmodels(result, lm1, lm2, num, denom) + SuspendObjModified("BlendLightmodels") + local firstHalf = 2*num < denom + for _, prop_meta in ipairs(lightmodel_properties) do + local prop_id = prop_meta.id + local v1 = lm1:GetProperty(prop_id) + local v2 = lm2:GetProperty(prop_id) + local value + local prop_blend = prop_meta.blend + if num >= denom or v1 == v2 or prop_blend == "set" then + value = v2 + elseif num <= 0 or prop_blend == "suppress" then + value = v1 + else + value = v2 + local prop_editor = prop_meta.editor + if prop_editor == "number" or prop_editor == "point" then + value = Lerp(v1, v2, num, denom) + elseif prop_editor == "color" then + value = InterpolateRGB(v1, v2, num, denom) + elseif type(prop_blend) == "number" and prop_blend ~= 50 then + -- "blend" here is a number indicating the descrete value change threshold + if prop_editor == "bool" then + value = v1 and 100 * num < prop_blend * denom or v2 and 100 * num >= (100 - prop_blend) * denom + elseif 100 * num < prop_blend * denom then + value = v1 + end + elseif firstHalf then + value = v1 + end + end + result:SetProperty(prop_id, value) + end + ResumeObjModified("BlendLightmodels") +end + +function LightmodelPreset:GetInteriorEnvmapImage() + return "Textures/Cubemaps/Thumbnails/" .. self.interior_envmap .. "Interior.jpg" +end + +function LightmodelPreset:GetExteriorEnvmapImage() + return "Textures/Cubemaps/Thumbnails/" .. self.exterior_envmap .. "Exterior.jpg" +end + +function LightmodelPreset:Sethdr_pano(value) + self.hdr_pano = value +end + +local function AppendDefaultExtension(path, default_extension) + if path and path ~= "" then + local _, _, extension = SplitPath(path) + if not extension or extension == "" then + path = path .. default_extension + end + end + return path +end + +if Platform.developer then + function LightmodelPreset:Setsky_custom_sun(b) + self.sky_custom_sun = b + if b then + self.sky_custom_sun_alt = self.sun_alt + self.sky_custom_sun_azi = self.sun_azi + else + self.sky_custom_sun_alt = 0 + self.sky_custom_sun_azi = 180 + end + ObjModified(self) + end +end + +function LightmodelPreset:Setuse_time_of_day(b) + self.use_time_of_day = b + ObjModified(self) +end + +function LightmodelPreset:Setshadow(b) + self.shadow = b + ObjModified(self) +end + +function LightmodelPreset:Setae_brightness_ign(b) + self.ae_brightness_ign = b + if (self.ae_brightness_ign - self.ae_darkness_ign <= 1) then + self.ae_darkness_ign = self.ae_brightness_ign - 1 + end +end + +function LightmodelPreset:Setae_darkness_ign(b) + self.ae_darkness_ign = b + if (self.ae_brightness_ign - self.ae_darkness_ign <= 1) then + self.ae_brightness_ign = self.ae_darkness_ign + 1 + end +end + +function LightmodelPreset:Getsun_earthtime_info() + local sunrise = hr.TODSunriseTime + local sunset = hr.TODSunsetTime + local noon = sunrise + (sunset - sunrise) / 2 + return string.format("Sunrise %02d:%02d Noon %02d:%02d Sunset %02d:%02d", + sunrise / 60, sunrise % 60, + noon / 60, noon % 60, + sunset / 60, sunset % 60 + ) +end + +function LightmodelPreset:Getsunrise_time() return EarthToLocalTime(hr.TODSunriseTime) end +function LightmodelPreset:Getsunrise_azi() return hr.TODSunriseAzi end +function LightmodelPreset:Getsunset_time() return EarthToLocalTime(hr.TODSunsetTime) end +function LightmodelPreset:Getsunset_azi() return hr.TODSunsetAzi end +function LightmodelPreset:Getsun_max_elevation() return hr.TODSunMaxElevation end + +function LightmodelPreset:Getsun_shadow_min() return hr.TODSunShadowMinAltitude end +function LightmodelPreset:Setsun_shadow_min(v) hr.TODSunShadowMinAltitude = v end + +function LightmodelPreset:Getsun_nr() return hr.TODNorthRotation end +function LightmodelPreset:Setsun_nr(v) hr.TODNorthRotation = v end + +function LightmodelPreset:Setsunrise_time(v) + hr.TODSunriseTime = LocalToEarthTime(v) + ObjModified(self) +end +function LightmodelPreset:Setsunrise_azi(v) + hr.TODSunriseAzi = v + ObjModified(self) +end +function LightmodelPreset:Setsunset_time(v) + hr.TODSunsetTime = LocalToEarthTime(v) + ObjModified(self) +end +function LightmodelPreset:Setsunset_azi(v) + hr.TODSunsetAzi = v + ObjModified(self) +end +function LightmodelPreset:Setsun_max_elevation(v) + hr.TODSunMaxElevation = v + ObjModified(self) +end + +function LightmodelPreset:Settime(v) + self.time = v + if hr.TODForceTime >= 0 then + hr.TODForceTime = LocalToEarthTime(self.time*1000) + end +end + +function LightmodelPreset:GetListName() + return self.group --string.match(self.id, "(%w+)_") or "*" +end + +function LightmodelPreset:Gettime_next(v) + local list_name = self:GetListName() + local next_lm = FindNextLightmodel(list_name, self.time+1) + if not next_lm then return "" end + return string.format("%02d:%02d (%s)", next_lm.time / 60, next_lm.time % 60, next_lm.id) +end + +function LightmodelPreset:PreviewStart() + if not self:EditorCheck(true) then return end + + SetLightmodelOverride(1, self.id) + hr.TODForceTime = LocalToEarthTime(self.time*1000) +end + +function LightmodelPreset:PreviewEnd() + if not self:EditorCheck(true) then return end + + local list_name = self:GetListName() + local next_lm = FindNextLightmodel(list_name, self.time+1) + SetLightmodelOverride(1, self.id) + hr.TODForceTime = LocalToEarthTime(next_lm.time*1000) +end + +function LightmodelPreset:PreviewBlendStart() + if not self:EditorCheck(true) then return end + + local list_name = self:GetListName() + local prev_lm = FindPrevLightmodel(list_name, self.time - 1) + SetLightmodelOverride(1, prev_lm.id) + hr.TODForceTime = LocalToEarthTime(self.time*1000) +end + +function LightmodelPreset:PreviewBlendEnd() + if not self:EditorCheck(true) then return end + + SetLightmodelOverride(1, self.id) + hr.TODForceTime = LocalToEarthTime((self.time + self.blend_time)*1000) +end + +function LightmodelPreset:PreviewBlend() + if IsEditorActive() then + print("Lightmodel blending preview works only outside the in-game editor!") + return + end + if not self:EditorCheck(true) then return end + + CreateRealTimeThread(function() + local list_name = self:GetListName() + local prev_lm = FindPrevLightmodel(list_name, self.time - 1) + CancelRendering() + SetLightmodelOverride(1, false) + SetLightmodel(1, prev_lm.id, 0) + Sleep(50) + ResumeRendering() + local start_time = LocalToEarthTime(self.time*1000) + local end_time = LocalToEarthTime((self.time+self.blend_time)*1000) + hr.TODForceTime = start_time + Sleep(1000) + local blend_time = MulDivRound(self.blend_time, const.HourDuration, 60) + SetLightmodel(1, self.id, blend_time) + local step = MulDivRound(60*1000, 10, const.HourDuration) + CreateGameTimeThread(function() + for time = start_time, end_time, step do + hr.TODForceTime = time + Sleep(10) + end + self:PreviewBlendEnd(self) + end) + end) +end + +function LightmodelsCombo() + return table.keys2(LightmodelPresets, true, "") +end + +DefineConstInt("Disaster", "LightningHorizontalMaxDistance", 600, "m") +DefineConstInt("Disaster", "LightningHorizontalMinDistance", 300, "m") +DefineConstInt("Disaster", "LightningVerticalMaxDistance", 600, "m") +DefineConstInt("Disaster", "LightningVerticalMinDistance", 300, "m") + +function WaitLightingStrike(view) + local lm = CurrentLightmodel[view] + if not lm or not lm.lightning_enable then + return + end + Sleep(AsyncRand(lm.lightning_interval_min, lm.lightning_interval_max)) + lm = CurrentLightmodel[view] + if not lm or not lm.lightning_enable then + return + end + local eye, lookat, _, _, _, fov = GetCamera() + if AsyncRand(100) < lm.lightning_strike_chance then + local disaster = const.Disaster + local min, max, lightning_fx + if AsyncRand(100) < lm.lightning_vertical_chance then + min, max, lightning_fx = disaster.LightningVerticalMinDistance, disaster.LightningVerticalMaxDistance, "LightningFarVertical" + else + min, max, lightning_fx = disaster.LightningHorizontalMinDistance, disaster.LightningHorizontalMaxDistance, "LightningFarHorizontal" + end + local fov_safe_area = 20 * 60 + fov = Max(Min(fov + fov_safe_area, 360 * 60), 0) + local rot_angle_in_frustum = AsyncRand(fov) - DivRound(fov, 2) + camera.GetYaw() + local rot_radius = AsyncRand(min, max) + local pos = RotateRadius(rot_radius, rot_angle_in_frustum, lookat):SetTerrainZ() + PlayFX(lightning_fx, "start", pos, pos, pos) + else + local pos = RotateRadius(100 * guim, AsyncRand(60 * 360), eye) + PlayFX("LightningThunderAround", "start", pos) + end + return true +end + +if FirstLoad then + LightningThreads = false +end + +function UpdateLightingThread(view) + local lm = CurrentLightmodel[view] + local lightning_thread = LightningThreads and LightningThreads[view] + if not lm or not lm.lightning_enable then + DeleteThread(lightning_thread) + if LightningThreads then LightningThreads[view] = nil end + return + end + if IsValidThread(lightning_thread) then + return + end + LightningThreads = LightningThreads or {} + LightningThreads[view] = CreateMapRealTimeThread(function(view) + local lm = CurrentLightmodel[view] + Sleep(lm and lm.lightning_delay_start or 0) + while WaitLightingStrike(view) do end + LightningThreads[view] = nil + end, view) +end + +function OnMsg.DoneMap() + for view, thread in pairs(LightningThreads) do + DeleteThread(thread) + end + LightningThreads = false +end + +function OnMsg.LoadGame() + for view in pairs(CurrentLightmodel) do + UpdateLightingThread(view) + end +end + +function OnMsg.LightmodelChange(view, lm, time, prev_lm) + local target_fx_class = "View" .. view + PlayFX("SetLightmodel", "end", prev_lm and prev_lm.id or "", target_fx_class) + PlayFX("SetLightmodel", "start", lm.id, target_fx_class) + UpdateLightingThread(view) +end + +function OnMsg.DoneMap() + if not CurrentLightmodel then + return + end + for view = 1, 1 do + local target_fx_class = "View" .. view + local lm = CurrentLightmodel[view] + if lm then + if lm.night then + PlayFX("Day", "end", lm.id or "", target_fx_class) + else + PlayFX("Night", "end", lm.id or "", target_fx_class) + end + PlayFX("Rain", "end", lm.id or "", target_fx_class) + PlayFX("Stormy", "end", lm.id or "", target_fx_class) + PlayFX("SetLightmodel", "end", lm.id or "", target_fx_class) + end + end +end + +function OnMsg.GedClosing(id) + local app = GedConnections[id] + if app.app_template == "LightmodelEditor" then + hr.AutoExposureMode = EngineOptions.EyeAdaptation == "On" and 1 or 0 + hr.EnablePostProcExposureSplit = 0 + end +end + +function OnMsg.GatherFXActions(list) + list[#list+1] = "Day" + list[#list+1] = "Night" + list[#list+1] = "Rain" + list[#list+1] = "SetLightmodel" +end + +if FirstLoad then + LightmodelLists = false +end + +function UpdateLightmodelLists() + local lists = {} + ForEachPreset(LightmodelPreset, function(lm, group_list) + if lm.use_time_of_day then + local list_name = lm:GetListName() + local list = lists[list_name] or {} + local entry = { time = lm.time, blend_time = lm.blend_time, id = lm.id } + list[#list+1] = entry + lists[list_name] = list + end + end) + for list_name, list in pairs(lists) do + table.sort(list, function(lm1, lm2) return lm1.time < lm2.time end) + end + LightmodelLists = lists +end + +OnMsg.BinAssetsLoaded = UpdateLightmodelLists + +function FindNextLightmodel(list_name, time_of_day) + local list = LightmodelLists and LightmodelLists[list_name] + if not list then return end + assert(time_of_day >= -24*60 and time_of_day < 2*24*60) -- time of + time_of_day = (time_of_day + 2*24*60) % (24*60) + for i = 1, #list do + local lm = list[i] + if lm.time >= time_of_day then + return LightmodelPresets[lm.id] + end + end + return list[1] +end + +function FindPrevLightmodel(list_name, time_of_day) + local list = LightmodelLists and LightmodelLists[list_name] + if not list then return end + assert(time_of_day >= -24*60 and time_of_day < 2*24*60) -- time of + time_of_day = (time_of_day + 2*24*60) % (24*60) + for i = #list, 1, -1 do + local lm = list[i] + if lm.time <= time_of_day then + return LightmodelPresets[lm.id] + end + end + return list[#list] +end + +------------- Editor only actions ----------------- + +function OnMsg.GedOpened(ged_id) + local conn = GedConnections[ged_id] + if conn and conn.app_template == "LightmodelEditor" then + local root = conn:ResolveObj("root") + local active_lightmodel = LightmodelPreset.GetInitialSelection() + if active_lightmodel then + local selection = { + table.find(root, root[active_lightmodel.group]), + table.find(root[active_lightmodel.group], active_lightmodel), + } + conn:Send("rfnApp", "SetSelection", "root", selection) + end + end +end + +function LightmodelPreset.GetInitialSelection() + local id, val = next(LightmodelPresets) + if not id then return end + return LightmodelPresets[CurrentLightmodel and CurrentLightmodel[1] and CurrentLightmodel[1].id] or val +end + +if FirstLoad then + ChangeLightmodelOverrideThread = false + CelestialPoleDebugThread = false +end + +function SetLightmodelOverrideDelay(view, lm) + if ChangeLightmodelOverrideThread then + DeleteThread(ChangeLightmodelOverrideThread) + end + ChangeLightmodelOverrideThread = CreateRealTimeThread(function() + Sleep(100) + SetLightmodelOverride(view, lm) + ChangeLightmodelOverrideThread = false + end) +end + +function LightmodelPreset:OnEditorSelect(selection, ged) + if not self:EditorCheck() then return end + + if IsKindOf(ged:ResolveObj("SelectedPreset"), "GedMultiSelectAdapter") then + return + end + + if selection then + SetLightmodelOverrideDelay(1, self) + FXCache = false + else + SetLightmodelOverrideDelay(1, false) + end +end + +local function AdjustColor(obj, prop, brightness) + local h, s, v = UIL.RGBtoHSV(GetRGB(obj[prop])) + obj[prop] = RGB(UIL.HSVtoRGB(h, s, MulDivRound(v, brightness, 100))) +end + +local function DebugMarkPole() + hr.SkyCelestialPoleDebug = 1 + DeleteThread(CelestialPoleDebugThread) + CelestialPoleDebugThread = CreateRealTimeThread(function() + Sleep(60000) + hr.SkyCelestialPoleDebug = 0 + end) +end + +function LightmodelPreset:Getcubemap_capture_preview() + return table.changed(hr, "CubemapCapturePreview") and true +end + +function LightmodelPreset:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "env_view_site" then + if table.changed(hr, "ViewEnv") then + self:ViewEnv() + end + return + elseif prop_id == "sky_is" then + if self[prop_id] then + hr.DeferFlags = hr.DeferFlags | const.DeferFlagSkyIS + else + hr.DeferFlags = hr.DeferMode & ~const.DeferFlagSkyIS + end + return + elseif prop_id == "cubemap_capture_preview" then + if self[prop_id] then + CubemapCaptureMode(true, "CubemapCapturePreview") + else + CubemapCaptureMode(false, "CubemapCapturePreview") + end + elseif prop_id == "env_exterior_capture_pos" or prop_id == "env_interior_capture_pos" then + self.env_capture_map = GetMapName() + ObjModified(self) + elseif prop_id == "stars_rotation" or prop_id == "stars_pole_alt" or prop_id == "stars_pole_azi" then + DelayedCall(50, DebugMarkPole) + elseif prop_id == "Group" or prop_id == "use_time_of_day" or prop_id == "time" or prop_id == "blend_time" then + UpdateLightmodelLists() + end + for feature, feature_data in pairs(LightmodelFeatureToProperties) do + if prop_id == feature_data.preset_feature_prop_id then + self:SetFeaturePresetId(feature, self:GetProperty(prop_id)) + end + end + if self:EditorCheck() then + DoSetLightmodel(1, self, 0) + DelayedCall(300, function(object) + Msg("LightmodelChange", 1, object, 0, object) + end, self) + end +end + +function LightmodelPreset:Gray(root, prop, ged) + local r, g, b = GetRGB( self[prop] ) + local gray = (33 * r + 50 * g + 17 * b) / 100 + return GedSetProperty(ged, self, prop, RGB(gray, gray, gray)) -- a call to the Ged Op for undo support +end + +function LightmodelPreset:ViewExteriorEnvPos() + editor.ClearSelWithUndoRedo() + ViewPos(self.env_exterior_capture_pos, 20*guim) +end + +function LightmodelPreset:ViewInteriorEnvPos() + editor.ClearSelWithUndoRedo() + ViewPos(self.env_interior_capture_pos, 20*guim) +end + +if FirstLoad then + g_LightmodelViewEnvLastCam = false +end + +function LightmodelPreset:ViewEnv() + local envmap_name = self.id .. self.env_view_site + local envmap_path = "Textures/Cubemaps/" .. envmap_name + + if not io.exists(envmap_path .. "Env.dds") then + assert(not "Env map must be captured before viewed!") + return + end + + local envmap_sh = Presets.SHDiffuseIrradiance.Default[envmap_name] + envmap_sh = envmap_sh and Decode64(envmap_sh.sh9_coefficients) + + DoSetLightmodel(1, LightmodelPresets.ArtPreview, 0) + SetCubemap(1, envmap_path, envmap_sh, 0, 0, 0) + SetCubemap(1, envmap_path, envmap_sh, 1, 0, 0) + + if not table.changed(hr, "ViewEnv") then + table.change(hr, "ViewEnv", { + DeferFlags = const.DeferFlagEnvMapOnly, + RenderCodeRenderables = 0, + RenderTransparent = 0, + RenderParticles = 0, + RenderLights = 0, + EnableScreenSpaceReflections = 0, + }) + if not g_LightmodelViewEnvLastCam then + g_LightmodelViewEnvLastCam = { GetCamera() } + cameraFly.Activate(1) + end + end +end + +function LightmodelPreset:HideEnv() + if table.changed(hr, "ViewEnv") then + table.restore(hr, "ViewEnv") + DoSetLightmodel(0, self, 0) + if g_LightmodelViewEnvLastCam then + SetCamera(table.unpack(g_LightmodelViewEnvLastCam)) + g_LightmodelViewEnvLastCam = false + end + end +end + +function LightmodelPreset:UseSelectionAsExteriorEnvPos() + if IsEditorActive() and #editor.GetSel() > 0 then + if IsKindOf(editor.GetSel()[1], "ShaderBall") then + self.env_exterior_capture_pos = editor.GetSel()[1]:GetBSphere() + self.env_capture_map = GetMapName() + ObjModified(self) + else + print("Please use a Shaderball object to mark the desired environment capture position") + end + end +end + +function LightmodelPreset:UseSelectionAsInteriorEnvPos() + if IsEditorActive() and #editor.GetSel() > 0 then + if IsKindOf(editor.GetSel()[1], "ShaderBall") then + self.env_interior_capture_pos = editor.GetSel()[1]:GetBSphere() + self.env_capture_map = GetMapName() + ObjModified(self) + else + print("Please use a Shaderball object to mark the desired environment capture position") + end + end +end + +if FirstLoad then + g_LMCaptureQueue = {} + g_LMCaptureThread = false +end + +function LightmodelPreset:CaptureEnvmap(site, ged) + if self.env_capture_map ~= GetMapName() then + if MapData[self.env_capture_map] then + ChangeMap(self.env_capture_map) + else + assert(false, "Capture map doesn't exist") + return "Capture map doesn't exist: " .. self.env_capture_map + end + end + + local env_capture_pos = site == "Interior" and self.env_interior_capture_pos or self.env_exterior_capture_pos + + if not env_capture_pos:IsValidZ() then + return "Capture position must not be a 2D position (on the terrain)" + end + + local map_x, map_y = terrain.GetMapSize() + if env_capture_pos:x() > map_x or + env_capture_pos:y() > map_y or + env_capture_pos:x() < 0 or + env_capture_pos:y() < 0 then + assert(false, "Camera is out of map!") + g_CapturingLightmodel = false + return "Camera is out of map!" + end + + local lightmodel_for_capture = LightmodelPresets[self.lm_capture] or self + + local sky_exp = GetSceneParam("SkyExp") + local sun_int = GetSceneParam("SunIntensity") + + DoSetLightmodel(1, lightmodel_for_capture, 0) + SetSceneParam(1, "RainEnable", 0, 0, 0) + + if site == "Exterior" then + SetSceneParam(1, "SkyExp", lightmodel_for_capture.sky_exp + lightmodel_for_capture.env_exterior_capture_sky_exp, 0, 0) + SetSceneParam(1, "SunIntensity", lightmodel_for_capture.sun_intensity + lightmodel_for_capture.env_exterior_capture_sun_int , 0, 0) + else + SetSceneParam(1, "SkyExp", lightmodel_for_capture.sky_exp + lightmodel_for_capture.env_interior_capture_sky_exp, 0, 0) + SetSceneParam(1, "SunIntensity", lightmodel_for_capture.sun_intensity + lightmodel_for_capture.env_interior_capture_sun_int, 0, 0) + end + + + -- hide undesired objects from environment + local objs_to_restore = {} + MapForEach("map", g_ClassesToHideInCubemaps or "EditorVisibleObject", function(object) + if object:GetEnumFlags(const.efVisible) > 0 then + table.insert(objs_to_restore, object) + object:ClearEnumFlags(const.efVisible) + end + end) + WaitNextFrame(10) + + HDRCubemapExportAll("Textures/Cubemaps/", self.id, site, env_capture_pos) + WaitNextFrame(10) + -- return hidden objects in environment if needed + for _, object in pairs(objs_to_restore) do + if IsValid(object) then + object:SetEnumFlags(const.efVisible) + end + end + self:SetProperty("sky_is", false) + self:SetProperty(site:lower() .. "_envmap", self.id) + SetSceneParam(1, "SkyExp", sky_exp, 0, 0) + SetSceneParam(1, "SunIntensity", sun_int, 0, 0) + WaitNextFrame(10) + ObjModified(self) + DoSetLightmodel(1, self, 0) + + if ged then + local result = ged:Send("rfnApp", "ReloadImage", ConvertToOSPath("Textures/Cubemaps/Thumbnails/" .. self.id .. site .. ".jpg")) + assert(not result) + end +end + +function LMCaptureThread() + while g_LMCaptureQueue[1] do + local lm, site, ged = table.unpack(g_LMCaptureQueue[1]) + table.remove(g_LMCaptureQueue, 1) + lm:CaptureEnvmap(site, ged) + print(lm.id) + end + g_LMCaptureThread = false +end + +function AddLMCapture(lm, site, ged) + table.insert(g_LMCaptureQueue, {lm, site, ged}) + if not g_LMCaptureThread then + g_LMCaptureThread = CreateRealTimeThread( LMCaptureThread ) + end +end + +function LightmodelPreset:CaptureExteriorEnvmap(root, prop_id, ged) + if not self:EditorCheck(true) then return end + AddLMCapture(self, "Exterior", ged) +end + +function LightmodelPreset:CaptureInteriorEnvmap(root, prop_id, ged) + if not self:EditorCheck(true) then return end + AddLMCapture(self, "Interior", ged) +end + +function LightmodelPreset:CaptureBothEnvmaps(root, prop_id, ged) + if not self:EditorCheck(true) then return end + AddLMCapture(self, "Exterior", ged) + AddLMCapture(self, "Interior", ged) +end + +function LightmodelPreset:ConvertHDRPano(site, ged) + if not self:EditorCheck(true) or self.hdr_pano == "" then + return + end + local _, base_name, _ = SplitPath(pano_path) + HDRCubemapFromPano("Textures/Cubemaps/", base_name, site, self.hdr_pano) + if ged then + local result = ged:Send("rfnApp", "ReloadImage", ConvertToOSPath("Textures/Cubemaps/Thumbnails/" .. base_name .. site .. ".jpg")) + assert(not result) + end +end + +function LightmodelPreset:ConvertExteriorEnvmap(root, prop_id, ged) + self:ConvertHDRPano("Exterior", ged) +end + +function LightmodelPreset:ConvertInteriorEnvmap(root, prop_id, ged) + self:ConvertHDRPano("Interior", ged) +end + +function LightmodelPreset:EditorCheck(print_err) + if CurrentMap == "" then + if print_err then + print("Load a map to access this Lightmodel action.") + end + return false + end + return true +end + +function LightmodelPreset:GetCubemapWarning() + if not MapData[self.env_capture_map] then + return string.format("Map for capturing cubemaps doesn't exist: %s", self.env_capture_map) + end +end + +function LightmodelPreset:GetWarning() + local cubemap_warning = self:GetCubemapWarning() + if cubemap_warning then + return cubemap_warning + end +end + +function LightmodelPreset:AutoExposureOn() + hr.AutoExposureMode = 1 + hr.EnablePostProcExposureSplit = 0 +end + +function LightmodelPreset:AutoExposureOff() + hr.AutoExposureMode = 0 + hr.EnablePostProcExposureSplit = 0 +end + +function LightmodelPreset:AutoExposureSplit() + hr.EnablePostProcExposureSplit = 1 +end + +function LightmodelPreset:AutoExposureDebugToggle() + ToggleHR "AutoExposureDebug" +end + +function LightmodelEditorTakeScreenshots(ged, obj) + local lightmodels = obj:IsKindOf("GedMultiSelectAdapter") and obj.__objects or { obj } + local prefix = os.date("%Y%m%d_%H%M%S_", os.time()) + local items = {} + AsyncCreatePath("AppData/LightmodelScreenshots") + for i, lm in ipairs(lightmodels) do + DoSetLightmodel(1, lm, 0) + WaitNextFrame(3) + LockCamera("Screenshot") + local filename = string.format("AppData/LightmodelScreenshots/%s_%s.png", prefix, lm.id) + MovieWriteScreenshot(filename, 0, 16, false) + items[#items+1] = ScreenshotItem:new{ display_name = lm.id, file_path = filename } + UnlockCamera("Screenshot") + WaitNextFrame(1) + end + local sdv = OpenGedApp("ScreenshotDiffViewer", items) + for _, s in ipairs(items) do + sdv:Send("rfnApp", "rfnSetSelectedFilePath", s.file_path, true) + end +end \ No newline at end of file diff --git a/CommonLua/Classes/MapData.lua b/CommonLua/Classes/MapData.lua new file mode 100644 index 0000000000000000000000000000000000000000..fd9d92159252a3573c33d02ba74c3a6935b6b304 --- /dev/null +++ b/CommonLua/Classes/MapData.lua @@ -0,0 +1,801 @@ +----- Play box / play area utility function + +function GetPlayBox(border) + local pb = mapdata.PassBorder + (border or 0) + local mw, mh = terrain.GetMapSize() + local maxh = const.MaxTerrainHeight + return boxdiag(pb, pb, 0, mw - pb, mh - pb, maxh) +end + +function ClampToPlayArea(pt) + return terrain.ClampPoint(pt, mapdata.PassBorder) +end + +function GetTerrainCursorClamped() + return ClampToPlayArea(GetTerrainCursor()) +end + + +----- Misc + +if FirstLoad then + UpdateMapDataThread = false +end + +MapTypesCombo = { "game", "system" } + +function HGMembersCombo(group, extra_item) + return function() + local combo = extra_item and { extra_item } or {} + for name, _ in sorted_pairs(table.get(Presets, "HGMember", group)) do + if type(name) == "string" then + table.insert(combo, name) + end + end + return combo + end +end + +function MapOrientCombo() + return { + { text = "East", value = 0 }, + { text = "South", value = 90 }, + { text = "West", value = 180 }, + { text = "North", value = 270 }, + } +end + +function MapNorthOrientCombo() + return { + { text = "East(90)", value = 90 }, + { text = "South(180)", value = 180 }, + { text = "West(270)", value = 270 }, + { text = "North(0)", value = 0 }, + } +end + +local map_statuses = { + { id = "Not started", color = "" }, + { id = "In progress", color = "" }, + { id = "Awaiting feedback", color = "" }, + { id = "Blocked", color = "" }, + { id = "Ready", color = "" }, +} +for _, item in ipairs(map_statuses) do map_statuses[item.id] = item.color end + + +----- MapDataPresetFilter + +local filter_by = { + { id = "Map production status", color_prop = "Status", prop_match = function(id) return id == "Author" or id == "Status" end }, + { id = "Scripting production status", color_prop = "ScriptingStatus", prop_match = function(id) return id:starts_with("Scripting") end, }, + { id = "Sounds production status", color_prop = "SoundsStatus", prop_match = function(id) return id:starts_with("Sounds") end }, +} + +DefineClass.MapDataPresetFilter = { + __parents = { "GedFilter" }, + + properties = { + no_edit = function(self, prop_meta) + local match_fn = table.find_value(filter_by, "id", self.FilterBy).prop_match + return prop_meta.id ~= "FilterBy" and prop_meta.id ~= "_" and prop_meta.id ~= "Tags" and not (match_fn and match_fn(prop_meta.id)) + end, + { id = "FilterBy", name = "Filter/colorize by", editor = "choice", default = filter_by[1].id, items = filter_by }, + { id = "Author", name = "Map author", editor = "choice", default = "", items = HGMembersCombo("Level Design", "") }, + { id = "Status", name = "Map status", editor = "choice", default = "", items = table.iappend({""}, map_statuses) }, + { id = "ScriptingAuthor", name = "Scripting author", editor = "choice", default = "", items = HGMembersCombo("Design", "") }, + { id = "ScriptingStatus", name = "Scripting status", editor = "choice", default = "", items = table.iappend({""}, map_statuses) }, + { id = "SoundsStatus", name = "Sounds status", editor = "choice", default = "", items = table.iappend({""}, map_statuses) }, + { id = "Tags", name = "Tags", editor = "set", default = set({ old = false }), three_state = true, items = { "old", "prefab", "random", "test", "playable" } }, + }, +} + +function MapDataPresetFilter:FilterObject(o) + if not IsKindOf(o, "MapDataPreset") then return true end + + local filtered = true + -- Tags + if self.Tags.old then + filtered = filtered and IsOldMap(o.id) + elseif self.Tags.old == false then + filtered = filtered and not IsOldMap(o.id) + end + + if self.Tags.prefab then + filtered = filtered and o.IsPrefabMap + elseif self.Tags.prefab == false then + filtered = filtered and not o.IsPrefabMap + end + + if self.Tags.random then + filtered = filtered and o.IsRandomMap + elseif self.Tags.random == false then + filtered = filtered and not o.IsRandomMap + end + + if self.Tags.test then + filtered = filtered and IsTestMap(o.id) + elseif self.Tags.test == false then + filtered = filtered and not IsTestMap(o.id) + end + + if self.Tags.playable then + filtered = filtered and o.GameLogic + elseif self.Tags.playable == false then + filtered = filtered and not o.GameLogic + end + -- end of Tags + + if self.FilterBy == "Map production status" then + return filtered and (self.Author == "" or self.Author == o.Author) and (self.Status == "" or self.Status == o.Status) + elseif self.FilterBy == "Scripting production status" then + return filtered and (self.ScriptingAuthor == "" or self.ScriptingAuthor == o.ScriptingAuthor) and (self.ScriptingStatus == "" or self.ScriptingStatus == o.ScriptingStatus) + elseif self.FilterBy == "Sounds production status" then + return filtered and self.SoundsStatus == "" or self.SoundsStatus == o.SoundsStatus + end + + return filtered +end + +function MapDataPresetFilter:TryReset(ged, op, to_view) + return false +end + + +----- MapDataPreset + +DefineClass.MapDataPreset = { + __parents = { "Preset" }, + properties = { + { category = "Production", id = "Author", name = "Map author", editor = "choice", items = HGMembersCombo("Level Design"), default = false }, + { category = "Production", id = "Status", name = "Map status", editor = "choice", items = map_statuses, default = map_statuses[1].id }, + { category = "Production", id = "ScriptingAuthor", name = "Scripting author", editor = "choice", items = HGMembersCombo("Design"), default = false }, + { category = "Production", id = "ScriptingStatus", name = "Scripting status", editor = "choice", items = map_statuses, default = map_statuses[1].id }, + { category = "Production", id = "SoundsStatus", name = "Sounds status", editor = "choice", items = map_statuses, default = map_statuses[1].id }, + + { category = "Base", id = "DisplayName", name = "Display name", editor = "text", default = "", translate = true, help = "Translated Map name" }, + { category = "Base", id = "Description", name = "Description", editor = "text", lines = 5, default = "", translate = true, help = "Translated Map description" }, + { category = "Base", id = "MapType", editor = "combo", default = "game", items = function () return MapTypesCombo end, developer = true }, + { category = "Base", id = "GameLogic", editor = "bool", default = true, no_edit = function(self) return self.MapType == "system" end, developer = true }, + { category = "Base", id = "ArbitraryScale", name = "Allow arbitrary object scale", editor = "bool", default = false, developer = true }, + { category = "Base", id = "Width", name = "Width (tiles)", editor = "number", min = 1, max = const.MaxMapWidth or 6145, step = 128, slider = true, default = 257, no_validate = true }, + { category = "Base", id = "Height", name = "Height (tiles)", editor = "number", min = 1, max = const.MaxMapHeight or 6145, step = 128, slider = true, default = 257, no_validate = true }, + { category = "Base", id = "_mapsize", editor = "help", help = function(self) return string.format("Map size (meters): %dm x %dm", MulDivTrunc(self.Width - 1, const.HeightTileSize, guim), MulDivTrunc(self.Height - 1, const.HeightTileSize, guim)) end, }, + { category = "Base", id = "NoTerrain", editor = "bool", default = false, }, + { category = "Base", id = "DisablePassability", editor = "bool", default = false, }, + { category = "Base", id = "ModEditor", editor = "bool", default = false, }, + + { category = "Camera", id = "CameraUseBorderArea", editor = "bool", default = true, help = "Use Border marker's area for camera area." }, + { category = "Camera", id = "CameraArea", editor = "number", default = 100, min = 0, max = max_int, help = "With center of map as center, this is the length of the bounding square side in voxels." }, + { category = "Camera", id = "CameraFloorHeight", editor = "number", default = 5, min = 0, max = 20, help = "The voxel height of camera floors."}, + { category = "Camera", id = "CameraMaxFloor", editor = "number", default = 5, min = 0, max = 20, help = "The highest camera floors, counting from 0."}, + { category = "Camera", id = "CameraType", editor = "choice", default = "Max", items = GetCameraTypesItems}, + { category = "Camera", id = "CameraPos", editor = "point", default = false}, + { category = "Camera", id = "CameraLookAt", editor = "point", default = false}, + { category = "Camera", id = "CameraFovX", editor = "number", default = false}, + { category = "Camera", id = "buttons", editor = "buttons", default = "RTS", buttons = {{name = "View Camera", func = "ViewCamera"}, {name = "Set Camera", func = "SetCamera"}}}, + + { category = "Random Map", id = "IsPrefabMap", editor = "bool", default = false, read_only = true }, + { category = "Random Map", id = "IsRandomMap", editor = "bool", default = false, }, + + { category = "Visual", id = "Lightmodel", editor = "preset_id", default = false, preset_class = "LightmodelPreset", help = "", developer = true}, + { category = "Visual", id = "EditorLightmodel", editor = "preset_id", default = false, preset_class = "LightmodelPreset", help = "", developer = true}, + { category = "Visual", id = "AtmosphericParticles", editor = "combo", default = "", items = ParticlesComboItems, buttons = {{name = "Edit", func = "EditParticleAction"}}, developer = true}, + + { category = "Orientation", id = "MapOrientation", name = "North", editor = "choice", items = MapNorthOrientCombo, default = 0, buttons = {{name = "Look North", func = "LookNorth"}} }, + + { category = "Terrain", id = "Terrain", editor = "bool", default = true, help = "Enable drawing of terrain", developer = true}, + { category = "Terrain", id = "BaseLayer", name = "Terrain base layer", editor = "combo", items = function() return GetTerrainNamesCombo() end, default = "", developer = true}, + { category = "Terrain", id = "ZOrder", editor = "choice", default = "z_order", items = { "z_order", "z_order_2nd" }, help = "Indicates which Z Order property from terrains to use for sorting", developer = true}, + { category = "Terrain", id = "OrthoTop", editor = "number", default = 50*guim, scale = "m", developer = true }, + { category = "Terrain", id = "OrthoBottom", editor = "number", default = 0, scale = "m", developer = true }, + { category = "Terrain", id = "PassBorder", name = "Passability border", editor = "number", default = 0, scale = "m", developer = true, help = "Width of the border zone with no passability" }, + { category = "Terrain", id = "PassBorderTiles", name = "Passability border (tiles)", editor = "number", default = 0, developer = true }, + { category = "Terrain", id = "TerrainTreeRows", name = "Number of terrain trees per row(NxN grid)", editor = "number", default = 4, developer = true }, + { category = "Terrain", id = "HeightMapAvg", name = "Height Avg", editor = "number", default = 0, scale = "m", read_only = true }, + { category = "Terrain", id = "HeightMapMin", name = "Height Min", editor = "number", default = 0, scale = "m", read_only = true }, + { category = "Terrain", id = "HeightMapMax", name = "Height Max", editor = "number", default = 0, scale = "m", read_only = true }, + + { category = "Audio", id = "Playlist", editor = "combo", default = "", items = PlaylistComboItems, developer = true}, + { category = "Audio", id = "Blacklist", editor = "prop_table", default = false, no_edit = true }, + { category = "Audio", id = "BlacklistStr", name = "Blacklist", editor = "text", lines = 5, default = "", developer = true, buttons = {{name = "Add", func = "ActionAddToBlackList"}}, dont_save = true }, + { category = "Audio", id = "Reverb", editor = "preset_id", default = false, preset_class = "ReverbDef", developer = true}, + + { category = "Objects", id = "MaxObjRadius", editor = "number", default = 0, scale = "m", read_only = true, buttons = {{name = "Show", func = "ShowMapMaxRadiusObj"}} }, + { category = "Objects", id = "MaxSurfRadius2D", editor = "number", default = 0, scale = "m", read_only = true, buttons = {{name = "Show", func = "ShowMapMaxSurfObj"}} }, + + { category = "Markers", id = "LockMarkerChanges", name = "Lock markers changes", editor = "bool", default = false, help = "Disable changing marker meta (e.g. prefab markers)." }, + { category = "Markers", id = "markers", editor = "prop_table", default = {}, no_edit = true }, + { category = "Markers", id = "MapMarkersCount", name = "Map markers count", editor = "number", default = 0, read_only = true }, + + { category = "Compatibility", id = "PublishRevision", name = "Published revision", editor = "number", default = 0, help = "The first revision where the map has been officially published. Should be filled to ensure compatibility after map changes." }, + { category = "Compatibility", id = "CreateRevisionOld", name = "Compatibility revision", editor = "number", default = 0, read_only = true, help = "Revision when the compatibility map ('old') was created. The 'AssetsRevision' of the 'old' maps is actually the revision of the original map." }, + { category = "Compatibility", id = "ForcePackOld", name = "Compatibility pack", editor = "bool", default = false, help = "Force the map to be packed in builds when being a compatibility map ('old')." }, + + { category = "Developer", id = "StartupEnable", name = "Use startup", editor = "bool", default = false, dev_option = true }, + { category = "Developer", id = "StartupCam", name = "Startup cam", editor = "prop_table", default = false, dev_option = true, no_edit = PropChecker("StartupEnable", false), buttons = {{name = "Update", func = "UpdateStartup"}, {name = "Goto", func = "GotoStartup"}} }, + { category = "Developer", id = "StartupEditor", name = "Startup editor", editor = "bool", default = false, dev_option = true, no_edit = PropChecker("StartupEnable", false) }, + + { category = "Developer", id = "LuaRevision", editor = "number", default = 0, read_only = true }, + { category = "Developer", id = "OrgLuaRevision", editor = "number", default = 0, read_only = true }, + { category = "Developer", id = "AssetsRevision", editor = "number", default = 0, read_only = true }, + + { category = "Developer", id = "NetHash", name = "Net hash", editor = "number", default = 0, read_only = true }, + { category = "Developer", id = "ObjectsHash", name = "Objects hash", editor = "number", default = 0, read_only = true }, + { category = "Developer", id = "TerrainHash", name = "Terrain hash", editor = "number", default = 0, read_only = true }, + { category = "Developer", id = "SaveEntityList", name = "Save entity list", editor = "bool", default = false, help = "Saves all entities used on that map, e.g. Objects, Markers, Auto Attaches..." }, + { category = "Developer", id = "InternalTesting", name = "Used for testing", editor = "bool", default = false, help = "This map is somehow related to testing." }, + + }, + + Zoom = false, + + SingleFile = false, + GlobalMap = "MapData", + + GedEditor = "GedMapDataEditor", + EditorMenubarName = false, -- Used to avoid generating an Action to open this editor (added manually) + + EditorViewPresetPostfix = Untranslated(""), + FilterClass = "MapDataPresetFilter", +} + +if config.ModdingToolsInUserMode then + MapDataPreset.FilterClass = nil +end + +function EditParticleAction(root, obj, prop_id, ged) + local parsysid = obj[prop_id] + if parsysid and parsysid ~= "" then + EditParticleSystem(parsysid) + end +end + +function LookNorth(root, obj, prop_id, ged) + local pos, lookat, camtype = GetCamera() + local cam_orient = CalcOrientation(pos, lookat) + local map_orient = (obj.MapOrientation - 90) * 60 + local cam_vector = RotateAxis(lookat - pos, point(0, 0, 4096), map_orient - cam_orient) + if camtype == "Max" then + InterpolateCameraMaxWakeup({ pos = pos, lookat = lookat }, { pos = pos - cam_vector, lookat = pos }, 650, nil, "polar", "deccelerated") + else + SetCamera(pos - cam_vector, pos, camtype) + end +end + +function DeveloperChangeMap(...) + local editor_mode = Platform.editor and IsEditorActive() + + if not editor.IsModdingEditor() then + -- Ivko: Consider removing this temporary going out of editor, which has caused countless problems + XShortcutsSetMode("Game") -- This will exit the editor by the virtue of the mode_exit_func + end + CloseMenuDialogs() + Msg("DevUIMapChangePrep", ...) + ChangeMap(...) + StoreLastLoadedMap() + + if editor_mode then + EditorActivate() + end +end + +function GedMapDataOpenMap(ged) + local preset = ged.selected_object + if IsKindOf(preset, "MapDataPreset") then + CreateRealTimeThread(DeveloperChangeMap, preset.id) + elseif IsKindOf(preset, "MapVariationPreset") then + EditorActivate() + CreateRealTimeThread(DeveloperChangeMap, preset:GetMap(), preset.id) + end +end + +function MapDataPreset:GetPresetStatusText() + return "Double click to open map." +end + +function MapDataPreset:GetEditorViewPresetPrefix() + local ged = FindGedApp(MapDataPreset.GedEditor) + local filter = ged and ged:FindFilter("root") + local color_prop = filter and table.find_value(filter_by, "id", filter.FilterBy).color_prop or "Status" + return map_statuses[self[color_prop]] or "" +end + +function MapDataPreset:GetMapName() + return self.id +end + +function MapDataPreset:SetPassBorderTiles(tiles) + self.PassBorder = tiles * const.HeightTileSize + ObjModified(self) +end + +function MapDataPreset:GetPassBorderTiles() + return self.PassBorder / const.HeightTileSize +end + +function MapDataPreset:ActionAddToBlackList(preset, prop_id, ged) + local track, err = ged:WaitUserInput("", "Select track", PlaylistTracksCombo()) + if not track or track == "" then + return + end + local blacklist = self.Blacklist or {} + table.insert_unique(blacklist, track) + preset.Blacklist = #blacklist > 0 and blacklist or nil + ObjModified(self) +end + +function MapDataPreset:GetMapMarkersCount() + return #(self.markers or "") +end + +function MapDataPreset:SetBlacklistStr(str) + local blacklist = string.tokenize(str, ',', nil, true) + self.Blacklist = #blacklist > 0 and blacklist or nil + ObjModified(self) +end + +function MapDataPreset:SetTerrainTreeSize(value) + hr.TR_TerrainTreeRows = value + ObjModified(self) +end + +function MapDataPreset:GetBlacklistStr() + return self.Blacklist and table.concat(self.Blacklist, ",\n") or "" +end + +function MapDataPreset:SetSaveIn(save_in) + if self.save_in == save_in then return end + if save_in ~= "" and Playlists[save_in] then + if self.Playlist == self:GetDefaultPropertyValue("Playlist") or self.save_in ~= "" and self.Playlist == self.save_in then + self.Playlist = save_in + end + elseif self.save_in ~= "" and Playlists[self.save_in] and self.Playlist == self.save_in then + self.Playlist = self:GetDefaultPropertyValue("Playlist") + end + Preset.SetSaveIn(self, save_in) + ObjModified(self) +end + +function MapDataPreset:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "MapType" then + if self.MapType == "system" then + self.GameLogic = false + end + ObjModified(self) + elseif prop_id == "PassBorder" then + if self.PassBorder == old_value or GetMapName() ~= self:GetMapName() then + return + end + CreateRealTimeThread(function() + SaveMap("no backup") + ChangeMap(GetMapName()) + end) + elseif prop_id == "EditorLightmodel" and GetMapName() == self.id then + self:ApplyLightmodel() + end + + if CurrentMap ~= "" then + DeleteThread(UpdateMapDataThread) + UpdateMapDataThread = CreateMapRealTimeThread(function() + Sleep(100) + self:ApplyMapData() + AtmosphericParticlesUpdate() + end) + end +end + +function MapDataPreset:GetError() + if (self.Width - 1) % 128 ~= 0 or (self.Height - 1) % 128 ~= 0 then + return "Map width and height minus 1 must divide by 128 - use the sliders to set them" + end +end + +function MapDataPreset:GetSaveFolder(save_in) + return self.ModMapPath or string.format("Maps/%s/", self.id) +end + +function MapDataPreset:GetSavePath(save_in, group) + return self:GetSaveFolder(save_in) .. "mapdata.lua" +end + +function MapDataPreset:GenerateCode(code) + local sizex, sizey = terrain.GetMapSize() + self.Width = self.Width or (sizex and sizex / guim + 1) + self.Height = self.Height or (sizey and sizey / guim + 1) + + code:append("DefineMapData") + code:appendt(self) +end + +function MapDataPreset:GetSaveData(file_path, presets, ...) + assert(#presets <= 1) + return Preset.GetSaveData(self, file_path, presets, ...) +end + +function MapDataPreset:HandleRenameDuringSave(save_path, path_to_preset_list) + local presets = path_to_preset_list[save_path] + if #presets ~= 1 then return end + + local last_save_path = g_PresetLastSavePaths[presets[1]] + if last_save_path and last_save_path ~= save_path then + local old_dir = SplitPath(last_save_path) + local new_dir = SplitPath(save_path) + SVNMoveFile(old_dir, new_dir) + end +end + +function MapDataPreset:ChooseLightmodel() + return self.Lightmodel +end + +function MapDataPreset:ApplyLightmodel() + if IsEditorActive() then + SetLightmodel(1, self.EditorLightmodel, 0) + else + SetLightmodel(1, LightmodelOverride and LightmodelOverride.id or self:ChooseLightmodel(), 0) + end +end + +local function ToggleEditor() + if mapdata.EditorLightmodel then + mapdata:ApplyLightmodel() + end +end + +OnMsg.GameEnterEditor = ToggleEditor +OnMsg.GameExitEditor = ToggleEditor + +function MapDataPreset:ApplyMapData(setCamera) + self:ApplyLightmodel() + AtmosphericParticlesApply() + + if config.UseReverb and self.Reverb then + local reverb = ReverbDefs[self.Reverb] + if not reverb then + self.Reverb = false + else + reverb:Apply() + end + end + + if setCamera and self.CameraPos and self.CameraPos ~= InvalidPos() and + self.CameraLookAt and self.CameraLookAt ~= InvalidPos() + then + SetCamera(self.CameraPos, self.CameraLookAt, self.CameraType, self.Zoom, nil, self.CameraFovX) + end + + hr.TR_TerrainTreeRows = self.TerrainTreeRows + + SetMusicBlacklist(self.Blacklist) + if self.Playlist ~= "" then + SetMusicPlaylist(self.Playlist) + end +end + +function MapDataPreset:GetPlayableSize() + local sizex, sizey = terrain.GetMapSize() + return sizex - 2 * mapdata.PassBorder, sizey - 2 * mapdata.PassBorder +end + +function MapDataPreset:SetCamera() + local zoom, props + self.CameraPos, self.CameraLookAt, self.CameraType, zoom, props, self.CameraFovX = GetCamera() + GedObjectModified(self) +end + +function MapDataPreset:ViewCamera() + if self.CameraPos and self.CameraLookAt and self.CameraPos ~= InvalidPos() and self.CameraLookAt ~= InvalidPos() then + SetCamera(self.CameraPos, self.CameraLookAt, self.CameraType, nil, nil, self.CameraFovX) + end +end + +function OnMsg.NewMap() + if mapdata.MapType == "system" then mapdata.GameLogic = false end + if IsKindOf(mapdata, "MapDataPreset") then + mapdata:ApplyMapData("set camera") + end +end + +function LoadAllMapData() + MapData = {} + + local map + local fenv = LuaValueEnv{ + DefineMapData = function(data) + local preset = MapDataPreset:new(data) + preset:SetGroup(preset:GetGroup()) + preset:SetId(map) + preset:PostLoad() + g_PresetLastSavePaths[preset] = preset:GetSavePath() + MapData[map] = preset + end, + } + + if IsFSUnpacked() then + local err, folders = AsyncListFiles("Maps", "*", "relative folders") + if err then return end + for i = 1, #folders do + map = folders[i] + local ok, err = pdofile(string.format("Maps/%s/mapdata.lua", map), fenv) + assert( ok, err ) + end + else + local function LoadMapDataFolder(folder) + local err, files = AsyncListFiles(folder, "*.lua") + if err then return end + for i = 1, #files do + local dir, file, ext = SplitPath(files[i]) + if file ~= "__load" then + map = file + dofile(files[i], fenv) + end + end + end + + LoadMapDataFolder("Data/MapData") + for _, dlc_folder in ipairs(DlcFolders) do + LoadMapDataFolder(dlc_folder .. "/Maps") + end + end + + Msg("MapDataLoaded") +end + +function OnMsg.PersistSave(data) + if IsKindOf(mapdata, "MapDataPreset") then + data.mapdata = {} + local props = mapdata:GetProperties() + for _, meta in ipairs(props) do + local id = meta.id + data.mapdata[id] = mapdata:GetProperty(id) + end + end +end + +function OnMsg.PersistLoad(data) + if data.mapdata then + mapdata = MapDataPreset:new(data.mapdata) + end +end + +function MapDataPreset:UpdateStartup() + if GetMap() == "" then + return + end + self:SetStartupCam{GetCamera()} + self:SetStartupEditor(IsEditorActive()) + ObjModified(self) +end + +function MapDataPreset:GotoStartup() + if GetMap() == "" then + return + end + local in_editor = self:GetStartupEditor() + if in_editor then + EditorActivate() + end + local startup_cam = self:GetStartupCam() + if startup_cam then + SetCamera(table.unpack(startup_cam)) + end +end + +for _, prop in ipairs(MapDataPreset.properties) do + if prop.dev_option then + prop.developer = true + prop.dont_save = true + MapDataPreset["Get" .. prop.id] = function(self) + return GetDeveloperOption(prop.id, "MapStartup", self.id, false) + end + MapDataPreset["Set" .. prop.id] = function(self, value) + SetDeveloperOption(prop.id, value, "MapStartup", self.id) + end + end +end + +local function MapStartup() + if MapReloadInProgress or GetMap() == "" or not mapdata:GetStartupEnable() then + return + end + mapdata:GotoStartup() +end +local function MapStartupDelayed() + DelayedCall(0, MapStartup) +end +OnMsg.EngineStarted = MapStartupDelayed +OnMsg.ChangeMapDone = MapStartupDelayed + + +----- Map variations data +-- +-- MapVariationPreset stores data about map variations that are edited as map patches applied over the base map: +-- * id - variation name +-- * group - base map name +-- * save_in - DLC to be saved in (or "") + +DefineClass.MapVariationPreset = { + __parents = { "Preset" }, + GedEditor = "GedMapVariationsEditor", +} + +-- custom property tweaks +function OnMsg.ClassesGenerate() + -- patch Group property => "Map" + local group_prop = table.copy(table.find_value(Preset.properties, "id", "Group")) + group_prop.name = "Map" + local old_validate = group_prop.validate + group_prop.validate = function(...) + local err = old_validate(...) + if err then + return err:gsub(" group", " map"):gsub("preset", "map variation"):gsub("Preset", "Map variation") + end + end + + -- patch Save In to only list DLCs (and not libs) + local savein_prop = table.copy(table.find_value(Preset.properties, "id", "SaveIn")) + savein_prop.items = function() return DlcComboItems() end + + MapVariationPreset.properties = { group_prop, savein_prop } +end + +function MapVariationPreset:GetMap() + return self.group +end + +function MapVariationPreset:GetMapPatchPath(id, map, save_in) + id = id or self.id + map = map or self:GetMap() + save_in = save_in or self.save_in + if save_in == "" then + return string.format("%s%s.patch", GetMapFolder(map), id) + end + return string.format("svnProject/Dlc/%s/MapVariations/%s - %s.patch", save_in, map, id) +end + +function MapVariationPreset:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Id" or prop_id == "Group" or prop_id == "SaveIn" then + local new_path = self:GetMapPatchPath() + local old_path = + prop_id == "Id" and self:GetMapPatchPath(old_value) or + prop_id == "Group" and self:GetMapPatchPath(nil, old_value) or + prop_id == "SaveIn" and self:GetMapPatchPath(nil, nil, old_value) + if old_path ~= new_path then + ExecuteWithStatusUI("Moving map variation...", function() + SVNMoveFile(old_path, new_path) + if IsMapVariationEdited(self) then + StopEditingCurrentMapVariation() + end + self:Save() + end) + end + end +end + +function MapVariationPreset:GetError() + if Platform.developer and not io.exists(self:GetMapPatchPath()) then + return string.format("Patch file %s doesn't exist.", self:GetMapPatchPath()) + end +end + +function MapVariationPreset:OnEditorDelete() + ExecuteWithStatusUI("Deleting map variation...", function() + local filename = self:GetMapPatchPath() + SVNDeleteFile(filename) + local path = SplitPath(filename) + if #io.listfiles(path, "*.*") == 0 then + SVNDeleteFile(path) + end + if IsMapVariationEdited(self) then + StopEditingCurrentMapVariation() + end + self:Save() + end) +end + +function MapVariationPreset:OnEditorNew(parent, ged, is_paste, old_id) + assert(is_paste) -- should be a Duplicate action, new MapVariationPreset instances are created via the Map editor + ExecuteWithStatusUI("Copying map variation...", function() + local old_path = self:GetMapPatchPath(old_id) + local new_path = self:GetMapPatchPath() + local err = AsyncCopyFile(old_path, new_path, "raw") + if err then + ged:ShowMessage("Error copying file", string.format("Failed to copy '%s' to its new location '%s'.", old_path, new_path)) + return + end + SVNAddFile(new_path) + self:Save() + end) +end + +function MapVariationPreset:GetPresetStatusText() + return "Double click to open in map editor." +end + + +----- Map variation global functions + +function MapVariationNameText(preset) + local name, save_in = preset.id, preset.save_in + return name .. (save_in ~= "" and string.format(" (%s)", save_in) or "") +end + +function MapVariationItems(map) + local ret = {} + for _, preset in ipairs(Presets.MapVariationPreset[CurrentMap]) do + table.insert(ret, { text = MapVariationNameText(preset), value = preset }) + end + table.sortby_field(ret, "text") + return ret +end + +function FindMapVariation(name, save_in) + local map_variations = Presets.MapVariationPreset[CurrentMap] + if not map_variations then return end + + if not save_in or save_in == "" then + return map_variations[name] + end + for _, preset in ipairs(map_variations) do + if preset.id == name and preset.save_in == save_in then + return preset + end + end +end + +function CreateMapVariation(name, save_in) + assert(IsEditorActive() and CurrentMap ~= "") + local preset = FindMapVariation(name, save_in) + if not preset then + preset = MapVariationPreset:new{ id = name, save_in = save_in, group = CurrentMap } + preset:Register() + preset:Save() + end + + CurrentMapVariation = preset +end + +function ApplyMapVariation(name, save_in) + if name then + local preset = FindMapVariation(name, save_in) + if preset then + XEditorApplyMapPatch(preset:GetMapPatchPath()) + CurrentMapVariation = preset + return + end + end + CurrentMapVariation = false +end + +if config.Mods then + +-- Load all mod related mapdata into the game (it looks for it in every "Maps" folder of each loaded mod) +function OnMsg.ModsReloaded() + local fenv = LuaValueEnv{ + DefineMapData = function(data) + if MapData[data.id] then + MapData[data.id]:delete() -- remove previous version of the map data, which is potentially loaded + end + local preset = MapDataPreset:new(data) + preset.mod = true -- display as [ModItem] in the MapData editor + preset:SetGroup(preset:GetGroup()) + preset:SetId(preset.id) + preset:PostLoad() + g_PresetLastSavePaths[preset] = preset.ModMapPath + MapData[preset.id] = preset + end, + } + + for _, mod in ipairs(ModsLoaded) do + local err, mapdataFiles = AsyncListFiles(mod.content_path .. "Maps/", "mapdata.lua", "recursive") + if not err and next(mapdataFiles) then + for _, mapdataFile in ipairs(mapdataFiles) do + local ok, err = pdofile(mapdataFile, fenv) + assert(ok, err) + end + end + end +end + +end diff --git a/CommonLua/Classes/Mod.lua b/CommonLua/Classes/Mod.lua new file mode 100644 index 0000000000000000000000000000000000000000..c7f37fd8489549584528e3f78715111f5236d8c1 --- /dev/null +++ b/CommonLua/Classes/Mod.lua @@ -0,0 +1,2889 @@ +if FirstLoad then + Mods = {} + ModsList = false -- in list form sorted by title, used for editing in Ged + ModsLoaded = false -- an array of the loaded mods in the order of loading + ModsPackFileName = "ModContent.hpk" + ModContentPath = "Mod/" + ModMountNextLabelIdx = 0 + --screenshots are copied to another folder and renamed + --this way we can distinguish them from shots uploaded from outside the mod editor + ModsScreenshotPrefix = "ModScreenshot_" + ModMessageLog = {} + + --Bumped whenever a major change to mods has been made. + --One that breaks backward compatibility. + ModMinLuaRevision = 233360 + + --Min game version required to load mods created using the current game version. + --Bumped whenever forward compatibility is broken for older game versions. + ModRequiredLuaRevision = 233360 + + --Mod blacklist support + --These mods will not show in the UI and will not be loaded + --Two types of blacklisting: + --deprecate -> mod is present in the base game and no longer needed, saves will work without it, no warnings for missing in saves, warning in ui that is deprecated, can be loaded again + --ban -> mod is banned from the game, saves with this mod missing are not guaranteed to work, doesn't show in the ui at all + ModIdBlacklist = {} -- { [id] = "deprecate", [id] = "ban" } + AutoDisabledModsAlertText = {} + + if Platform.goldmaster and io.exists("ModTools") then + function OnMsg.Autorun() + CreateRealTimeThread(function() + local out_path = ConvertToOSPath("ModTools/Src/ModTools.code-workspace") + if io.exists(out_path) then return end + local template_path = ConvertToOSPath("ModTools/Src/ModTools.code-workspace.template") + local err, contents = AsyncFileToString(template_path) + if err then return end + local mods_path = ConvertToOSPath("AppData/Mods/") + contents = string.gsub(contents, "Mods", string.gsub(mods_path, "\\", "\\\\")) + local err = AsyncStringToFile(out_path, contents) + end) + end + end +end + +function IsUserCreatedContentAllowed() + return true +end + +mod_print = CreatePrint{ + "mod", + format = "printf", + output = DebugPrint, +} + +-- Usage: +-- ModLog(T(...)) to print to Mod Manager dialog +-- ModLog(true, T(...)) to print to Mod Manager and store in log file +function ModLog(log_or_msg, msg) + local log + if IsT(log_or_msg) then + msg = log_or_msg + else + log = log_or_msg + end + msg = _InternalTranslate(msg) + ModMessageLog[#ModMessageLog + 1] = msg + if log then + mod_print(msg) + end + ObjModifiedDelayed(ModMessageLog) +end + +-- Usage: +-- ModLogF("asd", ...) to print to Mod Manager dialog +-- ModLogF(true, "asd", ...) to print to Mod Manager and store in log file +function ModLogF(log_or_fmt, fmt, ...) + local log, msg + if type(log_or_fmt) == "string" then + local arg1 = fmt + fmt = log_or_fmt + msg = string.format(fmt, arg1, ...) + else + log = log_or_fmt + msg = string.format(fmt, ...) + end + return ModLog(log, Untranslated(msg)) +end + +function OnMsg.Autorun() + ObjModified(ModMessageLog) +end + +if not config.Mods then + AreModdingToolsActive = empty_func + DefineClass("ModLinkDef", "PropertyObject") + return +end + +function AreModdingToolsActive() + return IsModEditorOpened() or IsModManagerOpened() or IsModEditorMap() or (Game and Game.testModGame) +end + +DocsRoot = "ModTools/Docs/" +if Platform.developer then + DocsRoot = "svnProject/Docs/ModTools/" +end + + +----- ModElement + +DefineClass.ModElement = { +} + +function ModElement:OnLoad(mod) + self:AddPathPrefix() +end + +function ModElement:OnUnload(mod) +end + +function ModElement:IsMounted() +end + +function ModElement:IsPacked() +end + +--root path is where the mod exists on the OS +function ModElement:GetModRootPath() +end + +--content path is the path where content can be accessed +function ModElement:GetModContentPath() +end + +function ModConvertSlashes(path) + --convert all '\' into '/' + return string.gsub(path, "\\", "/") +end + +local function EscapeMagicSymbols(path) + --convert all gsub 'magic symbols' into escaped symbols + return string.gsub(ModConvertSlashes(path), "[%(%)%.%%%+%-%*%?%[%^%$]", "%%%1") +end + +local function GetChildren(item) + if IsKindOf(item, "ModDef") then + return item.items + else + return item + end +end + +function ModElement:PostSave() + self:AddPathPrefix() +end + +local function ModResourceExists(path, item) + if io.exists(path) then + return true + end + + if item and IsKindOf(item, "SoundFile") then + local parent = GetParentTable(item) + local files = parent and parent:GetSoundFiles() + if files and files[path .. "." .. item:GetFileExt()] then + return true + end + end + + local res_id = ResourceManager.GetResourceID(path) + return res_id ~= const.InvalidResourceID +end + +local function RecursiveAddPathPrefix(item, mod_path, mod_os_path, mod_content_path, is_packed) + for i, prop in ipairs(item:GetProperties()) do + if (prop.editor == "browse" or prop.editor == "ui_image") and not prop.os_path then + local prop_id = prop.id + local path = item:GetProperty(prop_id) + if (path or "") ~= "" and not path:starts_with(ModContentPath) and not ModResourceExists(path, item) then + if is_packed then + item:SetProperty(prop_id, mod_content_path .. path) + else + if not string.find(path, EscapeMagicSymbols(mod_os_path)) then + item:SetProperty(prop_id, ModConvertSlashes(mod_os_path .. path)) + end + end + end + end + end + + for _, child in ipairs(GetChildren(item)) do + RecursiveAddPathPrefix(child, mod_path, mod_os_path, mod_content_path, is_packed) + end +end + +function ModElement:AddPathPrefix() + local mod_path = ModConvertSlashes(self:GetModRootPath()) + local mod_os_path = ConvertToOSPath(mod_path) + local mod_content_path = self:GetModContentPath() + local is_packed = self:IsPacked() + RecursiveAddPathPrefix(self, mod_path, mod_os_path, mod_content_path, is_packed) +end + +function ModElement:PreSave() + self:RemovePathPrefix() +end + +local function RecursiveRemovePathPrefix(item, mod_path, mod_os_path, mod_content_path, is_packed) + for i, prop in ipairs(item:GetProperties()) do + if (prop.editor == "browse" or prop.editor == "ui_image") and not prop.os_path then + local prop_id = prop.id + local path = item:GetProperty(prop_id) + if (path or "") ~= "" and not path:starts_with(ModContentPath) then + path = ModConvertSlashes(path) + local prefix = is_packed and mod_content_path or mod_os_path + local new_path = string.gsub(path, EscapeMagicSymbols(prefix), "") + item:SetProperty(prop_id, new_path) + end + end + end + + for _, child in ipairs(GetChildren(item)) do + RecursiveRemovePathPrefix(child, mod_path, mod_os_path, mod_content_path, is_packed) + end +end + +function ModElement:RemovePathPrefix() + local mod_path = ModConvertSlashes(self:GetModRootPath()) + local mod_os_path = ModConvertSlashes(ConvertToOSPath(mod_path)) + local mod_content_path = self:GetModContentPath() + local is_packed = self:IsPacked() + RecursiveRemovePathPrefix(self, mod_path, mod_os_path, mod_content_path, is_packed) +end + +function ModElement:NeedsResave() +end + + +----- ModLinkDef + +DefineClass.ModLinkDef = { + __parents = { "DisplayPreset", }, + __generated_by_class = "PresetDef", + + properties = { + { id = "Patterns", + editor = "string_list", default = {}, item_default = "", items = false, arbitrary_value = true, help = "A link should match any of these patterns. The pattern capture (in brackets) defines how the link will be displayed. Add a capture to remove the typical 'https://www.' at the start." }, + { id = "Icon", + editor = "ui_image", default = false, }, + }, + GlobalMap = "ModLinkDefs", + EditorMenubarName = "Mod links", + EditorMenubar = "Editors.Engine", + StoreAsTable = true, +} + +function NormalizeLink(link) + if not link or link == "" then return "" end + return link:starts_with("https://") and link or "https://" .. link +end + +function GetLinkDef(link) + link = NormalizeLink(link) + local link_def, link_short + ForEachPreset("ModLinkDef", function(def) + for _, pattern in ipairs(def.Patterns) do + link_short = link:match(pattern) + if link_short then + link_def = def + return "break" + end + end + end) + return link_def, link_short +end + +function GetLinkShort(link) + local def, short = GetLinkDef(link) + return short +end + + +----- ModDef + +DefineClass.ModDef = { + __parents = { "GedEditedObject", "ModElement", "Container", "InitDone" }, + properties = + { + { category = "Mod", id = "title", name = "Title", editor = "text", default = "" }, + { category = "Mod", id = "description", name = "Description", editor = "text", default = "", lines = 5, max_lines = 15, max_len = 8000, }, + { category = "Mod", id = "tags", name = "Tags", editor = false, default = "" }, + { category = "Mod", id = "image", name = "Preview image",editor = "ui_image", default = "", filter = "Image files|*.png;*.jpg" }, + { category = "Mod", id = "external_links",name = "Links", editor = "string_list", default = {}, arbitrary_value = true, help = function(obj, prop_meta) + local sites = {} + ForEachPreset("ModLinkDef", function(def) + sites[#sites + 1] = " • " .. (def.display_name == "" and def.id or TTranslate(def.display_name, def)) + end) + return "Allows including links in the mod description to the following sites:\n\n" .. table.concat(sites, "\n") + end }, + { category = "Mod", id = "last_changes", name = "Last changes", editor = "text", default = "", lines = 3, }, + { category = "Mod", id = "ignore_files", name = "Ignore files", editor = "string_list", default = { "*.git/*", "*.svn/*" }, + help = "Files in the mod folder that must not be included in the packaged mod." + }, + { category = "Mod", id = "dependencies", name = "Dependencies", editor = "nested_list", default = false, base_class = "ModDependency", inclusive = true, + help = "Allows specifying a list of mods required for this mod to work,\nor mods that must be loaded before it, if present." + }, + { category = "Mod", id = "id", name = "ID", editor = "text", default = "", read_only = true }, + { category = "Mod", id = "content_path", name = "Content path", editor = "text", default = false, read_only = true, help = "Folder to access the mod files.", buttons = {{name = "Copy", func = "CopyContentPath"}}, dont_save = true }, + { category = "Mod", id = "author", name = "Author", editor = "text", default = "", read_only = true }, -- platform specific author name + { category = "Mod", id = "version_major", name = "Major version", editor = "number", default = 0 }, + { category = "Mod", id = "version_minor", name = "Minor version", editor = "number", default = 0 }, + { category = "Mod", id = "version", name = "Revision", editor = "number", default = 0, read_only = true }, + { category = "Mod", id = "lua_revision", name = "Required game version", editor = "number", default = 0, read_only = true }, + { category = "Mod", id = "saved_with_revision", name = "Saved with game version", editor = "number", default = 0, read_only = true }, + + -- not displayed, used for saving only + { category = "Mod", id = "entities", editor = "prop_table", default = false, no_edit = true }, + { category = "Mod", id = "code", editor = "prop_table", default = false, no_edit = true }, + { category = "Mod", id = "loctables", editor = "prop_table", default = false, no_edit = true }, + { category = "Mod", id = "default_options", editor = "prop_table", default = false, no_edit = true }, + { category = "Mod", id = "has_data", editor = "bool", default = false, no_edit = true }, + { category = "Mod", id = "saved", editor = "number", default = false, no_edit = true }, + { category = "Mod", id = "code_hash", editor = "number", default = false, no_edit = true }, + + { category = "Screenshot", id = "screenshot1", name = "Screenshot", editor = "ui_image", default = "", filter = "Image files|*.png;*.jpg" }, + { category = "Screenshot", id = "screenshot2", name = "Screenshot", editor = "ui_image", default = "", filter = "Image files|*.png;*.jpg" }, + { category = "Screenshot", id = "screenshot3", name = "Screenshot", editor = "ui_image", default = "", filter = "Image files|*.png;*.jpg" }, + { category = "Screenshot", id = "screenshot4", name = "Screenshot", editor = "ui_image", default = "", filter = "Image files|*.png;*.jpg" }, + { category = "Screenshot", id = "screenshot5", name = "Screenshot", editor = "ui_image", default = "", filter = "Image files|*.png;*.jpg" }, + + { category = "Conflict Detection", id = "affected_resources", name = "Affected resources", editor = "nested_list", default = false, base_class = "ModResourceDescriptor", inclusive = true, read_only = true, + help = "Lists the game content affects by the mod, used to detect potential conflicts as mods are loaded.", + sort_order = 1000000, + }, + }, + path = "", -- source folder, this is where the metadata and ModContent.hpk is located + source = "appdata", + env = false, + packed = false, + mounted = false, + mount_label = false, + status = "alive", + items = false, + items_file_timestamp = 0, + options = false, + dev_message = "", + ContainerClass = "ModItem", + force_reload = false, + mod_opening = false, + mod_ged_id = false, + -- Mods Editor + GedTreeChildren = function (self) return self.items end, +} + +function ModDef:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "title" then + local new_title = "Mod Editor - " .. self[prop_id] + ged:Send("rfnApp", "SetTitle", new_title) + end +end + +function ModDef:CopyContentPath() + CopyToClipboard(self.content_path) +end + +function ModDef:Init() + self.options = ModOptionsObject:new({ __mod = self }) +end + +function ModDef:delete() + if self.status == "deleted" then + return + end + + self:UnloadItems() + self:UnmountContent() + self.status = "deleted" +end + +function ModDef:IsOpenInGed() + return not not GedObjects[ParentTableCache[self]] +end + +function ModDef:CalculatePersistHash() + local hash = PropertyObject.CalculatePersistHash(self) + self:ForEachModItem(function(mod_item) + mod_item:TrackDirty() -- calculate the current hash, if not already calculated + hash = xxhash(hash, mod_item:EditorData().current_hash) -- consider the mod changed if any mod item is changed + end) + return hash +end + +function ModDef:__eq(rhs) + return + self.id == rhs.id and + self.source == rhs.source and + self.version == rhs.version +end + +function ModDef:GetTags() + assert(false, "override this method in game-specific code") + return { } +end + +function ModDef:GetEditorView() + if self:ItemsLoaded() then + return Untranslated(" (loaded)\nid , version ") + else + return Untranslated("\nid , version ") + end +end + +local ModIdCharacters = "ACDEFGHJKLMNPQRSTUVWXYabcdefghijkmnopqrstuvwxyz345679" +function ModDef:GenerateId() + local id = "" + for i = 1, 7 do + local rand = AsyncRand(i > 1 and #ModIdCharacters or #ModIdCharacters - 10) + id = id .. ModIdCharacters:sub(rand, rand) + end + return id +end + +function ModDef:GetExternalLinkError() + local seen = {} + for i, link in ipairs(self.external_links) do + if link ~= "" then + local def = GetLinkDef(link) + if not def then return "Unrecognized link: " .. link, i end + if seen[def] then + return "Duplicate link to: " .. def.id + end + seen[def] = true + end + end +end + +function ModDef:GetError() + return self:GetExternalLinkError() +end + +function ModDef:GetValidatedExternalLinks() + local seen, validated = {}, {} + for _, link in ipairs(self.external_links) do + local def = GetLinkDef(link) + if def and not seen[def] then + validated[#validated + 1] = link + seen[def] = true + end + end + return validated +end + +function ModDef:GenerateMountLabel() + ModMountNextLabelIdx = ModMountNextLabelIdx + 1 + return "mod_" .. ModMountNextLabelIdx +end + +function ModDef:ChangePaths(mod_path) + self:RemovePathPrefix() + self.path = mod_path + self:AddPathPrefix() +end + +function ModDef:NeedsResave() + return self:ForEachModItem(function(item) + if item:NeedsResave() then + return true + end + end) +end + +function ModDef:GetWarning() + if self:NeedsResave() then + return "Please resave this mod for optimization reasons." + end +end + +function ModDef:SortItems() + table.stable_sort(self.items, function(a, b) + if a.class == b.class then + return a.name < b.name + end + return a.class < b.class + end) +end + +function ModDef:ItemsLoaded() + return not not self.items +end + +function ModDef:GenerateModItemId(mod_item) + return string.format("autoid_%s_%s", self.id, self:GenerateId()) +end + +function ModDef:GetDefaultOptions() + local options_items = { } + if not self.items then return options_items end + + self:ForEachModItem("ModItemOption", function(item) + if item.name and item.name ~= "" then + options_items[item.name] = item.DefaultValue + end + end) + return options_items +end + +function ModDef:HasOptions() + return self.has_options or next(self.default_options) -- has_options is used for backwards comp +end + +function ModDef:GetOptionItems(test) + local options_items = not test and { } + if not self.items then return options_items end + self:ForEachModItem("ModItemOption", function(item) + if item.name and item.name ~= "" then + if test then return true end + table.insert(options_items, item) + end + end) + + return options_items +end + +function ModDef:LoadCode() + if not LuaLoadedForMods[self.id] then + rawset(self.env, "FirstLoad", true) + LuaLoadedForMods[self.id] = true + end + + -- load classes generated from Presets (e.g. composite classes) first + local errs = {} + for _, filename in ipairs(self.code) do + if not filename:starts_with("Code/") then + local ok, err = pdofile(self.content_path .. filename, self.env, "t") + if not ok then + err = err and err:gsub(self.content_path, ""):gsub("cannot read ", ""):gsub(": ", " - ") + table.insert(errs, err or string.format("%s: Unknown error", filename)) + end + end + end + + -- load Code mod items last, to allow them define methods of classes loaded above, etc. + for _, filename in ipairs(self.code) do + if filename:starts_with("Code/") then + local ok, err = pdofile(self.content_path .. filename, self.env, "t") + if not ok then + err = err and err:gsub(self.content_path, ""):gsub("cannot read ", ""):gsub(": ", " - ") + table.insert(errs, err or string.format("%s: Unknown error", filename)) + end + end + end + + rawset(self.env, "FirstLoad", false) + return next(errs) and errs +end + +function ModDef:StoreItemsFileModifiedTime() + self.items_file_timestamp = io.getmetadata(self.content_path .. "items.lua", "modification_time") +end + +function ModDef:IsItemsFileModified() + return self.items_file_timestamp ~= io.getmetadata(self.content_path .. "items.lua", "modification_time") +end + +function ModDef:LoadItems() + if self:ItemsLoaded() then return end + + assert(self.content_path) + local path = self.content_path .. "items.lua" + local ok, items = pdofile(path, self.env) + self.items = ok and items or { } + if not ok then + local err = items + ModLogF(true, "Failed to load mod items for %s. Error: %s", self:GetModLabel("plainText"), err) + return + end + + -- cache the source file to make sure it matches with the debug info of loaded functions + -- (and we can get their source code even if items.lua is edited externally) + local err, source = AsyncFileToString(path, nil, nil, "lines") + if not err then CacheLuaSourceFile(path, source) end + + PopulateParentTableCache(self) + self:StoreItemsFileModifiedTime() + + local has_data + self:ForEachModItem(function(item) + item.mod = self + sprocall(item.OnModLoad, item, self) + has_data = has_data or item.is_data + end) + self.has_data = has_data +end + +function ModDef:LoadOptions() + if not self:HasOptions() then + self:UnloadOptions() + return + end + + if AccountStorage then + local options_in_storage = AccountStorage.ModOptions and AccountStorage.ModOptions[self.id] + self:UnloadOptions() + table.overwrite(self.options, options_in_storage) + -- initialize option defaults + -- so mod code can use it without GetProperty + for id, default_value in pairs(self.default_options) do + if rawget(self.options, id) == nil then + rawset(self.options, id, default_value) + end + end + Msg("ApplyModOptions", self.id) + end +end + +function ModDef:UnloadOptions() + local options_obj = self.options + options_obj.properties = nil + options_obj.__defaults = nil + table.clear(options_obj) + options_obj.__mod = self +end + +function ModDef:UnloadItems() + if not self:ItemsLoaded() then + return + end + + self:ForEachModItem(function(item) + item:OnModUnload(self) + item:delete() + end) + + self.items = false + self.has_data = false +end + +function ModDef:ForEachModItem(classname, fn) + if not self.items then return end + for _, item in ipairs(self.items) do + local ret = item:ForEachModItem(classname, fn) + if ret == "break" then + break + elseif ret ~= nil then + return ret + end + end +end + +function ModDef:FindModItem(mod_item) + return table.find(self.items, mod_item) +end + +function ApplyModOptions(host) + CreateRealTimeThread(function(host) + local mod = GetDialogModeParam(host) + local options = mod.options + if not options then return end + --@@@msg ApplyModOptions,mod_id- fired when loading the mod options and when the user applies changes to their mod options. + Msg("ApplyModOptions", mod.id) + + AccountStorage.ModOptions = AccountStorage.ModOptions or { } + local storage_table = AccountStorage.ModOptions[mod.id] or { } + for _, prop in ipairs(options:GetProperties()) do + local value = options:GetProperty(prop.id) + value = type(value) == "table" and table.copy(value) or value + storage_table[prop.id] = value + end + AccountStorage.ModOptions[mod.id] = storage_table + SaveAccountStorage(1000) + + SetBackDialogMode(host) + end, host) +end + +function CancelModOptions(host) + CreateRealTimeThread(function(host) + local mod = GetDialogModeParam(host) + local original_obj = ResolvePropObj(host:ResolveId("idOriginalModOptions").context) + if not mod or not original_obj then return end + local properties = mod.options:GetProperties() + for i = 1, #properties do + local prop = properties[i] + local original_value = original_obj:GetProperty(prop.id) + mod.options:SetProperty(prop.id, original_value) + end + SetBackDialogMode(host) + end, host) +end + +function ResetModOptions(host) + CreateRealTimeThread(function(host) + local mod = GetDialogModeParam(host) + local options = mod and mod.options + if not options then return end + local properties = mod.options:GetProperties() + for i = 1, #properties do + local prop = properties[i] + local default_value = mod.options:GetDefaultPropertyValue(prop.id, prop) + mod.options:SetProperty(prop.id, default_value) + end + ObjModified(mod.options) + end, host) +end + +function HasModsWithOptions() + local mods_to_load = AccountStorage and AccountStorage.LoadMods + if not mods_to_load then return end + for i,id in ipairs(mods_to_load) do + local mod_def = Mods[id] + if mod_def and mod_def:HasOptions() then + return true + end + end +end + +function ModDef:UpdateEntities() + --backwards compatibility for r342666 + self.bin_assets = nil + + local dirty + if self.items then + local entities = false + self:ForEachModItem("ModItemEntity", function(item) + if item.entity_name ~= "" then + entities = entities or {} + table.insert(entities, item.entity_name) + dirty = dirty or item:IsDirty() + end + end) + self.entities = entities + end + return dirty +end + +function ModDef:UpdateCode() + local dirty + if self.items then + local code = false + local code_hash + self:ForEachModItem(function(item) + local name = item:GetCodeFileName() or "" + if name ~= "" then + code = code or {} + code[#code + 1] = name + local err, hash + if name ~= "" then + err, hash = AsyncFileToString(item:GetCodeFilePath(), nil, nil, "hash") + end + code_hash = xxhash(code_hash, name, hash) + dirty = dirty or err or item:IsDirty() + end + end) + dirty = dirty or code_hash ~= self.code_hash + self.code = code + self.code_hash = code_hash + end + return dirty +end + +function ModDef:UpdateLocTables() + if self.items then + local loctables + self:ForEachModItem("ModItemLocTable", function(item) + loctables = loctables or {} + item:RemovePathPrefix() + loctables[#loctables + 1] = { filename = item.filename, language = item.language } + item:AddPathPrefix() + end) + self.loctables = loctables + end +end + +function ModDef:MountContent() + if not self.mounted then + self.mounted = true + self.mount_label = self.mount_label or self:GenerateMountLabel() + if self.packed then + MountPack(self.content_path, self.path .. ModsPackFileName, "label:" .. self.mount_label) + else + MountFolder(self.content_path, self.path, "label:" .. self.mount_label) + end + end + + local binAssetsLabel = self.mount_label .. "BinAssets" + + if self.packed and MountsByLabel(binAssetsLabel) == 0 then + MountFolder("BinAssets/Materials", self.content_path .. "BinAssets", "seethrough label:" .. binAssetsLabel) + end +end + +function ModDef:UnmountContent() + if not self.mounted then return end + local binAssetsLabel = self.mount_label .. "BinAssets" + if self.packed and MountsByLabel(binAssetsLabel) > 0 then + UnmountByLabel(binAssetsLabel) + end + UnmountByLabel(self.mount_label) + self.mounted = false +end + +function ModDef:IsMounted() + return self.mounted +end + +function ModDef:IsPacked() + return self.packed +end + +function ModDef:GetModRootPath() + return ConvertToOSPath(SlashTerminate(self.path)) +end + +function ModDef:GetModContentPath() + return self.content_path +end + +function ModDef:IsTooOld() + return self.lua_revision < ModMinLuaRevision +end + +function ModDef:IsTooNew() + return self.lua_revision > LuaRevision +end + +function ModDef:RefreshAffectedResources() + if not self.items then return end + + local all_affected_res = {} + self:ForEachModItem(function(item) + table.iappend(all_affected_res, item:GetAffectedResources() or empty_table) + end) + + -- If there are changes to the affected resources, clear the cache + if not self.affected_resources or not table.iequal(self.affected_resources, all_affected_res) then + ClearModsAffectedResourcesCache() + end + + self.affected_resources = all_affected_res +end + + +----- Saving + +function ModDef:CanSaveMod(ged, ask_for_save_question) + assert(self.items) + if not self.items then return end + + local title = "Mod "..self.title + if ask_for_save_question and ged:WaitQuestion(title, ask_for_save_question, "Yes", "No") ~= "ok" then + return + end + if self:IsItemsFileModified() and ged:WaitQuestion(title, + "The items.lua file was modified externally.\n\nSaving will overwrite the external changes. Continue?", "Yes", "No") ~= "ok" then + return + end + if PreloadFunctionsSourceCodes(self) == "error" and ged:WaitQuestion(title, + "Unable to get the code of all Lua functions.\n\nThe code of some functions will be lost. Continue?", "Yes", "No") ~= "ok" then + return + end + return true +end + +function ModDef:PreSave() + ModElement.PreSave(self) + self:RefreshAffectedResources() +end + +function ModDef:SaveDef(serialize_only) + self.external_links = self:GetValidatedExternalLinks() + if self.content_path then + local code_dirty + if not serialize_only then + Msg("ModDefUpdate", self) + self.lua_revision = ModRequiredLuaRevision + self.saved_with_revision = LuaRevision + self.version = self.version + 1 + self.saved = os.time() + self.default_options = self:GetDefaultOptions() + self:UpdateEntities() + self:UpdateLocTables() + code_dirty = self:UpdateCode() + end + + local data = pstr("return ", 32768) + self:PreSave() + data:appendv(self, "") + self:PostSave() + local err = AsyncStringToFile(self.content_path .. "metadata.lua", data) + + if not serialize_only then + CreateRealTimeThread(function() + Sleep(200) + self:ForEachModItem(function(item) + item:MarkClean() + end) + self:MarkClean() + ObjModified(self) + end) + DelayedCall(50, SortModsList) + end + return err, code_dirty + end +end + +function ModDef:SaveItems() + if not self:ItemsLoaded() then return "not loaded" end + + -- generate code + local data = pstr("return ", 65536) + local saved_preset_classes = {} + self:ForEachModItem(function(item) item:PreSave() end) + ValueToLuaCode(self.items, "", data) + self:ForEachModItem(function(item) item:PostSave(saved_preset_classes) end) + + -- save to file + local path = self.content_path .. "items.lua" + local err = AsyncStringToFile(path, data) + data:free() + self:StoreItemsFileModifiedTime() + + -- trigger PresetSave, usually used for postprocessing data or applying changes + for class in sorted_pairs(saved_preset_classes) do + Msg("PresetSave", class) + end + + CacheModDependencyGraph() + return err +end + +function ModDef:SaveOptions() + if self.options then + self.options.properties = nil + self.options.__defaults = nil + end + if self.has_options then + self.has_options = nil + end +end + +function ModDef:SaveWholeMod() + GedSetUiStatus("mod_save", "Saving...") + DiagnosticMessageSuspended = true + PauseInfiniteLoopDetection("SaveMod") + + local err, code_dirty = self:SaveDef() + self:SaveItems() + self:SaveOptions() + if code_dirty then + ReloadLua() + end + + ResumeInfiniteLoopDetection("SaveMod") + DiagnosticMessageSuspended = false + GedSetUiStatus("mod_save") +end + +function ModDef:CompareVersion(other_mod, ignore_revision) + assert(other_mod) + local version_diffs = { + self.version_major - (other_mod.version_major or self.version_major), + self.version_minor - (other_mod.version_minor or self.version_minor), + ignore_revision and 0 or (self.version - (other_mod.version or 0)), + } + + for i = 1, #version_diffs do + local diff = version_diffs[i] + if diff ~= 0 then + return diff + end + end + return 0 +end + +function ModDef:GetVersionString() + return string.format("%d.%02d-%03d", self.version_major, self.version_minor, self.version) +end + +function ModDef:GetModLabel(plainText) + local text = string.format("%s (id %s, v%s)", self.title, self.id, self:GetVersionString()) + return plainText and text or Untranslated(text) +end + +function ModDefPersist(mod_info) + setmetatable(mod_info, ModDef) + local mod_def = Mods[mod_info.id] + if not mod_def then + ModLogF(true, "Savegame references Mod %s which is not present", mod_info:GetModLabel("plainText")) + return mod_info + end + if not mod_def.items then + ModLogF(true, "This savegame tries to load Mod %s, which is present, but not loaded", mod_def:GetModLabel("plainText")) + elseif mod_def:CompareVersion(mod_info) ~= 0 then + ModLogF(true, "This savegame tries to load Mod %s, which is loaded with a different version %s", mod_info:GetModLabel("plainText"), mod_def:GetVersionString()) + else + ModLogF("This savegame loads Mod %s", mod_def:GetModLabel("plainText")) + end + return mod_def +end + +function ModDef:__persist() + local mod_info = { + title = self.title, + id = self.id, + version_major = self.version_major, + version_minor = self.version_minor, + version = self.version, + lua_revision = self.lua_revision, + } + return function() + return ModDefPersist(mod_info) + end +end + +function OnMsg.PersistSave(data) + data["ModsLoaded"] = ModsLoaded +end + +function OnMsg.BugReportStart(print_func) + local active_mods = {} + for i,mod in ipairs(ModsLoaded) do + table.insert(active_mods, string.format("%s (id %s, v%s, source %s, required Lua: %d, saved with Lua: %d", + mod.title, mod.id, mod:GetVersionString(), mod.source, + mod.lua_revision, mod.saved_with_revision)) + end + table.sort(active_mods) + print_func("Active Mods:" .. (next(active_mods) and ("\n\t" .. table.concat(active_mods, "\n\t")) or "None") .. "\n") + local ignored_mods = {} + if SavegameMeta then + for _, mod in ipairs(SavegameMeta.ignored_mods) do + table.insert(ignored_mods, string.format("%s (id %s, v%s, source %s, required Lua: %d, saved with Lua: %d", + mod.title or "no info", mod.id or "no info", mod.version or "no info", mod.source or "no info", + mod.lua_revision or 0, mod.saved_with_revision or 0)) + end + end + print_func("Ignored Mods:" .. (next(ignored_mods) and ("\n\t" .. table.concat(ignored_mods, "\n\t")) or "None") .. "\n") + local affected_resources = GetAllLoadedModsAffectedResources() + if next(affected_resources) then + print_func("Affected resources from mods (doesn't include ignored mods and custom code):\n\t" .. table.concat(affected_resources, "\n\t")) + end + + local codes = { } + Msg("GatherModDownloadCode", codes) + if next(codes) then + print_func("Paste in the console to download active mods:") + for source,code in pairs(codes) do + print_func("\t", code) + end + print_func("\n") + end +end + + +----- loading + +ModEnvBlacklist = { + --HG values + IsDlcOwned = true, + AccountStorage = true, + async = true, + AsyncOpWait = true, + FirstLoad = true, + InitDefaultAccountStorage = true, + ReloadLua = true, + SetAccountStorage = true, + SaveAccountStorage = true, + XPlayerActivate = true, + XPlayersReset = true, + WaitLoadAccountStorage = true, + WaitSaveAccountStorage = true, + _DoSaveAccountStorage = true, + ConsoleExec = true, + Crash = true, + Stomp = true, + Msg = true, + OnMsg = true, + ModMsgBlacklist = true, + GetAutoCompletionList = true, + GedOpInspectorSetGlobal = true, + getfileline = true, + CompileExpression = true, + CompileFunc = true, + GetFuncSourceString = true, + GetFuncSource = true, + FuncSource = true, + LoadConfig = true, + SVNDeleteFile = true, + SVNAddFile = true, + SVNMoveFile = true, + SVNLocalInfo = true, + SVNShowLog = true, + SVNShowBlame = true, + SVNShowDiff = true, + SaveSVNFile = true, + GetSvnInfo = true, + GetCallLine = true, + SaveLuaTableToDisk = true, + LoadLuaTableFromDisk = true, + insideHG = true, + SaveLanguageOption = true, + GetMachineID = true, + SaveDLCOwnershipDataToDisk = true, + LoadDLCOwnershipDataFromDisk = true, + GetLuaSaveGameData = true, + GetLuaLoadGamePermanents = true, + --file operations + DbgPackMod = true, + AsyncAchievementUnlock = true, + AsyncCopyFile = true, + AsyncCreatePath = true, + AsyncDeletePath = true, + AsyncExec = true, + AsyncFileClose = true, + AsyncFileDelete = true, + AsyncFileFlush = true, + AsyncFileOpen = true, + AsyncFileRead = true, + AsyncFileRename = true, + AsyncFileToString = true, + AsyncFileWrite = true, + AsyncGetFileAttribute = true, + AsyncGetSourceInfo = true, + AsyncListFiles = true, + AsyncMountPack = true, + AsyncPack = true, + AsyncStringToFile = true, + AsyncSetFileAttribute = true, + AsyncUnmount = true, + AsyncUnpack = true, + CheatPlatformUnlockAllAchievements = true, + CheatPlatformResetAllAchievements = true, + CopyFile = true, + DeleteFolderTree = true, + EV_OpenFile = true, + FileToLuaValue = true, + LoadFilesForSearch = true, + MountFolder = true, + MountPack = true, + OS_OpenFile = true, + PreloadFiles = true, + StringToFileIfDifferent = true, + Unmount = true, + DeleteMod = true, + --web operations + AsyncWebRequest = true, + AsyncWebSocket = true, + hasRfnPrefix = true, + LocalIPs = true, + sockAdvanceDeadline = true, + sockConnect = true, + sockDelete = true, + sockDisconnect = true, + sockEncryptionKey = true, + sockGenRSAEncryptedKey = true, + sockGetGroup = true, + sockGetHostName = true, + sockGroupStats = true, + sockListen = true, + sockNew = true, + sockProcess = true, + sockResolveName = true, + sockSend = true, + sockSetGroup = true, + sockSetOption = true, + sockSetRSAEncryptedKey = true, + sockStr = true, + sockStructs = true, + --mod loading + ModEnvBlacklist = true, + LuaModEnv = true, + ModsReloadDefs = true, + ModsPackFileName = true, + ModsScreenshotPrefix = true, + CanUnlockAchievement = true, + ModIdBlacklist = true, + ModsReloadItems = true, + ProtectedModsReloadItems = true, + ContinueModsReloadItems = true, + + --built-ins + _G = true, + getfenv = true, + setfenv = true, + getmetatable = true, + --setmetatable = true, + rawget = true, + collectgarbage = true, + load = true, + loadfile = true, + loadstring = true, + dofile = true, + pdofile = true, + dofolder = true, + dofolder_files = true, + dofolder_folders = true, + dostring = true, + module = true, + require = true, + --libraries + debug = true, + io = true, + os = true, + package = true, + lfs = true, +} + +ModMsgBlacklist = { + PersistGatherPermanents = true, + PersistLoad = true, + PersistSave = true, + ModBlacklistPrefixes = true, + DebugDownloadMods = true, + PasswordChanged = true, + UnableToUnlockAchievementReasons = true, +} + +function OnMsg.Autorun() + local string_starts_with = string.starts_with + local prefixes = {"Debug"} + Msg("ModBlacklistPrefixes", prefixes) + for key, value in pairs(_G) do + if type(key) == "string" then + for _, prefix in ipairs(prefixes) do + if string_starts_with(key, prefix, true) then + ModEnvBlacklist[key] = true + break + end + end + end + end + ModEnvBlacklist.DebugPrint = nil -- this is just a log without screen output +end + +const.MaxModDataSize = 32 * 1024 + +--[[@@@ +Writes data into a persistent storage, that can be accessed between different game sessions. +The data must be a string, no longer than *const.MaxModDataSize* - make sure to always check if you're exceeding this size. +This storage is not shared, but is per mod. Anything stored here can only be read by the same mod using [ReadModPersistentData](#ReadModPersistentData). +@function err WriteModPersistentData(data) +@param data - the data to be stored (as a string). +@result err - error message or nil, if successful. +See also: [TupleToLuaCode](#TupleToLuaCode), [Compress](#Compress), [AsyncCompress](#AsyncCompress); +]] +local max_data_length = const.MaxModDataSize +local function WriteModPersistentData(mod, data) + if type(data) ~= "string" then + return "data must be a string" + end + + if #data > max_data_length then + return string.format("data longer than const.MaxModDataSize (%d bytes)", max_data_length) + end + + if not AccountStorage.ModPersistentData then + AccountStorage.ModPersistentData = { } + end + + if AccountStorage.ModPersistentData[mod.id] == data then return end + AccountStorage.ModPersistentData[mod.id] = data + SaveAccountStorage(5000) +end + +--[[@@@ +Reads data from a persistent storage, that can be accessed between different game sessions. +This storage is not shared, but is per mod. Anything read here has been previously stored only by the same mod using [WriteModPersistentData](#WriteModPersistentData). +@function err, data ReadModPersistentData() +@result err - error message or nil, if successful. +@result data - data previously stored or nil. +See also: [LuaCodeToTuple](#LuaCodeToTuple), [Decompress](#Decompress), [AsyncDecompress](#AsyncDecompress); +]] +local function ReadModPersistentData(mod) + return nil, AccountStorage.ModPersistentData and AccountStorage.ModPersistentData[mod.id] +end + +--[[@@@ +Writes `CurrentModStorageTable` into a persistent storage, that can be accessed between different game sessions. +This is an ease-of-use function for the most common use case of persistent storage - when storing data in a table. +It uses [WriteModPersistentData](#WriteModPersistentData) internally, thus the *const.MaxModDataSize* limit applies. +@function err WriteModPersistentStorageTable() +@result err - error message or nil, if successful. +]] +local function WriteModPersistentStorageTable(mod) + local storage = rawget(mod.env, "CurrentModStorageTable") + if type(storage) ~= "table" then + storage = {} + end + local data = TupleToLuaCode(storage) + return WriteModPersistentData(mod, data) +end + +local function CreateModPersistentStorageTable(mod) + if not AccountStorage then + WaitLoadAccountStorage() + end + local storage + local err, data = ReadModPersistentData(mod) + if not err then + err, storage = LuaCodeToTuple(data, mod.env) + end + if type(storage) ~= "table" then + storage = {} + end + return storage +end + +function LuaModEnv(env) + assert(ModEnvBlacklist[1] == nil, "All entries in 'ModEnvBlacklist' must be keys") + + env = env or { } + local env_meta = { __name = "ModEnv" } + local original_G = _G + + --setup black/white lists + local value_whitelist = { } --for faster access + local env_blacklist = ModEnvBlacklist + local meta_blacklist = { } + meta_blacklist[env_meta] = true + meta_blacklist[original_G] = true + + --setup environment + for k in pairs(value_whitelist) do + if env[k] == nil then + env[k] = original_G[k] + end + end + + --setup environment metatable + env_meta.__index = function(env, key) + if env_blacklist[key] then return end + local value = rawget(original_G, key) + if value ~= nil then + return value + end + if key == "class" then return "" end + if key == "__ancestors" then return empty_table end + error("Attempt to use an undefined global '" .. tostring(key) .. "'", 1) + end + + env_meta.__newindex = function(env, key, value) + if env_blacklist[key] then return end + if not Loading and PersistableGlobals[key] == nil then + error("Attempt to create a new global '" .. tostring(key) .. "'", 1) + end + rawset(original_G, key, value) + end + + --setup exposed power functions + local function safe_getmetatable(t) + local meta = getmetatable(t) + if meta_blacklist[meta] then return end + return meta + end + local function safe_setmetatable(t, new_meta) + local meta = getmetatable(t) + if meta_blacklist[meta] then return end + return setmetatable(t, new_meta) + end + + local function safe_rawget(t, key) + local t_value = rawget(t, key) + if rawequal(t, env) and t_value == nil and not env_blacklist[key] then + return rawget(original_G, key) + end + return t_value + end + + local function safe_Msg(name, ...) + if ModMsgBlacklist[name] then return end + local raw_Msg = original_G.Msg + return raw_Msg(name, ...) + end + local safe_OnMsg = { } + setmetatable(safe_OnMsg, { __newindex = + function(_, name, func) + if ModMsgBlacklist[name] then return end + local raw_OnMsg = original_G.OnMsg + raw_OnMsg[name] = func + end + }) + + --finilize setting up the environment and fill in some tables + env._G = env + env.getmetatable = safe_getmetatable + --env.setmetatable = safe_setmetatable + env.rawget = safe_rawget + env.os = { time = os.time } + env.Msg = safe_Msg + env.OnMsg = safe_OnMsg + + setmetatable(env, env_meta) + return env +end + +if FirstLoad then + SharedModEnv = { } +end + +function ModDef:SetupEnv() + local env = self.env + rawset(env, "CurrentModPath", self.content_path) + rawset(env, "CurrentModId", self.id) + rawset(env, "CurrentModDef", self) + rawset(env, "CurrentModStorageTable", CreateModPersistentStorageTable(self)) + rawset(env, "CurrentModOptions", self.options) + + rawset(env, "WriteModPersistentData", function(...) + return WriteModPersistentData(self, ...) + end) + rawset(env, "ReadModPersistentData", function(...) + return ReadModPersistentData(self, ...) + end) + rawset(env, "WriteModPersistentStorageTable", function(...) + return WriteModPersistentStorageTable(self, ...) + end) +end + +function OnMsg.PersistGatherPermanents(permanents) + permanents["func:getmetatable"] = getmetatable + permanents["func:setmetatable"] = setmetatable + permanents["func:os.time"] = os.time + permanents["func:Msg"] = Msg +end + +function CanLoadUnpackedMods() + return not Platform.console --or Platform.developer +end + +function ListModFolders(path, source) + path = SlashTerminate(path) + local folders = io.listfiles(path, "*", "folders") + table.sort(folders, CmpLower) + + if next(folders) then + local folder_names = table.imap(folders, string.sub, #path + 1) + end + + for i=1,#folders do + folders[i] = { path = folders[i], source = source } + end + + return folders +end + +function SortModsList() + if #(ModsList or "") <= 1 then return end + table.sort(ModsList, function(a, b) + if b:ItemsLoaded() then + return a:ItemsLoaded() and a.title < b.title + end + return a:ItemsLoaded() or a.title < b.title + end) + ObjModified(ModsList) +end + +if FirstLoad then + g_ModDefSourceNotified = {} +end + +function ModsReloadDefs() + --load all places where a mod can be found + local folders = { } + if Platform.desktop then + local f = ListModFolders("AppData/Mods/", "appdata") + table.iappend(folders, f) + end + if config.AdditionalModFolder then + local f = ListModFolders(config.AdditionalModFolder, "additional") + table.iappend(folders, f) + end + Msg("GatherModDefFolders", folders) + + --to avoid issues, when loading metadata, mods are allowed to access only this function + --nothing else exists in their environment + local descriptor_classes = ClassDescendantsList("ModResourceDescriptor") + local metadata_env = { + PlaceObj = function(class, ...) + if class ~= "ModDef" and class ~= "ModDependency" and not table.find(descriptor_classes, class) then return end + return PlaceObj(class, ...) + end, + box = box, + } + + local new_mods = { } + + local multiple_sources + for i,folder in ipairs(folders) do + --execute the mod metadata.lua file + local env, ok, def + local source = folder.source + local pack_path = folder.path .. "/" .. ModsPackFileName + --Get last subfolder from a path or complete path if it has no slash in it + --SplitPath does not work for paths containing a dot + local folder_name = string.sub(folder.path, (string.match(folder.path, "^.*()/") or 0) + 1) + if io.exists(pack_path) then + local prev_id = LocalStorage.ModIdCache and LocalStorage.ModIdCache[folder.path] + local hpk_mounted_path = ModContentPath .. (prev_id or folder_name) .. "/" + local mount_label = ModDef:GenerateMountLabel() + local err = MountPack(hpk_mounted_path, pack_path, "label:" .. mount_label) + if not err then + env = LuaModEnv() + + ok, def = pdofile(hpk_mounted_path .. "metadata.lua", metadata_env, "t") + if ok and IsKindOf(def, "ModDef") then + def.packed = true + def.mount_label = mount_label + Msg("PackedModDefLoaded", pack_path, def) + if prev_id ~= def.id then + UnmountByLabel(mount_label) + LocalStorage.ModIdCache = LocalStorage.ModIdCache or {} + LocalStorage.ModIdCache[folder.path] = def.id + SaveLocalStorage() + else + def.mounted = true + end + else + UnmountByLabel(mount_label) + end + end + elseif (folder.source == "appdata" or folder.source == "additional") or CanLoadUnpackedMods() then + env = LuaModEnv() + ok, def = pdofile(folder.path .. "/metadata.lua", metadata_env, "t") + end + + --finilize loading the mod definition and replace old definitions, if needed + if env and IsKindOf(def, "ModDef") then + def.env = env + def.path = folder.path .. "/" --don't need to use 'ChangePaths()' here + def.content_path = ModContentPath .. def.id .. "/" + def.source = source + + local mod_used + if def:IsTooOld() then + ModLogF("Outdated definition for %s loaded from %s. (Unsupported game version)", def:GetModLabel("plainText"), def.source) + end + + local old = new_mods[def.id] + if old then + multiple_sources = table.create_set(multiple_sources, def.id, true) + local cmp = old:CompareVersion(def) + if cmp < 0 or (cmp == 0 and old.packed and not def.packed) then + mod_used = true + end + else + mod_used = true + end + + if mod_used then + if old then + old:delete() + end + + new_mods[def.id] = def + def:SetupEnv() + def:MountContent() + def:OnLoad() + else + def:delete() + end + else + local err = def + if not err:ends_with("File Not Found") then + ModLogF(true, "Failed to load mod metadata from %s. Error: %s", folder.path, err) + end + end + end + + for id in pairs(multiple_sources) do + local def = new_mods[id] + if g_ModDefSourceNotified[id] ~= def.path then + g_ModDefSourceNotified[id] = def.path + local packed_str = def.packed and "packed" or "unpacked" + ModLogF("Mod %s loaded from %s (%s)", def:GetModLabel("plainText"), def.source, packed_str) + end + end + + local old_mods = Mods + local new_ids, old_ids = table.keys(new_mods), table.keys(old_mods) + local any_changes = not (table.is_subset(new_ids, old_ids) and table.is_subset(old_ids, new_ids)) + if not any_changes then + for id, new_mod in pairs(new_mods) do + local old_mod = old_mods[id] + if not old_mod or new_mod ~= old_mod then + any_changes = true + break + end + end + end + if not any_changes then + for id, new_mod in pairs(new_mods) do + new_mod:delete() + end + + ModsList = ModsList or {} + return + end + + --delete all old mods + local any_loaded = not not next(ModsLoaded) + for id,mod in pairs(Mods or empty_table) do + mod:delete() + end + + Mods = new_mods + + -- add a reference to the mod in affected resources + for id, mod in pairs(Mods) do + if mod.affected_resources then + for _, res in ipairs(mod.affected_resources) do + res.mod = mod + end + end + end + + -- if new mod defs are loaded/unloaded, clear the affected resources cache + ClearModsAffectedResourcesCache() + + ModsList = {} + for id, mod in pairs(Mods) do + ModsList[#ModsList+1] = mod + mod_print("once", "Loaded mod def %s (id %s, v%s) %s from %s", mod.title, mod.id, mod:GetVersionString(), mod.packed and "packed" or "unpacked", mod.source) + end + SortModsList() + CacheModDependencyGraph() + Msg("ModDefsLoaded") + + if any_loaded then + ModsReloadItems() + end +end + +local function GetModAllDependencies(mod) + local result = { } + + --local dependencies + for i,dep in ipairs(mod.dependencies or empty_table) do + if dep.id and dep.id ~= "" and not table.find(result, "id", dep.id) then + table.insert(result, dep) + end + end + return result +end + +local function GetModDependenciesList(mod, result) + result = result or { } + if mod then + local dependencies = GetModAllDependencies(mod) + for i,dep in ipairs(dependencies) do + local dep_id = dep.id + local dep_mod = Mods[dep_id] + if dep_mod then + if table.find(result, dep_mod) then + return "cycle" + end + table.insert(result, dep_mod) + local err = GetModDependenciesList(dep_mod, result) + if err then return err end + else + table.insert(result, dep_id) + end + end + end + return false, result +end + +local function DetectDependencyError(dep, all, dep_mod, stack) + if stack[dep.id] then --cycling dependencies + return "cycle" + elseif not table.find(all, dep.id) then --not in 'mods to load' list + return "not loaded" + elseif not dep:ModFits(dep_mod) then --incompatible + return "incompatible" + end +end + +local function EnqueueMod(mod, all, queue, stack) + if not mod then return "no mod" end + if queue[mod.id] then return end + + local mod_queue = table.copy(queue) + table.insert_unique(mod_queue, mod.id) + mod_queue[mod.id] = mod + + local dependencies = GetModAllDependencies(mod) + if next(dependencies) then + stack[mod.id] = true + for i,dep in ipairs(dependencies) do + local dep_mod = Mods[dep.id] + --forced mods don't go through dependency checks + if not mod.force_reload then + if dep_mod then --mod is preset + local err = DetectDependencyError(dep, all, dep_mod, stack) + if err then + if err == "cycle" then + local other_mods = { } + for id in pairs(stack) do + table.insert(other_mods, Untranslated(Mods[id].title)) + end + ModLogF("Mod %s creates circular dependency cycle with %s.", mod:GetModLabel("plainText"), other_mods) + elseif err == "not loaded" then + ModLogF("Cannot load %s because required mod %s is not active.", mod:GetModLabel("plainText"), dep_mod:GetModLabel("plainText")) + elseif err == "incompatible" then + ModLogF("Cannot load %s because required mod %s is not compatible.", mod:GetModLabel("plainText"), dep_mod:GetModLabel("plainText")) + end + + stack[mod.id] = nil + if dep.required then + return err + end + end + else --mod is not present + ModLogF("Cannot load %s because required mod %s is not found.", mod:GetModLabel("plainText"), dep.title) + stack[mod.id] = nil + return "not found" + end + end + + local err = EnqueueMod(dep_mod, all, mod_queue, stack) + if err then + --forced mods must be loaded no matter what + if not mod.force_reload then + stack[mod.id] = nil + return err + end + end + end + stack[mod.id] = nil + end + + for i, mod_id in ipairs(mod_queue) do + if table.find(all, mod_id) then + table.insert_unique(queue, mod_id) + queue[mod_id] = mod_queue[mod_id] + end + end +end + +local function GetLoadingQueue(list) + local queue = { } + for i, mod_id in ipairs(list) do + EnqueueMod(Mods[mod_id], list, queue, { }) + end + return queue +end + + +function GetModsToLoad() + if not IsUserCreatedContentAllowed() then + return empty_table + end + + local list + if (AccountStorage and AccountStorage.LoadAllMods) or config.LoadAllMods then + list = table.keys(Mods) + table.sort(list) + end + list = list or AccountStorage and table.icopy(AccountStorage.LoadMods) or {} + + -- Autodisable blacklisted mods + AutoDisabledModsAlertText = { ["ban"] = {}, ["deprecate"] = {}} + for idx, modId in ripairs(list) do + local blacklistReason = GetModBlacklistedReason(modId) + LocalStorage.AutoDisableDeprecated = LocalStorage.AutoDisableDeprecated or {} + local disableMod = (not LocalStorage.AutoDisableDeprecated[modId] and blacklistReason and blacklistReason == "deprecate") or (blacklistReason and blacklistReason == "ban") + if disableMod and blacklistReason and Mods[modId] then + table.insert(AutoDisabledModsAlertText[blacklistReason], Mods[modId]:GetModLabel("plainText")) + TurnModOff(modId) + LocalStorage.AutoDisableDeprecated = LocalStorage.AutoDisableDeprecated or {} + LocalStorage.AutoDisableDeprecated[modId] = true + for _, presetData in pairs(LocalStorage.ModPresets) do + if presetData.mod_ids[modId] then + RemoveModFromModPreset(presetId, modId) + end + end + table.remove(list, idx) + end + end + + SaveLocalStorage() + + list = table.ifilter(list, function(i, id) + local mod = Mods[id] + if not mod then + ModLogF("Couldn't find mod %s from your account storage.", id) + return + end + if not mod:IsTooOld() or mod.force_reload then + return true + else + ModLogF("Outdated mod %s cannot be loaded. (Unsupported game version)", mod:GetModLabel("plainText")) + end + end) + + return GetLoadingQueue(list) +end + +if FirstLoad then +g_CantLoadMods = {} +end + +ErrorLoadingModsT = T(164802745041, "The following mods couldn't be loaded. Open the mod manager for more information:\n") +function WaitErrorLoadingMods(customErrorMsg) + local errorMsg = customErrorMsg or ErrorLoadingModsT + local modsToLoad = GetModsToLoad() + local modsEnabledByUser + if (AccountStorage and AccountStorage.LoadAllMods) or config.LoadAllMods then + modsEnabledByUser = table.keys(Mods) + table.sort(modsEnabledByUser) + end + modsEnabledByUser = modsEnabledByUser or AccountStorage and table.icopy(AccountStorage.LoadMods) or {} + + local modsFailedToLoad + for idx, mod_id in ipairs(modsEnabledByUser) do + if not table.find(modsToLoad, mod_id) then + modsFailedToLoad = table.create_add(modsFailedToLoad, "" .. (Mods[mod_id] and Mods[mod_id].title or mod_id)) + TurnModOff(mod_id, "updatePreset") + g_CantLoadMods[mod_id] = true + end + end + + if g_ModsUIContextObj then + g_ModsUIContextObj:GetInstalledMods() + end + + if modsFailedToLoad then + SaveAccountStorage(5000) + modsFailedToLoad = table.concat(modsFailedToLoad, "\n") + WaitMessage( + terminal.dekstop, + T(824112417429, "Warning"), + T{675270242132, "", errorT = errorMsg, mods_list = Untranslated(modsFailedToLoad)}, + T(325411474155, "OK") + ) + end +end + +function ModsReloadItems(map_folder, force_reload, first_load) + if not config.Mods then return end + assert(IsRealTimeThread()) + + local queue = GetModsToLoad() + local list = table.copy(queue) + table.sort(list) + + if not force_reload then + --no changes to mods list? + local loaded_ids = ModsLoaded and table.map(ModsLoaded, "id") or {} + table.sort(loaded_ids) + if table.iequal(loaded_ids, list) then + return + end + end + + local reload_assets + local reload_lua + local has_data + + --unload old mods + if ModsLoaded then + for _,mod in ipairs(ModsLoaded) do + if mod:ItemsLoaded() or mod.status == "deleted" then + has_data = has_data or mod.has_data + mod:UnloadItems() + mod:UnloadOptions() + --print("Unload mod", mod.id) + if next(mod.code) then reload_lua = true end + if next(mod.entities) then reload_assets = true end + end + end + end + + --fill ModsLoaded with the new mods + local old_loaded = ModsLoaded + ModsLoaded = {} + + for i, id in ipairs(queue) do + local mod = Mods[id] + mod.force_reload = false + assert(not table.find(ModsLoaded, mod)) + ModsLoaded[#ModsLoaded + 1] = mod + if next(mod.code) then reload_lua = true end + if next(mod.entities) then reload_assets = true end + end + + --reload bin assets, if needed + if reload_assets then + RegisterModDelayedLoadEntities(ModsLoaded) + if not first_load then + ModsLoadAssets(map_folder) + WaitDelayedLoadEntities() + end + end + + --loading options happens before the items and code to allow for access of CurrentModOptions + for _, mod in ipairs(ModsLoaded) do + mod:LoadOptions() + end + + --reload Lua, if needed + if first_load then + return --will be continued during ReloadForDlc + end + + if reload_lua then + for i,mod in ipairs(old_loaded) do + if not queue[mod.id] then + --@@@msg ModUnloadLua, mod_id - fired just before unloading a mod with Lua code. + Msg("ModUnloadLua", mod.id) + end + end + ReloadLua() + end + + return ContinueModsReloadItems(map_folder, reload_assets, has_data) +end + +function ContinueModsReloadItems(map_folder, reload_assets, has_data) + --load the items of the new mods + for _, mod in ipairs(ModsLoaded) do + if not mod:ItemsLoaded() then + mod:LoadItems() + end + has_data = has_data or mod.has_data + end + mod_print("Loaded mod items for: %s", table.concat(table.map(ModsLoaded, "id"), ", ")) + + --backwards compatibility for r342666 + local any_old_entity_mods + for i, mod in ipairs(ModsLoaded) do + if mod.bin_assets then + mod:ForEachModItem("ModItemEntity", function(item) + any_old_entity_mods = true + DelayedLoadEntity(mod, item.entity_name) + end) + end + end + if any_old_entity_mods then + RegisterModDelayedLoadEntities(ModsLoaded) + ModsLoadAssets(map_folder) + WaitDelayedLoadEntities() + ReloadLua() + end + + if reload_assets then + ReloadClassEntities() + end + + for _, mod in ipairs(ModsLoaded) do + if mod:HasOptions() then + Msg("ApplyModOptions", mod.id) --throw the msg here to make sure it is called after the lua is reloaded and everything for the mod is loaded + end + end + + PopulateParentTableCache(Mods) + ObjModified(ModsList) + --@@@msg ModsReloaded - fired right after mods are loaded, unloaded or changed. + Msg("ModsReloaded") + -- TODO: use has_data to raise a msg about changed data +end + +function ProtectedModsReloadItems(map_folder, force_reload) + LoadingScreenOpen("idLoadingScreen", "reload mod items") + local old_render_mode = GetRenderMode() + WaitRenderMode("ui") + ModsReloadItems(map_folder, force_reload) + WaitRenderMode(old_render_mode) + LoadingScreenClose("idLoadingScreen", "reload mod items") +end + +function ModsLoadAssets(map_folder) + LoadingScreenOpen("idModEntitesReload", "ModEntitesReload") + local old_render_mode = GetRenderMode() + WaitRenderMode("ui") + local lastMap = AreModdingToolsActive() and ModEditorMapName or CurrentMap --force reload the map to prevent issues with spawned objects from the mod on the current map + ResetGameSession() + ForceReloadBinAssets() + DlcReloadAssets(DlcDefinitions) + --actually reload the assets + LoadBinAssets(map_folder or CurrentMapFolder) + --wait & unmount + while AreBinAssetsLoading() do + Sleep(1) + end + + ChangeMap(lastMap) + WaitRenderMode(old_render_mode) + hr.TR_ForceReloadNoTextures = 1 + LoadingScreenClose("idModEntitesReload", "ModEntitesReload") +end + +if FirstLoad then + LuaLoadedForMods = {} + ModsPreGameMenuOpen = false + ModsLoadCodeErrorsMessage = false + ModsDisplayingMessage = false +end + +local loadedWithErrorsT = T(306573510595, "Mod Loaded with Errors") + +function OnMsg.PreGameMenuOpen() + CreateRealTimeThread(function() + ModsDisplayingMessage = true + -- display mods that were loaded with errors + if ModsLoadCodeErrorsMessage then + WaitMessage(nil, loadedWithErrorsT, ModsLoadCodeErrorsMessage, T(1000136, "OK")) + ModsLoadCodeErrorsMessage = false + end + -- display mods that were rejected (because of dependencies, blacklisting, etc.) + WaitErrorLoadingMods() + ModsDisplayingMessage = false + end) +end + +function DisplayModsLoadCodeErrorsMessage() + CreateRealTimeThread(function() + ModsDisplayingMessage = true + WaitMessage(nil, loadedWithErrorsT, ModsLoadCodeErrorsMessage, T(1000136, "OK")) + ModsLoadCodeErrorsMessage = false + ModsDisplayingMessage = false + end) +end + +function ModsLoadCode() -- called while reloading Lua (in autorun.lua) + local collected_errors = {} + for _, mod in ipairs(ModsLoaded or empty_table) do + local loading_errors = mod:LoadCode() + if loading_errors then + local errs = table.concat(loading_errors, "\n") + ModLogF(true, string.format("Errors while loading mod %s:\n%s", mod.title, errs)) -- log in the mods log + + -- collect all mod loading errors to display to the user in a single message box + table.insert(collected_errors, + T{560233458867, "Mod :\n", title = mod.title, errs = errs }) + end + end + + if next(collected_errors) then + ModsLoadCodeErrorsMessage = table.concat(collected_errors, "\n\n") + --- if the pre-game menu hasn't been opened yet, the message will be displayed when it is + if (config.MainMenu == 0 or ModsPreGameMenuOpen) and not ModsDisplayingMessage then + DisplayModsLoadCodeErrorsMessage() + end + end +end + +function ModsLoadLocTables() + local list + if not config.Mods then return end + if AccountStorage and AccountStorage.LoadAllMods or config.LoadAllMods then + list = table.keys(Mods) + table.sort(list) + end + list = list or AccountStorage and AccountStorage.LoadMods or {} + + local loctables_loaded + for i, id in ipairs(list) do + local mod = Mods[id] + if mod then + if mod:IsTooOld() then + ModLogF("Outdated mod %s cannot be loaded. (Unsupported game version)", mod:GetModLabel("plainText")) + else + for _, loctable in ipairs(mod.loctables or empty_table) do + if loctable.language == GetLanguage() or loctable.language == "Any" then + local file_path = mod.content_path .. loctable.filename + if io.exists(file_path) then + LoadTranslationTableFile(file_path) + loctables_loaded = true + end + end + end + end + end + end + if loctables_loaded then + Msg("TranslationChanged") + end +end + +local function DebugWaitThreads(msg, ...) + local threads = {} + Msg(msg, threads, ...) + while next(threads) do + for i = #threads, 1, -1 do + if not IsValidThread(threads[i]) then + table.remove(threads, i) + end + end + Sleep(100) + end +end + +function DebugWaitDownloadExternalMods(mods) + DebugWaitThreads("DebugDownloadExternalMods", mods) +end + +function DebugWaitCopyExternalMods(mods) + DebugWaitThreads("DebugCopyExternalMods", mods) +end + +function DebugDownloadSavegameMods(missings_mods) + if not IsRealTimeThread() then + CreateRealTimeThread(DebugDownloadSavegameMods) + return + end + local mods = {} + for _, mod in ipairs(missings_mods or SavegameMeta.active_mods) do + if not Mods[mod.id] then + mods[#mods + 1] = mod + else + printf("Mod with ID %s is already present in your local files.", mod.id) + end + end + DebugWaitDownloadExternalMods(mods) + DebugWaitCopyExternalMods(mods) + for _, mod in ipairs(missings_mods or SavegameMeta.active_mods) do + TurnModOn(mod.id) + end + SaveAccountStorage() + ModsReloadDefs() + ModsReloadItems() +end + + +----- ModItem + +DefineClass.ModItem = { + __parents = { "GedEditedObject", "InitDone", "ModElement", "Container" }, + properties = { + { category = "Mod", id = "name", name = "Name", default = "", editor = "text", }, + { category = "Mod", id = "comment", name = "Comment", default = "", editor = "text", }, + { category = "Mod", id = "Documentation", editor = "documentation", dont_save = true, sort_order = 9999999, }, -- display collapsible Documentation at this position + }, + mod = false, + EditorName = false, + EditorView = Untranslated("','')>"), + ModItemDescription = T(674857971939, ""), + ContainerAddNewButtonMode = "children", -- add a + button to add child item in the tree view (if the ModItem can have children) + GedTreeCollapsedByDefault = true, +} + +function ModItem:IsOpenInGed() + return not not GedObjects[ParentTableCache[self.mod]] +end + +function ModItem:OnEditorNew(parent, ged, is_paste, duplicate_id, mod_id) + -- Mod item presets can also be added through Preset Editors (see GedOpClonePresetInMod) + -- In those cases the reference to the mod will be set from the mod_id parameter + + self.mod = (IsKindOf(parent, "ModDef") and parent or parent.mod) or (mod_id and Mods and Mods[mod_id]) + assert(self.mod, "Mod item has no reference to a mod") +end + +function ModItem:OnAfterEditorNew(parent, ged, is_paste, old_id, mod_id) + -- Mod item presets can also be added through Preset Editors (see GedOpClonePresetInMod) + -- In those cases the reference to the mod will be set from the mod_id parameter + + self.mod = (IsKindOf(parent, "ModDef") and parent or parent.mod) or (mod_id and Mods and Mods[mod_id]) + assert(self.mod, "Mod item has no reference to a mod") +end + +-- only used for ModItemPreset, which is not a ModItemUsingFiles +function ModItem:OnEditorDelete(mod, ged) + local path = self:GetCodeFilePath() + if path and path ~= "" then + AsyncFileDelete(path) + end +end + +function ModItem:GetPropOSPathKey(prop_id) + return string.format("%s_%s_%s", self.class, self.name, prop_id) +end + +function ModItem:StoreOSPath(prop_id, value) + local prop_meta = self:GetPropertyMetadata(prop_id) + if prop_meta and prop_meta.os_path and prop_meta.dont_save then + local key = self:GetPropOSPathKey(prop_id) + table.set(LocalStorage, "ModItemOSPaths", self.mod.id, key, value) + SaveLocalStorageDelayed() + end +end + +function ModItem:StoreOSPaths(prop_id, value) + for i, prop_meta in ipairs(self:GetProperties()) do + if prop_meta.os_path and prop_meta.dont_save then + local prop_id = prop_meta.id + local value = self:GetProperty(prop_id) + self:StoreOSPath(prop_id, value) + end + end +end + +function ModItem:RestoreOSPaths() + for i, prop_meta in ipairs(self:GetProperties()) do + if prop_meta.os_path and prop_meta.dont_save then + local prop_id = prop_meta.id + local key = self:GetPropOSPathKey(prop_id) + local value = table.get(LocalStorage, "ModItemOSPaths", self.mod.id, key) + if value ~= nil and not self:IsDefaultPropertyValue(prop_id, prop_meta, value) then + self:SetProperty(prop_id, value) + end + end + end +end + +function ModItem:OnEditorSetProperty(prop_id, old_value, ged) + self:StoreOSPath(prop_id, self:GetProperty(prop_id)) +end + +function ModItem:OnEditorSelect(selected, ged) +end + +function ModItem:IsMounted() + return self.mod and self.mod:IsMounted() +end + +function ModItem:IsPacked() + return self.mod and self.mod:IsPacked() +end + +function ModItem:GetModRootPath() + return self.mod and self.mod:GetModRootPath() +end + +function ModItem:GetModContentPath() + return self.mod and self.mod:GetModContentPath() +end + +function ModItem:OnModLoad(mod) + self:RestoreOSPaths() + return ModElement.OnLoad(self, mod) +end + +function ModItem:OnModUnload(mod) + return ModElement.OnUnload(self, mod) +end + +function ModItem:TestModItem(ged) +end + +function ModItem:GetCodeFileName(name) +end + +function ModItem:GetCodeFilePath(name) + name = self:GetCodeFileName(name) + if not name or name == "" then return "" end + return self.mod and self.mod.content_path .. name +end + +function ModItem:FindFreeFilename(name) + local n = 1 + local file_name = name + while io.exists(self:GetCodeFilePath(file_name)) do + n = n + 1 + file_name = name .. tostring(n) + end + return file_name +end + +function ModItem:CleanupForSave(injected_props, restore_data) + restore_data = PropertyObject.CleanupForSave(self, injected_props, restore_data) + restore_data[#restore_data + 1] = { obj = self, key = "mod", value = self.mod } + self.mod = nil + return restore_data +end + +function ModItem:PreSave() + self:StoreOSPaths() + return ModElement.PreSave(self) +end + +function ModItem:GetAffectedResources() + return empty_table +end + +function ModItem:GetMapName() + -- return the map name if the mod item contains an editor map +end + +function ModItem:ForEachModItem(classname, fn) + if not fn then + fn = classname + classname = nil + end + if classname and not IsKindOf(self, classname) then return end + return fn(self) +end + +----- ModOptions + +function ModOptionEditorContext(context, prop_meta) + local value_fn = function() return context:GetProperty(prop_meta.id) end + local prop_meta_subcontext = SubContext(prop_meta, { + context_override = context, + }) + local new_context = SubContext(context, { + prop_meta = prop_meta_subcontext, + value = value_fn, + }) + + if prop_meta.help and prop_meta.help ~= "" then + new_context.RolloverTitle = Untranslated(prop_meta.name) + new_context.RolloverText = Untranslated(prop_meta.help) + end + + return new_context +end + +DefineClass.ModOptionsObject = { + __parents = { "PropertyObject" }, + + __defaults = false, + __mod = false, +} + +function ModOptionsObject:Clone(class, parent) + class = class or self.class + local obj = g_Classes[class]:new(parent) + obj.__mod = self.__mod + obj:CopyProperties(self) + return obj +end + +function ModOptionsObject:GetProperties() + local properties = rawget(self, "properties") + if properties then return properties end + + local properties = {} + self.properties = properties + self.__defaults = {} + + local option_items = self.__mod:GetOptionItems() + for i,option in ipairs(option_items) do + local option_prop_meta = option:GetOptionMeta() + table.insert(properties, option_prop_meta) + self.__defaults[option.name] = option.DefaultValue + end + + return properties +end + +function ModOptionsObject:GetProperty(id) + self:GetProperties() + local value = rawget(self, id) + if value ~= nil then return value end + return self.__defaults[id] +end + +function ModOptionsObject:SetProperty(id, value) + rawset(self, id, value) +end + +DefineClass.ModItemOption = { + __parents = { "ModItem" }, + properties = { + { id = "name", name = "Id", editor = "text", default = "", translate = false, validate = ValidateIdentifier }, + { id = "DisplayName", name = "Display Name", editor = "text", default = "", translate = false }, + { id = "Help", name = "Tooltip", editor = "text", default = "", translate = false }, + }, + + mod_option = false, + ValueEditor = false, + EditorSubmenu = "Mod options", +} + +function ModItemOption:GetModItemDescription() + if not self:IsDefaultPropertyValue("name", self:GetPropertyMetadata("name"), self:GetProperty("name")) then + return Untranslated(" = ") + else + return Untranslated("NewOption") + end +end + +function ModItemOption:OnEditorNew(parent, ged, is_paste) + self.mod:LoadOptions() +end + +function ModItemOption:OnModLoad() + ModItem.OnModLoad(self) + self.mod_option = self.class +end + +function ModItemOption:GetOptionMeta() + local display_name = self.DisplayName + if not display_name or display_name == "" then + display_name = self.name + end + + return { + id = self.name, + name = T(display_name), + editor = self.ValueEditor, + default = self.DefaultValue, + help = self.Help, + } +end + +function ModItemOption:NeedsResave() + return self.mod.has_options --deprecated prop +end + +DefineClass.ModItemOptionToggle = { + __parents = { "ModItemOption" }, + properties = { + { id = "DefaultValue", name = "Default Value", editor = "bool", default = false }, + }, + + ValueEditor = "bool", + EditorName = "Option Toggle", + Documentation = "Creates a UI entry which toggles between the 2 defined values when pressed.", +} + +function ModItemOptionToggle:GetModItemDescription() + return string.format("%s = %s", self.name, self.DefaultValue and "On" or "Off") +end + +DefineClass.ModItemOptionNumber = { + __parents = { "ModItemOption" }, + properties = { + { id = "DefaultValue", name = "Default Value", editor = "number", default = 0 }, + { id = "MinValue", name = "Min", editor = "number", default = 0 }, + { id = "MaxValue", name = "Max", editor = "number", default = 100 }, + { id = "StepSize", name = "Step Size", editor = "number", default = 1 }, + }, + + ValueEditor = "number", + EditorName = "Option Number", + Documentation = "Creates a UI entry with a slider.", +} + +function ModItemOptionNumber:GetOptionMeta() + local meta = ModItemOption.GetOptionMeta(self) + meta.min = self.MinValue + meta.max = self.MaxValue + meta.step = self.StepSize + meta.slider = true + meta.show_value_text = true + return meta +end + +DefineClass.ModItemOptionChoice = { + __parents = { "ModItemOption" }, + properties = { + { id = "DefaultValue", name = "Default Value", editor = "choice", default = "", items = function(self) return self.ChoiceList end }, + { id = "ChoiceList", name = "Choice List", editor = "string_list", default = false } + }, + + ValueEditor = "choice", + EditorName = "Option Choice", + Documentation = "Creates a UI entry with a dropdown that contains all listed options.", +} + +function ModItemOptionChoice:GetOptionMeta() + local meta = ModItemOption.GetOptionMeta(self) + meta.items = { } + for i,item in ipairs(self.ChoiceList or empty_table) do + table.insert(meta.items, { text = T(item), value = item }) + end + return meta +end + + +----- ModDependency + +local function GetModDependencyDescription(mod) + return string.format("%s - %s - v %d.%d", mod.title, mod.id, mod.version_major, mod.version_minor) +end + +function ModDependencyCombo() + local result = { } + for id,mod in pairs(Mods) do + local text = GetModDependencyDescription(mod) + local entry = { id = id, text = Untranslated(text) } + table.insert(result, entry) + end + + return result +end + +DefineClass.ModDependency = { + __parents = { "PropertyObject" }, + properties = { + { id = "id", name = "Mod", editor = "combo", default = "", items = ModDependencyCombo }, + { id = "title", name = "Title", editor = "text", default = "", translate = false, + read_only = function(dep) return dep.id ~= "" end, + no_edit = function(dep) return dep.title == "" or dep.id == "" or Mods[dep.id] end }, --editor visible when mod is selected but is missing + { id = "version_major", name = "Major Version", editor = "number", default = 0 }, + { id = "version_minor", name = "Minor Version", editor = "number", default = 0 }, + { id = "required", name = "Required", editor = "bool", default = true, help = "A non-required dependency mod will be loaded before your mod, if it is present." }, + }, + own_mod = false, --used for display purposes, assigned in CacheModDependencyGraph +} + +function ModDependency:ModFits(mod_def) + if not mod_def then return false, "no mod" end + if self.id ~= mod_def.id then return false, "different mod" end + if mod_def:CompareVersion(self, "ignore_revision") < 0 then return false, "incompatible" end + return true +end + +function ModDependency:GetEditorView() + local mod = Mods[self.id] + if mod then + return GetModDependencyDescription(self) --needs to be self, so the correct version is displayed + end + + return self.class +end + +function ModDependency:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "id" then + local mod = Mods[self.id] + if mod then + local err, list = GetModDependenciesList(mod) + if err == "cycle" then + ged:ShowMessage("Warning: Cycle", "This mod dependency creates a cycle (or refers to an already existing cycle)") + end + self.title = mod.title + self.version_major = mod.version_major + self.version_minor = mod.version_minor + else + self.title = nil + self.version_major = nil + self.version_minor = nil + end + end +end + +----- + +if FirstLoad then + ModDependencyGraph = false +end + +local function CollapseDependencyGraph(node, direction, root_id, all_nodes, visited, list, list_failed) + list = list or { } + list_failed = list_failed or { } + visited = visited or { } + + if not visited[node] then + visited[node] = true + for i,dep in ipairs(node[direction]) do + local dep_mod = Mods[dep.id] + local successful = dep:ModFits(dep_mod) + local target_list = successful and list or list_failed + + --avoid having two entries that have the same mod member + local idx + if direction == "incoming" then + idx = table.find(target_list, "own_mod", dep.own_mod) + else + idx = table.find(target_list, "id", dep.id) + end + if idx then + --strive to have a required entry, instead of an optional one + if not target_list[idx].required then + target_list[idx] = dep + end + else + --issues between other mods are not reported only if the direction is 'outgoing' + --others do not interfere with the workings of this mod + if direction == "outgoing" or successful or dep.id == root_id then + table.insert(target_list, dep) + end + end + + if successful then + local next_id = (direction == "outgoing") and dep.id or dep.own_mod.id + CollapseDependencyGraph(all_nodes[next_id], direction, root_id, all_nodes, visited, list, list_failed) + end + end + end + + return list, list_failed +end + +function CacheModDependencyGraph() + --'incoming' are mods that depend on this one + --'outgoing' are mods that this one depends on + + local nodes = { } + for id,mod in pairs(Mods) do + local entry = nodes[id] or { incoming = { }, outgoing = { } } + nodes[id] = entry + entry.outgoing = GetModAllDependencies(mod) + for i,dep in ipairs(entry.outgoing) do + dep.own_mod = mod + local dep_entry = nodes[dep.id] or { incoming = { }, outgoing = { } } + nodes[dep.id] = dep_entry + table.insert(dep_entry.incoming, dep) + end + end + + ModDependencyGraph = { } + for id,mod in pairs(Mods) do + local root_id = mod.id + local outgoing, outgoing_failed = CollapseDependencyGraph(nodes[root_id], "outgoing", root_id, nodes) + local incoming, incoming_failed = CollapseDependencyGraph(nodes[root_id], "incoming", root_id, nodes) + ModDependencyGraph[id] = { + outgoing = outgoing, + incoming = incoming, + outgoing_failed = outgoing_failed, + incoming_failed = incoming_failed, + } + end +end + +function WaitWarnAboutSkippedMods() + local all_mods = AccountStorage and AccountStorage.LoadMods + local skipped_mods = {} + for _, id in ipairs(all_mods or empty_table) do + local dependency_data = ModDependencyGraph and ModDependencyGraph[id] + if dependency_data then + for _, dep in ipairs(dependency_data.outgoing or empty_table) do + if dep.required and not table.find(AccountStorage.LoadMods, dep.id) then + table.insert_unique(skipped_mods, dep.own_mod.title) + end + end + for _, dep in ipairs(dependency_data.outgoing_failed or empty_table) do + if dep.required then + table.insert_unique(skipped_mods, dep.own_mod.title) + end + end + end + end + if #(skipped_mods or "") > 0 then + local skipped = table.concat(skipped_mods, "\n") + WaitMessage(terminal.desktop, + T(824112417429, "Warning"), + T{949870544095, "The following mods will not be loaded because of missing or incompatible mods that they require:\n\n", skipped = Untranslated(skipped)}, + T(325411474155, "OK") + ) + end +end + +---- + +if FirstLoad then + ReportedMods = false +end + +function ReportModLuaError(mod, err, stack) + ReportedMods = ReportedMods or {} + if ReportedMods[mod.id] then + return + end + ReportedMods[mod.id] = true + local v_major = mod.version_major or ModDef.version_major + local v_minor = mod.version_minor or ModDef.version_minor + local v = mod.version or ModDef.version + local ver_str = string.format("%d.%02d-%03d", v_major or 0, v_minor or 0, v or 0) + ModLogF(true, "Lua error in mod %s (id %s, v%s) from %s", mod.title, mod.id, ver_str, mod.source) + Msg("OnModLuaError", mod, err, stack) +end + +function OnMsg.ModsReloaded() + SetSpecialLuaErrorHandling("Mods", #ModsLoaded > 0) +end + +function OnMsg.OnLuaError(err, stack) + for _, mod in ipairs(ModsLoaded) do + if mod.content_path then + if string.find_lower(err, mod.content_path) or string.find_lower(stack, mod.content_path) then + ReportModLuaError(mod, err, stack) + end + end + end +end + +--- + +if not Platform.developer then + if Platform.asserts then + OnMsg.EngineStarted = function() + ConsoleSetEnabled(true) + end + else + OnMsg.ChangeMap = function(map) + local dev_tools_visible = IsModEditorMap(map) or IsEditorActive() + ConsoleSetEnabled(dev_tools_visible) + local dev_interface = GetDialog(GetDevUIViewport()) + if dev_interface then + dev_interface:SetUIVisible(dev_tools_visible) + end + end + end +end + +--Gossip loaded mods info +function GossipMods() + local loadedMods = {} + for _, mod in ipairs(ModsLoaded or empty_table) do + table.insert(loadedMods, {id = mod.id, name = mod.title, version = mod.version}) + end + NetGossip("Mods", loadedMods) +end + +OnMsg.ModsReloaded = GossipMods +OnMsg.NetConnect = GossipMods + +function GetModBlacklistedReason(modId) + return ModIdBlacklist[modId] +end + +function TFormat.BlacklistedMods(deprecatedMods, bannedMods) + local deprecatedT = T{711775436656, "The following mods are now deprecated. They have been integrated into the base game and will be automatically disabled:\n", mods_list = deprecatedMods} + local bannedT = T{872385252314, "The following mods have been blacklisted and automatically blocked:\n", mods_list = bannedMods} + if deprecatedMods and bannedMods then + return T{942032529432, "\n\n", deprecated = deprecatedT, banned = bannedT} + elseif deprecatedMods then + return deprecatedT + elseif bannedMods then + return bannedT + end +end + +function CheckBlacklistedMods() + local deprecatedMods = next(AutoDisabledModsAlertText["deprecate"]) and table.concat(AutoDisabledModsAlertText["deprecate"], "\n") or false + local bannedMods = next(AutoDisabledModsAlertText["ban"]) and table.concat(AutoDisabledModsAlertText["ban"], "\n") or false + if not deprecatedMods and not bannedMods then return end + + CreateRealTimeThread(function() + local textT = TFormat.BlacklistedMods(deprecatedMods, bannedMods) + WaitMessage( + terminal.dekstop, + T(824112417429, "Warning"), + textT, + T(784547514723, "Ok") + ) + AutoDisabledModsAlertText = {} + end) +end + +OnMsg.PreGameMenuOpen = CheckBlacklistedMods + + +----- ModResourceDescriptor + +DefineClass.ModResourceDescriptor = { + __parents = { "PropertyObject" }, + properties = { + }, + mod = false, -- reference to the mod which affected the described resource +} + +function ModResourceDescriptor:CheckForConflict(other) + return false +end + +function ModResourceDescriptor:GetResourceTextDescription(conflict_reason) + return "" +end + + +----- Mod Conflicts + +function ClearModsAffectedResourcesCache() + ModsAffectedResourcesCache = { valid = false } +end + +if FirstLoad then + ModsAffectedResourcesCache = false + ClearModsAffectedResourcesCache() +end + +-- Populate the affected resources cache using the loaded mods +function FillModsAffectedResourcesCache() + ClearModsAffectedResourcesCache() + + for idx, mod in ipairs(ModsLoaded) do + if mod.affected_resources then + for _, res in ipairs(mod.affected_resources) do + if not ModsAffectedResourcesCache[res.class] then + ModsAffectedResourcesCache[res.class] = {} + end + table.insert(ModsAffectedResourcesCache[res.class], res) + end + end + end + + ModsAffectedResourcesCache.valid = true +end + +-- Add the given to-be-loaded mod's affected resources to the affected resources cache if they aren't already in it +function AddToModsAffectedResourcesCache(mod) + if not mod.affected_resources then return end + + for _, res in ipairs(mod.affected_resources) do + if not ModsAffectedResourcesCache[res.class] then + ModsAffectedResourcesCache[res.class] = {} + end + table.insert_unique(ModsAffectedResourcesCache[res.class], res) + end +end + +-- Remove the given to-be-loaded mod's affected resources from the affected resources cache if they are in it +function RemoveFromModsAffectedResourcesCache(mod) + if not mod.affected_resources then return end + + for _, res in ipairs(mod.affected_resources) do + if ModsAffectedResourcesCache[res.class] then + local idx = table.find(ModsAffectedResourcesCache[res.class], res) + + if idx then + table.remove(ModsAffectedResourcesCache[res.class], idx) + end + end + end +end + +-- Get the conflicts between all loaded (and to-be-loaded) mods. +-- There's a conflict when the two mods affect the same game resource described by ModResourceDescriptors. +function GetAllLoadedModsConflicts() + if not ModsAffectedResourcesCache or not ModsAffectedResourcesCache.valid then + FillModsAffectedResourcesCache() + end + + local conflicts = {} + for idx, mod in ipairs(ModsLoaded) do + local mod_conflicts = GetSingleModConflicts(mod) + table.iappend(conflicts, mod_conflicts) + end + + return conflicts +end + +-- Get the conflicts the given mod has with other loaded (and to-be-loaded) mods. +-- There's a conflict when the two mods affect the same game resource described by ModResourceDescriptors. +function GetSingleModConflicts(mod) + if not ModsAffectedResourcesCache or not ModsAffectedResourcesCache.valid then + FillModsAffectedResourcesCache() + end + + local conflicts = {} + for _, res in ipairs(mod.affected_resources or empty_table) do + if ModsAffectedResourcesCache[res.class] then + + local resources_to_check = ModsAffectedResourcesCache[res.class] or empty_table + for _, other_res in ipairs(resources_to_check) do + if res ~= other_res and mod.id ~= other_res.mod.id then + local conflict, reason = res:CheckForConflict(other_res) + if conflict then + local msg = res:GetResourceTextDescription(reason) + + -- Check if this msg and mod pair has already been recorded + -- This is for mods that conflict on the same resource multiple times + local duplicate_msg = false + for _, conf in ipairs(conflicts) do + if conf.msg == msg and conf.mod1 == mod.id and conf.mod2 == other_res.mod.id then + duplicate_msg = true + break + end + end + + if not duplicate_msg then + table.insert(conflicts, { mod1 = mod.id, mod2 = other_res.mod.id, msg = msg }) + end + end + end + end + + end + end + + table.sortby_field(conflicts, "mod2") + return conflicts +end + +-- Creates a formatted message about the given mod's conflicts based on the result of GetSingleModConflicts() +function GetModConflictsMessage(mod, conflicts) + local msg = "" + local prev_mod2 + for _, conf in ipairs(conflicts) do + local title2 = Mods[conf.mod2] and Mods[conf.mod2].title + if not prev_mod2 then + msg = string.format("%s:\n", title2) + prev_mod2 = conf.mod2 + end + + if prev_mod2 == conf.mod2 then + msg = string.format("%s\n - %s", msg, conf.msg) + else + msg = string.format("%s\n\n%s:\n - %s", msg, title2, conf.msg) + end + + prev_mod2 = conf.mod2 + end + + return msg +end diff --git a/CommonLua/Classes/ModItem.lua b/CommonLua/Classes/ModItem.lua new file mode 100644 index 0000000000000000000000000000000000000000..1cc34b80f2c1b8818025e57536143c00a5ffdcaf --- /dev/null +++ b/CommonLua/Classes/ModItem.lua @@ -0,0 +1,2930 @@ +if not config.Mods then + DefineModItemPreset = empty_func + g_FontReplaceMap = false + return +end + +DefineClass.ModItemUsingFiles = { + __parents = { "ModItem" }, + properties = { + {category = "Mod", id = "CopyFiles", name = "CopyFileNames", default = false, editor = "prop_table", no_edit = true}, + } +} + +function ModItemUsingFiles:GetFilesList() + -- implement in the specific class to return a list of associated files/folders +end + +function ModItemUsingFiles:GetFilesContents(filesList) + filesList = filesList or self:GetFilesList() + local serializedFiles = {} + serializedFiles.mod_content_path = self.mod.content_path + serializedFiles.mod_id = self.mod.id + for _, filename in ipairs(filesList) do + local is_folder = IsFolder(filename) + local err, data = AsyncFileToString(filename) + table.insert(serializedFiles, { filename = filename, data = data, is_folder = is_folder }) + end + return serializedFiles +end + +function ModItemUsingFiles.__toluacode(self, indent, pstr, GetPropFunc, injected_props) + if GedSerializeInProgress then + self.CopyFiles = self:GetFilesContents() + end + local code = ModItem.__toluacode(self, indent, pstr, GetPropFunc, injected_props) + self.CopyFiles = nil + return code +end + +function ModItemUsingFiles:PasteFiles() + if not self.CopyFiles then return end -- ResolvePasteFilesConflicts can clear the variable + for _, file in ipairs(self.CopyFiles) do + if file.filename and file.filename ~= "" then + local folder = file.filename + if not file.is_folder then + folder = folder:match("^(.*)/[^/]*$") + end + + local err = AsyncCreatePath(folder) + if err then ModLogF("Error creating path:", err) end + + if not file.is_folder and file.data then + local err = AsyncStringToFile(file.filename, file.data) + if err then ModLogF("Error creating file:", err) end + end + end + end + self.CopyFiles = nil +end + +-- replaces the "filename" of self.CopyFiles[index] with the new paths without conflicts +-- may also prompt user for a manual change to resolve the conflict +-- returns any necessary/handy info to be used in OnAfterPasteFiles +function ModItemUsingFiles:ResolvePasteFilesConflicts() +end + +-- applies whatever changes need to be applied after the files have been created (some change the files' contents, so it need to be after paste) +function ModItemUsingFiles:OnAfterPasteFiles(changes_meta) +end + +function ModItemUsingFiles:OnAfterEditorNew(parent, ged, is_paste) + if self.CopyFiles and #self.CopyFiles > 0 then + local changes_meta = self:ResolvePasteFilesConflicts(ged) + self:PasteFiles() + self:OnAfterPasteFiles(changes_meta) + end +end + +function ModItemUsingFiles:OnEditorDelete(parent, ged) + for _, path in ipairs(self:GetFilesList()) do + if path ~= "" then AsyncFileDelete(path) end + end +end + + +----- ModItemCode + +DefineClass.ModItemCode = { + __parents = { "ModItemUsingFiles" }, + properties = { + { + category = "Mod", id = "name", name = "Name", default = "Script", editor = "text", + validate = function(self, value) + value = value:trim_spaces() + if value == "" then + return "Please enter a valid name" + end + return self.mod:ForEachModItem("ModItemCode", function(item) + if item ~= self and item.name == value then + return "A code item with that name already exists" + end + end) + end, + }, + { category = "Code", id = "CodeFileName", name = "File name", default = "", editor = "text", read_only = true, buttons = {{name = "Open", func = "OpenCodeFile"}},}, + { category = "Code", id = "CodeError", name = "Error", default = "", editor = "text", lines = 1, max_lines = 3, read_only = true, dont_save = true, translate = false, code = true }, + { category = "Code", id = "Preview", name = "Preview", default = "", editor = "text", lines = 10, max_lines = 30, wordwrap = false, read_only = true, dont_save = true, translate = false, code = true }, + }, + EditorName = "Code", + EditorSubmenu = "Assets", + preview = "", + Documentation = "This mod item allows you to load a single file of Lua code in the Lua environment of the game. The code can then directly affect the game, or be used by some other mod items.", + DocumentationLink = "Docs/ModItemCode.md.html", + TestDescription = "Reloads lua." +} + +function ModItemCode:OnEditorNew() + self.name = self:FindFreeFilename(self.name) +end + +function ModItemCode:EnsureFileExists() + AsyncCreatePath(self.mod.content_path .. "Code/") + local file_path = self:GetCodeFilePath() + if file_path ~= "" and not io.exists(file_path) then + AsyncStringToFile(file_path, "") + end +end + +function ModItemCode:GetFilesList() + self:EnsureFileExists() + return {self:GetCodeFilePath()} +end + +function ModItemCode:ResolvePasteFilesConflicts() + self.name = self:FindFreeFilename(self.name) + self.CopyFiles[1].filename = self:GetCodeFilePath() +end + +function ModItemCode:OnAfterEditorNew(parent, ged, is_paste) + self:EnsureFileExists() +end + +function ModItemCode:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "name" then + local old_file_name = self:GetCodeFilePath(old_value) + local new_file_name = self:GetCodeFilePath() + AsyncCreatePath(self.mod.content_path .. "Code/") + local err + if io.exists(old_file_name) then + local data + err, data = AsyncFileToString(old_file_name) + err = err or AsyncStringToFile(new_file_name, data) + if err then + ged:ShowMessage("Error", string.format("Error creating %s", new_file_name)) + self.name = old_value + ObjModified(self) + return + end + AsyncFileDelete(old_file_name) + elseif not io.exists(new_file_name) then + err = AsyncStringToFile(new_file_name, "") + end + end +end + +function ModItemCode:GetCodeFileName(name) + name = name or self.name or "" + if name == "" then return end + return string.format("Code/%s.lua", name:gsub('[/?<>\\:*|"]', "_")) +end + +function ModItemCode:OpenCodeFile() + self:EnsureFileExists() + local file_path = self:GetCodeFilePath() + if file_path ~= "" then + CreateRealTimeThread(AsyncExec, "explorer " .. ConvertToOSPath(file_path)) + end +end + +function ModItemCode:GetPreview() + local err, code = AsyncFileToString(self:GetCodeFilePath()) + self.preview = code + return code or "" +end + +function ModItemCode:FindFreeFilename(name) + if name == "" then return name end + + local existing_code_files = {} + self.mod:ForEachModItem("ModItemCode", function(item) + if item ~= self then + existing_code_files[item.name] = true + end + end) + + local n = 1 + local folder, file_name, ext = SplitPath(name) + local matching_digits = file_name:match("(%d*)$") + local n = matching_digits and tonumber(matching_digits) or -1 + file_name = file_name:sub(1, file_name:len() - matching_digits:len()) + local new_file_name = (file_name .. tostring(n > 0 and n or "")) + while existing_code_files[new_file_name] or io.exists(folder .. new_file_name .. ext) do + n = n + 1 + new_file_name = (file_name .. tostring(n > 0 and n or "")) + end + return folder .. new_file_name .. ext +end + +function ModItemCode:GetError() + if not io.exists(self:GetCodeFilePath()) then return "No file! Click the 'Open' button to create it." end + return self.mod:ForEachModItem("ModItemCode", function(item) + if item ~= self and item.name == self.name then + return "Multiple ModItemCode items point to the same script!" + end + end) +end + +function ModItemCode:TestModItem(ged) + if self.mod:UpdateCode() then + ReloadLua() + end + ObjModified(self) + ged:ShowMessage("Information", "Your code has been loaded and is currently active in the game.") +end + +function OnMsg.ModCodeChanged(file, change) + for i,mod in ipairs(ModsLoaded) do + if not mod.packed then + mod:ForEachModItem("ModItemCode", function(item) + if string.find_lower(file, item.name) then + ObjModified(item) + return "break" + end + end) + end + end +end + + +----- T funcs + +local function DuplicateT(v) + if getmetatable(v) == TConcatMeta then + local ret = {} + for _, t in ipairs(v) do + table.insert(ret, DuplicateT(t)) + end + return setmetatable(ret, TConcatMeta) + end + v = _InternalTranslate(v, false, false) + return T{RandomLocId(), v} +end + +function DuplicateTs(obj, visited) + visited = visited or {} + for key, value in pairs(obj) do + if not MembersReferencingParents[key] then + if value ~= "" and IsT(value) then + obj[key] = DuplicateT(value) + elseif type(value) == "table" and not visited[value] then + visited[value] = true + DuplicateTs(value, visited) + end + end + end +end + + +----- ModItemPreset + +function DefineModItemPreset(preset, class) + class = class or {} + class.GedEditor = false + class.ModdedPresetClass = preset + class.EditorView = ModItem.EditorView + class.__parents = { "ModItemPreset", preset, } + class.GetError = ModItemPreset.GetError + assert((class.EditorName or "") ~= "", "EditorName is required for mod item presets") + if (class.EditorName or "") == "" then + class.EditorName = preset + end + + local properties = class.properties or {} + local id_prop = table.copy(table.find_value(Preset.properties, "id", "Id")) + local group_prop = table.copy(table.find_value(Preset.properties, "id", "Group")) + id_prop.category = "Mod" + group_prop.category = "Mod" + table.insert(properties, id_prop) + table.insert(properties, group_prop) + table.insert(properties, { id = "Comment", no_edit = true, }) -- duplicates with ModItem's comment property + table.insert(properties, { category = "Mod", id = "Documentation", dont_save = true, editor = "documentation", sort_order = 9999999 }) -- duplicates with ModItem's comment property + table.insert(properties, { id = "new_in", editor = false }) + class.properties = properties + + UndefineClass("ModItem" .. preset) + DefineClass("ModItem" .. preset, class) + return class +end + +function DefineModItemCompositeObject(preset, class) + local class = DefineModItemPreset(preset, class) + class.__parents = { "ModItemCompositeObject", preset, } + class.new = function(class, obj) + obj = CompositeDef.new(class, obj) + obj = ModItemPreset.new(class, obj) + return obj + end + class.GetProperties = ModItemCompositeObject.GetProperties + return class +end + +DefineClass.ModItemCompositeObject = { + __parents = { "ModItemPreset" }, + + mod_properties_cache = false, +} + +function OnMsg.ClassesBuilt() + ClassDescendantsList("ModItemPreset", function(name, class) + for idx, prop in ipairs(class.properties) do + if prop.category == "Preset" then + prop = table.copy(prop) + prop.category = "Mod" + class.properties[idx] = prop + end + end + end) +end + +-- making this a local function to avoid duplication in the "Copy from" and "Copy from group"; mostly for the filter func +local function GetFilteredPresetsCombo(obj, group) + return PresetsCombo(obj.PresetClass or obj.class, + group, + nil, + function(preset) return preset ~= obj and not preset.Obsolete end)() +end + +DefineClass.ModItemPreset = { + __parents = { "Preset", "ModItem" }, + properties = { + { category = "Mod", id = "__copy_group", name = "Copy from group", default = "Default", editor = "combo", + items = function(obj) + local candidate_groups = PresetGroupsCombo(obj.PresetClass or obj.class)() + local groups = {} + for _, group in ipairs(candidate_groups) do + local num_presets = #(GetFilteredPresetsCombo(obj, group)) + if num_presets ~= 0 and group ~= "Obsolete" then table.insert(groups, group) end + end + return groups + end, + no_edit = function (obj) return not obj.HasGroups end, dont_save = true, }, + { category = "Mod", id = "__copy", name = "Copy from", default = "", editor = "combo", + items = function(obj) + local group = obj.PresetClass ~= obj.ModdedPresetClass and not obj.HasGroups and obj.ModdedPresetClass or obj.__copy_group + return GetFilteredPresetsCombo(obj, group) + end, + dont_save = true, }, + { id = "SaveIn", editor = false }, + { id = "name", default = false, editor = false }, + { id = "TODO", editor = false }, + { id = "Obsolete", editor = false }, + }, + EditorView = ModItem.EditorView, + GedEditor = false, + ModItemDescription = T(159662765679, ""), + ModdedPresetClass = false, + save_in = "none", + is_data = true, + TestDescription = "Loads the mod item's data in the game." +} + +function ModItemPreset:SetSaveIn() end +function ModItemPreset:GetSaveIn() return "none" end +function ModItemPreset:GetSaveFolder() return nil end +function ModItemPreset:GetSavePath() return nil end +function ModItemPreset:Getname() return self.id end +function ModItemPreset:GetSaveLocationType() return "mod" end + +function ModItemPreset:IsOpenInGed() + return Preset.IsOpenInGed(self) or ModItem.IsOpenInGed(self) +end + +function ModItemPreset:delete() + Preset.delete(self) + InitDone.delete(self) +end + +function ModItemPreset:GetCodeFileName(name) + if self.HasCompanionFile or self.GetCompanionFilesList ~= Preset.GetCompanionFilesList then + name = name or self.id + local sub_folder = IsKindOf(self, "CompositeDef") and self.ObjectBaseClass or self.PresetClass + return name and name ~= "" and + string.format("%s/%s.lua", sub_folder, name:gsub('[/?<>\\:*|"]', "_")) + end +end + +function ModItemPreset:GetPropOSPathKey(prop_id) + return string.format("%s_%s_%s", self.class, self.id, prop_id) +end + +function ModItemPreset:PreSave() + self:OnPreSave() + return ModItem.PreSave(self) +end + +function ModItemPreset:PostSave(saved_preset_classes) + if saved_preset_classes then + saved_preset_classes[self.PresetClass or self.class] = true + end + if self:GetCodeFileName() then + local code = pstr("", 8 * 1024) + local err = self:GenerateCompanionFileCode(code) + if not err then + local path = self:GetCodeFilePath() + local folder = SplitPath(path) + AsyncCreatePath(folder) + AsyncStringToFile(path, code) + end + end + self:OnPostSave() + return ModItem.PostSave(self) +end + +-- This is a request to store the preset to disk, e.g. from the "Save" button in the Script Editor +-- For a mod item, initiate a save of the mod items to do this +function ModItemPreset:Save(by_user_request, ged) + self.mod:SaveItems() +end + +function ModItemPreset:OnCopyFrom(preset) +end + +function ModItemPreset:GatherPropertiesBlacklisted(blacklist) +end + +function ModItemPreset:OnEditorSetProperty(prop_id, old_value, ged) + Preset.OnEditorSetProperty(self, prop_id, old_value, ged) + + if prop_id == "Id" then + if self:GetCodeFileName() then + -- delete old code file, a new one will be generated upon saving + AsyncFileDelete(self:GetCodeFilePath(old_value)) + end + elseif prop_id == "__copy" then + local preset_class = self.PresetClass or self.class + local preset_group = self.PresetClass ~= self.ModdedPresetClass and not self.HasGroups and self.ModdedPresetClass or self.__copy_group + local id = self.__copy + local preset + ForEachPresetExtended(preset_class, + function(obj) + if obj.group == preset_group and obj.id == id and obj ~= self then + preset = obj + return "break" + end + end + ) + if not preset then + return + end + + local function do_copy() + local blacklist = { "Id", "Group", "comment", "__copy" } + self:GatherPropertiesBlacklisted(blacklist) + CopyPropertiesBlacklisted(preset, self, blacklist) + + --copy even default value props to prevent leftovers from previous copies + local presetProps = preset:GetProperties() + for _, prop in ipairs(presetProps) do + local propId = prop.id + if not table.find(blacklist, propId) and preset:IsPropertyDefault(propId, prop) then + self[propId] = nil + end + end + + table.iclear(self) + local count = 0 + for _, value in ipairs(preset) do + local err, copy = CopyValue(value) + assert(not err, err) + if not err then + count = count + 1 + self[count] = copy + end + end + PopulateParentTableCache(self) + DuplicateTs(self) + + for _, sub_obj in ipairs(self) do + if IsKindOf(sub_obj, "ParticleEmitter") then + self:OverrideEmitterFuncs(sub_obj) + end + if IsKindOf(sub_obj, "SoundFile") then + self:OverrideSampleFuncs(sub_obj) + end + end + + self:OnCopyFrom(preset) + if ged and ged.app_template == "ModEditor" then + ObjModified(ged:ResolveObj("root")) + else + ObjModifiedMod(self.mod) + end + ObjModified(self) + end + + self.__copy = nil + ObjModified(self) + + if ged and ged.app_template == "ModEditor" then + CreateRealTimeThread(function() + local fmt = "Do you want to copy all properties from %s.%s?\n\nThe current values of the ModItem properties will be lost." + local msg = string.format(fmt, preset_group, id) + if ged:WaitQuestion("Warning", msg, "Yes", "No") ~= "ok" then + self.__copy = nil + ObjModified(self) + return + end + do_copy() + end) + else + do_copy() + end + end +end + +function ModItemPreset:OnAfterEditorNew(parent, ged, is_paste, duplicate_id, mod_id) + -- Mod item presets can be added through Preset Editors (see GedOpClonePresetInMod) + -- In those cases the reference to the mod will be added from there + if ged and ged.app_template ~= "ModEditor" then + if self.mod then + -- Update the mod manually + if self.mod:ItemsLoaded() and not self.mod:IsPacked() then + table.insert(self.mod.items, self) + self:MarkDirty() + self.mod:MarkDirty() + end + ObjModifiedMod(self.mod) + end + return + end + + self:MarkDirty() +end + +function ModItemPreset:OnEditorDelete(mod, ged) + -- Update the mod and Mod Editor tree panel whenever a mod item is deleted from a Preset Editor + if ged and ged.app_template ~= "ModEditor" then + if self.mod and self.mod:ItemsLoaded() and not self.mod:IsPacked() then + local idx = table.find(self.mod.items, self) + if idx then + table.remove(self.mod.items, idx) + self.mod:MarkDirty() + ObjModifiedMod(self.mod) + end + end + return + end + + if Presets[self.ModdedPresetClass] then + ObjModified(Presets[self.ModdedPresetClass]) + end +end + +function ModItemPreset:TestModItem(ged) + self:PostLoad() + if self:GetCodeFileName() then + self:PostSave() + if self.mod:UpdateCode() then + ReloadLua() + end + ged:ShowMessage("Information", "The preset has been loaded and is currently active in the game.") + end +end + +function ModItemPreset:OnModLoad() + ModItem.OnModLoad(self) + self:PostLoad() +end + +function ModItemPreset:GetWarning() + local warning = g_Classes[self.ModdedPresetClass].GetWarning(self) + return warning or + self:IsDirty() and self:GetCodeFileName() and "Use the Test button or save the mod to test your changes." +end + +function ModItemPreset:GetError() + if self.id == "" then + return "Please specify mod item Id." + end + return g_Classes[self.ModdedPresetClass].GetError(self) +end + +function ModItemPreset:IsReadOnly() + return false +end + +function ModItemPreset:GetAffectedResources() + if self.ModdedPresetClass and self.id and self.id ~= "" then + local affected_resources = {} + table.insert(affected_resources, ModResourcePreset:new({ + mod = self.mod, + Class = self.ModdedPresetClass, + Id = self.id, + ClassDisplayName = self.EditorName, + })) + return affected_resources + end + + return empty_table +end + +function OnMsg.ClassesPostprocess() + ClassDescendantsList("ModItemPreset", function(name, class) + class.PresetClass = class.PresetClass or class.ModdedPresetClass + end) +end + +function OnMsg.ModsReloaded() + for class, presets in pairs(Presets) do + _G[class]:SortPresets() + end +end + +----- ModResourcePreset - Describes a preset affected by a mod. The preset can be either added or replaced, or have a specific property value changed. +DefineClass.ModResourcePreset = { + __parents = { "ModResourceDescriptor" }, + properties = { + { id = "Class", name = "Preset class", editor = "text", default = false, }, + { id = "Id", name = "Preset id", editor = "text", default = false, }, + { id = "Prop", name = "Preset property", editor = "text", default = false, }, + { id = "ClassDisplayName", name = "Class display name", editor = "text", default = false, }, + }, +} + +function ModResourcePreset:CheckForConflict(other) + return self.Class and self.Class == other.Class and self.Id and self.Id == other.Id and ((self.Prop and self.Prop == other.Prop) or (not self.Prop or not other.Prop)) +end + +function ModResourcePreset:GetResourceTextDescription() + return string.format("%s \"%s\"", self.ClassDisplayName or self.Class, self.Id) +end + +----- ModItemLightmodel + +DefineModItemPreset("LightmodelPreset", { + EditorName = "Lightmodel", + EditorSubmenu = "Other", + properties = { + { id = "cubemap_capture_preview" }, + { id = "exterior_envmap" }, + { id = "ext_env_exposure" }, + { id = "ExteriorEnvmapImage" }, + { id = "interior_envmap" }, + { id = "int_env_exposure" }, + { id = "InteriorEnvmapImage" }, + { id = "env_exterior_capture_sky_exp" }, + { id = "env_exterior_capture_sun_int" }, + { id = "env_exterior_capture_pos" }, + { id = "env_interior_capture_sky_exp" }, + { id = "env_interior_capture_sun_int" }, + { id = "env_interior_capture_pos" }, + { id = "env_capture_map" }, + { id = "env_capture" }, + { id = "env_view_site" }, + { id = "hdr_pano" }, + { id = "lm_capture" }, + { id = "__" }, + }, + Documentation = "Define a set of lighting parameters controlling the look of the day/night cycle.", + TestDescription = "Overrides the current lightmodel." +}) + +function ModItemLightmodelPreset:GetCubemapWarning() +end + +function ModItemLightmodelPreset:TestModItem(ged) + SetLightmodelOverride(1, LightmodelOverride ~= self and self) +end + +----- ModItemEntity + +ModEntityClassesCombo = { + "", + --common + "AnimatedTextureObject", "AutoAttachObject", + "Deposition", "Decal", "FloorAlignedObj", "Mirrorable", +} + +DefineClass.BaseModItemEntity = { + __parents = { "ModItemUsingFiles", "BasicEntitySpecProperties" }, + properties = { + { category = "Mod", id = "name", name = "Name", default = "", editor = "text", untranslated = true }, + { category = "Mod", id = "entity_name", name = "Entity Name", default = "", editor = "text", read_only = true, untranslated = true }, + { category = "Misc", id = "class_parent", name = "Class", editor = "combo", items = ModEntityClassesCombo, default = "", entitydata = true }, + }, + TestDescription = "Loads the entity in the engine and places a dummy object with it." +} + +function BaseModItemEntity:OnEditorNew(mod, ged, is_paste) + self.name = self.name == "" and "Entity" or self.name +end + +function BaseModItemEntity:Import(root, prop_id, socket, btn_param, idx) +end + +function BaseModItemEntity:ReloadEntities(root, prop_id, socket, btn_param, idx) + ModsLoadAssets() +end + +function BaseModItemEntity:ExportEntityData() + local data = self:ExportEntityDataForSelf() + data.editor_artset = "Mods" + return data +end + +function BaseModItemEntity:PostSave(...) + ModItem.PostSave(self, ...) + if self.entity_name == "" then return end + local data = self:ExportEntityData() + if not next(data) then return end + local code = string.format("EntityData[\"%s\"] = %s", self.entity_name, ValueToLuaCode(data)) + local path = self:GetCodeFilePath() + local folder = SplitPath(path) + AsyncCreatePath(folder) + AsyncStringToFile(path, code) +end + +function BaseModItemEntity:GetCodeFileName() + if self.entity_name == "" then return end + local data = self:ExportEntityData() + if not next(data) then return end + return string.format("Entities/%s.lua", self.entity_name) +end + +function BaseModItemEntity:TestModItem(ged) + if self.entity_name == "" then return end + + self.mod:UpdateCode() + DelayedLoadEntity(self.mod, self.entity_name) + WaitDelayedLoadEntities() + Msg("BinAssetsLoaded") -- force entity-related structures reload - not needed for this visualization, but necessary to use this e.g. in a building a bit later + ReloadLua() + + if GetMap() == "" then + ModLogF("Entity testing only possible when a map is loaded") + return + end + + local obj = PlaceObject("Shapeshifter") + obj:ChangeEntity(self.entity_name) + obj:SetPos(GetTerrainCursorXY(UIL.GetScreenSize()/2)) + if IsEditorActive() then + EditorViewMapObject(obj, nil, true) + else + ViewObject(obj) + end +end + +function BaseModItemEntity:NeedsResave() + if self.mod.bin_assets then return true end +end + +local function DeleteIfEmpty(path) + local err, files = AsyncListFiles(path, "*", "recursive") + local err, folders = AsyncListFiles(path, "*", "recursive,folders") + if #files == 0 and #folders == 0 then + AsyncDeletePath(path) + end +end + +function BaseModItemEntity:GetFilesList() + if self.entity_name == "" then return end + local entity_root = self.mod.content_path .. "Entities/" + local entity_name = self.entity_name + local err, entity = ParseEntity(entity_root, entity_name) + if err then + return + end + + local files_list = {} + + table.insert(files_list, entity_root .. entity_name .. ".ent") + table.insert(files_list, entity_root .. entity_name .. ".lua") + + for _, name in ipairs(entity.meshes) do + table.insert(files_list, entity_root .. name .. ".hgm") + end + for _, name in ipairs(entity.animations) do + if io.exists(entity_root .. name .. ".hga") then + table.insert(files_list, entity_root .. name .. ".hga") + else + table.insert(files_list, entity_root .. name .. ".hgacl") + end + end + for _, name in ipairs(entity.materials) do + table.insert(files_list, entity_root .. name .. ".mtl") + end + for _, name in ipairs(entity.textures) do + table.insert(files_list, entity_root .. "Textures/" .. name .. ".dds") + table.insert(files_list, entity_root .. "Textures/Fallbacks/" .. name .. ".dds") + end + + -- remove duplicates + local trimmed_files_list = {} + for _, filename in ipairs(files_list) do + if not files_list[filename] then + files_list[filename] = true + table.insert(trimmed_files_list, filename) + end + end + return trimmed_files_list +end + +function BaseModItemEntity:IsDuplicate(mod, entity_name) + mod = mod or self.mod + entity_name = entity_name or self.entity_name + return mod:ForEachModItem("BaseModItemEntity", function(mc) + if mc.entity_name == entity_name and mc ~= self then + return true + end + end) +end + +function BaseModItemEntity:ResolvePasteFilesConflicts(ged) + if self.entity_name and self:IsDuplicate(self.mod, self.entity_name) then + ModLogF(string.format("Entity <%s> already exists in mod <%s>! Created empty entity, instead.", self.entity_name, self.mod.id)) + self.CopyFiles = false + self.entity_name = "" + self.name = "Entity" + return + end + + local prev_mod_id = self.CopyFiles.mod_id + for _, file in ipairs(self.CopyFiles) do + if prev_mod_id ~= self.mod.id then + file.filename = file.filename:gsub(prev_mod_id, self.mod.id) + end + end +end + +local function CleanEntityFolders(entity_root, entity_name) + DeleteIfEmpty(entity_root .. "Meshes/") + DeleteIfEmpty(entity_root .. "Animations/") + DeleteIfEmpty(entity_root .. "Materials/") + DeleteIfEmpty(entity_root .. "Textures/Fallbacks/") + DeleteIfEmpty(entity_root .. "Textures/") + DeleteIfEmpty(entity_root) +end + +function BaseModItemEntity:OnEditorDelete(mod, ged) + if self.entity_name == "" then return end + local entity_root = self.mod.content_path .. "Entities/" + CleanEntityFolders(entity_root, self.entity_name) +end + +function GetModEntities(typ) + local results = {} + -- ignore type for now, return all types + for _, mod in ipairs(ModsLoaded) do + mod:ForEachModItem("BaseModItemEntity", function(mc) + if mc.entity_name ~= "" then + results[#results + 1] = mc.entity_name + end + end) + end + table.sort(results) + return results +end + +if FirstLoad then + EntityLoadEntities = {} +end + +function DelayedLoadEntity(mod, entity_name) + local idx = table.find(EntityLoadEntities, 2, entity_name) + if idx then + if mod == EntityLoadEntities[idx][1] then + return + end + ModLogF(true, "%s overrides entity %s from %s", mod.id, entity_name, EntityLoadEntities[idx][1].id) + table.remove(EntityLoadEntities, idx) + end + local entity_filename = mod.content_path .. "Entities/" .. entity_name .. ".ent" + if not io.exists(entity_filename) then + ModLogF(true, "Failed to open entity file %s", entity_filename) + return + end + EntityLoadEntities[#EntityLoadEntities+1] = {mod, entity_name, entity_filename} +end + +function WaitDelayedLoadEntities() + if #EntityLoadEntities > 0 then + local list = EntityLoadEntities + EntityLoadEntities = {} + AsyncLoadAdditionalEntities( table.map(list, 3) ) + ReloadFadeCategories(true) + ReloadClassEntities() + Msg("EntitiesLoaded") + Msg("AdditionalEntitiesLoaded") + + for i, data in ipairs(list) do + if not IsValidEntity(data[2]) then + ModLogF(true, "Mod %s failed to load %s", data[1]:GetModLabel("plainText"), data[2]) + end + end + end +end + +function RegisterModDelayedLoadEntities(mods) + for i, mod in ipairs(mods) do + for j, entity_name in ipairs(mod.entities) do + DelayedLoadEntity(mod, entity_name) + end + end +end + +---- + +DefineClass.ModItemEntity = { + __parents = { "BaseModItemEntity", "EntitySpecProperties" }, + + EditorName = "Entity", + EditorSubmenu = "Assets", + + properties = { + { category = "Mod", id = "import", name = "Import", editor = "browse", os_path = "AppData/ExportedEntities/", filter = "Entity files|*.ent", default = "", dont_save = true}, + { category = "Mod", id = "buttons", editor = "buttons", default = false, buttons = {{name = "Import Entity Files", func = "Import"}, {name = "Reload entities (slow)", func = "ReloadEntities"}}, untranslated = true}, + }, + Documentation = "Imports art assets from Blender.", + DocumentationLink = "Docs/ModItemEntity.md.html" +} + +if Platform.developer then + g_HgnvCompressPath = "svnSrc/Tools/hgnvcompress/Bin/hgnvcompress.exe" + g_HgimgcvtPath = "svnSrc/Tools/hgimgcvt/Bin/hgimgcvt.exe" + g_OpusCvtPath = "svnSrc/ExternalTools/opusenc.exe" +else + g_HgnvCompressPath = "ModTools/AssetsProcessor/hgnvcompress.exe" + g_HgimgcvtPath = "ModTools/hgimgcvt.exe" + g_OpusCvtPath = "ModTools/opusenc.exe" +end + +function ParseEntity(root, name) + local filename = root .. name .. ".ent" + local err, xml = AsyncFileToString(filename) + if err then return err end + + local entity = { materials = {}, meshes = {}, animations = {}, textures = {} } + for asset in string.gmatch(xml, " already exists in this mod!", entity_name)) + return + end + + local entity_root = self.mod.content_path .. "Entities/" + local err = AsyncCreatePath(entity_root) + if err then + ModLogF(true, "Failed to create path %s: %s", entity_root, err) + end + + ModLogF("Importing entity %s", entity_name) + + local dest_path = entity_root .. entity_name .. ext + err = AsyncCopyFile(self.import, dest_path) + if err then + ModLogF(true, "Failed to copy entity %s to %s: %s", entity_name, dest_path, err) + return + end + + local err, entity = ParseEntity(import_root, entity_name) + if err then + ModLogF(true, "Failed to open entity file %s: %s", dest_path, err) + return + end + + local function CopyAssetType(folder, tbl, exts, asset_type) + local dest_path = entity_root .. folder + + for _, asset in ipairs(entity[tbl]) do + local err = AsyncCreatePath(dest_path) + if err then + ModLogF(true, "Failed to create path %s: %s", dest_path, err) + break + end + local matched = false + for _,ext in ipairs(type(exts) == "table" and exts or {exts}) do + local src_filename + if string.starts_with(asset, folder) then + src_filename = import_root .. asset .. ext + else + src_filename = import_root .. folder .. asset .. ext + end + if io.exists(src_filename) then + local dest_filename + if string.starts_with(asset, folder) then + dest_filename = entity_root .. asset .. ext + else + dest_filename = entity_root .. folder .. asset .. ext + end + err = AsyncCopyFile(src_filename, dest_filename) + if err then + ModLogF(true, "Failed to copy %s to %s: %s", src_filename, dest_filename, err) + else + ModLogF("Importing %s %s", asset_type, asset) + ReloadEntityResource(dest_filename, "modified") + end + matched = true + end + end + if not matched then + ModLogF(true, "Missing file %s referenced in entity", asset) + end + end + end + + CopyAssetType("Meshes/", "meshes", ".hgm", "mesh") + CopyAssetType("Animations/", "animations", { ".hga", ".hgacl" }, "animation") + CopyAssetType("Materials/", "materials", ".mtl", "material") + CopyAssetType("Textures/", "textures", ".dds", "texture") + + local dest_path = entity_root .. "Textures/Fallbacks/" + for _, asset in ipairs(entity.textures) do + local err = AsyncCreatePath(dest_path) + if err then + ModLogF(true, "Failed to create path %s: %s", dest_path, err) + break + end + local src_filename = entity_root .. "Textures/" .. asset .. ".dds" + local dest_filename = dest_path .. asset .. ".dds" + local cmdline = string.format("\"%s\" \"%s\" \"%s\" --truncate %d", ConvertToOSPath(g_HgimgcvtPath), ConvertToOSPath(src_filename), ConvertToOSPath(dest_filename), 64) + local err = AsyncExec(cmdline, ".", true) + if err then + ModLogF(true, "Failed to generate backup for <%s: %s", asset, err) + end + end + + self.name = entity_name + + self.entity_name = entity_name + self:StoreOSPaths() + + ObjModified(self) +end + +----- ModItemFont + +if FirstLoad then + g_FontReplaceMap = {} +end + +DefineClass.FontAsset = { + __parents = { "InitDone" }, + + properties = { + { id = "FontPath", name = "Font path", editor = "browse", + default = false, filter = "Font files|*.ttf;*.otf", + mod_dst = function() return GetModAssetDestFolder("Font") end }, + }, +} + +function FontAsset:Done() + if self.FontPath then + AsyncDeletePath(self.FontPath) + end +end + +function FontAsset:LoadFont(font_path) + local file_list = {} + table.insert(file_list, font_path) + UIL.LoadFontFileList(file_list) -- !TODO: previously loaded fonts were reported as failure, incorrectly, see mantis 241725 + return true +end + +function FontAsset:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "FontPath" then + GedSetUiStatus("mod_import_font_asset", "Importing font...") + -- Delete the old font file if there's one + if old_value then + AsyncDeletePath(old_value) + end + + -- Load the new font + if self.FontPath then + local ok = self:LoadFont(self.FontPath) + if not ok then + ged:ShowMessage("Failed importing font", "The font file could not be processed correctly. Please try another font file or format. \nRead the mod item font documentation for more details on supported formats.") + AsyncDeletePath(self.FontPath) + self.FontPath = nil + else + ged:ShowMessage("Success", "Font loaded successfully.") + end + end + + GedSetUiStatus("mod_import_font_asset") + end +end + +local function font_items() + return UIL.GetAllFontNames() +end + +DefineClass.FontReplaceMapping = { + __parents = { "InitDone" }, + + properties = { + { id = "Replace", name = "Replace", editor = "choice", default = false, items = font_items }, + { id = "With", name = "With", editor = "choice", default = false, items = font_items }, + }, +} + +function FontReplaceMapping:Done() + if self.Replace and g_FontReplaceMap then + g_FontReplaceMap[self.Replace] = nil + end +end + +function FontReplaceMapping:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Replace" then + if old_value and g_FontReplaceMap then + g_FontReplaceMap[old_value] = nil + end + end + + if self.Replace and self.With and g_FontReplaceMap then + g_FontReplaceMap[self.Replace] = self.With + Msg("TranslationChanged") + end +end + +DefineClass.ModItemFont = { + __parents = { "ModItemUsingFiles", }, + + properties = { + { category = "Font assets", id = "AssetFiles", name = "Font asset files", editor = "nested_list", default = false, + base_class = "FontAsset", auto_expand = true, help = "Import TTF and OTF font files to be loaded into the game", }, + + { category = "Font replace mapping", id = "ReplaceMappings", name = "Font replace mappings", editor = "nested_list", + default = false, base_class = "FontReplaceMapping", auto_expand = true, + help = "Choose fonts to replace and which fonts to replace them with", }, + + { category = "Font replace mapping", id = "TextStylesHelp", name = "TextStyles help", editor = "help", default = false, + help = "You can also replace individual text styles by adding \"TextStyle\" mod items.", }, + }, + + EditorName = "Font", + EditorSubmenu = "Assets", + Documentation = "Imports new font files and defines which in-game fonts should be replaced by them.", + DocumentationLink = "Docs/ModItemFont.md.html" +} + +function ModItemFont:OnEditorNew(mod, ged, is_paste) + self.name = "Font" +end + +function ModItemFont:GetFontTargetPath() + return SlashTerminate(self.mod.content_path) .. "Fonts" +end + +function ModItemFont:OnModLoad() + self:LoadFonts() + self:ApplyFontReplaceMapping() + Msg("TranslationChanged") + + ModItem.OnModLoad(self) +end + +function ModItemFont:OnModUnload() + self:RemoveFontReplaceMapping() + Msg("TranslationChanged") + + return ModItem.OnModUnload(self) +end + +function ModItemFont:LoadFonts() + if not self.AssetFiles then return false end + + local file_list = {} + for _, font_asset in ipairs(self.AssetFiles) do + if font_asset.FontPath then + table.insert(file_list, font_asset.FontPath) + end + end + return UIL.LoadFontFileList(file_list) +end + +function ModItemFont:ApplyFontReplaceMapping() + if not self.ReplaceMappings or not g_FontReplaceMap then return false end + + for _, mapping in ipairs(self.ReplaceMappings) do + if mapping.Replace and mapping.With then + g_FontReplaceMap[mapping.Replace] = mapping.With + end + end +end + +function ModItemFont:RemoveFontReplaceMapping() + if not self.ReplaceMappings or not g_FontReplaceMap then return false end + + for _, mapping in ipairs(self.ReplaceMappings) do + if mapping.Replace then + g_FontReplaceMap[mapping.Replace] = nil + end + end +end + +function ModItemFont:GetAffectedResources() + if self.ReplaceMappings then + local affected_resources = {} + for _, mapping in ipairs(self.ReplaceMappings) do + if mapping.Replace and mapping.With then + table.insert(affected_resources, ModResourceFont:new({ + mod = self.mod, + Font = mapping.Replace + })) + end + end + return affected_resources + end + + return empty_table +end + +function ModItemFont:GetFilesList() + local slf = self + local files_list = {} + for _, font_asset in ipairs(self.AssetFiles) do + table.insert(files_list, font_asset.FontPath or "") + end + return files_list +end + +function ModItemFont:FindFreeFilename(name) + if name == "" then return name end + local n = 1 + local folder, file_name, ext = SplitPath(name) + local matching_digits = file_name:match("(%d*)$") + local n = matching_digits and tonumber(matching_digits) or -1 + file_name = file_name:sub(1, file_name:len() - matching_digits:len()) + while io.exists(folder .. (file_name .. tostring(n > 0 and n or "")) .. ext) do + n = n + 1 + end + return folder .. (file_name .. tostring(n > 0 and n or "")) .. ext +end + +function ModItemFont:ResolvePasteFilesConflicts() + for index, _ in ipairs(self.CopyFiles) do + self.CopyFiles[index].filename = self.CopyFiles[index].filename:gsub(self.CopyFiles.mod_content_path, self.mod.content_path) + self.CopyFiles[index].filename = self:FindFreeFilename(self.CopyFiles[index].filename) + if self.CopyFiles[index].data then + self.AssetFiles[index].FontPath = self.CopyFiles[index].filename + end + end +end + +function ModItemFont:OnAfterEditorNew(parent, ged, is_paste) + local err = AsyncCreatePath(self:GetFontTargetPath()) +end + +----- ModResourceFont - Describes a font replaced by a mod. +DefineClass.ModResourceFont = { + __parents = { "ModResourceDescriptor" }, + properties = { + { id = "Font", name = "Font name", editor = "text", default = false, }, + }, +} + +function ModResourceFont:CheckForConflict(other) + return self.Font and self.Font == other.Font +end + +function ModResourceFont:GetResourceTextDescription() + return string.format("\"%s\" font", self.Font) +end + +----- ModItemDecalEntity + +local size_items = { + { id = "Small", name = "Small (10cm x 10cm)" }, + { id = "Medium", name = "Medium (1m x 1m)" }, + { id = "Large", name = "Large (10m x 10m)" }, +} + +local decal_group_items = { + "Default", "Terrain", "TerrainOnly", "Unit", +} + +DefineClass.ModItemDecalEntity = { + __parents = { "BaseModItemEntity" }, + + EditorName = "Decal", + EditorSubmenu = "Assets", + + properties = { + { category = "Decal", id = "size", name = "Size", editor = "choice", default = "Small", items = size_items }, + { category = "Decal", id = "BaseColorMap", name = "Basecolor map", editor = "browse", os_path = true, filter = "Image files|*.png;*.tga", default = "", mtl_map = "BaseColorDecal", dont_save = true }, + { category = "Decal", id = "NormalMap", name = "Normal map", editor = "browse", os_path = true, filter = "Image files|*.png;*.tga", default = "", mtl_map = "NormalMapDecal", dont_save = true }, + { category = "Decal", id = "RMMap", name = "Roughness/metallic map", editor = "browse", os_path = true, filter = "Image files|*.png;*.tga", default = "", mtl_map = "RMDecal", dont_save = true }, + { category = "Decal", id = "AOMap", name = "Ambient occlusion map", editor = "browse", os_path = true, filter = "Image files|*.png;*.tga", default = "", mtl_map = "AODecal", dont_save = true }, + { category = "Decal", id = "TriplanarDecal", name = "Triplanar", editor = "bool", default = false, mtl_prop = true, help = "When toggled the decal is projected along every axis, not only forward." }, + { category = "Decal", id = "DoubleSidedDecal", name = "Double sided", editor = "bool", default = true, mtl_prop = true, help = "When toggled the decal can be seen from the backside as well. This is useful for objects that can be hidden, like wall slabs." }, + { category = "Decal", id = "DecalGroup", name = "Group", editor = "choice", default = "Default", items = decal_group_items, mtl_prop = true, help = "Determines what objects will have the decal projected onto.\n\nDefault - everything\nTerrain - the terrain, slabs and small terrain objects like grass, rocks and others\nTerrainOnly - only the terrain\nUnit - only units" }, + { category = "Mod", id = "entity_name", name = "Entity Name", default = "", editor = "text", untranslated = true }, + { category = "Mod", id = "buttons", editor = "buttons", default = false, buttons = {{name = "Import Decal Files", func = "Import"}, {name = "Reload entities (slow)", func = "ReloadEntities"}}, untranslated = true}, + { category = "Misc", id = "class_parent", name = "Class", editor = "combo", items = ClassDescendantsCombo("Decal", true), default = "", entitydata = true }, + }, + Documentation = "Defines decal which can be placed via the ActionFXDecal mod item.", +} + +function ModItemDecalEntity:Import(root, prop_id, ged_socket) + GedSetUiStatus("mod_import_decal", "Importing...") + + local success = self:DoImport(root, prop_id, ged_socket) + if not success then + GedSetUiStatus("mod_import_decal") + return + end + + self:OnModLoad() + WaitDelayedLoadEntities() + Msg("BinAssetsLoaded") + + GedSetUiStatus("mod_import_decal") + ged_socket:ShowMessage("Success", "Decal imported successfully!") +end + +function ModItemDecalEntity:DoImport(root, prop_id, ged_socket) + --Compose folder paths + local output_dir = ConvertToOSPath(self.mod.content_path) + local ent_dir = output_dir .. "Entities/" + local mesh_dir = ent_dir .. "Meshes/" + local mtl_dir = ent_dir .. "Materials/" + local texture_dir = ent_dir .. "Textures/" + local fallback_dir = texture_dir .. "Fallbacks/" + --Create folder structure + if not self:CreateDirectory(ged_socket, ent_dir, "Entities") then return end + if not self:CreateDirectory(ged_socket, mesh_dir, "Meshes") then return end + if not self:CreateDirectory(ged_socket, mtl_dir, "Materials") then return end + if not self:CreateDirectory(ged_socket, texture_dir, "Textures") then return end + if not self:CreateDirectory(ged_socket, fallback_dir, "Fallbacks") then return end + + --Process images + for i,prop_meta in ipairs(self:GetProperties()) do + if prop_meta.mtl_map then + local path = self:GetProperty(prop_meta.id) + if path ~= "" then + if not self:ImportImage(ged_socket, prop_meta.id, texture_dir, fallback_dir) then + return + end + end + end + end + + --Compose file names + local ent_file = self.entity_name .. ".ent" + local ent_output = ent_dir .. ent_file + + local mtl_file = self.entity_name .. "_mesh.mtl" + local mtl_output = mtl_dir .. mtl_file + + local mesh_file = self.entity_name .. "_mesh.hgm" + local mesh_output = mesh_dir .. mesh_file + + --Create the entity file + if not self:CreateEntityFile(ged_socket, ent_output, mesh_file, mtl_file) then + return + end + + --Create the material file + if not self:CreateMtlFile(ged_socket, mtl_output) then + return + end + + --Create the mesh file + if not self:CreateMeshFile(ged_socket, mesh_output) then + return + end + + return true +end + +function ModItemDecalEntity:CreateDirectory(ged_socket, path, name) + local err = AsyncCreatePath(path) + if err then + ged_socket:ShowMessage("Failed importing decal", string.format("Failed creating %s directory: %s.", name, err)) + return + end + + return true +end + +function ModItemDecalEntity:GetTextureFileName(prop_id, extension) + return string.format("mod_%s_%s%s", prop_id, self.entity_name, extension) +end + +function ModItemDecalEntity:ValidateImage(prop_id, ged_socket) + local path = self:GetProperty(prop_id) + if not io.exists(path) then + local prop_name = self:GetPropertyMetadata(prop_id).name + ged_socket:ShowMessage("Failed importing decal", string.format("Import failed - the %s image was not found.", prop_name)) + return + end + + local w, h = UIL.MeasureImage(path) + if w ~= h then + local prop_name = self:GetPropertyMetadata(prop_id).name + ged_socket:ShowMessage("Failed importing decal", string.format("The import failed because the %s image width and height are wrong. Image must be a square and pixel width and height must be power of two (e.g. 1024, 2048, 4096, etc.).", prop_name)) + return + end + + if w <= 0 or band(w, w - 1) ~= 0 then --check if there's only one bit set in the entire number (equivalent to it being a power of 2) + local prop_name = self:GetPropertyMetadata(prop_id).name + ged_socket:ShowMessage("Failed importing decal", string.format("The import failed because the %s image width and height are wrong. Image must be a square and pixel width and height must be power of two (e.g. 1024, 2048, 4096, etc.).", prop_name)) + return + end + + return true +end + +function ModItemDecalEntity:ImportImage(ged_socket, prop_id, texture_dir, fallback_dir) + if not self:ValidateImage(prop_id, ged_socket) then + return + end + + local path = self:GetProperty(prop_id) + local texture_name = self:GetTextureFileName(prop_id, ".dds") + + -- Create the compressed textures that we will use in the game from the uncompressed one provided by the mod + local texture_output = texture_dir .. texture_name + local cmdline = string.format("\"%s\" -dds10 -24 bc1 -32 bc3 -srgb \"%s\" \"%s\"", ConvertToOSPath(g_HgnvCompressPath), path, texture_output) + local err = AsyncExec(cmdline, "", true, false) + if err then + ged_socket:ShowMessage("Failed importing decal", string.format("Failed creating compressed image: .", err)) + return + end + + -- Create the fallback for the compressed texture + local fallback_output = fallback_dir .. texture_name + cmdline = string.format("\"%s\" \"%s\" \"%s\" --truncate %d", ConvertToOSPath(g_HgimgcvtPath), texture_output, fallback_output, const.FallbackSize) + local err = AsyncExec(cmdline, "", true, false) + if err then + ged_socket:ShowMessage("Failed importing decal", string.format("Failed creating fallback image: %s.", err)) + return + end + + return true +end + +function ModItemDecalEntity:CreateEntityFile(ged_socket, ent_path, mesh_file, mtl_file) + local placeholder_entity = string.format("DecMod_%s", self.size) + local bbox = GetEntityBoundingBox(placeholder_entity) + local bbox_min_str = string.format("%d,%d,%d", bbox:minxyz()) + local bbox_max_str = string.format("%d,%d,%d", bbox:maxxyz()) + local bcenter, bradius = GetEntityBoundingSphere(placeholder_entity) + local bcenter_str = string.format("%d,%d,%d", bcenter:xyz()) + local lines = { + '', + '', + '\t', + '\t\t', + '\t', + '\t', + '\t\t', + string.format('\t\t', mesh_file), + string.format('\t\t', mtl_file), + string.format('\t\t', bcenter_str, bradius), + string.format('\t\t', bbox_min_str, bbox_max_str), + '\t', + '', + } + + local content = table.concat(lines, "\n") + local err = AsyncStringToFile(ent_path, content) + if err then + ged_socket:ShowMessage("Failed importing decal", string.format("Failed creating entity file: %s.", err)) + return + end + + return true +end + +function ModItemDecalEntity:CreateMtlFile(ged_socket, mtl_path) + --prepare properties + local mtl_props = { + AlphaTestValue = 128, + BlendType = "Blend", + CastShadow = false, + SpecialType = "Decal", + Deposition = false, + TerrainDistortedMesh = false, + } + for i,prop_meta in ipairs(self:GetProperties()) do + local id = prop_meta.id + if prop_meta.mtl_map then + local path = self:GetProperty(id) + mtl_props[prop_meta.mtl_map] = io.exists(path) + elseif prop_meta.mtl_prop then + mtl_props[id] = self:GetProperty(id) + end + end + + local lines = { + '', + '', + '\t', + } + --insert maps + for i,prop_meta in ipairs(self:GetProperties()) do + local id = prop_meta.id + if prop_meta.mtl_map and mtl_props[prop_meta.mtl_map] then + local path = self:GetTextureFileName(id, ".dds") + table.insert(lines, string.format('\t\t<%s Name="%s" mc="0"/>', id, path)) + end + end + --insert properties + for id,value in sorted_pairs(mtl_props) do + local value_type, value_str = type(value), "" + if value_type == "boolean" then + value_str = value and "1" or "0" + else + value_str = tostring(value) + end + table.insert(lines, string.format('\t\t', id, value_str)) + end + table.insert(lines, '\t') + table.insert(lines, '') + + local content = table.concat(lines, "\n") + local err = AsyncStringToFile(mtl_path, content) + if err then + ged_socket:ShowMessage("Failed importing decal", string.format("Failed creating material file: .", err)) + return + end + + return true +end + +function ModItemDecalEntity:CreateMeshFile(ged_socket, hgm_path) + local placeholder_entity = string.format("DecMod_%s", self.size) + local placeholder_file = placeholder_entity .. "_mesh.hgm" + local placeholder_path = "Meshes/" .. placeholder_file + + local err = AsyncCopyFile(placeholder_path, hgm_path) + if err then + ged_socket:ShowMessage("Failed importing decal", string.format("Could not create a mesh file: %s.", err)) + return + end + + return true +end + +----- ModItemGameValue + +DefineClass.ModItemGameValue = { + __parents = { "ModItem" }, + properties = { + { id = "name", default = false, editor = false, }, + { category = "GameValue", id = "category", name = "Category", default = "Gameplay", editor = "choice", + items = ClassCategoriesCombo("Consts")}, + { category = "GameValue", id = "id", name = "ID", default = "", editor = "choice", + items = ClassPropertiesCombo("Consts", "category", "") }, + { category = "GameValue", id = "const_name", name = "Name", default = "", editor = "text", read_only = true, dont_save = true}, + { category = "GameValue", id = "help", name = "Help", default = "", editor = "text", read_only = true, dont_save = true}, + { category = "GameValue", id = "default_value", name = "Default value", default = 0, editor = "number", read_only = true, dont_save = true}, + { category = "GameValue", id = "percent", name = "Percent", default = 0, editor = "number",}, + { category = "GameValue", id = "amount", name = "Amount", default = 0, editor = "number",}, + { category = "GameValue", id = "modified_value",name = "Modified value",default = 0, editor = "number", read_only = true, dont_save = true}, + }, + EditorName = "Game value", + EditorSubmenu = "Gameplay", + is_data = true, +} + +function ModItemGameValue:Getconst_name() + local metadata = Consts:GetPropertyMetadata(self.id) + return _InternalTranslate(metadata and metadata.name or "") +end + +function ModItemGameValue:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "category" then + self.id = "" + end + ModItem.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +function ModItemGameValue:GetProperties() + local properties = {} + for _, prop_meta in ipairs(self.properties) do + local prop_id = prop_meta.id + if prop_id == "default_value" or prop_id == "amount" or prop_id == "modified_value" then + local const_meta = Consts:GetPropertyMetadata(self.id) + if const_meta then + prop_meta = table.copy(prop_meta) + prop_meta.scale = const_meta.scale + end + end + properties[#properties + 1] = prop_meta + end + return properties +end + +function ModItemGameValue:Gethelp() + local metadata = Consts:GetPropertyMetadata(self.id) + return _InternalTranslate(metadata and metadata.help or "") +end + +function ModItemGameValue:Getdefault_value() + return Consts:GetDefaultPropertyValue(self.id) or 0 +end + +function ModItemGameValue:Getmodified_value() + local default_value = Consts:GetDefaultPropertyValue(self.id) or 0 + return MulDivRound(default_value, self.percent + 100, 100) + self.amount +end + +function ModItemGameValue:ResetProperties() + self.id = self:GetDefaultPropertyValue("id") + self.const_name = self:GetDefaultPropertyValue("const_name") + self.help = self:GetDefaultPropertyValue("help") + self.default_value = self:GetDefaultPropertyValue("default_value") + self.modified_value = self:GetDefaultPropertyValue("modified_value") +end + +function ModItemGameValue:GetModItemDescription() + if self.id == "" then return "" end + local pct = self.percent ~= 0 and string.format(" %+d%%", self.percent) or "" + local const_meta = Consts:GetPropertyMetadata(self.id) + local prefix = self.amount > 0 and "+" or "" + local amount = self.amount ~= 0 and prefix .. FormatNumberProp(self.amount, const_meta.scale) or "" + return Untranslated(string.format("%s.%s %s %s", self.category, self.id, pct, amount)) +end + +function GenerateGameValueDoc() + if not g_Classes.Consts then return end + local output = {} + local categories = ClassCategoriesCombo("Consts")() + local out = function(...) + output[#output+1] = string.format(...) + end + local props = Consts:GetProperties() + for _, category in ipairs(categories) do + out("## %s", category) + for _, prop in ipairs(props) do + if prop.category == category then + out("%s\n:\t*g_Consts.%s*
\n\t%s\n", _InternalTranslate(prop.name), prop.id, _InternalTranslate(prop.help or prop.name)) + end + end + end + local err, suffix = AsyncFileToString("svnProject/Docs/ModTools/empty.md.html") + if err then return err end + output[#output+1] = suffix + AsyncStringToFile("svnProject/Docs/ModTools/ModItemGameValue_list.md.html", table.concat(output, "\n")) +end + +---- + +function GenerateObjectDocs(base_class) + -- extract list of classes + local list = ClassDescendantsList(base_class) + + -- generate documentation using the classes' Documentation property and property metadata + local output = { string.format("# Documentation for *%s objects*\n", base_class) } + local base_props = g_Classes[base_class]:GetProperties() + + local hidden_dlc_list = {} + ForEachPreset("DLCConfig", function(p) + if not p.public then hidden_dlc_list[p.id] = true end + end) + + local hidden_classdef_list = {} + ForEachPreset(base_class.."Def", function(p) + local save_in = p:HasMember("save_in") and p.save_in or nil + if save_in and hidden_dlc_list[save_in] then + hidden_classdef_list[p.id] = p.save_in + end + end) + + for _, name in ipairs(list) do + local class = g_Classes[name] + if class:HasMember("Documentation") and class.Documentation and not hidden_classdef_list[name] then + output[#output+1] = string.format("## %s\n", name) + output[#output+1] = class.Documentation + for _, prop in ipairs(class:GetProperties()) do + if not table.find(base_props, "id", prop.id) then + if prop.help and prop.help ~= "" then + output[#output+1] = string.format("%s\n: %s\n", prop.name or prop.id, prop.help) + else + --print("Missing documentation for property", prop.name or prop.id, "of class", name) + end + end + end + else + --print("Missing documentation for class", name) + end + end + + OutputDocsFile(string.format("Lua%sDoc.md.html", base_class), output) +end + +if config.RunUnpacked and Platform.developer then + -- Auto-generated docs for effects, conditions, etc. + function OnMsg.PresetSave(class) + local classdef = g_Classes[class] + if IsKindOf(classdef, "ClassDef") then + GenerateObjectDocs("Effect") + GenerateObjectDocs("Condition") + elseif IsKindOf(classdef, "ConstDef") then + GenerateGameValueDoc() + end + end +end + +function GetAllLanguages() + local languages = table.copy(AllLanguages, "deep") + table.insert(languages, 1, { value = "Any", text = "Any", iso_639_1 = "en" }) + return languages +end + +DefineClass.ModItemLocTable = { + __parents = { "ModItem" }, + properties = { + { id = "name", default = false, editor = false }, + { category = "Mod", id = "language", name = "Language", default = "Any", editor = "dropdownlist", items = GetAllLanguages() }, + { category = "Mod", id = "filename", name = "Filename", editor = "browse", filter = "Comma separated values (*.csv)|*.csv", default = "" }, + }, + EditorName = "Localization", + EditorSubmenu = "Assets", + ModItemDescription = T(677060079613, ""), + Documentation = "Adds translation tables to localize the game in other languages.", + DocumentationLink = "Docs/ModItemLocTable.md.html", + TestDescription = "Loads the translation table." +} + +function ModItemLocTable:OnModLoad() + ModItem.OnModLoad(self) + if self.language == GetLanguage() or self.language == "Any" then + if io.exists(self.filename) then + LoadTranslationTableFile(self.filename) + Msg("TranslationChanged") + end + end +end + +function ModItemLocTable:TestModItem() + if io.exists(self.filename) then + LoadTranslationTableFile(self.filename) + Msg("TranslationChanged") + end +end + + +----- XTemplate + +DefineModItemPreset("XTemplate", { + GetSaveFolder = function() end, + EditorName = "UI Template (XTemplate)", + EditorSubmenu = "Other", + TestDescription = "Opens a preview of the template." +}) + +function ModItemXTemplate:TestModItem(ged) + GedOpPreviewXTemplate(ged, self, false) +end + +function ModItemXTemplate:GetSaveFolder(...) + return ModItemPreset.GetSaveFolder(self, ...) +end + +function ModItemXTemplate:GetSavePath(...) + return ModItemPreset.GetSavePath(self, ...) +end + +------ SoundPreset + +DefineModItemPreset("SoundPreset", { + EditorName = "Sound", + EditorSubmenu = "Other", + Documentation = "Defines sound presets which can be played via the ActionFXSound mod item.", + DocumentationLink = "Docs/ModItemSound.md.html", + TestDescription = "Plays the sound file." +}) + +function ModItemSoundPreset:GetSoundFiles() + local file_paths = SoundPreset.GetSoundFiles(self) + for _, sound_file in ipairs(self) do + local sound_path = sound_file:Getpath() + if io.exists(sound_path) then + file_paths[sound_path] = true + end + end + return file_paths +end + +function ModItemSoundPreset:OverrideSampleFuncs(sample) + sample.GetFileFilter = function() + return "Sample File|*.opus;*.wav" + end + sample.Setpath = function(obj, path) + sample.file = path + end + sample.Getpath = function() + if sample.file == "" then + return ".opus" + elseif string.ends_with(sample.file, ".opus") or string.ends_with(sample.file, ".wav") then + return sample.file + else + return sample.file .. ".wav" + end + end + sample.GetFileExt = function() + return "opus" + end +end + +function ModItemSoundPreset:OnModLoad() + --replace child funcs + for _, sample in ipairs(self or empty_table) do + if IsKindOf(sample, "SoundFile") then + self:OverrideSampleFuncs(sample) + end + end + ModItemPreset.OnModLoad(self) + LoadSoundBank(self) +end + +function ModItemSoundPreset:TestModItem(ged) + LoadSoundBank(self) + GedPlaySoundPreset(ged) +end + +function ModItemSoundPreset:GenerateUniquePresetId() + return SoundPreset.GenerateUniquePresetId(self, "Sound") +end + +------ ActionFX + +local function GenerateUniqueActionFXHandle(mod_item) + local mod_id = mod_item.mod.id + local index = mod_item.mod:FindModItem(mod_item) + while true do + local handle = string.format("%s_%d", mod_id, index) + local any_collisions + mod_item.mod:ForEachModItem("ActionFX", function(other_item) + if other_item ~= mod_item then + if other_item.handle == handle then + any_collisions = true + return "break" + end + end + end) + if not any_collisions then + return handle + else + index = index + 1 + end + end +end + +local function DefineModItemActionFX(preset, editor_name) + local actionfx_mod_class = DefineModItemPreset(preset, { + EditorName = editor_name, + EditorSubmenu = "ActionFX", + EditorShortcut = false, + TestDescription = "Plays the fx with actor and target the selected merc." + }) + + actionfx_mod_class.SetId = function(self, id) + self.handle = id + return Preset.SetId(self, id) + end + + actionfx_mod_class.GetSavePath = function(self, ...) + return ModItemPreset.GetSavePath(self, ...) + end + + actionfx_mod_class.delete = function(self) + return g_Classes[self.ModdedPresetClass].delete(self) + end + + actionfx_mod_class.TestModItem = function(self, ged) + PlayFX(self.Action, self.Moment, SelectedObj, SelectedObj) + end + + actionfx_mod_class.PreSave = function(self) + if not self.handle and self.mod then + if self.id == "" then + self:SetId(self.mod:GenerateModItemId(self)) + end + self.handle = self.id + end + return ModItemPreset.PreSave(self) + end + + local properties = actionfx_mod_class.properties or {} + table.iappend(properties, { + { id = "__copy_group" }, + { id = "__copy" }, + }) + + actionfx_mod_class.ModItemDescription = function (self) + return self:DescribeForEditor() + end +end + +DefineModItemActionFX("ActionFXSound", "ActionFX Sound") +DefineModItemActionFX("ActionFXObject", "ActionFX Object") +DefineModItemActionFX("ActionFXDecal", "ActionFX Decal") +DefineModItemActionFX("ActionFXLight", "ActionFX Light") +DefineModItemActionFX("ActionFXColorization", "ActionFX Colorization") +DefineModItemActionFX("ActionFXParticles", "ActionFX Particles") +DefineModItemActionFX("ActionFXRemove", "ActionFX Remove") + +function OnMsg.ModsReloaded() + RebuildFXRules() +end + +------ Particles + +DefineClass.ModItemParticleTexture = { + __parents = { "ModItem" }, + + properties ={ + { category = "Texture", id = "import", name = "Import", editor = "browse", os_path = true, filter = "Image files|*.png;*.tga", default = "", dont_save = true }, + { category = "Texture", id = "btn", editor = "buttons", default = false, buttons = {{name = "Import Particle Texture", func = "Import"}}, untranslated = true}, + }, +} + +function ModItemParticleTexture:Import(root, prop_id, ged_socket) + GedSetUiStatus("mod_import_particle_texture", "Importing...") + + local output_dir = ConvertToOSPath(self.mod.content_path) + + local w, h = UIL.MeasureImage(self.import) + if w ~= h then + assert(false, "Image is not square") + ged_socket:ShowMessage("Failed importing texture", "The import failed because the image width and height are wrong. Image must be a square and pixel width and height must be power of two (e.g. 1024, 2048, 4096, etc.).") + GedSetUiStatus("mod_import_particle_texture") + return + end + + if w <= 0 or band(w, w - 1) ~= 0 then --check if there's only one bit set in the entire number (equivalent to it being a power of 2) + assert(false, "Image sizes are not power of 2") + ged_socket:ShowMessage("Failed importing texture", "The import failed because the image width and height are wrong. Image must be a square and pixel width and height must be power of two (e.g. 1024, 2048, 4096, etc.).") + GedSetUiStatus("mod_import_particle_texture") + return + end + + -- use the same file name, just add the .dds extension + local dir, name, ext = SplitPath(self.import) + local texture_name = name .. ".dds" + local texture_dir = output_dir .. GetModAssetDestFolder("Particle Texture") + local texture_output = texture_dir .. "/" .. texture_name + local fallback_dir = texture_dir .. "Fallbacks/" + local fallback_output = fallback_dir .. texture_name + + local err = AsyncCreatePath(texture_dir) + if err then + assert(false, "Failed to create mod textures dir") + ged_socket:ShowMessage("Failed importing texture", string.format("Failed creating %s directory: %s.", "Textures", err)) + GedSetUiStatus("mod_import_particle_texture") + return + end + + err = AsyncCreatePath(fallback_dir) + if err then + assert(false, "Failed to create mod fallback dir") + ged_socket:ShowMessage("Failed importing texture", string.format("Failed creating %s directory: %s.", "Fallbacks", err)) + GedSetUiStatus("mod_import_particle_texture") + return + end + + -- Create the compressed textures that we will use in the game from the uncompressed one provided by the mod + local cmdline = string.format("\"%s\" -dds10 -24 bc1 -32 bc3 -srgb \"%s\" \"%s\"", ConvertToOSPath(g_HgnvCompressPath), self.import, texture_output) + local err, out = AsyncExec(cmdline, "", true, false) + if err then + assert(false, "Failed to create compressed texture image") + ged_socket:ShowMessage("Failed importing texture", string.format("Failed creating compressed image: %s.", err)) + GedSetUiStatus("mod_import_particle_texture") + return + end + + -- Create the fallback for the compressed texture + cmdline = string.format("\"%s\" \"%s\" \"%s\" --truncate %d", ConvertToOSPath(g_HgimgcvtPath), texture_output, fallback_output, const.FallbackSize) + err = AsyncExec(cmdline, "", true, false) + if err then + assert(false, "Failed to create texture fallback") + ged_socket:ShowMessage("Failed importing texture", string.format("Failed creating fallback image: %s.", err)) + GedSetUiStatus("mod_import_particle_texture") + return + end + + self:OnModLoad() + + GedSetUiStatus("mod_import_particle_texture") + ged_socket:ShowMessage("Success", "Texture imported successfully!") +end + +DefineModItemPreset("ParticleSystemPreset", { + properties = { + { id = "ui", name = "UI Particle System" , editor = "bool", default = false, no_edit = true, }, + { id = "saving", editor = "bool", default = false, dont_save = true, no_edit = true, }, + }, + EditorName = "Particle system", + EditorSubmenu = "Other", + Documentation = "Creates a new particle system and defines its parameters.", + TestDescription = "Places the particle system on the screen center." +}) + +function ModItemParticleSystemPreset:GetTextureBasePath() + return "" +end + +function ModItemParticleSystemPreset:GetTextureTargetPath() + return "" +end + +function ModItemParticleSystemPreset:GetTextureTargetGamePath() + return "" +end + +function ModItemParticleSystemPreset:IsDirty() + return ModItemPreset.IsDirty(self) +end + +function ModItemParticleSystemPreset:PreSave() + self.saving = true + ModItem.PreSave(self) +end + +function ModItemParticleSystemPreset:PostSave(...) + self.saving = false + ModItem.PostSave(self, ...) + ParticlesReload(self:GetId()) +end + +function ModItemParticleSystemPreset:PostLoad() + ParticleSystemPreset.PostLoad(self) + ParticlesReload(self:GetId()) +end + +function ModItemParticleSystemPreset:OverrideEmitterFuncs(emitter) + emitter.GetTextureFilter = function() + return "Texture (*.dds)|*.dds" + end + + emitter.GetNormalmapFilter = function() + return "Texture (*.dds)|*.dds" + end + + emitter.ShouldNormalizeTexturePath = function() + return not self.saving + end +end + +function ModItemParticleSystemPreset:GetTextureFolders() + return { "Textures/Particles" } +end + +function ModItemParticleSystemPreset:OnModLoad() + -- replace child funcs + self.saving = nil + for _, child in ipairs(self) do + if IsKindOf(child, "ParticleEmitter") then + self:OverrideEmitterFuncs(child) + end + end + ModItemPreset.OnModLoad(self) +end + +function ModItemParticleSystemPreset:Getname() + return ModItemPreset.Getname(self) +end + +function ModItemParticleSystemPreset:EditorItemsMenu() + -- remove particle params from subitems + local items = Preset.EditorItemsMenu(self) + local idx = table.find(items, 1, "ParticleParam") + if idx then + table.remove(items, idx) + end + return items +end + +if FirstLoad then + TestParticleSystem = false +end + +function ModItemParticleSystemPreset:TestModItem() + if IsValid(TestParticleSystem) then + DoneObject(TestParticleSystem) + end + TestParticleSystem = PlaceParticles(self.id) + TestParticleSystem:SetPos(GetTerrainGamepadCursor()) -- place at screen center +end + +---- + +DefineModItemPreset("ConstDef", { EditorName = "Constant", EditorSubmenu = "Gameplay" }) + +function OnMsg.ClassesPostprocess() + local idx = table.find(ModItemConstDef.properties, "id", "Group") + local prop = table.copy(ModItemConstDef.properties[idx]) + prop.name = "group" -- match the other props case + prop.category = nil -- "Misc" category + table.remove(ModItemConstDef.properties, idx) + table.insert(ModItemConstDef.properties, table.find(ModItemConstDef.properties, "id", "type"), prop) +end + +ModItemConstDef.GetSavePath = ModItemPreset.GetSavePath +ModItemConstDef.GetSaveData = ModItemPreset.GetSaveData + +function ModItemConstDef:AssignToConsts() + local self_group = self.group or "Default" + assert(self.group ~= "") + local const_group = self_group == "Default" and const or const[self_group] + if not const_group then + const_group = {} + const[self_group] = const_group + end + local value = self.value + if value == nil then + value = ConstDef:GetDefaultValueOf(self.type) + end + local id = self.id or "" + if id == "" then + const_group[#const_group + 1] = value + else + const_group[id] = value + end +end + +function ModItemConstDef:PostSave(...) + self:AssignToConsts() + return ModItemPreset.PostSave(self, ...) +end + +function ModItemConstDef:OnModLoad() + ModItemPreset.OnModLoad(self) + self:AssignToConsts() +end + +function TryGetModDefFromObj(obj) + if IsKindOf(obj, "ModDef") then + return obj + elseif IsKindOf(obj, "ModItem") then + return obj.mod + else + local mod_item_parent = GetParentTableOfKindNoCheck(obj, "ModItem") + return mod_item_parent and mod_item_parent.mod + end +end + + +----- ModItemChangeProp + +local forbidden_classes = { + Achievement = true, + PlayStationActivities = true, + RichPresence = true, + TrophyGroup = true, +} + +local function GlobalPresetClasses(self) + local items = {} + local classes = g_Classes + for name in pairs(Presets) do + if not forbidden_classes[name] then + local classdef = classes[name] + if classdef and classdef.GlobalMap then + items[#items + 1] = name + end + end + end + table.sort(items) + table.insert(items, 1, "") + return items +end + +local forbidden_props = { + Id = true, + Group = true, + Comment = true, + TODO = true, + SaveIn = true, +} + +local function PresetPropsCombo(self) + local preset = self:ResolveTargetPreset() + local props = preset and preset:GetProperties() + local items = {} + for _, prop in ipairs(props) do + if not forbidden_props[prop.id] and + not prop_eval(prop.dont_save, preset, prop) and + not prop_eval(prop.no_edit , preset, prop) and + not prop_eval(prop.read_only, preset, prop) + then + items[#items + 1] = prop.id + end + end + table.sort(items) + table.insert(items, 1, "") + return items +end + +if FirstLoad then + ModItemChangeProp_OrigValues = {} +end +local orig_values = ModItemChangeProp_OrigValues + +local function TargetFuncDefault(self, value, default) + return value +end + +local CanAppendToTableListProps = { + dropdownlist = true, + string_list = true, + preset_id_list = true, + number_list = true, + point_list = true, + T_list = true, + nested_list = true, + property_array = true, +} + +local function CanAppendToTable(prop) + return CanAppendToTableListProps[prop.editor] +end + +DefineClass.ModItemChangePropBase = { + __parents = { "ModItem" }, + properties = { + { category = "Change Property", id = "TargetClass", name = "Class", default = "", editor = "choice", items = GlobalPresetClasses, reapply = true }, + { category = "Change Property", id = "TargetId", name = "Preset", default = "", editor = "choice", items = function(self, prop_meta) return PresetsCombo(self.TargetClass)(self, prop_meta) end, no_edit = function(self) return not self:ResolveTargetMap() end, reapply = true }, + { category = "Change Property", id = "TargetProp", name = "Property", default = "", editor = "choice", items = PresetPropsCombo, no_edit = function(self) return not self:ResolveTargetPreset() end, reapply = true }, + { category = "Change Property", id = "OriginalValue", name = "Original Value", default = false, editor = "bool", no_edit = function(self) return not self:ResolvePropTarget() end, dont_save = true, read_only = true, untranslated = true }, + { category = "Change Property", id = "EditType", name = "Edit Type", default = "Replace", editor = "dropdownlist", no_edit = function(self) return not self:ResolvePropTarget() end, help = " completely overwrites property.\n adds new entries while keeping existing ones.\n modifies the value by using code. Requires Lua knowledge.", reapply = true, + items = function(self) + local options = {"Replace", "Code"} + local propTarget = self:ResolvePropTarget() + if CanAppendToTable(propTarget) then + table.insert(options, "Append To Table") + end + return options + end + }, + { category = "Change Property", id = "TargetValue", name = "Value", default = false, editor = "bool", no_edit = function(self) return not self:ResolvePropTarget() or self.EditType == "Code" end, dont_save = function(self) return self.EditType == "Code" end, untranslated = true }, + { category = "Change Property", id = "TargetFunc", name = "Change", default = TargetFuncDefault, editor = "func", params = "self, value, default", no_edit = function(self) return not self:ResolvePropTarget() or self.EditType ~= "Code" end, help = "Property change code. The expression parameters are the mod item, the current property value and the original property value. The current property value may differ from the original one in the presence of other mods, tweaking the same property." }, + }, + + Documentation = "Changes a specific preset property value. Clicking the test button will apply the change.\n\nIf more than one mod changes the same preset property, the one that has loaded last will decide the final value. Defining the dependency list for a mod will ensure the load order of given mods and allow for some control over this problem.", + EditorName = "Change property", + EditorSubmenu = "Gameplay", + tweaked_values = false, + is_data = true, + TestDescription = "Applies the change to the selected preset." +} + +DefineClass("ModItemChangeProp", "ModItemChangePropBase") + +function ModItemChangePropBase:OnEditorNew(mod, ged, is_paste) + self.name = "ChangeProperty" +end + +function ModItemChangePropBase:GetModItemDescription() + if self.name == "" then + return Untranslated(".") + end + return self.ModItemDescription +end + +function ModItemChangePropBase:ResolveTargetMap() + local classdef = g_Classes[self.TargetClass] + local name = classdef and classdef.GlobalMap + return name and _G[name] +end + +function ModItemChangePropBase:ResolveTargetPreset() + local map = self:ResolveTargetMap() + return map and map[self.TargetId] +end + +function ModItemChangePropBase:ResolvePropTarget() + local preset = self:ResolveTargetPreset() + local props = preset and preset:GetProperties() + return props and table.find_value(props, "id", self.TargetProp) +end + +function ModItemChangePropBase:SetTargetValue(value) + self.tweaked_values = self.tweaked_values or {} + table.set(self.tweaked_values, self.TargetClass, self.TargetId, self.TargetProp, value) +end + +function ModItemChangePropBase:GetChangedValue() + return table.get(self.tweaked_values, self.TargetClass, self.TargetId, self.TargetProp) +end + +function ModItemChangePropBase:GetPropValue() + local preset = self:ResolveTargetPreset() + local prop = preset and self:ResolvePropTarget() + if prop then + local value = preset:GetProperty(self.TargetProp) + return preset:ClonePropertyValue(value, prop) + end +end + +function ModItemChangePropBase:GetTargetValue() + local value = self:GetChangedValue() + if value ~= nil then + return value + end + if self.EditType == "Append To Table" then + return false + end + return self:GetPropValue() +end + +function ModItemChangePropBase:GetOriginalValue() + local orig_value = table.get(orig_values, self.TargetClass, self.TargetId, self.TargetProp) + if orig_value ~= nil then + return orig_value + end + return self:GetPropValue() +end + +function ModItemChangePropBase:OverwriteProp(prop_id, props, prop) + local my_prop = table.find_value(props, "id", prop_id) + local keep = { + id = my_prop.id, + name = my_prop.name, + category = my_prop.category, + no_edit = my_prop.no_edit, + dont_save = my_prop.dont_save, + read_only = my_prop.read_only, + os_path = my_prop.os_path, + } + table.clear(my_prop) + table.overwrite(my_prop, prop) + table.overwrite(my_prop, keep) +end + +function ModItemChangePropBase:GetProperties() + local props = ModItem.GetProperties(self) + local prop = self:ResolvePropTarget() + if prop then + if prop.category == const.ComponentsPropCategory then + -- disallow changing CompositeDef components via ModItemChangeProp + local help_prop = table.find_value(props, "id", "TargetValue") + table.clear(help_prop) + table.overwrite(help_prop, { + category = "Mod", id = "TargetValue", editor = "help", + help = "
into the mod folder. This copies them inside the mod folder and converts them to the specific format that the engine uses.\n\nNOTE: You need only one of these per asset type. Importing before executing on all files in the Source Folder.", + + properties = { + { id = "assetType", name = "Asset Type", editor = "dropdownlist", default = "UI image", help = "Depending on this choice the import will convert the source files to the correct format for the game engine.", + items = { "UI image", "Particle Texture", "Sound"}, arbitrary_value = false, + }, + { id = "allowedExt", name = "Allowed Extensions", read_only = true, editor = "text", default = ""}, + { id = "srcFolder", name = "Source Folder", editor = "browse", os_path = true, filter = "folder", default = "", dont_save = true, help = "The folder from which ALL assets of the selected type will be imported.\n Do not give a path inside the mod itself as the import will already copy the files in the mod." }, + { id = "destFolder", name = "Destination Folder", editor = "browse", filter = "folder", default = "", help = "The folder inside the mod in which the imported assets will be placed." }, + { id = "btn", editor = "buttons", default = false, buttons = {{name = "Convert & Import Files From Source Folder", func = "Import"}}, untranslated = true}, + }, +} + +function ModItemConvertAsset:Import(root, prop_id, ged_socket) + CreateRealTimeThread(function() + if self:GetError() then + ged_socket:ShowMessage("Fail", "Please resolve the displayed error before importing assets!") + return + end + + local assetType = self.assetType + local srcFolderOs = self.srcFolder + local destFolderOS = ConvertToOSPath(self.destFolder) + + if ged_socket:WaitQuestion("Warning", string.format("Before the import, this will all content in the Destination Folder: \n\nAre you sure you want to continue?", destFolderOS), "Yes", "No") ~= "ok" then + return + end + + GedSetUiStatus("importAssets", "Importing...") + local importedFiles = 0 + AsyncDeletePath(destFolderOS) + local err, partialSuccess = ModAssetTypeInfo[assetType].importFunc(srcFolderOs, destFolderOS, ModAssetTypeInfo[assetType], ged_socket, importedFiles) + + if not next(err) then + ged_socket:ShowMessage("Success", "Files imported successfully!") + else + local allErrors = table.concat(err, "\n") + if partialSuccess then + ged_socket:ShowMessage("Partial Success", string.format("Couldn't import all assets:\n\n%s", allErrors)) + else + ged_socket:ShowMessage("Fail", string.format("Couldn't import assets:\n\n%s", allErrors)) + end + end + + self:OnModLoad() + + GedSetUiStatus("importAssets") + end) +end + +function ModItemConvertAsset:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "assetType" and self.assetType then + self.destFolder = self.mod.content_path .. ModAssetTypeInfo[self.assetType].folder + self.allowedExt = table.concat(ModAssetTypeInfo[self.assetType].ext, " | ") + end + if not self.assetType then + self.allowedExt = "" + end + + return ModItemPreset.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +function ModItemConvertAsset:OnEditorNew(mod, ged, is_paste) + self.name = "ConvertAsset" + self.destFolder = self.mod.content_path .. ModAssetTypeInfo[self.assetType].folder + self.allowedExt = table.concat(ModAssetTypeInfo[self.assetType].ext, " | ") +end + +function ModItemConvertAsset:ModItemDescription() + return Untranslated(self.assetType .. " - " .. self.name) +end + +function ModItemConvertAsset:GetWarning() + if self.srcFolder ~= "" then + local srcFolderFullPath = ConvertToOSPath(self.srcFolder) + local modFolderFullPath = ConvertToOSPath(self.mod.content_path) + if string.starts_with(srcFolderFullPath, modFolderFullPath, true) then + return "Source Folder should not point to a location inside the mod itself." + end + end + + if not self.mod then return end + return self.mod:ForEachModItem("ModItemConvertAsset", function(item) + if item ~= self and self.assetType == item.assetType then + return "Having more than one Convert Asset mod item for the same asset type is not recommended as both overwrite the destination folder." + end + end) +end + +function ModItemConvertAsset:GetError() + if not self.name or self.name == "" then + return "Set a name for the mod item." + end + + if self.name and self.mod then + local ret = self.mod:ForEachModItem("ModItemConvertAsset", function(item) + if item ~= self then + if item.name == self.name then + return string.format("The name of the mod item '%s' must be unique!", self.name) + end + end + end) + if ret then return ret end + end + + if not self.assetType then + return "Pick an asset type." + end + + if not self.srcFolder or self.srcFolder == "" then + return "Pick source folder path." + end + + if not io.exists(self.srcFolder) then + return "Source folder does not exist." + end +end + +----- ModResourceMap - Describes a map affected by a mod. +----- The map can be changed by a map patch, which adds/changes/deletes objects and terrain or it can be replaced entirely. +----- If it's replaced entirely, only the Map property will be set. +DefineClass.ModResourceMap = { + __parents = { "ModResourceDescriptor" }, + properties = { + { id = "Map", name = "Map", editor = "text", default = false, }, + { id = "Objects", name = "Objects", editor = "prop_table", default = false, }, + { id = "Grids", name = "Grids", editor = "prop_table", default = false, }, + { id = "ObjBoxes", name = "Object Boxes", editor = "prop_table", default = false, }, + }, +} + +function ModResourceMap:CheckForConflict(other) + if self.Map ~= other.Map then return false end + + if self.Objects and other.Objects then + for _, hash in ipairs(self.Objects) do + for _, o_hash in ipairs(other.Objects) do + if hash == o_hash then + return true, "objects" + end + end + end + end + + if self.Grids and other.Grids then + for _, grid in ipairs(editor.GetGridNames()) do + if self.Grids[grid] and other.Grids[grid] then + local w, h = IntersectSize(self.Grids[grid], other.Grids[grid]) + if w > 0 and h > 0 then -- if only one is > 0 then only the borders are touching + return true, { grid = grid, intersection_box = IntersectRects(self.Grids[grid], other.Grids[grid]) } + end + end + end + end + + if self.ObjBoxes and other.ObjBoxes then + for _, bx in ipairs(self.ObjBoxes) do + for _, o_bx in ipairs(other.ObjBoxes) do + if bx:Intersect2D(o_bx) > 0 then + return true, "objects" + end + end + end + end + + return true, "map_replaced" +end + +function ModResourceMap:GetResourceTextDescription(reason) + if reason == "map_replaced" then + return string.format("\"%s\" map", self.Map) + end + + if reason == "objects" then -- objects + return string.format("Object(s) on the \"%s\" map", self.Map) + end + + if type(reason) == "table" then -- grids + return string.format("Area(s) of the \"%s\" map", self.Map) + end + + return string.format("Area(s) or object(s) on the \"%s\" map", self.Map) +end diff --git a/CommonLua/Classes/ModItemFolder.lua b/CommonLua/Classes/ModItemFolder.lua new file mode 100644 index 0000000000000000000000000000000000000000..6eaf00ee5fb89c719362ef4d031474c93eee942b --- /dev/null +++ b/CommonLua/Classes/ModItemFolder.lua @@ -0,0 +1,129 @@ +DefineClass.ModItemFolder = { + __parents = { "ModItem" }, + properties = { + { category = "Mod", id = "_", default = false, editor = "help", help = " %s", self.Name) + end + return string.format(" %s = %s", self.Name, _InternalTranslate(Untranslated(""), self.Value, false)) +end + + +DefineClass.ScriptReturnExpr = { + __parents = { "ScriptBlock" }, + properties = { + { id = "Value", editor = "expression", default = empty_func, params = "" }, + }, + EditorName = "Return Lua expression value(s)", + EditorSubmenu = "Scripting", + EditorView = Untranslated(" "), +} + +function ScriptReturnExpr:GenerateCode(pstr, indent) + pstr:append(GetFuncBody(self.Value, indent, "return"), "\n") +end + +DefineClass.ScriptReturn = { + __parents = { "ScriptBlock" }, + ContainerClass = "ScriptValue", + EditorName = "Return script value(s)", + EditorSubmenu = "Scripting", + EditorView = Untranslated(""), +} + +function ScriptReturn:GenerateCode(pstr, indent) + pstr:append(indent, "return") + local delimeter = " " + for _, block in ipairs(self) do + pstr:append(delimeter) + block:GenerateCode(pstr, "") + delimeter = ", " + end + pstr:append("\n") +end + + +----- Compound statements (e.g. if-then-else) + +-- All elements of a compound statements are selected at once, so it can't be partially moved or deleted in Ged +DefineClass.ScriptCompoundStatementElement = { + __parents = { "ScriptBlock" }, +} + +function ScriptCompoundStatementElement:FindMainBlock() + local parent = GetParentTableOfKind(self, "ScriptBlock") + local idx = table.find(parent, self) + if not idx then return end + while idx > 0 and not IsKindOf(parent[idx], "ScriptCompoundStatement") do + idx = idx - 1 + end + return parent, idx +end + +function ScriptCompoundStatementElement:OnEditorSelect(selected, ged) + local parent, idx = self:FindMainBlock() + if parent then + ged:SelectSiblingsInFocusedPanel(parent[idx]:GetCompleteSelection{ idx }, selected) + end +end + +function ScriptCompoundStatementElement:GetContainerAddNewButtonMode() + -- only the last compound statement element allows adding siblings + local parent, idx = self:FindMainBlock() + local my_idx = table.find(parent, self) + return my_idx == idx + parent[idx]:GetExtraStatementCount() and "floating_combined" or "floating" +end + + +DefineClass.ScriptCompoundStatement = { + __parents = { "ScriptCompoundStatementElement" }, + ExtraStatementClass = "", +} + +function ScriptCompoundStatement:GetCompleteSelection(selection) + local idx = selection[#selection] + for i = idx + 1, idx + self:GetExtraStatementCount() do + selection[#selection + 1] = i + end + return selection +end + +function ScriptCompoundStatement:OnAfterEditorNew(parent, ged, is_paste) + if not is_paste then + local parent = GetParentTableOfKind(self, "ScriptBlock") + local idx = table.find(parent, self) + if not IsKindOf(parent[idx + 1], self.ExtraStatementClass) then -- TODO: Undo issue, this check doesn't work because the 'then' is not yet restored + table.insert(parent, idx + 1, g_Classes[self.ExtraStatementClass]:new()) + ParentTableModified(parent[idx + 1], parent) + end + end +end + +function ScriptCompoundStatement:GetExtraStatementCount() + return 1 +end + + +----- If-then-else + +DefineClass.ScriptIf = { + __parents = { "ScriptCompoundStatement" }, + properties = { + { id = "HasElse", name = "Has else", editor = "bool", default = false, }, + }, + ContainerClass = "ScriptValue", + ExtraStatementClass = "ScriptThen", + + EditorView = Untranslated(""), + EditorName = "if-then-else", + EditorSubmenu = "Scripting", + + else_backup = false, +} + +function ScriptIf:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "HasElse" then + local parent, idx = self:FindMainBlock() + if self.HasElse then + table.insert(parent, idx + 2, self.else_backup or ScriptElse:new()) + ParentTableModified(parent[idx + 2], parent) + else + self.else_backup = table.remove(parent, idx + 2) + end + + local selected_path, nodes = unpack_params(ged.last_app_state.root.selection) + ged:SetSelection("root", selected_path, self:GetCompleteSelection{ nodes[1] }, false, "restoring_state") + ObjModified(self) + end +end + +function ScriptIf:GetExtraStatementCount() + return self.HasElse and 2 or 1 +end + +function ScriptIf:GenerateCode(pstr, indent) + pstr:append(indent, "if ") + indent = indent .. "\t" + + if #self == 0 then + pstr:append("true ") + elseif #self == 1 then + self[1]:GenerateCode(pstr, "") + else + for i, subitem in ipairs(self) do + subitem:GenerateCode(pstr, i == 1 and "" or indent) + if i ~= #self then + pstr:append(" and\n") + end + end + end +end + +DefineClass.ScriptThen = { + __parents = { "ScriptCompoundStatementElement" }, + ContainerClass = "ScriptBlock", + EditorExcludeAsNested = true, + EditorView = Untranslated(""), +} + +function ScriptThen:GenerateCode(pstr, indent) + pstr:append(" then\n") + ScriptCompoundStatementElement.GenerateCode(self, pstr, indent) + local parent, index = self:FindMainBlock() + if not parent[index].HasElse then + pstr:append(indent, "end\n") + end +end + +DefineClass.ScriptElse = { + __parents = { "ScriptCompoundStatementElement" }, + ContainerClass = "ScriptBlock", + EditorExcludeAsNested = true, + EditorView = Untranslated(""), +} + +function ScriptElse:GenerateCode(pstr, indent) + pstr:append(indent, "else\n") + ScriptCompoundStatementElement.GenerateCode(self, pstr, indent) + pstr:append(indent, "end\n") +end + + +----- Loops + +DefineClass.ScriptForEach = { + __parents = { "ScriptBlock" }, + properties = { + { id = "IPairs", name = "Array", editor = "bool", default = true, }, + { id = "CounterVar", name = "Store index in", editor = "text", default = "i", no_edit = function(self) return not self.IPairs end, }, + { id = "ItemVar", name = "Store value in", editor = "text", default = "item", no_edit = function(self) return not self.IPairs end, }, + { id = "KeyVar", name = "Store key in", editor = "text", default = "key", no_edit = function(self) return self.IPairs end, }, + { id = "ValueVar", name = "Store value in", editor = "text", default = "value", no_edit = function(self) return self.IPairs end, }, + { id = "Table", editor = "nested_obj", default = false, base_class = "ScriptValue", auto_expand = true }, + }, + ContainerClass = "ScriptBlock", + EditorName = "for-each", + EditorSubmenu = "Scripting", +} + +function ScriptForEach:OnEditorNew(parent, ged, is_paste) + if not is_paste then + self.Table = ScriptVariableValue:new() + end +end + +function ScriptForEach:GenerateCode(pstr, indent) + local key_var = self.IPairs and self.CounterVar or self.KeyVar + local val_var = self.IPairs and self.ItemVar or self.ValueVar + local iterate = self.IPairs and "ipairs" or "pairs" + pstr:appendf("%sfor %s, %s in %s(", indent, key_var, val_var, iterate) + if self.Table then + self.Table:GenerateCode(pstr, "") + pstr:append(") do\n") + else + pstr:append("empty_table) do\n") + end + for _, item in ipairs(self) do + item:GenerateCode(pstr, indent .. "\t") + end + pstr:append(indent, "end\n") +end + +function ScriptForEach:GatherVars(vars) + vars[self.IPairs and self.CounterVar or self.KeyVar ] = true + vars[self.IPairs and self.ItemVar or self.ValueVar] = true +end + +function ScriptForEach:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "IPairs" then + self.KeyVar = nil + self.ValueVar = nil + self.CounterVar = nil + self.ItemVar = nil + end +end + +function ScriptForEach:GetEditorView() + local key_var = self.IPairs and self.CounterVar or self.KeyVar + local val_var = self.IPairs and self.ItemVar or self.ValueVar + local tbl = self.Table and _InternalTranslate(Untranslated(""), self.Table, false) or "?" + local text = string.format(" (%s, %s) %s", key_var, val_var, tbl) + return Untranslated(text) +end + + +DefineClass.ScriptLoop = { + __parents = { "ScriptBlock" }, + properties = { + { id = "CounterVar", name = "Store index in", editor = "text", default = "i", }, + { id = "StartIndex", name = "Start index", editor = "expression", params = "", default = function() return 1 end, }, + { id = "EndIndex", name = "End index", editor = "expression", params = "", default = function() return 1 end, }, + { id = "Step", editor = "expression", params = "", default = function() return 1 end, }, + }, + ContainerClass = "ScriptBlock", + EditorName = "for", + EditorSubmenu = "Scripting", +} + +function ScriptLoop:GenerateCode(pstr, indent) + local startidx = GetExpressionBody(self.StartIndex) + local endidx = GetExpressionBody(self.EndIndex) + local step = GetExpressionBody(self.Step) + if step == "1" then + pstr:appendf("%sfor %s = %s, %s do\n", indent, self.CounterVar, startidx, endidx) + else + pstr:appendf("%sfor %s = %s, %s, %s do\n", indent, self.CounterVar, startidx, endidx, step) + end + for _, item in ipairs(self) do + item:GenerateCode(pstr, indent .. "\t") + end + pstr:append(indent, "end\n") +end + +function ScriptLoop:GetEditorView() + local startidx = GetExpressionBody(self.StartIndex) + local endidx = GetExpressionBody(self.EndIndex) + local step = GetExpressionBody(self.Step) + return string.format(" %s %s %s%s", + self.CounterVar, startidx, endidx, step ~= "1" and " "..step or "") +end + + +DefineClass.ScriptBreak = { + __parents = { "ScriptSimpleStatement" }, + EditorName = "break loop", + EditorSubmenu = "Scripting", + EditorView = Untranslated(""), + AutoPickParams = false, + CodeTemplate = "break", +} + + +----- Simple statements with support to generate code via CodeTemplate + +DefineClass.ScriptSimpleStatement = { + __parents = { "ScriptBlock" }, + properties = { + { category = "Parameters", id = "Param1", name = function(self) return self.Param1Name end, editor = "choice", default = "", items = ScriptVarsCombo, no_edit = function(self) return not self.Param1Name end, help = function(self) return self.Param1Help end, }, + { category = "Parameters", id = "Param2", name = function(self) return self.Param2Name end, editor = "choice", default = "", items = ScriptVarsCombo, no_edit = function(self) return not self.Param2Name end, help = function(self) return self.Param2Help end, }, + { category = "Parameters", id = "Param3", name = function(self) return self.Param3Name end, editor = "choice", default = "", items = ScriptVarsCombo, no_edit = function(self) return not self.Param3Name end, help = function(self) return self.Param3Help end, }, + }, + Param1Name = false, + Param2Name = false, + Param3Name = false, + Param1Help = false, + Param2Help = false, + Param3Help = false, + AutoPickParams = true, + CodeTemplate = "", + NewLine = true, +} + +-- Values that require allocation will be made upvalues, so they are not allocated with each call to the script. +-- Common values such as point30 will be used as well. +function ScriptSimpleStatement:ValueToLuaCode(value) + if value == empty_func then return "empty_func" end + if value == empty_box then return "empty_box" end + if value == point20 then return "point20" end + if value == point30 then return "point30" end + if value == axis_x then return "axis_x" end + if value == axis_y then return "axis_y" end + if value == axis_z then return "axis_z" end + + if type(value) == "table" or type(value) == "userdata" then + local program = GetParentTableOfKind(self, "ScriptProgram") + local prefix = + IsPoint(value) and "pt" or + IsBox(value) and "bx" or + type(value) == "table" and "t" or "v" + return program:RequestUpvalue(prefix, value) + end + + return ValueToLuaCode(value) +end + +function ScriptSimpleStatement:GenerateCode(pstr_out, indent) + -- self[conjunction] case, output all subitem ScriptBlocks separated with a conjunction such as 'and' + local code = self.CodeTemplate:gsub("self(%b[])", function(conjunction) + local str, n = pstr("", 64), #self + conjunction = string.format(" %s ", conjunction:sub(2, -2)) + for idx, subitem in ipairs(self) do + subitem:GenerateCode(str, "") + if idx ~= n then + str:append(conjunction) + end + end + if #str ~= 0 then + return str:str() + end + if conjunction == " and " then return "true" + elseif conjunction == " or " then return "false" + elseif conjunction == " + " then return "0" + elseif conjunction == " * " then return "1" + end + return "" + end) + + code = code:gsub("(%$?)self%.([%w_]+)", function(prefix, identifier) + -- self.prop, where prop is another ScriptBlock nested_obj + local value = self[identifier] + if IsKindOf(value, "ScriptBlock") then + local str = pstr("", 32) + value:GenerateCode(str, "") + return str:str() + end + -- $self.prop means we have a variable name in self.prop (output directly, not enclosed in quotes) + if prefix == "$" then + return (value ~= "" and value or "nil") + end + -- default case - output the property value + return self:ValueToLuaCode(value) -- the method will request upvalues for tables, points, boxes, etc. + end) + code = code:gsub("\n", "\n"..indent) + pstr_out:append(indent, code, self.NewLine and "\n" or "") +end + +function ScriptSimpleStatement:OnAfterEditorNew(parent, ged, is_paste) + if self.AutoPickParams and not is_paste then + local params = GetParentTableOfKind(self, "ScriptProgram"):GetParamNames() + for i, param in ipairs(params) do + if self["Param"..i.."Name"] then + self:SetProperty("Param" .. i, param) + end + end + end +end + +DefineClass.ScriptPrint = { + __parents = { "ScriptSimpleStatement" }, + Param1Name = "Param1", + Param2Name = "Param2", + Param3Name = "Param3", + EditorName = "Print", + EditorSubmenu = "Effects", + EditorView = Untranslated("print()"), + CodeTemplate = "print($self.Param1, $self.Param2, $self.Param3)", + AutoPickParams = false, +} + + +----- Values + +DefineClass.ScriptValue = { + __parents = { "ScriptSimpleStatement" }, + NewLine = false, +} + +DefineClass.ScriptExpression = { + __parents = { "ScriptValue" }, + properties = { + { id = "Value", editor = "expression", default = empty_func, params = "" }, + }, + EditorName = "Expression", + EditorSubmenu = "Scripting", +} + +function ScriptExpression:GetEditorView() + return GetExpressionBody(self.Value) +end + +function ScriptExpression:GenerateCode(pstr, indent) + pstr:append(GetExpressionBody(self.Value)) +end + +DefineClass.ScriptVariableValue = { + __parents = { "ScriptValue" }, + properties = { + { id = "Variable", editor = "choice", default = "", items = ScriptVarsCombo } + }, + EditorName = "Variable value", + EditorSubmenu = "Values", + EditorView = Untranslated(""), + CodeTemplate = "$self.Variable", +} + + +----- Conditions + +DefineClass.ScriptCondition = { + __parents = { "ScriptValue" }, + properties = { + { id = "Negate", editor = "bool", default = false, no_edit = function(self) return not self.HasNegate end } + }, + -- all these properties must be defined when adding a new condition + HasNegate = false, + Documentation = "", + EditorView = Untranslated(""), + EditorViewNeg = Untranslated("not "), + EditorName = false, + EditorSubmenu = false, +} + +function ScriptCondition:GenerateCode(pstr, indent) + if self.Negate then pstr:append("not (") end + ScriptValue.GenerateCode(self, pstr, indent) + if self.Negate then pstr:append(")") end +end + +function ScriptCondition:GetEditorView() + return self.Negate and self.EditorViewNeg or self.EditorView +end + + +DefineClass.ScriptCheckNumber = { + __parents = { "ScriptCondition" }, + properties = { + { id = "Value", editor = "nested_obj", default = false, base_class = "ScriptValue", }, + { id = "Condition", editor = "choice", default = "==", items = function (self) return { ">=", "<=", ">", "<", "==", "~=" } end, }, + { id = "Amount", editor = "nested_obj", default = false, base_class = "ScriptValue", }, + }, + HasNegate = false, + EditorName = "Check number", + EditorSubmenu = "Conditions", + CodeTemplate = "self.Value $self.Condition self.Amount", +} + +function ScriptCheckNumber:OnAfterEditorNew() + self.Amount = ScriptExpression:new() + ParentTableModified(self.Amount, self) +end + +function ScriptCheckNumber:GetEditorView() + local value1 = self.Value and _InternalTranslate(Untranslated(""), self.Value, false) or "" + local value2 = self.Amount and _InternalTranslate(Untranslated(""), self.Amount, false) or "" + return string.format("%s %s %s", value1, self.Condition, value2) +end diff --git a/CommonLua/Classes/ShaderBall.lua b/CommonLua/Classes/ShaderBall.lua new file mode 100644 index 0000000000000000000000000000000000000000..6d130a037ced61e82351ada5e170ccadd9d77e5e --- /dev/null +++ b/CommonLua/Classes/ShaderBall.lua @@ -0,0 +1,4 @@ +DefineClass.ShaderBall = +{ + __parents = {"Object"} +} diff --git a/CommonLua/Classes/Shapeshifter.lua b/CommonLua/Classes/Shapeshifter.lua new file mode 100644 index 0000000000000000000000000000000000000000..96538f8bb6e39ff06f600001658ba628fc530680 --- /dev/null +++ b/CommonLua/Classes/Shapeshifter.lua @@ -0,0 +1,84 @@ +--[[@@@ +@class Shapeshifter +class overview... +--]] + +DefineClass.Shapeshifter = +{ + __parents = { "Object", "ComponentAttach" }, + properties = { + { id = "Entity", editor = "choice", default = "", items = GetAllEntitiesCombo }, + }, + variable_entity = true, +} + +function Shapeshifter:SetEntity(entity) + self:ChangeEntity(entity) +end + +Shapeshifter.ShouldAttach = return_true + +DefineClass.ShapeshifterClass = +{ + __parents = { "Shapeshifter" }, + current_class = "", + + ChangeClass = function(self, class_name) + local class = g_Classes[class_name] + if class then + self.current_class = class_name + self:ChangeEntity(class:GetEntity()) + if GetClassGameFlags(class_name, const.gofAttachedOnGround) ~= 0 then + self:SetGameFlags(const.gofAttachedOnGround) + else + self:ClearGameFlags(const.gofAttachedOnGround) + end + end + end, +} + +DefineClass.PlacementCursor = +{ + __parents = { "ShapeshifterClass", "AutoAttachObject" }, + + ChangeClass = function(self, class_name) + g_Classes.ShapeshifterClass.ChangeClass(self, class_name) + if rawget(g_Classes[class_name], "scale") then + self:SetScale(g_Classes[class_name].scale) + end + -- maybe the previous attaches should be cleared. not a common case + AutoAttachObjectsToPlacementCursor(self) + end, +} + +PlacementCursor.ShouldAttach = return_true + +DefineClass.PlacementCursorAttachment = +{ + __parents = { "ShapeshifterClass", "AutoAttachObject" }, + flags = { efWalkable = false, efApplyToGrids = false, efCollision = false }, +} + +PlacementCursorAttachment.ShouldAttach = return_true + +DefineClass.PlacementCursorAttachmentTerrainDecal = +{ + __parents = { "ShapeshifterClass", "AutoAttachObject", "TerrainDecal" }, + flags = { efWalkable = false, efApplyToGrids = false, efCollision = false }, +} + +PlacementCursorAttachmentTerrainDecal.ShouldAttach = return_true + +---- + +-- helper class to stub obj flags changes in ChangeEntity by default +DefineClass.EntityChangeKeepsFlags = +{ + __parents = { "CObject" }, +} + +function EntityChangeKeepsFlags:ChangeEntity(entity, state, keep_flags) + return CObject.ChangeEntity(self, entity, state or const.InvalidState, keep_flags == nil and "keep_flags" or keep_flags) +end + +---- diff --git a/CommonLua/Classes/Socket.lua b/CommonLua/Classes/Socket.lua new file mode 100644 index 0000000000000000000000000000000000000000..f611bc5b73646c109e9ee244222cd0738b9c6c8a --- /dev/null +++ b/CommonLua/Classes/Socket.lua @@ -0,0 +1,286 @@ +if not rawget(_G, "sockProcess") then return end + +-- SocketObjs is initialized by luaHGSocket.cpp + +DefineClass.BaseSocket = { + __parents = { "InitDone", "EventLogger" }, + [true] = false, + + owner = false, + socket_type = "BaseSocket", + stats_group = 0, + + host = false, + port = false, + + msg_size_max = 1 * 1024 * 1024, + timeout = 60 * 60 * 1000, + + Send = sockSend, + Listen = sockListen, + Disconnect = sockDisconnect, + IsConnected = sockIsConnected, + SetOption = sockSetOption, + GetOption = sockGetOption, + + SetAESEncryptionKey = sockEncryptionKey, + + GenRSAEncryptedKey = sockGenRSAEncryptedKey, + SetRSAEncryptedKey = sockSetRSAEncryptedKey, +} + +function BaseSocket:Init() + local socket = self[true] or sockNew() + self[true] = socket + SocketObjs[socket] = self + self:SetOption("timeout", self.timeout) + self:SetOption("maxbuffer", self.msg_size_max) + sockSetGroup(self, self.stats_group) +end + +function BaseSocket:Done() + local owner = self.owner + if owner then + owner:OnConnectionDone(self) + end + local socket = self[true] + if SocketObjs[socket] == self then + if self:IsConnected() then + self:OnDisconnect("delete") + end + sockDelete(socket) + SocketObjs[socket] = nil + self[true] = false + end +end + +function BaseSocket:UpdateEventSource() + if self.host and self.port then + self.event_source = string.format("%s:%d(%s)", self.host, self.port, sockStr(self)) + else + self.event_source = string.format("-(%s)", sockStr(self)) + end +end + +function BaseSocket:Connect(timeout, host, port) + self.host = host + self.port = port + self:UpdateEventSource() + return sockConnect(self, timeout, host, port) +end + +function BaseSocket:WaitConnect(timeout, host, port) + local err = self:Connect(timeout, host, port) + if err then return err end + return select(2, WaitMsg(self)) +end + +function BaseSocket:OnAccept(socket, host, port) + local owner = self.owner + local sock = g_Classes[self.socket_type]:new{ + [true] = socket, + owner = owner, + } + sock:OnConnect(nil, host, port) + if owner then + owner:OnConnectionInit(sock) + end + return sock +end + +function BaseSocket:OnConnect(err, host, port) + Msg(self, err) + self.host = not err and host or nil + self.port = not err and port or nil + self:UpdateEventSource() + --self:Log("OnConnect") + return self +end + +function BaseSocket:OnDisconnect(reason) +end + +function BaseSocket:OnReceive(...) +end + + +----- MessageSocket + +DefineClass.MessageSocket = { + __parents = { "BaseSocket" }, + + socket_type = "MessageSocket", + + --__hierarchy_cache = true, + call_waiting_threads = false, + call_timeout = 30000, + + msg_size_max = 16*1024, + + -- default strings table, do not modify + serialize_strings = { + "rfnCall", + "rfnResult", + "rfnStrings", + }, + -- autogenerated: + serialize_strings_pack = false, + [1] = false, -- idx_to_string + [2] = false, -- string_to_idx +} + + +local weak_values = { __mode = "v" } + +function MessageSocket:Init() + self.call_waiting_threads = {} + setmetatable(self.call_waiting_threads, weak_values) + + self:SetOption("message", true) +end + +function MessageSocket:OnDisconnect(reason) + local call_waiting_threads = self.call_waiting_threads + if next(call_waiting_threads) then + for id, thread in pairs(call_waiting_threads) do + Wakeup(thread, "disconnected") + call_waiting_threads[id] = nil + end + end + self.serialize_strings = nil + self[1] = nil + self[2] = nil +end + +--Temporary? +function MessageSocket:Serialize(...) + return SerializeStr(self[2], ...) +end + +function MessageSocket:Unserialize(...) + return UnserializeStr(self[1], ...) +end + +if Platform.developer then +function GetDeepDiff(data1, data2, diff, depth) + depth = depth or 1 + assert(depth < 20) + if depth >= 20 then return end + local function add(d) + for i=1,#diff do + if compare(diff[i], d) then --> don't add the same diff twice + return + end + end + diff[ #diff + 1 ] = d + end + if data1 == data2 then return end + local type1 = type(data1) + local type2 = type(data2) + if type1 ~= type2 then + add(format_value(data1)) + elseif type1 == "table" then + for k, v1 in pairs(data1) do + local v2 = data2[k] + if v1 ~= v2 then + if v2 == nil then + add{[k] = format_value(v1)} + else + GetDeepDiff(v1, v2, diff, depth + 1) + end + end + end + else + add(data1) + end +end + +function MessageSocket:Send(...) + local original_data = {...} + local unserialized_data = {UnserializeStr(self[1], SerializeStr(self[2], ...))} + if not compare(original_data, unserialized_data, nil, true) then + rawset(_G, "__a", original_data) + rawset(_G, "__b", unserialized_data) + rawset(_G, "__diff", {}) + GetDeepDiff(original_data, unserialized_data, __diff) + assert(false) + end + return sockSend(self, ...) +end +end + +--------- rfn ------------ + +function MessageSocket:rfnStrings( serialize_strings_pack ) + -- decompress and evaluate the remote serialize_strings + local loader = load( "return " .. Decompress( serialize_strings_pack )) + local idx_to_string = loader and loader() + if type( idx_to_string ) ~= "table" then + assert( false, "Failed to unserialize the string serialization table!" ) + self:Disconnect() + return + end + self.serialize_strings = idx_to_string + self[1] = idx_to_string + self[2] = table.invert(idx_to_string) +end + +if FirstLoad then + rcallID = 0 +end + +local function passResults(ok, ...) + if ok then return ... end + return "timeout" +end + +local hasRfnPrefix = hasRfnPrefix +local launchRealTimeThread = LaunchRealTimeThread +function MessageSocket:Call(func, ...) + assert(hasRfnPrefix(func)) + local id = rcallID + rcallID = id + 1 + local err = self.Send(self, "rfnCall", id, func, ...) + if err then return err end + if not CanYield() then + self:ErrorLog("Call cannot sleep", func, TupleToLuaCode(...), GetStack(2)) + return "not in thread" + end + self.call_waiting_threads[id] = CurrentThread() + return passResults(WaitWakeup(self.call_timeout)) +end + +local function __f(id, func, self, ...) + local err = self.Send(self, "rfnResult", id, func(self, ...)) + if err and err ~= "disconnected" and err ~= "no socket" then + self:ErrorLog("Result send failed", err) + end + return err +end +function MessageSocket:rfnCall(id, name, ...) + if hasRfnPrefix(name) then + local func = self[name] + if func then + return launchRealTimeThread(__f, id, func, self, ...) + end + end + self:ErrorLog("Call name", name) + self:Disconnect() +end + +function MessageSocket:rfnResult(id, ...) + local thread = self.call_waiting_threads[id] + self.call_waiting_threads[id] = nil + Wakeup(thread, ...) +end + +function OnMsg.ClassesPreprocess(classdefs) + for name, classdef in pairs(classdefs) do + local serialize_strings = rawget(classdef, "serialize_strings") + if serialize_strings then + classdef.serialize_strings_pack = Compress( TableToLuaCode( serialize_strings ) ) + classdef[1] = serialize_strings + classdef[2] = table.invert(serialize_strings) + end + end +end \ No newline at end of file diff --git a/CommonLua/Classes/SoundDummy.lua b/CommonLua/Classes/SoundDummy.lua new file mode 100644 index 0000000000000000000000000000000000000000..895ae2cfcaf0d9711939684fe22b5a23ce98f1e1 --- /dev/null +++ b/CommonLua/Classes/SoundDummy.lua @@ -0,0 +1,27 @@ +-- this class is used for creating sound dummy objects that only play sounds + +DefineClass.SoundDummy = { + __parents = { "ComponentAttach", "ComponentSound", "FXObject" }, + flags = { efVisible = false, efWalkable = false, efCollision = false, efApplyToGrids = false }, + entity = "" +} + +DefineClass.SoundDummyOwner = { + __parents = { "Object", "ComponentAttach" }, + snd_dummy = false, +} + +function SoundDummyOwner:PlayDummySound(id, fade_time) + if not self.snd_dummy then + self.snd_dummy = PlaceObject("SoundDummy") + self:Attach(self.snd_dummy, self:GetSpotBeginIndex("Origin")) + end + self.snd_dummy:SetSound(id, 1000, fade_time) +end + +function SoundDummyOwner:StopDummySound(fade_time) + if IsValid(self.snd_dummy) then + self.snd_dummy:StopSound(fade_time) + end +end + diff --git a/CommonLua/Classes/Sounds.lua b/CommonLua/Classes/Sounds.lua new file mode 100644 index 0000000000000000000000000000000000000000..6de8ef352bec2cc01b9c1453de1025962489d88b --- /dev/null +++ b/CommonLua/Classes/Sounds.lua @@ -0,0 +1,1121 @@ +if FirstLoad then + SoundBankPresetsPlaying = {} + SoundEditorGroups = {} + SoundEditorSampleInfoCache = {} + SoundFilesCache = {} + SoundFilesCacheTime = 0 +end + +config.SoundTypesPath = config.SoundTypesPath or "Lua/Config/__SoundTypes.lua" +config.SoundTypeTest = config.SoundTypeTest or "SoundTest" + +local function EditorSoundStoped(sound_group, id) + local group = SoundEditorGroups[sound_group] + if group then + table.remove_value(group, id) + if #group == 0 then + SetOptionsGroupVolume(sound_group, group.old_volume) + SoundEditorGroups[sound_group] = nil + print("Restoring volume to", group.old_volume, "for group", sound_group) + end + end +end + +local function EditorSoundStarted(sound_group, id) + local group = SoundEditorGroups[sound_group] or {} + table.insert(group, id) + if #group == 1 then + group.old_volume = GetOptionsGroupVolume(sound_group) + SoundEditorGroups[sound_group] = group + print("Temporarily setting the volume of", sound_group ,"to 1000.") + end + SetOptionsGroupVolume(sound_group, 1000) +end + +local function PlayStopSoundPreset(id, obj, sound_group, sound_type) + if SoundBankPresetsPlaying[id] then + StopSound(SoundBankPresetsPlaying[id]) + SoundBankPresetsPlaying[id] = nil + EditorSoundStoped(sound_group, id) + ObjModified(obj) + return nil + end + + local result, err = PlaySound(id, sound_type) + if result then + SoundBankPresetsPlaying[id] = result + EditorSoundStarted(sound_group, id) + ObjModified(obj) + + local looping, duration = IsSoundLooping(result), GetSoundDuration(result) + print("Playing sound", id, "looping:", looping, "duration:", duration) + if not looping and duration then + CreateRealTimeThread(function() + Sleep(duration) + SoundBankPresetsPlaying[id] = nil + EditorSoundStoped(sound_group, id) + ObjModified(obj) + end) + end + else + print("Failed to play sound", id, ":", err) + end +end + +function GedPlaySoundPreset(ged) + local sel_obj = ged:ResolveObj("SelectedObject") + + if IsKindOf(sel_obj, "SoundPreset") then + PlayStopSoundPreset(sel_obj.id, Presets.SoundPreset, SoundTypePresets[sel_obj.type].options_group, config.SoundTypeTest) + elseif IsKindOf(sel_obj, "SoundFile") then + local preset = ged:GetParentOfKind("SelectedObject", "SoundPreset") + if preset then + PlayStopSoundPreset(sel_obj.file, preset, SoundTypePresets[preset.type].options_group, config.SoundTypeTest or preset.type) + end + end +end + +DefineClass.SoundPreset = { + __parents = {"Preset"}, + properties = { + -- "sound" + { id = "type", editor = "preset_id", preset_class = "SoundTypePreset", name = "Sound Type", default = "" }, + { id = "looping", editor = "bool", default = false, read_only = function (self) return self.periodic end, help = "Looping sounds are played in an endless loop without a gap." }, + { id = "periodic", editor = "bool", default = false, read_only = function (self) return self.looping end, help = "Periodic sounds are repeated with random pauses between the repetitions; a different sample is chosen randomly each time." }, + { id = "animsync", name = "Sync with animation", editor = "bool", default = false, read_only = function (self) return self.looping end, help = "Plays at the start of each animation. Anim synced sounds are periodic as well." }, + { id = "volume", editor = "number", default = 100, min = 0, max = 300, slider = true, help = "Per-sound bank volume attenuation", + buttons = { { name = "Adjust by %", func = "GedAdjustVolume" } }, + }, + { id = "loud_distance", editor = "number", default = 0, min = 0, max = MaxSoundLoudDistance, slider = true, scale = "m", help = "No attenuation below that distance (in meters). In case of zero the sound group loud_distance is used." }, + { id = "silence_frequency", editor = "number", default = 0, min = 0, help = "A random sample is chosen to play each time from this bank, using a weighted random; if this is non-zero, nothing will play with chance corresponding to this weight."}, + { id = "silence_duration", editor = "number", default = 1000, min = 0, no_edit = function (self) return not self.periodic end, help = "Duration of the silence, if the weighted random picks silence. (Valid only for periodic sounds.)" }, + { id = "periodic_delay", editor = "number", default = 0, min = 0, no_edit = function (self) return not self.periodic end, help = "Delay between repeating periodic sounds, fixed part."}, + { id = "random_periodic_delay", editor = "number", default = 1000, min = 0, no_edit = function (self) return not self.periodic end, help = "Delay between repeating periodic sounds, random part."}, + { id = "loop_start", editor = "number", default = 0, min = 0, no_edit = function (self) return not self.looping end, help = "For looping sounds, specify start of the looping part, in milliseconds." }, + { id = "loop_end", editor = "number", default = 0, min = 0, no_edit = function (self) return not self.looping end, help = "For looping sounds, specify end of the looping part, in milliseconds." }, + + { id = "unused", editor = "bool", default = false, read_only = true, dont_save = true }, + }, + + GlobalMap = "SoundPresets", + ContainerClass = "SoundFile", + GedEditor = "SoundEditor", + EditorMenubarName = "Sound Bank Editor", + EditorMenubar = "Editors.Audio", + EditorIcon = "CommonAssets/UI/Icons/bell message new notification sign.png", + PresetIdRegex = "^[%w _+-]*$", + FilterClass = "SoundPresetFilter", + + EditorView = Untranslated(" ', '')>', '')>") +} + +if FirstLoad then + SoundPresetAdjustPercent = false + SoundPresetAdjustPercentUI = false +end + +function OnMsg.GedExecPropButtonStarted() + SoundPresetAdjustPercentUI = false +end + +function OnMsg.GedExecPropButtonCompleted(obj) + ObjModified(obj) +end + +function SoundPreset:GetUnusedStr() + return self.unused and "unused" or "" +end + +function SoundPreset:GedAdjustVolume(root, prop_id, ged, btn_param, idx) + if not SoundPresetAdjustPercentUI then + SoundPresetAdjustPercentUI = true + SoundPresetAdjustPercent = ged:WaitUserInput("Enter adjust percent") + if not SoundPresetAdjustPercent then + ged:ShowMessage("Invalid Value", "Please enter a percentage number.") + return + end + end + if SoundPresetAdjustPercent then + self.volume = MulDivRound(self.volume, 100 + SoundPresetAdjustPercent, 100) + ObjModified(self) + end +end + +function SoundPreset:GetSoundFiles() + if GetPreciseTicks() < 0 and SoundFilesCacheTime >= 0 or GetPreciseTicks() > SoundFilesCacheTime + 1000 then + SoundFilesCache = {} + local files = io.listfiles("Sounds", "*", "recursive") + for _, name in ipairs(files) do + SoundFilesCache[name] = true + end + SoundFilesCacheTime = GetPreciseTicks() + end + return SoundFilesCache +end + +function SoundPreset:GetError() + local stype = SoundTypePresets[self.type] + if not stype then + return "Please set a valid sound type." + end + + local err_table = { "", "error" } -- indexes of subobjects to be underlined are inserted in this table + + -- Positional sounds should be mono + if stype.positional then + for idx, sample in ipairs(self) do + local data = sample:GetSampleData() + if data and data.channels > 1 then + err_table[1] = "The underlined positional sounds should be mono only." + table.insert(err_table, idx) + end + end + end + if #err_table > 2 then + return err_table + end + + -- Invalid characters in file name + for idx, sample in ipairs(self) do + local file = sample.file + for i = 1, #file do + if file:byte(i) > 127 then + err_table[1] = "Invalid character(s) are found in the underlined sample file names." + table.insert(err_table, idx) + break + end + end + end + if #err_table > 2 then + return err_table + end + + -- File missing + local filenames = self:GetSoundFiles() + for idx, sample in ipairs(self) do + if not filenames[sample:Getpath()] then + err_table[1] = "The underlined sound files are missing or empty." + table.insert(err_table, idx) + end + end + if #err_table > 2 then + return err_table + end + + -- Duplicate files + local file_set = {} + for idx, sample in ipairs(self) do + local file = sample.file + if file_set[file] then + err_table[1] = "Duplicate sample files." + table.insert(err_table, idx) + table.insert(err_table, file_set[file]) + end + file_set[file] = idx + end + if #err_table > 2 then + return err_table + end +end + +function SoundPreset:EditorColor() + if SoundBankPresetsPlaying[self.id] then + return "" + end + return #self == 0 and "" or "" +end + +function SoundPreset:GetSampleCount() + return #self == 0 and "" or #self +end + +function SoundPreset:OnEditorNew() + LoadSoundBank(self) +end + +function SoundPreset:OverrideSampleFuncs() +end + +function SoundPreset:Setlooping(val) + if type(val) == "number" then + self.looping = (val == 1) + else + self.looping = not not val + end +end + +function SoundPreset:Getlooping(val) + if type(self.looping) == "number" then + return self.looping ~= 0 + end + return self.looping and true or false +end + +local bool_filter_items = { { text = "true", value = true }, { text = "false", value = false }, { text = "any", value = "any" } } +DefineClass.SoundPresetFilter = { + __parents = { "GedFilter" }, + + properties = { + { id = "SoundType", name = "Sound type", editor = "choice", default = "", items = PresetsCombo("SoundTypePreset", false, "") }, + { id = "Looping", editor = "choice", default = "any", items = bool_filter_items }, + { id = "Periodic", editor = "choice", default = "any", items = bool_filter_items }, + { id = "AnimSync", name = "Sync with animation", editor = "choice", default = "any", items = bool_filter_items }, + { id = "Unused", name = "Unused", editor = "choice", default = "any", items = bool_filter_items }, + }, +} + +function SoundPresetFilter:FilterObject(o) + if self.SoundType ~= "" and o.type ~= self.SoundType then return false end + if self.Looping ~= "any" and o.looping ~= self.Looping then return false end + if self.Periodic ~= "any" and o.periodic ~= self.Periodic then return false end + if self.AnimSync ~= "any" and o.animsync ~= self.AnimSync then return false end + if self.Unused ~= "any" and o.unused ~= self.Unused then return false end + return true +end + + +local sample_base_folder = "Sounds" +local function folder_fn(obj) return obj:GetFolder() end +local function filter_fn(obj) return obj:GetFileFilter() end +DefineClass.SoundFile = { + __parents = {"PropertyObject"}, + properties = { + { id = "file", editor = "browse", no_edit = true, default = "" }, + { id = "path", dont_save = true, name = "Path", editor = "browse", default = "", folder = folder_fn, filter = filter_fn, mod_dst = function() return GetModAssetDestFolder("Sound") end}, + { id = "frequency", editor = "number", min = 0}, + }, + frequency = 100, + EditorView = Untranslated("[No name] "), + EditorName = "Sample", + StoreAsTable = true, +} + +DefineClass.Sample = { + __parents = {"SoundFile"}, + StoreAsTable = false, +} + +function SoundFile:GetFolder() + return sample_base_folder +end + +function SoundFile:GetFileFilter() + local file_ext = self:GetFileExt() + return string.format("Sample File(*.%s)|*.%s", file_ext, file_ext) +end + +function SoundFile:GetFileExt() + return "wav" +end + +function SoundFile:GetStripPattern() + local file_ext = self:GetFileExt() + return "(.*)." .. file_ext .. "%s*$" +end + +function SoundFile:GetSampleData() + local info = SoundEditorSampleInfoCache[self:Getpath()] + if not info then + info = GetSoundInformation(self:Getpath()) + SoundEditorSampleInfoCache[self:Getpath()] = info + end + return info +end + +function SoundFile:SampleInformation() + local info = self:GetSampleData() + if not info then return "" end + local channels = info.channels > 1 and "stereo" or "mono" + local bits = info.bits_per_sample + local duration = info.duration or 0 + return string.format("%s %s %0.3fs", channels, bits, duration / 1000.0) +end + +function SoundFile:GetBitsPerSample() + local info = SoundEditorSampleInfoCache[self:Getpath()] + if not info then + info = GetSoundInformation(self:Getpath()) + SoundEditorSampleInfoCache[self:Getpath()] = info + end + return info.bits_per_sample +end + +function SoundFile:GedColor() + if not io.exists(self:Getpath()) then return "" end + + if SoundBankPresetsPlaying[self.file] then + return "" + end + return "" +end + +function SoundFile:OnEditorSetProperty(prop_id, old_value, ged) + if rawget(_G, "SoundStatsInstance") then + local sound = ged:GetParentOfKind("SelectedObject", "SoundPreset") + LoadSoundBank(sound) + ObjModified(ged:ResolveObj("root")) + ObjModified(sound) + end +end + +function SoundFile:Setpath(path) + local normalized = string.match(path, self:GetStripPattern()) + if normalized then + self.file = normalized + else + print("Invalid sound path - must be in project's Sounds/ folder") + end +end + +function SoundFile:Getpath() + return self.file .. "." .. self:GetFileExt() +end + +function SoundFile:OnEditorNew(preset, ged, is_paste) + preset:OverrideSampleFuncs(self) + local is_mod_item = TryGetModDefFromObj(preset) + if not is_paste and not is_mod_item then + CreateRealTimeThread(function() + local os_path = self:GetFolder() + if type(os_path) == "table" then + os_path = os_path[1][1] + end + os_path = ConvertToOSPath(os_path .. "/") + local path_list = ged:WaitBrowseDialog(os_path, self:GetFileFilter(), false, true) + if path_list and #path_list > 0 then + local current_index = (table.find(preset, self) or -1) + 1 + for i = #path_list, 2, -1 do + local path = path_list[i] + local next_sample = SoundFile:new({}) + next_sample:Setpath(ConvertFromOSPath(path, "Sounds/")) + table.insert(preset, current_index, next_sample) + end + self:Setpath(ConvertFromOSPath(path_list[1], "Sounds/")) + SuspendObjModified("NewSample") + ObjModified(self) + ObjModified(preset) + ged:OnParentsModified("SelectedObject") + ResumeObjModified("NewSample") + end + end) + end +end + +function LoadSoundPresetSoundBanks() + ForEachPresetGroup(SoundPreset, function(group) + local preset_list = Presets.SoundPreset[group] + LoadSoundBanks(preset_list) + end) +end + +if FirstLoad then + l_test_counter_1 = 0 + l_test_counter_2 = 0 +end + +function IsSoundEditorOpened() + if not rawget(_G, "GedConnections") then return false end + for key, conn in pairs(GedConnections) do + if conn.app_template == SoundPreset.GedEditor then + return true + end + end + return false +end + +function IsSoundTypeEditorOpened() + if not rawget(_G, "GedConnections") then return false end + for key, conn in pairs(GedConnections) do + if conn.app_template == SoundTypePreset.GedEditor then + return true + end + end + return false +end + +---- + +if FirstLoad then + SoundMuteReasons = {} + SoundUnmuteReasons = {} +end + +function UpdateMuteSound() + local mute_force, unmute_force = 0, 0 + for reason, force in pairs(SoundMuteReasons) do + mute_force = Max(mute_force, force) + --print("Mute", ValueToStr(reason), force) + end + for reason, force in pairs(SoundUnmuteReasons) do + unmute_force = Max(unmute_force, force) + --print("Unmute", ValueToStr(reason), force) + end + --print("Mute:", mute_force, "Unmute:", unmute_force) + SetMute(mute_force > unmute_force) +end + +local function DoSetMuteSoundReason(reasons, reason, force) + reason = reason or false + force = force or 0 + reasons[reason] = force > 0 and force or nil + UpdateMuteSound() +end +function SetMuteSoundReason(reason, force) + return DoSetMuteSoundReason(SoundMuteReasons, reason, force or 1) +end +function ClearMuteSoundReason(reason) + return DoSetMuteSoundReason(SoundMuteReasons, reason, false) +end + +function SetUnmuteSoundReason(reason, force) + return DoSetMuteSoundReason(SoundUnmuteReasons, reason, force or 1) +end +function ClearUnmuteSoundReason(reason) + return DoSetMuteSoundReason(SoundUnmuteReasons, reason, false) +end + +------------------- Editor ----------------- + +function OnMsg.GedOpened(ged_id) + local conn = GedConnections[ged_id] + if not conn then return end + if conn.app_template == SoundPreset.GedEditor then + SoundStatsInstance = SoundStatsInstance or SoundStats:new() + conn:BindObj("stats", SoundStatsInstance) + SoundStatsInstance:Refresh() + SetUnmuteSoundReason("SoundEditor", 1000) + end + if conn.app_template == SoundTypePreset.GedEditor then + ActiveSoundsInstance = ActiveSoundsInstance or ActiveSoundStats:new() + conn:BindObj("active_sounds", ActiveSoundsInstance) + ActiveSoundsInstance.ged_conn = conn + ActiveSoundsInstance:RescanAction() + SetUnmuteSoundReason("SoundTypeEditor", 1000) + end +end + +function OnMsg.GedClosing(ged_id) + local conn = GedConnections[ged_id] + if not conn then return end + if conn.app_template == SoundPreset.GedEditor then + ClearUnmuteSoundReason("SoundEditor") + end + if conn.app_template == SoundTypePreset.GedEditor then + ClearUnmuteSoundReason("SoundTypeEditor") + end +end + +function SoundPreset:SaveAll(...) + Preset.SaveAll(self, ...) + LoadSoundPresetSoundBanks() +end + +----------------- Stats ------------------ +if FirstLoad then + SoundStatsInstance = false + ActiveSoundsInstance = false +end + +local sound_flags = {"Playing", "Looping", "NoReverb", "Positional", "Disable", "Replace", "Pause", "Periodic", "AnimSync", "DeleteSample", "Stream", "Restricted"} +local volume_scale = const.VolumeScale +local function FlagsToStr(flags) + return flags and table.concat(table.keys(flags, true), " | ") or "" +end + +DefineClass.SoundInfo = { + __parents = { "PropertyObject" }, + properties = { + -- Stats + { id = "sample", editor = "text", default = "" }, + { id = "sound_bank", editor = "preset_id", default = "", preset_class = "SoundPreset" }, + { id = "sound_type", editor = "preset_id", default = "", preset_class = "SoundTypePreset" }, + { id = "format", editor = "text", default = "" }, + { id = "channels", editor = "number", default = 1 }, + { id = "duration", editor = "number", default = 0, scale = "sec" }, + { id = "state", editor = "text", default = "" }, + { id = "sound_flags", editor = "prop_table", default = false, items = sound_flags, no_edit = true }, + { id = "type_flags", editor = "prop_table", default = false, items = sound_flags, no_edit = true }, + { id = "SoundFlags", editor = "text", default = "" }, + { id = "TypeFlags", editor = "text", default = "" }, + { id = "obj", editor = "object", default = false, no_edit = true }, -- "text" because "object" doesn't work for CObject + { id = "ObjText", name = "obj", editor = "text", default = false, buttons = { {name = "Show", func = "ShowObj" }} }, -- "text" because "object" doesn't work for CObject + { id = "current_pos", editor = "point", default = false, buttons = { {name = "Show", func = "ShowCurrentPos" }} }, + { id = "Attached", editor = "bool", default = false, help = "Is the sound attached to the object. An object can have a single attached sound to play" }, + { id = "sound_handle", editor = "number", default = 0, no_edit = true }, + { id = "SoundHandleHex", name = "sound_handle", editor = "text", default = "" }, + { id = "play_idx", editor = "number", default = -1, help = "Index in the list of actively playing sounds" }, + { id = "volume", editor = "number", default = 0, scale = volume_scale }, + { id = "final_volume", editor = "number", default = 0, scale = volume_scale, help = "The final volume formed by the sound's volume and the type's final volume" }, + { id = "loud_distance", editor = "number", default = 0, scale = "m" }, + { id = "time_fade", editor = "number", default = 0 }, + { id = "loop_start", editor = "number", default = 0, scale = "sec" }, + { id = "loop_end", editor = "number", default = 0, scale = "sec" }, + }, + + GetSoundFlags = function(self) + return FlagsToStr(self.sound_flags) + end, + GetTypeFlags = function(self) + return FlagsToStr(self.type_flags) + end, + GetAttached = function(self) + local obj = self.obj + if not IsValid(obj) then + return false + end + local sample, sbank, stype, shandle = obj:GetSound() + return shandle == self.sound_handle + end, + GetObjText = function(self) + local obj = self.obj + return IsValid(obj) and obj.class or "" + end, + GetSoundHandleHex = function(self) + return string.format("%d (0x%X)", self.sound_handle, self.sound_handle) + end, + ShowCurrentPos = function(self) + local pos = self.current_pos + if IsValidPos(self.current_pos) then + ShowMesh(3000, function() + return { + PlaceSphere(pos, guim/2), + PlaceCircle(pos, self.loud_distance) + } + end) + local eye = pos + point(0, 2*self.loud_distance, 3*self.loud_distance) + SetCamera(eye, pos, nil, nil, nil, nil, 300) + end + end, + ShowObj = function(self) + local obj = self.obj + if IsValid(obj) then + local pos = obj:GetVisualPos() + local eye = pos + point(0, self.loud_distance, 2*self.loud_distance) + SetCamera(eye, pos, nil, nil, nil, nil, 300) + if obj:GetRadius() == 0 then + return + end + CreateRealTimeThread(function(obj) + local highlight + SetContourReason(obj, 1, "ShowObj") + for i=1,20 do + if not IsValid(obj) then + break + end + highlight = not highlight + DbgSetColor(obj, highlight and 0xffffffff or 0xff000000) + Sleep(100) + end + ClearContourReason(obj, 1, "ShowObj") + DbgSetColor(obj) + end, obj) + end + end, + GetEditorView = function(self) + if self.sound_bank then + return string.format("%s.%s", self.sound_type, self.sound_bank) + end + local text = self.sample + if string.starts_with(text, "Sounds/", true) then + text = text:sub(8) + end + return text + end, +} + +DefineClass.ActiveSoundStats = { + __parents = { "InitDone" }, + properties = { + -- Stats + { id = "AutoUpdate", name = "Auto Update (ms)", editor = "number", default = 0, category = "Stats" }, + { id = "HideMuted", name = "Hide Muted", editor = "bool", default = false, category = "Stats" }, + { id = "active_sounds", name = "Active Sounds", editor = "nested_list", base_class = "SoundInfo", default = false, category = "Stats", read_only = true, buttons = {{name = "Rescan", func = "RescanAction" }} }, + }, + sound_hash = false, + ged_conn = false, + auto_update = 0, + auto_update_thread = false, +} + +function ActiveSoundStats:Done() + DeleteThread(self.auto_update_thread) +end + +function ActiveSoundStats:SetHideMuted(value) + self.HideMuted = value + self:RescanAction() +end + +function ActiveSoundStats:IsShown() + local ged_conn = self.ged_conn + local active_sounds = table.get(ged_conn, "bound_objects", "active_sounds") + return active_sounds == self and ged_conn:IsConnected() +end + +function ActiveSoundStats:SetAutoUpdate(auto_update) + self.auto_update = auto_update + DeleteThread(self.auto_update_thread) + if auto_update <= 0 then + return + end + self.auto_update_thread = CreateRealTimeThread(function() + while true do + Sleep(auto_update) + if self:IsShown() then + self:RescanAction() + end + end + end) +end + +function ActiveSoundStats:GetAutoUpdate() + return self.auto_update +end + +function ActiveSoundStats:RescanAction() + local list = GetActiveSounds() + table.sort(list, function(s1, s2) + return s1.sample < s2.sample or s1.sample == s2.sample and s1.sound_handle < s2.sound_handle + end) + local active_sounds = self.active_sounds or {} + self.active_sounds = active_sounds + local sound_hash = self.sound_hash or {} + self.sound_hash = sound_hash + local hide_muted = self.HideMuted + local k = 1 + for i, info in ipairs(list) do + if not hide_muted or info.final_volume > 0 then + local hash = table.hash(info, nil, 1) + if not active_sounds[k] or sound_hash[k] ~= hash then + active_sounds[k] = SoundInfo:new(info) + sound_hash[k] = hash + k = k + 1 + end + end + end + if #active_sounds ~= k then + table.iclear(active_sounds, k + 1) + end + ObjModified(self) +end + +DefineClass.SoundStats = { + __parents = { "PropertyObject" }, + properties = { + -- Stats + { id = "total_sounds", name = "Sounds", editor = "number", default = 0, category = "Stats", read_only = true}, + { id = "total_samples", name = "Samples", editor = "number", default = 0, category = "Stats", read_only = true}, + { id = "total_size", name = "Total MBs", editor = "number", default = 0, scale=1024*1024, category = "Stats", read_only = true}, + { id = "compressed_total_size", name = "Total compressed MBs", editor = "number", default = 0, scale=1024*1024, category = "Stats", read_only = true}, + { id = "unused_samples", name = "Unused samples", editor = "number", default = 0, scale=1, category = "Stats", read_only = true, buttons = {{name = "List", func = "PrintUnused" }, {name = "Refresh", func = "RefreshAction" }}}, + { id = "unused_total_size", name = "Total unused MBs", editor = "number", default = 0, scale=1024*1024, category = "Stats", read_only = true}, + { id = "compressed_unused_total_size", name = "Total unused compressed MBs", editor = "number", default = 0, scale=1024*1024, category = "Stats", read_only = true}, + { id = "unused_count", name = "Unused Banks Count", editor = "number", default = 0, buttons = {{name = "Search", func = "SearchUnusedBanks" }}, category = "Stats", read_only = true}, + }, + refresh_thread = false, + walked_files = false, + unused_samples_list = false, +} + +function SoundStats:SearchUnusedBanks(root, name, ged) + local st = GetPreciseTicks() + local data_strings = {} + for class, groups in sorted_pairs(Presets) do + if class ~= "SoundPreset" then + for _, group in ipairs(groups) do + for _, preset in ipairs(group) do + for key, value in pairs(preset) do + if type(value) == "string" then + data_strings[value] = true + end + end + end + end + end + end + local count = 0 + local unused = {} + for name, sound in pairs(SoundPresets) do + sound.unused = nil + if #sound > 0 and not data_strings[name] then + unused[#unused + 1] = sound + end + end + -- TODO: search the unused in the code, then in the maps + self.unused_count = #unused + for _, sound in ipairs(unused) do + sound.unused = true + end + print(#unused, "unused sounds found in", GetPreciseTicks() - st, "ms") +end + +function SoundStats:PrintUnused(root, name, ged) + local txt = "" + for _, sample in ipairs(self.unused_samples_list) do + txt = txt .. sample .. "\n" + end + ged:ShowMessage("List", txt) +end + +function SoundStats:Getunused_samples() + return #(self.unused_samples_list or "") +end + +function SoundStats:RefreshAction(root) + self:Refresh() +end + +function SoundStats:Refresh() + self.refresh_thread = self.refresh_thread or CreateRealTimeThread(function() + SoundEditorSampleInfoCache = {} + + local total_sounds, total_samples, total_size, compressed_total_size = 0, 0, 0, 0 + local walked_files = {} + local original_sizes, compressed_sizes = {}, {} + self.unused_samples_list = {} + self.total_sounds = 0 + self.total_samples = 0 + self.total_size = 0 + self.compressed_total_size = 0 + self.unused_total_size = 0 + self.compressed_unused_total_size = 0 + ObjModified(self) -- hide all values + + local function compressed_sample_path(path) + local dir, name = SplitPath(path) + return "svnAssets/Bin/win32/" .. dir .. name .. ".opus" + end + + ForEachPreset(SoundPreset, function(sound) + total_sounds = total_sounds + 1 + total_samples = total_samples + #sound + for i, sample in ipairs(sound) do + local path = sample:Getpath() + if not walked_files[path] then + walked_files[path] = true + + local original_size = io.getsize(path) or 0 + original_sizes[path] = original_size + if original_size > 0 then + total_size = total_size + original_size + end + + local compressed_path = compressed_sample_path(path) + local compressed_size = io.getsize(compressed_path) or 0 + compressed_sizes[path] = compressed_size + if compressed_size > 0 then + compressed_total_size = compressed_total_size + compressed_size + end + end + end + end) + + self.total_sounds = total_sounds + self.total_samples = total_samples + self.total_size = total_size + self.compressed_total_size = compressed_total_size + self.walked_files = walked_files + self.unused_samples_list = self:CalcUnusedSamples() + local unused_total_size, compressed_unused_total_size = 0, 0 + for i, file in ipairs(self.unused_samples_list) do + unused_total_size = unused_total_size + io.getsize(file) + local compressed_file = compressed_sample_path(file) + compressed_unused_total_size = compressed_unused_total_size + io.getsize(compressed_file) + end + self.unused_total_size = unused_total_size + self.compressed_unused_total_size = compressed_unused_total_size + + local active_sounds = GetActiveSounds() + for i, info in ipairs(active_sounds) do + active_sounds[i] = SoundInfo:new(info) + end + self.active_sounds = active_sounds + + ObjModified(self) + ObjModified(Presets.SoundPreset) + self.refresh_thread = false + end) +end + + +local function ListSamples(dir, type) + dir = dir or "Sounds" + local sample_file_ext = Platform.developer and "wav" or "opus" + type = type or ("*." .. sample_file_ext) + local samples = io.listfiles(dir, type, "recursive") + local normalized = {} + local rem_ext_pattern = "(.*)." .. sample_file_ext + for i=1,#samples do + local str = samples[i] + if str then + normalized[#normalized + 1] = str + end + end + return normalized +end + +function SoundStats:CalcUnusedSamples() + local files = ListSamples("Sounds") + local unused = {} + local used = self.walked_files + + for i, file in ipairs(files) do + if not used[file] then + table.insert(unused, file) + end + end + table.sort(unused) + return unused +end + +function SoundPreset:OnEditorSetProperty(prop_id, old_value, ged) + LoadSoundBank(self) + if SoundStatsInstance then + SoundStatsInstance:Refresh() + end + Preset.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +local function ApplySoundBlacklist(replace) + ForEachPreset(SoundPreset, function(sound) + for j = #sound, 1, -1 do + local sample = sound[j] + sample.file = replace[sample.file] or sample.file + end + end) +end + +function OnMsg.DataLoaded() + if config.ReplaceSound and rawget(_G, "ReplaceSound") then + ApplySoundBlacklist(ReplaceSound) + end + rawset(_G, "ReplaceSound", nil) + LoadSoundPresetSoundBanks() +end + +function OnMsg.DoneMap() + PauseSounds(2) +end + +function OnMsg.GameTimeStart() + ResumeSounds(2) +end + +function OnMsg.PersistPostLoad() + ResumeSounds(2) +end + +local function RegisterTestType() + if not config.SoundTypeTest or SoundTypePresets[config.SoundTypeTest] then return end + local preset = SoundTypePreset:new{ + options_group = "", + positional = false, + pause = false, + Comment = "Used when playing sounds from the sound editor" + } + preset:SetGroup("Test") + preset:SetId(config.SoundTypeTest) + preset:PostLoad() + g_PresetLastSavePaths[preset] = false +end + +function SoundGroupsCombo() + local items = table.icopy(config.SoundGroups) + table.insert(items, 1, "") + return items +end + +local debug_levels = { + { value = 1, text = "simple", help = "listener circle + vector to playing objects" }, + { value = 2, text = "normal", help = "simple + loud distance circle + volume visualization" }, + { value = 3, text = "verbose", help = "normal + sound texts for all map sound" }, +} + +DefineClass.SoundTypePreset = { + __parents = {"Preset"}, + properties = { + { id = "SaveIn", editor = false }, + { id = "options_group", editor = "choice", items = SoundGroupsCombo, name = "Options Group", default = Platform.ged and "" or config.SoundGroups[1] }, + { id = "channels", editor = "number", default = 1, min = 1 }, + { id = "importance", editor = "number", default = 0, min = -128, max = 127, slider = true, help = "Used when trying to replace a playing sound with different sound type." }, + { id = "volume", editor = "number", default = 100, min = 0, max = 300, slider = true}, -- TODO: write "help" items + { id = "ducking_preset", editor = "preset_id", name = "Ducking Preset", default = "NoDucking", preset_class = "DuckingParam", help = "Objects with lower ducking tier will reduce the volume of objects with higher ducking tier, when they are active. -1 tier is excluded from ducking" }, + { id = "GroupVolume", editor = "number", name = "Group Volume", default = const.MaxVolume, scale = const.MaxVolume / 100, read_only = true, dont_save = true }, + { id = "OptionsVolume", editor = "number", name = "Option Volume", default = const.MaxVolume, scale = const.MaxVolume / 100, read_only = true, dont_save = true }, + { id = "FinalVolume", editor = "number", name = "Final Volume", default = const.MaxVolume, scale = const.MaxVolume / 100, read_only = true, dont_save = true }, + { id = "fade_in", editor = "number", name = "Min Fade In (ms)", default = 0, help = "Min time to fade in the sound when it starts playing" }, + { id = "replace", editor = "bool", default = true, help = "Replace a playing sound if no free channels are available" }, + { id = "positional", editor = "bool", default = true, help = "Enable 3D (only for mono sounds)" }, + { id = "reverb", editor = "bool", default = false, help = "Enable reverb effects for these sounds" }, + { id = "enable", editor = "bool", default = true, help = "Disable all sounds from this type" }, + --{ id = "exclusive", editor = "bool", default = true, help = "Disable all other, non exclusive sounds" }, + { id = "pause", editor = "bool", default = true, help = "Can be paused" }, + { id = "restricted", editor = "bool", default = false, help = "Can be broadcast" }, + { id = "loud_distance", editor = "number", default = DefaultSoundLoudDistance, min = 0, max = MaxSoundLoudDistance, slider = true, scale = "m", help = "No attenuation below that distance (in meters). In case of zero the sound group loud_distance is used." }, + { id = "dbg_color", name = "Color", category = "Debug", editor = "color", default = 0, developer = true, help = "Used for sound debug using visuals", alpha = false }, + { id = "DbgLevel", name = "Debug Level", category = "Debug", editor = "set", default = set(), max_items_in_set = 1, items = debug_levels, developer = true, dont_save = true, help = "Change the sound debug level." }, + { id = "DbgFilter", name = "Use As Filter", category = "Debug", editor = "bool", default = false, developer = true, dont_save = true, help = "Set to sound debug filter." }, + }, + + GlobalMap = "SoundTypePresets", + GedEditor = "SoundTypeEditor", + EditorMenubarName = "Sound Type Editor", + EditorMenubar = "Editors.Audio", + EditorIcon = "CommonAssets/UI/Icons/headphones.png", +} + +function SoundTypePreset:GetDbgFilter() + return listener.DebugType == self.id +end + +function SoundTypePreset:SetDbgFilter(value) + if value then + listener.DebugType = self.id + if listener.Debug == 0 then + listener.Debug = 1 + end + elseif listener.DebugType == self.id then + listener.DebugType = "" + if listener.Debug == 1 then + listener.Debug = 0 + end + end +end + +function SoundTypePreset:GetDbgLevel() + if listener.Debug == 0 then + return set() + end + return set(listener.Debug) +end + +function SoundTypePreset:SetDbgLevel(value) + listener.Debug = next(value) or 0 +end + +function SoundTypePreset:GetEditorView() + local txt = " /" + if self.dbg_color ~= 0 then + local r, g, b = GetRGB(self.dbg_color) + txt = txt .. string.format(" |", r, g, b) + end + return Untranslated(txt) +end + +function SoundTypePreset:GetFinalVolume() + local _, final = GetTypeVolume(self.id) + return final +end + +function SoundTypePreset:GetGroupVolume() + return GetGroupVolume(self.group) +end + +function SoundTypePreset:GetOptionsVolume() + return GetOptionsGroupVolume(self.options_group) +end + +function SoundTypePreset:GetPresetSaveLocations() + return {{ text = "Common", value = "" }} +end + +function SoundTypePreset:GetSavePath() + return config.SoundTypesPath +end + +function OnMsg.ClassesBuilt() + ReloadSoundTypes(true) +end + +function SoundTypePreset:Setstereo(value) + self.stereo = value + if value then + self.positional = false + self.reverb = false + end +end + +function SoundTypePreset:Setreverb(value) + self.reverb = value + if value then + self.stereo = false + end +end + +function SoundTypePreset:Setpositional(value) + self.positional = value + if value then + self.stereo = false + end +end + +function ReloadSoundTypes(reload) + if reload then + for id, sound in pairs(SoundTypePresets) do + DoneObject(sound) + end + assert(not next(SoundTypePresets)) + LoadPresets(SoundTypePreset:GetSavePath()) -- sound types come from a single file + end + RegisterTestType() + ForEachPresetGroup(SoundTypePreset, function(group) + LoadSoundTypes(Presets.SoundTypePreset[group]) + end) + ApplySoundOptions(EngineOptions) + ObjModified(Presets.SoundTypePreset) +end + +------------------- Editor ----------------- + + +function OnMsg.GedOpened(ged_id) + local conn = GedConnections[ged_id] + if conn and conn.app_template == SoundTypePreset.GedEditor then + SoundTypeStatsInstance = SoundTypeStatsInstance or SoundTypeStats:new() + conn:BindObj("stats", SoundTypeStatsInstance) + SoundTypeStatsInstance:Refresh() + end +end + +function OnMsg.GedClosing(ged_id) + local conn = GedConnections[ged_id] + if conn.app_template == SoundTypePreset.GedEditor then + ReloadSoundTypes(true) + end +end + +function SoundTypePreset:OnEditorSetProperty(prop_id, old_value, ged) + if SoundTypeStatsInstance then + SoundTypeStatsInstance:Refresh() + end + Preset.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +----------------- Stats ------------------ +if FirstLoad then + SoundTypeStatsInstance = false +end + +DefineClass.SoundTypeStats = { + __parents = { "PropertyObject" }, + properties = { + -- Stats + { id = "total_channels", name = "Channels", editor = "number", default = 0, category = "Stats", read_only = true}, + }, +} + +function SoundTypeStats:Refresh() + + local total_channels = 0 + ForEachPreset(SoundTypePreset, function(sound_type) + total_channels = total_channels + sound_type.channels + end) + self.total_channels = total_channels + ObjModified(self) +end + +function SoundTypePreset:OnEditorSetProperty(obj, prop_id, old_value) + if SoundTypeStatsInstance then + SoundTypeStatsInstance:Refresh() + end +end \ No newline at end of file diff --git a/CommonLua/Classes/StateObject.lua b/CommonLua/Classes/StateObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..a88cbfb2c8e3bf7ee3c02df94d533fea730aa02e --- /dev/null +++ b/CommonLua/Classes/StateObject.lua @@ -0,0 +1,1136 @@ +DefineClass.StateObject = +{ + __parents = { "CooldownObj" }, + __hierarchy_cache = true, + + so_state = false, + so_enabled = false, + so_action = false, + so_action_param = false, + so_target = false, + so_active_zone = false, + so_anim_control_thread = false, + so_movement_thread = false, + so_state_time_start = 0, + so_target_trigger = false, + so_tick_enable = false, + so_tick_thread = false, + so_aitick_enable = true, + so_aitick_thread = false, + so_context = false, + so_changestateidx = 0, -- times state has been changed + so_prev_different_state_id = false, -- TODO: for debug only, remove this member + so_next_state_id = false, + so_trigger_object = false, + so_trigger_action = false, + so_trigger_target = false, + so_trigger_target_pos = false, + so_cooldown = "", + so_buffered_trigger = false, + so_buffered_trigger_object = false, + so_buffered_trigger_action = false, + so_buffered_trigger_target = false, + so_buffered_trigger_target_pos = false, + so_buffered_trigger_time = false, + so_buffered_times = false, + + so_debug_triggers = config.StateObjectTraceLog or false, + so_state_start_time = false, + so_changestateidx_at_start_time = 0, -- so_changestateidx when first change_state in this tick + + so_states = false, + + so_state_debug = false, + so_compensate_anim_rotation = {}, + so_compensate_angle = false, + so_context_sync = false, + so_state_destructors = false, + so_step_modifier = 100, + so_speed_modifier = 100, + so_state_anim_speed_modifier = 100, + so_action_start_passed = false, + so_action_hit_passed = false, + so_wait_phase_threads = false, + so_repeat = false, + so_repeat_thread = false, + so_net_sync_stateidx = false, + so_net_nav = false, + so_net_pos = false, + so_net_pos_time = false, + so_net_nav_sent_time = false, + so_net_target = false, + so_net_target_sent_time = false, + so_net_target_thread = false, +} + +function FindStateSet( class ) + local check_classes = { class } + + while #check_classes > 0 do + local name = table.remove(check_classes, 1) + local set = DataInstances.StateSet[name] + + if set then + return set + end + + local parents = _G[name].__parents + for i = 1, #parents do + table.insert(check_classes, parents[i]) + end + end +end + +function StateObject:Init() + local set = FindStateSet( self.class ) + if set then + self.so_states = set.so_states + end + + --[[ + -- find the state set + local check_classes = { self.class } + while #check_classes > 0 do + local name = table.remove(check_classes, 1) + local set = DataInstances.StateSet[name] + if set then + self.so_states = set.so_states + break + end + local parents = _G[name].__parents + for i = 1, #parents do + table.insert(check_classes, parents[i]) + end + end + ]] + if Platform.developer then + if not self.so_tick_enable then + for state_name, state_data in sorted_pairs(self.so_states) do + for i_trigger = 1, #state_data do + local trigger = state_data[i_trigger].trigger + if trigger == "Tick" then + assert(false, string.format("\"Tick\" trigger used in state \"%s\" of state object \"%s\" with \"so_tick_enable = false\"", state_name, self.class)) + break + end + end + end + end + end +end + +function StateObject:Done() + if self.so_state then + self:ChangeState(false) + if self.so_state then + printf("ERROR: %s entered state %s on destroy!", self.class, self.so_state.name) + end + end +end + +function StateObject:PushStateDestructor(dtor) + local destructors = self.so_state_destructors + if destructors then + local count = destructors[1] + 1 + destructors[1] = count + destructors[count + 1] = dtor + else + self.so_state_destructors = { 1, dtor } + end +end + +function StateObject:ChangeSet(name) + local set = DataInstances.StateSet[name] + if set then + self.so_states = set.so_states + end +end + +function StateObject:ChangeStateIfDifferent(state_id) + if not self.so_state or self.so_state.name ~= state_id then + self:ChangeState(state_id) + end +end + +MaxChangeStates = 10 +CriticChangeStates = 15 + +function StateObject:InternalChangeState(state_id, is_triggered, trigger_object, trigger_action, trigger_target, trigger_target_pos, forced_target, forced_target_state) + if self.so_debug_triggers then + self:Trace("[StateObject1]State changed {1} prev {2}\n{3}", state_id, self.so_state, trigger_object, trigger_action, trigger_target, trigger_target_pos, self:GetPos(), self:GetAngle(), GetStack(1)) + end + self.so_enabled = (state_id or "") ~= "" + self.so_next_state_id = state_id + if self.so_state then + if self.so_cooldown ~= "" then + self:SetCooldown(self.so_cooldown) + end + local destructors = self.so_state_destructors + local count = destructors and destructors[1] or 0 + while count > 0 do + local dtor = destructors[count + 1] + destructors[count + 1] = false + destructors[1] = count - 1 + dtor(self) + count = destructors[1] + end + if self.so_action_start_passed then + self.so_action_start_passed = false + local stateidx = self.so_changestateidx + self:StateActionMoment("end") + if stateidx ~= self.so_changestateidx then return end + end + end + if self.so_compensate_angle then + self:SetAngle(self:GetAngle() + self.so_compensate_angle, 0) + self.so_compensate_angle = false + end + local state = false + if (state_id or "") ~= "" then + state = self.so_states[state_id] + if not state then + printf('%s invalid state "%s"', self.class, state_id) + self:InternalChangeState(self.so_states.Error and "Error" or false) + return + end + end + self.so_changestateidx = self.so_changestateidx + 1 + local stateidx = self.so_changestateidx + self.so_next_state_id = false + if is_triggered and self.so_state_start_time == GameTime() then + local times = self.so_changestateidx - self.so_changestateidx_at_start_time + if times > MaxChangeStates then + local error_msg = string.format('%s has too many state changes: "%s" --> "%s" --> "%s"', self.class, tostring(self.so_prev_different_state_id), self.so_state and self.so_state.name or "false", tostring(state_id)) + assert(times ~= MaxChangeStates + 1, error_msg) -- only once + print(error_msg) + if self.so_changestateidx - self.so_changestateidx_at_start_time > CriticChangeStates then + if state_id and state_id ~= "Error" then + self:InternalChangeState(self.so_states.Error and "Error" or false) + return + end + end + end + else + self.so_state_start_time = GameTime() + self.so_changestateidx_at_start_time = self.so_changestateidx + end + self:WakeupWaitPhaseThreads() + + self:SetMoveSys(false) + if self.so_anim_control_thread and self.so_anim_control_thread ~= CurrentThread() then + DeleteThread(self.so_anim_control_thread) + end + self.so_anim_control_thread = nil + self.so_repeat = nil + self.so_repeat_thread = nil + self.so_net_target = nil + self.so_net_target_sent_time = nil + if self.so_net_target_thread then + DeleteThread(self.so_net_target_thread) + self.so_net_target_thread = nil + end + + Msg(self) + + self.so_target_trigger = false + + if not state then + -- disable state machine + self.so_state = nil + self.so_action = nil + self.so_action_param = nil + self.so_target = nil + self.so_active_zone = nil + self.so_state_time_start = nil + self.so_buffered_trigger = nil + self.so_buffered_trigger_time = nil + self.so_trigger_object = nil + self.so_trigger_action = nil + self.so_trigger_target = nil + self.so_trigger_target_pos = nil + self.so_buffered_times = nil + if CurrentThread() ~= self.so_tick_thread then + DeleteThread(self.so_tick_thread) + end + self.so_tick_thread = nil + if CurrentThread() ~= self.so_aitick_thread then + DeleteThread(self.so_aitick_thread) + end + self.so_aitick_thread = nil + self.so_step_modifier = nil + self.so_state_anim_speed_modifier = nil + self:SetModifiers("StateAction", nil) + self:NewStateStarted() + return + end + local same_state = self.so_state == state + if not same_state then + self.so_prev_different_state_id = self.so_state and self.so_state.name or false -- TODO: debug, remove this line + self.so_state = state + end + if self.so_state_debug then + self.so_state_debug:SetText(self.so_state.name) + end + self.so_action = self:GetStateAction() + self.so_action_param = false + + -- Start + self.so_state_time_start = GameTime() + self.so_active_zone = "Start" + self.so_state_time_start = GameTime() + self.so_action_hit_passed = false + + self:StateChanged() + + -- Target + local target = forced_target + if forced_target == nil then + target = self:FindStateTarget(self.so_state, trigger_object, trigger_action, trigger_target, trigger_target_pos) + if stateidx ~= self.so_changestateidx then + return + end + end + self.so_trigger_object = trigger_object + self.so_trigger_action = trigger_action + self.so_trigger_target = trigger_target + self.so_trigger_target_pos = trigger_target_pos + self:SetStateTarget(target) + self.so_net_target = self.so_target + self.so_cooldown = self.so_state:GetInheritProperty(self, "cooldown") or "" + + -- Startup triggers + self:RaiseTrigger("Start", trigger_object, trigger_action, trigger_target, trigger_target_pos) + if stateidx ~= self.so_changestateidx then + return -- this function calls itself recursively and should exit when state is changed + end + self:ExecBufferedTriggers() + if stateidx ~= self.so_changestateidx then + return -- this function calls itself recursively and should exit when state is changed + end + if self.so_tick_enable then + self:RaiseTrigger("Tick") + if stateidx ~= self.so_changestateidx then + return -- this function calls itself recursively and should exit when state is changed + end + end + self:RaiseTrigger("AITick") + if stateidx ~= self.so_changestateidx then + return -- this function calls itself recursively and should exit when state is changed + end + + if forced_target_state then + target:InternalChangeState(forced_target_state, is_triggered, self, false, false, false, self) + if stateidx ~= self.so_changestateidx then + return -- this function calls itself recursively and should exit when state is changed + end + else + local target_trigger = self.so_state:GetInheritProperty(self, "target_trigger") + if target_trigger and target_trigger ~= "" then + if self:RaiseTargetTrigger(target_trigger) then + self.so_target_trigger = target_trigger + end + if stateidx ~= self.so_changestateidx then + return -- this function calls itself recursively and should exit when state is changed + end + end + end + if self.so_tick_enable and not self.so_tick_thread then + self.so_tick_thread = CreateGameTimeThread(function(self) + while self.so_tick_thread == CurrentThread() do + Sleep(33) + self:RaiseTrigger("Tick") + end + end, self) + ThreadsSetThreadSource(self.so_tick_thread, "Tick trigger") + end + if self.so_aitick_enable and not self.so_aitick_thread then + self.so_aitick_thread = CreateGameTimeThread(function(self) + Sleep(BraidRandom(self.handle, 500)) + while self.so_aitick_thread do + self:RaiseTrigger("AITick") + Sleep(500) + end + end, self) + ThreadsSetThreadSource(self.so_aitick_thread, "AITick trigger") + end + + if self.so_state.restore_axis then + self:SetAxis(axis_z, 100) + end + + local state_lifetime = self.so_state:GetInheritProperty(self, "state_lifetime") + if state_lifetime == "" then + state_lifetime = "animation" + end + local anim = self.so_state:GetAnimation(self) + self.so_compensate_angle = self.so_compensate_anim_rotation[anim] or false + if anim ~= "" and not self:HasState(anim) then + printf("Invalid animation %s for %s!", anim, self.class) + if state_lifetime == "animation" then + self:NextState() + return + end + end + if state_lifetime == "animation" and anim == "" then + state_lifetime = "movement" -- backward compatibility + end + self:NewStateStarted() + self:SetStateAnim(self.so_state, anim) + local movement = self.so_state:GetInheritProperty(self, "movement") + self:SetMoveSys(movement, state_lifetime == "movement") + if stateidx ~= self.so_changestateidx then return end + if state_lifetime == "animation" then + self:StartStateAnimControl() + else + local state_duration = tonumber(state_lifetime) + if state_duration then + self.so_anim_control_thread = CreateGameTimeThread(function(self, stateidx, state_duration) + Sleep(state_duration) + if stateidx ~= self.so_changestateidx then return end + self:NextState() + end, self, stateidx, state_duration) + end + end + self:SetModifiers("StateAction", self.so_action and self.so_action.modifiers) + local animation_step = self.so_state:GetInheritProperty(self, "animation_step") + self.so_step_modifier = tonumber(animation_step) + local animation_speed = self.so_state:GetInheritProperty(self, "animation_speed") + if animation_speed == "" then + animation_speed = 100 + elseif StateAnimationSpeedCorrection[animation_speed] then + animation_speed = StateAnimationSpeedCorrection[animation_speed](self) + else + animation_speed = tonumber(animation_speed) + end + self.so_state_anim_speed_modifier = animation_speed + self:UpdateAnimSpeed() + self:StateActionMoment("start") +end + +function StateObject:StateChanged() +end + +function StateObject:NewStateStarted() + self:ClearPath() +end + +function StateObject:StartStateAnimControl(obj) + local stateidx = self.so_changestateidx + self.so_anim_control_thread = CreateGameTimeThread(function(self, obj, stateidx) + obj = obj or self + -- PreAction + obj:WaitPhase(obj:GetStateAnimPhase("Start")) + -- Action + self:StateActionMoment("action") + if stateidx ~= self.so_changestateidx then return end + obj:WaitPhase(obj:GetStateAnimPhase("Hit")) + -- Hit + self:StateActionMoment("hit") + if stateidx ~= self.so_changestateidx then return end + obj:WaitPhase(obj:GetStateAnimPhase("End")) + -- PostAction + self:StateActionMoment("post-action") + if stateidx ~= self.so_changestateidx then return end + obj:WaitPhase(obj:GetLastPhase()) + -- End state + if stateidx ~= self.so_changestateidx then return end + self:NextState() + end, self, obj, stateidx) + ThreadsSetThreadSource(self.so_anim_control_thread, "Animation control") +end + +function StateObject:SetStateAnim(state, anim, flags, crossfade, animation_phase) + anim = anim or state:GetAnimation(self) + if anim == "" then return end + animation_phase = animation_phase or state:GetInheritProperty(self, "animation_phase") + local old_anim = self:GetAnim(1) + local same_anim = old_anim == EntityStates[anim] + if same_anim and (animation_phase == "KeepSameAnimPhase" or animation_phase == "KeepSameAnimPhaseBeforeHit" and self:GetAnimPhase(1) < self:GetStateAnimPhase("Hit", anim, state)) then + return + end + if not flags then + flags = 0 + end + if not crossfade then + if same_anim and self:IsAnimLooping(1) then + crossfade = -1 + else + local animation_blending = state:GetInheritProperty(self, "animation_blending") + local dontCrossfade = self.so_compensate_anim_rotation[anim] or animation_blending == "no" + crossfade = dontCrossfade and 0 or -1 + end + end + self:SetAnim(1, anim, flags, crossfade) + + if Platform.developer and not same_anim and SelectionEditorShownSpots[self] and self:GetEntity() then + if GetStateMeshFile(self:GetEntity(), old_anim) ~= GetStateMeshFile(self:GetEntity(), EntityStates[anim]) then + local window, window_id = PropEditor_GetFirstWindow("SelectionEditor") + if window then + local selection_editor = window.main_obj + if selection_editor:IsKindOf("SelectionEditor") and selection_editor[1] == self then + selection_editor:ToggleSpots() + selection_editor:ToggleSpots() + end + end + end + end + + local start_phase + if animation_phase ~= "" and not (same_anim and animation_phase == "Random") then + start_phase = self:GetStateAnimPhase(animation_phase, anim, state) + end + if start_phase and start_phase > 0 then + self:SetAnimPhase(1, start_phase) + end +end + +function StateObject:GetLastPhase(channel) + local duration = GetAnimDuration(self:GetEntity(), self:GetAnim(channel or 1)) + return duration-1 +end + +function StateObject:WaitPhase(phase) + local cur_phase = self:GetAnimPhase(1) + local last_phase = self:GetLastPhase(1) + phase = Min(phase, last_phase) + + local t = self:TimeToPhase(1, phase) or 0 + if t == 0 then + return true + end + self.so_wait_phase_threads = self.so_wait_phase_threads or {} + table.insert(self.so_wait_phase_threads, CurrentThread()) + local stateidx = self.so_changestateidx + local interrupted + while true do + WaitWakeup(t) + if not IsValid(self) or stateidx ~= self.so_changestateidx then + interrupted = true + break + end + t = self:TimeToPhase(1, phase) or 0 + if t == 0 then + break + end + if self:IsAnimLooping(1) then + local prev_phase = cur_phase + cur_phase = self:GetAnimPhase(1) + if cur_phase >= prev_phase then + if phase >= prev_phase and phase <= cur_phase then + break + end + elseif phase <= cur_phase or phase >= prev_phase then + break + end + end + end + table.remove_value(self.so_wait_phase_threads, CurrentThread()) + return not interrupted +end + +function StateObject:UpdateAnimSpeed(mod) + mod = mod or self.so_state_anim_speed_modifier + self.so_speed_modifier = mod + self:SetAnimSpeed(1, mod * 10) + self:SetAnimSpeed(2, mod * 10) + self:SetAnimSpeed(3, mod * 10) + self:WakeupWaitPhaseThreads() + if self:IsKindOf("AnimMomentHook") then + self:AnimMomentHookUpdate() + end +end + +function StateObject:WakeupWaitPhaseThreads() + local wait_phase_threads = self.so_wait_phase_threads + if not wait_phase_threads then return end + for i = #wait_phase_threads, 1, -1 do + if not Wakeup(wait_phase_threads[i]) then + table.remove(wait_phase_threads, i) + end + end +end + +function StateObject:ChangeState(...) + return self:InternalChangeState(...) +end + +function StateObject:NetSyncState(trigger_id, trigger) + if not netInGame or not self.so_state or self.so_net_sync_stateidx == self.so_changestateidx then + return + end + local net_nav, trigger_target_pos, channeling_stopped, split_time, torso_angle + if NetIsLocal(self) then + if not (trigger and trigger.net_sync) and trigger_id and not State.net_triggers[trigger_id] then + return + end + net_nav = self:GetStateContext("navigation_vector") + self.so_net_nav_sent_time = GameTime() + trigger_target_pos = IsPoint(self.so_trigger_target_pos) and self.so_trigger_target_pos + if self:IsKindOf("Hero") then + channeling_stopped = self:IsChannelingStopped() + split_time = self.split_time + if self.torso_control then + torso_angle = self.torso_obj:GetAngle() + end + end + elseif IsKindOf(self, "Monster") then + if not (trigger and trigger.net_sync) then + if not self.so_action or not self.so_action:IsMonsterNetSyncAction(self) or not NetIsLocal(self.monster_target) then + return + end + end + else + return + end + self.so_net_sync_stateidx = self.so_changestateidx + self.so_net_pos = self:IsValidPos() and self:GetVisualPos() + self.so_net_pos_time = GameTime() + --printf("Net sync state: %s, %s, pos=%s", self.class, (self.so_state and self.so_state.name or "false"), tostring(self.so_net_pos)) + local step_time = self:TimeToPosInterpolationEnd() + if step_time == 0 then step_time = nil end + local step = step_time and self:GetPos() - self:GetVisualPos() + local angle, attack_angle = self:GetAngle(), self:GetAttackAngle() + local target = self.so_target and (IsPoint(self.so_target) and self.so_target or NetValidate(self.so_target)) or false + local state_handle = self.so_state and StateHandles[self.so_state.name] + + NetEventOwner("ChangeState", + self, state_handle, target, trigger_target_pos, + self.so_net_pos or nil, step, step_time, angle, attack_angle, + net_nav, channeling_stopped, split_time, torso_angle + ) +end + +function StateObject:RaiseTargetTrigger(trigger_id) + if trigger_id and trigger_id ~= "" then + local target = self.so_target + if IsValid(target) and target:IsKindOf("StateObject") then + local trigger_processed = target:RaiseTrigger(trigger_id, self) + if trigger_processed then + return true + end + end + end + --printf("Target trigger failed: %s / %s -> %s -> %s / %s / %s", self.class, self.so_state.name, trigger_id, target and target.class or "no target", target and target.so_state and target.so_state.name or "no state", not target and "" or tostring(target.so_active_zone)) +end + +local cached_empty_table +function StateObject:RaiseTrigger(trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed, level) + level = level or 0 + assert(level < 128) + if level >= 130 then + return + end + local state = self.so_state + if not state or not self.so_enabled then + return -- state machine disabled + end + if not trigger_id or trigger_id == "" then + return + end + time_passed = time_passed or 0 + if self.so_debug_triggers then + if trigger_id == "AITick" or trigger_id == "Tick" or trigger_id == "Start" or trigger_id == "Action" or trigger_id == "Hit" or trigger_id == "End" or trigger_id == "PostAction" then + self:Trace("[StateObject2]RaiseTrigger {1} current state {2} trigger object {3}", trigger_id, state, trigger_object, trigger_action, trigger_target, trigger_target_pos) + else + self:Trace("[StateObject1]RaiseTrigger {1} current state {2} trigger object {3}", trigger_id, state, trigger_object, trigger_action, trigger_target, trigger_target_pos) + end + end + + -- combo buffer could be kept, and various combo length combinations finishing with trigger_id could be tested + local matched_triggers = cached_empty_table or {} + cached_empty_table = nil + local trigger = state:ResolveTrigger(self, trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed, false, matched_triggers) + if #matched_triggers == 0 then + cached_empty_table = matched_triggers + end + local state_id = trigger and trigger.state + if trigger_id == "Start" and state.name == state_id then + return + end + local consumed = false + local buffer_trigger = State.buffered_triggers[trigger_id] + if buffer_trigger then + self.so_buffered_trigger = false + end + + -- run functions of matched triggers (including "copy_triggers") + for i = 1, #matched_triggers do + local trig = matched_triggers[i] + if trig.cooldown_to_set ~= "" then + consumed = true + self:SetCooldown(trig.cooldown_to_set) + end + if trig.next_state ~= "" then + consumed = true + self.so_next_state_id = trig.next_state + end + if trig.func ~= "" then + if trig.state ~= "continue" then + consumed = true + end + local f = TriggerFunctions[trig.func] + if f then + local stateidx = self.so_changestateidx + local ret = f(trig, self, trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed) + if stateidx ~= self.so_changestateidx then + self:NetSyncState(trigger_id, trig) + return true -- the function could decide to change state + elseif ret and ret == "break" then + return true --the function asked us to terminate this trigger chain + end + else + print("Unknown trigger function " .. trig.func) + end + end + if trig.raise_trigger ~= "" then + local stateidx = self.so_changestateidx + self:RaiseTrigger(trig.raise_trigger, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed, level + 1) + if stateidx ~= self.so_changestateidx then + return true + end + end + end + + if state_id and state_id ~= "" and state_id ~= "break" and state_id ~= "continue" and (state_id ~= state.name or self.so_active_zone ~= "Start" or self.so_target ~= self:FindStateTarget(self.so_state, trigger_object, trigger_action, trigger_target, trigger_target_pos)) then + if state_id ~= "consume_trigger" then + self:ChangeState(state_id, true, trigger_object, trigger_action, trigger_target, trigger_target_pos) + local inherit_trigger_id = trigger and trigger.trigger + if inherit_trigger_id ~= trigger_id then + -- recast the original trigger to allow base trigger processing and dispath + -- the object should be in Start zone to check for dispatch + if self.so_debug_triggers then + self:Trace("[StateObject2]Inherited trigger " .. inherit_trigger_id) + end + local old_zones = self.so_active_zone + self.so_active_zone = "Start" + local trigger_processed = self:RaiseTrigger(trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed, level+1) + if not trigger_processed then + self.so_active_zone = old_zones + end + end + self:NetSyncState(trigger_id, trigger) + end + return true + end + if consumed then + return true + end + if buffer_trigger then + self:BufferTrigger(trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos) + end +end + +function StateObject:ApplyMatchedTrigger(handle, trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed) + if not TriggerHandles then + assert(false, "Trigger handles not defined!") + return + end + local trig = TriggerHandles[handle] + if not trig then + assert(false, "Error resolving trigger from handle") + return + end + local function_id = trig.func + local cooldown_to_set = trig.cooldown_to_set + if cooldown_to_set ~= "" then + self:SetCooldown(cooldown_to_set) + end + if function_id and function_id ~= "" then + local f = TriggerFunctions[function_id] + if f then + f(trig, self, trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos, time_passed) + else + print("Unknown trigger function " .. function_id) + end + end + if trig.next_state ~= "" then + self.so_next_state_id = trig.next_state + end +end + +function StateObject:IsStateChangeAllowed() + return true +end + +function StateObject:BufferTrigger(trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos) + self.so_buffered_trigger = trigger_id + self.so_buffered_trigger_object = trigger_object + self.so_buffered_trigger_action = trigger_action + self.so_buffered_trigger_target = trigger_target + self.so_buffered_trigger_target_pos = trigger_target_pos + self.so_buffered_trigger_time = GameTime() + self.so_buffered_times = self.so_buffered_times or {} + self.so_buffered_times[trigger_id] = self.so_buffered_trigger_time +end + +function StateObject:ExecBufferedTriggers() + local trigger_id = self.so_buffered_trigger + if not trigger_id then + return + end + self.so_buffered_trigger = false + local trigger_time = self.so_buffered_trigger_time + local time_passed = GameTime() - trigger_time + if time_passed > State.triggers_buffered_time then + return -- expired + end + if self:RaiseTrigger(trigger_id, self.so_buffered_trigger_object, self.so_buffered_trigger_action, self.so_buffered_trigger_target, self.so_buffered_trigger_target_pos, time_passed) then + -- trigger consumed + else + -- restore the buffered trigger when not processed + self.so_buffered_trigger_time = trigger_time + end +end + +function StateObject:IsTriggerResolved(trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos) + local state = self.so_state + local trigger = state and state:ResolveTrigger(self, trigger_id, trigger_object, trigger_action, trigger_target, trigger_target_pos) + if trigger then + local state_id, function_id = trigger.state, trigger.func + if function_id and function_id ~= "" or state_id and state_id ~= "" and state_id ~= "break" and state_id ~= "continue" then + return true + end + end + return false +end + +function StateObject:FindStateTarget(state, trigger_object, trigger_action, trigger_target, trigger_target_pos, debug_level) + local target + local target_group = state:GetInheritProperty(self, "target") + if target_group ~= "" then + target = StateTargets[target_group](self, state, trigger_object, trigger_action, trigger_target, trigger_target_pos, debug_level) + end + return target or false +end + +function StateObject:SetStateTarget(target) + if self.so_debug_triggers then + self:Trace("[StateObject1]Target {1} for state {2}", target, self.so_state) + end + self.so_target = target +end + +function StateObject:ChangeStateTarget(target) + if self.so_target == (target or false) then + return + end + if self.so_target and not IsPoint(self.so_target) then + self:StateActionMoment("target_lost") + end + self:SetStateTarget(target) + if self.so_target and not IsPoint(self.so_target) then + self:StateActionMoment("new_target") + end +end + +function StateObject:NextState() + local next_state_id = self.so_next_state_id or self.so_state:GetInheritProperty(self, "next_state") + local elapsed_time = GameTime() - self.so_state_time_start + if elapsed_time == 0 or not self.so_context_sync then + if next_state_id == self.so_state.name and elapsed_time == 0 then + printf('%s hangs in state "%s"', self.class, next_state_id) + Sleep(1000) + end + self:InternalChangeState(next_state_id, true) + else + self:ChangeState(next_state_id) + end +end + +function StateObject:SetMoveSys(movement, next_state_on_finish) + if CurrentThread() ~= self.so_movement_thread then + DeleteThread(self.so_movement_thread) + end + local move_sys = movement and (type(movement) == "function" and movement or StateMoveSystems[movement]) + if not move_sys then + self.so_movement_thread = nil + if next_state_on_finish then + self:NextState() + end + return + end + local stateidx = self.so_changestateidx + self.so_movement_thread = CreateGameTimeThread(function(self, stateidx, next_state_on_finish, move_sys) + if stateidx == self.so_changestateidx then + move_sys(self) + end + if next_state_on_finish and stateidx == self.so_changestateidx then + self:NextState() + end + end, self, stateidx, next_state_on_finish, move_sys) + ThreadsSetThreadSource(self.so_movement_thread, "Movement system") +end + +function StateObject:ModifyStateContext(id, value) + self:SetStateContext(id, (self:GetStateContext(id) or 0) + value) +end + +function StateObject:SetStateContext(id, value) + local so_context = self.so_context + local old_value + if not so_context then + so_context = {} + self.so_context = so_context + else + old_value = so_context[id] + end + so_context[id] = value + return old_value +end + +function StateObject:GetStateContext(id) + local so_context = self.so_context + if so_context then + return so_context[id] + end +end + +function StateObject:HasCooldown(cooldown_id) + if (cooldown_id or "") == "" then + return false + end + if self.so_cooldown == cooldown_id then + return true + end + return CooldownObj.HasCooldown(self, cooldown_id) +end + +function StateObject:GetStateAction(state_id) + local state + if state_id then + state = self.so_states[state_id] + else + state = self.so_state + end + local classname = state and state:GetInheritProperty(self, "action") + return classname and classname ~= "" and g_Classes[classname] +end + +function StateObject:StateActionMoment(moment, ...) + local stateidx = self.so_changestateidx + local trigger, buffered_triggers, ai_tick + if moment == "start" then + -- the triggers are raised earlier on state change + self.so_action_start_passed = true + elseif moment == "action" then + self.so_active_zone = "Action" + trigger = "Action" + ai_tick = true + elseif moment == "hit" then + self.so_active_zone = "Action" + self.so_action_hit_passed = true + trigger = "Hit" + elseif moment == "post-action" then + if not self.so_action_hit_passed then + self:StateActionMoment("hit") + end + self.so_active_zone = "PostAction" + trigger = "PostAction" + buffered_triggers = true + ai_tick = true + elseif moment == "end" then + if not self.so_action_hit_passed then + self:StateActionMoment("interrupted") + end + if self.so_target then + self:StateActionMoment("target_lost") + end + trigger = "End" + end + local action = self.so_action + if action then + action:Moment(moment, self, ...) + if stateidx ~= self.so_changestateidx then return end + end + if buffered_triggers then + self:ExecBufferedTriggers() + if stateidx ~= self.so_changestateidx then return end + end + if trigger then + self:RaiseTrigger(trigger) + if stateidx ~= self.so_changestateidx then return end + end + if ai_tick then + self:RaiseTrigger("AITick") + if stateidx ~= self.so_changestateidx then return end + end + if moment == "start" then + if IsValid(self.so_target) then + self:StateActionMoment("new_target") + if stateidx ~= self.so_changestateidx then return end + end + self.so_active_zone = "PreAction" + end +end + +function StateObject:GetStateAnimPhase(id, anim, state) + if id == "" then return 0 end + state = state == nil and self.so_state or state + anim = anim or state and state:GetAnimation(self) + anim = anim ~= "" and anim or self:GetStateText() + local phase + if id == "Hit" then + if state and state:GetInheritProperty(self, "override_moments") == "yes" then + local prop = state:GetInheritProperty(self, "animation_hit") + phase = tonumber(prop) + end + if not phase or phase < 0 then + phase = self:GetAnimMoment(anim, id) or 0 + end + elseif id == "Start" then + if state and state:GetInheritProperty(self, "override_moments") == "yes" then + local prop = state:GetInheritProperty(self, "animation_start") + phase = tonumber(prop) + end + if not phase or phase < 0 then + phase = self:GetAnimMoment(anim, id) or 0 + end + elseif id == "End" then + if state and state:GetInheritProperty(self, "override_moments") == "yes" then + local prop = state:GetInheritProperty(self, "animation_end") + phase = tonumber(prop) + end + if not phase or phase < 0 then + phase = self:GetAnimMoment(anim, id) or GetAnimDuration(self:GetEntity(), anim) - 1 + end + elseif id == "LastPhase" then + phase = GetAnimDuration(self:GetEntity(), anim) - 1 + elseif id == "Random" then + phase = self:StateRandom(GetAnimDuration(self:GetEntity(), anim)) + elseif type(id) == "number" then + phase = id + elseif id == "TargetHit" then + if IsValid(self.so_target) then + phase = self.so_target:GetStateAnimPhase("Hit") + end + else + phase = self:GetAnimMoment(anim, id) + end + return phase or 0 +end + +function StateObject:TimeToStartMoment() + local phase = self:GetStateAnimPhase("Start") + local time = self:TimeToPhase(1, phase) + return time +end + +function StateObject:TimeToEndMoment() + local phase = self:GetStateAnimPhase("End") + local time = self:TimeToPhase(1, phase) + return time +end + +function StateObject:TimeToHitMoment() + local phase = self:GetStateAnimPhase("Hit") + local time = self:TimeToPhase(1, phase) + return time +end + +function StateObject:WaitEndMoment() + local phase = self:GetStateAnimPhase("End") + local result = self:WaitPhase(phase) + return result +end + +function StateObject:WaitHitMoment() + local phase = self:GetStateAnimPhase("Hit") + local result = self:WaitPhase(phase) + return result +end + +function StateObject:WaitStateChanged() + WaitMsg(self) +end + +function StateObject:WaitStateExit(state) + while self.so_state and self.so_state.name == state do + WaitMsg(self) + end +end + +function StateObject:StateDebug(show) + if self.so_state_debug and not show then + self.so_state_debug:delete() + self.so_state_debug = false + elseif not self.so_state_debug and show then + self.so_state_debug = Text:new() + self:Attach(self.so_state_debug) + self.so_state_debug:SetText(self.so_state.name) + end +end + +function StateObject:StateRandom(range, seed) + seed = seed or self.so_state and self.so_state.seed or 0 + seed = xxhash(seed, MapLoadRandom, self.handle) + return (BraidRandom(seed, range)) +end + +function StateObject:StateRepeat(interval, func, ...) + self.so_repeat = self.so_repeat or {} + table.insert(self.so_repeat, { GameTime(), interval, func, ...}) + if IsValidThread(self.so_repeat_thread) then + Wakeup(self.so_repeat_thread) + return + end + self.so_repeat_thread = CreateGameTimeThread(function(self) + while self.so_repeat_thread == CurrentThread() do + local next_update + local game_time = GameTime() + local list = self.so_repeat + local i = 1 + while i <= #list do + local rep = list[i] + local dt = rep[1] - game_time + if dt == 0 then + dt = rep[3](self.so_action, self, unpack_params(rep, 4)) + if self.so_repeat_thread ~= CurrentThread() then + return -- the state is finished + end + if dt == nil then dt = rep[2] end + if dt and dt >= 0 then + rep[1] = game_time + dt + else + dt = false + table.remove(list, i) + i = i - 1 + end + end + if dt and (not next_update or next_update > dt) then + next_update = dt + end + i = i + 1 + end + if not next_update then + return + end + WaitWakeup(next_update) + end + end, self) +end + +-- MULTIPLAYER SYNCHRONIZATION: +function StateObject:GetDynamicData(data) + local state_id = self.so_state and StateHandles[self.so_state.name] + if state_id then + data.state_id = state_id + data.target = self.so_target and (IsPoint(self.so_target) and self.so_target or NetValidate(self.so_target)) or nil + if self.so_context and next(self.so_context) ~= nil then + data.context = self.so_context + end + data.so_next_state_id = self.so_next_state_id and StateHandles[self.so_next_state_id] or nil + data.trigger_target_pos = self.so_trigger_target_pos or nil + end +end + +function StateObject:SetDynamicData(data) + local state_id = data.state_id and StateHandles[data.state_id] + if state_id then + self:InternalChangeState(state_id, false, + false, + false, + false, + data.trigger_target_pos or false, + data.target or false) + self.so_next_state_id = data.so_next_state_id and StateHandles[data.so_next_state_id] + end +end diff --git a/CommonLua/Classes/StateTriggerSource.lua b/CommonLua/Classes/StateTriggerSource.lua new file mode 100644 index 0000000000000000000000000000000000000000..d94f0387af27c0b2924f5988541dd96645a18146 --- /dev/null +++ b/CommonLua/Classes/StateTriggerSource.lua @@ -0,0 +1,592 @@ +ComboButtonsDelay = 100 +KeyDoubleClickTime = 300 + +DefineClass.TriggerSource = { + __parents = { "InitDone" }, + trigger_target = false, +} + +function TriggerSource:Init(target) + self.trigger_target = target +end + +function TriggerSource:RaiseTrigger(trigger, source) + if not trigger then return end + local target = self.trigger_target + + if source then + target:SetStateContext("trigger_source", source) + end + if type(trigger) == "table" then + for i = 1, #trigger do + local trigger_processed = target:RaiseTrigger(trigger[i]) + if trigger_processed then + break + end + end + else + target:RaiseTrigger(trigger) + end + + return true +end + +function TriggerSource:SetTargetContext(context, value) + local target = self.trigger_target + + if type(context) == "table" then + local n = #context + if n > 0 then + for i = 1, n do + target:SetStateContext(context[i], value) + end + return true + end + elseif type(context) == "string" then + target:SetStateContext(context, value) + return true + end +end + + +DefineClass.TerminalTriggerSource = +{ + __parents = { "TriggerSource", "OldTerminalTarget" }, + active = true, + controller_id = false, + last_combo_button = false, + last_combo_button_time = false, + HoldButtonTime = 350, + held_buttons = false, + + x_triggers = false, + x_combo_buttons = false, + x_triggers_up = false, + x_triggers_hold = false, + x_contexts = false, + + kb_triggers = false, + kb_triggers_up = false, + kb_triggers_hold = false, + kb_triggers_double_click = false, + kb_triggers_modifiers = false, + kb_contexts = false, + last_key = false, + last_key_time = false, + + mouse_triggers = false, + mouse_triggers_up = false, + mouse_triggers_hold = false, + mouse_contexts = false, + + terminal_target_priority = -500, +} + +-- should be predifined in games +if FirstLoad then + KeyboardTriggers = { down = {}, up = {}, hold = {}, contexts = {}, double_click = {}, modifiers = {} } + MouseTriggers = { down = {}, up = {}, hold = {}, contexts = {}, modifiers = {} } + XControllerTriggers = { down = {}, up = {}, hold = {}, contexts = {}, modifiers = {} } + TerminalTriggerSourceList = {} +end + +function TerminalTriggerSource:Init(target, controller_id) + TerminalTriggerSourceList[#TerminalTriggerSourceList + 1] = self + self.kb_triggers = KeyboardTriggers.down + self.kb_triggers_up = KeyboardTriggers.up + self.kb_triggers_hold = KeyboardTriggers.hold + self.kb_triggers_double_click = KeyboardTriggers.double_click + self.kb_triggers_modifiers = KeyboardTriggers.modifiers + self.kb_contexts = KeyboardTriggers.contexts + self.x_triggers = XControllerTriggers.down + self.x_combo_buttons = XControllerTriggers.combo_buttons + self.x_triggers_up = XControllerTriggers.up + self.x_triggers_hold = XControllerTriggers.hold + self.x_contexts = XControllerTriggers.contexts + self.mouse_triggers = MouseTriggers.down + self.mouse_triggers_up = MouseTriggers.up + self.mouse_triggers_hold = MouseTriggers.hold + self.mouse_contexts = MouseTriggers.contexts + + self.controller_id = controller_id + self.held_buttons = {} + terminal.AddTarget(self) + self:RestoreContext(true) +end + +function TerminalTriggerSource:Done() + table.remove_value(TerminalTriggerSourceList, self) + if self.controller_id then + XInput.SetRumble(self.controller_id, 0, 0) + end + self:SetActive(false) + terminal.RemoveTarget(self) + self:ClearContext() +end + +function TerminalTriggerSource:DisableKeyboard() + self.OnKbdKeyDown = false + self.OnKbdKeyUp = false + self.kb_triggers = false + self.kb_triggers_up = false + self.kb_triggers_hold = false + self.kb_triggers_double_click = false + self.kb_triggers_modifiers = false + self.kb_contexts = false +end + +function TerminalTriggerSource:SetActive(active) + if self.active == active then + return + end + self.active = active + if active then + self:OnActivate() + else + if self.controller_id then + XInput.SetRumble(self.controller_id, 0, 0) + end + for button, thread in pairs(self.held_buttons) do + DeleteThread(thread) + self.held_buttons[button] = nil + end + self:OnDeactivate() + end +end + +function TerminalTriggerSource:TrackHoldButton(button_id, trigger) + self:StopTrackingHoldButton(button_id) + if trigger and button_id and not self.held_buttons[button_id] then + self.held_buttons[button_id] = CreateRealTimeThread(function() + Sleep(self.HoldButtonTime) + self.held_buttons[button_id] = nil + if self.active and not IsPaused() then + self:RaiseTrigger(trigger) + end + end) + end +end + +function TerminalTriggerSource:StopTrackingHoldButton(button_id) + assert(self.held_buttons) + local thread = button_id and self.held_buttons[button_id] + if thread then + DeleteThread(thread) + self.held_buttons[button_id] = nil + end +end + +function TerminalTriggerSource:OnXButtonDown(button, controller_id) + if not self.active or not self.x_triggers or not button or controller_id ~= self.controller_id or IsPaused() then + return "continue" + end + self:TrackHoldButton(button, self.x_triggers_hold[button]) + local context = self:SetTargetContext(self.x_contexts[button], true) + local trigger_processed = self:RaiseTrigger(self.x_triggers[button], "XBOXController") + local combo_trigger_processed + if self.x_combo_buttons[button] then + if self.last_combo_button and RealTime() - self.last_combo_button_time < ComboButtonsDelay then + local combo_trigger = self.x_triggers[self.last_combo_button .. button] or self.x_triggers[button..self.last_combo_button] + if combo_trigger then + combo_trigger_processed = self:RaiseTrigger(combo_trigger, "XBOXController") + end + end + if combo_trigger_processed then + self.last_combo_button = false + else + self.last_combo_button = button + self.last_combo_button_time = RealTime() + end + end + return (context or trigger_processed or combo_trigger_processed) and "break" or "continue" +end + +function TerminalTriggerSource:OnXButtonUp(button, controller_id) + if not button or not self.x_triggers or controller_id ~= self.controller_id then + return "continue" + end + if self.last_combo_button == button then + self.last_combo_button = false + end + self:StopTrackingHoldButton(button) + if not self.active or IsPaused() then + return "continue" + end + local context = self:SetTargetContext(self.x_contexts[button], false) + local trigger_processed = self:RaiseTrigger(self.x_triggers_up[button], "XBOXController") + return (context or trigger_processed) and "break" or "continue" +end + +local no_input = point20 +local function InputToWorldVector(v, view) + if not v then + return no_input + end + local x, y = v:xy() + return Rotate(point(-x,y), XControlCameraGetYaw(view) - 90*60) +end + +function TerminalTriggerSource:UpdateThumbs(current_state) + local camera_hook + if type(current_state) == "table" then + local view = self.trigger_target.camera_view + local value1 = InputToWorldVector(current_state.LeftThumb, view) + local value2 = InputToWorldVector(current_state.RightThumb, view) + local processed1 = self:SetTargetContext(self.x_contexts.LeftThumbVector, value1) + local processed2 = self:SetTargetContext(self.x_contexts.RightThumbVector, value2) + camera_hook = processed1 and value1:Len2D() > 0 or processed2 and value2:Len2D() > 0 + end +end + +function TerminalTriggerSource:OnXNewPacket(_, controller_id, last_state, current_state) + if not self.active or controller_id ~= self.controller_id or IsPaused() then + return "continue" + end + self:UpdateThumbs(current_state) + self:SetTargetContext(self.x_contexts.LeftTriggerVector, current_state.LeftTrigger) + self:SetTargetContext(self.x_contexts.RightTriggerVector, current_state.RightTrigger) + return "continue" +end + +function TerminalTriggerSource:UpdateMouseCamera() +end + +function TerminalTriggerSource:OnMousePos(pt) + if not self.active or not self.kb_triggers or IsPaused() then + return "continue" + end + -- Have the character walk in the direction he's facing. + local vector = self:GetKeyboardDir("navigation_vector", false, false) + if vector then + local x, y = vector:xy() + if x ~= 0 or y ~= 0 then + self:SetTargetContext("navigation_vector", vector) + end + end + return "continue" +end + +function TerminalTriggerSource:_OnButtonDown(button, track_id) + if not self.active or not self.mouse_triggers or not self.kb_triggers or IsPaused() then + return "continue" + end + + self:UpdateMouseCamera() + self:TrackHoldButton(track_id, self.mouse_triggers_hold[button]) + local context = self:SetTargetContext(self.mouse_contexts[button], true) + local trigger = self:RaiseTrigger(self.mouse_triggers[button], "Mouse") + + return (context or trigger) and "break" or "continue" +end + +function TerminalTriggerSource:_OnButtonUp(button, track_id) + self:StopTrackingHoldButton(track_id) + if not self.active or not self.mouse_triggers or not self.kb_triggers or IsPaused() then + return "continue" + end + + self:UpdateMouseCamera() + local context = self:SetTargetContext(self.mouse_contexts[button], false) + local trigger_processed = self:RaiseTrigger(self.mouse_triggers_up[button], "Mouse") + + return (context or trigger_processed) and "break" or "continue" +end + +function TerminalTriggerSource:OnLButtonDown() + return self:_OnButtonDown("LButton", "left_mouse_button") +end + +TerminalTriggerSource.OnLButtonDoubleClick = TerminalTriggerSource.OnLButtonDown + +function TerminalTriggerSource:OnLButtonUp() + return self:_OnButtonUp("LButton", "left_mouse_button") +end + +function TerminalTriggerSource:OnRButtonDown() + return self:_OnButtonDown("RButton", "right_mouse_button") +end + +TerminalTriggerSource.OnRButtonDoubleClick = TerminalTriggerSource.OnRButtonDown + +function TerminalTriggerSource:OnRButtonUp() + return self:_OnButtonUp("RButton", "right_mouse_button") +end + +function TerminalTriggerSource:OnMButtonDown() + return self:_OnButtonDown("MButton", "middle_mouse_button") +end + +TerminalTriggerSource.OnMButtonDoubleClick = TerminalTriggerSource.OnMButtonDown + +function TerminalTriggerSource:OnMButtonUp() + return self:_OnButtonUp("MButton", "middle_mouse_button") +end + +function TerminalTriggerSource:OnXButton1Down() + return self:_OnButtonDown("XButton1", "mouse_xbutton1") +end + +TerminalTriggerSource.OnXButton1DoubleClick = TerminalTriggerSource.OnXButton1Down + +function TerminalTriggerSource:OnXButton1Up() + return self:_OnButtonUp("XButton1", "mouse_xbutton1") +end + +function TerminalTriggerSource:OnXButton2Down() + return self:_OnButtonDown("XButton2", "mouse_xbutton2") +end + +TerminalTriggerSource.OnXButton2DoubleClick = TerminalTriggerSource.OnXButton2Down + +function TerminalTriggerSource:OnXButton2Up() + return self:_OnButtonUp("XButton2", "mouse_xbutton2") +end + +function TerminalTriggerSource:OnKbdKeyDown(virtual_key, repeated) + if repeated or not virtual_key or not self.active or not self.kb_triggers or IsPaused() then + return "continue" + end + self:UpdateMouseCamera() + self:TrackHoldButton(virtual_key, self.kb_triggers_hold[virtual_key]) + local modifiers = self.kb_triggers_modifiers[virtual_key] + + local context = self:SetKbdTargetContext(virtual_key, true) + for i = 1, modifiers and #modifiers or 0 do + local t = modifiers[i] + if t.context then + local raise = true + for j = 1, #t do + raise = raise and terminal.IsKeyPressed(t[j]) + end + context = raise and self:SetTargetContext(t.context, true) or context + end + end + + if self.kb_triggers_double_click[virtual_key] then + if self.last_key == virtual_key and RealTime() - self.last_key_time < KeyDoubleClickTime then + self.last_key = false + self:RaiseTrigger(self.kb_triggers_double_click[virtual_key], "Keyboard") + else + self.last_key = virtual_key + self.last_key_time = RealTime() + end + end + + local trigger_processed = self:RaiseTrigger(self.kb_triggers[virtual_key], "Keyboard") + for i = 1, modifiers and #modifiers or 0 do + local t = modifiers[i] + if t.trigger then + local raise = true + for j = 1, #t do + raise = raise and terminal.IsKeyPressed(t[j]) + end + trigger_processed = raise and self:RaiseTrigger(t.trigger, "Keyboard") or trigger_processed + end + end + + return (context or trigger_processed) and "break" or "continue" +end + +function TerminalTriggerSource:OnKbdKeyUp(virtual_key, repeated) + self:StopTrackingHoldButton(virtual_key) + if repeated or not virtual_key or not self.active or not self.kb_triggers or IsPaused() then + return "continue" + end + self:UpdateMouseCamera() + local context = self:SetKbdTargetContext(virtual_key, false) + local trigger_processed = self:RaiseTrigger(self.kb_triggers_up[virtual_key], "Keyboard") + + local modifiers = self.kb_triggers_modifiers[virtual_key] + for i = 1, modifiers and #modifiers or 0 do + if modifiers[i].context then + self:SetTargetContext(modifiers[i].context, nil) + end + end + return (context or trigger_processed) and "break" or "continue" +end + +function TerminalTriggerSource:GetKeyboardDir(context, virtual_key, key_down) + if not context and self.kb_contexts then + return + end + local x, y + for vk, desc in pairs(self.kb_contexts) do + local dir = type(desc) == "table" and desc[1] == context and desc.dir + if dir then + -- IsKeyPressed for the currently processed key returns the opposite + if (vk == virtual_key and key_down) or (vk ~= virtual_key and terminal.IsKeyPressed(vk)) then + if dir == "left" then + if not x then x = -1 elseif x > 0 then x = 0 end + elseif dir == "right" then + if not x then x = 1 elseif x < 0 then x = 0 end + elseif dir == "down" then + if not y then y = -1 elseif y > 0 then y = 0 end + elseif dir == "up" then + if not y then y = 1 elseif y < 0 then y = 0 end + end + end + end + end + if x or y then + local v = InputToWorldVector(point(x or 0, y or 0) * 32767) + return v + end +end + +function TerminalTriggerSource:SetKbdTargetContext(virtual_key, key_down) + if not self.kb_contexts then return end + local context = self.kb_contexts[virtual_key] + local value = key_down + if type(context) == "table" and context.dir then + value = self:GetKeyboardDir(context[1], virtual_key, key_down) or no_input + end + local result = self:SetTargetContext(context, value) + return result +end + +function TerminalTriggerSource:RestoreContext(reset) + local contexts = {} + local function SetContext(context, value) + if type(context) == "table" then + for i = 1, #context do + local id = context[i] + if IsPoint(value) and IsPoint(contexts[id]) and value:Len() < contexts[id]:Len() then + -- nothing + else + contexts[id] = value or contexts[id] or false + end + end + elseif type(context) == "string" then + local id = context + if IsPoint(value) and IsPoint(contexts[id]) and value:Len() < contexts[id]:Len() then + -- nothing + else + contexts[id] = value or contexts[id] or false + end + end + end + -- keyboard + if self.kb_contexts then + for virtual_key, context in pairs(self.kb_contexts) do + local value = false + if terminal.IsKeyPressed(virtual_key) then + if type(context) == "table" and context.dir then + value = self:GetKeyboardDir(context[1], virtual_key, virtual_key) or no_input + else + value = true + end + end + SetContext(context, value) + end + end + -- XBOX controller + local controller_id = self.controller_id + local controller_state = false + if controller_id then + controller_state = XInput.CurrentState[controller_id] + assert(type(controller_state) == "table", "Trigger source associated to an invalid controller " .. controller_id) + end + if self.x_contexts then + for button, context in pairs(self.x_contexts) do + local value = false + if controller_id then + if button == "LeftThumbVector" then + value = InputToWorldVector(controller_state.LeftThumb, self.trigger_target.camera_view) + elseif button == "RightThumbVector" then + value = InputToWorldVector(controller_state.RightThumb, self.trigger_target.camera_view) + elseif button == "LeftTriggerVector" then + value = controller_state.LeftTrigger + elseif button == "RightTriggerVector" then + value = controller_state.RightTrigger + else + value = XInput.IsCtrlButtonPressed(controller_id, button) + end + end + SetContext(context, value) + end + end + -- apply contexts + for id, value in pairs(contexts) do + local set = reset or self.trigger_target:GetStateContext(id) + if set then + self:SetTargetContext(id, value) + end + end +end + +function TerminalTriggerSource:OnActivate() + if not self.trigger_target then return end + + self:RestoreContext() + + -- raise up triggers for buttons + local up_triggers = {} + if self.kb_triggers_up then + for virtual_key, trigger_id in pairs(self.kb_triggers_up) do + up_triggers[trigger_id] = not terminal.IsKeyPressed(virtual_key) + end + end + if self.controller_id and self.x_triggers_up then + for button, trigger_id in pairs(self.x_triggers_up) do + if up_triggers[trigger_id] ~= false then + up_triggers[trigger_id] = not XInput.IsCtrlButtonPressed(self.controller_id, button) + end + end + end + for trigger_id, raise in pairs(up_triggers) do + if raise then + self:RaiseTrigger(trigger_id) + end + end +end + +function TerminalTriggerSource:OnDeactivate() + if not self.trigger_target then return end + self:SetTargetContext("navigation_vector", nil) +end + +function TerminalTriggerSource:ClearContext() + ClearTerminalStateObjectContext(self.trigger_target) +end + +function ClearTerminalStateObjectContext(obj) + if not IsValid(obj) then return end + local list = GetTerminalStateObjectContexts() + for i = 1, #list do + local context = list[i] + obj:SetStateContext(context, nil) + end +end + +function GetTerminalStateObjectContexts() + local t = {} + local function AddContexts(context) + if type(context) == "table" then + for i = 1, #context do + t[context[i]] = true + end + else + t[context] = true + end + end + for virtual_key, context in pairs(KeyboardTriggers.contexts) do + AddContexts(context) + end + for button, context in pairs(XControllerTriggers.contexts) do + AddContexts(context) + end + local list = {} + for k in sorted_pairs(t) do + list[#list+1] = k + end + return list +end + +function OnMsg.Resume() + for i = 1, #TerminalTriggerSourceList do + local ts = TerminalTriggerSourceList[i] + if ts.active then + ts:OnActivate() + end + end +end diff --git a/CommonLua/Classes/Stats.lua b/CommonLua/Classes/Stats.lua new file mode 100644 index 0000000000000000000000000000000000000000..ac05c7580efd16e558fdfd5be5715aa504126ad1 --- /dev/null +++ b/CommonLua/Classes/Stats.lua @@ -0,0 +1,148 @@ +--[[ + +A tool for stats gathering and archivation + +You can configure how many date components you want to handle and for each of them, how many items to keep in an archive. +Dates have their most significant part first. The datapoints MUST be added chronologically. Archiving is automated +(i.e. doesn't rely on calling a special function) and relies on a user-specified "date diff" function to calculate how +many items must be shifted in each level of the archive. See the example below (DateDiffMonthYear) implemented for +a two-component Year+Month date. + +For example if you want to keep stats per year, month and date you will have levels = 3, a date will be a {y, m ,d} and +you'll need a DateDiffYMD function. + +WARNING: All dates given to this class must not be reused by the caller (we might keep references to them!) + +If needed, the requirement for chronological datapoints can be waived with just a few changes. + +]] + +-- ATTN: Weird! Returns the difference per date level +-- Second return value is the first level at which we see a difference, false if no diff +-- diff( {1, 11}, {3, 1} ) == {2, 14} +-- Looking at the years only, two years have passed +-- Looking at the months only, 14 months have passed +function DateDiffMonthYear(older, newer) + local yeardiff = newer[1] - older[1] + local monthdiff = 12 * yeardiff + newer[2] - older[2] + local diff = { yeardiff, monthdiff } + + if yeardiff > 0 then return diff, 1 end + if monthdiff > 0 then return diff, 2 end + + return diff +end + +DefineClass.Stats = { + __parents = { "InitDone", }, + + -- Provide these when creating! + levels = 1, -- how many components are there in a "date" + limits = false, -- for each level, number of archive points. + DateDiff = function(older, newer) assert(false, "implement me") end, + + current_date = false, -- abstract "date": a tuple of size "levels", most significant part first (think year-month-date) + data = false, -- history of the aggregated datapoints per each of the levels + oldest = false, -- accumulated all datapoints when they "fall through" the archive + + -- debug: + DateFormatCheck = function(date) return true end, +} + +function Stats:Init() + assert(self.levels and self.limits) + self:Clear() +end + +function Stats:Clear() + self.oldest = {} + self.data = {} + self.current_date = {} + for i=1, self.levels do + self.current_date[i] = 0 + self.oldest[i] = 0 + + assert(self.limits[i], "Missing archive limit, use 0 if you don't want archives for a level") + self.limits[i] = self.limits[i] or 0 + self.data[i] = {} + for j=1, self.limits[i] do + self.data[i][j] = 0 + end + end +end + +-- Overload to check for other types of date validity +function Stats:_CheckDate(date) + -- 1. Must be a valid tuple + if #date ~= self.levels then return false end + -- 2. Must not contain blanks and must be later than the current date + for i=1, self.levels do + if not date[i] then return false end + if date[i] < self.current_date[i] then return false end + if date[i] > self.current_date[i] then break end + end + return self:DateFormatCheck(date) +end + +function Stats:_ArchiveLevel(level, shift) + assert(self.limits[level] == #self.data[level]) + if self.limits[level] == 0 then return end + + local archive = self.data[level] + local count = #archive + + -- 1. Trivial case, zero the whole archive + if shift >= count then + local sum = self.oldest[level] + for i=1,count do + sum = sum + archive[i] + archive[i] = 0 + end + self.oldest[level] = sum + return + end + -- 2. Shift as needed, fill the rest with zeros + local sum = self.oldest[level] + for i = 1, shift do + sum = sum + archive[i] + end + self.oldest[level] = sum + for i=1, count-shift do + archive[i] = archive[i+shift] + end + for i=count-shift+1, count do + archive[i] = 0 + end +end + +function Stats:_UpdateArchive(date) + assert(self:_CheckDate(date), "Invalid date or a date in the past") + + local date_diff, start_level = self.DateDiff(self.current_date, date) + if not start_level then return end + + for i=start_level, self.levels do + self:_ArchiveLevel(i, date_diff[i]) + end + self.current_date = date +end + +----------------[ Public interface ]----------- + +-- ATTN: Caller should NOT modify the date after using it, this class may keep a reference to it + +function Stats:Add(date, value) + self:_UpdateArchive(date) + + for i=1, self.levels do + local archive = self.data[i] + local index = self.limits[i] + archive[ index ] = archive[ index ] + value + end +end + +-- ATTN: Returns a reference to the actual data, do not modify! +function Stats:GetArchive(level, today) + self:_UpdateArchive(today) + return self.data[level], self.oldest[level] +end diff --git a/CommonLua/Classes/StatusEffects.lua b/CommonLua/Classes/StatusEffects.lua new file mode 100644 index 0000000000000000000000000000000000000000..6c0cdf243502dafcc6effdc0d7c852565124367a --- /dev/null +++ b/CommonLua/Classes/StatusEffects.lua @@ -0,0 +1,341 @@ +----- Status effects exclusivity + +if FirstLoad then + ExclusiveStatusEffects, RemoveStatusEffects = {}, {} +end + +local exclusive_status_effects, remove_status_effects = ExclusiveStatusEffects, RemoveStatusEffects +function BuildStatusEffectExclusivityMaps() + ExclusiveStatusEffects, RemoveStatusEffects = {}, {} + exclusive_status_effects, remove_status_effects = ExclusiveStatusEffects, RemoveStatusEffects + for name, class in pairs(ClassDescendants("StatusEffect")) do + local exclusive = exclusive_status_effects[name] or {} + local remove = remove_status_effects[name] or {} + ForEachPreset(name, function(preset) + local id1 = preset.id + for _, id2 in ipairs(preset.Incompatible) do + exclusive[id1] = table.create_add_set(exclusive[id1], id2) + exclusive[id2] = table.create_add_set(exclusive[id2], id1) + end + for _, id2 in ipairs(preset.RemoveStatusEffects) do + remove[id1] = table.create_add_set(remove[id1], id2) + local back_referenced = table.get(remove, id2, id1) + if back_referenced then + exclusive[id1] = nil + else + exclusive[id2] = table.create_add_set(exclusive[id2], id1) + end + end + end) + exclusive_status_effects[name] = exclusive + remove_status_effects[name] = remove + end +end + +OnMsg.ModsReloaded = BuildStatusEffectExclusivityMaps +OnMsg.DataLoaded = BuildStatusEffectExclusivityMaps + +----- StatusEffect + +DefineClass.StatusEffect = { + __parents = { "PropertyObject" }, + properties = { + { category = "Status Effect", id = "IsCompatible", editor = "expression", params = "self, owner, ..." }, + { category = "Status Effect", id = "Incompatible", name = "Incompatible", help = "Defines mutually exclusive status effects that cannot coexist. A status effect cannot be added if there are exclusive ones already present. The relationship is symmetric.", + editor = "preset_id_list", default = {}, preset_class = function(obj) return obj.class end, item_default = "", }, + { category = "Status Effect", id = "RemoveStatusEffects", name = "Remove", help = "Status effects to be removed when this one is added. A removed status effect cannot be added later if the current status effect is present, unless they are set to remove each other.", + editor = "preset_id_list", default = {}, preset_class = function(obj) return obj.class end, item_default = "", }, + { category = "Status Effect", id = "ExclusiveResults", name = "Exclusive To", + editor = "text", default = false, dont_save = true, read_only = true, max_lines = 2, }, + { category = "Status Effect", id = "OnAdd", editor = "func", params = "self, owner, ..." }, + { category = "Status Effect", id = "OnRemove", editor = "func", params = "self, owner, ..." }, + { category = "Status Effect Limit", id = "StackLimit", name = "Stack limit", editor = "number", default = 0, min = 0, + no_edit = function(self) return not self.HasLimit end, dont_save = function(self) return not self.HasLimit end, + help = "When the Stack limit count is reached, OnStackLimitReached() is called" }, + { category = "Status Effect Limit", id = "StackLimitCounter", name = "Stack limit counter", editor = "expression", + default = function (self, owner) return self.id end, + no_edit = function(self) return self.StackLimit == 0 end, dont_save = function(self) return self.StackLimit == 0 end, + help = "Returns the name of the limit counter used to count the StatusEffects. For example different StatusEffects can share the same counter."}, + { category = "Status Effect Limit", id = "OnStackLimitReached", editor = "func", params = "self, owner, ...", + no_edit = function(self) return self.StackLimit == 0 end, dont_save = function(self) return self.StackLimit == 0 end, }, + { category = "Status Effect Expiration", id = "Expiration", name = "Auto expire", editor = "bool", default = false, + no_edit = function(self) return not self.HasExpiration end, dont_save = function(self) return not self.HasExpiration end, }, + { category = "Status Effect Expiration", id = "ExpirationTime", name = "Expiration time", editor = "number", default = 480000, scale = "h", min = 0, + no_edit = function(self) return not self.Expiration end, dont_save = function(self) return not self.Expiration end, }, + { category = "Status Effect Expiration", id = "ExpirationRandom", name = "Expiration random", editor = "number", default = 0, scale = "h", min = 0, + no_edit = function(self) return not self.Expiration end, dont_save = function(self) return not self.Expiration end, + help = "Expiration time + random(Expiration random)" }, + { category = "Status Effect Expiration", id = "ExpirationLimits", name = "Expiration Limits (ms)", editor = "range", default = false, + no_edit = function(self) return not self.Expiration end, dont_save = true, read_only = true }, + { category = "Status Effect Expiration", id = "OnExpire", editor = "func", params = "self, owner", + no_edit = function(self) return not self.Expiration end, dont_save = function(self) return not self.Expiration end, }, + }, + StoreAsTable = true, + + HasLimit = true, + HasExpiration = true, + + Instance = false, + expiration_time = false, +} + +local find = table.find +local find_value = table.find_value +local remove_value = table.remove_value + +function StatusEffect:GetExpirationLimits() + return range(self.ExpirationTime, self.ExpirationTime + self.ExpirationRandom) +end + +function StatusEffect:IsCompatible(owner) + return true +end + +function StatusEffect:OnAdd(owner) +end + +function StatusEffect:OnRemove(owner) +end + +function StatusEffect:OnStackLimitReached(owner, ...) +end + +function StatusEffect:OnExpire(owner) + owner:RemoveStatusEffect(self, "expire") +end + +function StatusEffect:RemoveFromOwner(owner, reason) + owner:RemoveStatusEffect(self, reason) +end + +function StatusEffect:PostLoad() + self.__index = self -- fix for instances in saved games +end + +function StatusEffect:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Incompatible" or prop_id == "RemoveStatusEffects" then + BuildStatusEffectExclusivityMaps() + end + return Preset.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +function StatusEffect:GetExclusiveResults() + return table.concat(table.keys(table.get(ExclusiveStatusEffects, self.class, self.id), true), ", ") +end + +----- StatusEffectsObject + +DefineClass.StatusEffectsObject = { + __parents = { "Object" }, + status_effects = false, + status_effects_can_remove = true, + status_effects_limits = false, +} + +local table = table +local empty_table = empty_table + +function StatusEffectsObject:AddStatusEffect(effect, ...) + if not effect:IsCompatible(self, ...) then return end + local class = effect.class + for _, id in ipairs(exclusive_status_effects[class][effect.id]) do + if self:FirstEffectByIdClass(id, class) then + return + end + end + local limit = effect.StackLimit + if limit > 0 then + local status_effects_limits = self.status_effects_limits + if not status_effects_limits then + status_effects_limits = {} + self.status_effects_limits = status_effects_limits + end + local counter = effect:StackLimitCounter() or false + local count = status_effects_limits[counter] or 0 + if limit == 1 then -- for Modal effects (StackLimit == 1) keep a reference to the effect itself in the limits table + if count ~= 0 then + return effect:OnStackLimitReached(self, ...) + end + status_effects_limits[counter] = effect + else + if count >= limit then + return effect:OnStackLimitReached(self, ...) + end + status_effects_limits[counter] = count + 1 + end + end + self:RefreshExpiration(effect) + local status_effects = self.status_effects + if not status_effects then + status_effects = {} + self.status_effects = status_effects + end + for _, id in ipairs(remove_status_effects[class][effect.id]) do + local effect + repeat + effect = self:FirstEffectByIdClass(id, class) + if effect then + effect:RemoveFromOwner(self, "exclusivity") + end + until not effect + end + status_effects[#status_effects + 1] = effect + effect:OnAdd(self, ...) + return effect +end + +function StatusEffectsObject:RefreshExpiration(effect) + if effect.Expiration then + assert(effect.Instance) -- effects with expiration have to be instanced + effect.expiration_time = GameTime() + effect.ExpirationTime + InteractionRand(effect.ExpirationRandom, "status_effect", self) + end +end + +function StatusEffectsObject:RemoveStatusEffect(effect, ...) + assert(self.status_effects_can_remove) + local n = remove_value(self.status_effects, effect) + assert(n) -- removing an effect that is not added + if not n then return end + local limit = effect.StackLimit + if limit > 0 then + local status_effects_limits = self.status_effects_limits + local counter = effect:StackLimitCounter() or false + if status_effects_limits then + local count = status_effects_limits[counter] or 1 + if limit == 1 or count == 1 then + status_effects_limits[counter] = nil + else + status_effects_limits[counter] = count - 1 + end + end + end + effect:OnRemove(self, ...) +end + +-- Modal effects are the ones with StackLimit == 1 +function StatusEffectsObject:GetModalStatusEffect(counter) + local status_effects_limits = self.status_effects_limits + local effect = status_effects_limits and status_effects_limits[counter or false] or false + assert(not effect or type(effect) == "table") + return effect +end + +function StatusEffectsObject:FirstEffectByCounter(counter) + local status_effects_limits = self.status_effects_limits + local effect = status_effects_limits and status_effects_limits[counter or false] or false + if not effect then return end + if type(effect) == "table" then + assert(effect:StackLimitCounter() == counter) + return effect + end + for _, effect in ipairs(self.status_effects or empty_table) do + if effect.StackLimit > 1 and effect:StackLimitCounter() == counter then + return effect + end + end +end + +function StatusEffectsObject:ExpireStatusEffects(time) + time = time or GameTime() + local expired_effects + local status_effects = self.status_effects or empty_table + for _, effect in ipairs(status_effects) do + assert(effect) + if effect and (effect.expiration_time or time) - time < 0 then + expired_effects = expired_effects or {} + expired_effects[#expired_effects + 1] = effect + end + end + if not expired_effects then return end + for i, effect in ipairs(expired_effects) do + if i == 1 or find(status_effects, effect) then + effect:OnExpire(self) + effect.expiration_time = nil + end + end +end + +function StatusEffectsObject:FirstEffectById(id) + return find_value(self.status_effects, "id", id) +end + +function StatusEffectsObject:FirstEffectByGroup(group) + return group and find_value(self.status_effects, "group", group) +end + +function StatusEffectsObject:FirstEffectByIdClass(id, class) + for i, effect in ipairs(self.status_effects) do + if effect.id == id and IsKindOf(effect, class) then + return effect, i + end + end +end + +function StatusEffectsObject:ForEachEffectByClass(class, func, ...) + local can_remove = self.status_effects_can_remove + self.status_effects_can_remove = false + local res + for _, effect in ipairs(self.status_effects or empty_table) do + if IsKindOf(effect, class) then + res = func(effect, ...) + if res then break end + end + end + if can_remove then + self.status_effects_can_remove = nil + end + return res +end + +function StatusEffectsObject:ChooseStatusEffect(none_chance, list, templates) + if not list or #list == 0 or none_chance > 0 and InteractionRand(100, "status_effect", self) < none_chance then + return + end + local cons = list[1] + if type(cons) == "string" then + if #list == 1 then + if not templates or templates[cons] then + return cons + end + else + local weight = 0 + for _, cons in ipairs(list) do + if not templates or templates[cons] then + weight = weight + 1 + end + end + weight = InteractionRand(weight, "status_effect", self) + for _, cons in ipairs(list) do + if not templates or templates[cons] then + weight = weight - 1 + if weight < 0 then + return cons + end + end + end + end + else + assert(type(cons) == "table") + if #list == 1 then + if not templates or templates[cons.effect] then + return cons.effect + end + else + local weight = 0 + for _, cons in ipairs(list) do + if not templates or templates[cons.effect] then + weight = weight + cons.weight + end + end + weight = InteractionRand(weight, "status_effect", self) + for _, cons in ipairs(list) do + if not templates or templates[cons.effect] then + weight = weight - cons.weight + if weight < 0 then + return cons.effect + end + end + end + end + end +end diff --git a/CommonLua/Classes/SubstituteByRandomChildEntity.lua b/CommonLua/Classes/SubstituteByRandomChildEntity.lua new file mode 100644 index 0000000000000000000000000000000000000000..dd69572c169119ad03eced50c6696cab48778c6a --- /dev/null +++ b/CommonLua/Classes/SubstituteByRandomChildEntity.lua @@ -0,0 +1,105 @@ +--[[@@@ +@class SubstituteByRandomChildEntity +This class will be substituted by a random leaf child class of the current class. +--]] + +if FirstLoad then + RandomAutoattachLists = {} +end + +function OnMsg.ClassesBuilt() + RandomAutoattachLists = {} +end + +DefineClass.SubstituteByRandomChildEntity = +{ + __parents = { "Object" }, +} + +function SubstituteByRandomChildEntity:GetRandomEntity(seed) + local candidates = RandomAutoattachLists[self.class] + if not candidates then + candidates = ClassLeafDescendantsList(self.class) + RandomAutoattachLists[self.class] = candidates + end + + if not candidates or #candidates == 0 then + return false + end + + local idx = (seed % (#candidates)) + 1 + local class_name = candidates[idx] + local class = _G[candidates[idx]] + local entity = class.entity or class_name + return entity +end + +function SubstituteByRandomChildEntity:IsForcedLODMinAttach() + local parent = self:GetParent() + if not parent then return end + + while parent do + if parent:GetForcedLODMin() then + return true + end + parent = parent:GetParent() + end +end + +function SubstituteByRandomChildEntity:SubstituteEntity(seed) + local entity = self:GetRandomEntity(seed) + if entity then + self:ChangeEntity(entity) + local edata = EntityData[entity] + edata = edata and edata.entity + local details = (edata.DetailClass and edata.DetailClass ~= "") and edata.DetailClass or "Essential" + if details ~= "Essential" and self:IsForcedLODMinAttach() then + -- NOTE: this has to be mirrored in AutoAttachObjects() too + DoneObject(self) + else + self:SetDetailClass(details) + ApplyCurrentEnvColorizedToObj(self) -- entity changed, possibly colorization too. + GetTopmostParent(self):DestroyRenderObj(true) + end + end +end + +local substitute_queue = {} +local substitute_thread = false + +local function EnqueueSubstitute(obj) + if not substitute_thread then + substitute_thread = CreateRealTimeThread(function() + local IsValid = IsValid + local PtIsValid = point(0,0,0).IsValid + local tblRemove = table.remove + local xxhash = xxhash + while #substitute_queue > 0 do + local min_idx = Max(1, #substitute_queue - 5000) + for idx = #substitute_queue, min_idx, -1 do + local obj = substitute_queue[idx] + if IsValid(obj) then + local pos = obj:GetPos() + if PtIsValid(pos) then + tblRemove(substitute_queue, idx) + obj:SubstituteEntity(xxhash(pos)) + end + else + tblRemove(substitute_queue, idx) + end + end + Sleep(3) + end + substitute_thread = false + end) + end + table.insert(substitute_queue, obj) +end + +function SubstituteByRandomChildEntity:Init() + if IsEditorActive() then + self:SubstituteEntity(AsyncRand()) + else + EnqueueSubstitute(self) + end +end diff --git a/CommonLua/Classes/Template.lua b/CommonLua/Classes/Template.lua new file mode 100644 index 0000000000000000000000000000000000000000..28829314287115aa6d7b3640cb8e8d8989306f96 --- /dev/null +++ b/CommonLua/Classes/Template.lua @@ -0,0 +1,499 @@ +DefineClass.Template = +{ + -- It currently inherits CObject because that's all the editor will currently show in default brush mode + __parents = { "Shapeshifter", "EditorVisibleObject", "EditorTextObject", "EditorCallbackObject" }, + entity = "WayPoint", + flags = { efWalkable = false, efCollision = false, efApplyToGrids = false }, + + properties = { + { id = "TemplateOf", category = "Template", editor = "combo", default = "", important = true, items = function(self) return self:GetTemplatesList() end }, + { id = "autospawn", category = "Template", name = "Autospawn", editor = "bool", default = true }, + { id = "EditorLabel", category = "Template", editor = "text", default = "", no_edit = true }, + { id = "Opacity" }, + { id = "LastSpawnedObject", category = "Template", name = "Last Spawned Object", editor = "object", default = false, read_only = true, dont_save = true }, + { id = "RemainingHandles", category = "Template", name = "Remaining Handles", editor = "number", default = 0, read_only = true, dont_save = true }, + { id = "SpawnCount", category = "Template", name = "Spawned Objects", editor = "number", default = 0, read_only = true, dont_save = true }, + { id = "template_root", category = "Template", name = "Root Class", editor = "text", read_only = true, dont_save = true }, + }, + + template_root = "CObject", -- must be set by descendants + + Walkable = false, + Collision = false, + ApplyToGrids = false, + + reserved_handles = 10, + + editor_text_color = RGB(128,192,128), + editor_text_member = "TemplateOf", +} + +-- template functions +function Template:Init() + self:SetOpacity(65) +end + +function Template:GetLastSpawnedObject() + return TemplateSpawn[self] +end + +function Template:GetRemainingHandles() + local count = 0 + for i=self.handle+1, self.handle+self.reserved_handles do + if not HandleToObject[i] then + count = count + 1 + end + end + return count +end + +function Template:GetSpawnCount() + return self.reserved_handles - self:GetRemainingHandles() +end + +local TemplatesListCache = {} +function OnMsg.ClassesBuilt() + TemplatesListCache = {} +end +function Template:GetTemplatesList() + if TemplatesListCache[self.template_root] then + return TemplatesListCache[self.template_root] + end + -- Get all classes that inherit from template_root + -- WARNING: It will get non-instantiatable classes as well! + local cache = ClassDescendantsList(self.template_root, function(name, class) + return class:GetEntity() ~= "" and IsValidEntity(class:GetEntity()) and not name:ends_with("impl", true) + end) + TemplatesListCache[self.template_root] = cache + return cache +end + +local template_props = {} +function Template:IsTemplateProperty(id) + local props = template_props[self.class] + if not props then + props = {} + template_props[self.class] = props + for i = 1, #self.properties do + props[self.properties[i].id] = true + end + end + return props[id] +end + +function Template:SetProperties(values) + local template = values.TemplateOf + if template then + assert(g_Classes[template], "Invalid template class") + self:SetProperty("TemplateOf", template) + end + + local props = self:GetProperties() + for i = 1, #props do + local id = props[i].id + local value = values[id] + if value ~= nil and id ~= "TemplateOf" then + self:SetProperty(id, value) + end + end +end + +function Template:GetProperties() + local template_class = g_Classes[(self.TemplateOf or "") ~= "" and self.TemplateOf or self.template_root] + local properties = table.copy(self.properties) + + -- move TemplateOf first (it should be at least before StateText property) + local idx = table.find(properties, "id", "TemplateOf") + local prop = properties[idx] + table.remove(properties, idx) + table.insert(properties, 1, prop) + + local properties2 = template_class:GetProperties() + for i = 1, #properties2 do + local p = properties2[i] + local id = p.id + if id == "Opacity" then + -- ignore it + else + local template_prop_idx = table.find(properties, "id", id) + if template_prop_idx then + if id == "ColorModifier" then + properties[template_prop_idx] = p + end + else + if (p.editor == "combo" or p.editor == "dropdownlist" or p.editor == "set") and type(p.items) == "function" then + p = table.copy(p) + local func = p.items + p.items = function(o, editor) + return func(o, editor) + end + end + properties[#properties + 1] = p + end + end + end + return properties +end + +function Template:GetDefaultPropertyValue(prop, prop_meta) + if not self:IsTemplateProperty(prop) then + local class = g_Classes[self.TemplateOf] + if class then + return class:GetDefaultPropertyValue(prop, prop_meta) + end + end + if prop == "ApplyToGrids" then + return GetClassEnumFlags(self.TemplateOf or self.class, const.efApplyToGrids) + elseif prop == "Collision" then + return GetClassEnumFlags(self.TemplateOf or self.class, const.efCollision) + elseif prop == "Walkable" then + return GetClassEnumFlags(self.TemplateOf or self.class, const.efWalkable) + end + return CObject.GetDefaultPropertyValue(self, prop, prop_meta) +end + +local function TransferValue(class, prop_meta, prev_class, prev_value) + local prev_prop_meta = prev_class:GetPropertyMetadata(prop_meta.id) + if prev_prop_meta and prev_prop_meta.editor == prop_meta.editor then + if not prop_meta.items then return prev_value end + + local items = prop_meta.items + if type(items) == "function" then items = items(class) end + if type(items) == "table" then + for i = 1, #items do + local item = items[i] + if item == prev_value or type(item) == "table" and item.value == prev_value then + return prev_value + end + end + end + end +end + +-- invoked from the editor; transfers as much info as possible +function Template:SetTemplateOf(classname) + local prev_class = g_Classes[self.TemplateOf] + local class = g_Classes[classname] + assert(class and class:IsKindOf(self.template_root) or classname == "WayPoint", "Invalid template class: " .. classname) + if not class then + return + end + + if prev_class then + -- remove default property values so they're not transfered to the new template class + local props = prev_class.properties + for i = 1, #props do + local prop_meta = props[i] + local prop = prop_meta.id + if not self:IsTemplateProperty(prop) then + local value = rawget(self, prop) + if value ~= nil and prev_class:IsDefaultPropertyValue(prop, prop_meta, value) then + self[prop] = nil + end + end + end + end + + self.TemplateOf = classname + self:DestroyAttaches() + + local entity = class:GetEntity() + if entity == "" then entity = Template.entity end + self:ChangeEntity(entity) + -- reset collision flags to their defaults for the new class + self.ApplyToGrids = GetClassEnumFlags(classname, const.efApplyToGrids) + self.Collision = GetClassEnumFlags(classname, const.efCollision) + self.Walkable = GetClassEnumFlags(classname, const.efWalkable) + + if prev_class then + local props = class.properties + for i = 1, #props do + local prop_meta = props[i] + local prop = prop_meta.id + + if not self:IsTemplateProperty(prop) then + local value = rawget(self, prop) + + if value ~= nil then + self[prop] = TransferValue(class, prop_meta, prev_class, value) + end + end + end + end +end + +function Template:EditorCallbackPlace() + if not self.TemplateOf then + if self.template_root == "CObject" then + self:SetTemplateOf("WayPoint") + else + local list = self:GetTemplatesList() + if list and #list > 0 then + self:SetTemplateOf(list[1]) + else + self:SetTemplateOf("WayPoint") + end + end + end + self:RandomizeProperties() + self:TemplateApplyProperties() +end + +function Template:EditorCallbackClone() + assert(g_Classes[self.TemplateOf]) + self:RandomizeProperties() + self:TemplateApplyProperties() +end + +function Template:OnEditorSetProperty(prop_id) + assert(g_Classes[self.TemplateOf]) + self:TemplateClearProperties() + self:TemplateApplyProperties() + if prop_id == "TemplateOf" then + ObjModified(self) + end +end + +function Template.TurnTemplatesIntoObjects(list) + local listOfObjsToDestroy = {} + for i = 1, #list do + local t = list[i] + if t:IsKindOf("Template") then + local obj = t:Spawn() + if obj then + obj:SetGameFlags(const.gofPermanent) + list[i] = obj + table.insert(listOfObjsToDestroy,t) + end + end + end + DoneObjects(listOfObjsToDestroy) + return list +end + +g_convert_template_order = { "Template" } +function Template.TurnObjectsIntoTemplates(list) + local listOfObjsToDestroy = {} + for i = 1, #list do + local o = list[i] + local template_class + if not o:IsKindOf("Template") and o:GetGameFlags(const.gofPermanent) ~= 0 then + for j = 1, #g_convert_template_order do + local template_class = g_convert_template_order[j] + if o:IsKindOf(g_Classes[template_class].template_root) then + local template = PlaceObject(template_class) + template:SetTemplateOf(o.class) + template:CopyProperties(o) + -- Boilerplate Stuff + template:SetEnumFlags(const.efVisible) + template:SetGameFlags(const.gofPermanent) + template.SpawnCheckpoint = "Start" + template:TemplateApplyProperties() + list[i] = template + table.insert(listOfObjsToDestroy,o) + break + end + end + end + end + DoneObjects(listOfObjsToDestroy) + return list +end + +function Template:GetProperty(property) + if self:IsTemplateProperty(property) then + return PropertyObject.GetProperty(self, property) + end + local value = rawget(self, property) + if value ~= nil then + return value + end + local class = g_Classes[self.TemplateOf] + if class then + return class:GetDefaultPropertyValue(property) + end +end + +function Template:SetProperty(property, value) + if self:IsTemplateProperty(property) then + return PropertyObject.SetProperty(self, property, value) + end + self[property] = value + return true +end + +function Template:TemplateClearProperties() + local template_class = g_Classes[self.TemplateOf] + if template_class and template_class:HasMember("TemplateClearProperties") then + local old_meta = getmetatable(self) + setmetatable(self, template_class) -- pretend to be an instance of template_class, instead of template + self:TemplateClearProperties(template_class) + setmetatable(self, old_meta) -- return to being a template again + end +end + +function Template:TemplateApplyProperties() + local template_class = g_Classes[self.TemplateOf] + if template_class and template_class:HasMember("TemplateApplyProperties") then + local old_meta = getmetatable(self) + setmetatable(self, template_class) -- pretend to be an instance of template_class, instead of template + self.GetProperty = old_meta.GetProperty + rawset(self, "IsTemplateProperty", old_meta.IsTemplateProperty) + self:TemplateApplyProperties(template_class) + self.GetProperty = nil + self.IsTemplateProperty = nil + setmetatable(self, old_meta) -- return to being a template again + end +end + +function Template:RandomizeProperties(seed) + local template_class = g_Classes[self.TemplateOf] + if template_class then + local old_meta = getmetatable(self) + setmetatable(self, template_class) -- pretend to be an instance of template_class, instead of template + self:RandomizeProperties(seed) + setmetatable(self, old_meta) -- return to being a template again + end +end + +function Template:CopyProperties(source, properties) + if IsKindOf(source, "Template") then + self:SetTemplateOf(source.TemplateOf) + else + self:SetTemplateOf(source.class) + end + Object.CopyProperties(self, source, properties) +end + +MapVar("TemplateSpawn", {}) -- todo: handle the case of multiplayer + +function Template:Spawn() + -- Some templates can only be spawned on e.g. "easy" + local handle + for i=self.handle+1, self.handle+self.reserved_handles do + if not HandleToObject[i] then + handle = i + break + end + end + if not handle then return end + local object = PlaceObject(self.TemplateOf, { handle = handle } ) + if not object then return end + object:CopyProperties(self, object:GetProperties()) + + if object:IsKindOf("Hero") and not (object.groups and table.find(object.groups, object.class)) then + object:AddToGroup(object.class) + end + TemplateSpawn[self] = object + if object:HasMember("spawned_by_template") then + object.spawned_by_template = self + end + Msg(self) + return object +end + +function Template:EditorEnter() + self:TemplateApplyProperties() +end + +function Template:EditorExit() + self:TemplateClearProperties() +end + +function Template:GetApplyToGrids() + return self.ApplyToGrids +end + +function Template:SetApplyToGrids(value) + self.ApplyToGrids = value +end + +function Template:GetCollision() + return self.Collision +end + +function Template:SetCollision(value) + self.Collision = value +end + +function Template:GetWalkable() + return self.Walkable +end + +function Template:SetWalkable(value) + self.Walkable = value +end + +function Template:__enum() + if self.TemplateOf and _G[self.TemplateOf] then + return _G[self.TemplateOf]:__enum() + end + return PropertyObject.__enum(self) +end + +function ResolveObjectRef(obj) + if IsValid(obj) and obj:IsKindOf("Template") then + return TemplateSpawn[obj] + end + return obj +end + +function WaitResolveObjectRef(obj) + if IsValid(obj) and obj:IsKindOf("Template") then + if not TemplateSpawn[obj] then + WaitMsg(obj) + end + return TemplateSpawn[obj] + end + return obj +end + +function IsTemplateOrClass(obj, class) + if obj:IsKindOf(class) then return true end + return obj:IsKindOf("Template") and rawget(_G, obj.TemplateOf) and _G[obj.TemplateOf]:IsKindOf(class) +end + +function IsTemplateOrClasses(obj, classes) + if obj:IsKindOfClasses(classes) then return true end + return obj:IsKindOf("Template") and rawget(_G, obj.TemplateOf) and _G[obj.TemplateOf]:IsKindOfClasses(classes) +end + +function GetTemplateGroupsComboList() + local list = { "Disabled","Default Spawn"} + for k,group in pairs(groups) do + for j=1, #group do + if IsValid(group[j]) and group[j]:IsKindOf("TemplateOpponent") then + list[#list + 1] = k + break + end + end + end + table.sort(list) + return list +end + +function CreateClassShapeshifter(classname) + local o = PlaceObject("ShapeshifterClass") + o:ChangeClass(classname) + o:SetGameFlags(const.gofSyncState) + + local t = PlaceObject("Template") + t:SetTemplateOf(classname) + t:RandomizeProperties() + t:TemplateApplyProperties() + + o:SetColorModifier(t:GetColorModifier()) + for i = t:GetNumAttaches(), 1, -1 do + local attach = t:GetAttach(i) + local attach_spot = attach:GetAttachSpot() + o:Attach(attach, attach_spot) + end + DoneObject(t) + return o +end + +function Template:GetEditorLabel() + local template_class = g_Classes[self.TemplateOf] + local template_label = template_class and template_class:GetProperty("EditorLabel") + return "Template of " .. (template_label or self.TemplateOf) +end \ No newline at end of file diff --git a/CommonLua/Classes/TerrainConfig.lua b/CommonLua/Classes/TerrainConfig.lua new file mode 100644 index 0000000000000000000000000000000000000000..3c65d345fbecc4c15ade3eb3658c12e0696ab3b6 --- /dev/null +++ b/CommonLua/Classes/TerrainConfig.lua @@ -0,0 +1,235 @@ +function IsTerrainEntityId(id) + return id:starts_with("Terrain") +end + +function TerrainMaterials() + local path = "svnAssets/Bin/Common/Materials/" + local files = io.listfiles(path) + + local filtered = {} + for _, path in ipairs(files) do + local dir, name, ext = SplitPath(path) + if string.starts_with(name, "Terrain") then + table.insert(filtered, name .. ext) + end + end + + return filtered +end + +function MaxTerrainTextureIdx() + local max_idx = -1 + for idx in pairs(TerrainTextures) do + max_idx = Max(max_idx, idx) + end + return max_idx +end + +DefineClass.TerrainObj = { + __parents = { "Preset" }, + properties = { + { id = "Group", editor = false }, + + -- index in the terrain grid, generated per preset and saved (so DLC terrains and deleting terrains won't cause issues) + { category = "Terrain", id = "idx", name = "Type index", editor = "number", default = 0, read_only = true, }, + { category = "Terrain", id = "invalid", name = "Invalid terrain", editor = "bool", default = false, help = "Missing terrains (e.g. disabled in a DLC) will be visualized by the first invalid terrain found", }, + { category = "Terrain", id = "material_name", name = "Material", editor = "combo", default = "", items = TerrainMaterials, help = "The Alpha of the base color is used as 'height' of the terrain, when blending with others. The height should always reach atleast 128(grey), otherwise there will be problems with the blending." }, + { category = "Terrain", id = "size", name = "Size", editor = "number", scale = "m", default = config.DefaultTerrainTileSize }, + { category = "Terrain", id = "offset_x", name = "Offset X", editor = "number", scale = "m", default = 0, }, + { category = "Terrain", id = "offset_y", name = "Offset Y", editor = "number", scale = "m", default = 0, }, + { category = "Terrain", id = "rotation", name = "Rotation", editor = "number", scale = "deg", default = 0 }, + { category = "Terrain", id = "color_modifier", name = "Color modifier", editor = "rgbrm", default = RGB(100, 100, 100), buttons = {{name = "Reset", func = "ResetColorModifier"}}}, + { category = "Terrain", id = "vertical", name = "Vertical", editor = "bool", default = false }, + { category = "Terrain", id = "inside", name = "Inside", editor = "bool", default = false }, + { category = "Terrain", id = "blur_radius", name = "Blur radius", editor = "number", default = 2*guim, min = 0, max = 15 * guim, slider = true, scale = "m" }, + { category = "Terrain", id = "z_order", name = "Z Order", editor = "number", default = 0 }, + { category = "Terrain", id = "type", name = "FX Surface type", editor = "combo", default = "Dirt", items = PresetsPropCombo("TerrainObj", "type") }, + + { category = "Textures", id = "basecolor", editor = "text", default = "", read_only = true, dont_save = true, buttons = {{name = "Locate", func = "EV_LocateFile"}} }, + { category = "Textures", id = "normalmap", editor = "text", default = "", read_only = true, dont_save = true, buttons = {{name = "Locate", func = "EV_LocateFile"}} }, + { category = "Textures", id = "rmmap", editor = "text", default = "", read_only = true, dont_save = true, buttons = {{name = "Locate", func = "EV_LocateFile"}} }, + + { category = "Grass", name = "Grass Density", id = "grass_density", editor="number", scale = 1000, default = 1000 }, + { category = "Grass", name = "Grass List", id = "grass_list", editor = "nested_list", base_class = "TerrainGrass", default = false, inclusive = true }, + }, + + EditorName = "Terrain Config", + EditorMenubarName = "Terrain Config", + EditorIcon = "CommonAssets/UI/Menu/TerrainConfigEditor.tga", + EditorMenubar = "Editors.Art", + FilterClass = "TerrainFilter", + + StoreAsTable = false, +} + +if const.pfTerrainCost then + function PathfindSurfTypesCombo() + local items = table.copy(pathfind_pass_types) or {} + items[1] = "" -- default + return items + end + table.insert(TerrainObj.properties, { category = "Terrain", id = "pass_type", name = "Pass Type", editor = "choice", default = "", items = PathfindSurfTypesCombo }) +end + +local function RefreshMaterials() + ForEachPresetExtended(TerrainObj, function(preset) + preset:RefreshMaterial() + end) + ObjModified(Presets.TerrainObj) + Msg("TerrainMaterialsLoaded") +end + +OnMsg.BinAssetsLoaded = RefreshMaterials + +function ReloadTerrains() + local presets = {} + ForEachPreset("TerrainObj", function(preset) + if preset.material_name ~= "" then + presets[#presets + 1] = preset + end + end) + + local invalid = presets[1] + TerrainTextures = {} + TerrainNameToIdx = {} + for _, preset in ipairs(presets) do + TerrainTextures[preset.idx] = preset + TerrainNameToIdx[preset.id] = preset.idx + if preset.invalid then + invalid = preset + end + end + + -- replace the holes in terrain textures (e.g. terrains moved to a DLC) with an invalid terrain, + -- so the C side continues to receive a solid array of terrains + local extended_presets = {} + for i = 0, MaxTerrainTextureIdx() do + local preset = TerrainTextures[i] + if not preset then + preset = TerrainObj:new() + preset.SetId = function(self, id) self.id = id end + preset.SetGroup = function(self, group) self.group = group end + preset:CopyProperties(invalid) + preset.idx = i + end + extended_presets[i + 1] = preset + end + TerrainTexturesLoad(extended_presets) + Msg("TerrainTexturesLoaded") +end + +function OnMsg.DataLoaded() + ReloadTerrains() +end + +function TerrainObj:RefreshMaterial() + if self.material_name == "" then return false end + local mat_props = GetMaterialProperties(self.material_name) + if mat_props then + self.basecolor = mat_props.BaseColorMap or "" + self.normalmap = mat_props.NormalMap or "" + self.rmmap = mat_props.RMMap or "" + end +end + +function TerrainObj:OnEditorSetProperty(...) + self:RefreshMaterial() + ReloadTerrains() + hr.TR_ForceReloadTextures = true + Preset.OnEditorSetProperty(self, ...) +end + +function OnMsg.GedOpened(ged_id) + local ged = GedConnections[ged_id] + if ged and ged:ResolveObj("root") == Presets.TerrainObj then + CreateRealTimeThread(RefreshMaterials) + end +end + +function TerrainObj:OnEditorNew() + self.idx = MaxTerrainTextureIdx() + 1 + self:RefreshMaterial() +end + +function TerrainObj:PostLoad() + Preset.PostLoad(self) + self:RefreshMaterial() +end + +function TerrainObj:SortPresets() + -- sort terrain alphabetically by id for convenience; terrain indexes that are stored in the grid are saved in the 'idx' property + local presets = Presets[self.PresetClass or self.class] or empty_table + for _, group in ipairs(presets) do + table.sort(group, function(a, b) + local aid, bid = a.id:lower(), b.id:lower() + return aid < bid or aid == bid and a.save_in < b.save_in + end) + end + ObjModified(presets) +end + +function ApplyTerrainPreview(classdef, objname) + local previews = { + { id = "Height", tex = "basecolor", img_draw_alpha_only = true }, + { id = "Basecolor", tex = "basecolor" }, + { id = "Normalmap", tex = "normalmap" }, + { id = "RM", tex = "rmmap" }, + } + + for i = 1, #previews do + local preview = previews[i] + local id = preview.id .. "Preview" + local getter = function(self) + local ext = preview.ext or "" + local obj = objname and self[objname] or self + if type(obj) == "function" then + obj = obj(self) + end + local condition = not preview.condition or obj and obj[preview.condition] + return (obj and obj.id ~= "" and condition) and rawget(obj, preview.tex) and obj[preview.tex] or "" + end + classdef["Get" .. id] = getter + table.insert(classdef.properties, table.find(classdef.properties, "id", preview.tex) + 1, { + category = "Textures", + id = id, + name = preview.id, + editor = "image", + default = "", + dont_save = true, + img_size = 128, + img_box = 1, + base_color_map = not preview.img_draw_alpha_only, + img_draw_alpha_only = preview.img_draw_alpha_only, + no_edit = function(self) return getter(self) == "" end, + }) + end +end + +ApplyTerrainPreview(TerrainObj) + +function TerrainObj:GetEditorViewPresetPrefix() + local preview = self:GetBasecolorPreview() + return " " .. self.idx .. " " +end + + +----- Filter for the Terrain editor + +DefineClass.TerrainFilter = { + __parents = { "GedFilter" }, + + properties = { + { id = "Material", editor = "choice", default = "", items = TerrainMaterials }, + }, +} + +function TerrainFilter:FilterObject(o) + if self.Material ~= "" and o.material_name ~= self.Material then + return false + end + return true +end + +function TerrainFilter:TryReset(ged, op, to_view) + return false +end diff --git a/CommonLua/Classes/TupleStorage.lua b/CommonLua/Classes/TupleStorage.lua new file mode 100644 index 0000000000000000000000000000000000000000..ce76666b376b89ad0fda023108e39bc5a640a5a9 --- /dev/null +++ b/CommonLua/Classes/TupleStorage.lua @@ -0,0 +1,452 @@ +-- TupleStorage works with tuples consisting of basic lua values and tables of basic lua values. +-- TupleStorage uses files named /.XXXXXX.lua. +-- Files are split when they grow above . +-- Writes are buffered until the buffer gets to or a new file is created +-- Write buffer is flushed (regardless of size) every milliseconds + +DefineClass.TupleStorage = { + __parents = { "InitDone", "EventLogger" }, + + storage_dir = "Storage", + sub_dir = "", + file_name = "file", + file_ext = "csv", + event_source = "TupleStorage", + single_file = false, + + max_file_size = 1024*1024, + max_buffer_size = 64*1024, + periodic_buffer_flush = 7717, + + min_file_index = 1, + max_file_index = 1, + + buffer = false, + buffer_offset = 0, + + flush_thread = false, + flush_queue = false, + + done = false, +} + +function TupleStorage:Init() + if not self.storage_dir then + local empty = function() end + self.DeleteFiles = empty + self.DeleteFile = empty + self.Flush = empty + self.WriteTuple = empty + self.ReadTuple = empty + self.ReadAllTuples = empty + return + end + self.storage_dir = self.storage_dir .. self.sub_dir + local err = AsyncCreatePath(self.storage_dir) + if err then + self:ErrorLog(err) + end + self.file_name = string.gsub(self.file_name, '[/?<>\\:*|"]', "_") + if self.single_file then + local file_name = string.format("%s/%s.%s", self.storage_dir, self.file_name, self.file_ext) + self.GetFileName = function(self, index) + assert(index == 1) + return file_name + end + self.max_file_size = 1024*1024*1024 + self.event_source = string.format("TupleFile %s/%s", self.sub_dir, self.file_name) + err, self.buffer_offset = AsyncGetFileAttribute(file_name, "size") + else + local err, files = AsyncListFiles(self.storage_dir, string.format("%s.*.%s", self.file_name, self.file_ext), "relative") + local pattern = "%.(%d+)%." .. self.file_ext .. "$" + if err then self:ErrorLog(err) return end + local min, max = max_int, -1 + for i = 1, #files do + local index = string.match(files[i], pattern) + if index then + index = tonumber(index) + min = Min(min, index) + max = Max(max, index) + end + end + if min <= max then + self.min_file_index = min + self.max_file_index = max + 1 + end + self.event_source = string.format("TupleStorage %s/%s", self.sub_dir, self.file_name) + end + self.buffer = pstr("", self.max_buffer_size) + self.flush_queue = {} + if self.periodic_buffer_flush then + CreateRealTimeThread(function(self) + while self.buffer do + self:Flush() + Sleep(self.periodic_buffer_flush) + end + end, self) + end +end + +function TupleStorage:Done() + self.done = true + self.RawWrite = function() return "done" end + self.DeleteFile = function() return "done" end + self.DeleteFiles = function() return "done" end + self:Flush() + if self.buffer then self.buffer:free() end + self.buffer = false +end + +function TupleStorage:GetFileName(index) + return string.format("%s/%s.%08d.%s", self.storage_dir, self.file_name, index, self.file_ext) +end + +function TupleStorage:DeleteFile(file_index) + if self.min_file_index == file_index then + self.min_file_index = file_index + 1 + end + if self.max_file_index == file_index then + self.buffer_offset = 0 + self.buffer = pstr("", self.max_buffer_size) + end + local err = AsyncFileDelete(self:GetFileName(file_index)) + if err then + self:ErrorLog(err, self:GetFileName(file_index)) + return err + end +end + +function TupleStorage:DeleteFiles() + local result + self.min_file_index = nil + self.max_file_index = nil + self.buffer_offset = 0 + self.buffer = pstr("", self.max_buffer_size) + for file_index = self.min_file_index, self.max_file_index - 1 do + local err = AsyncFileDelete(self:GetFileName(file_index)) + if err then + result = result or err + self:ErrorLog(err, self:GetFileName(file_index)) + end + end + return result +end + +local function _load(loader, err, ...) + if err then + return err + end + return loader(...) +end + +function TupleStorage:LoadTuple(loader, line, file_index) + local err = _load(loader, LuaCodeToTupleFast(line)) + if err then + self:ErrorLog(err, self:GetFileName(file_index), line) + return err + end +end + +-- err = loader(line, file_index, ...) +function TupleStorage:ReadAllTuplesRaw(loader, file_filter, mem_limit) + local result, stop_enum, mem + if mem_limit then + collectgarbage("stop") + mem = collectgarbage("count") + end + local process_thread + for file_index = self.min_file_index, self.max_file_index do + local file_name = self:GetFileName(file_index) + if self.done then result = "done" break end + if not file_filter or file_filter(self, file_name, file_index) then + local err, data = AsyncFileToString(file_name, nil, nil, "lines") + if not err or (err ~= "Path Not Found" and err ~= "File Not Found") then -- some journal/blob files are going to be deleted, we want to skip these + if not err then + if IsValidThread(process_thread) then + WaitMsg(process_thread) + end + process_thread = CreateRealTimeThread(function(data) + for i = 1, #data do + local err = loader(data[i], file_index) + result = result or err + end + data = nil + if mem_limit and collectgarbage("count") - mem > mem_limit then + collectgarbage("collect") + collectgarbage("stop") + mem = collectgarbage("count") + end + Msg(CurrentThread()) + end, data) + else + self:ErrorLog("ReadAllTuplesRaw", err, file_name) + result = result or err + end + end + end + end + if IsValidThread(process_thread) then + WaitMsg(process_thread) + end + if mem_limit then + collectgarbage("collect") + collectgarbage("restart") + end + return result +end + +function TupleStorage:RawRead(file_index, file_offset, data_size) + -- read from flush queue + local queue = self.flush_queue + for i = 1, #queue do + local qfile_index, qbuffer, qoffset = unpack_params(queue[i]) + if file_index == qfile_index and file_offset >= qoffset and file_offset < qoffset + #qbuffer then + local offset = file_offset - qoffset + return qbuffer:sub(offset, offset + data_size) + end + end + -- read from buffer + if self.buffer and file_index == self.max_file_index and file_offset >= self.buffer_offset then + local offset = file_offset - self.buffer_offset + return qbuffer:sub(offset, offset + data_size) + end + -- read from file + local err, data = AsyncFileToString(self:GetFileName(file_index), data_size, file_offset) + if err then + self:ErrorLog("RawRead", err, self:GetFileName(file_index), file_offset, data_size) + return err + end + return nil, data +end + +--[[ +function TupleStorage:ReadAllTuples_old(loader, file_filter, mem_limit) + return self:ReadAllTuplesRaw(function(line, file_index) + return self:LoadTuple(loader, line, file_index) + end, file_filter, mem_limit) +end +--]] + +function TupleStorage:ReadAllTuples(loader, file_filter, mem_limit) + local result, mem + if mem_limit then + collectgarbage("stop") + mem = collectgarbage("count") + end + local process_thread + for file_index = self.min_file_index, self.max_file_index do + local file_name = self:GetFileName(file_index) + if self.done then result = "done" break end + if not file_filter or file_filter(self, file_name, file_index) then + local err, data = AsyncFileToString(file_name, nil, nil, "pstr") + if not err or (err ~= "Path Not Found" and err ~= "File Not Found") then + if not err then + if IsValidThread(process_thread) then + WaitMsg(process_thread) + end + process_thread = CreateRealTimeThread(function(data, file_name) + local err_table = data:parseTuples(loader) + data:free() + for i, err in ipairs(err_table) do + if err then + self:ErrorLog("ReadAllTuples", err, file_name, i) + end + result = result or err + end + if mem_limit and collectgarbage("count") - mem > mem_limit then + collectgarbage("collect") + collectgarbage("stop") + mem = collectgarbage("count") + end + Msg(CurrentThread()) + end, data, file_name) + else + self:ErrorLog("ReadAllTuples", err, file_name) + result = result or err + end + end + end + end + if IsValidThread(process_thread) then + WaitMsg(process_thread) + end + if mem_limit then + collectgarbage("collect") + collectgarbage("restart") + end + return result +end + +function TupleStorage:ReadTuple(loader, file_index, file_offset, data_size) + local err, line = self:RawRead(file_index, file_offset, data_size) + if err then return err end + return self:LoadTuple(loader, line, file_index) +end + +function TupleStorage:PreFlush(filename, data, offset) +end + +function TupleStorage:Flush(wait) + local buffer = self.buffer + if not buffer or #buffer == 0 then return end + local flush_request = {self.max_file_index, buffer, self.buffer_offset} + self.flush_queue[#self.flush_queue + 1] = flush_request + self.buffer_offset = self.buffer_offset + #buffer + if self.buffer_offset > self.max_file_size then + self.buffer_offset = 0 + self.max_file_index = self.max_file_index + 1 + end + self.buffer = pstr("", self.max_buffer_size) + if not IsValidThread(self.flush_thread) then + self.flush_thread = CreateRealTimeThread(function() + while self.flush_queue[1] do + local data = self.flush_queue[1] + local file_index, buffer, offset = data[1], data[2], data[3] + local file_name = self:GetFileName(file_index) + self:PreFlush(file_index, buffer, offset) + local err = AsyncStringToFile(file_name, buffer, offset ~= 0 and offset) + table.remove(self.flush_queue, 1) + Msg(data) + if err then + self:ErrorLog(err, file_name, offset, #buffer) + end + buffer:free() + end + self.flush_thread = false + end) + end + if wait then + WaitMsg(flush_request) + end +end + +function TupleStorage:WriteTuple(...) + return self:RawWrite(TupleToLuaCodePStr(...), true) +end + +function TupleStorage:WriteTupleChecksum(...) + return self:RawWrite(TupleToLuaCodeChecksumPStr(...), true) +end + +function TupleStorage:RawWrite(data, free) + local data_size = #data + local size = #self.buffer + data_size + 1 + -- flush if needed + if self.buffer_offset + size > self.max_file_size or size > self.max_buffer_size then + self:Flush() + end + -- write to buffer + local buffer = self.buffer + local tuple_offset = self.buffer_offset + #buffer + buffer:append(data, "\n") + + if free then data:free() end + return nil, self.max_file_index, tuple_offset, data_size +end + +function TableLoader(t) + return function(...) t[#t + 1] = {...} end +end + +--[[ Tests + +local function pr(err) + if err then + print("Error:", err) + end +end + +local value_len = 160 +local values_count = 5 + +function StorageWriteTest(records) + Sleep(200) + records = records or 10000 + printf("Writing storage test with %d records of %d values of len %d", records, values_count, value_len) + local storage = TupleStorage:new{ storage_dir = "TestStorage", + -- max_buffer_size = 10*1024*1024, + } + print("Deleting files") + pr(storage:DeleteFiles()) + local item = string.rep("v", value_len) + local data = {} + for i = 1, values_count do + data[i] = item + end + print("Writing") + local time = GetPreciseTicks(1000) + for i = 1, records do + pr(storage:WriteTuple(unpack_params(data))) + end + printf("Writing done for %d ms", GetPreciseTicks(1000) - time) + print("Flushing") + time = GetPreciseTicks(1000) + storage:Flush(true) + printf("Flushing done for %d ms", GetPreciseTicks(1000) - time) + storage:delete() +end + +local records, values, len = 0, 0, 0 +local function loader(...) + local data = {...} + records = records + 1 + for i = 1, #data do + values = values + 1 + len = len + #data[i] + end +end + +function StorageReadTest() + Sleep(200) + records, values, len = 0, 0, 0 + + printf("Reading all storage records test") + local storage = TupleStorage:new{ storage_dir = "TestStorage" } + print("Reading") + local time = GetPreciseTicks(1000) + storage:ReadAllTuples(loader) + printf("Reading done for %d ms", GetPreciseTicks(1000) - time) + if values == 0 then + print("Nothing read") + else + printf("Read %d records with avg %d values of avg len %d", records, values / records, len / values) + end + storage:delete() +end + +function StorageReadTest2(n, threads) + Sleep(200) + n = n or 1000 + + printf("Reading random storage records test") + local storage = TupleStorage:new{ storage_dir = "TestStorage" } + print("Reading") + local time = GetPreciseTicks(1000) + local rand, seed = BraidRandom(time) + local record_size = values_count * (value_len + 2) + values_count + local records_in_file = (storage.max_file_size + record_size - 1) / record_size + threads = threads or 1 + n = n / threads + for k = 1, threads do + CreateRealTimeThread(function() + for i = 1, n do + rand, seed = BraidRandom(seed, 10000) + storage:ReadTuple(loader, rand / records_in_file + 1, (rand % records_in_file) * record_size, record_size) + end + threads = threads - 1 + if threads == 0 then + Msg("Test2Done") + end + end) + end + WaitMsg("Test2Done") + printf("Reading done for %d ms", GetPreciseTicks(1000) - time) + if values == 0 then + print("Nothing read") + else + printf("Read %d records with avg %d values of avg len %d", records, values / records, len / values) + end + storage:delete() +end + +--]] \ No newline at end of file diff --git a/CommonLua/Classes/_cobject.lua b/CommonLua/Classes/_cobject.lua new file mode 100644 index 0000000000000000000000000000000000000000..7e353747dc3f862f559e505108a737b79a3c2d80 --- /dev/null +++ b/CommonLua/Classes/_cobject.lua @@ -0,0 +1,3122 @@ +--- Resets the color modifier of the specified property on the given object. +--- +--- @param parentEditor table The parent editor object. +--- @param object table The object to reset the color modifier on. +--- @param property string The property to reset the color modifier for. +--- @param ... any Additional arguments (not used). +function ResetColorModifier(parentEditor, object, property, ...) + object:SetProperty(property, const.clrNoModifier) +end + +--- +--- Returns a list of all collection names. +--- +--- @return table A table of collection names, with an empty string as the first element. +function GetCollectionNames() + local names = table.keys(CollectionsByName, "sort") + table.insert(names, 1, "") + return names +end + +--- +--- Generates a list of options for handling collisions with camera items. +--- +--- The options depend on the class flags of the object, which determine whether the object should repulse the camera or become transparent when colliding with camera items. +--- +--- @param obj table The object that is colliding with camera items. +--- @return table A list of options for handling the collision, with the appropriate default options selected based on the object's class flags. +--- +function OnCollisionWithCameraItems(obj) + -- Implementation details +end +local function OnCollisionWithCameraItems(obj) + local class_become_transparent = GetClassEnumFlags(obj.class, const.efCameraMakeTransparent) ~= 0 + local class_repulse_camera = GetClassEnumFlags(obj.class, const.efCameraRepulse) ~= 0 + local items = { + { text = "no action", value = "no action"}, + { text = "repulse camera", value = "repulse camera"}, + { text = "become transparent", value = "become transparent"}, + { text = "repulse camera & become transparent", value = "repulse camera & become transparent"}, + } + if class_repulse_camera then + items[2] = { text = "repulse camera (class default)", value = false } + elseif class_become_transparent then + items[3] = { text = "become transparent (class default)", value = false } + else + items[1] = { text = "no action (class default)", value = false } + end + return items +end + +--- +--- A mapping of collision handling options to the corresponding class flags for an object. +--- +--- The `"repulse camera"` option sets the `efCameraRepulse` flag, which causes the object to repulse the camera when colliding with it. +--- The `"become transparent"` option sets the `efCameraMakeTransparent` flag, which causes the object to become transparent when colliding with the camera. +--- +--- @field repulse camera table The collision handling option to repulse the camera, and the corresponding class flags. +--- @field become transparent table The collision handling option to become transparent, and the corresponding class flags. +--- +OCCtoFlags = { + ["repulse camera"] = { efCameraMakeTransparent = false, efCameraRepulse = true }, + ["become transparent"] = { efCameraMakeTransparent = true, efCameraRepulse = false }, +} + +--- +--- Initializes a table of flag names for various engine-defined flags. +--- +--- The flags are organized into four categories: Game, Enum, Class, and Component. +--- The flag names are extracted from the `const` table, which contains all the engine-defined constants. +--- +--- This initialization is performed only on the first load of the script. +--- +--- @return nil +--- +if FirstLoad then + FlagsByBits = { + Game = {}, + Enum = {}, + Class = {}, + Component = {} + } + local const_keys = table.keys(const) + local const_vars = EnumEngineVars("const.") + for key in pairs(const_vars) do + const_keys[#const_keys + 1] = key + end + for i = 1, #const_keys do + local key = const_keys[i] + local flags + if string.starts_with(key, "gof") then + flags = FlagsByBits.Game + elseif string.starts_with(key, "ef") then + flags = FlagsByBits.Enum + elseif string.starts_with(key, "cf") then + flags = FlagsByBits.Class + elseif string.starts_with(key, "cof") then + flags = FlagsByBits.Component + end + if flags then + local value = const[key] + if value ~= 0 then + flags[LastSetBit(value) + 1] = key + end + end + end + FlagsByBits.Enum[1] = { name = "efAlive", read_only = true } +end + +--- +--- Flags that control the visibility and rendering of an object. +--- +--- - `efVisible`: Determines whether the object is visible. +--- - `gofWarped`: Indicates whether the object is warped or distorted. +--- - `efShadow`: Determines whether the object casts a shadow. +--- - `efSunShadow`: Determines whether the object casts a shadow from the sun. +--- +local efVisible = const.efVisible +local gofWarped = const.gofWarped +local efShadow = const.efShadow +local efSunShadow = const.efSunShadow + +--- +--- Returns a table of surface names indexed by their bit flags. +--- +--- The returned table maps each surface bit flag to the corresponding surface name. +--- +--- @return table A table mapping surface bit flags to surface names. +--- +local function GetSurfaceByBits() + local flags = {} + for name, flag in pairs(EntitySurfaces) do + if IsPowerOf2(flag) then + flags[LastSetBit(flag) + 1] = name + end + end + return flags +end + +-- MapObject is a base class for all objects that are on the map. +-- Only classes that inherit MapObject can be passed to Map enumeration functions. +--- +--- The base class for all objects that are on the map. +--- +--- Only classes that inherit `MapObject` can be passed to Map enumeration functions. +--- +--- @class MapObject +--- @field GetEntity fun(): Entity The entity associated with this map object. +--- @field persist_baseclass string The base class name for persistence. +--- @field UnpersistMissingClass fun(self: MapObject, id: string, permanents: table): MapObject Called when a missing class is encountered during unpersisting. +--- +DefineClass.MapObject = { + __parents = { "PropertyObject" }, + GetEntity = empty_func, + persist_baseclass = "class", + UnpersistMissingClass = function(self, id, permanents) return self end +} + +--[[@@@ +@class CObject +CObjects are objects, accessible to Lua, which have a counterpart in the C++ side of the engine. +They do not have allocated memory in the Lua side, and therefore cannot store any information. +Reference: [CObject](LuaCObject.md.html) +--]] +--- +--- The base class for all objects that are on the map. +--- +--- Only classes that inherit `MapObject` can be passed to Map enumeration functions. +--- +--- @class MapObject +--- @field GetEntity fun(): Entity The entity associated with this map object. +--- @field persist_baseclass string The base class name for persistence. +--- @field UnpersistMissingClass fun(self: MapObject, id: string, permanents: table): MapObject Called when a missing class is encountered during unpersisting. +DefineClass.CObject = +{ + __parents = { "MapObject", "ColorizableObject", "FXObject" }, + __hierarchy_cache = true, + entity = false, + flags = { + efSelectable = true, efVisible = true, efWalkable = true, efCollision = true, + efApplyToGrids = true, efShadow = true, efSunShadow = true, + cfConstructible = true, gofScaleSurfaces = true, + cofComponentCollider = const.maxCollidersPerObject > 0, + }, + radius = 0, + texture = "", + material_type = false, + template_class = "", + distortion_scale = 0, + orient_mode = 0, + orient_mode_bias = 0, + max_allowed_radius = const.GameObjectMaxRadius, + variable_entity = false, + + -- Properties, editable in the editor's property window (Ctrl+O) + properties = { + { id = "ClassFlagsProp", name = "ClassFlags", editor = "flags", + items = FlagsByBits.Class, default = 0, dont_save = true, read_only = true }, + { id = "ComponentFlagsProp", name = "ComponentFlags", editor = "flags", + items = FlagsByBits.Component, default = 0, dont_save = true, read_only = true }, + { id = "EnumFlagsProp", name = "EnumFlags", editor = "flags", + items = FlagsByBits.Enum, default = 1, dont_save = true }, + { id = "GameFlagsProp", name = "GameFlags", editor = "flags", + items = FlagsByBits.Game, default = 0, dont_save = true, size = 64 }, + { id = "SurfacesProp", name = "Surfaces", editor = "flags", + items = GetSurfaceByBits, default = 0, dont_save = true, read_only = true }, + { id = "DetailClass", name = "Detail class", editor = "dropdownlist", + items = {"Default", "Essential", "Optional", "Eye Candy"}, default = "Default", + help = "Controls the graphic details level set from the options that can hide the object. Essential objects are never hidden.", + }, + + { id = "Entity", editor = "text", default = "", read_only = true, dont_save = true }, + -- The default values MUST be the values these properties are initialized with at object creation + { id = "Pos", name = "Pos", editor = "point", default = InvalidPos(), scale = "m", + buttons = {{ name = "View", func = "GedViewPosButton" }}, }, + { id = "Angle", editor = "number", default = 0, min = 0, max = 360*60 - 1, slider = true, scale = "deg", no_validate = true }, -- GetAngle can return -360..+360, skip validation + { id = "Scale", editor = "number", default = 100, slider = true, + min = function(self) return self:GetMinScale() end, + max = function(self) return self:GetMaxScale() end, + }, + { id = "Axis", editor = "point", default = axis_z, local_space = true, + buttons = {{ name = "View", func = "GedViewPosButton" }}, }, + { id = "Opacity", editor = "number", default = 100, min = 0, max = 100, slider = true }, + { id = "StateCategory", editor = "choice", items = function() return ArtSpecConfig and ArtSpecConfig.ReturnAnimationCategories end, default = "All", dont_save = true }, + { id = "StateText", name = "State/Animation", editor = "combo", default = "idle", items = function(obj) return obj:GetStatesTextTable(obj.StateCategory) end, show_recent_items = 7, + help = "Sets the mesh state or animation of the object.", + }, + { id = "TestStateButtons", editor = "buttons", default = false, dont_save = true, buttons = { + {name = "Play once(c)", func = "BtnTestOnce"}, + {name = "Loop(c)", func = "BtnTestLoop"}, + {name = "Test(c)", func = "BtnTestState"}, + {name = "Play once", func = "BtnTestOnce", param = "no_compensate"}, + {name = "Loop", func = "BtnTestLoop", param = "no_compensate"}, + {name = "Test", func = "BtnTestState", param = "no_compensate"}, + }}, + { id = "ForcedLOD", name = "Visualise LOD", editor = "number", default = 0, min = 0, slider = true, dont_save = true, help = "Forces specific lod to show.", + max = function(obj) + return obj:IsKindOf("GedMultiSelectAdapter") and 0 or (Max(obj:GetLODsCount(), 1) - 1) + end, + no_edit = function(obj) return not IsValid(obj) or not obj:HasEntity() or obj:GetEntity() == "InvisibleObject" end + }, + { id = "ForcedLODState", name = "Forced LOD", editor = "dropdownlist", + items = function(obj) return obj:GetLODsTextTable() end, default = "Automatic", + }, + { id = "Groups", editor = "string_list", default = false, items = function() return table.keys2(Groups or empty_table, "sorted") end, arbitrary_value = true, + help = "Assigns the object under one or more different names, by which it is referenced from the gameplay logic via markers or Lua code.", + }, + + { id = "ColorModifier", editor = "rgbrm", default = RGB(100, 100, 100) }, + { id = "Saturation", name = "Saturation(Debug)", editor = "number", slider = true, min = 0, max = 255, default = 128 }, + { id = "Gamma", name = "Gamma(Debug)", editor = "color", default = RGB(128, 128, 128) }, + + { id = "SIModulation", editor = "number", default = 100, min = 0, max = 255, slider = true}, + { id = "SIModulationManual", editor = "bool", default = false, read_only = true}, + + { id = "Occludes", editor = "bool", default = false }, + { id = "Walkable", editor = "bool", default = true }, + { id = "ApplyToGrids", editor = "bool", default = true }, + { id = "IgnoreHeightSurfaces", editor = "bool", default = false, }, + { id = "Collision", editor = "bool", default = true }, + { id = "Visible", editor = "bool", default = true, dont_save = true }, + { id = "SunShadow", name = "Shadow from Sun", editor = "bool", default = function(obj) return GetClassEnumFlags(obj.class, efSunShadow) ~= 0 end }, + { id = "CastShadow", name = "Shadow from All", editor = "bool", default = function(obj) return GetClassEnumFlags(obj.class, efShadow) ~= 0 end }, + { id = "Mirrored", name = "Mirrored", editor = "bool", default = false }, + { id = "OnRoof", name = "On Roof", editor = "bool", default = false }, + { id = "DontHideWithRoom", name = "Don't hide with room", editor = "bool", default = false, + no_edit = not const.SlabSizeX, dont_save = not const.SlabSizeX, + }, + + { id = "SkewX", name = "Skew X", editor = "number", default = 0 }, + { id = "SkewY", name = "Skew Y", editor = "number", default = 0 }, + { id = "ClipPlane", name = "Clip Plane", editor = "number", default = 0, read_only = true, dont_save = true }, + { id = "Radius", name = "Radius (m)", editor = "number", default = 0, scale = guim, read_only = true, dont_save = true }, + + { id = "AnimSpeedModifier", name = "Anim Speed Modifier", editor = "number", default = 1000, min = 0, max = 65535, slider = true }, + + { id = "OnCollisionWithCamera", editor = "choice", default = false, items = OnCollisionWithCameraItems, }, + { id = "Warped", editor = "bool", default = function (obj) return GetClassGameFlags(obj.class, gofWarped) ~= 0 end }, + + -- Required for map saving purposes only. + { id = "CollectionIndex", name = "Collection Index", editor = "number", default = 0, read_only = true }, + { id = "CollectionName", name = "Collection Name", editor = "choice", + items = GetCollectionNames, default = "", dont_save = true, + buttons = {{ name = "Collection Editor", func = function(self) + if self:GetRootCollection() then + OpenCollectionEditorAndSelectCollection(self) + end + end }}, + }, + }, + + SelectionPropagate = empty_func, + GedTreeCollapsedByDefault = true, -- for Ged object editor (selection properties) + PropertyTabs = { + { TabName = "Object", Categories = { Misc = true, ["Random Map"] = true, Child = true, } }, + }, + IsVirtual = empty_func, + GetDestlock = empty_func, +} + +--- +--- Returns the minimum and maximum scale limits for the CObject. +--- +--- If `mapdata.ArbitraryScale` is true, the limits are 10 and `const.GameObjectMaxScale`. +--- Otherwise, the limits are retrieved from the `ArtSpecConfig.ScaleLimits` table, using the object's `editor_category` and `editor_subcategory` properties. +--- If the limits cannot be found in the config, the default limits of 10 and 250 are returned. +--- +--- @return number min_scale +--- @return number max_scale +function CObject:GetScaleLimits() + if mapdata.ArbitraryScale then + return 10, const.GameObjectMaxScale + end + + local data = EntityData[self:GetEntity() or false] + local limits = data and rawget(_G, "ArtSpecConfig") and ArtSpecConfig.ScaleLimits + if limits then + local cat, sub = data.editor_category, data.editor_subcategory + local limits = + cat and sub and limits[cat][sub] or + cat and limits[cat] + if limits then + return limits[1], limits[2] + end + end + return 10, 250 +end + +--- +--- Returns the minimum and maximum scale limits for the CObject. +--- +--- The minimum scale limit is retrieved from the first element of the scale limits returned by `CObject:GetScaleLimits()`. +--- +--- The maximum scale limit is retrieved from the second element of the scale limits returned by `CObject:GetScaleLimits()`. +--- +--- @return number min_scale The minimum scale limit for the CObject. +--- @return number max_scale The maximum scale limit for the CObject. +function CObject:GetMinScale() return self:GetScaleLimits() end +function CObject:GetMaxScale() return select(2, self:GetScaleLimits()) end +--- +--- Sets the scale of the CObject, clamped to the minimum and maximum scale limits. +--- +--- @param scale number The desired scale for the CObject. +function CObject:SetScaleClamped(scale) + self:SetScale(Clamp(scale, self:GetScaleLimits())) +end + +--- +--- Returns the current value of the CObject's enum flags. +--- +--- @return number enum_flags The current value of the CObject's enum flags. +function CObject:GetEnumFlagsProp() + return self:GetEnumFlags() +end + +--- +--- Sets the enum flags of the CObject to the specified value. +--- +--- This function first sets the enum flags of the CObject to the specified `val` value, and then clears any enum flags that are not set in the `val` value. +--- +--- @param val number The new value for the CObject's enum flags. +--- +function CObject:SetEnumFlagsProp(val) + self:SetEnumFlags(val) + self:ClearEnumFlags(bnot(val)) +end + +--- +--- Returns the current value of the CObject's game flags. +--- +--- @return number game_flags The current value of the CObject's game flags. +function CObject:GetGameFlagsProp() + return self:GetGameFlags() +end + +--- +--- Defines constants for the detail class of a CObject. +--- +--- `gofDetailClass0` and `gofDetailClass1` are bit flags that represent the detail class of a CObject. +--- `gofDetailClassMask` is a mask that can be used to extract the detail class from the CObject's game flags. +--- +--- @field gofDetailClass0 number A bit flag representing the "Default" detail class. +--- @field gofDetailClass1 number A bit flag representing the "Essential", "Optional", or "Eye Candy" detail classes. +--- @field gofDetailClassMask number A mask that can be used to extract the detail class from the CObject's game flags. +local gofDetailClass0, gofDetailClass1 = const.gofDetailClass0, const.gofDetailClass1 +local gofDetailClassMask = const.gofDetailClassMask +--- Defines a table of detail class names and their corresponding bit flag values. +--- +--- The detail class of a CObject is represented by a combination of two bit flags: +--- - `gofDetailClass0`: Represents the "Default" detail class. +--- - `gofDetailClass1`: Represents the "Essential", "Optional", or "Eye Candy" detail classes. +--- +--- This table maps the detail class names to their corresponding bit flag values, which are defined in the `const` table. +--- +--- @field ["Default"] number The bit flag value for the "Default" detail class. +--- @field ["Essential"] number The bit flag value for the "Essential" detail class. +--- @field ["Optional"] number The bit flag value for the "Optional" detail class. +--- @field ["Eye Candy"] number The bit flag value for the "Eye Candy" detail class. +local s_DetailsValue = { + ["Default"] = const.gofDetailClassDefaultMask, + ["Essential"] = const.gofDetailClassEssential, + ["Optional"] = const.gofDetailClassOptional, + ["Eye Candy"] = const.gofDetailClassEyeCandy, +} +local s_DetailsName = {} +for name, value in pairs(s_DetailsValue) do + s_DetailsName[value] = name +end + +--- +--- Returns the name of the detail class corresponding to the given detail class mask. +--- +--- @param mask number The detail class mask. +--- @return string The name of the detail class. +function GetDetailClassMaskName(mask) + return s_DetailsName[mask] +end + +--- +--- Sets the game flags of the CObject and updates the detail class accordingly. +--- +--- @param val number The new value for the game flags. +function CObject:SetGameFlagsProp(val) + self:SetGameFlags(val) + self:ClearGameFlags(bnot(val)) + self:SetDetailClass(s_DetailsName[val & gofDetailClassMask]) +end + +--- +--- Returns the class flags of the CObject. +--- +--- @return number The class flags of the CObject. +function CObject:GetClassFlagsProp() + return self:GetClassFlags() +end + +--- +--- Returns the component flags of the CObject. +--- +--- @return number The component flags of the CObject. +function CObject:GetComponentFlagsProp() + return self:GetComponentFlags() +end + +--- +--- Returns the enum flags of the CObject. +--- +--- @return number The enum flags of the CObject. +function CObject:GetEnumFlagsProp() + return self:GetEnumFlags() +end + +--- +--- Returns the surface mask of the CObject. +--- +--- @return number The surface mask of the CObject. +function CObject:GetSurfacesProp() + return GetSurfacesMask(self) +end + +--- +--- Returns the detail class of the CObject. +--- +--- @return string The name of the detail class. +function CObject:GetDetailClass() + return IsValid(self) and s_DetailsName[self:GetGameFlags(gofDetailClassMask)] or s_DetailsName[0] +end + +--- +--- Sets the detail class of the CObject by updating the corresponding game flags. +--- +--- @param details string The name of the detail class to set. +function CObject:SetDetailClass(details) + local value = s_DetailsValue[details] + if band(value, gofDetailClass0) ~= 0 then + self:SetGameFlags(gofDetailClass0) + else + self:ClearGameFlags(gofDetailClass0) + end + if band(value, gofDetailClass1) ~= 0 then + self:SetGameFlags(gofDetailClass1) + else + self:ClearGameFlags(gofDetailClass1) + end +end + +--- +--- Sets the shadow-only state of the CObject. +--- +--- @param bSet boolean Whether to set the CObject as shadow-only. +--- @param time number The time in seconds to transition the opacity change. +function CObject:SetShadowOnly(bSet, time) + if not time or IsEditorActive() then + time = 0 + end + if bSet then + self:SetHierarchyGameFlags(const.gofSolidShadow) + self:SetOpacity(0, time) + else + self:ClearHierarchyGameFlags(const.gofSolidShadow) + self:SetOpacity(100, time) + end +end + +--- +--- Sets the gamma value of the CObject. +--- +--- @param value number The new gamma value to set. +function CObject:SetGamma(value) + local saturation = GetAlpha(self:GetSatGamma()) + self:SetSatGamma(SetA(value, saturation)) +end + +--- +--- Gets the gamma value of the CObject. +--- +--- @return number The gamma value of the CObject. +function CObject:GetGamma() + return SetA(self:GetSatGamma(), 255) +end + +--- +--- Sets the saturation value of the CObject. +--- +--- @param value number The new saturation value to set. +function CObject:SetSaturation(value) + local old = self:GetSatGamma() + self:SetSatGamma(SetA(old, value)) +end + +--- +--- Gets the saturation value of the CObject. +--- +--- @return number The saturation value of the CObject. +function CObject:GetSaturation() + return GetAlpha(self:GetSatGamma()) +end + +--- +--- Handles editor property changes for a CObject. +--- +--- @param prop_id string The ID of the property that was changed. +--- @param old_value any The previous value of the property. +--- @param ged table The GED (Graphical Editor) object associated with the property. +--- @param multi boolean Whether the property change was part of a multi-object edit. +--- +--- This function is called when a property of the CObject is changed in the editor. It performs the following actions: +--- +--- 1. Calls the `OnEditorSetProperty` function of the `ColorizableObject` class. +--- 2. If the changed property is "Saturation" or "Gamma", and the `hr.UseSatGammaModifier` is 0, it sets `hr.UseSatGammaModifier` to 1 and calls `RecreateRenderObjects()`. +--- 3. If the changed property is "ForcedLODState", and the CObject is an `AutoAttachObject`, it calls the `SetAutoAttachMode` function with the current auto attach mode. +--- 4. If the changed property is "SIModulation", it sets the `SIModulationManual` property based on whether the new value is the default value. +--- +function CObject:OnEditorSetProperty(prop_id, old_value, ged, multi) + ColorizableObject.OnEditorSetProperty(self, prop_id, old_value, ged, multi) + + if (prop_id == "Saturation" or prop_id == "Gamma") and hr.UseSatGammaModifier == 0 then + hr.UseSatGammaModifier = 1 + RecreateRenderObjects() + elseif prop_id == "ForcedLODState" then + if self:IsKindOf("AutoAttachObject") then + self:SetAutoAttachMode(self:GetAutoAttachMode()) + end + elseif prop_id == "SIModulation" then + local prop_meta = self:GetPropertyMetadata(prop_id) + self.SIModulationManual = self:GetProperty(prop_id) ~= prop_meta.default + end +end + +--- +--- Resets the `ObjectsShownOnPreSave` table to `false` on first load. +--- +--- This code is executed when the script is first loaded, and it sets the `ObjectsShownOnPreSave` table to `false`. This table is used to store information about objects that were made visible or had their opacity changed during the pre-save process, so that their state can be restored after the map is saved. +--- +--- @param FirstLoad boolean Whether this is the first time the script has been loaded. +if FirstLoad then + ObjectsShownOnPreSave = false +end + +--- +--- Handles the pre-save process for the map, ensuring that certain objects are visible and have their opacity set to 100% before the map is saved. +--- +--- This function is called when the game receives the `PreSaveMap` message, which occurs before the map is saved. It iterates through all `CObject` instances in the map and performs the following actions: +--- +--- 1. If the object has the `gofSolidShadow` game flag set and is not a `Decal`, its current opacity is stored in the `ObjectsShownOnPreSave` table, and its opacity is set to 100%. +--- 2. If the object is not visible (its `efVisible` enum flag is 0), and it is not an `EditorVisibleObject` or a `Slab`, its visibility is set to visible and its state is stored in the `ObjectsShownOnPreSave` table. +--- +--- The `ObjectsShownOnPreSave` table is used to store the state of the objects that were modified during the pre-save process, so that their state can be restored after the map is saved. +--- +function OnMsg.PreSaveMap() + ObjectsShownOnPreSave = {} + MapForEach("map", "CObject", function(o) + if o:GetGameFlags(const.gofSolidShadow) ~= 0 and not IsKindOf(o, "Decal") then + ObjectsShownOnPreSave[o] = o:GetOpacity() + o:SetOpacity(100) + elseif o:GetEnumFlags(const.efVisible) == 0 then + local skip = IsKindOf(o, "EditorVisibleObject") or (const.SlabSizeX and IsKindOf(o, "Slab")) + if not skip then + ObjectsShownOnPreSave[o] = true + o:SetEnumFlags(const.efVisible) + end + end + end) +end + + +--- +--- Restores the visibility and opacity of objects that were modified during the pre-save process. +--- +--- This function is called when the game receives the `PostSaveMap` message, which occurs after the map has been saved. It iterates through the `ObjectsShownOnPreSave` table, which was populated during the `OnMsg.PreSaveMap` function, and restores the original visibility and opacity of the affected objects. +--- +--- For objects that had their opacity set to 100% during the pre-save process, their original opacity is restored. For objects that were made visible during the pre-save process, their visibility is restored to the original state. +--- +--- After the objects have been restored, the `ObjectsShownOnPreSave` table is set to `false` to indicate that the post-save process has completed. +--- +--- @param ObjectsShownOnPreSave table A table that stores the original visibility and opacity of objects that were modified during the pre-save process. +function OnMsg.PostSaveMap() + for o, opacity in pairs(ObjectsShownOnPreSave) do + if IsValid(o) then + if type(opacity) == "number" then + o:SetOpacity(opacity) + else + o:ClearEnumFlags(const.efVisible) + end + end + end + ObjectsShownOnPreSave = false +end + +--- +--- Returns the collision behavior with the camera for this CObject. +--- +--- This function checks the default collision behavior for the class of this CObject, as well as the current collision behavior set for this specific CObject instance. It returns a string indicating the current collision behavior, which can be one of the following: +--- +--- - "repulse camera" +--- - "become transparent" +--- - "repulse camera & become transparent" +--- - "no action" +--- +--- If the current collision behavior matches the default behavior for the class, this function returns `false`. +--- +--- @return string|boolean The current collision behavior with the camera, or `false` if it matches the default behavior. +function CObject:GetOnCollisionWithCamera() + local become_transparent_default = GetClassEnumFlags(self.class, const.efCameraMakeTransparent) ~= 0 + local repulse_camera_default = GetClassEnumFlags(self.class, const.efCameraRepulse) ~= 0 + local become_transparent = self:GetEnumFlags(const.efCameraMakeTransparent) ~= 0 + local repulse_camera = self:GetEnumFlags(const.efCameraRepulse) ~= 0 + if become_transparent_default == become_transparent and repulse_camera_default == repulse_camera then + return false + end + if repulse_camera and not become_transparent then + return "repulse camera" + end + if become_transparent and not repulse_camera then + return "become transparent" + end + if become_transparent and repulse_camera then + return "repulse camera & become transparent" + end + return "no action" +end + +--- +--- Sets the collision behavior with the camera for this CObject. +--- +--- This function allows setting the collision behavior with the camera for this CObject. The behavior can be one of the following: +--- +--- - "repulse camera" +--- - "become transparent" +--- - "repulse camera & become transparent" +--- - "no action" +--- +--- The function will set the appropriate enum flags on the CObject to reflect the desired behavior. +--- +--- @param value string The desired collision behavior with the camera. Can be one of the strings listed above. +--- +function CObject:SetOnCollisionWithCamera(value) + local cmt, cr + if value then + local flags = OCCtoFlags[value] + cmt = flags and flags.efCameraMakeTransparent + cr = flags and flags.efCameraRepulse + end + if cmt == nil then + cmt = GetClassEnumFlags(self.class, const.efCameraMakeTransparent) ~= 0 -- class default + end + if cmt then + self:SetEnumFlags(const.efCameraMakeTransparent) + else + self:ClearEnumFlags(const.efCameraMakeTransparent) + end + if cr == nil then + cr = GetClassEnumFlags(self.class, const.efCameraRepulse) ~= 0 -- class default + end + if cr then + self:SetEnumFlags(const.efCameraRepulse) + else + self:ClearEnumFlags(const.efCameraRepulse) + end +end + +--- +--- Gets the name of the collection that this CObject belongs to. +--- +--- @return string The name of the collection, or an empty string if the CObject is not in a collection. +--- +function CObject:GetCollectionName() + local col = self:GetCollection() + return col and col.Name or "" +end + +--- +--- Sets the name of the collection that this CObject belongs to. +--- +--- This function allows setting the collection that this CObject belongs to. If the CObject was previously in a different collection, it will be removed from that collection and added to the new one. +--- +--- @param name string The name of the collection to set for this CObject. +--- +function CObject:SetCollectionName(name) + local col = CollectionsByName[name] + local prev_col = self:GetCollection() + if prev_col ~= col then + self:SetCollection(col) + end +end + +--- +--- Returns a list of objects that are "connected to", or "a part of" this object. +--- +--- These objects "go together" in undo and copy logic; e.g. it is assumed changes to the Room can update/delete/create its child Slabs. +--- +--- @return table A list of objects that are related to this object. +--- +function CObject:GetEditorRelatedObjects() + -- return objects that are "connected to", or "a part of" this object + -- these objects "go together" in undo and copy logic; e.g. is it assumed changes to the Room can update/delete/create its child Slabs +end + +--- +--- Returns an object that "owns" this object logically, e.g. a Room owns all its Slabs. +--- +--- Changes to the "parent" will be tracked by undo when the "child" is updated/deleted/created. +--- It is also assumed that moving the "parent" will auto-move the "children". +--- +--- @return CObject The parent object of this CObject, or nil if this CObject has no parent. +--- +function CObject:GetEditorParentObject() + -- return an object that "owns" this object logically, e.g. a Room owns all its Slabs + -- changes to the "parent" will be tracked by undo when the "child" is updated/deleted/created + -- it is also assumed that moving the "parent" will auto-move the "childen" +end + +-- Used to identify objects on existing maps that don't have a handle, e.g. in map patches. +-- Returns a hash of the basic object properties, but some classes like Container and Slab +-- this is not sufficient, and they have separate implementations. +--- +--- Returns a unique identifier for this CObject. +--- +--- The identifier is calculated using the object's class, entity, position, axis, angle, and scale. +--- +--- @return string A unique identifier for this CObject. +--- +function CObject:GetObjIdentifier() + return xxhash(self.class, self.entity, self:GetPos(), self:GetAxis(), self:GetAngle(), self:GetScale()) +end + +--- Returns the material type of this CObject. +--- +--- @return string The material type of this CObject. +function CObject:GetMaterialType() + return self.material_type +end + +-- copy functions exported from C +for name, value in pairs(g_CObjectFuncs) do + CObject[name] = value +end + +-- table used for keeping references in the C code to Lua objects +--- +--- A table that maps C objects to their corresponding Lua objects. +--- This table uses weak keys and values, allowing the Lua objects to be garbage collected when they are no longer referenced. +--- +--- @type table +--- +MapVar("__cobjectToCObject", {}, weak_keyvalues_meta) + +-- table with destroyed objects +--- +--- A table that maps destroyed CObject instances to a boolean value. +--- This table uses weak keys, allowing the CObject instances to be garbage collected when they are no longer referenced. +--- +--- @type table +--- +MapVar("DeletedCObjects", {}, weak_keyvalues_meta) + +--- Creates a new Lua object by calling the `new` method on the metatable of the provided Lua object. +--- +--- @param luaobj table The Lua object to create a new instance of. +--- @return table A new instance of the Lua object. +function CreateLuaObject(luaobj) + return luaobj.new(getmetatable(luaobj), luaobj) +end + +--- +--- Retrieves a new CObject instance from the C++ implementation. +--- +--- @param class table The class of the CObject to create. +--- @param components table The components to initialize the CObject with. +--- @return CObject A new CObject instance. +--- +local __PlaceObject = __PlaceObject +--- Creates a new CObject instance. +--- +--- @param class table The class of the CObject to create. +--- @param luaobj table The Lua object to create a new instance of. +--- @param components table The components to initialize the CObject with. +--- @return table A new instance of the CObject. +function CObject.new(class, luaobj, components) + if luaobj and luaobj[true] then -- constructed from C + return luaobj + end + local cobject = __PlaceObject(class.class, components) + assert(cobject) + if cobject then + if luaobj then + luaobj[true] = cobject + else + luaobj = { [true] = cobject } + end + __cobjectToCObject[cobject] = luaobj + end + setmetatable(luaobj, class) + return luaobj +end + +--- +--- Deletes a CObject instance. +--- +--- @param fromC boolean If true, the CObject is being deleted from C++ code. If false, the CObject is being deleted from Lua code. +--- +function CObject:delete(fromC) + if not self[true] then return end + self:RemoveLuaReference() + self:SetCollectionIndex(0) + + DeletedCObjects[self] = true + if not fromC then + __DestroyObject(self) + end + __cobjectToCObject[self[true]] = nil + self[true] = false +end + +--- +--- Retrieves the collection that the CObject is a part of. +--- +--- @return table|boolean The collection that the CObject is a part of, or false if the CObject is not part of a collection. +--- +function CObject:GetCollection() + local idx = self:GetCollectionIndex() + return idx ~= 0 and Collections[idx] or false +end + +--- +--- Retrieves the root collection that the CObject is a part of. +--- +--- @return table|boolean The root collection that the CObject is a part of, or false if the CObject is not part of a collection. +--- +function CObject:GetRootCollection() + local idx = Collection.GetRoot(self:GetCollectionIndex()) + return idx ~= 0 and Collections[idx] or false +end + +--- +--- Sets the collection that the CObject is a part of. +--- +--- @param collection table|boolean The collection to set the CObject to, or false to remove the CObject from any collection. +--- @return boolean True if the collection was successfully set, false otherwise. +--- +function CObject:SetCollection(collection) + return self:SetCollectionIndex(collection and collection.Index or false) +end + +--- +--- Returns whether the CObject is visible. +--- +--- @return boolean True if the CObject is visible, false otherwise. +--- +function CObject:GetVisible() + return self:GetEnumFlags( efVisible ) ~= 0 +end + +--- +--- Sets the visibility of the CObject. +--- +--- @param value boolean Whether the CObject should be visible or not. +--- +function CObject:SetVisible(value) + if value then + self:SetEnumFlags( efVisible ) + else + self:ClearEnumFlags( efVisible ) + end +end + +--- +--- A table that caches the forced LOD state for CObject instances. +--- +local cached_forced_lods = {} + +--- +--- Caches the forced LOD state for the CObject instance. +--- +--- This function stores the current forced LOD state of the CObject in the `cached_forced_lods` table, so that it can be restored later using the `RestoreForcedLODState` function. +--- +--- @function CObject:CacheForcedLODState +--- @return nil +function CObject:CacheForcedLODState() + cached_forced_lods[self] = self:GetForcedLOD() or self:GetForcedLODMin() +end + +--- +--- Restores the forced LOD state of the CObject instance. +--- +--- This function retrieves the previously cached forced LOD state of the CObject and sets it back on the object. If the cached state was a number, it sets the forced LOD index to that value. If the cached state was `true`, it sets the forced LOD to the minimum. If the cached state was `false`, it clears the forced LOD. +--- +--- After restoring the forced LOD state, the function removes the cached state from the `cached_forced_lods` table. +--- +--- @function CObject:RestoreForcedLODState +--- @return nil +function CObject:RestoreForcedLODState() + if type(cached_forced_lods[self]) == "number" then + self:SetForcedLOD(cached_forced_lods[self]) + elseif cached_forced_lods[self] then + self:SetForcedLODMin(true) + else + self:SetForcedLOD(const.InvalidLODIndex) + end + + cached_forced_lods[self] = nil +end + +--- +--- Returns a table of strings representing the different LOD (Level of Detail) levels for the CObject. +--- +--- The table contains the following entries: +--- - "Automatic": Represents automatic LOD selection. +--- - "LOD 0", "LOD 1", ...: Represents the different LOD levels, starting from 0. +--- - "Minimum": Represents the minimum LOD level. +--- +--- @return table A table of strings representing the different LOD levels. +function CObject:GetLODsTextTable() + local lods = {} + + lods[1] = "Automatic" + + for i = 1, Max(self:GetLODsCount(), 1) do + lods[i + 1] = string.format("LOD %s", i - 1) + end + + lods[#lods + 1] = "Minimum" + + return lods +end + +--- +--- Returns the current forced LOD (Level of Detail) state of the CObject instance. +--- +--- The function first checks the `cached_forced_lods` table for a previously cached forced LOD state. If a cached state is found, it is returned. +--- +--- If no cached state is found, the function retrieves the current forced LOD state of the CObject by calling `self:GetForcedLOD()` or `self:GetForcedLODMin()`. The returned value is then categorized and returned as a string: +--- +--- - If the forced LOD state is a number, it is returned as a string in the format "LOD {number}". +--- - If the forced LOD state is `true`, it is returned as the string "Minimum". +--- - If the forced LOD state is `false` or `const.InvalidLODIndex`, it is returned as the string "Automatic". +--- +--- @return string The current forced LOD state of the CObject instance. +function CObject:GetForcedLODState() + local lodState = cached_forced_lods[self] + + if lodState == nil then + lodState = self:GetForcedLOD() or self:GetForcedLODMin() + end + + if type(lodState) == "number" then + return string.format("LOD %s", lodState) + elseif lodState then + return "Minimum" + else + return "Automatic" + end +end + +--- +--- Sets the forced LOD (Level of Detail) state of the CObject instance. +--- +--- The function first checks the `cached_forced_lods` table for a previously cached forced LOD state. If a cached state is found, it is returned. +--- +--- If no cached state is found, the function retrieves the current forced LOD state of the CObject by calling `self:GetForcedLOD()` or `self:GetForcedLODMin()`. The returned value is then categorized and returned as a string: +--- +--- - If the forced LOD state is a number, it is returned as a string in the format "LOD {number}". +--- - If the forced LOD state is `true`, it is returned as the string "Minimum". +--- - If the forced LOD state is `false` or `const.InvalidLODIndex`, it is returned as the string "Automatic". +--- +--- @param value string The new forced LOD state to set. Can be "Minimum", "Automatic", or a string in the format "LOD {number}". +function CObject:SetForcedLODState(value) + local cache_forced_lod = nil + if value == "Minimum" then + self:SetForcedLODMin(true) + cache_forced_lod = true + elseif value == "Automatic" then + self:SetForcedLOD(const.InvalidLODIndex) + cache_forced_lod = false + else + local lodsTable = self:GetLODsTextTable() + local targetIndex = nil + + for index, tableValue in ipairs(lodsTable) do + if value == tableValue then + targetIndex = index + break + end + end + + if targetIndex then + local lod = Max(targetIndex - 2, 0) + cache_forced_lod = lod + self:SetForcedLOD(lod) + end + end + if cached_forced_lods[self] ~= nil then + cached_forced_lods[self] = cache_forced_lod + end +end + +--- +--- Returns whether the CObject instance is currently warped. +--- +--- @return boolean Whether the CObject instance is warped. +function CObject:GetWarped() + return self:GetGameFlags(gofWarped) ~= 0 +end + +--- +--- Sets the warped state of the CObject instance. +--- +--- If `value` is `true`, the CObject instance is set to be warped by setting the `gofWarped` game flag. +--- If `value` is `false`, the `gofWarped` game flag is cleared, setting the CObject instance to be not warped. +--- +--- @param value boolean The new warped state to set for the CObject instance. +function CObject:SetWarped(value) + if value then + self:SetGameFlags(gofWarped) + else + self:ClearGameFlags(gofWarped) + end +end + +--- Returns whether the object is in the process of being destructed +--@cstyle bool IsBeingDestructed(object obj) +--@param obj object +--- +--- Returns whether the given object is in the process of being destructed. +--- +--- @param obj object The object to check. +--- @return boolean Whether the object is being destructed. +function IsBeingDestructed(obj) + return DeletedCObjects[obj] or obj:IsBeingDestructed() +end + +--- +--- Sets whether the CObject instance should use real-time animation. +--- +--- If `bRealtime` is `true`, the `gofRealTimeAnim` game flag is set on the CObject instance, enabling real-time animation. +--- If `bRealtime` is `false`, the `gofRealTimeAnim` game flag is cleared, disabling real-time animation. +--- +--- @param bRealtime boolean Whether to enable or disable real-time animation for the CObject instance. +--- +function CObject:SetRealtimeAnim(bRealtime) + if bRealtime then + self:SetHierarchyGameFlags(const.gofRealTimeAnim) + else + self:ClearHierarchyGameFlags(const.gofRealTimeAnim) + end +end + +--- +--- Returns whether the CObject instance is currently using real-time animation. +--- +--- @return boolean Whether the CObject instance is using real-time animation. +function CObject:GetRealtimeAnim() + return self:GetGameFlags(const.gofRealTimeAnim) ~= 0 +end + +-- Support for groups +--- +--- Initializes the global `Groups` table to an empty table. +--- +--- The `Groups` table is used to store groups of `CObject` instances. This mapping allows `CObject` instances to be associated with one or more groups. +--- +--- @field Groups table A table that maps group names to lists of `CObject` instances. +--- +MapVar("Groups", {}) +--- +--- Finds the first occurrence of an element in a table. +--- +--- @param t table The table to search. +--- @param value any The value to search for. +--- @return integer|nil The index of the first occurrence of the value, or `nil` if not found. +--- +local find = table.find +local remove_entry = table.remove_entry + +--- +--- Adds the CObject instance to the specified group. +--- +--- If the group does not exist, it is created. The CObject instance is then added to the group. +--- +--- @param group_name string The name of the group to add the CObject instance to. +--- +function CObject:AddToGroup(group_name) + local group = Groups[group_name] + if not group then + group = {} + Groups[group_name] = group + end + if not find(group, self) then + group[#group + 1] = self + self.Groups = self.Groups or {} + self.Groups[#self.Groups + 1] = group_name + end +end + +--- +--- Checks if the CObject instance is a member of the specified group. +--- +--- @param group_name string The name of the group to check. +--- @return boolean Whether the CObject instance is a member of the specified group. +--- +function CObject:IsInGroup(group_name) + return find(self.Groups, group_name) +end + +--- +--- Removes the CObject instance from the specified group. +--- +--- If the CObject instance is not a member of the specified group, this function does nothing. +--- +--- @param group_name string The name of the group to remove the CObject instance from. +--- +function CObject:RemoveFromGroup(group_name) + remove_entry(Groups[group_name], self) + remove_entry(self.Groups, group_name) +end + +--- +--- Removes the CObject instance from all groups it is a member of. +--- +--- If the CObject instance is not a member of any groups, this function does nothing. +--- +function CObject:RemoveFromAllGroups() + local Groups = Groups + for i, group_name in ipairs(self.Groups) do + remove_entry(Groups[group_name], self) + end + self.Groups = nil +end + +--[[@@@ +Called when a cobject having a Lua reference is being destroyed. The method isn't overriden by child classes, but instead all implementations are called starting from the topmost parent. +@function void CObject:RemoveLuaReference() +--]] +--- +--- Removes the Lua reference for the CObject instance. +--- +--- This method is called when a CObject instance having a Lua reference is being destroyed. The method is not overridden by child classes, but instead all implementations are called starting from the topmost parent. +--- +function CObject:RemoveLuaReference() +end +RecursiveCallMethods.RemoveLuaReference = "procall_parents_last" +CObject.RemoveLuaReference = CObject.RemoveFromAllGroups + +--- +--- Sets the groups that the CObject instance belongs to. +--- +--- This function removes the CObject instance from any groups it was previously a member of, and adds it to the specified groups. +--- +--- @param groups table A table of group names that the CObject instance should belong to. +--- +function CObject:SetGroups(groups) + for _, group in ipairs(self.Groups or empty_table) do + if not find(groups or empty_table, group) then + self:RemoveFromGroup(group) + end + end + for _, group in ipairs(groups or empty_table) do + if not find(self.Groups or empty_table, group) then + self:AddToGroup(group) + end + end +end + +--- +--- Gets a random spot position asynchronously. +--- +--- @param type string The type of spot to get. +--- @return table The random spot position. +--- +function CObject:GetRandomSpotAsync(type) + return self:GetRandomSpot(type) +end + +--- +--- Gets a random spot position asynchronously. +--- +--- @param type string The type of spot to get. +--- @return table The random spot position. +--- +function CObject:GetRandomSpotPosAsync(type) + return self:GetRandomSpotPos(type) +end + +-- returns false, "local" or "remote" +--- +--- Returns false, indicating that this CObject instance does not have a network state. +--- +function CObject:NetState() + return false +end + +--- +--- Gets whether the CObject instance is walkable. +--- +--- @return boolean Whether the CObject instance is walkable. +--- +function CObject:GetWalkable() + return self:GetEnumFlags(const.efWalkable) ~= 0 +end + +--- +--- Sets whether the CObject instance is walkable. +--- +--- @param walkable boolean Whether the CObject instance should be walkable. +--- +function CObject:SetWalkable(walkable) + if walkable then + self:SetEnumFlags(const.efWalkable) + else + self:ClearEnumFlags(const.efWalkable) + end +end + +--- +--- Gets whether the CObject instance has collision enabled. +--- +--- @return boolean Whether the CObject instance has collision enabled. +--- +function CObject:GetCollision() + return self:GetEnumFlags(const.efCollision) ~= 0 +end + +--- +--- Sets whether the CObject instance has collision enabled. +--- +--- @param value boolean Whether the CObject instance should have collision enabled. +--- +function CObject:SetCollision(value) + if value then + self:SetEnumFlags(const.efCollision) + else + self:ClearEnumFlags(const.efCollision) + end +end + +--- +--- Gets whether the CObject instance should be applied to grids. +--- +--- @return boolean Whether the CObject instance should be applied to grids. +--- +function CObject:GetApplyToGrids() + return self:GetEnumFlags(const.efApplyToGrids) ~= 0 +end + +--- +--- Sets whether the CObject instance should be applied to grids. +--- +--- @param value boolean Whether the CObject instance should be applied to grids. +--- +function CObject:SetApplyToGrids(value) + if not not value == self:GetApplyToGrids() then + return + end + if value then + self:SetEnumFlags(const.efApplyToGrids) + else + self:ClearEnumFlags(const.efApplyToGrids) + end + self:InvalidateSurfaces() +end + +--- +--- Gets whether the CObject instance should ignore height surfaces. +--- +--- @return boolean Whether the CObject instance should ignore height surfaces. +--- +function CObject:GetIgnoreHeightSurfaces() + return self:GetGameFlags(const.gofIgnoreHeightSurfaces) ~= 0 +end + +--- +--- Sets whether the CObject instance should ignore height surfaces. +--- +--- @param value boolean Whether the CObject instance should ignore height surfaces. +--- +function CObject:SetIgnoreHeightSurfaces(value) + if not not value == self:GetIgnoreHeightSurfaces() then + return + end + if value then + self:SetGameFlags(const.gofIgnoreHeightSurfaces) + else + self:ClearGameFlags(const.gofIgnoreHeightSurfaces) + end + self:InvalidateSurfaces() +end + +--- +--- Checks if the CObject instance has a valid entity. +--- +--- @return boolean Whether the CObject instance has a valid entity. +--- +function CObject:IsValidEntity() + return IsValidEntity(self:GetEntity()) +end + +--- +--- Gets whether the CObject instance has sun shadow enabled. +--- +--- @return boolean Whether the CObject instance has sun shadow enabled. +--- +function CObject:GetSunShadow() + return self:GetEnumFlags(const.efSunShadow) ~= 0 +end + +--- +--- Sets whether the CObject instance should cast a sun shadow. +--- +--- @param sunshadow boolean Whether the CObject instance should cast a sun shadow. +--- +function CObject:SetSunShadow(sunshadow) + if sunshadow then + self:SetEnumFlags(const.efSunShadow) + else + self:ClearEnumFlags(const.efSunShadow) + end +end + +--- +--- Gets whether the CObject instance casts a shadow. +--- +--- @return boolean Whether the CObject instance casts a shadow. +--- +function CObject:GetCastShadow() + return self:GetEnumFlags(const.efShadow) ~= 0 +end + +--- +--- Sets whether the CObject instance should cast a shadow. +--- +--- @param shadow boolean Whether the CObject instance should cast a shadow. +--- +function CObject:SetCastShadow(shadow) + if shadow then + self:SetEnumFlags(const.efShadow) + else + self:ClearEnumFlags(const.efShadow) + end +end + +--- +--- Gets whether the CObject instance is on a roof. +--- +--- @return boolean Whether the CObject instance is on a roof. +--- +function CObject:GetOnRoof() + return self:GetGameFlags(const.gofOnRoof) ~= 0 +end + +--- +--- Sets whether the CObject instance is on a roof. +--- +--- @param on_roof boolean Whether the CObject instance is on a roof. +--- +function CObject:SetOnRoof(on_roof) + if on_roof then + self:SetGameFlags(const.gofOnRoof) + else + self:ClearGameFlags(const.gofOnRoof) + end +end + +--- +--- Gets whether the CObject instance should not be hidden with the room. +--- +--- @return boolean Whether the CObject instance should not be hidden with the room. +--- +function CObject:GetDontHideWithRoom() + return self:GetGameFlags(const.gofDontHideWithRoom) ~= 0 +end + +--- +--- Sets whether the CObject instance should not be hidden with the room. +--- +--- @param val boolean Whether the CObject instance should not be hidden with the room. +--- +function CObject:SetDontHideWithRoom(val) + if val then + self:SetGameFlags(const.gofDontHideWithRoom) + else + self:ClearGameFlags(const.gofDontHideWithRoom) + end +end +if const.SlabSizeX then + function CObject:GetDontHideWithRoom() + return self:GetGameFlags(const.gofDontHideWithRoom) ~= 0 + end + + function CObject:SetDontHideWithRoom(val) + if val then + self:SetGameFlags(const.gofDontHideWithRoom) + else + self:ClearGameFlags(const.gofDontHideWithRoom) + end + end +end + +--- +--- Gets the number of LODs (Levels of Detail) for the CObject's current state. +--- +--- @return number The number of LODs for the CObject's current state. +--- +function CObject:GetLODsCount() + local entity = self:GetEntity() + return entity ~= "" and GetStateLODCount(entity, self:GetState()) or 1 +end + +--- +--- Gets the default property value for the specified property of the CObject instance. +--- +--- @param prop string The name of the property to get the default value for. +--- @param prop_meta table The metadata for the property. +--- @return any The default value for the specified property. +--- +--- +--- Gets the default property value for the specified property of the CObject instance. +--- +--- @param prop string The name of the property to get the default value for. +--- @param prop_meta table The metadata for the property. +--- @return any The default value for the specified property. +--- +function CObject:GetDefaultPropertyValue(prop, prop_meta) + if prop == "ApplyToGrids" then + return GetClassEnumFlags(self.class, const.efApplyToGrids) ~= 0 + elseif prop == "Collision" then + return GetClassEnumFlags(self.class, const.efCollision) ~= 0 + elseif prop == "Walkable" then + return GetClassEnumFlags(self.class, const.efWalkable) ~= 0 + elseif prop == "DetailClass" then + local details_mask = GetClassGameFlags(self.class, gofDetailClassMask) + return GetDetailClassMaskName(details_mask) + end + return PropertyObject.GetDefaultPropertyValue(self, prop, prop_meta) +end + +-- returns the first valid state for the unit or the last one if none is valid +--- +--- Chooses a valid state for the CObject instance. +--- +--- @param state string The current state of the CObject. +--- @param next_state string The next state to try. +--- @return string The valid state for the CObject. +--- +function CObject:ChooseValidState(state, next_state, ...) + if next_state == nil then return state end + if state and self:HasState(state) and not self:IsErrorState(state) then + return state + end + return self:ChooseValidState(next_state, ...) +end + +-- State property (implemented as text for saving compatibility) +--- +--- Gets a table of state names for the CObject instance, optionally filtered by category. +--- +--- @param category string (optional) The category to filter the states by. +--- @return table A table of state names for the CObject instance. +--- +function CObject:GetStatesTextTable(category) + local entity = IsValid(self) and self:GetEntity() + if not IsValidEntity(entity) then return {} end + local states = category and GetStatesFromCategory(entity, category) or self:GetStates() + local i = 1 + while i <= #states do + local state = states[i] + if string.starts_with(state, "_") then --> ignore states beginning with '_' + table.remove(states, i) + else + if self:IsErrorState(GetStateIdx(state)) then + states[i] = state.." *" + end + i = i + 1 + end + end + table.sort(states) + return states +end + +--- +--- Sets the state of the CObject instance to the specified value. +--- +--- If the value ends with an asterisk (*), the asterisk is removed before setting the state. +--- If the specified state does not exist for the CObject instance, an error is stored. +--- +--- @param value string The state to set for the CObject instance. +--- @param ... any Additional arguments to pass to the SetState function. +--- +function CObject:SetStateText(value, ...) + if value:sub(-1, -1) == "*" then + value = value:sub(1, -3) + end + if not self:HasState(value) then + StoreErrorSource(self, "Missing object state " .. self:GetEntity() .. "." .. value) + else + self:SetState(value, ...) + end +end + +--- +--- Gets the current state text of the CObject instance. +--- +--- @return string The current state text of the CObject instance. +--- +function CObject:GetStateText() + return GetStateName(self) +end + +--- +--- Called when the property editor for this CObject instance is opened. +--- Sets the realtime animation flag for the CObject instance to true. +--- +function CObject:OnPropEditorOpen() + self:SetRealtimeAnim(true) +end + +-- Functions for manipulating text attaches + +-- Attaches a text at the given spot +-- @param text string Text to be attached +-- @param spot int Id of the spot +function CObject:AttachText( text, spot ) + local obj = PlaceObject ( "Text" ) + obj:SetText(text) + if spot == nil then + spot = self:GetSpotBeginIndex("Origin") + end + self:Attach(obj, spot) + return obj +end + +-- Attaches a text at the given spot, which is updated trough a function each 900ms + random ( 200ms ) +-- @param f function A function that returns the updated text +-- @param spot int Id of the spot +--- +--- Attaches a text object to the CObject instance that is updated through a function at a regular interval. +--- +--- @param f function A function that returns the updated text and the sleep duration in milliseconds. +--- @param spot integer (optional) The spot index to attach the text object to. If not provided, the text object will be attached to the "Origin" spot. +--- @return table The attached text object. +--- +function CObject:AttachUpdatingText(f, spot) + -- Implementation details +end +function CObject:AttachUpdatingText( f, spot ) + local obj = PlaceObject ( "Text" ) + CreateRealTimeThread( function () + while IsValid(obj) do + local text, sleep = f(obj) + obj:SetText(text or "") + Sleep((sleep or 900) + AsyncRand(200)) + end + end) + if spot == nil then + spot = self:GetSpotBeginIndex("Origin") + end + self:Attach(obj, spot) + return obj +end + +-- calls the func or the obj method when the current thread completes (but within the same millisecond); +-- multiple calls with the same arguments result in the function being called only once. +--- +--- Notifies the CObject instance of the specified method. +--- +--- @param method string The name of the method to notify. +--- +function CObject:Notify(method) + Notify(self, method) +end + +if Platform.editor then + --- + --- Checks if the specified class can be placed in the editor. + --- + --- @param class_name string The name of the class to check. + --- @return boolean True if the class can be placed in the editor, false otherwise. + --- + function EditorCanPlace(class_name) + local class = g_Classes[class_name] + return class and class:EditorCanPlace() + end + function CObject:EditorCanPlace() + return IsValidEntity(self:GetEntity()) + end +end + +CObject.GetObjectBySpot = empty_func + +--- Shows the spots of the object using code renderables. +--- +--- Shows the spots of the object using code renderables. +--- +--- @param spot_type string (optional) The type of spots to show. If not provided, all spots will be shown. +--- @param annotation string (optional) The annotation to filter the spots by. If not provided, all spots will be shown. +--- @param show_spot_idx boolean (optional) If true, the spot index will be shown in the spot name. +--- +function CObject:ShowSpots(spot_type, annotation, show_spot_idx) + if not self:HasEntity() then return end + local start_id, end_id = self:GetAllSpots(self:GetState()) + local scale = Max(1, DivRound(10000, self:GetScale())) + for i = start_id, end_id do + local spot_name = GetSpotNameByType(self:GetSpotsType(i)) + if not spot_type or string.find(spot_name, spot_type) then + local spot_annotation = self:GetSpotAnnotation(i) + if not annotation or string.find(spot_annotation, annotation) then + local text_obj = Text:new{ editor_ignore = true } + local text_str = self:GetSpotName(i) + if show_spot_idx then + text_str = i .. '.' .. text_str + end + if spot_annotation then + text_str = text_str .. ";" .. spot_annotation + end + text_obj:SetText(text_str) + self:Attach(text_obj, i) + + local orientation_obj = CreateOrientationMesh() + orientation_obj.editor_ignore = true + orientation_obj:SetScale(scale) + self:Attach(orientation_obj, i) + end + end + end +end + +--- Hides the spots of the objects. +--- Hides the spots of the object. +--- +--- This function destroys all the text and mesh attachments that were created by the `CObject:ShowSpots()` function. +--- +--- If the object does not have an entity, this function will return early without doing anything. +function CObject:HideSpots() + if not self:HasEntity() then return end + self:DestroyAttaches("Text") + self:DestroyAttaches("Mesh") +end + + +--- +--- A table of colors used to represent different types of object surfaces in the game. +--- +--- The keys in this table correspond to the different surface types, and the values are the colors to use for each type. +--- +--- @field ApplyToGrids red The color to use for grids that can be applied to. +--- @field Build purple The color to use for build surfaces. +--- @field ClearRoad white The color to use for clear road surfaces. +--- @field Collision green The color to use for collision surfaces. +--- @field Flat const.clrGray The color to use for flat surfaces. +--- @field Height cyan The color to use for height surfaces. +--- @field HexShape yellow The color to use for hex-shaped surfaces. +--- @field Road black The color to use for road surfaces. +--- @field Selection blue The color to use for selected surfaces. +--- @field Terrain RGBA(255, 0, 0, 128) The color to use for terrain surfaces. +--- @field TerrainHole magenta The color to use for terrain hole surfaces. +--- @field Walk const.clrPink The color to use for walkable surfaces. +--- +ObjectSurfaceColors = { + ApplyToGrids = red, + Build = purple, + ClearRoad = white, + Collision = green, + Flat = const.clrGray, + Height = cyan, + HexShape = yellow, + Road = black, + Selection = blue, + Terrain = RGBA(255, 0, 0, 128), + TerrainHole = magenta, + Walk = const.clrPink, +} + +--- A weak-keyed table that maps CObject instances to a set of meshes representing the surfaces of the object. +--- +--- This table is used by the `CObject:ShowSurfaces()` and `CObject:HideSurfaces()` functions to track the meshes that are created to visualize the surfaces of each object. +--- +--- The keys in this table are the CObject instances, and the values are tables that map surface types to the corresponding mesh objects. +--- +--- This table uses a weak-keys metatable, which means that the CObject instances will be automatically removed from the table when they are garbage collected. +MapVar("ObjToShownSurfaces", {}, weak_keys_meta) +--- +--- A table that stores the types of object surfaces that have been turned off and should not be displayed. +--- +--- This table is used by the `CObject:ShowSurfaces()` function to determine which surface types should be hidden from the visualization. +--- +--- The keys in this table are the surface type strings, and the values are boolean flags indicating whether that surface type has been turned off. +--- +--- This table is typically populated and modified by other parts of the codebase to control which object surfaces are shown or hidden. +--- +MapVar("TurnedOffObjSurfaces", {}) + +--- Shows the surfaces of the object using code renderables. +--- Shows the surfaces of the object using code renderables. +--- +--- This function is responsible for displaying the various surfaces of a CObject instance. It iterates through the different surface types defined in the `EntitySurfaces` table, and creates a mesh object for each surface type that is present on the object and has not been turned off. +--- +--- The created mesh objects are stored in the `ObjToShownSurfaces` table, which maps CObject instances to their corresponding surface meshes. This table is used by the `CObject:HideSurfaces()` function to clean up the surface meshes when they are no longer needed. +--- +--- If the `ObjToShownSurfaces` table is not empty after this function is called, it also opens the "ObjSurfacesLegend" dialog to display a legend for the surface colors. +--- +--- @param self CObject The CObject instance whose surfaces should be displayed. +function CObject:ShowSurfaces() + local entity = self:GetEntity() + if not IsValidEntity(entity) then return end + local entry = ObjToShownSurfaces[self] + for stype, flag in pairs(EntitySurfaces) do + if HasAnySurfaces(entity, EntitySurfaces[stype]) + and not (stype == "All" or stype == "AllPass" or stype == "AllPassAndWalk") + and not TurnedOffObjSurfaces[stype] + and (not entry or not entry[stype]) then + local color1 = ObjectSurfaceColors[stype] or RandColor(xxhash(stype)) + local color2 = InterpolateRGB(color1, black, 1, 2) + local mesh = CreateObjSurfaceMesh(self, flag, color1, color2) + mesh:SetOpacity(75) + entry = table.create_set(entry, stype, mesh) + end + end + ObjToShownSurfaces[self] = entry or {} + OpenDialog("ObjSurfacesLegend") +end + +--- Hides the surfaces of the object. +--- Hides the surfaces of the object. +--- +--- This function is responsible for cleaning up the surface meshes that were created by the `CObject:ShowSurfaces()` function. It iterates through the `ObjToShownSurfaces` table, which maps CObject instances to their corresponding surface meshes, and destroys each of the mesh objects. +--- +--- If the `ObjToShownSurfaces` table becomes empty after this function is called, it also closes the "ObjSurfacesLegend" dialog, as there are no longer any surface meshes to display. +--- +--- @param self CObject The CObject instance whose surfaces should be hidden. +function CObject:HideSurfaces() + for stype, mesh in pairs(ObjToShownSurfaces[self]) do + DoneObject(mesh) + end + ObjToShownSurfaces[self] = nil + if not next(ObjToShownSurfaces) then + CloseDialog("ObjSurfacesLegend") + end +end + +--- Handles the loading of the game state. +--- +--- When the game is loaded, this function checks if there are any object surfaces that were previously shown. If so, it opens the "ObjSurfacesLegend" dialog to display a legend for the surface colors. +function OnMsg.LoadGame() + if next(ObjToShownSurfaces) then + OpenDialog("ObjSurfacesLegend") + end +end + + +----- Ged + +--- Formats the label for the Ged tree view. +--- +--- This function is responsible for generating the label that will be displayed for the current `CObject` instance in the Ged tree view. It first retrieves the `EditorLabel` property of the object, which is used as the base label. If the object has a `Name` or `ParticlesName` property, this is appended to the label separated by a hyphen. +--- +--- @param self CObject The `CObject` instance for which the label should be formatted. +--- @return string The formatted label for the Ged tree view. +function CObject:GedTreeViewFormat() + if IsValid(self) then + local label = self:GetProperty("EditorLabel") or self.class + local value = self:GetProperty("Name") or self:GetProperty("ParticlesName") + local tname = value and (IsT(value) and _InternalTranslate(value) or type(value) == "string" and value) or "" + if #tname > 0 then + label = label .. " - " .. tname + end + return label + end +end + +--- Returns the list of attached objects for the current CObject instance. +--- +--- This function retrieves the list of objects attached to the current CObject instance, and filters out any attached objects that have the "editor_ignore" property set. The filtered list of attached objects is then returned. +--- +--- @return table The list of attached objects for the current CObject instance, excluding any objects with the "editor_ignore" property set. +function CObject:GedTreeChildren() + local ret = IsValid(self) and self:GetAttaches() or empty_table + return table.ifilter(ret, function(k, v) return not rawget(v, "editor_ignore") end) +end + + +------------------------------------------------------------ +----------------- Animation Moments ------------------------ +------------------------------------------------------------ + +--- Retrieves the animation moments for the specified entity and animation. +--- +--- This function retrieves the animation moments for the specified entity and animation. If a moment type is provided, the function will filter the moments to only include those of the specified type. +--- +--- @param entity table The entity for which to retrieve the animation moments. +--- @param anim string The animation for which to retrieve the moments. +--- @param moment_type string (optional) The type of moments to retrieve. +--- @return table The list of animation moments, or an empty table if none are found. +function GetEntityAnimMoments(entity, anim, moment_type) + local anim_entity = GetAnimEntity(entity, anim) + local preset_group = anim_entity and Presets.AnimMetadata[anim_entity] + local preset_anim = preset_group and preset_group[anim] + local moments = preset_anim and preset_anim.Moments + if moments and moment_type then + moments = table.ifilter(moments, function(_, m, moment_type) + return m.Type == moment_type + end, moment_type) + end + return moments or empty_table +end +local GetEntityAnimMoments = GetEntityAnimMoments + +--- Retrieves the animation moments for the specified animation of the CObject instance. +--- +--- This function retrieves the animation moments for the specified animation of the CObject instance. If a moment type is provided, the function will filter the moments to only include those of the specified type. +--- +--- @param anim string (optional) The animation for which to retrieve the moments. If not provided, the current state text of the CObject instance will be used. +--- @param moment_type string (optional) The type of moments to retrieve. +--- @return table The list of animation moments, or an empty table if none are found. +function CObject:GetAnimMoments(anim, moment_type) + return GetEntityAnimMoments(self:GetEntity(), anim or self:GetStateText(), moment_type) +end + +--- The `AnimSpeedScale` constant is used to scale the animation speed of an object. It is multiplied by itself to create the `AnimSpeedScale2` constant, which is likely used for further scaling or calculations related to animation speed. +local AnimSpeedScale = const.AnimSpeedScale +local AnimSpeedScale2 = AnimSpeedScale * AnimSpeedScale + +--- Iterates through the animation moments for the specified animation, phase, and moment index. +--- +--- This function iterates through the animation moments for the specified animation, phase, and moment index. It returns the type, time, and the moment object for the specified moment index. If the moment index is not found, it returns `false` and `-1`. +--- +--- @param anim string The name of the animation. +--- @param phase number The current phase of the animation. +--- @param moment_index number The index of the moment to retrieve. +--- @param moment_type string (optional) The type of moment to retrieve. +--- @param reversed boolean Whether the animation is playing in reverse. +--- @param looping boolean Whether the animation is looping. +--- @param moments table (optional) The list of animation moments to iterate through. +--- @param duration number (optional) The duration of the animation. +--- @return boolean, number, table The type of the moment, the time of the moment, and the moment object, or `false` and `-1` if the moment is not found. +function CObject:IterateMoments(anim, phase, moment_index, moment_type, reversed, looping, moments, duration) + moments = moments or self:GetAnimMoments(anim) + local count = #moments + if count == 0 or moment_index <= 0 then + return false, -1 + end + duration = duration or GetAnimDuration(self:GetEntity(), anim) + local count_down = moment_index + local next_loop + + if not reversed then + local time = -phase -- current looped beginning time of the animation + local idx = 1 + while true do + if idx > count then -- if we are out of moments for this loop - start over with increased time + if not looping then + return false, -1 + end + idx = 1 + time = time + duration + if count_down == moment_index and time > duration then + return false, -1 -- searching for non-existent moment + end + next_loop = true + end + local moment = moments[idx] + if (not moment_type or moment_type == moment.Type) and time + moment.Time >= 0 then + if count_down == 1 then + return moment.Type, time + Min(duration-1, moment.Time), moment, next_loop + end + count_down = count_down - 1 + end + idx = idx + 1 + end + else + local time = phase - duration + local idx = count + while true do + if idx == 0 then + if not looping then + return false, -1 + end + idx = count + time = time + duration + if count_down == moment_index and time > duration then + return false, -1 -- searching for non-existent moment + end + next_loop = true + end + local moment = moments[idx] + if (not moment_type or moment_type == moment.Type) and time + duration - moment.Time >= 0 then + if count_down == 1 then + return moment.Type, time + duration - moment.Time, moment, next_loop + end + count_down = count_down - 1 + end + idx = idx - 1 + end + end +end + +--- +--- Gets the channel data for the specified animation channel. +--- +--- @param channel number The animation channel to get data for. +--- @param moment_index number The index of the animation moment to get data for. +--- @return string anim The name of the animation. +--- @return number phase The current phase of the animation. +--- @return number moment_index The index of the animation moment. +--- @return boolean reversed Whether the animation is playing in reverse. +--- @return boolean looping Whether the animation is looping. +--- +function CObject:GetChannelData(channel, moment_index) + local reversed = self:IsAnimReversed(channel) + if moment_index < 1 then + reversed = not reversed + moment_index = -moment_index + end + local looping = self:IsAnimLooping(channel) + local anim = GetStateName(self:GetAnim(channel)) + local phase = self:GetAnimPhase(channel) + + return anim, phase, moment_index, reversed, looping +end + +--- +--- Computes the time required to reach a specific animation time based on the current animation speed. +--- +--- @param anim_time number The target animation time. +--- @param combined_speed number The combined animation speed. +--- @param looping boolean Whether the animation is looping. +--- @return number The time required to reach the target animation time. +--- +function ComputeTimeTo(anim_time, combined_speed, looping) + -- Implementation details +end +local function ComputeTimeTo(anim_time, combined_speed, looping) + if combined_speed == AnimSpeedScale2 then + return anim_time + end + if combined_speed == 0 then + return max_int + end + local time = anim_time * AnimSpeedScale2 / combined_speed + if time == 0 and anim_time ~= 0 and looping then + return 1 + end + return time +end + +--- +--- Computes the time required to reach a specific animation moment based on the current animation speed. +--- +--- @param channel number The animation channel to get data for. +--- @param moment_type string The type of animation moment to find. +--- @param moment_index number The index of the animation moment to get data for. +--- @return number The time required to reach the target animation moment. +--- +function CObject:TimeToMoment(channel, moment_type, moment_index) + if moment_index == nil and type(channel) == "string" then + channel, moment_type, moment_index = 1, channel, moment_type + end + local anim, phase, index, reversed, looping = self:GetChannelData(channel, moment_index or 1) + local _, anim_time = self:IterateMoments(anim, phase, index, moment_type, reversed, looping) + if anim_time == -1 then + return + end + local combined_speed = self:GetAnimSpeed(channel) * self:GetAnimSpeedModifier() + return ComputeTimeTo(anim_time, combined_speed, looping) +end + +--- +--- Callback function that is called when an animation moment is reached. +--- +--- @param moment string The name of the animation moment that was reached. +--- @param anim string The name of the animation that the moment belongs to. +--- @param remaining_duration number The remaining duration of the animation in milliseconds. +--- @param moment_counter number The number of moments that have been reached so far in the animation. +--- @param loop_counter number The number of times the animation has looped. +--- +function CObject:OnAnimMoment(moment, anim, remaining_duration, moment_counter, loop_counter) + PlayFX(FXAnimToAction(anim), moment, self) +end + +--- +--- Plays an animation with a specified duration and calls a callback function when a specific animation moment is reached. +--- +--- @param state string The name of the animation state to play. +--- @param duration number The duration of the animation in milliseconds. +--- @return string The result of the animation playback, either "invalid" if the object is no longer valid, or "msg" if the wait function returned true. +--- +function CObject:PlayTimedMomentTrackedAnim(state, duration) + return self:WaitMomentTrackedAnim(state, nil, nil, nil, nil, nil, duration) +end + +--- +--- Plays an animation with a specified state and calls a callback function when a specific animation moment is reached. +--- +--- @param state string The name of the animation state to play. +--- @param moment string The name of the animation moment to call the callback for. +--- @param callback function The callback function to call when the animation moment is reached. +--- @param ... any Additional arguments to pass to the callback function. +--- @return string The result of the animation playback, either "invalid" if the object is no longer valid, or "msg" if the wait function returned true. +--- +function CObject:PlayAnimWithCallback(state, moment, callback, ...) + return self:WaitMomentTrackedAnim(state, nil, nil, nil, nil, nil, nil, moment, callback, ...) +end + +--- +--- Plays an animation with a specified state and calls a callback function when a specific animation moment is reached. +--- +--- @param state string The name of the animation state to play. +--- @param count number The number of times to play the animation. +--- @param flags number The animation flags to use. +--- @param crossfade number The crossfade duration in milliseconds. +--- @param duration number The duration of the animation in milliseconds. +--- @param moment string The name of the animation moment to call the callback for. +--- @param callback function The callback function to call when the animation moment is reached. +--- @param ... any Additional arguments to pass to the callback function. +--- @return string The result of the animation playback, either "invalid" if the object is no longer valid, or "msg" if the wait function returned true. +--- +function CObject:PlayMomentTrackedAnim(state, count, flags, crossfade, duration, moment, callback, ...) + return self:WaitMomentTrackedAnim(state, nil, nil, count, flags, crossfade, duration, moment, callback, ...) +end + +--- +--- Plays an animation with a specified state and calls a callback function when a specific animation moment is reached. +--- +--- @param state string The name of the animation state to play. +--- @param wait_func function An optional function that is called during the animation to check if the animation should be interrupted. +--- @param wait_param any An optional parameter to pass to the `wait_func` function. +--- @param count number The number of times to play the animation. +--- @param flags number The animation flags to use. +--- @param crossfade number The crossfade duration in milliseconds. +--- @param duration number The duration of the animation in milliseconds. +--- @param moment string The name of the animation moment to call the callback for. +--- @param callback function The callback function to call when the animation moment is reached. +--- @param ... any Additional arguments to pass to the callback function. +--- @return string The result of the animation playback, either "invalid" if the object is no longer valid, or "msg" if the wait function returned true. +--- +function CObject:WaitMomentTrackedAnim(state, wait_func, wait_param, count, flags, crossfade, duration, moment, callback, ...) + if not IsValid(self) then return "invalid" end + if (state or "") ~= "" then + if not self:HasState(state) then + GameTestsError("once", "Missing animation:", self:GetEntity() .. '.' .. state) + duration = duration or 1000 + else + self:SetState(state, flags or 0, crossfade or -1) + assert(self:GetAnimPhase() == 0) + local anim_duration = self:GetAnimDuration() + if anim_duration == 0 then + GameTestsError("once", "Zero length animation:", self:GetEntity() .. '.' .. state) + duration = duration or 1000 + else + local channel = 1 + duration = duration or (count or 1) * anim_duration + local moments = self:GetAnimMoments(state) + local moment_count = table.count(moments, "Type", moment) + if moment and callback and moment_count ~= 1 then + StoreErrorSource(self, "The callback is supposed to be called once for animation", state, "but there are", moment_count, "moments with the name", moment) + end + local anim, phase, count_down, reversed, looping = self:GetChannelData(channel, 1) + local moment_counter, loop_counter = 0, 0 + while duration > 0 do + if not IsValid(self) then return "invalid" end + local moment_type, time, moment_descr, next_loop = self:TimeToNextMoment(channel, count_down, anim, phase, reversed, looping, moments, anim_duration) + local sleep_time + if not time or time == -1 then + sleep_time = duration + else + sleep_time = Min(duration, time) + end + if not wait_func then + Sleep(sleep_time) + elseif wait_func(wait_param, sleep_time) then + return "msg" + end + + if not IsValid(self) then return "invalid" end + duration = duration - sleep_time + if sleep_time == time and (duration ~= 0 or not next_loop) then + moment_counter = moment_counter + 1 + -- moment reached + if next_loop then + loop_counter = loop_counter + 1 + end + if self:OnAnimMoment(moment_type, anim, duration, moment_counter, loop_counter) == "break" then + assert(not callback) + return "break" + end + if callback then + if not moment then + if callback(moment_type, ...) == "break" then + return "break" + end + elseif moment == moment_type then + if callback(...) == "break" then + return "break" + end + callback = nil + end + end + end + + phase = nil + count_down = 2 + end + end + end + end + if duration and duration > 0 then + if not wait_func then + Sleep(duration) + elseif wait_func(wait_param, duration) then + return "msg" + end + end + if callback and moment then + callback(...) + end +end + +--- +--- Plays a transition animation with a callback. +--- +--- @param anim string The animation to play. +--- @param moment string The animation moment to trigger the callback at. +--- @param callback function The callback function to call when the specified moment is reached. +--- @param ... any Additional arguments to pass to the callback function. +--- @return string The result of the animation execution. +--- +function CObject:PlayTransitionAnim(anim, moment, callback, ...) + return self:ExecuteWeakUninterruptable(self.PlayAnimWithCallback, anim, moment, callback, ...) +end + +--- +--- Calculates the time until the next animation moment is reached. +--- +--- @param channel number The animation channel to check. +--- @param index number The index of the animation moment to check. +--- @param anim string The name of the animation. +--- @param phase number The current animation phase. +--- @param reversed boolean Whether the animation is playing in reverse. +--- @param looping boolean Whether the animation is looping. +--- @param moments table The table of animation moments. +--- @param duration number The duration of the animation. +--- @return string|nil The type of the next animation moment. +--- @return number|nil The time until the next animation moment is reached. +--- @return table|nil The description of the next animation moment. +--- @return boolean|nil Whether the next animation moment is the start of a new loop. +--- +function CObject:TimeToNextMoment(channel, index, anim, phase, reversed, looping, moments, duration) + anim = anim or GetStateName(self:GetAnim(channel)) + phase = phase or self:GetAnimPhase(channel) + if reversed == nil then + reversed = self:IsAnimReversed(channel) + end + if looping == nil then + looping = self:IsAnimLooping(channel) + end + if index < 1 then + reversed = not reversed + index = -index + end + local moment_type, anim_time, moment_descr, next_loop = self:IterateMoments(anim, phase, index, nil, + reversed, looping, moments, duration) + if anim_time == -1 then + return + end + local combined_speed = self:GetAnimSpeed(channel) * self:GetAnimSpeedModifier() + local time = ComputeTimeTo(anim_time, combined_speed, looping) + + return moment_type, time, moment_descr, next_loop +end + +--- Returns the type of the specified animation moment. +--- +--- @param channel number The animation channel to check. +--- @param moment_index number The index of the animation moment to check. +--- @return string|nil The type of the animation moment. +function CObject:TypeOfMoment(channel, moment_index) + local anim, phase, index, reversed, looping = self:GetChannelData(channel, moment_index or 1) + return self:IterateMoments(anim, phase, index, false, reversed, looping) +end + +--- +--- Returns the time of the specified animation moment. +--- +--- @param anim string The name of the animation. +--- @param moment_type string The type of the animation moment to get. +--- @param moment_index number The index of the animation moment to get. +--- @param raise_error boolean Whether to raise an error if the moment is not found. +--- @return number|nil The time of the animation moment, or nil if not found. +--- +function CObject:GetAnimMoment(anim, moment_type, moment_index, raise_error) + local _, anim_time = self:IterateMoments(anim, 0, moment_index or 1, moment_type, false, self:IsAnimLooping()) + if anim_time ~= -1 then + return anim_time + end + if not raise_error then + return + end + assert(false, string.format("No such anim moment: %s.%s.%s", self:GetEntity(), anim, moment_type), 1) + return self:GetAnimDuration(anim) +end + +--- +--- Returns the type of the specified animation moment. +--- +--- @param anim string The name of the animation. +--- @param moment_index number The index of the animation moment to check. +--- @return string|nil The type of the animation moment, or nil if not found. +--- +function CObject:GetAnimMomentType(anim, moment_index) + local moment_type = self:IterateMoments(anim, 0, moment_index or 1, false, false, self:IsAnimLooping()) + if not moment_type or moment_type == "" then + return + end + return moment_type +end + +--- +--- Returns the count of the specified animation moments. +--- +--- @param anim string The name of the animation. +--- @param moment_type string The type of the animation moment to count. +--- @return number The count of the animation moments. +function CObject:GetAnimMomentsCount(anim, moment_type) + return #self:GetAnimMoments(anim, moment_type) +end + +-- TODO: maybe return directly the (filtered) table from the Presets.AnimMetadata +--- +--- Returns a table of animation moments for the specified entity and animation. +--- +--- @param entity table The entity to get the animation moments for. +--- @param anim string The name of the animation to get the moments for. +--- @return table A table of animation moments, where each moment is a table with `type` and `time` fields. +--- +function GetStateMoments(entity, anim) + local moments = {} + for idx, moment in ipairs(GetEntityAnimMoments(entity, anim)) do + moments[idx] = {type = moment.Type, time = moment.Time} + end + return moments +end + +--- +--- Returns a table of the names of all animation moments for the specified entity and animation. +--- +--- @param entity table The entity to get the animation moment names for. +--- @param anim string The name of the animation to get the moment names for. +--- @return table A table of the names of all animation moments. +--- +function GetStateMomentsNames(entity, anim) + if not IsValidEntity(entity) or GetStateIdx(anim) == -1 then return empty_table end + local moments = {} + for idx, moment in ipairs(GetEntityAnimMoments(entity, anim)) do + moments[moment.Type] = true + end + return table.keys(moments, true) +end + +--- +--- Returns a table of the default animation metadata for all entities. +--- +--- The returned table is keyed by entity name, and each value is a table containing the default animation metadata for that entity. +--- The default animation metadata is an `AnimMetadata` object with the ID `"__default__"` and the entity name as the group. +--- The `AnimComponents` field of the `AnimMetadata` object is a table of `AnimComponentWeight` objects, one for each animation component defined for the entity. +--- +--- @return table The default animation metadata for all entities. +--- +function GetEntityDefaultAnimMetadata() + local entityDefaultAnimMetadata = {} + for name, entity_data in pairs(EntityData) do + if entity_data.anim_components then + local anim_components = table.map( entity_data.anim_components, function(t) return AnimComponentWeight:new(t) end ) + local animMetadata = AnimMetadata:new({id = "__default__", group = name, AnimComponents = anim_components}) + entityDefaultAnimMetadata[name] = { __default__ = animMetadata } + end + end + return entityDefaultAnimMetadata +end + +--- +--- Reloads the animation data for the game. +--- +--- This function performs the following steps: +--- 1. Reloads the animation component definitions from the `AnimComponents` table. +--- 2. Clears the existing animation metadata. +--- 3. Loads the animation metadata from the `Presets.AnimMetadata` table and the `GetEntityDefaultAnimMetadata()` function. +--- 4. Sets the speed modifier for each animation metadata entry based on the `const.AnimSpeedScale` value. +--- +--- This function is called when the game data is loaded or reloaded, and when an animation preset is saved. +--- +function ReloadAnimData() +end +local function ReloadAnimData() + ReloadAnimComponentDefs(AnimComponents) + + ClearAnimMetaData() + LoadAnimMetaData(Presets.AnimMetadata) + LoadAnimMetaData(GetEntityDefaultAnimMetadata()) + + local speed_scale = const.AnimSpeedScale + for _, entity_meta in ipairs(Presets.AnimMetadata) do + for _, anim_meta in ipairs(entity_meta) do + local speed_modifier = anim_meta.SpeedModifier * speed_scale / 100 + SetStateSpeedModifier(anim_meta.group, GetStateIdx(anim_meta.id), speed_modifier) + end + end +end + +--- +--- Reloads the animation data for the game when the game data is loaded or reloaded, or when an animation preset is saved. +--- +--- This function performs the following steps: +--- 1. Reloads the animation component definitions from the `AnimComponents` table. +--- 2. Clears the existing animation metadata. +--- 3. Loads the animation metadata from the `Presets.AnimMetadata` table and the `GetEntityDefaultAnimMetadata()` function. +--- 4. Sets the speed modifier for each animation metadata entry based on the `const.AnimSpeedScale` value. +--- +--- @see GetEntityDefaultAnimMetadata +--- @see ReloadAnimComponentDefs +--- @see ClearAnimMetaData +--- @see LoadAnimMetaData +--- @see SetStateSpeedModifier +--- @see GetStateIdx +OnMsg.DataLoaded = ReloadAnimData +OnMsg.DataReloadDone = ReloadAnimData + +--- +--- Reloads the animation data for the game when an animation preset is saved. +--- +--- This function is called when an animation preset is saved, and performs the following steps: +--- 1. Checks if the saved preset is for an `AnimComponent` or `AnimMetadata` class. +--- 2. If so, calls the `ReloadAnimData()` function to reload the animation data. +--- +--- @param className string The name of the class for which the preset was saved. +--- +function OnMsg.PresetSave(className) + local class = g_Classes[className] + if IsKindOf(class, "AnimComponent") or IsKindOf(class, "AnimMetadata") then + ReloadAnimData() + end +end + +------------------------------------------------------- +---------------------- Testing ------------------------ +------------------------------------------------------- + +--- +--- Initializes the global `g_DevTestState` table with default values when the file is first loaded. +--- +--- The `g_DevTestState` table is used to store information related to the development test state of a `CObject` instance. This includes the current test thread, the object being tested, and the starting position, angle, and axis of the object. +--- +--- This code is executed only once, when the file is first loaded. +--- +if FirstLoad then + g_DevTestState = { + thread = false, + obj = false, + start_pos = false, + start_axis = false, + start_angle = false, + } +end + +--- +--- Executes a test state for the CObject instance. +--- +--- This function performs the following steps: +--- 1. Checks if the editor is active. If not, prints a message indicating that the test is only available in the editor. +--- 2. If a previous test thread exists, deletes it. +--- 3. If the current CObject instance is different from the previous one, stores the starting position, angle, and axis of the object. +--- 4. Creates a new real-time thread that performs the test state: +--- - Sets the animation to the current state of the object. +--- - Retrieves the duration of the animation. +--- - If the duration is not zero, sets the position and axis-angle of the object to the starting values. +--- - Optionally, applies compensation for the object's step axis and angle. +--- - Loops the animation the specified number of times (or 5 times if no value is provided). +--- - During each loop, checks if the object is still valid, if the editor is still active, and if the object's state has not changed. If any of these conditions are not met, the loop is broken. +--- - Plays the animation for the current state, setting the position and axis-angle back to the starting values. +--- - If compensation is not required, sleeps for the duration of the animation. +--- +--- @param main table The main table (not used) +--- @param prop_id number The property ID (not used) +--- @param ged table The GED table (not used) +--- @param no_compensate boolean If true, disables compensation for the object's step axis and angle +--- +function CObject:BtnTestState(main, prop_id, ged, no_compensate) + self:TestState(nil, no_compensate) +end + +--- +--- Executes a single test state for the CObject instance. +--- +--- This function performs the following steps: +--- 1. Checks if the editor is active. If not, prints a message indicating that the test is only available in the editor. +--- 2. If a previous test thread exists, deletes it. +--- 3. If the current CObject instance is different from the previous one, stores the starting position, angle, and axis of the object. +--- 4. Creates a new real-time thread that performs the test state: +--- - Sets the animation to the current state of the object. +--- - Retrieves the duration of the animation. +--- - If the duration is not zero, sets the position and axis-angle of the object to the starting values. +--- - Optionally, applies compensation for the object's step axis and angle. +--- - Plays the animation for the current state, setting the position and axis-angle back to the starting values. +--- - If compensation is not required, sleeps for the duration of the animation. +--- +--- @param main table The main table (not used) +--- @param prop_id number The property ID (not used) +--- @param ged table The GED table (not used) +--- @param no_compensate boolean If true, disables compensation for the object's step axis and angle +--- +function CObject:BtnTestOnce(main, prop_id, ged, no_compensate) + self:TestState(1, no_compensate) +end + +--- +--- Executes a test state for the CObject instance, repeating the test a specified number of times. +--- +--- This function calls the `CObject:TestState()` function with the specified number of repetitions, and optionally disables compensation for the object's step axis and angle. +--- +--- @param main table The main table (not used) +--- @param prop_id number The property ID (not used) +--- @param ged table The GED table (not used) +--- @param no_compensate boolean If true, disables compensation for the object's step axis and angle +--- +function CObject:BtnTestLoop(main, prop_id, ged, no_compensate) + self:TestState(10000000000, no_compensate) +end + +--- +--- Executes a test state for the CObject instance, optionally repeating the test a specified number of times. +--- +--- This function performs the following steps: +--- 1. Checks if the editor is active. If not, prints a message indicating that the test is only available in the editor. +--- 2. If a previous test thread exists, deletes it. +--- 3. If the current CObject instance is different from the previous one, stores the starting position, angle, and axis of the object. +--- 4. Creates a new real-time thread that performs the test state: +--- - Sets the animation to the current state of the object. +--- - Retrieves the duration of the animation. +--- - If the duration is not zero, sets the position and axis-angle of the object to the starting values. +--- - Optionally, applies compensation for the object's step axis and angle. +--- - Plays the animation for the current state, setting the position and axis-angle back to the starting values. +--- - If compensation is not required, sleeps for the duration of the animation. +--- +--- @param self CObject The CObject instance +--- @param rep number The number of times to repeat the test (default is 5) +--- @param ignore_compensation boolean If true, disables compensation for the object's step axis and angle +--- +function CObject.TestState(self, rep, ignore_compensation) + if not IsEditorActive() then + print("Available in editor only") + end + + + if g_DevTestState.thread then + DeleteThread(g_DevTestState.thread) + end + if g_DevTestState.obj ~= self then + g_DevTestState.start_pos = self:GetVisualPos() + g_DevTestState.start_angle = self:GetVisualAngle() + g_DevTestState.start_axis = self:GetVisualAxis() + g_DevTestState.obj = self + end + g_DevTestState.thread = CreateRealTimeThread(function(self, rep, ignore_compensation) + local start_pos = g_DevTestState.start_pos + local start_angle = g_DevTestState.start_angle + local start_axis = g_DevTestState.start_axis + self:SetAnim(1, self:GetState(), 0, 0) + local duration = self:GetAnimDuration() + if duration == 0 then return end + local state = self:GetState() + local step_axis, step_angle + if not ignore_compensation then + step_axis, step_angle = self:GetStepAxisAngle() + end + + local rep = rep or 5 + for i = 1, rep do + if not IsValid(self) or not IsEditorActive() or self:GetState() ~= state then + break + end + self:SetAnim(1, state, const.eDontLoop, 0) + + self:SetPos(start_pos) + self:SetAxisAngle(start_axis, start_angle) + + if ignore_compensation then + Sleep(duration) + else + local parts = 2 + for i = 1, parts do + local start_time = MulDivRound(i - 1, duration, parts) + local end_time = MulDivRound(i, duration, parts) + local part_duration = end_time - start_time + + local part_step_vector = self:GetStepVector(state, start_angle, start_time, part_duration) + self:SetPos(self:GetPos() + part_step_vector, part_duration) + + local part_rot_angle = MulDivRound(i, step_angle, parts) - MulDivRound(i - 1, step_angle, parts) + self:Rotate(step_axis, part_rot_angle, part_duration) + Sleep(part_duration) + if not IsValid(self) or not IsEditorActive() or self:GetState() ~= state then + break + end + end + end + + Sleep(400) + if not IsValid(self) or not IsEditorActive() or self:GetState() ~= state then + break + end + self:SetPos(start_pos) + self:SetAxisAngle(start_axis, start_angle) + Sleep(400) + end + + g_DevTestState.obj = false + end, self, rep, ignore_compensation) +end + +--- +--- Sets the color of the object based on the specified text style. +--- +--- @param id string The ID of the text style to use. +function CObject:SetColorFromTextStyle(id) + assert(TextStyles[id]) + self.textstyle_id = id + local color = TextStyles[id].TextColor + local _, _, _, opacity = GetRGBA(color) + self:SetColorModifier(color) + self:SetOpacity(opacity) +end + +--- +--- Recursively sets the contour visibility for the object and all its attached objects. +--- +--- @param visible boolean Whether the contour should be visible or not. +--- @param id string The ID of the contour to set. +function CObject:SetContourRecursive(visible, id) + if not IsValid(self) or IsBeingDestructed(self) then + return + end + if visible then + self:SetContourOuterID(true, id) + self:ForEachAttach(function(attach) + attach:SetContourRecursive(true, id) + end) + else + self:SetContourOuterID(false, id) + self:ForEachAttach(function(attach) + attach:SetContourRecursive(false, id) + end) + end +end + +--- +--- Recursively calls a function or method on the current object and all its attached objects. +--- +--- @param self CObject The object to call the function or method on. +--- @param func function|string The function or method name to call. +--- @param ... any Additional arguments to pass to the function or method. +--- @return string|nil If the `func` parameter is not a function or method name, returns an error message. Otherwise, returns nothing. +function CallRecursive(self, func, ...) + if not IsValid(self) or IsBeingDestructed(self) then + return + end + + if type(func) == "function" then + func(self, ...) + elseif type(func) == "string" then + table.fget(self, func, "(", ...) + else + return "Invalid parameter. Expected function or method name" + end + + self:ForEachAttach(CallRecursive, func, ...) +end + +--- +--- Recursively sets the 'under construction' flag for the object and all its attached objects. +--- +--- @param data boolean Whether the object is under construction or not. +function CObject:SetUnderConstructionRecursive(data) + if not IsValid(self) or IsBeingDestructed(self) then + return + end + self:SetUnderConstruction(data) + self:ForEachAttach(function(attach, data) + attach:SetUnderConstructionRecursive(data) + end, data) +end + +--- +--- Recursively sets the 'contour outer occlude' flag for the object and all its attached objects. +--- +--- @param self CObject The object to set the 'contour outer occlude' flag on. +--- @param set boolean Whether to set the 'contour outer occlude' flag or not. +function CObject:SetContourOuterOccludeRecursive(set) + if not IsValid(self) or IsBeingDestructed(self) then + return + end + self:SetContourOuterOcclude(set) + self:ForEachAttach(function(attach, set) + attach:SetContourOuterOccludeRecursive(set) + end, set) +end + +--- +--- Gets the bounding box of the object and all its attached objects, excluding objects of the specified classes. +--- +--- @param self CObject The object to get the bounding box for. +--- @param ignore_classes table|nil A table of class names to ignore when calculating the bounding box. +--- @return table The bounding box of the object and all its attached objects, excluding the specified classes. +function CObject:GetObjectAttachesBBox(ignore_classes) + local bbox = self:GetObjectBBox() + self:ForEachAttach(function(attach) + if not ignore_classes or not IsKindOfClasses(attach, ignore_classes) then + bbox = AddRects(bbox, attach:GetObjectBBox()) + end + end) + + return bbox +end + +--- +--- Gets the error message for the current object, if any. +--- +--- This function checks for several potential errors related to the object's colliders and collection index. If the object has colliders but is not marked as "Essential", it will return an error message. If the object's collection index is invalid, it will also return an error message. +--- +--- @return string|nil The error message, or nil if there are no errors. +function CObject:GetError() + if not IsValid(self) then return end + + local parent = self:GetParent() + -- CheckCollisionObjectsAreEssentials + if const.maxCollidersPerObject > 0 then + if not parent and self:GetEnumFlags(const.efCollision) ~= 0 then + if collision.GetFirstCollisionMask(self) then + local detail_class = self:GetDetailClass() + if detail_class == "Default" then + local entity = self:GetEntity() + local entity_data = EntityData[entity] + detail_class = entity and entity_data and entity_data.entity.DetailClass or "Essential" + end + if detail_class ~= "Essential" then + return "Object with colliders is not declared 'Essential'" + end + end + end + end + + -- Validate collection index + if not parent then -- obj is not attached + local col = self:GetCollectionIndex() + if col > 0 and not Collections[col] then + self:SetCollectionIndex(0) + return string.format("Missing collection object for index %s", col) + end + end +end +--- Enables or disables recursive calls to the `OnHoverStart`, `OnHoverUpdate`, and `OnHoverEnd` methods for `CObject` instances. +--- +--- When `RecursiveCallMethods.OnHoverStart` is `true`, calling `CObject:OnHoverStart()` will recursively call the `OnHoverStart` method on all attached objects. +--- When `RecursiveCallMethods.OnHoverUpdate` is `true`, calling `CObject:OnHoverUpdate()` will recursively call the `OnHoverUpdate` method on all attached objects. +--- When `RecursiveCallMethods.OnHoverEnd` is `true`, calling `CObject:OnHoverEnd()` will recursively call the `OnHoverEnd` method on all attached objects. +--- +--- The `CObject.OnHoverStart`, `CObject.OnHoverUpdate`, and `CObject.OnHoverEnd` properties are set to `empty_func` to provide a default implementation for these methods. + +RecursiveCallMethods.OnHoverStart = true +CObject.OnHoverStart = empty_func +RecursiveCallMethods.OnHoverUpdate = true +CObject.OnHoverUpdate = empty_func +RecursiveCallMethods.OnHoverEnd = true +CObject.OnHoverEnd = empty_func + +--- Registers a new global variable named "ContourReasons" and sets its initial value to `false`. +--- +--- The "ContourReasons" variable is used to store information about contour reasons for objects. It is initialized as a new table with weak keys, allowing the garbage collector to remove entries when the associated objects are no longer referenced. +MapVar("ContourReasons", false) +--- Sets a contour reason for the specified object. +--- +--- If the `ContourReasons` table does not exist, it is created with a weak key metatable. +--- The `ContourReasons` table stores contour reasons for each object. For each object, a table of contours is stored, and for each contour, a table of reasons is stored. +--- +--- If the contour reasons table for the object does not exist, it is created. If the reasons table for the specified contour does not exist, it is created and the object's contour is set to true recursively. +--- +--- If the reason already exists in the reasons table, the function simply returns. Otherwise, the reason is added to the reasons table. +--- +--- @param obj CObject The object to set the contour reason for. +--- @param contour string The contour to set the reason for. +--- @param reason string The reason to set. +function SetContourReason(obj, contour, reason) + if not ContourReasons then + ContourReasons = setmetatable({}, weak_keys_meta) + end + local countours = ContourReasons[obj] + if not countours then + countours = {} + ContourReasons[obj] = countours + end + local reasons = countours[contour] + if reasons then + reasons[reason] = true + return + end + obj:SetContourRecursive(true, contour) + countours[contour] = {[reason] = true} +end +--- Removes a contour reason for the specified object. +--- +--- If the `ContourReasons` table does not exist or does not have an entry for the specified object, the function simply returns. +--- +--- If the reasons table for the specified contour does not exist or does not have the specified reason, the function simply returns. +--- +--- If the reasons table for the specified contour becomes empty after removing the reason, the contour is set to false recursively for the object, and the contours table and the `ContourReasons` table are cleaned up as necessary. +--- +--- @param obj CObject The object to clear the contour reason for. +--- @param contour string The contour to clear the reason for. +--- @param reason string The reason to clear. +function ClearContourReason(obj, contour, reason) + local countours = (ContourReasons or empty_table)[obj] + local reasons = countours and countours[contour] + if not reasons or not reasons[reason] then + return + end + reasons[reason] = nil + if not next(reasons) then + obj:SetContourRecursive(false, contour) + countours[contour] = nil + if not next(countours) then + ContourReasons[obj] = nil + end + end +end + +-- Additional functions for working with groups + +--- Returns a table, containing all objects from the specified group. +-- @param name string - The name of the group to get all objects from. +--- Returns a table containing all objects from the specified group. +--- +--- If the specified group does not exist, an empty table is returned. +--- +--- @param name string The name of the group to get all objects from. +--- @return table A table containing all objects in the specified group. +function GetGroup(name) + local list = {} + local group = Groups[name] + if not group then + return list + end + + for i = 1,#group do + local obj = group[i] + if IsValid(obj) then list[#list + 1] = obj end + end + return list +end + +--- Returns a reference to the specified group. +--- +--- If the specified group does not exist, this function will return `nil`. +--- +--- @param name string The name of the group to get a reference to. +--- @return table|nil A reference to the specified group, or `nil` if the group does not exist. +function GetGroupRef(name) + return Groups[name] +end + +--- Checks if a group with the given name exists. +--- +--- @param name string The name of the group to check. +--- @return boolean true if the group exists, false otherwise. +function GroupExists(name) + return not not Groups[name] +end + +--- Returns a table containing the names of all groups. +--- +--- The group names are sorted alphabetically. +--- +--- @return table A table containing the names of all groups. +function GetGroupNames() + local group_names = {} + for group, _ in pairs(Groups) do + table.insert(group_names, group) + end + table.sort(group_names) + return group_names +end + +--- Returns a table containing the names of all groups, with a leading space added to each name. +--- +--- The group names are sorted alphabetically. +--- +--- @return table A table containing the names of all groups, with a leading space added to each name. +function GroupNamesWithSpace() + local group_names = {} + for group, _ in pairs(Groups) do + group_names[#group_names + 1] = " " .. group + end + table.sort(group_names) + return group_names +end + +--- Spawns the template objects from the specified group, adding the spawned ones to all groups the templates were in. +-- @param name string The name of the group to be spawned. +-- @param pos point Position to center the group on while spawning +-- @param filter is the same function that is passed to MapGet/MapCount queries +-- @return table An object list, containing the spawned units. +function SpawnGroup(name, pos, filter_func) + local list = {} + local templates = MapFilter(GetGroup(name, true), "map", "Template", filter_func) + if #templates > 0 then + -- Calculate offset to move group (if any) + local center = AveragePoint(templates) + if pos then + center, pos = pos, (pos - center):SetInvalidZ() + end + for _, obj in ipairs(templates) do + local spawned = obj:Spawn() + if spawned then + if pos then + spawned:SetPos(obj:GetPos() + pos) + end + list[#list + 1] = spawned + end + end + end + return list +end + + +--- Spawns the template objects from the specified group, adding the spawned ones to all groups the templates were in; disperses the times of spawning in the given time interval. +-- @param name string The name of the group to be spawned. +-- @param pos point Position to center the group on while spawning +-- @param filter is the same structure that is passed to MapGet/MapCount queries +-- @param time number The length of the interval in which all units are randomly spawned. +-- @return table An object list, containing the spawned units. +function SpawnGroupOverTime(name, pos, filter, time) + local list = {} + local templates = MapFilter(GetGroup(name, true), "map", "Template", filter_func) + -- Find appropriate times for spawning + local times, sum = {}, 0 + for i = 1, #templates do + if templates[i]:ShouldSpawn() then + local rand = AsyncRand(1000) + times[i] = rand + sum = sum + rand + else + times[i] = false + end + end + + -- Spawn the units using the already known time intervals + for i,obj in ipairs(templates) do + if times[i] then + local spawned_obj = obj:Spawn() + if spawned_obj then + list[#list + 1] = spawned_obj:SetPos(pos) + Sleep(times[i]*time/sum) + end + end + end + return list +end + +--- Clears the global flag tables used for tracking MapObject class information. +-- These tables are used to cache and optimize access to MapObject class flags. +-- They are cleared after the map is loaded, to ensure they are properly reinitialized. +__enumflags = false +__classflags = false +__componentflags = false +__gameflags = false + +--- Clears the global flag tables used for tracking MapObject class information. +-- These tables are used to cache and optimize access to MapObject class flags. +-- They are cleared after the map is loaded, to ensure they are properly reinitialized. +function OnMsg.ClassesPostprocess() + -- Clear surfaces flags for objects without surfaces or valid entities + local asWalk = EntitySurfaces.Walk + local efWalkable = const.efWalkable + -- Collision flag is also used to enable/disable terrain surface application + local asCollision = EntitySurfaces.Collision + local efCollision = const.efCollision + local asApplyToGrids = EntitySurfaces.ApplyToGrids + local efApplyToGrids = const.efApplyToGrids + local cmPassability = const.cmPassability + local cmDefaultObject = const.cmDefaultObject + + __enumflags = FlagValuesTable("MapObject", "ef", function(name, flags) + local class = g_Classes[name] + local entity = class:GetEntity() + if not class.variable_entity and IsValidEntity(entity) then + if not HasAnySurfaces(entity, asWalk) then + flags = FlagClear(flags, efWalkable) + end + if not HasAnySurfaces(entity, asCollision) and not HasMeshWithCollisionMask(entity, cmDefaultObject) then + flags = FlagClear(flags, efCollision) + end + if not HasAnySurfaces(entity, asApplyToGrids) and not HasMeshWithCollisionMask(entity, cmPassability) then + flags = FlagClear(flags, efApplyToGrids) + end + return flags + end + end) + __gameflags = FlagValuesTable("MapObject", "gof") + __classflags = FlagValuesTable("MapObject", "cf") + __componentflags = FlagValuesTable("MapObject", "cof") +end + +--- Reloads the MapObject class information in the C++ engine after the classes have been built. +-- This function is called in response to the ClassesBuilt message, and is responsible for: +-- - Clearing the static class information in the C++ engine +-- - Reloading the MapObject class information +-- - Reloading the information for all classes that inherit from MapObject +-- - Clearing the global flag tables used for tracking MapObject class information +-- These flag tables are used to cache and optimize access to MapObject class flags, and are cleared +-- to ensure they are properly reinitialized after the map is loaded. +function OnMsg.ClassesBuilt() + -- mirror MapObject class info in the C++ engine for faster access + ClearStaticClasses() + ReloadStaticClass("MapObject", g_Classes.MapObject) + ClassDescendants("MapObject", ReloadStaticClass) + -- clear flag tables + __enumflags = nil + __classflags = nil + __componentflags = nil + __gameflags = nil +end + +--- Clears references to cobjects in all lua objects after the map is done loading. +-- This function is called in response to the PostDoneMap message, and is responsible for: +-- - Iterating through the __cobjectToCObject table, which maps cobjects to lua objects +-- - Setting the true field of each lua object to false, effectively clearing the reference to the cobject +-- This is necessary to ensure that lua objects do not hold onto references to cobjects that have been destroyed or are no longer valid. +function OnMsg.PostDoneMap() + -- clear references to cobjects in all lua objects + for cobject, obj in pairs(__cobjectToCObject or empty_table) do + if obj then + obj[true] = false + end + end +end + +DefineClass.StripCObjectProperties = { + __parents = { "CObject" }, + properties = { + { id = "ColorizationPalette" }, + { id = "ClassFlagsProp" }, + { id = "ComponentFlagsProp" }, + { id = "EnumFlagsProp" }, + { id = "GameFlagsProp" }, + { id = "SurfacesProp" }, + { id = "Axis" }, + { id = "Opacity" }, + { id = "StateCategory" }, + { id = "StateText" }, + { id = "Mirrored" }, + { id = "ColorModifier" }, + { id = "Occludes" }, + { id = "ApplyToGrids" }, + { id = "IgnoreHeightSurfaces" }, + { id = "Walkable" }, + { id = "Collision" }, + { id = "OnCollisionWithCamera" }, + { id = "Scale" }, + { id = "SIModulation" }, + { id = "SIModulationManual" }, + { id = "AnimSpeedModifier" }, + { id = "Visible" }, + { id = "SunShadow" }, + { id = "CastShadow" }, + { id = "Entity" }, + { id = "Angle" }, + { id = "ForcedLOD" }, + { id = "Groups" }, + { id = "CollectionIndex" }, + { id = "CollectionName" }, + { id = "Warped" }, + { id = "SkewX", }, + { id = "SkewY", }, + { id = "ClipPlane", }, + { id = "Radius", }, + { id = "Sound", }, + { id = "OnRoof", }, + { id = "DontHideWithRoom", }, + { id = "Saturation" }, + { id = "Gamma" }, + { id = "DetailClass", }, + { id = "ForcedLODState", }, + { id = "TestStateButtons", }, + }, +} + +for i = 1, const.MaxColorizationMaterials do + table.iappend( StripCObjectProperties.properties, { + { id = string.format("EditableColor%d", i) }, + { id = string.format("EditableRoughness%d", i) }, + { id = string.format("EditableMetallic%d", i) }, + }) +end + +--- +--- Toggles the visibility of spots associated with the given CObject. +--- +--- @param self CObject The CObject instance to toggle spot visibility for. +--- +function CObject:AsyncCheatSpots() + ToggleSpotVisibility{self} +end + +--- +--- Deletes the CObject instance. +--- +--- This function is used to delete the CObject instance from the game world. +--- +--- @param self CObject The CObject instance to delete. +--- +function CObject:CheatDelete() + DoneObject(self) +end + +--- +--- Shows the class hierarchy for the CObject instance. +--- +--- This function is used to display the class hierarchy for the CObject instance in the game's debug UI. +--- +--- @param self CObject The CObject instance to display the class hierarchy for. +--- +function CObject:AsyncCheatClassHierarchy() + DbgShowClassHierarchy(self.class) +end + +--- +--- Recursively marks all entities attached to the CObject instance. +--- +--- This function is used to mark all entities that are attached to the CObject instance, including any entities that are attached to those attached entities. The marked entities are stored in the provided `entities` table. +--- +--- @param self CObject The CObject instance to mark attached entities for. +--- @param entities table A table to store the marked entities in. +--- @return table The `entities` table, containing all marked entities. +--- +function CObject:__MarkEntities(entities) + if not IsValid(self) then return end + + entities[self:GetEntity()] = true + for j = 1, self:GetNumAttaches() do + local attach = self:GetAttach(j) + attach:__MarkEntities(entities) + end +end + +--- +--- Recursively marks all entities attached to the CObject instance. +--- +--- This function is used to mark all entities that are attached to the CObject instance, including any entities that are attached to those attached entities. The marked entities are stored in the provided `entities` table. +--- +--- @param self CObject The CObject instance to mark attached entities for. +--- @param entities table A table to store the marked entities in. +--- @return table The `entities` table, containing all marked entities. +--- +function CObject:MarkAttachEntities(entities) + entities = entities or {} + + self:__MarkEntities(entities) + + return entities +end + +--- +--- Takes a screenshot of the CObject instance. +--- +--- This function is used to take a screenshot of the CObject instance, which can be useful for debugging purposes. +--- +--- @param self CObject The CObject instance to take a screenshot of. +--- +function CObject:AsyncCheatScreenshot() + IsolatedObjectScreenshot(self) +end + +-- Dev functionality +--- +--- A table of allowed members for CObject instances. +--- +CObjectAllowedMembers = {} +CObjectAllowedDeleteMethods = {} diff --git a/CommonLua/Classes/_object.lua b/CommonLua/Classes/_object.lua new file mode 100644 index 0000000000000000000000000000000000000000..1aac6e8d149195bfe1500d30e3fb71ed5b3a8423 --- /dev/null +++ b/CommonLua/Classes/_object.lua @@ -0,0 +1,712 @@ +--[[@@@ +@class Object +Object are CObject that have also allocated Lua memory and thus can participate in more sophisticated game logic instead of just being vizualized. +--]] +DefineClass.Object = +{ + __parents = { "CObject", "InitDone" }, + __hierarchy_cache = true, + flags = { cfLuaObject = true, }, + spawned_by_template = false, + handle = false, + reserved_handles = 0, + NetOwner = false, + GameInit = empty_func, + + properties = { + { id = "Handle", editor = "number", default = "", read_only = true, dont_save = true }, + { id = "spawned_by_template", name = "Spawned by template", editor = "object", read_only = true, dont_save = true }, + }, +} + +RecursiveCallMethods.GameInit = "procall" + +--[[@@@ +Called after the object's creation has been completed and the game is running. The method isn't overriden by child classes, but instead all implementations are called starting from the topmost parent. +@function void Object:GameInit() +--]] +--[[@@@ +Called in the beginning of the object's creation. The method isn't overriden by child classes, but instead all implementations are called starting from the topmost parent. +@function void Object:Init() +--]] +--[[@@@ +Called when the object is being destroyed. The method isn't overriden by child classes, but instead all implementations are called starting from the last child class. +@function void Object:Done() +--]] + +-- HandleToObject allows each Object to be uniquely identified and prevents it from being garbage collected +-- An object's handle is a number. Permanent objects store their handle in the map. +-- When an object is created if it does not have a handle it gets an automatically generated one. +-- The handle can be used as object specific pseudo random seed or to order a list of objects. +-- An object may request a pool of handles instead of just one. The size of the pool is const.PerObjectHandlePool (project specific). + +-- Below is a map of the object handle space: +-- negative - reserved for application use +-- 0 .. 1,000,000 - reserved for application use +-- 1,000,000 - 1,000,000,000 - autogenerated handles for objects with handle pools +-- 1,000,000,000 - 1,900,000,000 - autogenerated handles for objects without handle pools +-- 1,900,000,000 - 2,000,000,000 - autogenerated handles for objects created during map loading +-- 2,000,000,000 - 2,147,483,646 - autogenerated sync handles (no handle pool) + +local HandlesAutoPoolStart = const.HandlesAutoPoolStart or 1000000 +local HandlesAutoPoolSize = (const.HandlesAutoPoolSize or 999000000) - (const.PerObjectHandlePool or 1024) +local HandlesAutoStart = const.HandlesAutoStart or 1000000000 +local HandlesAutoSize = const.HandlesAutoSize or 900000000 +local HandlesMapLoadingStart = HandlesAutoStart + HandlesAutoSize +local HandlesMapLoadingSize = 100000000 +local HandlePoolMask = bnot((const.PerObjectHandlePool or 1024) - 1) +-- PerObjectHandlePool should be a power of two +assert(band(bnot(HandlePoolMask), const.PerObjectHandlePool or 1024) == 0) + +--- +--- Checks if the given handle is within the range reserved for objects created during map loading. +--- +--- @param h number The handle to check +--- @return boolean true if the handle is within the map loading range, false otherwise +function IsLoadingHandle(h) + return h and h >= HandlesMapLoadingStart and h <= (HandlesMapLoadingStart + HandlesMapLoadingSize) +end + +--- +--- Returns the start and size of the range of automatically generated object handles. +--- +--- @return number start The start of the range of automatically generated object handles. +--- @return number size The size of the range of automatically generated object handles. +function GetHandlesAutoLimits() + return HandlesAutoStart, HandlesAutoSize +end + +--- +--- Returns the start and size of the range of automatically generated object handles, as well as the size of the handle pool. +--- +--- @return number start The start of the range of automatically generated object handles. +--- @return number size The size of the range of automatically generated object handles. +--- @return number poolSize The size of the handle pool. +function GetHandlesAutoPoolLimits() + return HandlesAutoPoolStart, HandlesAutoPoolSize, const.PerObjectHandlePool or 1024 +end + +--- +--- Defines global variables to store object handles, game init threads, and game init objects. +--- +--- @global HandleToObject table A table that maps object handles to their corresponding objects. +--- @global GameInitThreads table A table that stores the game init threads for each object. +--- @global GameInitAfterLoading table A table that stores the objects that need to have their GameInit() method called after the game has finished loading. +MapVar("HandleToObject", {}) +MapVar("GameInitThreads", {}) +MapVar("GameInitAfterLoading", {}) + +--- +--- Called when the game time starts. Processes any objects that need to have their GameInit() method called after the game has finished loading. +--- +function OnMsg.GameTimeStart() + local list = GameInitAfterLoading + local i = 1 + while i <= #list do + local obj = list[i] + if IsValid(obj) then + obj:GameInit() + end + i = i + 1 + end + GameInitAfterLoading = false +end + +--- +--- Cancels the GameInit() method call for the specified object. +--- +--- If the object has a pending GameInit() call in a game time thread, the thread is deleted. +--- If the object is in the GameInitAfterLoading table, it is removed from the table. +--- +--- @param obj table The object to cancel the GameInit() call for. +--- @param bCanDeleteCurrentThread boolean If true, the current thread can be deleted. If false, the current thread will not be deleted. +--- +function CancelGameInit(obj, bCanDeleteCurrentThread) + local thread = GameInitThreads[obj] + if thread then + DeleteThread(thread, bCanDeleteCurrentThread) + GameInitThreads[obj] = nil + return + end + local list = GameInitAfterLoading + if list then + for i = #list, 1, -1 do + if list[i] == obj then + list[i] = false + return + end + end + end +end + +--- +--- Creates a new object instance of the specified class. +--- +--- This function is responsible for generating a unique handle for the new object, and associating it with the object in the `HandleToObject` table. +--- +--- If the object has a `GameInit` method, it will be called either immediately in a new game time thread, or added to the `GameInitAfterLoading` table to be called later after the game has finished loading. +--- +--- @param class table The class definition of the object to create. +--- @param luaobj table The Lua object to associate with the new C object. +--- @param components table An optional table of component objects to associate with the new object. +--- @param ... any Additional arguments to pass to the object's `Init` method. +--- @return table The new object instance. +function Object.new(class, luaobj, components, ...) + local self = CObject.new(class, luaobj, components) + + local h = self.handle + if h then + local prev_obj = HandleToObject[h] + if prev_obj and prev_obj ~= self then + assert(false, string.format("Duplicate handle %d: new '%s', prev '%s'", h, class.class, prev_obj.class)) + h = false + end + end + if not h then + h = self:GenerateHandle() + self.handle = h + end + HandleToObject[h] = self + + OnHandleAssigned(h) + + if self.GameInit ~= empty_func then + local loading = GameInitAfterLoading + if loading then + loading[#loading + 1] = self + else + GameInitThreads[self] = CreateGameTimeThread(function(self) + if IsValid(self) then + self:GameInit() + end + GameInitThreads[self] = nil + end, self) + end + end + self:NetUpdateHash("Init") + self:Init(...) + return self +end + +--- +--- Deletes the object instance. +--- +--- This function is responsible for removing the object from the `HandleToObject` table, marking it as deleted in the `DeletedCObjects` table, and calling the `Done()` method on the object. +--- +--- If the object has a handle, it asserts that the handle is associated with the object in the `HandleToObject` table. It then removes the handle from the `HandleToObject` table and marks the object as deleted in the `DeletedCObjects` table. +--- +--- Finally, it calls the `Done()` method on the object and deletes the C object using the `CObject.delete()` function. +--- +--- @param fromC boolean If true, the delete was initiated from C code. +function Object:delete(fromC) + if not self[true] then + return + end + + dbg(self:Trace("Object:delete", GetStack(2))) + + local h = self.handle + assert(not h or HandleToObject[h] == self, "Object is already destroyed", 1) + assert(not DeletedCObjects[self], "Object is already destroyed", 1) + HandleToObject[h] = nil + DeletedCObjects[self] = true + self:Done() + CObject.delete(self, fromC) +end + +-- called while loading map after object is placed and its properties are set +-- use to compute members from other properties +--- +--- Sets the `PostLoad` function to an empty function. +--- +--- The `PostLoad` function is called after an object's properties have been set, and is used to compute members from other properties. +--- +--- By setting `PostLoad` to an empty function, this disables the default behavior of the `PostLoad` function. +--- +--- @field AutoResolveMethods.PostLoad boolean If true, the `PostLoad` function will be called after an object's properties have been set. +--- @field Object.PostLoad function An empty function that does nothing. This is used to disable the default behavior of the `PostLoad` function. +AutoResolveMethods.PostLoad = true +Object.PostLoad = empty_func + +--- +--- Copies the properties from the specified object to this object. +--- +--- This function uses the `PropertyObject.CopyProperties()` function to copy the specified properties from the source object to this object. +--- +--- After the properties have been copied, the `PostLoad()` function is called on this object. This allows the object to perform any additional processing or initialization that is required after the properties have been set. +--- +--- @param obj table The object to copy properties from. +--- @param properties table (optional) A table of property names to copy. If not provided, all properties will be copied. +function Object:CopyProperties(obj, properties) + PropertyObject.CopyProperties(self, obj, properties) + self:PostLoad() +end + +-- C side invoke +--- +--- Copies the properties from the specified source object to the destination object. +--- +--- This function uses the `Object:CopyProperties()` method to copy the properties from the source object to the destination object. +--- +--- After the properties have been copied, the function returns the destination object. This is necessary because the game object could be changed during the `CopyProperties()` call, so the new object needs to be returned. +--- +--- @param dest table The destination object to copy properties to. +--- @param source table The source object to copy properties from. +--- @return table The destination object, which may have been modified during the `CopyProperties()` call. +function CCopyProperties(dest, source) + dest:CopyProperties(source) + return dest -- the game object could be changed during this call, need to return the new one +end + +--- +--- Changes the class metatable of the specified object to the class definition for the given class name. +--- +--- This function is used to change the class of an object at runtime. It sets the metatable of the object to the class definition for the specified class name. +--- +--- @param obj table The object to change the class of. +--- @param classname string The name of the class to set the object's class to. +function ChangeClassMeta(obj, classname) + local classdef = g_Classes[classname] + assert(classdef) + if not classdef then + return + end + setmetatable(obj, classdef) +end +-- C side invoke +function ChangeClassMeta(obj, classname) + local classdef = g_Classes[classname] + assert(classdef) + if not classdef then + return + end + setmetatable(obj, classdef) +end + +--- +--- Generates a random number using the AsyncRand function. +--- +--- @return number A random number generated using AsyncRand. +HandleRand = AsyncRand + +--- +--- Generates a unique handle for an object. +--- +--- This function is used to generate a unique handle for an object. The handle is used to identify the object and ensure that it is unique within the game world. +--- +--- If the object is a sync object, the function calls `GenerateSyncHandle()` to generate the handle. Otherwise, it generates a random handle within a specified range. +--- +--- If the object has a reserved handle range, the function generates a handle within that range. Otherwise, it generates a handle within the global handle pool. +--- +--- @return number The generated handle for the object. +function Object:GenerateHandle() + if self:IsSyncObject() then + return GenerateSyncHandle(self) + end + local range = self.reserved_handles + local h + if range == 0 then + local start, size = HandlesAutoStart, HandlesAutoSize + if ChangingMap then + start, size = HandlesMapLoadingStart, HandlesMapLoadingSize + end + repeat + h = start + HandleRand(size) + until not HandleToObject[h] + else + assert(band(range, HandlePoolMask) == 0) -- the reserved pool is large enough + repeat + h = band(HandlesAutoPoolStart + HandleRand(HandlesAutoPoolSize), HandlePoolMask) + until not HandleToObject[h] + end + return h +end + +--- +--- Returns the handle of the object. +--- +--- @return number The handle of the object. +function Object:GetHandle() + return self.handle +end + +--- +--- Sets the handle of the object. +--- +--- This function is used to set the handle of the object. It performs the following steps: +--- +--- 1. Converts the input handle to a number or uses the input handle as is. +--- 2. Asserts that the current handle is not set or that the object is the one associated with the current handle. +--- 3. If the handle is the same as the current handle, returns the handle. +--- 4. If the handle is set and another object is associated with it, asserts an error and generates a new handle. +--- 5. Removes the association between the current handle and the object. +--- 6. Associates the new handle with the object. +--- 7. Sets the handle of the object. +--- 8. Calls the `OnHandleAssigned` function with the new handle. +--- +--- @param h number The new handle for the object. +--- @return number The new handle for the object. +function Object:SetHandle(h) + h = tonumber(h) or h or false + assert(not self.handle or HandleToObject[self.handle] == self) + if self.handle == h then + return h + end + if h and HandleToObject[h] then + assert(false, string.format("Duplicate handle %d: new '%s', prev '%s'", h, self.class, HandleToObject[h].class)) + h = self:GenerateHandle() + end + HandleToObject[self.handle] = nil + if h then + HandleToObject[h] = self + end + self.handle = h + + OnHandleAssigned(h) + + return h +end + +--- +--- Regenerates the handle of the object. +--- +--- This function is used to generate a new handle for the object and set it using the `SetHandle` function. +--- +--- @function Object:RegenerateHandle +--- @return number The new handle for the object. +function Object:RegenerateHandle() + self:SetHandle(self:GenerateHandle()) +end + +-- A pseudorandom that is stable for the lifetime of the object and avoids clustering artefacts +--- +--- Generates a pseudorandom number based on the object's handle and a provided key. +--- +--- This function uses the xxhash algorithm to generate a pseudorandom number based on the object's handle and a provided key. The resulting number is then modulated by the given range to produce a value within that range. +--- +--- @param range number The range of the resulting pseudorandom number. +--- @param key any The key to use for the pseudorandom number generation. +--- @param ... any Additional arguments to pass to the xxhash function. +--- @return number A pseudorandom number within the given range. +function Object:LifetimeRandom(range, key, ...) + assert(range and key) + return abs(xxhash(self.handle, key, ...)) % range +end + +--- +--- Resets the spawn state of the object and any objects that have reserved handles. +--- +--- This function is used to reset the spawn state of the object and any objects that have reserved handles. It iterates through the reserved handles and recursively calls the `ResetSpawn` function on any objects that have a reserved handle. It also calls the `DoneObject` function on any objects that are found. +--- +--- @function Object:ResetSpawn +--- @return nil +function Object:ResetSpawn() + if self.reserved_handles == 0 then + return + end + local handle = self.handle + 1 + local max_handle = self.handle + self.reserved_handles + while handle < max_handle do + local obj = HandleToObject[handle] + if obj then + handle = handle + 1 + obj.reserved_handles + obj:ResetSpawn() + DoneObject(obj) + else + handle = handle + 1 + end + end +end + +-- returns false, "local" or "remote" +--- +--- Returns the network state of the object's owner. +--- +--- If the object has a valid net owner, this function returns the net state of the net owner. Otherwise, it returns `false`. +--- +--- @function Object:NetState +--- @return boolean|string The net state of the object's owner, or `false` if the object has no net owner. +function Object:NetState() + if IsValid(self.NetOwner) then + return self.NetOwner:NetState() + end + return false +end + +RecursiveCallMethods.GetDynamicData = "call" +RecursiveCallMethods.SetDynamicData = "call" + +--- +--- Retrieves the dynamic data of the object. +--- +--- This function retrieves various dynamic properties of the object, such as the net owner, visual position, visual angle, and gravity. The retrieved data is stored in the provided `data` table. +--- +--- @function Object:GetDynamicData +--- @param data table A table to store the retrieved dynamic data. +--- @return nil +function Object:GetDynamicData(data) + if IsValid(self.NetOwner) then + data.NetOwner = self.NetOwner + end + if self:IsValidPos() and not self:GetParent() then + local vpos_time = self:TimeToPosInterpolationEnd() + if vpos_time ~= 0 then + data.vpos = self:GetVisualPos() + data.vpos_time = vpos_time + end + end + local vangle_time = self:TimeToAngleInterpolationEnd() + if vangle_time ~= 0 then + data.vangle = self:GetVisualAngle() + data.vangle_time = vangle_time + end + local gravity = self:GetGravity() + if gravity ~= 0 then + data.gravity = gravity + end +end + +--- +--- Sets the dynamic data of the object. +--- +--- This function sets various dynamic properties of the object, such as the net owner, gravity, visual position, and visual angle. The dynamic data is provided in the `data` table. +--- +--- @function Object:SetDynamicData +--- @param data table A table containing the dynamic data to set. +--- @return nil +function Object:SetDynamicData(data) + self.NetOwner = data.NetOwner + if data.gravity then + self:SetGravity(data.gravity) + end + + if data.pos then + self:SetPos(data.pos) + end + if data.angle then + self:SetAngle(data.angle or 0) + end + if data.vpos then + local pos = self:GetPos() + self:SetPos(data.vpos) + self:SetPos(pos, data.vpos_time) + end + if data.vangle then + local angle = self:GetAngle() + self:SetAngle(data.vangle) + self:SetAngle(angle, data.vangle_time) + end +end + +local ResolveHandle = ResolveHandle +local SetObjPropertyList = SetObjPropertyList +local SetArray = SetArray +--- +--- Constructs a new object from Lua code. +--- +--- This function is used to construct a new object from Lua code. It takes the object properties, array, and handle as input, and creates a new object with the given data. +--- +--- @function Object:__fromluacode +--- @param props table The object properties to set. +--- @param arr table The array data to set. +--- @param handle number The handle of the object. +--- @return Object The newly constructed object. +function Object:__fromluacode(props, arr, handle) + local obj = ResolveHandle(handle) + + if obj and obj[true] then + StoreErrorSource(obj, "Duplicate handle", handle) + assert(false, string.format("Duplicate handle %d: new '%s', prev '%s'", handle, self.class, obj.class)) + obj = nil + end + + obj = self:new(obj) + SetObjPropertyList(obj, props) + SetArray(obj, arr) + return obj +end + +--- +--- Converts the object to Lua code. +--- +--- This function is used to convert an object to Lua code. It takes the object properties, array, and handle as input, and generates a Lua code string that can be used to recreate the object. +--- +--- @function Object:__toluacode +--- @param indent string The indentation to use for the generated Lua code. +--- @param pstr string (optional) A string buffer to append the Lua code to. +--- @param GetPropFunc function (optional) A function to get the property value for the object. +--- @return string The generated Lua code for the object. +function Object:__toluacode(indent, pstr, GetPropFunc) + if not pstr then + local props = ObjPropertyListToLuaCode(self, indent, GetPropFunc) + local arr = ArrayToLuaCode(self, indent) + return string.format("PlaceObj('%s', %s, %s, %s)", self.class, props or "nil", arr or "nil", tostring(self.handle or "nil")) + else + pstr:appendf("PlaceObj('%s', ", self.class) + if not ObjPropertyListToLuaCode(self, indent, GetPropFunc, pstr) then + pstr:append("nil") + end + pstr:append(", ") + if not ArrayToLuaCode(self, indent, pstr) then + pstr:append("nil") + end + return pstr:append(", ", self.handle or "nil", ")") + end +end + +----- Sync Objects + +--- @class SyncObject +--- A class that represents a synchronized object in the game. +--- The `SyncObject` class inherits from the `Object` class and has the `gofSyncObject` flag set to `true`. +--- Synchronized objects are used to represent game objects that need to be synchronized across the network, such as player characters or other game entities. +--- The `SyncObject` class provides functionality for generating and managing the handles of synchronized objects. +DefineClass.SyncObject = {__parents={"Object"}, flags={gofSyncObject=true}} +--- +--- Converts a regular object into a synchronized object. +--- +--- This function sets the `gofSyncObject` flag on the object, indicating that it is a synchronized object. +--- It also generates a new handle for the object using the `GenerateHandle()` function, and updates the object's position, angle, entity, and state text over the network. +--- +--- @function Object:MakeSync +--- @return nil + +function Object:MakeSync() + if self:IsSyncObject() then return end + self:SetGameFlags(const.gofSyncObject) + self:SetHandle(self:GenerateHandle()) + self:NetUpdateHash("MakeSync", self:GetPos(), self:GetAngle(), self:GetEntity(), self:GetStateText()) +end +--- Selects a random element from the given table. +--- +--- This function selects a random element from the given table `tbl`. If the table has less than 2 elements, it returns the first element and its index. Otherwise, it generates a random index using the `Random()` function and returns the corresponding element and its index. +--- +--- @param tbl table The table to select a random element from. +--- @param key string (optional) A key to use for the random seed. +--- @return any, number The randomly selected element and its index. +function Object:TableRand(tbl, key) +end + +function Object:TableRand(tbl, key) + if not tbl then return elseif #tbl < 2 then return tbl[1], 1 end + local idx = self:Random(#tbl, key) + idx = idx + 1 + return tbl[idx], idx +end +--- +--- Selects a random element from the given table, with weighted probabilities. +--- +--- This function selects a random element from the given table `tbl`, with the probabilities of each element determined by the `calc_weight` function. The `calc_weight` function should take an element from the table and return a number representing the weight of that element. +--- +--- The function uses the `table.weighted_rand()` function to perform the weighted random selection, and the `self:Random()` function to generate a random seed based on the provided `key`. +--- +--- @param tbl table The table to select a random element from. +--- @param calc_weight function A function that takes an element from the table and returns a number representing its weight. +--- @param key string (optional) A key to use for the random seed. +--- @return any, number The randomly selected element and its index. +function Object:TableWeightedRand(tbl, calc_weight, key) +end + +function Object:TableWeightedRand(tbl, calc_weight, key) + if not tbl then return elseif #tbl < 2 then return tbl[1], 1 end + + local seed = self:Random(max_int, key) + return table.weighted_rand(tbl, calc_weight, seed) +end +--- Generates a random number within a specified range. +--- +--- This function generates a random number between `min` and `max` (inclusive) using the `self:Random()` function. +--- +--- @param min number The minimum value of the range. +--- @param max number The maximum value of the range. +--- @param ... any Additional arguments to pass to `self:Random()`. +--- @return number A random number within the specified range. +function Object:RandRange(min, max, ...) +end + +function Object:RandRange(min, max, ...) + return min + self:Random(max - min + 1, ...) +end + +--- +--- Generates a random seed based on the provided `key`. +--- +--- This function generates a random seed using the `self:Random()` function and the provided `key`. The seed is generated within the range of `max_int`. +--- +--- @param key string A key to use for the random seed. +--- @return number The generated random seed. +function Object:RandSeed(key) + return self:Random(max_int, key) +end + +--- +--- Defines the range of handles used for synchronization between the client and server. +--- +--- `HandlesSyncStart` is the starting handle value for synchronized objects. +--- `HandlesSyncSize` is the total number of handles available for synchronization. +--- `HandlesSyncEnd` is the ending handle value for synchronized objects. +--- +--- These values are used to manage the allocation and tracking of handles for objects that need to be synchronized between the client and server. +local HandlesSyncStart = const.HandlesSyncStart or 2000000000 +local HandlesSyncSize = const.HandlesSyncSize or 147483647 +local HandlesSyncEnd = HandlesSyncStart + HandlesSyncSize - 1 + +--- +--- Initializes a table to store custom sync handles and sets the initial value for the next sync handle. +--- +--- The `CustomSyncHandles` table is used to store any custom sync handles that are not part of the standard range defined by `HandlesSyncStart` and `HandlesSyncEnd`. +--- The `NextSyncHandle` variable is set to the starting value of the standard sync handle range, `HandlesSyncStart`. +--- +--- @tparam table CustomSyncHandles A table to store custom sync handles. +--- @tparam number NextSyncHandle The initial value for the next sync handle. +MapVar("CustomSyncHandles", {}) +MapVar("NextSyncHandle", HandlesSyncStart) + +--- +--- Checks if the given handle is within the range of synchronized handles. +--- +--- This function checks if the provided `handle` is within the range of handles used for synchronization between the client and server. It also checks if the handle is a custom sync handle stored in the `CustomSyncHandles` table. +--- +--- @param handle number The handle to check. +--- @return boolean `true` if the handle is a synchronized handle, `false` otherwise. +function IsHandleSync(handle) + return handle >= HandlesSyncStart and handle <= HandlesSyncEnd or CustomSyncHandles[handle] +end + +--- +--- Generates a new synchronization handle for an object. +--- +--- This function generates a new synchronization handle for an object that needs to be synchronized between the client and server. It ensures that the generated handle is unique and within the range of reserved handles for synchronization. +--- +--- @return number The generated synchronization handle. +function GenerateSyncHandle() + local h = NextSyncHandle + while HandleToObject[h] do + h = (h + 1 <= HandlesSyncEnd) and (h + 1) or HandlesSyncStart + if h == NextSyncHandle then + assert(false, "All reserved handles are used!") + break + end + end + NextSyncHandle = (h + 1 <= HandlesSyncEnd) and (h + 1) or HandlesSyncStart + NetUpdateHash("GenerateSyncHandle", h) + return h +end + +--- +--- Defines a class `StripObjectProperties` that inherits from `StripCObjectProperties` and `Object`. +--- This class has the following properties: +--- +--- - `Entity`: The entity associated with the object. +--- - `Pos`: The position of the object. +--- - `Angle`: The angle of the object. +--- - `ForcedLOD`: The forced level of detail for the object. +--- - `Groups`: The groups the object belongs to. +--- - `CollectionIndex`: The index of the object in a collection. +--- - `CollectionName`: The name of the collection the object belongs to. +--- - `spawned_by_template`: Whether the object was spawned by a template. +--- - `Handle`: The handle of the object. +--- +DefineClass.StripObjectProperties = {__parents={"StripCObjectProperties", "Object"}, + properties={{id="Entity"}, {id="Pos"}, {id="Angle"}, {id="ForcedLOD"}, {id="Groups"}, {id="CollectionIndex"}, + {id="CollectionName"}, {id="spawned_by_template"}, {id="Handle"}}} diff --git a/CommonLua/Classes/collection.lua b/CommonLua/Classes/collection.lua new file mode 100644 index 0000000000000000000000000000000000000000..cd56d5c6f1bb6936c2afb803f88c454e5fc4ec0a --- /dev/null +++ b/CommonLua/Classes/collection.lua @@ -0,0 +1,566 @@ +local max_collection_idx = const.GameObjectMaxCollectionIndex +local GetCollectionIndex = CObject.GetCollectionIndex + +MapVar("Collections", {}) +MapVar("CollectionsByName", {}) +MapVar("g_ShowCollectionLimitWarning", true) + +DefineClass.Collection = { + __parents = { "Object" }, + flags = { efWalkable = false, efApplyToGrids = false, efCollision = false }, + + properties = { + category = "Collection", + { id = "Name", editor = "text", default = "", }, + { id = "Index", editor = "number", default = 0, min = 0, max = max_collection_idx, read_only = true, }, + { id = "Locked", editor = "bool", default = false, dont_save = true, }, + { id = "ParentName", name = "Parent", editor = "text", default = "", read_only = true, dont_save = true, }, + { id = "Type", editor = "text", default = "", read_only = true, }, + { id = "HideFromCamera", editor = "bool", default = false, help = "Makes collection use HideTop system to hide from camera regardless of the presence of HideTop objects within it or the objects' position relative to the playable area."}, + { id = "DontHideFromCamera", editor = "bool", default = false, help = "If true, HideTop objects in this collection will be ignored. HideFromCamera will override this."}, + { id = "HandleCount", name = "Handles Count", editor = "number", default = 0, read_only = true, dont_save = true, }, + { id = "Graft", name = "Change parent", editor = "dropdownlist", default = "", + items = function(self) + local names = GetCollectionNames() + if self.Name ~= "" then + table.remove_entry(names, self.Name) + end + return names + end, + buttons = { { name = "Set", func = "SetParentButton" } }, + dont_save = true, + }, + }, + + -- non-editor stubs + UpdateLocked = empty_func, + SetLocked = empty_func, +} + +-- hide unnused properties from CObject +for i = 1, #CObject.properties do + local prop = table.copy(CObject.properties[i]) + prop.no_edit = true + table.insert(Collection.properties, prop) +end + +-- property getters ---------------------------------- + +function Collection:GetParentName() + local parent = self:GetCollection() + return parent and parent.Name or "" +end + +------------------------------------------------------ + +if Platform.developer then + function Collection:SetCollectionIndex(new_index) + local col_idx = self.Index + if new_index and new_index ~= 0 and col_idx and col_idx ~= 0 then + if new_index == col_idx then + return false, "[Collection] The parent index is the same!" + end + local parent = Collections[new_index] + if parent and parent:GetCollectionRelation(col_idx) then + return false, "[Collection] The parent is a child!" + end + end + return CObject.SetCollectionIndex(self, new_index) + end +end + +function Collection:GetObjIdentifier() + return xxhash(self.Name, self.Index) +end + +function Collection:GetHandleCount() + local pool = 0 + local count = 0 + MapForEach("map", "attached", false, "collection", self.Index, true, "Object", function(obj) + pool = pool + 1 + obj.reserved_handles + count = count + 1 + end) + return pool, count +end + +function Collection:SetIndex(new_index) + new_index = new_index or 0 + local old_index = self.Index + local collections = Collections + if old_index ~= new_index or not collections[old_index] then + if new_index ~= 0 then + -- Check if we're creating a new collection or loading an existing one on map load + if collections[new_index] or new_index < 0 or new_index > max_collection_idx then + new_index = AsyncRand(max_collection_idx) + 1 + + local loop_index = new_index + while collections[new_index] do + new_index = new_index + 1 + if new_index == loop_index then + break + elseif new_index > max_collection_idx then + new_index = 1 -- circle around after the last index + end + end + end + + if not IsChangingMap() then + if collections[new_index] then -- no free index was found + CreateMessageBox( + terminal.desktop, + Untranslated("Error"), + Untranslated("Collection not created - collection limit exceeded!") + ) + return false + end + + -- If there are less than 10% free collection indexes => display a warning + if g_ShowCollectionLimitWarning then + local collections_count = #table.keys(collections) + 1 -- account for the new collection + if collections_count >= MulDivRound(max_collection_idx, 90, 100) then + CreateMessageBox( + terminal.desktop, + Untranslated("Warning"), + Untranslated(string.format("There are %d collections on this map, approaching the limit of %d.", collections_count, max_collection_idx)) + ) + g_ShowCollectionLimitWarning = false -- disable the warning until next map (re)load + end + end + end + + collections[new_index] = self + end + + if old_index ~= 0 and collections[old_index] == self then + self:SetLocked(false) + local parent_index = new_index ~= 0 and new_index or GetCollectionIndex(self) + MapForEach(true, "collection", old_index, function(o, idx) o:SetCollectionIndex(idx) end, parent_index) + collections[old_index] = nil + end + + self.Index = new_index + Collection.UpdateLocked() + end + return true +end + +function Collection.GetRoot(col_idx) + if col_idx and col_idx ~= 0 then + local locked_idx = editor.GetLockedCollectionIdx() + if col_idx ~= locked_idx then + local collections = Collections + while true do + local col_obj = collections[col_idx] + if not col_obj then + assert(false, "Root collection error") + return 0 + end + local parent_idx = GetCollectionIndex(col_obj) + if not parent_idx or parent_idx == 0 or parent_idx == locked_idx then + break + end + col_idx = parent_idx + end + end + end + return col_idx +end + +function Collection:Init() + self:SetGameFlags(const.gofPermanent) +end + +function Collection:Done() + self:SetIndex(false) + self:SetName(false) +end + +function Collection:SetName(new_name) + new_name = new_name or "" + local old_name = self.Name + local CollectionsByName = CollectionsByName + if old_name ~= new_name or not CollectionsByName[old_name] then + CollectionsByName[old_name] = nil + if new_name ~= "" then + local orig_prefix, new_name_idx + while CollectionsByName[new_name] do + if not orig_prefix then + -- Check if the name ends with a number + local idx = string.find(new_name, "_%d+$") + -- The old name with unique incremented index + orig_prefix = idx and string.sub(new_name, 1, idx - 1) or new_name + new_name_idx = idx and tonumber(string.sub(new_name, idx + 1)) or 0 + end + new_name_idx = new_name_idx + 1 + new_name = string.format("%s_%d", orig_prefix , new_name_idx) + end + CollectionsByName[new_name] = self + end + self.Name = new_name + end + return new_name +end + +function Collection:SetCollection(collection) + if collection and collection.Index == editor.GetLockedCollectionIdx() then + editor.AddToLockedCollectionIdx(self.Index) + end + CObject.SetCollection(self, collection) +end + +function Collection:OnEditorSetProperty(prop_id, old_value, ged) + ged:ResolveObj("root"):UpdateTree() +end + +function Collection.Create(name, idx, obj) + idx = idx or -1 + local col = Collection:new(obj) + if col:SetIndex(idx) then + if name then + col:SetName(name) + end + UpdateCollectionsEditor() + return col + end + DoneObject(col) +end + +function Collection:IsEmpty(permanents) + return MapCount("map", "collection", self.Index, true, nil, nil, permanents and const.gofPermanent or nil ) == 0 +end + +local function RemoveTempObjects(objects) + for i = #(objects or ""), 1, -1 do + local obj = objects[i] + if obj:GetGameFlags(const.gofPermanent) == 0 or obj:GetParent() then + table.remove(objects, i) + end + end +end + +function Collection.Collect(objects) + local uncollect = true + local trunk + local locked = Collection.GetLockedCollection() + objects = objects or empty_table + RemoveTempObjects(objects) + if #objects > 0 then + trunk = objects[1]:GetRootCollection() + for i = 2, #objects do + if trunk ~= objects[i]:GetRootCollection() then + uncollect = false + break + end + end + end + if trunk and trunk ~= locked and uncollect then + local op_name = string.format("Removed %d objects from collection", #objects) + table.insert(objects, trunk) -- add 'trunk' collection to the list of affected objects, as it could be deleted + XEditorUndo:BeginOp{ objects = objects, name = op_name } + for i = 1, #objects - 1 do + objects[i]:SetCollection(locked) + end + if trunk:IsEmpty() then + print("Destroyed collection: " .. trunk.Name) + table.remove(objects) -- 'trunk' is at the last index + Msg("EditorCallback", "EditorCallbackDelete", { trunk }) + DoneObject(trunk) + else + print(op_name .. ":" .. trunk.Name) + end + XEditorUndo:EndOp(objects) + UpdateCollectionsEditor() + return false + end + + local col = Collection.Create() + if not col then + return false + end + + XEditorUndo:BeginOp{ objects = objects, name = "Created collection" } + + col:SetCollection(locked) + local classes = false + if #objects > 0 then + classes = {} + local obj_to_add = {} + for i = 1, #objects do + local obj = objects[i] + classes[obj.class] = (classes[obj.class] or 0) + 1 + while true do + local obj_col = obj:GetCollection() + if not obj_col or obj_col == locked then + break + end + obj = obj_col + end + obj_to_add[obj] = true + end + for obj in pairs(obj_to_add) do + obj:SetCollection(col) + end + table.insert(objects, col) + UpdateCollectionsEditor() + end + + local name = false + if classes then + local max = 0 + for class, count in pairs(classes) do + if max < count then + max = count + name = class + end + end + end + col:SetName("col_" .. (name or col.Index)) + + XEditorUndo:EndOp(objects) + + print("Collection created: " .. col.Name) + return col +end + +function Collection.AddToCollection() + local sel = editor.GetSel() + RemoveTempObjects(sel) + local locked_col = Collection.GetLockedCollection() + local dest_col + local objects = {} + for i = 1, #sel do + local col = sel[i] and sel[i]:GetRootCollection() + if col and col ~= locked_col then + objects[col] = true + dest_col = col + else + objects[sel[i]] = true + end + end + if dest_col then + XEditorUndo:BeginOp{ objects = sel, name = string.format("Added %d objects to collection", #sel) } + for obj in pairs(objects) do + if obj ~= dest_col then + obj:SetCollection(dest_col) + end + end + XEditorUndo:EndOp(sel) + UpdateCollectionsEditor() + print("Collection modified: " .. dest_col.Name) + end +end + +function Collection.GetPath(idx) + local path = {} + while idx ~= 0 do + local collection = Collections[idx] + if not collection then + break + end + table.insert(path, 1, collection.Name) + idx = GetCollectionIndex(collection) + end + return table.concat(path, '/') +end + +local function GetSavePath(name) + return string.format("data/collections/%s.lua", name) +end + +local function DoneSilent(col) + Collections[col.Index] = nil + col.Index = 0 + DoneObject(col) +end + +local function add_obj(obj, list) + local col = obj:GetCollection() + if not col then + return + end + local objs = list[col] + if objs then + objs[#objs + 1] = obj + else + list[col] = {obj} + end +end + +local function GatherCollectionsEnum(obj, cols, is_deleted) + local col_idx = GetCollectionIndex(obj) + if col_idx ~= 0 and not is_deleted[obj] then + cols[col_idx] = true + end +end + +function Collection.DestroyEmpty() + local to_delete, is_deleted = {}, {} + local work_done + repeat + local cols = {} + MapForEach(true, "collected", true, GatherCollectionsEnum, cols, is_deleted) + work_done = false + for index, col in pairs(Collections) do + if not is_deleted[col] and not cols[index] then + table.insert(to_delete, col) + is_deleted[col] = true + work_done = true + end + end + until not work_done + + XEditorUndo:BeginOp{ objects = to_delete, name = "Deleted empty collections" } + Msg("EditorCallback", "EditorCallbackDelete", to_delete) + DoneObjects(to_delete) + XEditorUndo:EndOp() +end + +-- cleanup empty collections after every operation +OnMsg.EditorObjectOperationEnding = Collection.DestroyEmpty + +-- returns all collections containing objects and remove the rest if specified +function Collection.GetValid(remove_invalid, min_objs_per_col) + min_objs_per_col = min_objs_per_col or 1 + local colls = {} + local col_to_subs = {} + MapForEach("detached" , "Collection", function(obj) + colls[#colls + 1] = obj + add_obj(obj, col_to_subs) + end) + local col_to_objs = {} + MapForEach("map", "attached", false, "collected", true, "CObject", function(obj) + add_obj(obj, col_to_objs) + end) + local count0 = #colls + while true do + local ready = true + for i = #colls,1,-1 do + local col = colls[i] + local objects = col_to_objs[col] or "" + if #objects == 0 then + local subs = col_to_subs[col] or "" + if #subs < 2 then + ready = false + local parent_idx = GetCollectionIndex(col) or 0 + for j=1,#subs do + subs[j]:SetCollectionIndex(parent_idx) + end + local parent_subs = parent_idx and col_to_subs[parent_idx] + if parent_subs then + table.remove_value(parent_subs, col) + table.iappend(parent_subs, subs) + end + col_to_subs[col] = nil + table.remove(colls, i) + if remove_invalid then + DoneSilent(col) + else + col:SetCollection(false) + end + end + end + end + if ready then + break + end + end + for col, objs in pairs(col_to_objs) do + local subs = col_to_subs[col] or "" + if #subs > 0 then + assert(#objs > 0, "Invalid collection detected") + elseif #objs < min_objs_per_col then + local parent_idx = GetCollectionIndex(col) or 0 + for i=1,#objs do + objs[i]:SetCollectionIndex(parent_idx) + end + table.remove_entry(colls, col) + if remove_invalid then + DoneSilent(col) + else + col:SetCollection(false) + end + end + end + UpdateCollectionsEditor() + return colls, count0 - #colls +end + + +-- remove all nested collections on the map (max_cols is 0 by default, which means remove all collections from the map) +function Collection.RemoveAll(max_cols) + max_cols = max_cols or 0 + local removed = 0 + if max_cols > 0 then + local map = {} + MapForEach("map", "CObject", function(obj) + local levels = 0 + local col = obj:GetCollection() + if not col then + return + end + local new_col = map[col] + if new_col == nil then + local cols = {col} + local col_i = col + while true do + col_i = col_i:GetCollection() + if not col_i then + break + end + cols[#cols + 1] = col_i + assert(#cols < 100) + end + new_col = #cols > max_cols and cols[#cols - max_cols + 1] + map[col] = new_col + end + if new_col then + obj:SetCollection(new_col) + end + end) + for col, new_col in pairs(map) do + if new_col then + DoneSilent(col) + removed = removed + 1 + end + end + MapForEach("detached", "Collection", function(col) + if map[col] == nil then + DoneSilent(col) + removed = removed + 1 + end + end) + else + MapForEach("map", "CObject", function(obj) + obj:SetCollectionIndex(0) + end ) + MapForEach("detached", "Collection", function(col) + DoneSilent(col) + removed = removed + 1 + end) + end + UpdateCollectionsEditor() + return removed +end + +-- remove all contained objects including those in nested collections +function Collection:Destroy(center, radius) + local idx = self.Index + if idx ~= 0 then + SuspendPassEdits(self) + if center and radius then + MapDelete(center, radius, "attached", false, "collection", idx, true) + else + MapDelete("map", "attached", false, "collection", idx, true) + end + for _, col in pairs(Collections) do + if col:GetCollectionRelation(idx) then + DoneSilent(col) + end + end + ResumePassEdits(self) + end + DoneSilent(self) + UpdateCollectionsEditor() +end + +UpdateCollectionsEditor = empty_func \ No newline at end of file diff --git a/CommonLua/Classes/marker.lua b/CommonLua/Classes/marker.lua new file mode 100644 index 0000000000000000000000000000000000000000..ab4cc1cadc1b90a86d3e1fa30b8cab439108f429 --- /dev/null +++ b/CommonLua/Classes/marker.lua @@ -0,0 +1,459 @@ +DefineClass.Marker = { + __parents = { "InitDone" }, + + properties = { + { id = "name", name = "Name", editor = "text", read_only = true, default = false, }, + { id = "type", editor = "text", default = "", read_only = true }, + { id = "map", editor = "text", default = "", read_only = true }, + { id = "handle", editor = "number", default = 0, read_only = true, buttons = {{name = "Teleport", func = "ViewMarker"}}}, + { id = "pos", editor = "point", default = point(-1,-1)}, + { id = "display_name", editor = "text", default = "", translate = true }, + { id = "data", editor = "text", default = "", read_only = true }, + { id = "data_version", editor = "text", default = "", read_only = true }, + }, + StoreAsTable = false, +} + +if FirstLoad or ReloadForDlc then + Markers = {} +end + +function Marker:Register() + local name = self.name + if not name then + return + end + local old = Markers[name] + if old then + if old == self then + return + end + print("Duplicated marker:", name, "\n\t1.", self.map, "at", self.pos, "\n\t2.", old.map, "at", old.pos) + old:delete() + end + + table.insert(Markers, self) + Markers[name] = self +end + +function Marker:__fromluacode(table) + local obj = Container.__fromluacode(self, table) + obj:Register() + return obj +end + +function OnMsg.PostLoad() + table.sort(Markers, function(m1,m2) return CmpLower(m1.name, m2.name) end) +end + +function Marker:GetEditorView() + return self.name +end + +function Marker:Init() + self:Register() +end + +function Marker:Done() + table.remove_value(Markers, self) + Markers[self.name] = nil +end + +function OpenMarkerViewer() + OpenGedApp("GedMarkerViewer", Markers) +end + +function DeleteMapMarkers() + if mapdata.LockMarkerChanges then + print("Marker changes locked!") + return + end + local maps = {} + local maps_list = ListMaps() + for i=1,#maps_list do + maps[maps_list[i]] = true + end + local map = GetMapName() + + for i = #Markers, 1, -1 do + local marker = Markers[i] + if marker.map == map or not maps[marker.map] then + marker:delete() + end + end + ObjModified(Markers) +end + +function RebuildMapMarkers() + local t = GetPreciseTicks() + Msg("MarkersRebuildStart") + DeleteMapMarkers() + local count = MapForEach("map", "MapMarkerObj", nil, nil, const.gofPermanent, function(obj) + obj:CreateMarker() + end) + Msg("MarkersRebuildEnd") + table.sort(Markers, function(m1,m2) return CmpLower(m1.name, m2.name) end) + ObjModified(Markers) + if mapdata.LockMarkerChanges then + print("Marker changes locked!") + return + end + + local map_name = GetMapName() + local markers = {} + for _, marker in ipairs(Markers) do + if marker.map == map_name then + table.insert(markers, marker) + end + end + mapdata.markers = markers + Msg("MarkersChanged") + DebugPrint(string.format("%d map markers rebuilt in %d ms\n", count, GetPreciseTicks() - t)) +end + +OnMsg.SaveMap = RebuildMapMarkers + +DefineClass.MarkerBase = { + __parents = { "Object", "EditorObject", "EditorCallbackObject" }, + flags = { efMarker = true }, +} + +DefineClass.MapMarkerObj = { + __parents = { "MarkerBase", "MinimapObject", "EditorTextObject" }, + properties = { + { id = "MarkerName", category = "Gameplay", editor = "text", default = "", important = true, }, + { id = "MarkerDisplayName", category = "Gameplay", editor = "text", default = "", translate = true, important = true }, + }, + marker_type = "", + marker_name = false, + editor_text_member = "MarkerName", +} + +function MapMarkerObj:SetMarkerName(value) + self.MarkerName = value + self.marker_name = value ~= "" and GetMapName() .. " - " .. value or value +end + +function MapMarkerObj:VisibleOnMinimap() + return self.MarkerName ~= "" +end + +function MapMarkerObj:SetMinimapRollover() + self.minimap_rollover = nil -- resetting to the default value +end + +if Platform.developer then + +function MapMarkerObj:CreateMarker() + if mapdata.LockMarkerChanges then + print("Marker changes locked!") + return + end + local marker_name = self.marker_name + if (marker_name or "") == "" then return end + return Marker:new{ + name = marker_name, + type = self.marker_type, + map = GetMapName(), + handle = self.handle, + pos = self:GetVisualPos(), + display_name = self.MarkerDisplayName, + } +end + +end -- Platform.developer + +function ViewMarker(root, marker, prop_id, ged) + local map = marker.map + local handle = marker.handle + EditorWaitViewMapObjectByHandle(marker.handle, marker.map, ged) +end + +function ViewMarkerProp(editor_obj, obj, prop_id) + local name = obj[prop_id] + local marker = Markers[name] + if marker then + ViewMarker(editor_obj, marker) + end +end + +DefineClass.PosMarkerObj = { + __parents = { "MapMarkerObj", "EditorVisibleObject", "StripCObjectProperties" }, + entity = "WayPoint", + marker_type = "pos", + flags = { efCollision = false, efApplyToGrids = false, }, +} + +---- + +DefineClass.EditorMarker = { + __parents = { "MarkerBase", "EditorVisibleObject", "EditorTextObject", "EditorColorObject", }, + + properties = { + { id = "DetailClass", name = "Detail Class", editor = "dropdownlist", default = "Default", + items = {{text = "Default", value = 0}}, no_edit = true + }, + }, + + flags = { efWalkable = false, efCollision = false, efApplyToGrids = false }, + entity = "WayPoint", + editor_text_offset = point(0, 0, 13*guim), +} + +---- + +DefineClass.RadiusMarker = { + __parents = { "EditorMarker", "EditorSelectedObject" }, + editor_text_color = RGB(50, 50, 100), + editor_color = RGB(150, 150, 0), + radius_mesh = false, + radius_prop = false, + show_radius_on_select = false, +} + +function RadiusMarker:EditorSelect(selected) + if self.show_radius_on_select then + self:ShowRadius(selected) + end +end + +function RadiusMarker:GetMeshRadius() + return self.radius_prop and self[self.radius_prop] +end + +function RadiusMarker:GetMeshColor() + return self.editor_color +end + +function RadiusMarker:UpdateMeshRadius(radius) + if IsValid(self.radius_mesh) then + local scale = self:GetScale() + radius = radius or self:GetMeshRadius() or guim + radius = MulDivRound(radius, 100, scale) + self.radius_mesh:SetScale(MulDivRound(radius, 100, 10*guim)) + end +end + +function RadiusMarker:ShowRadius(show) + local radius = show and self:GetMeshRadius() + if not radius then + DoneObject(self.radius_mesh) + self.radius_mesh = nil + return + end + if not IsValid(self.radius_mesh) then + local radius_mesh = CreateCircleMesh(10*guim, self:GetMeshColor(), point30) + self.radius_mesh = radius_mesh + self:Attach(radius_mesh) + end + self:UpdateMeshRadius(radius) +end + +function RadiusMarker:EditorEnter(...) + if not self.show_radius_on_select or editor.IsSelected(self) then + self:ShowRadius(true) + end +end + +function RadiusMarker:EditorExit(...) + self:ShowRadius(false) +end + +function RadiusMarker:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == self.radius_prop then + self:UpdateMeshRadius() + end + EditorMarker.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +---- + +DefineClass.EnumMarker = { + __parents = { "RadiusMarker", "EditorTextObject" }, + properties = { + { category = "Enum", id = "EnumClass", name = "Class", editor = "text", default = false, help = "Accept children from the given class only" }, + { category = "Enum", id = "EnumCollection", name = "Collection", editor = "bool", default = false, help = "Use the marker's collection to filter children" }, + { category = "Enum", id = "EnumRadius", name = "Radius", editor = "number", default = 64*guim, scale = "m", min = 0, max = function(self) return self.EnumRadiusMax end, slider = true, help = "Max children distance" }, + { category = "Enum", id = "EnumInfo", name = "Objects", editor = "prop_table", default = false, read_only = true, dont_save = true, lines = 3, indent = "" }, + }, + editor_text_color = white, + editor_color = white, + radius_prop = "EnumRadius", + children_highlight = false, + EnumRadiusMax = 64*guim, +} + +function EnumMarker:GetEnumInfo() + local class_to_count = {} + for _, obj in ipairs(self:GatherObjects()) do + if IsValid(obj) then + class_to_count[obj.class] = (class_to_count[obj.class]or 0) + 1 + end + end + return class_to_count +end + +function EnumMarker:HightlightObjects(enable) + local prev_highlight = self.children_highlight + if not enable and not prev_highlight then + return + end + for _, obj in ipairs(prev_highlight) do + if IsValid(obj) then + ClearColorModifierReason(obj, "EnumMarker") + end + end + self.children_highlight = enable and self:GatherObjects() or nil + for _, obj in ipairs(self.children_highlight) do + SetColorModifierReason(obj, "EnumMarker", white) + end + return prev_highlight +end + +function EnumMarker:EditorSelect(selected) + if IsValid(self) then + self:HightlightObjects(selected) + end + return RadiusMarker.EditorSelect(self, selected) +end + +function EnumMarker:GatherObjects(radius) + radius = radius or self.EnumRadius + local collection = self:GetCollectionIndex() + if not self.EnumCollection then + return MapGet(self, radius, "attached", false, self.EnumClass or nil) + elseif collection == 0 then + return MapGet(self, radius, "attached", false, "collected", false, self.EnumClass or nil) + else + return MapGet(self, radius, "attached", false, "collection", collection, self.EnumClass or nil) + end +end + +function EnumMarker:GetError() + if self.EnumRadius ~= self.EnumRadiusMax then + local t1 = self:GatherObjects() or "" + local t2 = self:GatherObjects(self.EnumRadiusMax) or "" + if #t1 ~= #t2 then + return "Not all collection objects are inside the enum radius!" + end + end +end + +function EnumMarker.UpdateAll() + local st = GetPreciseTicks() + MapForEach("map", "EnumMarker", function(obj) obj:GatherObjects() end) + DebugPrint("Container markers updated in", GetPreciseTicks() - st, "ms") +end +OnMsg.PreSaveMap = EnumMarker.UpdateAll + +---- + +MapVar("ForcedImpassableMarkers", false) + +DefineClass.ForcedImpassableMarker = { + __parents = { "EditorMarker", "EditorSelectedObject", "EditorCallbackObject" }, + properties = { + { category = "Area", id = "SizeX", editor = "number", default = 0, scale = "m", min = 0 }, + { category = "Area", id = "SizeY", editor = "number", default = 0, scale = "m", min = 0 }, + { category = "Area", id = "SizeZ", editor = "number", default = 0, scale = "m", min = 0 }, + }, + editor_color = RGB(255, 0, 0), + editor_text_color = white, + mesh_obj = false, + area = false, +} + +function ForcedImpassableMarker:Init() + if not ForcedImpassableMarkers then + ForcedImpassableMarkers = {} + end + self:SetColorModifier(self.editor_color) + table.insert(ForcedImpassableMarkers, self) +end + +function ForcedImpassableMarker:EditorGetText() + return self.class +end + +function ForcedImpassableMarker:Done() + table.remove_value(ForcedImpassableMarkers, self) + if self.area then + RebuildGrids(self.area) + end +end + +function ForcedImpassableMarker:GetArea() + if not self.area then + if self:IsValidPos() then + local posx, posy, posz = self:GetVisualPosXYZ() + local sizex, sizey, sizez = self.SizeX, self.SizeY, self.SizeZ + if sizez > 0 then + -- clear terrain and slab passability inside the box + self.area = box( + posx - sizex/2, + posy - sizey/2, + posz - sizez/2, + posx + sizex/2 + 1, + posy + sizey/2 + 1, + posz + sizez/2 + 1) + else + -- clear terrain only passability inside the 2D box + self.area = box( + posx - sizex/2, + posy - sizey/2, + posx + sizex/2 + 1, + posy + sizey/2 + 1) + end + else + self.area = box() + end + end + return self.area +end + +function OnMsg.OnPassabilityRebuilding(clip) + for i, marker in ipairs(ForcedImpassableMarkers) do + local bx = marker:GetArea() + if clip:Intersect2D(bx) ~= const.irOutside then + terrain.ClearPassabilityBox(bx) + end + end +end + +function ForcedImpassableMarker:ShowArea(show) + DoneObject(self.mesh_obj) + self.mesh_obj = nil + if show then + self.mesh_obj = PlaceBox(self:GetArea(), self.editor_color, false, "depth test") + self:Attach(self.mesh_obj) + end +end + +function ForcedImpassableMarker:EditorSelect(selected) + self:ShowArea(selected) +end + +function ForcedImpassableMarker:RebuildArea() + SuspendPassEdits("ForcedImpassableMarker") + if self.area then + RebuildGrids(self.area) + self.area = false + end + RebuildGrids(self:GetArea()) + ResumePassEdits("ForcedImpassableMarker") + if self.mesh_obj then + self:ShowArea(true) + end +end + +function ForcedImpassableMarker:EditorCallbackPlace() + self:RebuildArea() +end + +function ForcedImpassableMarker:EditorCallbackMove() + self:RebuildArea() +end + +function ForcedImpassableMarker:OnEditorSetProperty(prop_id, old_value, ged) + self:RebuildArea() +end diff --git a/CommonLua/Classes/particles.lua b/CommonLua/Classes/particles.lua new file mode 100644 index 0000000000000000000000000000000000000000..2fd16001f5b44c5b8c0b33647aba43656821c515 --- /dev/null +++ b/CommonLua/Classes/particles.lua @@ -0,0 +1,412 @@ +function TestParticle(editor, obj, prop) + obj:SetPath(obj.par_name) +end + +function GetParticleSystemList() + local list = {} + for key, item in GetParticleSystemIterator() do + local item_name = item:GetId() + table.insert(list, item) + assert(not list[item_name]) + list[item_name] = item + end + return list +end + +function GetParticleSystemNameList(ui) + ui = ui or false + local list = {} + for key, item in GetParticleSystemIterator() do + local item_name = item:GetId() + if item.ui == ui then + table.insert(list, item_name) + end + end + return list +end + +function GetParticleSystemIterator() + return pairs(ParticleSystemPresets) +end + +function GetParticleSystem(name) + local preset = ParticleSystemPresets[name] + if preset then + return preset + end +end + +function GetParticleSystemNameListFromDisk() + local list = {} + for _, folder in ipairs(ParticleDirectories()) do + for idx, preset in ipairs(io.listfiles(folder, "*.bin")) do + table.insert(list, preset) + end + end + return list +end + +function ParticleDirectories() + local dirs = { "Data/ParticleSystemPreset" } + for _, folder in ipairs(DlcFolders or empty_table) do + local dir = folder .. "/Presets/ParticleSystemPreset" + if io.exists(dir) then + table.insert(dirs, dir) + end + end + return dirs +end + +-- Convenience function to be called from C +function GetParticleSystemForReloading(name) + return { GetParticleSystem(name) } +end + +function EditParticleSystem(name) + local sys = GetParticleSystem(name) + if IsKindOf(sys, "ParticleSystemPreset") then + sys:OpenEditor() + end +end + +DefineClass.ParSystemBase = +{ + __parents = { "CObject" }, + flags = { cfParticles = true, cfConstructible = false, efSelectable = false, efWalkable = false, efCollision = false, efApplyToGrids = false, efShadow = false, cofComponentParticles = true }, + entity = "", + dynamic_params = false, + + polyline = false, + + properties = { + { id = "ParticlesName", name = "Particles", editor = "preset_id", default = "", preset_class = "ParticleSystemPreset", autoattach_prop = true, buttons = {{name = "Apply", func = "ParSystemNameApply"}}}, + } +} + +function ParSystemBase:GetId() + return IsValid(self) and self:GetParticlesName() or "" +end + +for name, method in pairs(ComponentParticles) do + ParSystemBase[name] = method +end + +DefineClass.ParSystem = +{ + __parents = { "InvisibleObject", "ComponentAttach", "ParSystemBase", "EditorCallbackObject" }, + HelperEntity = "ParticlePlaceholder", + HelperScale = 10, + HelperCursor = true, +} + +function ParSystem:EditorGetText() + return self:GetParticlesName() +end + +local function RecreateParticle(par) + par:DestroyRenderObj() +end + +local function ParSystemPlayFX(par, no_delay) + if no_delay then + RecreateParticle(par) + else + if EditorSettings:GetTestParticlesOnChange() then + DelayedCall(500, RecreateParticle, par) + end + end +end + +ParSystem.EditorCallbackMove = ParSystemPlayFX +ParSystem.EditorCallbackRotate = ParSystemPlayFX +ParSystem.EditorCallbackMoveScale = ParSystemPlayFX + +function OnMsg.EditorSelectionChanged(objects) + for _, obj in ipairs(objects or empty_tample) do + if IsKindOf(obj, "ParSystem") then + ParSystemPlayFX(obj) + return + end + end +end + +function RecreateSelectedParticle(no_delay) + local selection = editor.GetSel() + for _, sel_obj in ipairs(selection) do + if IsKindOf(sel_obj, "ParSystem") then + ParSystemPlayFX(sel_obj, no_delay) + return + end + end +end + +DefineClass.ParSystemUI = +{ + __parents = { "ParSystemBase" }, + flags = { gofUILObject = true }, +} + +function ParSystemNameApply(editor, obj) + if obj:GetGameFlags(const.gofRealTimeAnim) == const.gofRealTimeAnim then + obj:SetCreatonTime(RealTime()) + else + obj:SetCreatonTime(GameTime()) + end + obj:DestroyRenderObj() + obj:ApplyDynamicParams() +end + +function ParSystem:ShouldBeGameTime() + local name = self:GetParticlesName() + if not name then return false end + if name == "" then return false end + local flags = ParticlesGetBehaviorFlags(name, -1) + if not flags then return false end + return flags.gametime and true +end + +function ParSystem:PostLoad() + self:ApplyDynamicParams() +end + +-- function OnMsg.GatherFXActors(list) +-- local t = GetParticleSystemNames() +-- local cnt = #list +-- for i = 1, #t do +-- list[cnt + i] = "Particles: " .. t[i] +-- end +-- end + +if FirstLoad then + g_DynamicParamsDefs = {} +end +function OnMsg.DoneMap() + g_DynamicParamsDefs = {} +end +function ParGetDynamicParams(name) + local defs = g_DynamicParamsDefs + local def = defs[name] + if not def then + def = ParticlesGetDynamicParams(name) or empty_table + defs[name] = def + end + return def +end + +if config.ParticleDynamicParams then + +function ParSystem:ApplyDynamicParams() + local proto = self:GetParticlesName() + local dynamic_params = ParGetDynamicParams(proto) + if not next(dynamic_params) then + self.dynamic_params = nil + return + end + self.dynamic_params = dynamic_params + local set_value = self.SetParamDef + for k, v in pairs(dynamic_params) do + set_value(self, v, v.default_value) + end +end + +function ParSystem:SetParam(param, value) + local dynamic_params = self.dynamic_params + local def = dynamic_params and rawget(dynamic_params, param) + if def then + self:SetParamDef(def, value) + end +end + +function ParSystem:SetParamDef(def, value) + local ptype = def.type + if ptype == "number" or ptype == "color" then + self:SetDynamicData(def.index, value) + elseif ptype == "point" then + local x, y, z = value:xyz() + local idx = def.index + self:SetDynamicData(idx, x) + self:SetDynamicData(idx + 1, y) + self:SetDynamicData(idx + 2, z or 0) + elseif ptype == "bool" then + self:SetDynamicData(def.index, value and 1 or 0) + end +end + +function ParSystem:GetParam(param, value) + local dynamic_params = self.dynamic_params + local p = dynamic_params and rawget(dynamic_params, param) + if p then + local ptype = p.type + if ptype == "number" or ptype == "color" then + return self:GetDynamicData(p.index) + elseif ptype == "point" then + local idx = p.index + return point( + self:GetDynamicData(idx), + self:GetDynamicData(idx + 1), + self:GetDynamicData(idx + 2)) + elseif ptype == "bool" then + return self:GetDynamicData(p.index) ~= 0 + end + end +end + +else -- config.ParticleDynamicParams + +ParSystem.ApplyDynamicParams = empty_func +ParSystem.SetParam = empty_func +ParSystem.GetParam = empty_func +ParSystem.SetParamDef = empty_func + +end -- config.ParticleDynamicParams + +function ParSystem:SetPolyline(polyline, parent) + local count = #polyline + assert(count <= 4) + if count <= 4 then + -- the last point is copied from the first in C-side so we skip it here + for i = 1, count - 1 do + local v1, v2 = polyline[i]:xy() + self:SetDynamicData(i, v1) + self:SetDynamicData(i+1, v2) + end + self:SetDynamicData(0, count) + end +end + +function OnMsg.LoadGame() + local empty = pstr("") + MapForEach(true, "ParSystem", function(o) + if o["polyline"] then + o.polyline = o.polyline or empty + o.polyline:SetPolyline(0) + end + end ) +end + +function PlaceParticles(name, class, components) + if type(name) ~= "string" or name == "" then + assert(false , "Particle name is missing") + return + end + local o = PlaceObject(class or "ParSystem", nil, components) + if not o then + assert(false, "Particle class missing: " .. (class or "ParSystem")) + return + end + if not o:SetParticlesName(name) then + assert(false, "No such particle name: " .. name) + DoneObject(o) + return + end + o:ApplyDynamicParams() + return o +end + +local function WaitClearParticle(obj, max_timeout) + local kill_time = now() + (max_timeout or 10000) + while IsValid(obj) and obj:HasParticles() and now() - kill_time < 0 do + Sleep(1000) + end + DoneObject(obj) +end + +-- gracefully stop a particle system +function StopParticles(obj, wait, max_timeout) + if not IsValid(obj) then + return + end + if obj:IsParticleSystemVanishing() or not obj:HasParticles() then + DoneObject(obj) + return + end + obj:StopEmitters() + if wait then + WaitClearParticle(obj, max_timeout) + else + if obj:GetGameFlags(const.gofRealTimeAnim) == const.gofRealTimeAnim then + CreateMapRealTimeThread(WaitClearParticle, obj, max_timeout) + else + CreateGameTimeThread(WaitClearParticle, obj, max_timeout) + end + end +end + +function StopMultipleParticles(objs, max_timeout) + if type(objs) ~= "table" or #objs == 0 then + return + end + -- This can be used only from a RTT, because HasParticles touches the renderer + CreateMapRealTimeThread(function(objs, max_timeout) + local kill_time = now() + (max_timeout or 10000) + for i=1,#objs do + local obj = objs[i] + if IsValid(obj) then + if obj:IsParticleSystemVanishing() or not obj:HasParticles() then + DoneObject(obj) + else + obj:StopEmitters() + end + end + end + while true do + local has_time = now() - kill_time < 0 + local loop + for i=1,#objs do + local obj = objs[i] + if IsValid(obj) then + if has_time and obj:HasParticles() then + loop = true + break + else + DoneObject(obj) + end + end + end + if not loop then + break + end + Sleep(1000) + end + end, objs, max_timeout) +end + +function PlaceParticlesOnce( particles, pos, angle, axis ) + local o = PlaceParticles( particles ) + if axis then + o:SetAxis( axis ) + end + if angle then + o:SetAngle( angle ) + end + if pos then + o:SetPos( pos ) + end + CreateMapRealTimeThread(function(o) + Sleep(2000) -- wait all emitters to stop emitting + StopParticles(o, true) + end, o) + return o +end + +function GetAttachParticle(obj, name) + for i = 1, obj:GetNumAttaches() do + local o = obj:GetAttach(i) + if o:IsKindOf("ParSystem") and o:GetProperty("ParticlesName") == name then + return o + end + end +end + +function GetParticleAttaches(obj, name) + local attaches = obj:GetNumAttaches() + local list = {} + for i = 1, attaches do + local o = obj:GetAttach(i) + if IsKindOf(o, "ParSystem") and o:GetProperty("ParticlesName") == name then + table.insert(list, o) + end + end + + return list +end diff --git a/CommonLua/CollapseMaterials.lua b/CommonLua/CollapseMaterials.lua new file mode 100644 index 0000000000000000000000000000000000000000..88fc258ac199c8117b653530368308aaa1151861 --- /dev/null +++ b/CommonLua/CollapseMaterials.lua @@ -0,0 +1,199 @@ +EngineBinAssetsPrints = {} +EngineBinAssetsPrint = CreatePrint { "", output = function(s) table.insert(EngineBinAssetsPrints, s) end } +EngineBinAssetsPrintf = CreatePrint { "", output = function(s) table.insert(EngineBinAssetsPrints, s) end, format = string.format } +EngineBinAssetsError = CreatePrint { "EB_ERROR", output = function(s) table.insert(EngineBinAssetsPrints, s) end } +EngineBinAssetsErrorf = CreatePrint { "EB_ERROR", output = function(s) table.insert(EngineBinAssetsPrints, s) end, format = string.format } + +local function MarkTestEntityTextures(texture_list) + local tex_map = {} + local test_assets_path = ConvertToOSPath("svnAssets/Bin/Common/Materials/") + for texture_id, path in pairs(texture_list) do + local texture_name = path:sub(path:find("[^\\]*$")) + tex_map[texture_name] = { + id = texture_id, + path = path, + skip = string.starts_with(path, test_assets_path), + } + end + return tex_map; +end + +local function AddTextureHashes(textures_data, cached_hashes) + local cached_hashes = cached_hashes or {} + for texture_id, texture_data in pairs(textures_data) do + local tex_hash = cached_hashes[texture_id] + if not tex_hash then + local err, hash = AsyncFileToString(texture_data.path, nil, nil, "hash") + if err then + EngineBinAssetsError("AsyncFileToString(" .. texture_data.path .. ") failed: " .. err) + return err + else + tex_hash = tostring(hash) + end + end + texture_data.hash = tex_hash + end +end + +local function GetTexturesCachedHashes() + local err, hash_list = FileToLuaValue("svnAssets/BuildCache/TextureHashes.lua") + if err then + EngineBinAssetsError("Could not load hash map with material textures!") + end + + local cached_hashes = {} + for filename, v in pairs(hash_list or empty_table) do + cached_hashes[filename:sub(filename:find("[^/]*$"))] = tostring(v.hash) + end + + return cached_hashes +end + +function MarkUniqueTextures(textures_data) + local sorted_texture_ids = table.keys(textures_data) + table.sort(sorted_texture_ids, function (a, b) return #a == #b and a < b or #a < #b end) + local unique_hashes = {} + for _, texture_id in ipairs(sorted_texture_ids) do + local tex_data = textures_data[texture_id] + if not tex_data.skip then + local tex_alias = unique_hashes[tex_data.hash] + + if tex_alias then + tex_data.alias = tex_alias + elseif not tex_data.hash then + EngineBinAssetsError("Missing texture hash for(" .. texture_id .. ")") + else + --tex_data.alias = texture_id -- not needed it its the same + unique_hashes[tex_data.hash] = texture_id + end + end + end + local num_tex = table.count(textures_data) + local num_unique = table.count(unique_hashes) + EngineBinAssetsPrintf("Entity textures: %d (%d Unique, %d Duplicates)", num_tex, num_unique, num_tex - num_unique) + return unique_hashes +end + +local function RemapMaterialTextures(mat_name, textures_data, used_tex, ent_textures) + local num_sub_mats = GetNumSubMaterials(mat_name) + for subi=1, num_sub_mats do + local props = GetMaterialProperties(mat_name, subi-1) + local new_props = false + for prop, value in sorted_pairs(props) do + if type(value) == "string" then + local tex_name = value:sub(value:find("[^/]*$")) + if tex_name then + local tex_data = textures_data[tex_name] + if tex_data then + if tex_data.alias then + new_props = new_props or {} + new_props[prop] = textures_data[tex_data.alias].id + + ent_textures[tex_data.alias] = true + + local alias_data = textures_data[tex_data.alias] + alias_data.used_in = alias_data.used_in or {} + table.insert(alias_data.used_in, mat_name) + else + used_tex[tex_name] = tex_data.path + ent_textures[tex_name] = true + + tex_data.used_in = tex_data.used_in or {} + table.insert(tex_data.used_in, mat_name) + end + end + elseif value:find("", 1, "plain") then + EngineBinAssetsPrintf("once", "Missing texture Textures/%s in material %s", value:match("Textures/(.*)%."), mat_name) + end + end + end + if new_props then + SetMaterialProperties(mat_name, subi-1, new_props) + end + end +end + +local function BuildTextureIdx(used_tex) + local tex_idx = {} + local textures = table.keys(used_tex) + table.sort(textures, function (a, b) return #a == #b and a < b or #a < #b end) + + for _, tex_name in ipairs(textures) do + tex_idx[#tex_idx+1] = string.format("Textures/%s=%s", tex_name, used_tex[tex_name]) + end + + local filename = "entities.txt" + local path = "svnAssets/BuildCache/win32/TextureIdx/" + local err = AsyncCreatePath(path) + assert(not err, "Error creating textures idx path: " .. tostring(err)) + local err = StringToFileIfDifferent(path .. filename, table.concat(tex_idx, "\r\n")) + assert(not err, "Error writing textures idx: " .. tostring(err)) +end + +local function ShortenTexturePaths(textures_data) + for _, texture_data in pairs(textures_data) do + texture_data.path = string.match(texture_data.path, "\\([^\\]+Assets.+)$") + end +end + +function CollapseMaterialTextures(texture_list, entities, cached_hashes, shorten_paths) + local textures_data = MarkTestEntityTextures(texture_list) + AddTextureHashes(textures_data, cached_hashes) + if shorten_paths then + ShortenTexturePaths(textures_data) + end + MarkUniqueTextures(textures_data) + + local materials_seen, entity_textures, used_tex = {}, {}, {} + for entity, _ in sorted_pairs(entities) do + if entity:sub(1,1) ~= "#" then + local ent_textures = {} + local states = GetStates(entity) + for si=1, #states do + local state = GetStateIdx(states[si]) + local num_lods = GetStateLODCount(entity, state) + for li=1, num_lods do + local material = GetStateMaterial(entity, state, li - 1) + if not materials_seen[material] then + RemapMaterialTextures(material, textures_data, used_tex, ent_textures) + materials_seen[material] = entity + entity_textures[entity] = ent_textures + else + local other_entity = materials_seen[material] + entity_textures[entity] = entity_textures[other_entity] + end + end + end + end + end + + return materials_seen, used_tex, textures_data +end + +function CollapseAllTextures() + local all_entities = GetAllEntities() + local texture_list = CollectMtlReferencedTextures() + local cached_hashes = GetTexturesCachedHashes() + + local materials_seen, used_tex, textures_data = CollapseMaterialTextures(texture_list, all_entities, cached_hashes, true) + + BuildTextureIdx(used_tex) + + return materials_seen, used_tex, textures_data +end + +function CollapseEntitiesTextures(entities) + local texture_list = {} + for entity, _ in sorted_pairs(entities) do + local states = GetStates(entity) + for si=1, #states do + local state = GetStateIdx(states[si]) + local state_textures = GetStateMaterialTextures(entity, state) + table.append(texture_list, state_textures) + end + end + + local materials_seen, used_tex, textures_data = CollapseMaterialTextures(texture_list, entities) + + return materials_seen, used_tex, textures_data +end \ No newline at end of file diff --git a/CommonLua/Connectivity.lua b/CommonLua/Connectivity.lua new file mode 100644 index 0000000000000000000000000000000000000000..106a136c33f5334ba7d0c33d1018b6de9610ed8f --- /dev/null +++ b/CommonLua/Connectivity.lua @@ -0,0 +1,78 @@ +if not const.ConnectivitySupported then + return +end + +OnMsg.PostNewMapLoaded = ConnectivityResume +OnMsg.PostLoadGame = ConnectivityResume + +if Platform.asserts then + +function TestConnectivity(unit, target, count) + count = count or 1 + unit = unit or SelectedObj + target = target or terrain.FindPassable(GetCursorPos()) + DbgClear() + if not IsKindOf(unit, "Movable") then return end + target = target or MapFindNearest(unit, "map", unit.class, function(obj) return obj ~= unit end) + if not target then return end + ConnectivityClear() -- test the uncached connectivity speed, as the cached one is practically zero. + local stA = GetPreciseTicks(1000000) + local pathA = ConnectivityCheck(unit, target) or false + local timeA = GetPreciseTicks(1000000) - stA + local stB = GetPreciseTicks(1000000) + local pfclass, range, min_range, path_owner, restrict_area_radius, restrict_area + local path_flags = const.pfmImpassableSource + local pathB = pf.HasPosPath(unit, target, pfclass, range, min_range, path_owner, restrict_area_radius, restrict_area, path_flags) or false + local timeB = GetPreciseTicks(1000000) - stB + DbgAddSegment(unit, target) + print("1 | path:", pathA, "| time:", timeA / 1000.0, "| ConnectivityCheck") + print("2 | path:", pathB, "| time:", timeB / 1000.0, "| pf.HasPosPath") + print("Linear dist 2D:", unit:GetDist2D(target)) +end + +function TestConnectivityShowPatch(pos) + hr.DbgAutoClearLimit = Max(20000, hr.DbgAutoClearLimit) + hr.DbgAutoClearLimitTexts = Max(10000, hr.DbgAutoClearLimitTexts) + pos = pos or SelectedObj or GetCursorPos() + local pfclass = 0 + if IsKindOf(pos, "Movable") then + pfclass = pos:GetPfClass() + end + pos = terrain.FindPassable(pos) + print(ValueToStr(ConnectivityPatchInfo(ConnectivityGameToPatch(pos), pfclass))) +end + +function TestConnectivityRecalcPatch(pos) + pos = pos or SelectedObj or GetCursorPos() + local grid = 0 + if IsKindOf(pos, "Movable") then + grid = table.get(pos:GetPfClassData(), "pass_grid") or 0 + end + pos = terrain.FindPassable(pos) + ConnectivityRecalcPatch(ConnectivityGameToPatch(pos, grid)) +end + +function TestConnectivityPerformance(pos) + pos = terrain.FindPassable(pos or SelectedObj or GetCursorPos()) + local minx, miny, maxx, maxy = GetPlayBox(guim):xyxy() + local seed = 0 + local count = 100000 + local x, y + local target = point() + SuspendThreadDebugHook(1) + local st = GetPreciseTicks(1000000) + for i=1,count do + x, seed = BraidRandom(seed, minx, maxx - 1) + y, seed = BraidRandom(seed, miny, maxy - 1) + if terrain.IsPassable(x, y) then + target:InplaceSet(x, y) + ConnectivityClear() + ConnectivityCheck(pos, target) + end + end + print("Avg Time:", (GetPreciseTicks(1000000) - st) / (1000.0 * count)) + print("Stats:", ConnectivityStats()) + ResumeThreadDebugHook(1) +end + +end -- Platform.asserts \ No newline at end of file diff --git a/CommonLua/Cooldown.lua b/CommonLua/Cooldown.lua new file mode 100644 index 0000000000000000000000000000000000000000..bb9c729dbe7e2152c778763b9bd9865497b82bc2 --- /dev/null +++ b/CommonLua/Cooldown.lua @@ -0,0 +1,285 @@ +DefineClass.CooldownDef = { + __parents = { "Preset", }, + properties = { + { category = "General", id = "DisplayName", name = "Display Name", + editor = "text", default = false, translate = true, }, + { category = "General", id = "TimeScale", name = "Time Scale", + editor = "choice", default = "sec", items = function (self) return GetTimeScalesCombo() end, }, + { category = "General", id = "TimeMin", name = "Default min", help = "Defaut cooldown time.", + editor = "number", default = 1000, + scale = function(obj) return obj.TimeScale end, }, + { category = "General", id = "TimeMax", name = "Default max", + editor = "number", default = false, + scale = function(obj) return obj.TimeScale end, }, + { category = "General", id = "MaxTime", name = "Max time", help = "The maximum time the cooldown can accumulate to.", + editor = "number", default = false, + scale = function(obj) return obj.TimeScale end, }, + { category = "General", id = "ExpireMsg", name = "Send CooldownExpired message", + editor = "bool", default = false }, + { category = "General", id = "OnExpire", name = "OnExpire", + editor = "script", default = false, params = "cooldown_obj, cooldown_def" }, + }, + GlobalMap = "CooldownDefs", + EditorMenubarName = "Cooldowns", + EditorMenubar = "Editors.Lists", + EditorIcon = "CommonAssets/UI/Icons/cooldown.png", +} + +DefineClass.CooldownObj = { + __parents = { "InitDone" }, + cooldowns = false, + cooldowns_thread = false, +} + +function CooldownObj:Init() + self.cooldowns = {} +end + +function CooldownObj:Done() + self.cooldowns = nil + DeleteThread(self.cooldowns_thread) + self.cooldowns_thread = nil +end + +function CooldownObj:GetCooldown(cooldown_id) + local cooldowns = self.cooldowns + local time = cooldowns and cooldowns[cooldown_id] + if not time or time == true then return time end + time = time - GameTime() + if time >= 0 then + return time + end + cooldowns[cooldown_id] = nil +end + +function CooldownObj:GetCooldowns() + for id in pairs(self.cooldowns) do + self:GetCooldown(id) + end + return self.cooldowns +end + +function CooldownObj:OnCooldownExpire(cooldown_id) + local def = CooldownDefs[cooldown_id] + assert(def) + if def.ExpireMsg then + Msg("CooldownExpired", self, cooldown_id, def) + end + local OnExpire = def.OnExpire + if OnExpire then + return OnExpire(self, def) + end +end + +function CooldownObj:DefaultCooldownTime(cooldown_id, def) + def = def or CooldownDefs[cooldown_id] + local min, max = def.TimeMin, def.TimeMax + if not max or min > max then + return min + end + return InteractionRandRange(min, max, cooldown_id) +end + +function CooldownObj:SetCooldown(cooldown_id, time, max) + local cooldowns = self.cooldowns + if not cooldowns then return end + local def = CooldownDefs[cooldown_id] + assert(def) + if not def then return end + time = time or self:DefaultCooldownTime(cooldown_id, def) + local prev_time = cooldowns[cooldown_id] + local now = GameTime() + if time == true then + cooldowns[cooldown_id] = true + else + if max then + if prev_time == true or prev_time and prev_time - now >= time then + return + end + end + time = Min(time, def.MaxTime) + cooldowns[cooldown_id] = now + time + if def.OnExpire or def.ExpireMsg then + if IsValidThread(self.cooldowns_thread) then + Wakeup(self.cooldowns_thread) + else + self.cooldowns_thread = CreateGameTimeThread(function(self) + while self:UpdateCooldowns() do end + end, self) + end + end + end + if not prev_time or prev_time ~= true and prev_time - now < 0 then + Msg("CooldownSet", self, cooldown_id, def) + end +end + +function CooldownObj:ModifyCooldown(cooldown_id, delta_time) + local cooldowns = self.cooldowns + if not cooldowns or (delta_time or 0) == 0 then return end + local def = CooldownDefs[cooldown_id] + assert(def) + local time = cooldowns[cooldown_id] + if not time or time == true then + return + end + local now = GameTime() + if time - now < 0 then + assert(not (def.OnExpire or def.ExpireMsg)) -- messages with expiration effects should be removed by now + cooldowns[cooldown_id] = nil + return + end + cooldowns[cooldown_id] = now + Min(time + delta_time - now, def.MaxTime) + if delta_time < 0 and (def.OnExpire or def.ExpireMsg) then + Wakeup(self.cooldowns_thread) + end + return true +end + +function CooldownObj:ModifyCooldowns(delta_time, filter) + local cooldowns = self.cooldowns + if not cooldowns or (delta_time or 0) == 0 then + return + end + if delta_time <= 0 then + Wakeup(self.cooldowns_thread) + end + local now = GameTime() + for cooldown_id, time in sorted_pairs(cooldowns) do + if time ~= true and time - now >= 0 or (not filter or filter(cooldown_id, time)) then + cooldowns[id] = now + Min(time + delta_time - now, def.MaxTime) + end + end +end + +function CooldownObj:RemoveCooldown(cooldown_id) + local cooldowns = self.cooldowns + if not cooldowns then return end + local def = CooldownDefs[cooldown_id] + assert(def) + local time = cooldowns[cooldown_id] + if time then + cooldowns[cooldown_id] = nil + if time == true or time - GameTime() >= 0 then + self:OnCooldownExpire(cooldown_id) + end + end +end + +function CooldownObj:RemoveCooldowns(filter) + local cooldowns = self.cooldowns + if not cooldowns then return end + local removed + local now = GameTime() + for cooldown_id, time in sorted_pairs(cooldowns) do + if not filter or filter(cooldown_id) then + cooldowns[id] = nil + if time == true or time - now >= 0 then + removed = removed or {} + removed[#removed + 1] = id + end + end + end + for _, id in ipairs(removed) do + self:OnCooldownExpire(id) + end +end + +function CooldownObj:UpdateCooldowns() + local cooldowns = self.cooldowns + if not cooldowns then return end + local now = GameTime() + local next_time + local CooldownDefs = CooldownDefs + while true do + local expired, more_expired + for cooldown_id, time in pairs(cooldowns) do + if time ~= true then + local def = CooldownDefs[cooldown_id] + time = time - now + if time <= 0 then + if def.OnExpire or def.ExpireMsg then + if expired then + more_expired = true + if expired > cooldown_id then + expired = cooldown_id + end + else + expired = cooldown_id + end + else + cooldowns[cooldown_id] = nil + end + else + if def.OnExpire or def.ExpireMsg then + next_time = Min(next_time, time) + end + end + end + end + if expired then + cooldowns[expired] = nil + self:OnCooldownExpire(expired) + end + if not more_expired then break end + end + if next_time then + WaitWakeup(next_time) + return true -- get called again + end + self.cooldowns_thread = nil +end + +function CooldownObj:GetDynamicData(data) + local cooldowns = self.cooldowns + if not cooldowns then return end + local now = GameTime() + for cooldown_id, time in pairs(cooldowns) do + if time ~= true and time - now < 0 then + cooldowns[cooldown_id] = nil + end + end + data.cooldowns = next(cooldowns) and cooldowns or nil +end + +function CooldownObj:SetDynamicData(data) + local cooldowns = data.cooldowns + if not cooldowns then + self.cooldowns = {} + DeleteThread(self.cooldowns_thread) + self.cooldowns_thread = nil + return + end + self.cooldowns = cooldowns + local CooldownDefs = CooldownDefs + for cooldown_id, time in pairs(cooldowns) do + local def = CooldownDefs[def] + if not def then + cooldowns[cooldown_id] = nil + elseif time ~= true then + if def.OnExpire or def.ExpireMsg then + if IsValidThread(self.cooldowns_thread) then + Wakeup(self.cooldowns_thread) + else + self.cooldowns_thread = CreateGameTimeThread(function(self) + while self:UpdateCooldowns() do end + end, self) + end + return + end + end + end + DeleteThread(self.cooldowns_thread) + self.cooldowns_thread = nil +end + +function CooldownObj:CheatClearCooldowns() + local cooldowns = self.cooldowns + if not cooldowns then return end + for cooldown_id in pairs(cooldowns) do + cooldowns[cooldown_id] = nil + self:OnCooldownExpire(cooldown_id) + end + self.cooldowns_thread = nil + ObjModified(self) +end diff --git a/CommonLua/Core/ConstDef.lua b/CommonLua/Core/ConstDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..182a8c0b18ac9c1b86ee997c17c28eca3ef66308 --- /dev/null +++ b/CommonLua/Core/ConstDef.lua @@ -0,0 +1,564 @@ +--- Defines the `ConstDef` class, which represents a global constant used by the game. +--- +--- The `ConstDef` class has the following properties: +--- - `type`: The type of the constant, which can be "bool", "number", "text", "color", "string_list", "preset_id", or "preset_id_list". +--- - `value`: The value of the constant, which can be of the type specified by the `type` property. +--- - `scale`: The scale factor for the value, which is only applicable for "number" type constants. +--- - `translate`: A boolean indicating whether the value should be translated. +--- - `preset_class`: The preset class associated with the constant, which is only applicable for "preset_id_list" and "preset_id" type constants. +--- +--- The `ConstDef` class also has the following methods: +--- - `GetLuaCode()`: Returns the Lua code to access the constant. +--- - `GetDefaultPropertyValue(prop, prop_meta)`: Returns the default value for a given property of the constant. +--- - `GetDefaultValueOf(type)`: Returns the default value for a given type of constant. +--- - `GetValueText()`: Returns the text representation of the constant's value. +DefineClass.ConstDef = {__parents={"Preset"}, properties={{id="_", editor="help", + help="", + color_start, self.name, color_end, self.ExeTimestamp, self.CPU, self.GPU) +end + +function FolderCrash:OpenLogFile() + local full_path = self.full_path or "" + if full_path ~= "" then + OpenTextFileWithEditorOfChoice(full_path) + end +end + +function CopySymbols(cache_folder, src_folder, module_name, local_name) + if (module_name or "") == "" then + return "Invalid param!" + end + if (local_name or "") == "" then + local_name = module_name + end + local pdbfile = cache_folder .. local_name .. ".pdb" + if io.exists(pdbfile) then + print("Using locally cached", pdbfile) + return + end + if src_folder == "" then + return "Symbols folder not found!" + end + local err, files = AsyncListFiles(src_folder, module_name .. ".*") + if err then + return print_format("Failed to list", src_folder, ":", err) + end + for _, file in ipairs(files) do + local file_dir, file_name, file_ext = SplitPath(file) + local dest = cache_folder .. local_name .. file_ext + print("Copying", file, "to", dest) + local err = AsyncCopyFile(file, dest, "raw") + if err then + return print_format("Failed to copy", file, ":", err) + end + end + if not io.exists(pdbfile) then + return print_format("No symbols found at", src_folder) + end +end + +function FolderCrash:DebugDump() + if not Platform.pc then + print("Supported on PC only!") + return + end + local err + local module_name = self:GetModuleName() or "" + if module_name == "" or string.lower(module_name) == "unknown" then + print("Invalid module name!") + return + end + local err, cache_folder = self:GetCacheFolder() + if err then + print("Failed to create working directory:", err) + return + end + local orig_dump_file = self.dump_file + local orig_dump_dir, dump_name, dump_ext = SplitPath(orig_dump_file) + local dump_file = cache_folder .. dump_name .. dump_ext + if not io.exists(dump_file) then + if not io.exists(orig_dump_file) then + print("No dump pack found!") + return + end + local err = AsyncCopyFile(orig_dump_file, dump_file, "raw") + if err then + print("Failed to copy", orig_dump_file, ":", err) + return + end + end + local err = CopySymbols(cache_folder, self.SymbolsFolder, module_name, self.LocalModuleName) + if err then + print("Copy symbols error:", err) + return + end + local os_path = ConvertToOSPath(dump_file) + local os_command = string.format("cmd /c start \"\" \"%s\"", os_path) + os.execute(os_command) +end + +function FetchSymbolsFolders() + local err + local st = GetPreciseTicks() + err, SymbolsFolders = AsyncListFiles(CrashFolderSymbols, "*", "folders") + if err then + print("Failed to fetch symbols folders from Bender:", err) + SymbolsFolders = {} + end + print(#SymbolsFolders, "symbol folders found in", GetPreciseTicks() - st, "ms at", CrashFolderSymbols) +end + +function ResolveSymbolsFolder(timestamp) + if (timestamp or "") == "" then + return + end + assert(SymbolsFolders) + for _, folder in ipairs(SymbolsFolders) do + if string.ends_with(folder, timestamp, true) then + return folder + end + end +end + +function OpenCrashFolderBrowser(location, timestamp) + CreateRealTimeThread(WaitOpenCrashFolderBrowser, location, timestamp) +end + +function WaitOpenCrashFolderBrowser(location, timestamp) + print("Opening crash folder browser at", location) + FetchSymbolsFolders() + if not CrashCache then + local err, str = AsyncFileToString(cache_file) + if not err then + CrashCache = dostring(str) + end + if not CrashCache or CrashCache.version ~= CrashCacheVersion then + CrashCache = { version = CrashCacheVersion } + end + end + if not CrashResolved then + local err, str = AsyncFileToString(resolved_file) + if not err then + CrashResolved = dostring(str) + end + if not CrashResolved then + CrashResolved = {} + end + end + local to_read, to_delete = {}, {} + local to_delete_count = 0 + local groups = {} + local total_count = 0 + local function AddCrashTo(crash, crash_name, group_name) + local group = groups[group_name] + if not group then + group = FolderCrashGroup:new{ name = group_name } + groups[group_name] = group + groups[#groups + 1] = group + end + group[#group + 1] = crash + end + local skipped = 0 + local function AddCrash(crash, group_name) + if timestamp and timestamp ~= crash.ExeTimestamp then + skipped = skipped + 1 + return + end + AddCrashTo(crash, crash.name, crash.group) + AddCrashTo(crash, crash.name, ">All") + total_count = total_count + 1 + end + local created = 0 + local read = 0 + local function ReadCrash(info) + read = read + 1 + local crashfile, folder = info[1], info[2] + local file_dir, file_name, file_ext = SplitPath(crashfile) + local dump_file = file_dir .. file_name .. ".dmp" + local err, info, label, values, revision_num, hash, DmpTimestamp + err, DmpTimestamp = AsyncGetFileAttribute(dump_file, "timestamp") + if err then + print(err, "while getting timestamp of", dump_file) + else + err, info, label, values, revision_num, hash = CrashFileParse(crashfile) + if err then + print(err, "error while reading", crashfile) + end + end + if err then + to_delete_count = to_delete_count + 1 + to_delete[#to_delete + 1] = crashfile + to_delete[#to_delete + 1] = dump_file + return + end + local group_name = string.sub(file_dir, #folder + 2) + if group_name == "" then + group_name = ">Ungrouped" + for pattern, name in pairs(defaults_groups) do + if file_dir:find(pattern) then + group_name = name + break + end + end + else + group_name = group_name:sub(1, -2) + group_name = group_name:gsub("\\", "/") + end + local crash = FolderCrash:new{ + dump_file = dump_file, + group = group_name, + folder = file_dir, + name = label, + full_path = crashfile, + crash_info = info, + date = os.date("%y/%m/%d %H:%M:%S", DmpTimestamp), + DmpTimestamp = DmpTimestamp, + ExeTimestamp = values.Timestamp, + SymbolsFolder = ResolveSymbolsFolder(values.Timestamp), + CPU = values.CPU, + GPU = values.GPU, + thread = values.Thread, + values = values, + hash = hash, + } + CrashCache[crashfile] = crash + AddCrash(crash) + created = created + 1 + if read % 100 == 0 then + print(#to_read - read, "remaining...") + end + end + + local folders + if type(location) == "string" then + folders = { location } + elseif type(location) == "table" then + folders = location + else + folders = { CrashFolderBender } + end + print("Fetching folder structure...") + while true do + local found + for i=#folders,1,-1 do + local folder = folders[i] + local star_i = folder:find_lower("*") + if star_i then + found = true + table.remove(folders, i) + local base = folder:sub(1, star_i - 1) + local sub = folder:sub(star_i + 1) + local err, subfolders = AsyncListFiles(base, "*", "folders") + if err then + print("Failed to fetch issues from", base, ":", err) + else + for _, subfolder in ipairs(subfolders) do + local f1 = subfolder .. sub + if io.exists(f1) then + folders[#folders + 1] = f1 + end + end + end + end + end + if not found then + break + end + end + for _, folder in ipairs(folders) do + if folder:ends_with("/") or folder:ends_with("\\") then + folder = folder:sub(1, -2) + end + local st = GetPreciseTicks() + local err, files = AsyncListFiles(folder, "*.crash", "recursive") + if err then + printf("Failed to fetch issues (%s) from '%s'", err, folder) + else + printf("%d crashes found in '%s'", #files, folder) + for i, crashfile in ipairs(files) do + local group_name + local cache = CrashCache[crashfile] + if cache then + AddCrash(cache) + else + to_read[#to_read + 1] = { crashfile, folder } + end + end + end + end + local st = GetPreciseTicks() + parallel_foreach(to_read, ReadCrash) + table.sortby_field(groups, "name") + for _, group in ipairs(groups) do + local names, timestamps, threads, gpus, cpus = {}, {}, {}, {}, {} + group.names = names + group.timestamps = timestamps + group.threads = threads + group.gpus = gpus + group.cpus = cpus + for _, crash in ipairs(group) do + local name = crash.name + names[name] = (names[name] or 0) + 1 + timestamps[crash.ExeTimestamp] = true + threads[crash.thread] = true + gpus[crash.GPU] = true + cpus[crash.CPU] = true + end + for _, crash in ipairs(group) do + crash.occurrences = names[crash.name] + end + group:Sort() + group.count = #group + end + print("Crashes processed:", total_count, ", skipped:", skipped, ", time:", GetPreciseTicks() - st, "ms") + if created > 0 then + local code = pstr("return ", 1024) + TableToLuaCode(CrashCache, nil, code) + AsyncCreatePath(base_cache_folder) + local err = AsyncStringToFile(cache_file, code, -2, 0, "zstd") + if err then + print("once", "Failed to save the crash cache to", cache_file, ":", err) + end + end + local ged = OpenGedAppSingleton("GedFolderCrashes", groups) + ged:SetSelection("root", { 1 }, nil, not "notify") + if to_delete_count > 0 then + if "ok" == WaitQuestion(terminal.desktop, "Warning", string.format("Confirm removal of %s invalid crash files?", to_delete_count)) then + local err = AsyncFileDelete(to_delete) + if err then + print(err, "while deleting invalid crash files!") + else + print(to_delete_count, "invalid crash files removed.") + end + end + end +end diff --git a/CommonLua/Data/AutoAttachPreset.lua b/CommonLua/Data/AutoAttachPreset.lua new file mode 100644 index 0000000000000000000000000000000000000000..03793f8625fc7e14d73bf35f3be587805fddf10e --- /dev/null +++ b/CommonLua/Data/AutoAttachPreset.lua @@ -0,0 +1,2 @@ +-- ========== THIS IS AN AUTOMATICALLY GENERATED FILE! ========== + diff --git a/CommonLua/Data/BindingsMenuCategory.lua b/CommonLua/Data/BindingsMenuCategory.lua new file mode 100644 index 0000000000000000000000000000000000000000..379a0547d43a8d156dd5b32b3bfcdb9ef0d800f8 --- /dev/null +++ b/CommonLua/Data/BindingsMenuCategory.lua @@ -0,0 +1,14 @@ +-- ========== GENERATED BY BindingsMenuCategory Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('BindingsMenuCategory', { + Name = T(528833407929, --[[BindingsMenuCategory Default Camera Name]] "Camera"), + id = "Camera", + save_in = "Common", +}) + +PlaceObj('BindingsMenuCategory', { + Name = T(966864367707, --[[BindingsMenuCategory Default Default Name]] "Default"), + id = "Default", + save_in = "Common", +}) + diff --git a/CommonLua/Data/BugReportTag.lua b/CommonLua/Data/BugReportTag.lua new file mode 100644 index 0000000000000000000000000000000000000000..afa07f20a418861caa7e3d2d03f7197c972af508 --- /dev/null +++ b/CommonLua/Data/BugReportTag.lua @@ -0,0 +1,137 @@ +-- ========== GENERATED BY BugReportTag Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Animations", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Combat", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Controller", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "FX", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Feedback", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Ged", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Mods", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Pathing", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Performance", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Polish", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Scripting", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Sound", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Tutorial", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "UI", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Units", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + Platform = true, + ShowInExternal = true, + id = "Windows", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + ShowInExternal = true, + id = "Writing", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + Platform = true, + ShowInExternal = true, + SortKey = 1, + id = "XB1", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + Platform = true, + ShowInExternal = true, + SortKey = 1, + id = "XSX", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + Platform = true, + ShowInExternal = true, + SortKey = 2, + id = "PS4", + save_in = "Common", +}) + +PlaceObj('BugReportTag', { + Platform = true, + ShowInExternal = true, + SortKey = 2, + id = "PS5", + save_in = "Common", +}) + diff --git a/CommonLua/Data/CooldownDef.lua b/CommonLua/Data/CooldownDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..cf1aca9d81ddbd2a136201e6c69f581b3f222a40 --- /dev/null +++ b/CommonLua/Data/CooldownDef.lua @@ -0,0 +1,9 @@ +-- ========== GENERATED BY CooldownDef Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('CooldownDef', { + Comment = "generic cooldown for general use", + group = "Default", + id = "Disabled", + save_in = "Common", +}) + diff --git a/CommonLua/Data/GameStateDef.lua b/CommonLua/Data/GameStateDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..3e1c55b46a94e7dc97d4c38ea3f9e5ebbba91bca --- /dev/null +++ b/CommonLua/Data/GameStateDef.lua @@ -0,0 +1,37 @@ +-- ========== GENERATED BY GameStateDef Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('GameStateDef', { + PlayFX = false, + ShowInFilter = false, + group = "Engine", + id = "Tutorial", + save_in = "Common", +}) + +PlaceObj('GameStateDef', { + MapState = false, + PlayFX = false, + ShowInFilter = false, + group = "Engine", + id = "gameplay", + save_in = "Common", +}) + +PlaceObj('GameStateDef', { + MapState = false, + PlayFX = false, + ShowInFilter = false, + group = "Engine", + id = "loading", + save_in = "Common", +}) + +PlaceObj('GameStateDef', { + MapState = false, + PlayFX = false, + ShowInFilter = false, + group = "Engine", + id = "paused", + save_in = "Common", +}) + diff --git a/CommonLua/Data/GradingLUTSource.lua b/CommonLua/Data/GradingLUTSource.lua new file mode 100644 index 0000000000000000000000000000000000000000..825fad35813da11f3a66305a8207746d2d9c7499 --- /dev/null +++ b/CommonLua/Data/GradingLUTSource.lua @@ -0,0 +1,9 @@ +-- ========== GENERATED BY GradingLUTSource Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('GradingLUTSource', { + group = "Default", + id = "Default", + save_in = "Common", + src_path = "svnAssets/Source/Textures/LUTs/Default.cube", +}) + diff --git a/CommonLua/Data/LightmodelPreset.lua b/CommonLua/Data/LightmodelPreset.lua new file mode 100644 index 0000000000000000000000000000000000000000..4fe3692f531edcdcb90a424b8b4ba59fdca8c241 --- /dev/null +++ b/CommonLua/Data/LightmodelPreset.lua @@ -0,0 +1,30 @@ +-- ========== GENERATED BY LightmodelPreset Editor (Ctrl-M) DO NOT EDIT MANUALLY! ========== + +PlaceObj('LightmodelPreset', { + ae_key_bias = -1323530, + env_capture_map = "__Empty", + env_exterior_capture_pos = point(23013, 25132, 2275), + env_interior_capture_pos = point(23013, 25132, 2275), + exposure = -83, + ext_env_exposure = 100, + exterior_envmap = "ArtPreview", + fog_color = 4294967295, + fog_density = 30, + fog_height_falloff = 2500, + fog_start = 80000, + group = "Default", + id = "LevelDesign", + int_env_exposure = 100, + interior_envmap = "ArtPreview", + mie_mc = 980, + mie_sh = 600, + ray_sh = 16000, + save_in = "Common", + sun_alt = 540, + sun_azi = 130, + sun_diffuse_color = 4294836188, + sun_intensity = 300, + use_time_of_day = false, + wind = "", +}) + diff --git a/CommonLua/Data/ListItem.lua b/CommonLua/Data/ListItem.lua new file mode 100644 index 0000000000000000000000000000000000000000..4d81b9d63a06395f66051a460cce01711aa72209 --- /dev/null +++ b/CommonLua/Data/ListItem.lua @@ -0,0 +1 @@ +-- ========== THIS IS AN AUTOMATICALLY GENERATED FILE! ========== diff --git a/CommonLua/Data/MapGen.lua b/CommonLua/Data/MapGen.lua new file mode 100644 index 0000000000000000000000000000000000000000..c88efa4f8028c772390ed4c3f4b9fa6a5d78c115 --- /dev/null +++ b/CommonLua/Data/MapGen.lua @@ -0,0 +1,1678 @@ +-- ========== GENERATED BY MapGen Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('MapGen', { + 'SaveIn', "Common", + 'Id', "BiomeCreator", + 'OnChange', "", + 'Lightmodel', "LevelDesign", + 'Dump', true, +}, { + PlaceObj('GridOpRun', { + 'Sequence', "WM_ReadGrids", + }), + PlaceObj('GridOpRun', { + 'Sequence', "WM_ProcessGrids", + }), + PlaceObj('GridOpRun', { + 'Sequence', "WM_MapReset", + }), + PlaceObj('GridOpDbg', { + 'RunModes', set( "Debug" ), + 'Show', "clear", + }), + PlaceObj('GridOpComment', { + 'Comment', "Colorize terrain", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Color", + 'InputName', "ColorMap", + 'ColorRed', -1000, + 'ColorGreen', -1000, + 'ColorBlue', -1000, + }), + PlaceObj('GridOpDbg', { + 'Enabled', false, + 'RunModes', set( "Debug" ), + 'Grid', "WaterMap", + 'AllowInspect', true, + }), + PlaceObj('GridOpComment', { + 'Comment', "Match biomes", + }), + PlaceObj('GridOpMapBiomeMatch', { + 'UseParams', true, + 'OutputName', "BiomeMap", + 'AllowInspect', true, + 'BiomeGroupParam', "BiomeGroup", + 'HeightMap', "HeightMapDistort", + 'SlopeMap', "SlopeMapSmooth", + 'WetMap', "WetMapSmooth", + 'HardnessMap', "HardnessMap", + 'OrientMap', "OrientMap", + 'SeaLevelMap', "SeaLevelMap", + 'WaterDistMap', "WaterDistDistort", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Biome", + 'InputName', "BiomeMap", + }), + PlaceObj('GridOpDbg', { + 'RunModes', set( "Debug" ), + 'Show', "biome", + 'Grid', "ColorMap", + 'AllowInspect', true, + 'ColorRand', true, + }), + PlaceObj('GridOpComment', { + 'Comment', "Set terrain textures", + }), + PlaceObj('GridOpMapPrefabTypes', { + 'InputName', "BiomeMap", + 'OutputName', "PrefabTypeMap", + 'AllowEmptyTypes', true, + }), + PlaceObj('GridOpMapBiomeTexture', { + 'InputName', "PrefabTypeMap", + 'AllowInspect', true, + 'FlowMap', "FlowMap", + 'FlowMax', 100, + 'HeightMap', "HeightMap", + 'GrassMap', "GrassMap", + }), + PlaceObj('GridOpComment', { + 'Comment', "Place prefabs", + }), + PlaceObj('BiomeFiller', { + 'InputName', "PrefabTypeMap", + 'SlopeGrid', "SlopeMap", + }), + PlaceObj('GridOpComment', { + 'Comment', "Finalize", + }), + PlaceObj('GridOpRun', { + 'Sequence', "PostProcessRandomMap", + }), + }) + +PlaceObj('MapGen', { + 'SaveIn', "Common", + 'Id', "BiomeFiller", + 'OnChange', "", +}, { + PlaceObj('GridOpMapExport', { + 'Operation', "Biome", + 'OutputName', "BiomeMap", + }), + PlaceObj('GridOpMapPrefabTypes', { + 'InputName', "BiomeMap", + 'OutputName', "PrefabTypeMap", + 'AllowEmptyTypes', true, + }), + PlaceObj('GridOpDistort', { + 'InputName', "PrefabTypeMap", + 'OutputName', "PrefabTypeMap", + 'Frequency', 50, + 'Octave_1', 64, + 'Octave_2', 128, + 'Octave_4', 512, + 'Octave_5', 1024, + 'Octave_6', 512, + 'Octave_7', 256, + 'Octave_8', 128, + 'Octave_9', 64, + 'Strength', 40, + }), + PlaceObj('BiomeFiller', { + 'InputName', "PrefabTypeMap", + }), + }) + +PlaceObj('MapGen', { + 'SaveIn', "Common", + 'Id', "ImportHeight", +}, { + PlaceObj('GridOpDir', { + 'BaseDir', "svnAssets/Bin/win32/Bin/AppData/temp/", + }), + PlaceObj('GridOpRead', { + 'OutputName', "HM", + 'FileName', "WorldMap.raw", + 'FileFormat', "raw16", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "HM", + }), + }) + +PlaceObj('MapGen', { + 'SaveIn', "Common", + 'Id', "MountainDemo", + 'OnChange', "", + 'Randomize', true, +}, { + PlaceObj('GridOpComment', { + 'Comment', "Obtain a mountain pattern. Needs a random shape in the biome grid as input. Used to demonstrate the documentation found in Docs/Internal/MapGenDemo.html.", + }), + PlaceObj('GridOpMapExport', { + 'Operation', "Biome", + 'OutputName', "Mountain", + }), + PlaceObj('GridOpWrite', { + 'RunModes', set( "Debug" ), + 'InputName', "Mountain", + 'FileName', "Pattern.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpChangeLim', { + 'Operation', "Mask", + 'InputName', "Mountain", + 'OutputName', "Mountain", + 'Max', 0, + }), + PlaceObj('GridOpConvert', { + 'InputName', "Mountain", + 'OutputName', "Mountain", + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "Mountain", + 'OutputName', "Mountain", + 'GridType', "float", + }), + PlaceObj('GridOpComment', { + 'Comment', "Calculate distance transform:", + }), + PlaceObj('GridOpDistance', { + 'InputName', "Mountain", + 'OutputName', "Mountain", + }), + PlaceObj('GridOpWrite', { + 'RunModes', set( "Debug" ), + 'InputName', "Mountain", + 'FileName', "Distance.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpComment', { + 'Comment', "Generate Perlin noise between 0 and 1:", + }), + PlaceObj('GridOpNoise', { + 'OutputName', "Noise", + 'RefName', "Mountain", + }), + PlaceObj('GridOpWrite', { + 'RunModes', set( "Debug" ), + 'InputName', "Noise", + 'FileName', "Noise.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpChangeLim', { + 'InputName', "Noise", + 'OutputName', "Noise", + }), + PlaceObj('GridOpComment', { + 'Comment', "Combine the noise and the distance:", + }), + PlaceObj('GridOpMulDivAdd', { + 'InputName', "Mountain", + 'OutputName', "Mountain", + 'MulName', "Noise", + }), + PlaceObj('GridOpWrite', { + 'RunModes', set( "Debug" ), + 'InputName', "Mountain", + 'FileName', "Combined.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpComment', { + 'Comment', "Distort the product:", + }), + PlaceObj('GridOpDistort', { + 'InputName', "Mountain", + 'OutputName', "Mountain", + 'Octave_1', 256, + 'Octave_3', 1024, + 'Octave_4', 512, + 'Octave_5', 256, + 'Octave_6', 128, + 'Octave_7', 64, + 'Octave_8', 32, + 'Octave_9', 16, + 'Strength', 80, + }), + PlaceObj('GridOpWrite', { + 'RunModes', set( "Debug" ), + 'InputName', "Mountain", + 'FileName', "Distort.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpComment', { + 'Comment', "Apply errosion:", + }), + PlaceObj('GridOpMapErosion', { + 'InputName', "Mountain", + 'OutputName', "Mountain", + 'Iterations', 200, + }), + PlaceObj('GridOpWrite', { + 'RunModes', set( "Debug" ), + 'InputName', "Mountain", + 'FileName', "Erosion.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpComment', { + 'Comment', "Apply the the height map:", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "Mountain", + 'Normalize', true, + 'HeightMax', 30000, + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "MapClear", +}, { + PlaceObj('GridOpMapReset', nil), + PlaceObj('GridOpMapReset', { + 'Operation', "Height", + 'Type', "TerrainGray", + }), + PlaceObj('GridOpMapReset', { + 'Operation', "Grass", + }), + PlaceObj('GridOpMapReset', { + 'Operation', "Objects", + 'Type', "dirt", + 'FilterFlagsAll', set(), + 'FilterFlagsAny', set( "Generated", "Permanent" ), + }), + PlaceObj('GridOpMapReset', { + 'Operation', "Color", + }), + PlaceObj('GridOpDbg', { + 'Show', "clear", + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "MapParams", + 'RunOnce', true, +}, { + PlaceObj('GridOpParamEval', { + 'ParamName', "MapGridSize", + 'ParamValue', "point(terrain.TypeMapSize())", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MapGridWidth", + 'ParamValue', "MapGridSize:x()", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MapGridHeight", + 'ParamValue', "MapGridSize:y()", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "HeightScale", + 'ParamValue', "const.TerrainHeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MapMaxHeight", + 'ParamValue', "const.MaxTerrainHeight / HeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "HeightTile", + 'ParamValue', "const.HeightTileSize", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "SlabSizeZ", + 'ParamValue', "const.SlabSizeZ", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "SlabSizeX", + 'ParamValue', "const.SlabSizeX", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "BiomeGroup", + 'ParamValue', "mapdata.BiomeGroup", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "HeightMin", + 'ParamValue', "mapdata.HeightMin / HeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "HeightMax", + 'ParamValue', "mapdata.HeightMax / HeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "WetMin", + 'ParamValue', "mapdata.WetMin", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "WetMax", + 'ParamValue', "mapdata.WetMax", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "GrassGridSize", + 'ParamValue', "point(terrain.GrassMapSize())", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "GrassGridWidth", + 'ParamValue', "GrassGridSize:x()", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "GrassGridHeight", + 'ParamValue', "GrassGridSize:y()", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaxSlope", + 'ParamValue', "const.MaxPassableTerrainSlope", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MinBumpSlope", + 'ParamValue', "mapdata.MinBumpSlope", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaxBumpSlope", + 'ParamValue', "mapdata.MaxBumpSlope", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaxWaterDist", + 'ParamValue', "const.RandomMap.BiomeMaxWaterDist", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MinSeaLevel", + 'ParamValue', "const.RandomMap.BiomeMinSeaLevel / HeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaxSeaLevel", + 'ParamValue', "const.RandomMap.BiomeMaxSeaLevel / HeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "SeaLevelSub", + 'ParamValue', "mapdata.SeaLevel > 0 and -mapdata.SeaLevel / HeightScale", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "PassBorder", + 'ParamValue', "mapdata.PassBorder", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "InfinityPositive", + 'ParamValue', "max_int", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "InfinityNegative", + 'ParamValue', "min_int", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaxRaw16", + 'ParamValue', "(2^16)-1", + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "NoiseExport_CreateBorder", +}, { + PlaceObj('GridOpDraw', { + 'OutputName', "Border", + 'RefName', "WorkMap", + 'DrawBorder', 150, + }), + PlaceObj('GridOpDistance', { + 'Operation', "Wave", + 'InputName', "Border", + 'OutputName', "Border", + }), + PlaceObj('GridOpFilter', { + 'InputName', "Border", + 'OutputName', "Border", + 'Intensity', 5, + }), + PlaceObj('GridOpChangeLim', { + 'InputName', "Border", + 'OutputName', "Border", + 'Smooth', true, + 'Remap', true, + 'RemapMin', 1000, + 'RemapMax', 100, + }), + PlaceObj('GridOpMulDivAdd', { + 'InputName', "WorkMap", + 'OutputName', "WorkMap", + 'MulName', "Border", + 'Div', 1000, + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "NoiseExport_ShowOnMap", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapClear", + }), + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "WorkMap", + 'OutputName', "WorkMapShow", + 'Max', 100, + 'Remap', true, + 'RemapMax', 255, + 'RemapMaxParam', "MapMaxHeight", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "WorkMapShow", + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "PostProcessRandomMap", +}, { + PlaceObj('GridOpRun', { + 'Operation', "Code", + 'Code', function (state, grid) + mapdata.IsRandomMap = true + end, + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "ResampleGrids", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'InputName', "FlowMap", + 'OutputName', "FlowMap", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'InputName', "WetMap", + 'OutputName', "WetMap", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpResample', { + 'Optional', true, + 'UseParams', true, + 'InputName', "WaterDist", + 'OutputName', "WaterDist", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpResample', { + 'Optional', true, + 'UseParams', true, + 'InputName', "HardnessMap", + 'OutputName', "HardnessMap", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "WM_MapReset", +}, { + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "HeightMap", + }), + PlaceObj('GridOpMapReset', { + 'Operation', "Objects", + 'Type', "dirt", + 'FilterFlagsAll', set(), + 'FilterFlagsAny', set( "Generated", "Permanent" ), + }), + PlaceObj('GridOpMapReset', { + 'Operation', "Grass", + 'Type', "dirt", + }), + PlaceObj('GridOpMapReset', { + 'Enabled', false, + }), + PlaceObj('GridOpMapReset', { + 'Operation', "Color", + }), + PlaceObj('GridOpDbg', { + 'Show', "clear", + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "WM_ProcessGrids", + 'RunMode', "Debug", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpComment', { + 'Comment', "Remove height bumps and holes ----------------------------------------------", + }), + PlaceObj('GridOpFilter', { + 'Operation', "Convolution", + 'InputName', "HeightMap", + 'OutputName', "HeightMapExtrem", + 'Kernel', { + -1, + -1, + -1, + -1, + 8, + -1, + -1, + -1, + -1, + }, + 'Scale', 8, + }), + PlaceObj('GridOpConvert', { + 'Operation', "Abs", + 'InputName', "HeightMapExtrem", + 'OutputName', "HeightMapExtrem", + }), + PlaceObj('GridOpMapSlope', { + 'InputName', "HeightMapExtrem", + 'OutputName', "HeightMapExtrem", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Clamp", + 'InputName', "HeightMapExtrem", + 'OutputName', "HeightMapExtrem", + 'MinParam', "MinBumpSlope", + 'MaxParam', "MaxBumpSlope", + 'Scale', 60, + 'RemapMax', 100, + }), + PlaceObj('GridOpFilter', { + 'InputName', "HeightMapExtrem", + 'OutputName', "HeightMapExtrem", + 'Intensity', 2, + 'RestoreLims', true, + }), + PlaceObj('GridOpFilter', { + 'InputName', "HeightMap", + 'OutputName', "HeightMapSmooth", + 'Intensity', 2, + }), + PlaceObj('GridOpLerp', { + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'TargetName', "HeightMapSmooth", + 'MaskName', "HeightMapExtrem", + }), + PlaceObj('GridOpDistort', { + 'InputName', "HeightMapSmooth", + 'OutputName', "HeightMapDistort", + 'Octave_4', 186, + 'Octave_5', 98, + 'Octave_6', 269, + 'Octave_7', 205, + 'Octave_8', 170, + }), + PlaceObj('GridOpFilter', { + 'InputName', "WetMap", + 'OutputName', "WetMapSmooth", + 'Intensity', 3, + 'Kernel', { + 1, + 2, + 1, + 2, + 4, + 2, + 1, + 2, + 1, + }, + 'Scale', 16, + 'RestoreLims', true, + }), + PlaceObj('GridOpComment', { + 'Comment', "Slope map ----------------------------------------------", + }), + PlaceObj('GridOpMapSlope', { + 'InputName', "HeightMapSmooth", + 'OutputName', "SlopeMap", + 'Units', "minutes", + }), + PlaceObj('GridOpFilter', { + 'InputName', "SlopeMap", + 'OutputName', "SlopeMapSmooth", + 'Scale', 16, + }), + PlaceObj('GridOpComment', { + 'Comment', "Orient map ----------------------------------------------", + }), + PlaceObj('GridOpMapSlope', { + 'Operation', "Orientation", + 'InputName', "HeightMapSmooth", + 'OutputName', "OrientMap", + 'Units', "", + }), + PlaceObj('GridOpChangeLim', { + 'Operation', "Remap", + 'InputName', "OrientMap", + 'OutputName', "OrientMap", + 'Min', -1, + 'Remap', true, + 'RemapMax', 1000, + }), + PlaceObj('GridOpComment', { + 'Comment', "Water dist ----------------------------------------------", + }), + PlaceObj('GridOpDistort', { + 'Optional', true, + 'InputName', "WaterDist", + 'OutputName', "WaterDistDistort", + 'Octave_4', 186, + 'Octave_5', 98, + 'Octave_6', 269, + 'Octave_7', 205, + 'Octave_8', 170, + }), + PlaceObj('GridOpFilter', { + 'Optional', true, + 'InputName', "WaterDist", + 'OutputName', "WaterDistCoef", + 'Intensity', 5, + 'Scale', 16, + 'RestoreLims', true, + }), + PlaceObj('GridOpConvert', { + 'Optional', true, + 'Operation', "Abs", + 'InputName', "WaterDistCoef", + 'OutputName', "WaterDistCoef", + }), + PlaceObj('GridOpMulDivAdd', { + 'Optional', true, + 'UseParams', true, + 'InputName', "WaterDistCoef", + 'OutputName', "WaterDistCoef", + 'DivParam', "MaxWaterDist", + }), + PlaceObj('GridOpMulDivAdd', { + 'Optional', true, + 'InputName', "WaterDistDistort", + 'OutputName', "WaterDistDistort", + 'MulName', "WaterDistCoef", + }), + PlaceObj('GridOpLerp', { + 'Optional', true, + 'InputName', "WaterDist", + 'OutputName', "WaterDistDistort", + 'TargetName', "WaterDistDistort", + 'MaskName', "WaterDistCoef", + }), + PlaceObj('GridOpComment', { + 'Comment', "Sea level ----------------------------------------------", + }), + PlaceObj('GridOpMulDivAdd', { + 'Optional', true, + 'UseParams', true, + 'InputName', "HeightMapSmooth", + 'OutputName', "SeaLevelMap", + 'AddParam', "SeaLevelSub", + }), + PlaceObj('GridOpChangeLim', { + 'Optional', true, + 'UseParams', true, + 'Operation', "Clamp", + 'InputName', "SeaLevelMap", + 'OutputName', "SeaLevelMap", + 'MinParam', "MinSeaLevel", + 'MaxParam', "MaxSeaLevel", + }), + PlaceObj('GridOpComment', { + 'Comment', "Color map ----------------------------------------------", + }), + PlaceObj('GridOpNoise', { + 'UseParams', true, + 'OutputName', "ColorNoise", + 'RefName', "SlopeMap", + 'Width', 4096, + 'Height', 4096, + 'GridType', "float", + 'Frequency', 70, + 'Octave_1', 16, + 'Octave_2', 32, + 'Octave_3', 64, + 'Octave_5', 256, + 'Octave_6', 512, + 'Octave_7', 1024, + 'Octave_8', 512, + 'Octave_9', 256, + }), + PlaceObj('GridOpChangeLim', { + 'InputName', "ColorNoise", + 'OutputName', "ColorNoise", + 'Max', 50, + 'MaxParam', "MapMaxHeight", + }), + PlaceObj('GridOpComment', { + 'Comment', "Leave the flow in the slopes only, while the noise will cover the plains:", + }), + PlaceObj('GridOpLerp', { + 'InputName', "ColorNoise", + 'OutputName', "ColorMap", + 'TargetName', "FlowMap", + 'MaskName', "SlopeMap", + }), + PlaceObj('GridOpComment', { + 'Comment', "Flow map ----------------------------------------------", + }), + PlaceObj('GridOpChangeLim', { + 'Operation', "Mask", + 'InputName', "SlopeMap", + 'OutputName', "InvFlatMask", + 'Min', 900, + 'Max', 100000, + }), + PlaceObj('GridOpMulDivAdd', { + 'InputName', "FlowMap", + 'OutputName', "FlowMap", + 'MulName', "InvFlatMask", + }), + }) + +PlaceObj('MapGen', { + 'Group', "SubProc", + 'SaveIn', "Common", + 'Id', "WM_ReadGrids", + 'RunOnce', true, +}, { + PlaceObj('GridOpComment', { + 'Comment', "The base directory name matches the loaded map name", + }), + PlaceObj('GridOpDir', { + 'Enabled', false, + 'BaseDir', "svnAssets/Source/MapGen/alt_02/", + }), + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpComment', { + 'Comment', "Wet map", + }), + PlaceObj('GridOpRead', { + 'OutputName', "WetMap", + 'FileName', "wear.raw", + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "WetMap", + 'OutputName', "WetMap", + 'GridType', "float", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "WetMap", + 'OutputName', "WetMap", + 'Max', 255, + 'Remap', true, + 'RemapMinParam', "WetMin", + 'RemapMax', 100, + 'RemapMaxParam', "WetMax", + }), + PlaceObj('GridOpComment', { + 'Comment', "Height map", + }), + PlaceObj('GridOpRead', { + 'OutputName', "HeightMap", + 'FileName', "height.r16", + }), + PlaceObj('GridOpConvert', { + 'Optional', true, + 'Operation', "Repack", + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'GridType', "float", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'MaxParam', "MaxRaw16", + 'Remap', true, + 'RemapMinParam', "HeightMin", + 'RemapMaxParam', "HeightMax", + }), + PlaceObj('GridOpComment', { + 'Comment', "Flow map", + }), + PlaceObj('GridOpRead', { + 'OutputName', "FlowMap", + 'FileName', "flow.raw", + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "FlowMap", + 'OutputName', "FlowMap", + 'GridType', "float", + }), + PlaceObj('GridOpChangeLim', { + 'Operation', "Remap", + 'InputName', "FlowMap", + 'OutputName', "FlowMap", + 'Max', 255, + 'Remap', true, + 'RemapMax', 100, + }), + PlaceObj('GridOpChangeLim', { + 'Operation', "Clamp", + 'InputName', "FlowMap", + 'OutputName', "FlowMap", + 'Max', 50, + 'Smooth', true, + 'Remap', true, + 'RemapMax', 100, + }), + PlaceObj('GridOpComment', { + 'Comment', "Water map", + }), + PlaceObj('GridOpRead', { + 'Optional', true, + 'OutputName', "WaterMap", + 'FileName', "water.r16", + }), + PlaceObj('GridOpConvert', { + 'Optional', true, + 'Operation', "Repack", + 'InputName', "WaterMap", + 'OutputName', "WaterMap", + 'GridType', "float", + }), + PlaceObj('GridOpRun', { + 'Optional', true, + 'Operation', "Func", + 'InputName', "WaterMap", + 'OutputName', "WaterDist", + 'Func', "BiomeWaterDist", + }), + PlaceObj('GridOpComment', { + 'Comment', "Hardness map", + }), + PlaceObj('GridOpRead', { + 'Optional', true, + 'OutputName', "HardnessMap", + 'FileName', "hardness.raw", + }), + PlaceObj('GridOpConvert', { + 'Optional', true, + 'Operation', "Repack", + 'InputName', "HardnessMap", + 'OutputName', "HardnessMap", + 'GridType', "float", + }), + PlaceObj('GridOpChangeLim', { + 'Operation', "Remap", + 'InputName', "HardnessMap", + 'OutputName', "HardnessMap", + 'Max', 255, + 'Remap', true, + 'RemapMax', 100, + }), + PlaceObj('GridOpComment', { + 'Comment', "Resample in developer mode to use the same grids over different map sizes", + }), + PlaceObj('GridOpRun', { + 'Sequence', "ResampleGrids", + }), + PlaceObj('GridOpComment', { + 'Comment', "Grass map", + }), + PlaceObj('GridOpNoise', { + 'UseParams', true, + 'OutputName', "GrassMap", + 'WidthParam', "GrassGridWidth", + 'HeightParam', "GrassGridHeight", + 'GridType', "uint16", + 'Frequency', 70, + 'Octave_1', 16, + 'Octave_2', 32, + 'Octave_3', 64, + 'Octave_5', 256, + 'Octave_6', 512, + 'Octave_7', 1024, + 'Octave_8', 512, + 'Octave_9', 256, + }), + PlaceObj('GridOpChangeLim', { + 'InputName', "GrassMap", + 'OutputName', "GrassMap", + 'Max', 100, + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "GrassMap", + 'OutputName', "GrassMap", + 'GridType', "uint8", + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "MapBackupRestore", + 'OnChange', "", + 'RunMode', "Debug", + 'Randomize', true, +}, { + PlaceObj('GridOpMapReset', { + 'Operation', "Backup", + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "MapBackupStore", + 'OnChange', "", + 'RunMode', "Debug", + 'Randomize', true, +}, { + PlaceObj('GridOpMapReset', { + 'Operation', "Backup", + 'Overwrite', true, + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "MapHeightMove", + 'OnChange', "", +}, { + PlaceObj('GridOpParamEval', { + 'ParamName', "HeightChange", + 'ParamValue', "10 * guim", + }), + PlaceObj('GridOpRun', { + 'Operation', "Code", + 'Code', function (state, grid) + ChangeMapZ(state.params.HeightChange) + end, + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "MapHeightScale", +}, { + PlaceObj('GridOpMapExport', { + 'Operation', "Height", + 'OutputName', "HeightMap", + }), + PlaceObj('GridOpMulDivAdd', { + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'Mul', 4, + 'Div', 5, + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "HeightMap", + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "MapTypeReplace", +}, { + PlaceObj('GridOpMapExport', { + 'OutputName', "TypeMap", + }), + PlaceObj('GridOpMapParamType', { + 'ParamName', "OldType", + }), + PlaceObj('GridOpMapParamType', { + 'ParamName', "NewType", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "OldIdx", + 'ParamValue', "GetTerrainTextureIndex(OldType)", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "NewIdx", + 'ParamValue', "GetTerrainTextureIndex(NewType)", + }), + PlaceObj('GridOpReplace', { + 'UseParams', true, + 'InputName', "TypeMap", + 'OutputName', "TypeMap", + 'OldParam', "OldIdx", + 'NewParam', "NewIdx", + }), + PlaceObj('GridOpMapImport', { + 'InputName', "TypeMap", + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "ShowGrassDensity", + 'OnChange', "", + 'RunMode', "Debug", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpMapExport', { + 'Operation', "Grass", + 'OutputName', "DbgGrid", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'InputName', "DbgGrid", + 'OutputName', "DbgGrid", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpDbg', { + 'Grid', "DbgGrid", + 'Normalize', false, + 'ValueMax', 100, + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "ShowSoundSources", + 'OnChange', "", + 'RunMode', "Debug", +}, { + PlaceObj('GridOpRun', { + 'Operation', "Code", + 'OutputName', "DbgGrid", + 'Code', function (state, grid) + local mw, mh = terrain.GetMapSize() + local gw, gh = terrain.TypeMapSize() + local g = NewComputeGrid(gw, gh, "u", 16) + local hash_to_value = {} + local values = 0 + MapForEach("map", "SoundSource", function(obj) + local h = obj:GetSoundHash() + local v = hash_to_value[h] + if not v then + v = values + 1 + hash_to_value[h] = v + assert(v <= 256) + values = v + end + local mr = obj:ResolveLoudDistance() + if mr > 0 then + local gr = mr * gw / mw + local mx, my = obj:GetPosXYZ() + local gx, gy = mx * gw / mw, my * gh / mh + GridCircleSet(g, v, gx, gy, gr) + end + end) + return nil, g + end, + }), + PlaceObj('GridOpDbg', { + 'Grid', "DbgGrid", + 'ColorRand', true, + 'InvalidValue', 0, + 'ColorFrom', RGBA(255, 0, 0, 0), + 'ColorTo', RGBA(128, 0, 0, 255), + 'Normalize', false, + 'ValueMax', 2, + }), + }) + +PlaceObj('MapGen', { + 'Group', "Tools", + 'SaveIn', "Common", + 'Id', "ShowTerrainTypes", + 'RunMode', "Debug", +}, { + PlaceObj('GridOpMapExport', { + 'OutputName', "DbgGrid", + }), + PlaceObj('GridOpDbg', { + 'Grid', "DbgGrid", + 'ColorRand', true, + }), + }) + +PlaceObj('MapGen', { + 'Group', "WM", + 'SaveIn', "Common", + 'Id', "CreateFlatMaskFromHeight", + 'Comment', "Create WM_FlatMask + WM_HeightMap", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpMapExport', { + 'Operation', "Height", + 'OutputName', "HeightMapOriginal", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'Operation', "Extend", + 'InputName', "HeightMapOriginal", + 'OutputName', "HeightMap", + 'Width', 4096, + 'WidthParam', "MapGridWidth", + 'Height', 4096, + 'HeightParam', "MapGridHeight", + 'ExtendMode', 1, + }), + PlaceObj('GridOpMapSlope', { + 'InputName', "HeightMap", + 'OutputName', "HeightMapSlope", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaxPlaySin", + 'ParamValue', "sin(const.RandomMap.PrefabMaxPlayAngle)", + 'ParamLocal', true, + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MaskBorder", + 'ParamValue', "(PassBorder + 50*guim) / HeightTile", + 'ParamLocal', true, + }), + PlaceObj('GridOpMulDivAdd', { + 'InputName', "HeightMapSlope", + 'OutputName', "HeightMapSlope", + 'Mul', 4096, + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Mask", + 'InputName', "HeightMapSlope", + 'OutputName', "FlatMask", + 'Max', 0, + 'MaxParam', "MaxPlaySin", + }), + PlaceObj('GridOpDraw', { + 'UseParams', true, + 'OutputName', "HeightMapFrame", + 'RefName', "HeightMap", + 'GridDefault', 1, + 'DrawValue', 0, + 'DrawBorderParam', "MaskBorder", + }), + PlaceObj('GridOpMulDivAdd', { + 'InputName', "FlatMask", + 'OutputName', "FlatMask", + 'MulName', "HeightMapFrame", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "MinPlayRadius", + 'ParamValue', "40*guim", + 'ParamLocal', true, + }), + PlaceObj('GridOpEnumAreas', { + 'UseParams', true, + 'InputName', "FlatMask", + 'OutputName', "FlatZones", + 'MinBorderParam', "MinPlayRadius", + 'TileParam', "HeightTile", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "SmoothDist", + 'ParamValue', "64*guim", + 'ParamLocal', true, + }), + PlaceObj('GridOpDistance', { + 'UseParams', true, + 'Operation', "Wave", + 'InputName', "FlatZones", + 'OutputName', "FlatZonesDist", + 'Tile', 1000, + 'TileParam', "HeightTile", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Clamp", + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesDist", + 'Max', 100, + 'MaxParam', "SmoothDist", + }), + PlaceObj('GridOpDistort', { + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesDist", + 'Frequency', 60, + 'Octave_1', 32, + 'Octave_2', 64, + 'Octave_3', 128, + 'Octave_4', 256, + 'Octave_5', 512, + 'Octave_6', 1024, + 'Octave_7', 512, + 'Octave_8', 256, + 'Octave_9', 128, + 'Strength', 30, + }), + PlaceObj('GridOpFilter', { + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesDist", + 'Intensity', 2, + }), + PlaceObj('GridOpDbg', { + 'Grid', "FlatZonesDist", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesWrite", + 'Max', 65535, + 'MaxParam', "MaxRaw16", + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "FlatZonesWrite", + 'OutputName', "FlatZonesWrite", + 'GridType', "uint16", + 'GridRound', true, + }), + PlaceObj('GridOpWrite', { + 'InputName', "FlatZonesWrite", + 'FileRelative', true, + 'FileName', "WM_FlatMask.r16", + 'FileFormat', "raw16", + 'Normalize', false, + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "HeightMap", + 'OutputName', "HeightMapWrite", + 'MinParam', "HeightMin", + 'Max', 100, + 'MaxParam', "HeightMax", + 'Remap', true, + 'RemapMax', 65535, + 'RemapMaxParam', "MaxRaw16", + }), + PlaceObj('GridOpWrite', { + 'InputName', "HeightMapWrite", + 'FileRelative', true, + 'FileName', "WM_HeightMap.r16", + 'FileFormat', "raw16", + 'Normalize', false, + }), + }) + +PlaceObj('MapGen', { + 'Group', "WM", + 'SaveIn', "Common", + 'Id', "CreateFlatMaskFromTexture", + 'Comment', "Create WM_FlatMask + WM_HeightMap", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpComment', { + 'Comment', "---- Export the height map", + }), + PlaceObj('GridOpMapExport', { + 'Operation', "Height", + 'OutputName', "HeightMapOriginal", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'Operation', "Extend", + 'InputName', "HeightMapOriginal", + 'OutputName', "HeightMap", + 'Width', 4096, + 'WidthParam', "MapGridWidth", + 'Height', 4096, + 'HeightParam', "MapGridHeight", + 'ExtendMode', 1, + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "HeightMap", + 'OutputName', "HeightMapWrite", + 'MinParam', "HeightMin", + 'Max', 100, + 'MaxParam', "HeightMax", + 'Remap', true, + 'RemapMax', 65535, + 'RemapMaxParam', "MaxRaw16", + }), + PlaceObj('GridOpWrite', { + 'InputName', "HeightMapWrite", + 'FileRelative', true, + 'FileName', "WM_HeightMap.r16", + 'FileFormat', "raw16", + 'Normalize', false, + }), + PlaceObj('GridOpComment', { + 'Comment', "---- Export the flat mask", + }), + PlaceObj('GridOpMapExport', { + 'OutputName', "TypeMapOriginal", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'Operation', "Extend", + 'InputName', "TypeMapOriginal", + 'OutputName', "TypeMap", + 'Width', 4096, + 'WidthParam', "MapGridWidth", + 'Height', 4096, + 'HeightParam', "MapGridHeight", + 'ExtendMode', 1, + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Mask", + 'InputName', "TypeMap", + 'OutputName', "TypeMapMask", + 'Max', 0, + 'Remap', true, + 'RemapMin', 1, + 'RemapMax', 0, + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "TypeMapMask", + 'OutputName', "TypeMapMask", + 'GridType', "float", + }), + PlaceObj('GridOpParamEval', { + 'ParamName', "SmoothDist", + 'ParamValue', "64*guim", + 'ParamLocal', true, + }), + PlaceObj('GridOpDistance', { + 'UseParams', true, + 'Operation', "Wave", + 'InputName', "TypeMapMask", + 'OutputName', "FlatZonesDist", + 'Tile', 1000, + 'TileParam', "HeightTile", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Clamp", + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesDist", + 'Max', 100, + 'MaxParam', "SmoothDist", + }), + PlaceObj('GridOpFilter', { + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesDist", + 'Intensity', 2, + }), + PlaceObj('GridOpDbg', { + 'Grid', "FlatZonesDist", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'InputName', "FlatZonesDist", + 'OutputName', "FlatZonesWrite", + 'Max', 65535, + 'MaxParam', "MaxRaw16", + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "FlatZonesWrite", + 'OutputName', "FlatZonesWrite", + 'GridType', "uint16", + 'GridRound', true, + }), + PlaceObj('GridOpWrite', { + 'InputName', "FlatZonesWrite", + 'FileRelative', true, + 'FileName', "WM_FlatMask.r16", + 'FileFormat', "raw16", + 'Normalize', false, + }), + }) + +PlaceObj('MapGen', { + 'Group', "WM", + 'SaveIn', "Common", + 'Id', "GenerateHeightFromNoise", + 'Comment', "Generate WM_HeightMap from noise", + 'Lightmodel', "LevelDesign", + 'Randomize', true, +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpNoise', { + 'UseParams', true, + 'OutputName', "WorkMap", + 'Width', 4096, + 'WidthParam', "MapGridWidth", + 'Height', 4096, + 'HeightParam', "MapGridHeight", + 'GridType', "float", + 'Frequency', 0, + 'Persistence', 40, + 'Octaves', 6, + 'Octave_2', 409, + 'Octave_3', 163, + 'Octave_4', 65, + 'Octave_5', 26, + 'Octave_6', 10, + }), + PlaceObj('GridOpDistort', { + 'InputName', "WorkMap", + 'OutputName', "WorkMap", + 'Frequency', 10, + 'Octave_1', 512, + 'Octave_2', 1024, + 'Octave_3', 512, + 'Octave_4', 256, + 'Octave_5', 128, + 'Octave_6', 64, + 'Octave_7', 32, + 'Octave_8', 16, + 'Octave_9', 8, + 'Scale', 200, + }), + PlaceObj('GridOpChangeLim', { + 'InputName', "WorkMap", + 'OutputName', "WorkMap", + 'Min', 10, + 'Max', 90, + 'MaxParam', "MapMaxHeight", + }), + PlaceObj('GridOpRun', { + 'Sequence', "NoiseExport_CreateBorder", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "WorkMap", + 'OutputName', "WorkMapWrite", + 'Max', 100, + 'Remap', true, + 'RemapMax', 255, + 'RemapMaxParam', "MaxRaw16", + }), + PlaceObj('GridOpConvert', { + 'Operation', "Repack", + 'InputName', "WorkMapWrite", + 'OutputName', "WorkMapWrite", + 'GridType', "uint16", + }), + PlaceObj('GridOpWrite', { + 'InputName', "WorkMapWrite", + 'FileRelative', true, + 'FileName', "WM_HeightMap.r16", + 'FileFormat', "raw16", + 'Normalize', false, + }), + PlaceObj('GridOpRun', { + 'Sequence', "NoiseExport_ShowOnMap", + }), + }) + +PlaceObj('MapGen', { + 'Group', "WM", + 'SaveIn', "Common", + 'Id', "ShowGrids", + 'Comment', "Show WM_HeightMap + WM_FlatMask", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpRun', { + 'Sequence', "MapClear", + }), + PlaceObj('GridOpRead', { + 'Comment', "Read the height map", + 'OutputName', "HeightMap", + 'FileName', "WM_HeightMap.r16", + 'FileFormat', "raw16", + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'Max', 65535, + 'MaxParam', "MaxRaw16", + 'Remap', true, + 'RemapMin', 10000, + 'RemapMinParam', "HeightMin", + 'RemapMax', 50000, + 'RemapMaxParam', "HeightMax", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "HeightMap", + }), + PlaceObj('GridOpRead', { + 'Optional', true, + 'Comment', "Read the flat mask", + 'OutputName', "FlatMask", + 'FileName', "WM_FlatMask.r16", + 'FileFormat', "raw16", + }), + PlaceObj('GridOpResample', { + 'Optional', true, + 'UseParams', true, + 'InputName', "FlatMask", + 'OutputName', "FlatMask", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpChangeLim', { + 'Optional', true, + 'Operation', "Mask", + 'InputName', "FlatMask", + 'OutputName', "FlatMask", + 'Max', 0, + 'Remap', true, + 'RemapMin', 1, + 'RemapMax', 0, + }), + PlaceObj('GridOpParamEval', { + 'Comment', "Terrain used to draw the mask at index 1", + 'ParamName', "DrawMaskTerrain", + 'ParamValue', "TerrainTextures[1].id", + }), + PlaceObj('GridOpMapImport', { + 'Optional', true, + 'UseParams', true, + 'InputName', "FlatMask", + 'TextureType', "Grass_01", + 'TextureTypeParam', "DrawMaskTerrain", + }), + }) + +PlaceObj('MapGen', { + 'Group', "WM", + 'SaveIn', "Common", + 'Id', "ShowPng", + 'Comment', "Show HeightMapOrig", +}, { + PlaceObj('GridOpRun', { + 'Sequence', "MapParams", + }), + PlaceObj('GridOpRun', { + 'Sequence', "MapClear", + }), + PlaceObj('GridOpRead', { + 'OutputName', "HeightMap", + 'FileName', "HeightMapOrig.png", + 'FileFormat', "image", + }), + PlaceObj('GridOpChangeLim', { + 'UseParams', true, + 'Operation', "Remap", + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'Max', 255, + 'Remap', true, + 'RemapMin', 10000, + 'RemapMinParam', "HeightMin", + 'RemapMax', 50000, + 'RemapMaxParam', "HeightMax", + }), + PlaceObj('GridOpFilter', { + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'Intensity', 3, + }), + PlaceObj('GridOpResample', { + 'UseParams', true, + 'InputName', "HeightMap", + 'OutputName', "HeightMap", + 'WidthParam', "MapGridWidth", + 'HeightParam', "MapGridHeight", + }), + PlaceObj('GridOpMapImport', { + 'Operation', "Height", + 'InputName', "HeightMap", + }), + }) + diff --git a/CommonLua/Data/ModLinkDef.lua b/CommonLua/Data/ModLinkDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..86c32c9c7a8582691ef1c8e4e08ca862b777aeee --- /dev/null +++ b/CommonLua/Data/ModLinkDef.lua @@ -0,0 +1,158 @@ +-- ========== GENERATED BY ModLinkDef Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(bitbucket.org/.+)", + "^https://(bitbucket%.org/.+)", + }, + display_name = T(726133463128, --[[ModLinkDef Bitbucket display_name]] "Bitbucket"), + group = "Assets", + id = "Bitbucket", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(github%.com/.+)", + "^https://(github%.com/.+)", + }, + display_name = T(201108242532, --[[ModLinkDef Github display_name]] "Github"), + group = "Assets", + id = "Github", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(gitlab%.com/.+)", + "^https://(gitlab%.com/.+)", + }, + display_name = T(227853310028, --[[ModLinkDef Gitlab display_name]] "Gitlab"), + group = "Assets", + id = "Gitlab", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(sketchfab.com/.+)", + "^https://(sketchfab.com/.+)", + }, + display_name = T(390355834669, --[[ModLinkDef Sketchfab display_name]] "Sketchfab"), + group = "Assets", + id = "Sketchfab", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(buymeacoffee%.com/[_%w%-]+)/?$", + }, + display_name = T(959743421223, --[[ModLinkDef BuyMeACoffee display_name]] "Buy me a coffee"), + group = "Donate", + id = "BuyMeACoffee", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(gofundme%.com/[_%w%-/]+)/?$", + }, + display_name = T(906349240178, --[[ModLinkDef GoFundMe display_name]] "Go fund me"), + group = "Donate", + id = "GoFundMe", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(patreon%.com/[_%w%-]+)/?$", + }, + display_name = T(600813442235, --[[ModLinkDef Patreon display_name]] "Patreon"), + group = "Donate", + id = "Patreon", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(paypal%.com/[_%w%-/]+)/?$", + }, + display_name = T(438718754503, --[[ModLinkDef PayPal display_name]] "PayPal"), + group = "Donate", + id = "PayPal", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(discord%.gg/.+)", + "^https://(discord%.gg/.+)", + }, + display_name = T(246193250881, --[[ModLinkDef Discord display_name]] "Discord"), + group = "Social", + id = "Discord", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://[%w%-]+%.(facebook%.com/[_%w%-]+)/?$", + }, + display_name = T(204821323993, --[[ModLinkDef Facebook display_name]] "Facebook"), + group = "Social", + id = "Facebook", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(instagram%.com/@[_%w%-]+)/?$", + }, + display_name = T(288121601820, --[[ModLinkDef Instagram display_name]] "Instagram"), + group = "Social", + id = "Instagram", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://(steamcommunity%.com)/.+", + }, + display_name = T(584767138531, --[[ModLinkDef Steam display_name]] "Steam"), + group = "Social", + id = "Steam", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(tiktok%.com/@[_%w%-]+)/?$", + }, + display_name = T(700359671296, --[[ModLinkDef TikTok display_name]] "TikTok"), + group = "Social", + id = "TikTok", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(twitter%.com/[_%w%-]+)/?$", + "^https://www%.(x%.com/[_%w%-]+)/?$", + }, + display_name = T(721067395835, --[[ModLinkDef XTwitter display_name]] "X/Twitter"), + group = "Social", + id = "XTwitter", + save_in = "Common", +}) + +PlaceObj('ModLinkDef', { + Patterns = { + "^https://www%.(youtube%.com/@[_%w%-]+)/?$", + }, + display_name = T(127341618668, --[[ModLinkDef YouTube display_name]] "YouTube"), + group = "Social", + id = "YouTube", + save_in = "Common", +}) + diff --git a/CommonLua/Data/MsgDef.lua b/CommonLua/Data/MsgDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..ea59294a020e06e5dc6a21622a674ca833111825 --- /dev/null +++ b/CommonLua/Data/MsgDef.lua @@ -0,0 +1,439 @@ +-- ========== GENERATED BY MsgDef Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('MsgDef', { + Params = "prop, old_value, new_value", + group = "Msg", + id = "ConstValueChanged", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "An object's cooldown has expired, send only for cooldowns with ExpireMsg = true", + Params = "obj, cooldown_id, cooldown_def", + group = "Msg", + id = "CooldownExpired", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "An object's cooldown became active", + Params = "obj, cooldown_id, cooldown_def", + group = "Msg", + id = "CooldownSet", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "obj", + group = "Msg", + id = "FallingDebrisCrash", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "achievement_id", + group = "Msg - Achievements", + id = "AchievementProgress", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "achievement_id", + group = "Msg - Achievements", + id = "AchievementUnlocked", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "query, request", + group = "Msg - Game", + id = "CanSaveGameQuery", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "The current game is being destroyed", + Params = "game", + group = "Msg - Game", + id = "DoneGame", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Game", + id = "GameOver", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "One or more GameState (global) values have changed", + Params = "new_state", + group = "Msg - Game", + id = "GameStateChanged", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Game", + id = "GameStateChangedNotify", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Game", + id = "GameTimeStart", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "Game loaded", + Params = "game", + group = "Msg - Game", + id = "LoadGame", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "New game created", + Params = "game", + group = "Msg - Game", + id = "NewGame", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "reason", + group = "Msg - Game", + id = "Pause", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "player", + group = "Msg - Game", + id = "PlayerObjectCreated", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "metadata, version", + group = "Msg - Game", + id = "PostLoadGame", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "metadata", + group = "Msg - Game", + id = "PreLoadGame", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Game", + id = "QuitGame", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "reason", + group = "Msg - Game", + id = "Resume", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Game", + id = "Start", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "view, lightmodel, time, prev_lm, from_override", + group = "Msg - Map", + id = "AfterLightmodelChange", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "The current map is about to be changed", + Params = "map", + group = "Msg - Map", + id = "ChangeMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "map", + group = "Msg - Map", + id = "ChangeMapDone", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "The current map is being destroyed", + group = "Msg - Map", + id = "DoneMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "view, lightmodel, time, prev_lm, from_override", + group = "Msg - Map", + id = "LightmodelChange", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "New map is about to be loaded", + Params = "map, mapdata", + group = "Msg - Map", + id = "NewMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "Map grids and objects are loaded but passability is not calculated yet", + group = "Msg - Map", + id = "NewMapLoaded", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Map", + id = "PostDoneMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "Map loading is complete. Passability is calculated and GameInit methods of objects are called.", + group = "Msg - Map", + id = "PostNewMapLoaded", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Map", + id = "PostSaveMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "map, mapdata", + group = "Msg - Map", + id = "PreNewMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Map", + id = "PreSaveMap", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "obj, prev", + group = "Msg - Map", + id = "SelectedObjChange", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "obj", + group = "Msg - Map", + id = "SelectionAdded", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Map", + id = "SelectionChange", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "obj", + group = "Msg - Map", + id = "SelectionRemoved", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - PhotoMode", + id = "PhotoModeBegin", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - PhotoMode", + id = "PhotoModeEnd", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - PhotoMode", + id = "PhotoModeFreeCameraActivated", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - PhotoMode", + id = "PhotoModeFreeCameraDeactivated", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - PhotoMode", + id = "PhotoModeScreenshotTaken", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Setpiece", + id = "SetPieceDoneWaitingLS", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "actor", + group = "Msg - Setpiece", + id = "SetpieceActorRegistered", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "actor", + group = "Msg - Setpiece", + id = "SetpieceActorUnegistered", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "state, thread, statement", + group = "Msg - Setpiece", + id = "SetpieceCommandCompleted", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Setpiece", + id = "SetpieceDialogClosed", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "setpiece, state", + group = "Msg - Setpiece", + id = "SetpieceEndExecution", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "setpiece", + group = "Msg - Setpiece", + id = "SetpieceEnded", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "setpiece", + group = "Msg - Setpiece", + id = "SetpieceEnding", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "setpiece", + group = "Msg - Setpiece", + id = "SetpieceStartExecution", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - Setpiece", + id = "SetpieceStarted", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "setpiece", + group = "Msg - Setpiece", + id = "SetpieceStarting", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "storybit_id, storybit_state", + group = "Msg - StoryBit", + id = "StoryBitActivated", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "storybit_id, storybit_state", + group = "Msg - StoryBit", + id = "StoryBitCompleted", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "storybit_id, storybit_state", + group = "Msg - StoryBit", + id = "StoryBitPopup", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "storybit_id, storybit_state, reply_counter", + group = "Msg - StoryBit", + id = "StoryBitReplyActivated", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "new_mode", + group = "Msg - UI", + id = "IGIModeChanged", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "old_mode, new_mode", + group = "Msg - UI", + id = "IGIModeChanging", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Params = "inGameInterface", + group = "Msg - UI", + id = "InGameInterfaceCreated", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - UI", + id = "InGameMenuClose", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + group = "Msg - UI", + id = "InGameMenuOpen", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "Closed the pregame menu.", + group = "Msg - UI", + id = "PreGameMenuClose", + save_in = "Common", +}) + +PlaceObj('MsgDef', { + Description = "Opened the pregame menu.", + group = "Msg - UI", + id = "PreGameMenuOpen", + save_in = "Common", +}) + diff --git a/CommonLua/Data/PersistedRenderVars.lua b/CommonLua/Data/PersistedRenderVars.lua new file mode 100644 index 0000000000000000000000000000000000000000..5b27da030b1358c1097febe3d597c3d74504676a --- /dev/null +++ b/CommonLua/Data/PersistedRenderVars.lua @@ -0,0 +1,51 @@ +-- ========== GENERATED BY PersistedRenderVars Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('CRM_DebugMeshMaterial', { + dirty = false, + id = "DebugMesh", + save_in = "Common", +}) + +PlaceObj('XTextParserVars', { + Active = true, + BreakCandidates = { + PlaceObj('BreakCandidateRange', { + 'Begin', 12288, + 'End', 12351, + 'Comment', "CJK Symbols and Punctuation", + }), + PlaceObj('BreakCandidateRange', { + 'Begin', 12352, + 'End', 12447, + 'Comment', "Hiragana", + }), + PlaceObj('BreakCandidateRange', { + 'Begin', 12448, + 'End', 12543, + 'Comment', "Katakana", + }), + PlaceObj('BreakCandidateRange', { + 'Begin', 19968, + 'End', 40959, + 'Comment', "CJK Unified Ideographs block", + }), + PlaceObj('BreakCandidateRange', { + 'Begin', 13312, + 'End', 19903, + 'Comment', "CJKUI Ext A block", + }), + PlaceObj('BreakCandidateRange', { + 'Begin', 131072, + 'End', 173791, + 'Comment', "CJKUI Ext B block", + }), + PlaceObj('BreakCandidateRange', { + 'Begin', 173824, + 'End', 177983, + 'Comment', "CJKUI Ext C block", + }), + }, + id = "DefaultXTextParserVars", + save_in = "Common", +}) + diff --git a/CommonLua/Data/PhotoFilterPreset.lua b/CommonLua/Data/PhotoFilterPreset.lua new file mode 100644 index 0000000000000000000000000000000000000000..16341109d5c9f36d6e412dd14bc28b673908a5d3 --- /dev/null +++ b/CommonLua/Data/PhotoFilterPreset.lua @@ -0,0 +1,66 @@ +-- ========== GENERATED BY PhotoFilterPreset Editor DO NOT EDIT MANUALLY! ========== + +PlaceObj('PhotoFilterPreset', { + SortKey = 1000, + description = T(654184428138, --[[PhotoFilterPreset None description]] "None"), + display_name = T(802694458575, --[[PhotoFilterPreset None display_name]] "None"), + group = "Default", + id = "None", + save_in = "Common", +}) + +PlaceObj('PhotoFilterPreset', { + SortKey = 2000, + description = T(265999916898, --[[PhotoFilterPreset BlackAndWhite1 description]] "Black and White 1"), + display_name = T(271345617623, --[[PhotoFilterPreset BlackAndWhite1 display_name]] "B&W 1"), + group = "Default", + id = "BlackAndWhite1", + save_in = "Common", + shader_file = "Shaders/PhotoFilter.fx", + shader_pass = "BLACK_AND_WHITE_1", +}) + +PlaceObj('PhotoFilterPreset', { + SortKey = 3000, + description = T(239091692687, --[[PhotoFilterPreset BlackAndWhite2 description]] "Black and White 2"), + display_name = T(452465285255, --[[PhotoFilterPreset BlackAndWhite2 display_name]] "B&W 2"), + group = "Default", + id = "BlackAndWhite2", + save_in = "Common", + shader_file = "Shaders/PhotoFilter.fx", + shader_pass = "BLACK_AND_WHITE_2", +}) + +PlaceObj('PhotoFilterPreset', { + SortKey = 4000, + description = T(663553818984, --[[PhotoFilterPreset BlackAndWhite3 description]] "Black and White 3"), + display_name = T(288633521351, --[[PhotoFilterPreset BlackAndWhite3 display_name]] "B&W 3"), + group = "Default", + id = "BlackAndWhite3", + save_in = "Common", + shader_file = "Shaders/PhotoFilter.fx", + shader_pass = "BLACK_AND_WHITE_3", +}) + +PlaceObj('PhotoFilterPreset', { + SortKey = 5000, + description = T(203834785616, --[[PhotoFilterPreset BleachBypass description]] "Bleach Bypass"), + display_name = T(965604869626, --[[PhotoFilterPreset BleachBypass display_name]] "Bleach Bypass"), + group = "Default", + id = "BleachBypass", + save_in = "Common", + shader_file = "Shaders/PhotoFilter.fx", + shader_pass = "BLEACH_BYPASS", +}) + +PlaceObj('PhotoFilterPreset', { + SortKey = 6000, + description = T(575543380238, --[[PhotoFilterPreset OrtonEffect description]] "A vivid effect"), + display_name = T(330875391821, --[[PhotoFilterPreset OrtonEffect display_name]] "Orton"), + group = "Default", + id = "OrtonEffect", + save_in = "Common", + shader_file = "Shaders/PhotoFilter.fx", + shader_pass = "ORTON_EFFECT", +}) + diff --git a/CommonLua/Data/StoryBit/TestStoryBit.lua b/CommonLua/Data/StoryBit/TestStoryBit.lua new file mode 100644 index 0000000000000000000000000000000000000000..12a8e0779113629f4dff1a79c52cdd76a7a5b92d --- /dev/null +++ b/CommonLua/Data/StoryBit/TestStoryBit.lua @@ -0,0 +1,29 @@ +-- ========== GENERATED BY StoryBit Editor (Ctrl-Alt-E) DO NOT EDIT MANUALLY! ========== + +PlaceObj('StoryBit', { + Category = "", + Comment = "Added here to be used in the test harness of classes, defined in Common", + EnableChance = 0, + Enabled = true, + ExpirationTime = 40000, + PopupFxAction = "MessagePopup", + ScriptDone = true, + group = "Default", + id = "TestStoryBit", + qa_info = PlaceObj('PresetQAInfo', { + data = { + { + action = "Modified", + time = 1618239324, + user = "Vihar", + }, + { + action = "Modified", + time = 1665415594, + user = "Xaerial", + }, + }, + }), + save_in = "Common", +}) + diff --git a/CommonLua/Data/TextStyle.lua b/CommonLua/Data/TextStyle.lua new file mode 100644 index 0000000000000000000000000000000000000000..61d8763c44fc8f4b6d610275803704a7f07ef60e --- /dev/null +++ b/CommonLua/Data/TextStyle.lua @@ -0,0 +1,625 @@ +-- ========== GENERATED BY TextStyle Editor (Ctrl-Alt-T) DO NOT EDIT MANUALLY! ========== + +PlaceObj('TextStyle', { + RolloverTextColor = -3231394, + ShadowColor = -16777216, + ShadowSize = 1, + TextColor = -1, + TextFont = T(550077700341, --[[TextStyle AnimMetadataEditorTimeline TextFont]] "droid, 15, bold"), + group = "Common", + id = "AnimMetadataEditorTimeline", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = -16777216, + ShadowSize = 2, + ShadowType = "outline", + TextColor = -1, + TextFont = T(677066324472, --[[TextStyle BugReportScreenshot TextFont]] "SchemeBk, 19"), + group = "Common", + id = "BugReportScreenshot", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextFont = T(540377112511, --[[TextStyle Console TextFont]] "droid, 18, bold"), + group = "Common", + id = "Console", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = -16777216, + ShadowSize = 1, + TextColor = -1, + TextFont = T(299680845876, --[[TextStyle ConsoleLog TextFont]] "droid, 13, bold"), + group = "Common", + id = "ConsoleLog", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextFont = T(676833502715, --[[TextStyle DevMenuBar TextFont]] "droid, 18"), + group = "Common", + id = "DevMenuBar", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 4294561280, + DisabledTextColor = 4294561280, + RolloverTextColor = 4294561280, + ShadowColor = 4278190080, + ShadowSize = 2, + TextColor = 4294561280, + TextFont = T(658220415596, --[[TextStyle EditorMapVariation TextFont]] "droid, 16, bold"), + group = "Common", + id = "EditorMapVariation", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextColor = -1, + TextFont = T(462336086331, --[[TextStyle EditorText TextFont]] "droid, 13"), + group = "Common", + id = "EditorText", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "EditorTextBoldDarkMode", + ShadowColor = 4278190080, + ShadowSize = 1, + TextColor = -1, + TextFont = T(379507993149, --[[TextStyle EditorTextBold TextFont]] "droid, 13, bold"), + group = "Common", + id = "EditorTextBold", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = 4278190080, + ShadowSize = 1, + TextColor = -1, + TextFont = T(906142381584, --[[TextStyle EditorTextBoldDarkMode TextFont]] "droid, 13, bold"), + group = "Common", + id = "EditorTextBoldDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextColor = -1, + TextFont = T(383557292126, --[[TextStyle EditorTextObject TextFont]] "droid, 10"), + group = "Common", + id = "EditorTextObject", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextFont = T(694918354766, --[[TextStyle EditorToolbar TextFont]] "droid, 16"), + group = "Common", + id = "EditorToolbar", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = 4278321409, + ShadowSize = 1, + ShadowType = "outline", + TextColor = 4294967295, + group = "Common", + id = "FXSourceText", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextColor = -1, + TextFont = T(707495870796, --[[TextStyle GedButton TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedButton", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = -16744448, + group = "Common", + id = "GedComment", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedConsoleDarkMode", + TextFont = T(121437438175, --[[TextStyle GedConsole TextFont]] "Inconsolata, 10, mono"), + group = "Common", + id = "GedConsole", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -8026488, + DisabledTextColor = -8026488, + RolloverTextColor = -5066062, + ShadowColor = 1073741824, + ShadowSize = 2, + TextColor = -5066062, + TextFont = T(188380725313, --[[TextStyle GedConsoleDarkMode TextFont]] "Inconsolata, 10, mono"), + group = "Common", + id = "GedConsoleDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedDefaultDarkMode", + DisabledRolloverTextColor = 2149589024, + RolloverTextColor = 4280295456, + TextFont = T(652883517412, --[[TextStyle GedDefault TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedDefault", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -2135772494, + DisabledTextColor = -2135772494, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(790478701043, --[[TextStyle GedDefaultDarkMode TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedDefaultDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -2135772494, + DisabledTextColor = -2135772494, + RolloverTextColor = -4210753, + ShadowColor = 4278190080, + ShadowSize = 2, + ShadowType = "outline", + TextColor = -4210753, + TextFont = T(509845126220, --[[TextStyle GedDefaultDarkModeOutline TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedDefaultDarkModeOutline", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedDefaultDarkMode", + DisabledRolloverTextColor = 2149589024, + RolloverTextColor = 4294967295, + TextColor = 4294967295, + TextFont = T(160098492013, --[[TextStyle GedDefaultWhite TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedDefaultWhite", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + RolloverTextColor = 4293067780, + TextColor = -1899516, + TextFont = T(561040482961, --[[TextStyle GedError TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedError", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 4292650258, + DisabledTextColor = 4292650258, + RolloverTextColor = 4292650258, + TextColor = 4292650258, + TextFont = T(682453582287, --[[TextStyle GedHighlight TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedHighlight", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedMultiLineDarkMode", + DisabledRolloverTextColor = -14671840, + DisabledTextColor = -14671840, + RolloverTextColor = -14671840, + TextFont = T(853003495629, --[[TextStyle GedMultiLine TextFont]] "SchemeBk, 13"), + group = "Common", + id = "GedMultiLine", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -4210753, + DisabledTextColor = -4210753, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(417624181884, --[[TextStyle GedMultiLineDarkMode TextFont]] "SchemeBk, 13"), + group = "Common", + id = "GedMultiLineDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 4283132358, + DisabledTextColor = 4283132358, + RolloverTextColor = 4283132358, + TextColor = 4283132358, + TextFont = T(544822349038, --[[TextStyle GedName TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedName", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + RolloverTextColor = -1, + TextFont = T(770550214577, --[[TextStyle GedPropTexturePicker TextFont]] "SchemeBk, 12"), + group = "Common", + id = "GedPropTexturePicker", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedPropertyNameDarkMode", + ShadowColor = -16777216, + TextFont = T(279457643881, --[[TextStyle GedPropertyName TextFont]] "droid, 13, bold"), + group = "Common", + id = "GedPropertyName", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = -16777216, + TextColor = 4292927712, + TextFont = T(647029962282, --[[TextStyle GedPropertyNameDarkMode TextFont]] "droid, 13, bold"), + group = "Common", + id = "GedPropertyNameDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = -16777216, + TextFont = T(117036373885, --[[TextStyle GedResetBtn TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedResetBtn", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedScriptDarkMode", + DisabledRolloverTextColor = 4284506208, + DisabledTextColor = 4284506208, + RolloverTextColor = -14671840, + TextFont = T(406478898071, --[[TextStyle GedScript TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedScript", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 4286414205, + DisabledTextColor = 4286414205, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(499673321480, --[[TextStyle GedScriptDarkMode TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedScriptDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 4292643868, + DisabledTextColor = 4292643868, + RolloverTextColor = 4292643868, + TextColor = 4292643868, + TextFont = T(620826385810, --[[TextStyle GedSearchHighlight TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedSearchHighlight", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 4280337440, + DisabledTextColor = 4280337440, + RolloverTextColor = 4280337440, + TextColor = 4280337440, + TextFont = T(366980479101, --[[TextStyle GedSearchHighlightPartial TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedSearchHighlightPartial", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedSmallDarkMode", + TextFont = T(226058743874, --[[TextStyle GedSmall TextFont]] "SchemeBk, 13"), + group = "Common", + id = "GedSmall", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -2135772494, + DisabledTextColor = -2135772494, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(742947978519, --[[TextStyle GedSmallDarkMode TextFont]] "SchemeBk, 13"), + group = "Common", + id = "GedSmallDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedTextPanelDarkMode", + DisabledRolloverTextColor = -9079435, + DisabledTextColor = -9079435, + TextFont = T(964644653008, --[[TextStyle GedTextPanel TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedTextPanel", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -7895161, + DisabledTextColor = -7895161, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(823824662735, --[[TextStyle GedTextPanelDarkMode TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedTextPanelDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedTitleDarkMode", + TextFont = T(530658180499, --[[TextStyle GedTitle TextFont]] "SchemeBk, 21"), + group = "Common", + id = "GedTitle", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -2135772494, + DisabledTextColor = -2135772494, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(711863707650, --[[TextStyle GedTitleDarkMode TextFont]] "SchemeBk, 21"), + group = "Common", + id = "GedTitleDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "GedTitleSmallDarkMode", + TextFont = T(441681013080, --[[TextStyle GedTitleSmall TextFont]] "SchemeBk, 18"), + group = "Common", + id = "GedTitleSmall", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -2135772494, + DisabledTextColor = -2135772494, + RolloverTextColor = -4210753, + TextColor = -4210753, + TextFont = T(403223298234, --[[TextStyle GedTitleSmallDarkMode TextFont]] "SchemeBk, 18"), + group = "Common", + id = "GedTitleSmallDarkMode", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + RolloverTextColor = 4294937600, + TextColor = 4294937600, + TextFont = T(490275285466, --[[TextStyle GedWarning TextFont]] "SchemeBk, 15"), + group = "Common", + id = "GedWarning", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = 1979711488, + ShadowSize = 2, + TextColor = -1, + TextFont = T(895197799725, --[[TextStyle GizmoText TextFont]] "droid, 32, bold"), + group = "Common", + id = "GizmoText", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = -11513776, + TextFont = T(284328712097, --[[TextStyle Heading1 TextFont]] "Source Sans Pro, 24, bold"), + group = "Common", + id = "Heading1", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = -11513776, + TextFont = T(885335880759, --[[TextStyle Heading2 TextFont]] "Source Sans Pro, 20, bold"), + group = "Common", + id = "Heading2", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = -11513776, + TextFont = T(439585102447, --[[TextStyle Heading3 TextFont]] "Source Sans Pro, 16, bold"), + group = "Common", + id = "Heading3", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = 4283453520, + TextFont = T(129096453581, --[[TextStyle Heading4 TextFont]] "Source Sans Pro, 12, bold"), + group = "Common", + id = "Heading4", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 1, + TextColor = 4294964418, + TextFont = T(945938229604, --[[TextStyle InfoText TextFont]] "droid, 16"), + group = "Common", + id = "InfoText", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = 4294967295, + ShadowType = "glow", + TextColor = 4294967295, + TextFont = T(472948101028, --[[TextStyle ProcMeshDefault TextFont]] "Inconsolata, 24, mono"), + group = "Common", + id = "ProcMeshDefault", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = 4194238464, + ShadowSize = 12, + ShadowType = "glow", + TextColor = 4294967295, + TextFont = T(877487182310, --[[TextStyle ProcMeshDefaultFX TextFont]] "Inconsolata, 24, mono"), + group = "Common", + id = "ProcMeshDefaultFX", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowColor = 4278321409, + ShadowSize = 4, + TextColor = -1, + TextFont = T(816198892401, --[[TextStyle SoundSourceText TextFont]] "droid, 16"), + group = "Common", + id = "SoundSourceText", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextFont = T(437851172926, --[[TextStyle UIAttachCursorText TextFont]] "SchemeBk, 26"), + group = "Common", + id = "UIAttachCursorText", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextFont = T(198044104901, --[[TextStyle UICredits TextFont]] "LibelSuitRg, 22"), + group = "Common", + id = "UICredits", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextFont = T(713876112298, --[[TextStyle UICreditsCompanyName TextFont]] "LibelSuitRg, 30"), + group = "Common", + id = "UICreditsCompanyName", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -8882056, + DisabledTextColor = -8882056, + RolloverTextColor = -67606, + ShadowType = "outline", + TextColor = -3231394, + TextFont = T(153850155088, --[[TextStyle UIShowcaseButton TextFont]] "droid, 30"), + group = "Common", + id = "UIShowcaseButton", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -67606, + DisabledTextColor = -67606, + RolloverTextColor = -67606, + ShadowType = "outline", + TextColor = -67606, + TextFont = T(655271755526, --[[TextStyle UIShowcaseDescription TextFont]] "Scheme, 20"), + group = "Common", + id = "UIShowcaseDescription", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = -8882056, + DisabledTextColor = -8882056, + RolloverTextColor = -67606, + ShadowType = "outline", + TextColor = -67606, + TextFont = T(702532298589, --[[TextStyle UIShowcaseTitle TextFont]] "droid, 30"), + group = "Common", + id = "UIShowcaseTitle", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + ShadowSize = 2, + ShadowType = "outline", + TextColor = -12791835, + TextFont = T(230778166687, --[[TextStyle UISubtitles TextFont]] "LibelSuitRg, 20, bold"), + group = "Common", + id = "UISubtitles", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DarkMode = "XEditorRolloverBoldDark", + RolloverTextColor = -14409182, + TextColor = -14409182, + TextFont = T(427815072062, --[[TextStyle XEditorRolloverBold TextFont]] "SchemeBk, 18"), + group = "Common", + id = "XEditorRolloverBold", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + RolloverTextColor = -5066062, + TextColor = -5066062, + TextFont = T(693159462624, --[[TextStyle XEditorRolloverBoldDark TextFont]] "SchemeBk, 18"), + group = "Common", + id = "XEditorRolloverBoldDark", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + DisabledRolloverTextColor = 2155905152, + DisabledTextColor = 2155905152, + RolloverTextColor = -5066062, + ShadowColor = 1073741824, + ShadowSize = 2, + TextColor = -5066062, + TextFont = T(753399336176, --[[TextStyle XEditorToolbarDark TextFont]] "SchemeBk, 18"), + group = "Common", + id = "XEditorToolbarDark", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextFont = T(150733850563, --[[TextStyle XEditorToolbarLight TextFont]] "SchemeBk, 18"), + group = "Common", + id = "XEditorToolbarLight", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = 4278453475, + TextFont = T(561040482961, --[[TextStyle blue TextFont]] "SchemeBk, 15"), + group = "Common", + id = "blue", + save_in = "Common", +}) + +PlaceObj('TextStyle', { + TextColor = 4293067780, + TextFont = T(561040482961, --[[TextStyle red TextFont]] "SchemeBk, 15"), + group = "Common", + id = "red", + save_in = "Common", +}) + diff --git a/CommonLua/Data/__SceneParamDef.lua b/CommonLua/Data/__SceneParamDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..371bb2481f4bb75f24fb3632b71e899d863dc9ff --- /dev/null +++ b/CommonLua/Data/__SceneParamDef.lua @@ -0,0 +1,167 @@ +-- ========== GENERATED BY SceneParamDef Editor DO NOT EDIT MANUALLY! ========== + +DefineSceneParam{ + default_value = 4294967295, + elements = 3, + group = "Cubemap", + id = "EnvDiffuseColor", + prop_category = "Cubemap", + prop_id = "env_diffuse_color", + prop_name = "Env diffuse color", + prop_type = "color", + save_in = "Common", +} +DefineSceneParam{ + default_value = 1000, + group = "Cubemap", + has_min_max = true, + id = "EnvDiffuseMultiplier", + prop_category = "Cubemap", + prop_id = "env_diffuse_multiplier", + prop_max = 4000, + prop_name = "Env diffuse multiplier", + save_in = "Common", +} +DefineSceneParam{ + default_value = 4294967295, + elements = 3, + group = "Cubemap", + id = "EnvSpecularColor", + prop_category = "Cubemap", + prop_id = "env_specular_color", + prop_name = "Env specular color", + prop_type = "color", + save_in = "Common", +} +DefineSceneParam{ + default_value = 1000, + group = "Cubemap", + has_min_max = true, + id = "EnvSpecularMultiplier", + prop_category = "Cubemap", + prop_id = "env_specular_multiplier", + prop_max = 4000, + prop_name = "Env specular multiplier", + save_in = "Common", +} +DefineSceneParam{ + default_value = 4286611584, + elements = 3, + group = "HeatHaze", + id = "HeatHazeColorization", + prop_category = "Heat Haze", + prop_id = "heat_haze_colorization", + prop_name = "Heat haze colorization", + prop_type = "color", + save_in = "Common", +} +DefineSceneParam{ + default_value = 250, + group = "HeatHaze", + has_min_max = true, + id = "HeatHazeHeightFalloff", + prop_category = "Heat Haze", + prop_id = "heat_haze_height_falloff", + prop_max = 500, + prop_min = 1, + prop_name = "Heat haze height falloff", + save_in = "Common", + scale = 10, +} +DefineSceneParam{ + default_value = 50, + group = "HeatHaze", + has_min_max = true, + id = "HeatHazeIntensity", + prop_category = "Heat Haze", + prop_id = "heat_haze_intensity", + prop_max = 100, + prop_name = "Heat haze intensity", + prop_type = "number", + save_in = "Common", + scale = 10, +} +DefineSceneParam{ + default_value = 50, + group = "HeatHaze", + has_min_max = true, + id = "HeatHazeSize", + prop_category = "Heat Haze", + prop_id = "heat_haze_size", + prop_max = 300, + prop_min = 1, + prop_name = "Heat haze size", + save_in = "Common", + scale = 10, +} +DefineSceneParam{ + default_value = 50, + group = "HeatHaze", + has_min_max = true, + id = "HeatHazeSpeed", + prop_category = "Heat Haze", + prop_id = "heat_haze_speed", + prop_max = 100, + prop_name = "Heat haze speed", + save_in = "Common", + scale = 10, +} +DefineSceneParam{ + default_value = 1000, + group = "HeatHaze", + has_min_max = true, + id = "HeatHazeStartDistance", + prop_category = "Heat Haze", + prop_id = "heat_haze_start_distance", + prop_max = 3000, + prop_name = "Heat haze start distance", + save_in = "Common", + scale = 1, +} +DefineSceneParam{ + default_value = 1000, + group = "Moon", + has_min_max = true, + id = "SkyObjectEnvIntensity", + prop_category = "Night Sky", + prop_id = "sky_object_env_intensity", + prop_max = 10000, + prop_name = "Sky env intensity", + save_in = "Common", +} +DefineSceneParam{ + default_value = 1000, + group = "Moon", + has_min_max = true, + id = "SkyObjectSunIntensity", + prop_category = "Night Sky", + prop_id = "sky_object_sun_intensity", + prop_max = 10000, + prop_name = "Sky sun intensity", + save_in = "Common", +} +DefineSceneParam{ + default_value = 0, + group = "Particles", + has_min_max = true, + id = "ParticleBlendExposure", + prop_category = "Exposure", + prop_id = "particle_exposure_blend", + prop_max = 100000, + prop_min = -100000, + prop_name = "Particle Exposure Blend", + save_in = "Common", + scale = 100, +} +DefineSceneParam{ + default_value = 100, + group = "Rain", + has_min_max = true, + id = "RainLightingContrast", + prop_category = "Rain", + prop_id = "rain_lighting_contrast", + prop_max = 100, + prop_name = "Rain lighting contrast", + save_in = "Common", + scale = 100, +} diff --git a/CommonLua/Data/__const.lua b/CommonLua/Data/__const.lua new file mode 100644 index 0000000000000000000000000000000000000000..cd4fe5d810fe9a560536ac963e2424d885f1331a --- /dev/null +++ b/CommonLua/Data/__const.lua @@ -0,0 +1,140 @@ +-- ========== GENERATED BY ConstDef Editor DO NOT EDIT MANUALLY! ========== + +DefineConst{ + group = "Camera", + id = "ShowFloatingTextDist", + save_in = "Common", + scale = "m", + value = 150000, +} +DefineConst{ + Comment = "Used by ExecuteWeakUninterruptable", + group = "CommandImportance", + id = "WeakImportanceThreshold", + save_in = "Common", + value = 50, +} +DefineConst{ + group = "Prefab", + id = "InvalidTerrain", + save_in = "Common", + type = "text", + value = "Invalid", +} +DefineConst{ + group = "RandomMap", + id = "BiomeMaxSeaLevel", + save_in = "Common", + scale = "m", + value = 50000, +} +DefineConst{ + group = "RandomMap", + id = "BiomeMaxWaterDist", + save_in = "Common", + scale = "m", + value = 100000, +} +DefineConst{ + group = "RandomMap", + id = "BiomeMinSeaLevel", + save_in = "Common", + scale = "m", + value = -50000, +} +DefineConst{ + Comment = "controls the maximum allowed object density in the prefabs", + group = "RandomMap", + id = "PrefabAvgObjRadius", + save_in = "Common", + scale = "m", + value = 6000, +} +DefineConst{ + Comment = "", + group = "RandomMap", + id = "PrefabBasePropCount", + save_in = "Common", + value = 12, +} +DefineConst{ + Comment = "Defines a prefab radius extension (percents) where the chance of placing similar prefabs is higher.", + group = "RandomMap", + id = "PrefabGroupSimilarDistPct", + save_in = "Common", + scale = "%", + value = 100, +} +DefineConst{ + Comment = "Defines the grouping chance for similar prefabs.", + group = "RandomMap", + id = "PrefabGroupSimilarWeight", + save_in = "Common", + value = 100, +} +DefineConst{ + Comment = "", + group = "RandomMap", + id = "PrefabMaxMapSize", + save_in = "Common", + scale = "m", + value = 8192000, +} +DefineConst{ + Comment = "controls the maximum allowed object radius in a prefab", + group = "RandomMap", + id = "PrefabMaxObjRadius", + save_in = "Common", + scale = "m", + value = 60000, +} +DefineConst{ + Comment = 'defines the maximum terrain denivelation which is considered "playable"', + group = "RandomMap", + id = "PrefabMaxPlayAngle", + save_in = "Common", + scale = "deg", + value = 180, +} +DefineConst{ + Comment = 'defines the minimum radius inside a zone to be considered "playable"', + group = "RandomMap", + id = "PrefabMinPlayRadius", + save_in = "Common", + scale = "m", + value = 40000, +} +DefineConst{ + Comment = "Maximum memory allowed for caching grids during the raster phase\n(currently 128 * 1024 * 1024)", + group = "RandomMap", + id = "PrefabRasterCacheMemory", + save_in = "Common", + value = 134217728, +} +DefineConst{ + Comment = "controls the number of map quadrants for parallel prefab rasterization", + group = "RandomMap", + id = "PrefabRasterParallelDiv", + save_in = "Common", + value = 8, +} +DefineConst{ + group = "RandomMap", + id = "PrefabVersionLog", + save_in = "Common", + type = "bool", + value = false, +} +DefineConst{ + group = "Scale", + id = "h", + save_in = "Common", + scale = "sec", + value = 40000, +} +DefineConst{ + group = "Scale", + id = "kg", + save_in = "Common", + value = 1000, +} diff --git a/CommonLua/Data/__load.lua b/CommonLua/Data/__load.lua new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/CommonLua/Dlc.lua b/CommonLua/Dlc.lua new file mode 100644 index 0000000000000000000000000000000000000000..e26eedc1ec5d7a0f3937e51ad0867eb77b789d60 --- /dev/null +++ b/CommonLua/Dlc.lua @@ -0,0 +1,953 @@ +--[==[ + +DLC OS-specific files have the following structure: + +DLC content is: + |- autorun.lua + |- revisions.lua + |- Lua.hpk (if containing newer version of Lua, causes reload) + |- Data.hkp (if containing newer version of Data) + |- Data/... (additional data) + |- Maps/... + |- Sounds.hpk + |- EntityTextures.hpk (additional entity textures) + |- ... + +The Autorun returns a table with a description of the dlc: +return { + name = "Colossus", + display_name = T{"Colossus"}, -- optional, must be a T with an id if present + pre_load = function() end, + post_load = function() end, + required_lua_revision = 12345, -- skip loading this DLC below this revision +} + + +DLC mount steps: + +1. Enumerate and mount OS-specific DLC packs (validating them on steam/pc) + -- result is a list of folders containing autorun.lua files + +2. Execute autorun.lua and revisions.lua; autorun.lua should set g_AvailableDlc[dlc.name] = true + +3. Check required_lua_revision and call dlc:pre_load() for each dlc that passes the check + +4. If necessary reload localization, lua and data from the lasest packs (it can update the follow up Dlc load steps) + +5. If necessary reload the latest Dlc assets + -- reload entities + -- reload BinAssets + -- reload sounds + -- reload music + +6. Call the dlc:post_load() for each dlc +]==] + +if FirstLoad then + -- "" and false are "no dlc" values for the missions and achievements systems respectively + g_AvailableDlc = {[""] = true, [false] = true} -- [achievement name] = true ; DLC code is expected to properly init this! + g_DlcDisplayNames = {} -- [achievement name] = "translated string" + DlcFolders = false + DlcDefinitions = false + DataLoaded = false +end + +if FirstLoad and Platform.playstation then + local err, list = AsyncPlayStationAddcontList(0) + g_AddcontStatus = {} + if not err then + for _, addcont in ipairs(list) do + g_AddcontStatus[addcont.label] = addcont.status + end + end +end + +dlc_print = CreatePrint{ + --"dlc" +} + +--[[@@@ +Returns if the player has a specific DLC installed. +@function bool IsDlcAvailable(string dlc) +@param dlc - The ID of a DLC. +@result bool - If the DLC is available and loaded. +]] +function IsDlcAvailable(dlc) + dlc = dlc or false + return g_AvailableDlc[dlc] +end + +function IsDlcOwned(dlc) +end + +function DLCPath(dlc) + if not dlc or dlc == "" then return "" end + return "DLC/" .. dlc +end + +-- Use, for example, for marking savegames. In all other cases use IsDlcAvailable +function GetAvailableDlcList() + local dlcs = {} + for dlc, v in pairs(g_AvailableDlc) do + if v and dlc ~= "" and dlc ~= false then + dlcs[ 1 + #dlcs ] = dlc + end + end + table.sort(dlcs) + return dlcs +end + +function GetDeveloperDlcs() + local dlcs = Platform.developer and IsFSUnpacked() and io.listfiles("svnProject/Dlc/", "*", "folders") or empty_table + for i, folder in ipairs(dlcs) do + dlcs[i] = string.gsub(folder, "svnProject/Dlc/", "") + end + table.sort(dlcs) + return dlcs +end + +DbgAllDlcs = false +DbgAreDlcsMissing = return_true +DbgIgnoreMissingDlcs = rawget(_G, "DbgIgnoreMissingDlcs") or {} + +if Platform.developer and IsFSUnpacked() then + DbgAllDlcs = GetDeveloperDlcs() + function DbgAreDlcsMissing() + for _, dlc in ipairs(DbgAllDlcs or empty_table) do + if not DbgIgnoreMissingDlcs[dlc] and not g_AvailableDlc[dlc] then + return dlc + end + end + end +end + +-- Helper function for tying a savegame to a set of required DLCs +-- metadata = FillDlcMetadata(metadata, dlcs) +function FillDlcMetadata(metadata, dlcs) + metadata = metadata or {} + dlcs = dlcs or GetAvailableDlcList() + local t = {} + for _, dlc in ipairs(dlcs) do + t[#t+1] = {id = dlc, name = g_DlcDisplayNames[dlc] or dlc} + end + metadata.dlcs = t + return metadata +end + +-- Step 1. Enumerate and mount OS-specific DLC packs (validating them on steam/pc) +function DlcMountOsPacks() + dlc_print("Mount Os Packs") + local folders = {} + local error = false + + if Platform.demo then + dlc_print("Mount Os Packs early out: no DLCs in demo") + return folders, error + end + + if Platform.playstation then + for label, status in pairs(g_AddcontStatus) do + if status == const.PlaystationAddcontStatusInstalled then + local addcont_error, mount_point = AsyncPlayStationAddcontMount(0, label) + local pack_error = MountPack(label, mount_point .. "/content.hpk") + error = error or addcont_error or pack_error + table.insert(folders, label) + end + end + dlc_print(string.format("PS4 Addcont: %d listed (%d mounted)", #g_AddcontStatus, #folders)) + end + if Platform.appstore then + local content = AppStore.ListDownloadedContent() + for i=1, #content do + local folder = "Dlc" .. i + local err = MountPack(folder, content[i].path) + if not err then + folders[#folders+1] = folder + end + end + end + if Platform.xbox then + local list + error, list = Xbox.EnumerateLocalDlcs() + if not error then + for idx = 1, #list do + local folders_index = #folders+1 + + local err, mountDir = AsyncXboxMountDLC(list[idx][1]) + if not err then + err = MountPack("Dlc" .. folders_index, mountDir .. "/content.hpk") + error = error or err + if not err then + folders[folders_index] = "Dlc" .. folders_index + end + end + end + end + end + if Platform.windows_store then + local err, list = WindowsStore.MountDlcs() + if not err then + for i=1, #list do + local folder = list[i] + local folders_index = #folders+1 + err = MountPack("Dlc" .. folders_index, folder .. "/content.hpk") + if not err then + folders[folders_index] = "Dlc" .. folders_index + end + end + end + end + + if not DlcFolders then -- Load the embedded DLCs only once + if Platform.developer and IsFSUnpacked() then + local dev_list = Platform.developer and IsFSUnpacked() and io.listfiles("svnProject/Dlc/", "*", "folders") or empty_table + for _, folder in ipairs(dev_list) do + local dlc = string.gsub(folder, "svnProject/Dlc/", "") + if not (LocalStorage.DisableDLC and LocalStorage.DisableDLC[dlc]) then + folders[#folders + 1] = folder + end + end + else + local files = io.listfiles("AppData/DLC/", "*.hpk", "non recursive") or {} + table.iappend(files, io.listfiles("DLC/", "*.hpk", "non recursive")) + if Platform.linux then + table.iappend(files, io.listfiles("dlc/", "*.hpk", "non recursive")) + end + if Platform.pgo_train then + table.iappend(files, io.listfiles("../win32-dlc", "*.hpk", "non recursive")) + end + dlc_print("Dlc os packs: ", files) + for i=1,#files do + local folder = "Dlc" .. tostring(#folders+1) + local err = MountPack(folder, files[i]) + if not err then + table.insert(folders, folder) + end + end + end + end + + dlc_print("Dlc folders: ", folders) + return folders, error +end + +-- 2. Execute autorun.lua and revisions.lua +function DlcAutoruns(folders) + dlc_print("Dlc Autoruns") + local dlcs = {} + + -- dlc.folder points to the autorun mount + for i = 1, #folders do + local folder = folders[i] + local dlc = dofile(folder .. "/autorun.lua") + if type(dlc) == "function" then + dlc = dlc(folder) + end + if type(dlc) == "table" then + dlc_print("Autorun executed for", dlc.name) + dlc.folder = folder + if Platform.developer and folder:starts_with("svnProject/Dlc") then + dlc.lua_revision, dlc.assets_revision = LuaRevision, AssetsRevision + else + dlc.lua_revision, dlc.assets_revision = dofile(folder .. "/revisions.lua") + end + table.insert(dlcs, dlc) + DebugPrint(string.format("DLC %s loaded, lua revision %d, assets revision %d\n", tostring(dlc.name), dlc.lua_revision or 0, dlc.assets_revision or 0)) + else + print("Autorun failed:", folder) + end + end + return dlcs +end + +-- 3. Call the dlc:pre_load() to all dlcs. Let a DLC decide that it doesn't want to be installed +function DlcPreLoad(dlcs) + local revision + for i = #dlcs, 1, -1 do + local required_lua_revision = dlcs[i].required_lua_revision + if required_lua_revision and required_lua_revision <= LuaRevision then + required_lua_revision = nil -- the required revision is lower, ignore condition + end + revision = Max(revision, required_lua_revision) + local pre_load = dlcs[i].pre_load or empty_func + if required_lua_revision or pre_load(dlcs[i]) == "remove" then + dlc_print("Dlc removed:", dlcs[i].name, required_lua_revision or "") + table.remove_value(DlcFolders, dlcs[i].folder) + table.remove(dlcs, i) + end + end + return revision +end + +function GetDlcRequiresTitleUpdateMessage() + local id = TGetID(MessageText.DlcRequiresUpdate) + if id and TranslationTable[id] then + return TranslationTable[id] + end + + -- fallback + local language, strMessage = GetLanguage(), nil + if language == "French" then + strMessage = "Certains contenus téléchargeables nécessitent l'installation d'une mise à jour du jeu pour fonctionner." + elseif language == "Italian" then + strMessage = "Alcuni contenuti scaricabili richiedono un aggiornamento del titolo per essere utilizzati." + elseif language == "German" then + strMessage = "Bei einigen Inhalten zum Herunterladen ist ein Update notwendig, damit sie funktionieren." + elseif language == "Spanish" or language == "Latam" then + strMessage = "Ciertos contenidos descargables requieren una actualización para funcionar." + elseif language == "Polish" then + strMessage = "Część zawartości do pobrania wymaga aktualizacji gry." + elseif language == "Russian" then + strMessage = "Загружаемый контент требует обновления игры." + else + strMessage = "Some downloadable content requires a title update in order to work." + end + return strMessage +end + + +local function find(dlcs, path, rev, rev_name) + local found + for i = #dlcs, 1, -1 do + local dlc = dlcs[i] + if dlc[rev_name] > rev and io.exists(dlc.folder .. path) then + rev = dlc[rev_name] + found = dlc + end + end + if found then + return found.folder .. path, found + end +end + +-- 4. If necessary reload localization, lua and data from the lasest packs (it can update the follow up Dlc load steps) +function DlcReloadLua(dlcs, late_dlc_reload) + local lang_reload + local reload = late_dlc_reload + + -- mount latest localization + local lang_pack = find(dlcs, "/Local/" .. GetLanguage() .. ".hpk", LuaRevision, "lua_revision") + if lang_pack then + dlc_print(" - localization:", lang_pack) + MountPack("", lang_pack, "", "CurrentLanguage") + lang_reload = true + end + + -- English language for e.g. the Mod Editor on PC + if config.GedLanguageEnglish then + local engl_pack = find(dlcs, "/Local/English.hpk", LuaRevision, "lua_revision") + if engl_pack then + MountPack("", engl_pack, "", "EnglishLanguage") + end + end + + -- reload entities + local binassets_path = "/BinAssets.hpk" + local binassets_pack = find(dlcs, binassets_path, AssetsRevision, "assets_revision") + if binassets_pack then + dlc_print(" - BinAssets:", binassets_pack) + UnmountByPath("BinAssets") + local err = MountPack("BinAssets", binassets_pack) + dlc_print(" - BinAssets:", binassets_pack, "ERROR", err) + ReloadEntities("BinAssets/entities.dat") + ReloadTextureHeaders() + reload = true + end + + -- reload Lua + if late_dlc_reload then -- clean the global tables to prevent duplication + Presets = {} + ClassDescendants("Preset", function(name, class) + if class.GlobalMap then + _G[class.GlobalMap] = {} + end + end) + end + + local lua_pack, dlc = find(dlcs, "/Lua.hpk", LuaRevision, "lua_revision") + if lua_pack then + dlc_print(" - lua:", dlc.folder .. "/Lua.hpk") + assert(not config.RunUnpacked) + UnmountByLabel("Lua") + LuaPackfile = lua_pack + reload = true + end + local data_pack, dlc = find(dlcs, "/Data.hpk", LuaRevision, "lua_revision") + if data_pack then + assert(io.exists(dlc.folder .. "/Data.hpk")) + UnmountByLabel("Data") + DataPackfile = data_pack + end + for i = 1, #dlcs do + if io.exists(dlcs[i].folder .. "/Code/") then + reload = true + break + end + end + + reload = reload or config.Mods and next(ModsLoaded) + if reload then + ReloadLua(true) + end + + if lang_reload and not reload then + LoadTranslationTables() + end +end + +function DlcMountVoices(dlcs, skip_sort) + UnmountByLabel("DlcVoices") + if not dlcs then return end + -- Mount all available voices packs in the multi (order by assets revision in case we want to fix a voice from one DLC from a later one) + local sorted_dlcs + if not skip_sort then + sorted_dlcs = table.copy(dlcs) + table.stable_sort(sorted_dlcs, function (a, b) return a.assets_revision < b.assets_revision end) + end + for i, dlc in ipairs(sorted_dlcs or dlcs) do + local voice_pack = string.format("%s/Local/Voices/%s.hpk", dlc.folder, GetVoiceLanguage()) + if MountPack("CurrentLanguage/Voices", voice_pack, "seethrough,label:DlcVoices") then + dlc_print(" - localization voice: ", voice_pack) + end + end +end + +function DlcMountMapPacks(dlcs) + for _, dlc in ipairs(dlcs) do + for _, map_pack in ipairs(io.listfiles(dlc.folder .. "/Maps", "*.hpk")) do + local map_name = string.match(map_pack, ".*/Maps/([^/]*).hpk") + if map_name then + MapPackfile[map_name] = map_pack + end + end + end +end + +function DlcMountUI(dlcs) + local asset_path = dlcs.folder .. "/UI/" + if io.exists(asset_path) then + local err = MountFolder("UI", asset_path, "seethrough") + dlc_print(" - UI:", asset_path, "ERROR", err) + end +end + +function DlcMountNonEntityTextures(dlcs) + local asset_path = find(dlcs, "/AdditionalNETextures.hpk", AssetsRevision, "assets_revision") + if asset_path then + UnmountByLabel("AdditionalNETextures") + local err = MountPack("", asset_path, "priority:high,seethrough,label:AdditionalNETextures") + dlc_print(" - non-entity textures:", asset_path, "ERROR", err) + end +end + +function DlcMountAdditionalEntityTextures(dlcs) + local asset_path = find(dlcs, "/AdditionalTextures.hpk", AssetsRevision, "assets_revision") + if asset_path then + UnmountByLabel("AdditionalTextures") + local err = MountPack("", asset_path, "priority:high,seethrough,label:AdditionalTextures") + dlc_print(" - entity textures:", asset_path, "ERROR", err) + end +end + +function DlcMountSounds(dlcs) + local asset_path = dlcs.folder .. "/Sounds/" + if io.exists(asset_path) then + local err = MountFolder("Sounds", asset_path, "seethrough") + dlc_print(" - Sounds:", asset_path, "ERROR", err) + end +end + +function DlcMountMeshesAndAnimations(dlcs) + local meshes_pack = find(dlcs, "/Meshes.hpk", AssetsRevision, "assets_revision") + if meshes_pack then + dlc_print(" - Meshes:", meshes_pack) + UnmountByPath("Meshes") + MountPack("Meshes", meshes_pack) + else + -- If we reload DLCs in packed mode, make sure to have the original meshes first + if MountsByPath("Meshes") == 0 and not IsFSUnpacked() then + MountPack("Meshes", "Packs/Meshes.hpk") + end + end + local animations_pack = find(dlcs, "/Animations.hpk", AssetsRevision, "assets_revision") + if animations_pack then + dlc_print(" - Animations:", animations_pack) + UnmountByPath("Animations") + MountPack("Animations", animations_pack) + else + if MountsByPath("Animations") == 0 and not IsFSUnpacked() then + MountPack("Animations", "Packs/Animations.hpk") + end + end + + -- mount additional meshes and animations for each DLC + for i, dlc in ipairs(dlcs) do + MountPack("", dlc.folder .. "/DlcMeshes.hpk", "seethrough,label:DlcMeshes") + MountPack("", dlc.folder .. "/DlcAnimations.hpk", "seethrough,label:DlcAnimations") + MountPack("", dlc.folder .. "/DlcSkeletons.hpk", "seethrough,label:DlcSkeletons") + MountPack("BinAssets", dlc.folder .. "/DlcBinAssets.hpk", "seethrough,label:DlcBinAssets") + end + + --common assets should be processed before the rest + UnmountByLabel("CommonAssets") + MountPack("", "Packs/CommonAssets.hpk", "seethrough,label:CommonAssets") +end + +function DlcReloadShaders(dlcs) + -- box DX9 and DX11 shader packs should be provided or missing + local asset_path, dlc = find(dlcs, "/ShaderCache" .. config.GraphicsApi .. ".hpk", AssetsRevision, "assets_revision") + if asset_path then + dlc_print(" - ShaderCache:", asset_path) + UnmountByPath("ShaderCache") + MountPack("ShaderCache", asset_path, "seethrough,in_mem,priority:high") + -- NOTE: new shader cache will be reloaded not on start up(main menu) but on next map/savegame load + hr.ForceShaderCacheReload = true + end +end + +function DlcAddMusic(dlcs) + local asset_path = dlcs.folder .. "/Music/" + if io.exists(asset_path) then + local err = MountFolder("Music/" .. dlc.name, asset_path) + dlc_print(" - Music:", asset_path, "ERROR", err) + Playlists[dlc.name] = PlaylistCreate("Music/" .. dlc.name) + end +end + +function DlcAddCubemaps(dlcs) + local asset_path = dlcs.folder .. "/Cubemaps/" + if io.exists(asset_path) then + local err = MountFolder("Cubemaps", asset_path, "seethrough") + dlc_print(" - Cubemaps:", asset_path, "ERROR", err) + end +end + +function DlcAddBillboards(dlcs) + local asset_path = dlcs.folder .. "/Textures/Billboards/" + if io.exists(asset_path) then + local err = MountFolder("Textures/Billboards", asset_path, "seethrough") + dlc_print(" - Billboards:", asset_path, "ERROR", err) + end +end + +function DlcMountMovies(dlcs) + if IsFSUnpacked() then return end + for _, dlc in ipairs(dlcs) do + local path = dlc.folder .. "/Movies/" + if io.exists(path) then + local err = MountFolder("Movies/", path, "seethrough") + dlc_print(" - DlcMovies:", path, err and "ERROR", err) + end + end +end + +function DlcMountBinAssets(dlcs) + if IsFSUnpacked() then return end + for _, dlc in ipairs(dlcs) do + local path = dlc.folder .. "/BinAssets/" + if io.exists(path) then + local err = MountFolder("BinAssets/", path, "seethrough") + dlc_print(" - DlcBinAssets:", path, err and "ERROR", err) + end + end +end + +function DlcMountMisc(dlcs) + UnmountByLabel("DlcMisc") + for _, dlc in ipairs(dlcs) do + local path = dlc.folder .. "/Misc/" + if io.exists(path) then + local err = MountFolder("Misc/", path, "seethrough,label:DlcMisc") + dlc_print(" - DlcMisc:", path, err and "ERROR", err) + end + end +end + +-- 5. If necessary reload the latest Dlc assets +function DlcReloadAssets(dlcs) + dlcs = table.copy(dlcs) + table.stable_sort(dlcs, function (a, b) return a.assets_revision < b.assets_revision end) + + -- mount map packs found in Maps/ + DlcMountMapPacks(dlcs) + for _, dlc in pairs(dlcs) do + -- mount the dlc UI + DlcMountUI(dlc) + -- mount the dlc sounds + DlcMountSounds(dlc) + -- mount the dlc music to the default playlist + DlcAddMusic(dlc) + -- mount the dlc cubemaps + DlcAddCubemaps(dlc) + -- mount the dlc billboards + DlcAddBillboards(dlc) + end + -- mount the most recent additional non-entity textures + DlcMountNonEntityTextures(dlcs) + -- mount the most recent additional entity textures + DlcMountAdditionalEntityTextures(dlcs) + -- mount latest meshes and animations plus additional ones in Dlcs + DlcMountMeshesAndAnimations(dlcs) + -- find latest shaders; OpenGL shaders are not reloaded + DlcReloadShaders(dlcs) + -- mount movies + DlcMountMovies(dlcs) + -- + DlcMountBinAssets(dlcs) + -- mount Misc + DlcMountMisc(dlcs) + -- mount voices + DlcMountVoices(dlcs, true) +end + +-- 6. Call the dlc.post_load() for each dlc +function DlcPostLoad(dlcs) + for _, dlc in ipairs(dlcs) do + if dlc.post_load then dlc:post_load() end + end +end + +function DlcErrorHandler(err) + print("DlcErrorHandler", err, GetStack()) +end + +function WaitInitialDlcLoad() -- does nothing on reloads (like what happens on Xbox) + if not DlcFolders then + WaitMsg("DlcsLoaded") + end +end + +function LoadDlcs(force_reload) + if Platform.developer and (LuaRevision == 0 or AssetsRevision == 0) then + for i=1, 50 do + Sleep(50) + if LuaRevision ~= 0 and AssetsRevision ~= 0 then break end + end + if LuaRevision == 0 or AssetsRevision == 0 then + print("Couldn't get LuaRevision or AssetsRevision, DLC loading may be off") + end + end + + if DlcFolders and not force_reload then + return + end + + if force_reload then + ForceReloadBinAssets() + DlcFolders = false + end + + if Platform.appstore then + local err = CopyDownloadedDLCs() + if err then + print("Failed to copy downloaded DLCs", err) + end + end + + LoadingScreenOpen("idDlcLoading", "dlc loading", T(808151841545, "Checking for downloadable content... Please wait.")) + + -- 1. Mount OS packs + local bCorrupt = false + local folders, err = DlcMountOsPacks() + if err == "File is corrupt" then + bCorrupt = true + err = false + end + + if err then + DlcErrorHandler(err) + end + + local dlcs = DlcAutoruns(folders) + table.stable_sort(dlcs, function (a, b) return a.lua_revision < b.lua_revision end) + + UnmountByLabel("Dlc") + local seen_dlcs = {} + for i, dlc in ripairs(dlcs) do + if seen_dlcs[dlc.name] then + table.remove(dlcs, i) + else + seen_dlcs[dlc.name] = true + dlc.title = dlc.title or dlc.display_name and _InternalTranslate(dlc.display_name) or dlc.name + if not dlc.folder:starts_with("svnProject/Dlc/") then + local org_folder = dlc.folder + dlc.folder = "Dlc/" .. dlc.name + MountFolder(dlc.folder, org_folder, "priority:high,label:Dlc") + end + end + end + DlcFolders = table.map(dlcs, "folder") + DlcDefinitions = table.copy(dlcs) + + local bRevision = DlcPreLoad(dlcs) + dlc_print("Dlc tables after preload:\n", table.map(dlcs, "name")) + + if config.Mods then + -- load mod items in the same loading screen + ModsReloadDefs() + ModsReloadItems(nil, nil, true) + end + + DlcReloadLua(dlcs, force_reload) + DlcReloadAssets(dlcs) + + + local metaCheck = const.PrecacheDontCheck + if Platform.test then + metaCheck = Platform.pc and const.PrecacheCheckUpToDate or const.PrecacheCheckExists + end + for _, dlc in ipairs(dlcs) do + ResourceManager.LoadPrecacheMetadata("BinAssets/resources-" .. dlc.name .. ".meta", metaCheck) + end + + DlcPostLoad(dlcs) + + -- Collect and translate the DLC display names. + -- We are just after the step that would reload the localization, so this would handle the case + -- where a DLC display name is translated in the new DLC-provided localization + local dlc_names = GetAvailableDlcList() + for i=1, #dlc_names do + local dlc_metadata = table.find_value(dlcs, "name", dlc_names[i]) + assert(dlc_metadata) + local display_name = dlc_metadata.display_name + if display_name then + if not IsT(display_name) or not TGetID(display_name) then + print("DLC", dlc_names[i], "display_name must be a localized T!") + end + display_name = _InternalTranslate(display_name) + assert(type(display_name)=="string") + g_DlcDisplayNames[ dlc_names[i] ] = display_name + end + end + + local dlc_names = GetAvailableDlcList() + if next(dlc_names) then + local infos = {} + for i=1, #dlc_names do + local dlcname = dlc_names[i] + infos[i] = string.format("%s(%s)", dlcname, g_DlcDisplayNames[dlcname] or "") + end + print("Available DLCs:", table.concat(infos, ",")) + end + Msg("DlcsLoaded") + + LoadData(dlcs) + + if config.Mods and next(ModsLoaded) then + ContinueModsReloadItems() + end + + if not Platform.developer then + if not config.Mods or not Platform.pc then -- the mod creation (available on PC only) needs the data and lua sources to be able to copy functions + UnmountByLabel("Lua") + UnmountByLabel("Data") + end + end + + -- Messages should be shown after LoadData(), as there are UI presets that need to be present + local interactive = not (Platform.developer and GetIgnoreDebugErrors()) + if interactive then + if bCorrupt then + WaitMessage(GetLoadingScreenDialog() or terminal.desktop, "", T(619878690503, --[[error_message]] "A downloadable content file appears to be damaged and cannot be loaded. Please delete it from the Memory section of the Dashboard and download it again."), nil, terminal.desktop) + end + if bRevision then + local message = Untranslated(GetDlcRequiresTitleUpdateMessage()) + WaitMessage(GetLoadingScreenDialog() or terminal.desktop, "", message) + end + end + + LoadingScreenClose("idDlcLoading", "dlc loading") + UIL.Invalidate() +end + +function LoadData(dlcs) + PauseInfiniteLoopDetection("LoadData") + collectgarbage("collect") + collectgarbage("stop") + + Msg("DataLoading") + MsgClear("DataLoading") + + LoadPresetFiles("CommonLua/Data") + LoadPresetFolders("CommonLua/Data") + ForEachLib("Data", function (lib, path) + LoadPresetFiles(path) + LoadPresetFolders(path) + end) + LoadPresetFolder("Data") + + for _, dlc in ipairs(dlcs or empty_table) do + LoadPresetFolder(dlc.folder .. "/Presets") + end + Msg("DataPreprocess") + MsgClear("DataPreprocess") + Msg("DataPostprocess") + MsgClear("DataPostprocess") + Msg("DataLoaded") + MsgClear("DataLoaded") + DataLoaded = true + + local mem = collectgarbage("count") + collectgarbage("collect") + collectgarbage("restart") + -- printf("Load Data mem %dk, peak %dk", collectgarbage("count"), mem) + ResumeInfiniteLoopDetection("LoadData") +end + +function WaitDataLoaded() + if not DataLoaded then + WaitMsg("DataLoaded") + end +end + +if Platform.xbox then + local oldLoadDlcs = LoadDlcs + function LoadDlcs(...) + SuspendSigninChecks("load dlcs") + SuspendInviteChecks("load dlcs") + sprocall(oldLoadDlcs, ...) + ResumeSigninChecks("load dlcs") + ResumeInviteChecks("load dlcs") + end +end + + +function OnMsg.BugReportStart(print_func) + local list = GetAvailableDlcList() + table.sort(list) + print_func("Dlcs: " .. table.concat(list, ", ")) +end + +function DlcComboItems(additional_item) + local items = {{ text = "", value = ""}} + for _, def in ipairs(DlcDefinitions) do + if not def.deprecated then + local name, title = def.name, def.title + if name ~= title then + title = name .. " (" .. title .. ")" + end + items[#items + 1] = {text = title, value = name} + end + end + if additional_item then + table.insert(items, 2, additional_item) + end + return items +end + +function DlcCombo(additional_item) + return function() + return DlcComboItems(additional_item) + end +end + +function RedownloadContent(list, progress) + if not NetIsConnected() then return "disconnected" end + progress(0) + AsyncCreatePath("AppData/DLC") + AsyncCreatePath("AppData/DownloadedDLC") + for i = 1, #list do + local dlc_name = list[i] + local name = dlc_name .. ".hpk" + local download_file = string.format("AppData/DownloadedDLC/%s.download", dlc_name) + local dlc_file = string.format("AppData/DLC/%s.hpk", dlc_name) + local err, def = NetCall("rfnGetContentDef", name) + if not err and def then + local err, local_def = CreateContentDef(download_file, def.chunk_size) + if err == "Path Not Found" or err == "File Not Found" then + err, local_def = CreateContentDef(dlc_file, def.chunk_size) + end + if local_def then local_def.name = name end + local start_progress = 100 * (i - 1) / #list + local file_progress = 100 * i / #list - start_progress + start_progress = start_progress + file_progress / 10 + progress(start_progress) + err = NetDownloadContent(download_file, def, + function (x, y) + progress(start_progress + MulDivRound(file_progress * 9 / 10, x, y)) + end, + local_def) + if not err then + local downloaded_dlc_file = string.format("AppData/DownloadedDLC/%s.hpk", dlc_name) + os.remove(downloaded_dlc_file) + os.rename(download_file, downloaded_dlc_file) + end + end + progress(100 * i / #list) + end +end + +function CopyDownloadedDLCs() + AsyncCreatePath("AppData/DLC") + AsyncCreatePath("AppData/DownloadedDLC") + local err, new_dlcs = AsyncListFiles("AppData/DownloadedDLC", "*.hpk", "relative") + if err then return err end + for i = 1, #new_dlcs do + local src = "AppData/DownloadedDLC/" .. new_dlcs[i] + if not AsyncCopyFile(src, "AppData/DLC/" .. new_dlcs[i], "raw") then + AsyncFileDelete(src) + end + end +end + +function DlcsLoadCode() + for i = 1, #(DlcFolders or "") do + dofolder(DlcFolders[i] .. "/Code/") + end +end + +function ReloadDevDlcs() + CreateRealTimeThread(function() + OpenPreGameMainMenu() + DlcFolders = false + for dlc in pairs(LocalStorage.DisableDLC or empty_table) do + g_AvailableDlc[dlc] = nil + end + SaveLocalStorage() + ClassDescendants("Preset", function(name, preset, Presets) + --purge presets, which are saved in Data, we are reloading it + if preset:GetSaveFolder() == "Data" then + if preset.GlobalMap then + _G[preset.GlobalMap] = {} + end + Presets[preset.PresetClass or name] = {} + end + end, Presets) + LoadDlcs("force reload") + end) +end + +function SetAllDevDlcs(enable) + local disabled = not enable + LocalStorage.DisableDLC = LocalStorage.DisableDLC or {} + for _, file in ipairs(io.listfiles("svnProject/Dlc/", "*", "folders")) do + local dlc = string.gsub(file, "svnProject/Dlc/", "") + if (LocalStorage.DisableDLC[dlc] or false) ~= disabled then + LocalStorage.DisableDLC[dlc] = disabled + DelayedCall(0, ReloadDevDlcs) + end + end +end + +function SaveDLCOwnershipDataToDisk(data, file_path) + local machine_id = GetMachineID() + if (machine_id or "") ~= "" then -- don't save to disk without machine id + data.machine_id = machine_id + --encrypt data and machine id and save to disk + SaveLuaTableToDisk(data, file_path, g_encryption_key) + end +end + +function LoadDLCOwnershipDataFromDisk(file_path) + if io.exists(file_path) then + --decrypt the file + local data, err = LoadLuaTableFromDisk(file_path, nil, g_encryption_key) + if not err then + if data and (data.machine_id or "") == GetMachineID() then -- check against current machine id + data.machine_id = nil -- remove the machine_id from the data, no need for it + return data + end + end + end + return {} +end diff --git a/CommonLua/Editor/AnimationMomentsEditor.lua b/CommonLua/Editor/AnimationMomentsEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..062d30377c6477724b13bb4d2bb0a57f7a9e05a4 --- /dev/null +++ b/CommonLua/Editor/AnimationMomentsEditor.lua @@ -0,0 +1,1046 @@ +local function WipeDeleted() + local to_delete = {} + ForEachPreset("AnimMetadata", function(preset) + local entity, anim = preset.group, preset.id + if not (IsValidEntity(entity) and HasState(entity, anim)) then + to_delete[#to_delete + 1] = preset + end + end) + for _, preset in ipairs(to_delete) do + preset:delete() + end +end + +DefineClass.AnimMoment = { + __parents = { "PropertyObject" }, + properties = { + { id = "Type", name = "Type", editor = "choice", default = "Moment", items = ActionMomentNamesCombo }, + { id = "Time", name = "Time (ms)", editor = "number", default = 0 }, + { id = "FX", name = "FX", editor = "choice", default = false, items = ActionFXClassCombo }, + { id = "Actor", name = "Actor", editor = "choice", default = false, items = ActorFXClassCombo }, + { id = "AnimRevision", name = "Animation Revision", editor = "number", default = 0, read_only = true }, + { id = "Reconfirm", editor = "buttons", default = false, + no_edit = function(obj) return not (obj:GetWarning() or obj:GetError()) end, + buttons = { { name = "Reconfirm", func = function(self, root, prop_id, ged) + self.AnimRevision = GetAnimationMomentsEditorObject().AnimRevision + ObjModified(self) + ObjModified(ged:ResolveObj("AnimationMetadata")) + ObjModified(ged:ResolveObj("Animations")) + end, + } } }, + }, +} + +function AnimMoment:GetEditorView() + if GetParentTableOfKind(self, "AnimMetadata").SpeedModifier ~= 100 then + local character = GetAnimationMomentsEditorObject() + return T{Untranslated(" at ""'), +} + +function AssetSpec:ChooseColor() + return self.TypeColor and string.format("", GetRGB(self.TypeColor)) or "" +end + +function AssetSpec:FindUniqueName(old_name) + local entity_spec = GetParentTableOfKindNoCheck(self, "EntitySpec") + local specs = entity_spec:GetSpecSubitems(self.class, not "inherit", self) -- exclude self + local name, j = old_name, 0 + while specs[name] do + j = j + 1 + name = old_name .. tostring(j) + end + return name +end + +function AssetSpec:OnAfterEditorNew(parent, ged, is_paste) + self.name = self:FindUniqueName(self.name) +end + +function AssetSpec:OnEditorSetProperty(prop_id, old_value, ged) + local entity_spec = GetParentTableOfKindNoCheck(self, "EntitySpec") + if prop_id == "name" then + -- don't allow a duplicated name among the subobjects + self.name = self:FindUniqueName(self.name) + if self:IsKindOf("MeshSpec") then + -- update references to the old name + for _, spec in pairs(entity_spec:GetSpecSubitems("StateSpec", not "inherit")) do + if spec.mesh == old_value then + spec.mesh = self.name + end + end + end + end + entity_spec:SortSubItems() + ObjModified(entity_spec) +end + +function AssetSpec:GetError() + if self.name == "" or self.name == "NONE" then + return "Please specify asset name." + end + if not self.name:match("^[_#a-zA-Z0-9]*$") then + return "The asset name has invalid characters." + end +end + +function AssetSpec:SetSaveIn(save_in) + self.save_in = save_in ~= "" and save_in or nil +end + +function AssetSpec:GetSaveIn() + return self.save_in +end + +DefineClass.MaskSpec = { + __parents = { "AssetSpec" }, + + properties = { + -- stored in max script + { maxScript = true, id = "entity", editor = "text", no_edit = true, dont_save = true, default = "" }, + }, + + TypeColor = RGB(175, 175, 0), +} + +function MaskSpec:Less(other) -- compare to another MaskSpec + if self.entity == other.entity then + return self.name < other.name + end + return self.entity < other.entity +end + +DefineClass.MeshSpec = { + __parents = { "AssetSpec" }, + + properties = { + -- stored in max script + { maxScript = true, id = "lod" , name = "LOD" , editor = "number", min = 0, default = 1 }, + { maxScript = true, id = "animated" , name = "Animated" , editor = "bool",default = false }, + { maxScript = true, id = "entity", editor = "text", no_edit = true, dont_save = true, default = "" }, + { maxScript = true, id = "material" , name = "Material Variations", editor="text", default = "", help = "Specify material variations separated by commas. No spaces allowed in the variation's name!"}, + { maxScript = true, id = "spots", name = "Required spots", editor = "text", default = "" }, + { maxScript = true, id = "surfaces", name = "Required surfaces", editor = "text", default = "" }, + { maxScript = true, toNumber = true, id = "maxTexturesSize", name = "Max textures size" , editor = "choice", default = "2048", + items = { "2048", "1024", "512" }, + }, + }, + + TypeColor = RGB(143, 0, 0), +} + +function MeshSpec:GetMaterialsArray() + local str_materials = string.gsub(self.material, " ", "") + return string.tokenize(str_materials, ",") +end + +function MeshSpec:Less(other) -- compare to another MeshSpec + if self.entity == other.entity then + if self.name == other.name then + if self.lod == other.lod then + return self.material < other.material + end + return self.lod < other.lod + end + return self.name < other.name + end + return self.entity < other.entity +end + +DefineClass.StateSpec = { + __parents = { "AssetSpec" }, + + properties = { + { id = "category", name = "Category", editor = "choice", items = function() return ArtSpecConfig.ReturnAnimationCategories end, default = "All" }, + { id = "SaveIn", name = "Save in", editor = "choice", default = "", items = function(obj) return obj:GetPresetSaveLocations() end, }, + { maxScript = true, id = "entity", editor = "text", default = "", no_edit = true, dont_save = true }, + { maxScript = true, id = "mesh", name = "Mesh", editor = "choice", + items = function(self) + local entity_spec = GetParentTableOfKind(self, "EntitySpec") + local meshes = entity_spec:GetSpecSubitems("MeshSpec", "inherit") + return table.keys2(meshes, "sorted", "NONE") + end, + default = "NONE" + }, + { maxScript = true, id = "animated", name = "Animated", editor = "bool", default = false ,read_only = true, dont_save = true,}, + { maxScript = true, id = "looping", name = "Looping", editor = "bool", default = false }, + { maxScript = true, id = "moments", name = "Required moments", editor="text", default = ""}, + }, + + TypeColor = RGB(0, 143, 0), +} + +function StateSpec:Getanimated() + local entity_spec = GetParentTableOfKind(self, "EntitySpec") + local mesh = entity_spec:GetMeshSpec(self.mesh) + return mesh and mesh.animated +end + +function StateSpec:Less(other) -- compare to another StateSpec + if self.entity == other.entity then + return self.name < other.name + end + return self.entity < other.entity +end + +function StateSpec:GetError() + if self.mesh == "" or self.mesh == "NONE" then + return "Please specify mesh name." + end +end + +function StateSpec:GetPresetSaveLocations() + return GetDefaultSaveLocations() +end + +----- EntitySpec + +local editor_artset_no_edit = function(obj) return obj.editor_exclude end +local editor_category_no_edit = function(obj) return obj.editor_exclude end +local editor_subcategory_no_edit = function(obj) return obj.editor_exclude or obj.editor_category == "" or not ArtSpecConfig[obj.editor_category.."Categories"] end + +local statuses = { + { id = "Brief", help = "The entity is named, and now concept and technical specs for it need to be prepared." }, + { id = "Ready for production", help = "The brief is done, and work on the entity can start." }, + { id = "In production", help = "The entity is currently being produced in-house or via outsourcing, or has been delivered but not yet exported to the game." }, + { id = "For approval", help = "The entity is produced and exported to the game." }, + { id = "Ready", help = "The entity has been approved and can be used by level designers and programmers." }, +} + +local _FadeCategoryComboItems = false + +function FadeCategoryComboItems() + if not _FadeCategoryComboItems then + local items = {} + for k,v in pairs(FadeCategories) do + table.insert(items, { value = k, text = k, sort_key = v.min } ) + end + table.sortby_field(items, "sort_key") + _FadeCategoryComboItems = items + end + return _FadeCategoryComboItems +end + +function GetArtSpecEditor() + for id, ged in pairs(GedConnections) do + if ged.app_template == EntitySpec.GedEditor then + return ged + end + end +end + +DefineClass.BasicEntitySpecProperties = { + __parents = { "PropertyObject" }, + properties = { + { id = "class_parent", name = "Class", editor = "combo", items = PresetsPropCombo("EntitySpec", "class_parent", ""), default = "", category = "Misc", help = "Classes which this entity class should inherit (comma separated).", entitydata = true, }, + { id = "fade_category", name = "Fade category" , editor = "choice", items = FadeCategoryComboItems, default = "Auto", category = "Misc", help = "How the entity should fade away when far from the camera.", entitydata = true, }, + { id = "DetailClass", name = "Detail class", editor = "dropdownlist", category = "Misc", items = {"Essential", "Optional", "Eye Candy"}, default = "Essential", entitydata = true, }, + }, +} + +function BasicEntitySpecProperties:ExportEntityDataForSelf() + local entity = {} + for _, prop_meta in ipairs(self:GetProperties()) do + local prop_id = prop_meta.id + if prop_meta.entitydata and not self:IsPropertyDefault(prop_id) then + if type(prop_meta.entitydata) == "function" then + entity[prop_id] = prop_meta.entitydata(prop_meta, self) + else + entity[prop_id] = self[prop_id] + end + end + end + return next(entity) and { entity = entity } or {} +end + +DefineClass.EntitySpecProperties = { + __parents = { "BasicEntitySpecProperties" }, + properties = { + { id = "can_be_inherited", name = "Can be inherited", editor = "bool", default = false, category = "Entity Specification"}, + { id = "inherit_entity", name = "Inherit entity", editor = "preset_id", default = "", preset_class = "EntitySpec", category = "Entity Specification", + help = "Entity to inherit meshes/animations from; only entities with 'Can be inherited' checked are listed.", + preset_filter = function(preset, self) return preset.can_be_inherited end, + }, + + { id = "material_type", name = "Material type", category = "Misc", editor = "preset_id", default = "", preset_class = "ObjMaterial", help = "Physical material of this entity.", entitydata = true, }, + { id = "on_collision_with_camera", name = "On collision with camera" , editor = "choice", items = { "", "no action", "become transparent", "repulse camera" }, default = "", category = "Misc", help = "Behavior of this entity when colliding with the camera.", entitydata = true, no_edit = NoCameraCollision }, + { id = "wind_axis", name = "Wind trunk stiffness" , editor = "number", default = 800, category = "Misc", scale = 1000, min = 100, max = 10000, slider = true, help = "Vertex noise needs to be set in the entity material to be affected by wind.", entitydata = true, }, + { id = "wind_radial", name = "Wind branch stiffness" , editor = "number", default = 1000, category = "Misc", scale = 1000, min = 500, max = 10000, slider = true, help = "Vertex noise needs to be set in the entity material to be affected by wind.", entitydata = true, }, + { id = "wind_modifier_strength", name = "Wind modifier strength" , editor = "number", default = 1000, category = "Misc", scale = 1000, min = 100, max = 10000, slider = true, help = "Vertex noise needs to be set in the entity material to be affected by wind.", entitydata = true, }, + { id = "wind_modifier_mask", name = "Wind modifier mask" , editor = "choice", default = 0, category = "Misc", items = const.WindModifierMaskComboItems, help = "Vertex noise needs to be set in the entity material to be affected by wind.", entitydata = true, }, + + { id = "winds", editor = "buttons", default = false, category = "Misc", buttons = { + { name = "Stop wind", func = function() terrain.SetWindStrength(point20, 0) end }, + { name = "N", func = function() terrain.SetWindStrength(axis_x, 2048) end }, + { name = "N (strong)", func = function() terrain.SetWindStrength(axis_x, 4096) end }, + { name = "E", func = function() terrain.SetWindStrength(axis_y, 2048) end }, + { name = "E (strong)", func = function() terrain.SetWindStrength(axis_y, 4096) end }, + { name = "S", func = function() terrain.SetWindStrength(-axis_x, 2048) end }, + { name = "S (strong)", func = function() terrain.SetWindStrength(-axis_x, 4096) end }, + { name = "W", func = function() terrain.SetWindStrength(-axis_y, 2048) end }, + { name = "W (strong)", func = function() terrain.SetWindStrength(-axis_y, 4096) end }, }, + }, + { id = "DisableCanvasWindBlending", name = "Disable canvas wind blending", category = "Misc", + default = false, editor = "bool", no_edit = function(self) + if not rawget(g_Classes, "Canvas") then return true end + + local is_canvas = false + for class in string.gmatch(self.class_parent, '([^,]+)') do + if IsKindOf(g_Classes[class], "Canvas") then + is_canvas = true + break + end + end + + return not is_canvas + end, + entitydata = true, + }, + + { category = "Defaults", id = "anim_components", name = "Anim components", + editor = "nested_list", default = false, base_class = "AnimComponentWeight", inclusive = true, auto_expand = true, }, + }, +} + +function EntitySpecProperties:ExportEntityDataForSelf() + local data = BasicEntitySpecProperties.ExportEntityDataForSelf(self) + + if self.anim_components and next(self.anim_components) then + data.anim_components = table.map(self.anim_components, function(ac) + local err, t = LuaCodeToTuple(TableToLuaCode(ac)) + assert(not err) + return t + end) + end + + return data +end + +DefineClass.EntitySpec = { + __parents = { "Preset", "EntitySpecProperties" }, + + properties = { + { id = "produced_by", name = "Produced By" , editor = "combo", default = "HaemimontGames", items = function() return ArtSpecConfig.EntityProducers end, category = "Entity Specification" }, + { id = "status", name = "Production status", editor = "choice", default = statuses[1].id, items = statuses, category = "Entity Specification" }, + { id = "placeholder", name = "Allow placeholder use", editor = "bool", default = false, category = "Entity Specification" }, + { id = "estimate", name = "Estimate (days)", editor = "number", default = 1, category = "Entity Specification" }, + { id = "LastChange", name = "Last change", editor = "text", default = "", translate = false, read_only = true, category = "Entity Specification" }, + -- { id = "inherit_mesh", name = "Inherit mesh", editor = "text", default = "mesh", category = "Entity Specification" }, + + -- Tags + { id = "editor_exclude", name = "Exclude from Map Editor", editor = "bool", default = false, category = "Map Editor" }, + { id = "editor_artset", name = "Art set", editor = "text_picker", no_edit = editor_category_no_edit, + items = function() return ArtSpecConfig.ArtSets end, horizontal = true, name_on_top = true, default = "", category = "Map Editor", + }, + { id = "editor_category", name = "Category", editor = "text_picker", no_edit = editor_category_no_edit, + items = function() return ArtSpecConfig.Categories end, horizontal = true, name_on_top = true, default = "", category = "Map Editor", + }, + { id = "editor_subcategory", name = "Subcategory", editor = "text_picker", horizontal = true, name_on_top = true, default = "", category = "Map Editor", + items = function(obj) return ArtSpecConfig[obj.editor_category.."Categories"] or empty_table end, no_edit = editor_subcategory_no_edit, + }, + + -- Misc + { id = "HasBillboard", name = "Billboard" , editor = "bool", default = false, category = "Misc", read_only = true, buttons = {{ name = "Rebake", func = "ActionRebake" }} }, + + -- stored in max script + { maxScript = true, id = "name", name = "Name", editor = false, default = "NONE", read_only = true, dont_save = true, }, + { maxScript = true, id = "unique_id", name = "UniqueID", editor = "number", default = -1, read_only = true, dont_save = true }, + { maxScript = true, id = "exportableToSVN", name = "Exportable to SVN", editor = "bool", default = true, category = "Entity Specification" }, + + { id = "Tools", editor = "buttons", default = false, category = "Entity Specification", buttons = { + { name = "List Files", func = "ListEntityFilesButton" }, + { name = "Delete Files", func = "DeleteEntityFilesButton" }, }, + }, + }, + + last_change_time = false, + + ContainerClass = "AssetSpec", + GlobalMap = "EntitySpecPresets", + GedEditor = "GedArtSpecEditor", + EditorMenubarName = "Art Spec", + EditorShortcut = "Ctrl-Alt-A", + EditorMenubar = "Editors.Art", + EditorIcon = "CommonAssets/UI/Icons/colour creativity palette.png", + FilterClass = "EntitySpecFilter", + PresetIdRegex = "^" .. EntityValidCharacters .. "*$", +} + +function EntitySpec:ExportEntityDataForSelf() + local data = EntitySpecProperties.ExportEntityDataForSelf(self) + + if not self.editor_exclude then + data.editor_artset = self.editor_artset ~= "" and self.editor_artset or nil + data.editor_category = self.editor_category ~= "" and self.editor_category or nil + data.editor_subcategory = self.editor_subcategory ~= "" and self.editor_subcategory or nil + end + if self.default_colors then + data.default_colors = {} + SetColorizationNoSetter(data.default_colors, self.default_colors) + end + + return data +end + +function EntitySpec:GetHasBillboard() + return table.find(hr.BillboardEntities, self.id) +end + +function EntitySpec:ActionRebake() + if table.find(hr.BillboardEntities, self.id) then + BakeEntityBillboard(self.id) + end +end + +function EntitySpec:Getunique_id() + return EntityIDs and EntityIDs[self.id] or -1 +end + +function EntitySpec:Setunique_id() + assert(false) +end + +function EntitySpec:GetEditorViewPresetPrefix() + g_AllEntities = g_AllEntities or GetAllEntities() + return g_AllEntities[self.id] and "" or self.exportableToSVN and "" or "" +end + +function EntitySpec:GetSaveFolder(save_in) + save_in = save_in or self.save_in + if save_in == "Common" then + return string.format("CommonAssets/Spec/") + else + return string.format("svnAssets/Spec/") + end +end + +function OnMsg.ClassesBuilt() + if not next(Presets.EntitySpec) and Platform.developer then + for idx, file in ipairs(io.listfiles("CommonAssets/Spec", "*.lua")) do + LoadPresets(file) + end + for idx, file in ipairs(io.listfiles("svnAssets/Spec", "*.lua")) do + LoadPresets(file) + end + end +end + +function EntitySpec:GenerateUniquePresetId(name) + local id = name or self.id + if not EntitySpecPresets[id] then + return id + end + + local new_id + local n = 0 + local id1, n1 = id:match("(.*)_(%d+)$") + if id1 and n1 then + id, n = id1, tonumber(n1) + end + repeat + n = n + 1 + new_id = string.format("%s_%02d", id, n) + until not EntitySpecPresets[new_id] + return new_id +end + +function EntitySpec:GetSavePath(save_in, group) + save_in = save_in or self.save_in or "" + + local folder = self:GetSaveFolder(save_in) + if not folder then return end + if save_in == "" then save_in = "base" end + return string.format("%sArtSpec-%s.lua", folder, save_in) +end + +function EntitySpec:GetLastChange() + return self.last_change_time and os.date("%Y-%m-%d %a", self.last_change_time) or "" +end + +function EntitySpec:GetCreationTime() -- the time is was marked as Ready in the art spec + return self.status == "Ready" and self.last_change_time +end + +function EntitySpec:GetModificationTime() -- the latest modification time as per the file system + self:EditorData().entity_files = self:EditorData().entity_files or GetEntityFiles(self.id) + local max = 0 + for _, file_name in ipairs(self:EditorData().entity_files) do + max = Max(max, GetAssetFileModificationTime(file_name)) + end + return max +end + +function EntitySpec:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "SaveIn" then + -- reset IDs when switching between project and common + if old_value == "Common" or self.save_in == "Common" then + local old_id = PreviousEntityIDs and PreviousEntityIDs[self.id] or nil + if old_id and self.save_in == "Common" and old_id < CommonAssetFirstID then old_id = nil end + if not next(PreviousEntityIDs) then PreviousEntityIDs = {} end + PreviousEntityIDs[self.id] = EntityIDs[self.id] + EntityIDs[self.id] = old_id + end + elseif prop_id == "status" then + self.last_change_time = os.time(os.date("!*t")) + elseif prop_id == "editor_exclude" then + self.editor_artset = nil + self.editor_category = nil + self.editor_subcategory = nil + elseif prop_id == "editor_category" then + self.editor_subcategory = nil + elseif prop_id == "wind_axis" or prop_id == "wind_radial" or prop_id == "wind_modifier_strength" or prop_id == "wind_modifier_mask" then + local axis, radial, strength, mask = GetEntityWindParams(self.id) + SetEntityWindParams(self.id, -1, self.wind_axis or axis, self.wind_radial or radial, self.wind_modifier_strength or strength, self.wind_modifier_mask or mask) + DelayedCall(300, RecreateRenderObjects) + elseif prop_id == "debris_list" then + local list_item = Presets.DebrisList.Default[self.debris_list] + if list_item then + local classes_weights = list_item.debris_list + self.debris_classes = {} + for _, entry in ipairs(classes_weights) do + local class_weight = DebrisWeight:new{DebrisClass = entry.DebrisClass, Weight = entry.Weight} + table.insert(self.debris_classes, class_weight) + end + else + self.debris_classes = false + end + GedObjectModified(self.debris_classes) + GedObjectModified(self) + elseif prop_id == "debris_classes" then + if not self.debris_classes or #self.debris_classes == 0 then + self.debris_list = "" + self.debris_classes = false + end + end + self:EditorData().entity_files = nil +end + +function EntitySpec:SortSubItems() + table.sort(self, function(a, b) if a.class == b.class then return a:Less(b) else return a.class < b.class end end) +end + +function EntitySpec:PostLoad() + SetEntityWindParams(self.id, -1, self.wind_axis, self.wind_radial, self.wind_modifier_strength, self.wind_modifier_mask) + self:SortSubItems() + Preset.PostLoad(self) +end + +function EntitySpec:GetError() + local has_mesh, has_state + for _, asset_spec in ipairs(self) do + has_mesh = has_mesh or asset_spec.class == "MeshSpec" + has_state = has_state or asset_spec.class == "StateSpec" + end + if not has_mesh then + return "Entity should have a MeshSpec" + elseif not has_state then + return "Entity should have a StateSpec" + end + + if (self.editor_artset == "" or not table.find(ArtSpecConfig.ArtSets, self.editor_artset)) and not editor_artset_no_edit(self) then + return "Please specify art set." + elseif (self.editor_category == "" or not table.find(ArtSpecConfig.Categories, self.editor_category)) and not editor_category_no_edit(self) then + return "Please specify entity category." + elseif (self.editor_subcategory == "" or ArtSpecConfig[self.editor_category .. "Categories"] and not table.find(ArtSpecConfig[self.editor_category .. "Categories"], self.editor_subcategory)) and not editor_subcategory_no_edit(self) then + return "Please specify entity subcategory." + end + + if self.editor_category == "Decal" and not string.find(self.class_parent, "Decal", 1, true) then + return "This entity is in the Decal category, but does not inherit the Decal class." + end +end + +function EntitySpec:Getname() + return self.id +end + +function EntitySpec:GetMeshSpec(meshName) + for _, spec in ipairs(self) do + if spec:IsKindOf("MeshSpec") and spec.name == meshName then + return spec + end + end + return false +end + +function EntitySpec:OnEditorSelect(selected, ged) + OnArtSpecSelectObject(self, selected) +end + +function EntitySpec:ReserveEntityIDs() + ForEachPreset(EntitySpec, function(ent_spec) + local name = ent_spec.id + if not EntityIDs[name] then + if ent_spec.save_in == "Common" then + ReserveCommonEntityID(name) + else + ReserveEntityID(name) + end + end + end) + if not LastEntityID then + LastEntityID = GetUnusedEntityID() - 1 + end +end + +function EntitySpec:GetSpecSubitems(spec_type, inherit, exclude) + -- go up the entity spec hierarchy to get inherited states and meshes + local t, es = {}, self + while es do + for _, spec in ipairs(es) do + if spec.class == spec_type and (not exclude or spec ~= exclude) then + t[spec.name] = t[spec.name] or spec + end + end + if not inherit then break end + es = EntitySpecPresets[es.inherit_entity] + end + return t +end + +function EntitySpec:SaveSpec(specs_class, filter, fn) + local res = {} + ForEachPreset(EntitySpec, function(ent_spec) + if filter and not filter(ent_spec) then return end + fn(ent_spec.id, ent_spec, res, ent_spec:GetSpecSubitems(specs_class, not "inherit")) + end) + table.sort(res) + return string.format("#(\n\t%s\n)\n", table.concat(res, ",\n\t")) +end + +function EntitySpec:SaveEntitySpec(filter) + return self:SaveSpec(nil, filter, function(name, es, res) + local id = EntityIDs[name] or -1 + assert(id > 0, "Entities without ids present at ArtSpec save! Please call a developer!") + res[#res + 1] = string.format('(EntitySpec name:"%s" id:%d exportableToSVN:%s)', + name, + id, + tostring(es.exportableToSVN)) + end) +end + +function EntitySpec:SaveMeshSpec(res, filter) + return self:SaveSpec("MeshSpec", filter, function(name, es, res, meshes) + for _, mesh in pairs(meshes) do + local materials = mesh:GetMaterialsArray() + for lod = 1, mesh.lod do + for m = 1, Max(#materials, 1) do + local mat = materials[m] and string.format('material:"%s" ', materials[m]) or "" + local spots = mesh.spots == "" and "" or string.format('spots:"%s" ', mesh.spots) + local surfaces = mesh.surfaces == "" and "" or string.format('surfaces:"%s" ', mesh.surfaces) + res[#res + 1] = string.format('(MeshSpec entity:"%s" name:"%s" lod:%d animated:%s %s%s%smaxTexturesSize:%d)', + name, + mesh.name, + lod, + tostring(mesh.animated), + mat, + spots, + surfaces, + mesh.maxTexturesSize) + end + end + end + end) +end + +function EntitySpec:SaveMaskSpec(filter) + return self:SaveSpec("MaskSpec", filter, function(name, es, res, masks) + for _, mask in pairs(masks) do + res[#res + 1] = string.format('(MaskSpec entity:"%s" name:"%s")', + name, + mask.name) + end + end) +end + + +function EntitySpec:SaveStateSpec(filter) + return self:SaveSpec("StateSpec", filter, function(name, es, res, states) + for _, state in pairs(states) do + local mesh = es:GetMeshSpec(state.mesh) + res[#res + 1] = string.format('(StateSpec entity:"%s" name:"%s" mesh:"%s" animated:%s looping:%s)', + name, + state.name, + mesh.name, + tostring(mesh.animated or false), + tostring(state.looping)) + end + end) +end + +function EntitySpec:SaveInheritanceSpec(filter) + return self:SaveSpec(nil, filter, function(name, es, res) + if es.inherit_entity ~= "" and es.inherit_entity ~= es.name then + res[#res + 1] = string.format('(InheritSpec entity:"%s" inherit:"%s" mesh:"%s")', + name, + es.inherit_entity, + "mesh") -- was es.inherit_mesh, this property was unused and removed + end + end) +end + +function EntitySpec:ExportMaxScript(folder, file_suffix, filter) + local filename + if file_suffix then + filename = string.format("%s/Spec/ArtSpec.%s.ms", folder, file_suffix) + else + filename = string.format("%s/Spec/ArtSpec.ms", folder) + end + local f,error_msg = io.open(filename, "w+") + + if f then + f:write( "struct StateSpec(entity, name, mesh, looping, animated, moments, compensation)\n" ) + f:write( "struct InheritSpec(entity, inherit, mesh)\n" ) + f:write( "struct MeshSpec(entity, name, lod, animated, spots, surfaces, decal, hgShader, dontCompressVerts, maxVerts, maxTris, maxBones, material, sortKey, maxTexturesSize)\n" ) + f:write( "struct EntitySpec(name, id, exportableToSVN)\n" ) + f:write( "struct MaskSpec(entity, name)\n" ) + f:write( "g_maxProjectBoneCount = " .. ArtSpecConfig.maxProjectBoneCount .. "\n" ) + f:write( "g_Platforms = " .. ArtSpecConfig.platforms .. "\n" ) + f:write( "g_EntitySpec = " .. self:SaveEntitySpec(filter) ) + f:write( "g_MeshSpec = " .. self:SaveMeshSpec(nil, filter) ) + f:write( "g_StateSpec = " .. self:SaveStateSpec(filter) ) + f:write( "g_InheritSpec = " .. self:SaveInheritanceSpec(filter) ) + f:write( "g_MaskSpec = " .. self:SaveMaskSpec(filter) ) + f:write("\n") + f:close() + + print( "Wrote " .. filename ) + SVNAddFile(filename) + return true + else + print("ERROR: [Save] Could not save " .. filename .. " - " .. error_msg) + return false + end +end + +function EntitySpec:ExportDlcLists() + local old = io.listfiles("svnAssets/Spec/", "*.*list") + if next(old) then + local err = AsyncFileDelete(old) + assert(not err, err) + end + + local by_dlc = {} + ForEachPreset(EntitySpec, function(entity_data) + local states = entity_data:GetSpecSubitems("StateSpec", not "inherit") + local entity_key = entity_data.save_in + + local mesh_names = {} + for mesh_name, mesh_spec in pairs(entity_data:GetSpecSubitems("MeshSpec")) do + for i=1,mesh_spec.lod do + mesh_names[#mesh_names+1] = mesh_name .. (( i == 1 ) and "" or ("." .. tonumber(i-1))) + end + end + + for _, state in sorted_pairs(states) do + local state_key = (state.save_in == "") and entity_key or state.save_in + if state_key ~= "Common" then + local file = (state_key == "") and "$(file)" or "Animations/$(file)" + state_key = state_key .. ".statelist" + local list = by_dlc[state_key] or { "return {\n" } + by_dlc[state_key] = list + list[#list + 1] = string.format("\t['$(assets)/Bin/Common/Animations/%s_%s.hgacl'] = '%s',\n", entity_data:Getname(), state.name, file) + end + end + + if entity_key == "Common" then return end + local file = (entity_key == "") and "$(file)" or "Meshes/$(file)" + entity_key = entity_key .. ".meshlist" + local list = by_dlc[entity_key] or { "return {\n" } + by_dlc[entity_key] = list + + local sorted_list = {} + for _, mesh_name in ipairs(mesh_names) do + sorted_list[#sorted_list + 1] = string.format("\t['$(assets)/Bin/Common/Meshes/%s_%s.hgm'] = '%s',\n", entity_data:Getname(), mesh_name, file) + end + table.sort(sorted_list) + table.iappend(list, sorted_list) + + while entity_data do + if entity_data.inherit_entity ~= "" then + local sorted_list = {} + for _, mesh_name in ipairs(mesh_names) do + sorted_list[#sorted_list + 1] = string.format("\t['$(assets)/Bin/Common/Meshes/%s_%s.hgm'] = '%s',\n", entity_data.inherit_entity, mesh_name, file) + end + table.sort(sorted_list) + table.iappend(list, sorted_list) + end + entity_data = EntitySpecPresets[entity_data.inherit_entity] + end + end) + + local files_to_save = {} + for save_in, files in pairs(by_dlc) do + files = table.get_unique(files) + files[#files + 1] = "}\n" + local filename = "svnAssets/Spec/" .. save_in + AsyncStringToFile(filename, files) + table.insert(files_to_save, filename) + end + SVNAddFile(files_to_save) + return true +end + +function EntitySpec:ExportEntityProducers() + local map = { } + ForEachPreset("EntitySpec", function(preset, group, filters) + if preset.save_in == "Common" then return end + map[preset.id] = preset.produced_by + end) + local content = ValueToLuaCode(map, nil, pstr("return ", 256*1024)) + local path = "svnAssets/Spec/EntityProducers.lua" + AsyncStringToFile(path, content) + SVNAddFile(path) + return true +end + +function EntitySpec:ExportEntityData() + local entities_by_dlc = { + ["Common"] = pstr("EntityData = {}\nif Platform.ged then return end\n"), + [""] = pstr("if Platform.ged then return end\n") + } + + ForEachPreset(EntitySpec, function(es) + local entity_data = es:ExportEntityDataForSelf() + if next(entity_data) then + local save_in = es.save_in or "" + entities_by_dlc[save_in] = entities_by_dlc[save_in] or pstr("") + local dlc_pstr = entities_by_dlc[save_in] + dlc_pstr:append("EntityData[\"", es.id, "\"] = ") + dlc_pstr:appendt(entity_data) + dlc_pstr:append("\n") + end + end) + + for dlc, data in pairs(entities_by_dlc) do + local path + if dlc == "Common" then + path = "CommonLua/_EntityData.generated.lua" + elseif dlc ~= "" then + path = string.format("svnProject/Dlc/%s/Code/_EntityData.generated.lua", dlc) + else + path = "Lua/_EntityData.generated.lua" + end + if #data > 0 then + local err = SaveSVNFile(path, data) + if err then return not err end + else + SVNDeleteFile(path) + end + end + return true +end + +function EntitySpec:SaveAll(...) + self:SortPresets() + + self:ReserveEntityIDs() + local SaveFailed = function() + print("Export failed") + end + + local default_filter = function(es) return es.save_in ~= "Common" end + if not self:ExportMaxScript("svnAssets", nil, default_filter) then SaveFailed() return end --combined file + for i,produced_by in ipairs(ArtSpecConfig.EntityProducers) do + -- separate file per art producer + local producer_filter = function(es) return es.produced_by == produced_by and es.save_in ~= "Common" end + if not self:ExportMaxScript("svnAssets", produced_by, producer_filter) then SaveFailed() return end + end + if not self:ExportEntityData() then SaveFailed() return end + if not self:ExportDlcLists() then SaveFailed() return end + if not self:ExportEntityProducers() then SaveFailed() return end + + local common_filter = function(es) return es.save_in == "Common" end + if not self:ExportMaxScript("CommonAssets", nil, common_filter) then SaveFailed() return end + + -- force saving ArtSpec-base.lua every time + local base_file_path = "svnAssets/Spec/ArtSpec-base.lua" + local prev_dirty_status = g_PresetDirtySavePaths[base_file_path] + g_PresetDirtySavePaths[base_file_path] = "EntitySpec" + + Preset.SaveAll(self, ...) + + g_PresetDirtySavePaths[base_file_path] = prev_dirty_status +end + +function EntitySpec:OnEditorNew(parent, ged, is_paste) + if not is_paste then + self[1] = MeshSpec:new{ name = "mesh" } + self[2] = StateSpec:new{ name = "idle", mesh = "mesh" } + end + + local _, _, base_name, suffix = self.id:find("(.*)_(%d%d)$") + if suffix == "01" and EntitySpecPresets[base_name] then + self:SetId(self:GenerateUniquePresetId(base_name .. "_02")) + end + self.last_change_time = os.time(os.date("!*t")) +end + +function EntitySpec:OnEditorDelete(parent, ged) + EntityIDs[self.id] = nil + self:DeleteEntityFiles() +end + +function EntitySpec:EditorContext() + local context = Preset.EditorContext(self) + table.remove_value(context.classes, "AssetSpec") + return context +end + +function EntitySpec:GetAnimRevision(entity, anim) + if not IsValidEntity(entity) or not HasState(entity, anim) then return 0 end + return GetAssetFileRevision("Animations/" .. GetEntityAnimName(entity, anim)) +end + +function EntitySpec:GetEntityFiles(entity) + entity = entity or self.id + local ef_list = GetEntityFiles(entity) + local existing, non_existing = {}, {} + for _, ef in ipairs(ef_list) do + table.insert(io.exists(ef) and existing or non_existing, ef) + end + return existing, non_existing +end + +function EntitySpec:ListEntityFilesButton(root, prop_id, ged) + local entity = self.id + local status = not IsValidEntity(entity) and "-> Invalid!" or "" + local existing, non_existing = self:GetEntityFiles(entity) + existing = table.map(existing, ConvertToOSPath) + non_existing = table.map(non_existing, ConvertToOSPath) + + local output = {} + table.sort(existing) + table.iappend(output, existing) + if #non_existing > 0 then + output[#output + 1] = "\nMissing, but referenced and/or mandatory files:" + table.sort(non_existing) + table.iappend(output, non_existing) + end + output[#output + 1] = string.format("\nTotal files: %d present and %d non-existent", #existing, #non_existing) + ged:ShowMessage(string.format("Files for entity: '%s' %s", entity, status), table.concat(output, "\n")) +end + +function EntitySpec:DeleteEntityFilesButton(root, prop_id, ged) + local result = ged:WaitQuestion("Confirm Deletion", "Delete all exported files for this entity?", "Yes", "No") + if result ~= "ok" then + return + end + CreateRealTimeThread(EntitySpec.DeleteEntityFiles, self) +end + +function EntitySpec:DeleteEntityFiles(id) + id = id or self.id + print(string.format("Deleting '%s' entity files...", id)) + + local f_existing = self:GetEntityFiles(id) + SVNDeleteFile(f_existing) + print("Done") +end + +function GedOpCleanupObsoleteAssets(ged, target, type) + if type == "mappings" then + CreateRealTimeThread(CleanupObsoleteMappingFiles) + else + CreateRealTimeThread(EntitySpec.CleanupObsoleteAssets, EntitySpec, ged) + end +end + +if FirstLoad then + CheckEntityUsageThread = false +end + +function CheckEntityUsage(ged, obj, selection) + DeleteThread(CheckEntityUsageThread) + CheckEntityUsageThread = CreateRealTimeThread(function() + obj = obj or {} + selection = selection or {} + local art_specs = obj[selection[1][1]] or {} + local selected_specs = selection[2] or {} + local entities = {} + for i, idx in ipairs(selected_specs) do + entities[i] = art_specs[idx].id + end + if #entities == 0 then + entities = table.keys(g_AllEntities or GetAllEntities()) + end + local all_files = {} + local function AddSourceFiles(path) + local err, files = AsyncListFiles(path, "*.lua", "recursive") + if not err then + table.iappend(all_files, files) + end + end + AddSourceFiles("CommonLua") + AddSourceFiles("Lua") + AddSourceFiles("Data") + AddSourceFiles("Dlc") + AddSourceFiles("Maps") + AddSourceFiles("Tools") + AddSourceFiles("svnAssets/Spec") + AddSourceFiles("CommonAssets/Spec") + if #entities == 1 then + print("Search for entity", entities[1], "in", #all_files, "files...") + elseif #entities < 4 then + print("Search for entities", table.concat(entities, ", "), "in", #all_files, "files...") + else + print("Search", #entities, "entities in", #all_files, "files...") + end + Sleep(1) + local string_to_files = SearchStringsInFiles(entities, all_files) + local filename = "AppData/EntityUsage.txt" + local err = AsyncStringToFile(filename, TableToLuaCode(string_to_files)) + if err then + print("Failed to save report:", err) + return + end + print("Report saved to:", ConvertToOSPath(filename)) + OpenTextFileWithEditorOfChoice(filename) + end) +end + +function CollectAllReferencedAssets() + local existing_assets = {} + local non_ref_entities = {} + g_AllEntities = g_AllEntities or GetAllEntities() + -- collecting all used assets + for entity_name in pairs(g_AllEntities) do + local entity_specs = GetEntitySpec(entity_name, "expect_missing") + if entity_specs then + local existing = EntitySpec:GetEntityFiles(entity_name) + for _, asset in ipairs(existing) do + local folder = asset:match("(Materials)/") or asset:match("(Animations)/") or asset:match("(Meshes)/") or asset:match("(Textures.*)/") + if folder then + existing_assets[folder] = existing_assets[folder] or {} + local asset_name = asset:match(folder.."/(.*)") + local ref_folder = existing_assets[folder] + ref_folder[asset_name] = "exists" + end + end + else + non_ref_entities[#non_ref_entities + 1] = entity_name + end + end + + return existing_assets, non_ref_entities +end + +function CleanupObsoleteMappingFiles(existing_assets) + if not CanYield() then + CreateRealTimeThread(CleanupObsoleteMappingFiles, existing_assets) + return + end + if not existing_assets then + existing_assets = CollectAllReferencedAssets() + end + -- drop extensions + local referenced_textures = {} + for asset_name in pairs(existing_assets.Textures) do + local texture_path = string.match(asset_name, "(.+)%.dds$") + if texture_path then + referenced_textures[texture_path] = true + end + end + + local err, files = AsyncListFiles("Mapping/", "*.json", "") + if err then + printf("Loading of texture remapping files failed: %s", err) + return + end + local files_removed = 0 + local texture_refs_removed = 0 + parallel_foreach(files, function(file) + file = ConvertToOSPath(file) + local err, content = AsyncFileToString(file) + if err then + printf("Loading of texture mapping file %s failed: %s", file, err) + return + end + local err, obj = JSONToLua(content) + if err then + printf("Loading of texture mapipng file %s failed : %s", file, err) + return + end + + local path, name, ext = SplitPath(file) + local entity_id = EntityIDs[name] and tostring(EntityIDs[name]) + + local ids = table.keys(obj) + for _, id in ipairs(ids) do + if not referenced_textures[id] or + (entity_id and not string.starts_with(id, entity_id)) then + obj[id] = nil + texture_refs_removed = texture_refs_removed + 1 + end + end + + if not next(obj) then + local err = AsyncFileDelete(file) + if err then print("Failed to delete file", file, err) end + files_removed = files_removed + 1 + else + local err, json = LuaToJSON(obj, { pretty = true, sort_keys = true, }) + if err then + printf("Failed to serialize json.") + return + end + local err = AsyncStringToFile(file, json) + if err then print("Failed to write file", file, err) end + end + end) + print("CleanupObsoleteMappingFiles - removed " .. files_removed .. " mapping files and " .. texture_refs_removed .. " texture references") +end + +function EntitySpec:CleanupObsoleteAssets(ged) + local result = ged:WaitQuestion("Confirm Deletion", "Cleanup all unreferenced art assets from entitites?", "Yes", "No") + if result ~= "ok" then return end + + local existing_assets, non_ref_entities = CollectAllReferencedAssets() + + -- delete all non existent entities + for _, name in ipairs(non_ref_entities) do + EntitySpec:DeleteEntityFiles(name) + end + -- checking folders and deleting non used assets + local assets = { + "Materials", + "Animations", + "Meshes", + "Textures", + } + + local to_delete = {} + for _, asset_type in ipairs(assets) do + local assets_list = {} + local entity_assets = existing_assets[asset_type] + if asset_type == "Textures" then + local texture_ids = {} + for asset,_ in pairs(entity_assets) do + local id = asset:match("(.*).dds") + texture_ids[id] = "exists" + end + table.iappend(assets_list, io.listfiles("svnAssets/Bin/win32/Textures", "*.dds", "non recursive")) + table.iappend(assets_list, io.listfiles("svnAssets/Bin/win32/Fallbacks/Textures", "*.dds", "non recursive")) + table.iappend(assets_list, io.listfiles("svnAssets/Bin/Common/TexturesMeta", "*.lua", "non recursive")) + + for _, asset in ipairs(assets_list) do + local asset_id = asset:match("Textures.*/(%d*)") + if not texture_ids[asset_id] then + table.insert(to_delete, asset) + end + end + else + assets_list = io.listfiles("svnAssets/Bin/Common/" ..asset_type) + for _, asset in ipairs(assets_list) do + local asset_name = asset:match(asset_type..".*/(.*)$") + if not entity_assets[asset_name] then + table.insert(to_delete, asset) + end + end + end + end + print(string.format("Deleted assets count: %d", #to_delete)) + SVNDeleteFile(to_delete) + print("done") +end + +function GedOpDeleteEntitySpecs(ged, presets, selection) + local res = ged:WaitQuestion("Confirm Deletion", "Delete the selected entity specs and all exported files?", "Yes", "No") + if res ~= "ok" then return end + return GedOpPresetDelete(ged, presets, selection) +end + +function GetEntitySpec(entity, expect_missing) + g_AllEntities = g_AllEntities or GetAllEntities() + if not g_AllEntities[entity] then + assert(expect_missing, string.format("No such entity '%s'!", entity)) + return false + end + local spec = EntitySpecPresets[entity] + assert(spec or expect_missing, string.format("Entity '%s' not found in ArtSpec!", entity)) + return spec +end + +function GetStatesFromCategory(entity, category, walked_entities) + if not category or category == "All" then + return GetStates(entity) + end + walked_entities = walked_entities or {} + if not walked_entities[entity] then + walked_entities[entity] = true + else + return {} + end + if not table.find(ArtSpecConfig.ReturnAnimationCategories, category) then + assert(false, string.format("No such animation category - '%s'!", category)) + return GetStates(entity) + end + local entity_spec = EntitySpecPresets[entity] or GetEntitySpec(entity) + if not entity_spec then return {} end + local states = {} + if entity_spec.inherit_entity ~= "" then + local inherited_states = GetStatesFromCategory(entity_spec.inherit_entity, category, walked_entities) + for i = 1, #inherited_states do + if not table.find(states, inherited_states[i]) then + states[#states + 1] = inherited_states[i] + end + end + end + for i = 1, #entity_spec do + local spec = entity_spec[i] + if spec.class == "StateSpec" and spec.category == category then + if not table.find(states, spec.name) then + states[#states + 1] = spec.name + end + end + end + return states +end + +function GetStatesFromCategoryCombo(entity, category, ignore_underscore, ignore_error_states) + local IsErrorState, GetStateIdx = IsErrorState, GetStateIdx + local states = {} + for _, state in ipairs(GetStatesFromCategory(entity, category)) do + local is_error_state = IsErrorState(entity, GetStateIdx(state)) + if (not ignore_underscore or not state:starts_with("_")) + and (not ignore_error_states or not is_error_state) + then + if is_error_state then + states[#states + 1] = state .. " *" + else + states[#states + 1] = state + end + end + end + table.sort(states) + return states +end + +if FirstLoad then + EntityIDs = false + PreviousEntityIDs = false + LastEntityID = false + LastCommonEntityID = false +end + +function EntitySpec:GetSaveData(file_path, preset_list, ...) + local code = Preset.GetSaveData(self, file_path, preset_list, ...) + local save_in = preset_list[1] and preset_list[1].save_in + local initializedIDsObject = false + if save_in == "Common" then + code:appendf("\nLastCommonEntityID = %d\n", LastCommonEntityID) + for name, id in sorted_pairs(EntityIDs) do + if id >= CommonAssetFirstID then + if not initializedIDsObject then + code:append("if not next(EntityIDs) then EntityIDs = {} end \n") + initializedIDsObject = true + end + code:appendf("EntityIDs[\"%s\"] = %d\n", name, id) + end + end + elseif save_in == "" then + code:appendf("\nLastEntityID = %d\n\n", LastEntityID) + for name, id in sorted_pairs(EntityIDs) do + if id < CommonAssetFirstID then + if not initializedIDsObject then + code:append("if not next(EntityIDs) then EntityIDs = {} end \n") + initializedIDsObject = true + end + code:appendf("EntityIDs[\"%s\"] = %d\n", name, id) + end + end + end + return code +end + +function ReserveCommonEntityID(entity) + if EntityIDs[entity] then + assert(false, "Entity already has a reserved ID (%d)!", EntityIDs[entity]) + return false + end + local id = GetUnusedCommonEntityID() + if id then + EntityIDs[entity] = id + LastCommonEntityID = id + return id + end + assert(false, "Could not reserve a new Entity ID!") + return false +end + +function GetUnusedCommonEntityID() + if not LastCommonEntityID then + local max_id = CommonAssetFirstID + if not next(EntityIDs) then + max_id = CommonAssetFirstID + end + for _, id in pairs(EntityIDs) do + max_id = Max(max_id, id) + end + if max_id >= CommonAssetFirstID then + LastCommonEntityID = max_id + else + assert(false, "GetUnusedCommonEntityID failed!") + return false + end + end + return LastCommonEntityID + 1 +end + +function ReserveEntityID(entity) + if EntityIDs[entity] then + assert(false, "Entity already has a reserved ID (%d)!", EntityIDs[entity]) + return false + end + local id = GetUnusedEntityID() + if id then + EntityIDs[entity] = id + LastEntityID = id + return id + end + assert(false, "Could not reserve a new Entity ID!") + return false +end + +function GetUnusedEntityID() + if not LastEntityID then + local max_id = -99999 + if not next(EntityIDs) then + max_id = 0 + end + local only_common = true + for _, id in pairs(EntityIDs) do + if id < CommonAssetFirstID then + only_common = false + max_id = Max(max_id, id) + end + end + if only_common then + max_id = 0 + end + if max_id >= 0 then + LastEntityID = max_id + else + assert(false, "GetUnusedEntityID failed!") + return false + end + end + return LastEntityID + 1 +end + +function ValidateEntityIDs() + local used_ids, errors = {}, false + for name, id in pairs(EntityIDs) do + if used_ids[id] then + StoreErrorSource(EntitySpecPresets[name], string.format("Duplicated entity id found - '%d' for entities '%s' and '%s'!", id, used_ids[id], name)) + errors = true + else + used_ids[id] = name + end + end + if errors then + OpenVMEViewer() + end +end + +function OnMsg.GedOpened(ged_id) + local gedApp = GedConnections[ged_id] + if gedApp and gedApp.app_template == EntitySpec.GedEditor then + ValidateEntityIDs() + end +end + + +----- Filtering + +DefineClass.EntitySpecFilter = { + __parents = { "GedFilter" }, + + properties = { + { id = "Class", editor = "combo", default = "", items = PresetsPropCombo("EntitySpec", "class_parent", "") }, + { id = "NotOfClass", editor = "combo", default = "", items = PresetsPropCombo("EntitySpec", "class_parent", "") }, + { id = "Category", editor = "choice", default = "", items = function() return table.iappend({""}, ArtSpecConfig.Categories) end }, + { id = "produced_by", name = "Produced by", editor = "combo", default = "", items = function() return table.iappend({""}, ArtSpecConfig.EntityProducers) end, }, + { id = "status", name = "Production status", editor = "choice", default = "", items = function() return table.iappend({{id = ""}}, statuses) end }, + { id = "MaterialType", editor = "preset_id", default = "", preset_class = "ObjMaterial" }, + { id = "OnCollisionWithCamera", editor = "choice", items = { "", "no action", "become transparent", "repulse camera" }, default = "", no_edit = NoCameraCollision }, + { id = "fade_category", name = "Fade Category" , editor = "choice", items = FadeCategoryComboItems, default = "", }, + { id = "HasBillboard", name = "Billboard" , editor = "choice", default = "", items = { "", "yes", "no" } }, + { id = "HasCollision", name = "Collision" , editor = "choice", default = "any", items = { "any", "has collision", "has no collision" } }, + { id = "ExportableToSVN", name = "Exportable to SVN", editor = "choice", default = "", items = { "", "true", "false" } }, + { id = "Exported", name = "Is exported", editor = "choice", default = "", items = { "", "yes", "no" } }, + { id = "FilterStateSpecDlc", name = "DLC", editor = "choice", default = false, items = DlcCombo{text = "Any", value = false} }, + { id = "FilterID", name = "ID", editor = "number", default = 0, help = "Find an entity by its unique numeric id." }, + }, + + billboard_entities = false, +} + +function EntitySpecFilter:Init() + self.ExportableToSVN = "true" + self.billboard_entities = table.invert(hr.BillboardEntities) +end + +function EntitySpecFilter:FilterObject(o) + if not IsKindOf(o, "EntitySpec") then + return true + end + if self.Class ~= "" and not string.find_lower(o.class_parent, self.Class) then + return false + end + if self.NotOfClass ~= "" and string.find_lower(o.class_parent, self.NotOfClass) then + return false + end + if self.Category ~= "" and o.editor_category ~= self.Category then + return false + end + if self.produced_by ~= "" and o.produced_by ~= self.produced_by then + return false + end + if self.status ~= "" and o.status ~= self.status then + return false + end + if self.MaterialType ~= "" and o.material_type ~= self.MaterialType then + return false + end + if not NoCameraCollision and self.OnCollisionWithCamera ~= "" and o.on_collision_with_camera ~= self.OnCollisionWithCamera then + return false + end + if self.fade_category ~= "" and o.fade_category ~= self.fade_category then + return false + end + if self.ExportableToSVN ~= "" and o.exportableToSVN ~= (self.ExportableToSVN == "true") then + return false + end + g_AllEntities = g_AllEntities or GetAllEntities() + local exported = g_AllEntities[o.id] + if self.Exported == "yes" and not exported or self.Exported == "no" and exported then + return false + end + if self.HasBillboard == "yes" and not self.billboard_entities[o.id] or self.HasBillboard == "no" and self.billboard_entities[o.id] then + return false + end + if self.HasCollision ~= "any" then + local has_collision = (exported and HasCollisions(o.id)) and "has collision" or "has no collision" + if self.HasCollision ~= has_collision then + return false + end + end + if self.FilterStateSpecDlc ~= false and + ((o.class == "EntitySpec" and o.save_in ~= self.FilterStateSpecDlc) or + (o.class == "StateSpec" and o.save_in ~= self.FilterStateSpecDlc)) then + return false + end + if self.FilterID > 0 and EntityIDs[o.id] ~= self.FilterID then + return false + end + return true +end + +function EntitySpecFilter:TryReset(ged, op, to_view) + return false +end + + +----- Preview entities from the selected entity specs + +if FirstLoad then + ArtSpecEditorPreviewObjects = {} +end + +function OnMsg.GedPropertyEdited(ged_id, obj, prop_id, old_value) + local gedApp = GedConnections[ged_id] + if gedApp and gedApp.app_template == EntitySpec.GedEditor then + if prop_id:find("Editable", 1, true) then -- quick way to see whether a colorization property is edited + for _, o in ipairs(ArtSpecEditorPreviewObjects) do + if IsValid(o) then + o:SetColorsFromTable(obj) + end + end + end + end +end + +function OnArtSpecSelectObject(entity_spec, selected) + if GetMap() == "" then return end + + -- delete old objects + local objs = ArtSpecEditorPreviewObjects + for _, obj in ipairs(objs) do + if IsValid(obj) then + obj:delete() + end + end + table.clear(objs) + + if not selected or IsTerrainEntityId(entity_spec.id) then return end + + -- create new objects, assign points starting at (0, 0) + local all_names = { entity_spec.id } + local _, _, base_name = entity_spec.id:find("(.*)_%d%d$") + if base_name then + local i = 1 + local name = string.format("%s_%02d", base_name, i) + local names = {} + while EntitySpecPresets[name] do + names[#names + 1] = name + i = i + 1 + name = string.format("%s_%02d", base_name, i) + end + if names[1] then + all_names = names + end + end + + local positions + local first_bbox, last_bbox + local direction = Rotate(camera.GetDirection(), 90 * 60):SetZ(0) + for _, name in ipairs(all_names) do + local obj = Shapeshifter:new() + obj:ChangeEntity(name) + obj:ClearEnumFlags(const.efApplyToGrids) + obj:SetColorsByColorizationPaletteName(g_DefaultColorsPalette) + AutoAttachObjects(obj) + obj:SetWarped(true) + + local bbox = obj:GetEntityBBox("idle") + if positions then + positions[#positions + 1] = positions[#positions] + SetLen(direction, last_bbox:sizey() / 2 + bbox:sizey() / 2 + 1 * guim) + else + first_bbox = bbox + positions = { point(0, 0) } + end + last_bbox = bbox + objs[#objs + 1] = obj + + local text = Text:new() + text:SetText(name) + objs[#objs + 1] = text + end + + -- set positions centered at the center of the screen + local angle = CalcOrientation(direction) + 90 * 60 + local central_point = GetTerrainGamepadCursor():SetInvalidZ() - positions[#positions] / 2 + local bottom_point, top_point = GetTerrainGamepadCursor(), GetTerrainGamepadCursor() + for i = 1, #objs / 2 do + local obj = objs[i * 2 - 1] + local bbox = obj:GetEntityBBox("idle") + local pos = positions[i] + central_point + local objPos = pos:SetTerrainZ() - point(0, 0, bbox:minz() + guic / 10) + obj:SetPos(objPos) + obj:SetAngle(angle) + objs[i * 2]:SetPos(pos:SetTerrainZ()) + if objPos:z() - bbox:sizez() < top_point:z() then + top_point = objPos - point(0, 0, bbox:sizez()) + end + end + + -- set camera "look at" position to the central point + local ptEye, ptLookAt = GetCamera() + local ptMoveVector = GetTerrainGamepadCursor() - ptLookAt + ptEye, ptLookAt = ptEye + ptMoveVector, ptLookAt + ptMoveVector + SetCamera(ptEye, ptLookAt) + + -- measure objects total screen width against screen size, and adjust camera to fit all of them + CreateRealTimeThread(function() + WaitNextFrame(3) + if IsValid(objs[1]) and IsValid(objs[#objs - 1]) then + local _, first_pos = GameToScreen(objs[1] :GetPos() - SetLen(direction, first_bbox:sizey() / 2)) + local _, last_pos = GameToScreen(objs[#objs - 1]:GetPos() + SetLen(direction, last_bbox:sizey() / 2)) + local _, bottom_pos = GameToScreen(bottom_point) + local _, top_pos = GameToScreen(top_point) + local objectsWidth = last_pos:x() - first_pos:x() + local objectsHeight = top_pos:y() - bottom_pos:y() + local w = MulDivRound(UIL.GetScreenSize():x(), 70, 100) + local h = MulDivRound(UIL.GetScreenSize():y(), 25, 100) + if objectsWidth > w or objectsHeight > h then + local backDirection = ptEye - ptLookAt + local len = Max(backDirection:Len() * objectsWidth / w, backDirection:Len() * objectsHeight / h) + SetCamera(ptLookAt + SetLen(backDirection, len), ptLookAt) + end + end + end) +end diff --git a/CommonLua/Editor/EditorGame.lua b/CommonLua/Editor/EditorGame.lua new file mode 100644 index 0000000000000000000000000000000000000000..9da64004a927fe6d63ca39b494561f272a1fb63e --- /dev/null +++ b/CommonLua/Editor/EditorGame.lua @@ -0,0 +1,705 @@ +function GetTerrainImage(texture) + local img = texture or "" --"UI/Editor/" .. texture + if img ~= "" and not io.exists(img) then + if string.ends_with(img, "tga", true) then + img = string.sub(img, 1, string.len(img) - 3) .. "dds" + end + end + return img +end + +local save_order_cache = {} +local save_order_class = {} +local save_objects_order = config.SaveObjectsOrder or {} + +local function FindSaveOrderByClass(obj) + local obj_class = obj.class + local save_order_idx = save_order_cache[obj_class] + if save_order_idx then + return save_order_idx, save_order_class[obj_class] + end + for i=1,#save_objects_order do + local classes = save_objects_order[i] + for j=1,#classes do + if IsKindOf(obj, classes[j]) then + save_order_cache[obj_class] = i + save_order_class[obj_class] = classes[j] + return i + end + end + end + save_order_cache[obj_class] = max_int + save_order_class[obj_class] = "" + return max_int +end + +function CompareObjectsForSave(o1, o2) + local class1 = FindSaveOrderByClass(o1) + local class2 = FindSaveOrderByClass(o2) + if class1 ~= class2 then + return class1 < class2 + end + + local pos_cmp = MortonXYPosCompare(o1, o2) + if pos_cmp ~= 0 then + return pos_cmp < 0 + end + + return lessthan(rawget(o1, "handle"), rawget(o2, "handle")) +end + +function ObjectsToLuaCode(objects, result, GetPropFunc) + table.sort(objects, CompareObjectsForSave) + if not IsPStr(result) then + result = result or {} + for _, obj in ipairs(objects) do + result[#result + 1] = obj:__toluacode("", nil, GetPropFunc) + result[#result + 1] = "\n" + end + else + local class = "" + for _, obj in ipairs(objects) do + local _, new_class = FindSaveOrderByClass(obj) + if new_class ~= class then + if class ~= "" then + result:appendf("-- end of objects of class %s\n", class) + end + class = new_class + end + obj:__toluacode("", result, GetPropFunc) + result:append("\n") + end + if class and class ~= "" then + result:appendf("-- end of objects of class %s\n", class) + end + end + return result +end + +function RemapCollections() + local collection_map = {} + local new_col_index = 1 + local max_index_value = const.GameObjectMaxCollectionIndex + local current_collections = Collections + for _ , col in pairs(Collections) do + local col_index = col.Index + if col_index > 0 and col_index < max_index_value then + collection_map[col_index] = col_index + end + end + for _ , col in pairs(Collections) do + local col_index = col.Index + if not collection_map[col_index] then + while collection_map[new_col_index] do + new_col_index = new_col_index + 1 + end + collection_map[col_index] = new_col_index + new_col_index = new_col_index + 1 + end + end + local all_collections = MapGet(true, "Collection") + for _ , col in ipairs(all_collections) do + local col_index = col.Index + col:SetIndex(collection_map[col_index]) + end +end + +local ReloadCollectionIndexes = false +function OnMsg.NewMapLoaded() + if ReloadCollectionIndexes and MapCount(true, "Collection") > 0 then + RemapCollections() + end +end + +function GetMapObjectsForSaving() + return MapGet(true, "attached", false, nil, nil, const.gofPermanent, nil, const.cfLuaObject, + function(o) + return not IsKindOf(o, "Collection") + end) or empty_table +end + +function SaveObjects(filename) + local code = pstr("", 64*1024) + + -- All valid object Collections. + -- They get serialized first, so that objects being loaded will be able to set their "CollectionIndex" property... properly. + local ol = Collection.GetValid() + ObjectsToLuaCode(ol, code) + + ol = GetMapObjectsForSaving() + + local max_handle = const.HandlesSyncStart or 2000000000 + for _, obj in ipairs(ol) do + if obj:IsSyncObject() then + max_handle = Max(max_handle, obj.handle) + end + end + + code:appendf("SetNextSyncHandle(%d)\n", max_handle + 1) + ObjectsToLuaCode(ol, code) + mapdata.ObjectsHash = xxhash(code) + code:append("\n\n-- objects without Lua object\n") + __DumpObjPropsForSave(code) + code:append("\n") + + local err = AsyncStringToFile(filename, code) + if err then + printf("Failed to save \"%s\": %s", filename, err) + end +end + +function MakeMapBackup() + local max_backup_files = 100 + local fldMap = GetMap() + local fldBackup = "EditorBackup/" + + local tFolders = {} + if not io.exists(fldBackup) then + io.createpath(fldBackup) + else + tFolders = io.listfiles(fldBackup, "*", "folders") or {} + end + + if #tFolders>=max_backup_files then --Find and remove the oldest + local str = tFolders[1] -- find + for i, v in ipairs(tFolders) do + if v- + fldBackupName = fldBackup..fldBackupName.."-"..strData.."/" + + if not io.exists(fldBackupName) then + io.createpath(fldBackupName) + end + + --copy files + local tMapFiles = io.listfiles(fldMap) or {} + for _, v in ipairs(tMapFiles) do + if not string.match(v, "%.hpk") and not string.match(v, "%.be") then + local f, err = io.open(v,"rb") + local strFile = "" + if f then + local i,j = v:find("/[- _%.%w]+$") + --print ( v:sub((i or 0)+1,-1) ) + local backup_name = fldBackupName..v:sub((i or 0)+1,-1) + local f1, err = io.open(backup_name, "wb") + if f1 then + while strFile do + strFile = f:read(2048*1024) + if strFile then + f1:write(strFile) + end + end + f1:close() + else + print("Cannot open backup file " .. backup_name .. " : " .. err) + end + f:close() + else + print("Cannot open map file " .. v .. " : " .. err) + end + end + end +end + +if FirstLoad then + EditorSavingThread = false +end + +function IsEditorSaving() + return IsValidThread(EditorSavingThread) +end + +function CreateCompatibilityMapCopy() + local rev = mapdata and mapdata.AssetsRevision or 0 + if rev == 0 then + return + end + local map = GetMapName() + if IsOldMap(map) then + return + end + -- if the map has been marked as 'published' and its revision dates before the last official build, the compatibility map should be included in future builds + local force_pack = mapdata.PublishRevision > 0 and rev <= const.LastPublishedAssetsRevision or false + local default_path = "svnAssets/Source/Maps/" .. map .. "/" + local new_map_name = map .. "_old" .. rev + local new_path = "svnAssets/Source/Maps/" .. new_map_name .. "/" + io.createpath(new_path) + SVNAddFile(new_path) + for _,file_path in ipairs(io.listfiles(default_path)) do + local file_new_path = string.gsub(file_path, map, new_map_name) + local err + if file_path:ends_with("/mapdata.lua", true) then + local err, str = AsyncFileToString(file_path) + if not err then + local idx = str:find("\tid = ", 1, true) + if idx then + local insert = "\tCreateRevisionOld = " .. tostring(AssetsRevision) .. ",\n\tForcePackOld = " .. tostring(force_pack) .. ",\n" + str = str:sub(1, idx - 1) .. insert .. str:sub(idx) + err = AsyncStringToFile(file_new_path, str) + end + end + else + err = CopyFile(file_path, file_new_path) + end + if err then + print("Copying " .. file_new_path .. " failed due to: " .. err .. "Try to do it manually.") + else + SVNAddFile(file_new_path) + end + end +end + +--[[ + options = { + validate_properties = true/false, -- default false, run property validation for every Object - VERY SLOW + validate_CObject = true/false, -- default true, validate CObjects + validate_Object = true/false, -- default true, validate Objects -- just for consistency, ignored, Objects are always validated ;-) + } +]] + +function CheckEssentialWarning(obj) + if IsKindOf(obj, "CObject") and obj:GetDetailClass() ~= "Essential" and not ObjEssentialCheck(obj) then + StoreErrorSource(obj, "Non-Essential(with collision surfaces) should have BOTH efCollision AND efApplyToGrids turned off!") + end +end + +function ValidateMapObjects(options) + DebugPrint("Validating map objects...\n") + local st = GetPreciseTicks() + + SuspendThreadDebugHook("ValidateMapObjects") + local silentVMEStack = config.SilentVMEStack + config.SilentVMEStack = true + + Msg("ValidateMap") + local procall = procall + + local options = options or {} + local validate_properties = options.validate_properties or false + local validate_CObject = options.validate_CObject or true + local validate_Object = options.validate_Object or true + if validate_CObject and not validate_Object then + assert(not "supported combination - Objects are always validated") + end + local gofFlagsAll = const.gofPermanent + local cfFlagsAll = not validate_CObject and const.cfLuaObject or nil + + local count + if validate_properties then + count = MapForEach(true, nil, nil, gofFlagsAll, nil, cfFlagsAll, nil, + function(obj) + local msg = obj:GetDiagnosticMessage("verbose") + if not msg then + -- + elseif msg[#msg] == "warning" then + StoreWarningSource(obj, msg[1]) + else + StoreErrorSource(obj, msg[1]) + end + CheckEssentialWarning(obj) + end) + else + count = MapForEach(true, nil, nil, gofFlagsAll, nil, cfFlagsAll, nil, + function(obj) + local _, err_msg, err_param = procall(obj.GetError, obj) + local _, warn_msg, warn_param = procall(obj.GetWarning, obj) + + if err_msg then + StoreErrorSource(err_param or obj, err_msg) + end + if warn_msg then + StoreWarningSource(warn_param or obj, warn_msg) + end + CheckEssentialWarning(obj) + end) + end + + ResumeThreadDebugHook("ValidateMapObjects") + config.SilentVMEStack = silentVMEStack + + DebugPrint("Validated", count, "objects in", GetPreciseTicks() - st, "ms\n") +end + +local function save_map(skipBackup, folder, silent) + folder = folder or GetMap() + AsyncCreatePath(folder) + + local backup_folder + if Platform.developer and not skipBackup then + -- do not back up new maps + if io.exists(folder .. "objects.lua") then + backup_folder = MakeMapBackup() + end + end + + Msg("PreSaveMap") + + if not silent then + ValidateMapObjects() + end + + Msg("SaveMap", folder, backup_folder) + + local new_terrain_hash = terrain.HashGrids(config.IgnorePassGridInTerrainHash) + if config.StorePrevTerrainMapVersionOnSave + and mapdata.GameLogic and mapdata.IsRandomMap + and mapdata.TerrainHash ~= new_terrain_hash + and (mapdata.AssetsRevision or 0) > 0 + then + -- Save compatibility map only if this map is under subversion. + local _, info = GetSvnInfo(folder) + if next(info) then + CreateCompatibilityMapCopy() + end + end + + local t = GetPreciseTicks() + SaveObjects(folder .. "objects.lua") + DebugPrint(string.format("Saved objects in %d ms\n", GetPreciseTicks() - t)) + + mapdata.TerrainHash = new_terrain_hash + terrain.Save(folder) + + WaitMinimapSaving() + + if Platform.developer and (config.SaveEntityList or mapdata.SaveEntityList) then + SaveMapEntityList(folder .. "entlist.txt") + end + + UpdateMapMaxObjRadius() + UpdateTerrainStats() + + local old_net_hash = mapdata.NetHash + mapdata.NetHash = xxhash(mapdata.TerrainHash, mapdata.ObjectsHash) + if old_net_hash ~= mapdata.NetHash then + mapdata.LuaRevision = LuaRevision + mapdata.OrgLuaRevision = OrgLuaRevision + mapdata.AssetsRevision = AssetsRevision + end + if folder == GetMap() then + mapdata:Save() + end + + Msg("PostSaveMap") + + EditorSavingThread = false + Msg("SaveMapDone") + + SVNAddFile(io.listfiles(folder)) +end + +function SaveMap(skipBackup, force, folder, silent) + if (IsEditorSaving() or not IsEditorActive() or IsChangingMap()) and not force then + return + end + + PauseInfiniteLoopDetection("SaveMap") + + EditorSavingThread = CurrentThread() + if not silent then print("Saving...") end + WaitNextFrame(4) + local start_time = GetPreciseTicks() + + if EditedMapVariation then + XEditorCreateMapPatch(EditedMapVariation:GetMapPatchPath(), "add_to_svn") + else + save_map(skipBackup, folder, silent) + end + + if not silent then + print(EditedMapVariation and "Map variation patch saved in" or "Map saved in", GetPreciseTicks() - start_time, "ms") + end + ResumeInfiniteLoopDetection("SaveMap") +end + +local function check_radius(obj, radius, surf) + local max_radius = obj.max_allowed_radius + if Max(radius, surf) > max_radius then + StoreErrorSource(obj, string.format("Object too large: %.3f / %.3f m", Max(radius, surf) * 1.0 / guim, max_radius * 1.0 / guim)) + radius = Min(max_radius, radius) + surf = Min(max_radius, surf) + end + return radius, surf +end + +function CalcMapMaxObjRadius(enum_flags_all, enum_flags_any, game_flags_all) + local max_radius_obj, max_surf_obj + local max_radius, max_surf = 0, 0 + local playbox = GetPlayBox() + game_flags_all = game_flags_all or const.gofPermanent + MapForEach("map", enum_flags_all, enum_flags_any, game_flags_all, function(obj, playbox) + local radius = obj:GetRadius() + local surf = radius + if max_surf < radius then + surf = obj:GetMaxSurfacesRadius2D() + end + radius, surf = check_radius(obj, radius, surf) + if max_radius < radius then + max_radius, max_radius_obj = radius, obj + end + if max_surf < surf and playbox:Dist2D2(obj) <= surf * surf then + max_surf, max_surf_obj = surf, obj + end + end, playbox) + return max_radius, max_surf, max_radius_obj, max_surf_obj +end + +local function max_obj_radius(obj) + local radius = obj:GetRadius() + local surf = obj:GetMaxSurfacesRadius2D() + radius, surf = check_radius(obj, radius, surf) + for _, attach in ipairs(obj:GetAttaches() or empty_table) do + local radius_i, surf_i = max_obj_radius(attach) + radius = Max(radius, radius_i) + surf = Max(surf, surf_i) + end + return radius, surf +end + +function UpdateMapMaxObjRadius(obj) + local radius, surf + if obj then + radius, surf = max_obj_radius(obj) + radius = Max(mapdata.MaxObjRadius, radius) + if GetPlayBox():Dist2D2(obj) > surf * surf then + surf = 0 + end + surf = Max(mapdata.MaxSurfRadius2D, surf) + else + radius, surf = CalcMapMaxObjRadius() + end + mapdata.MaxObjRadius = radius + mapdata.MaxSurfRadius2D = surf + SetMapMaxObjRadius(radius, surf) +end + +function UpdateTerrainStats() + local tavg, tmin, tmax = terrain.GetAreaHeight() + mapdata.HeightMapAvg = tavg + mapdata.HeightMapMin = tmin + mapdata.HeightMapMax = tmax +end + +function ShowMapMaxRadiusObj() + local radius, surf, radius_obj, surf_obj = CalcMapMaxObjRadius() + EditorViewMapObject(radius_obj, nil, true) +end + +function ShowMapMaxSurfObj() + local radius, surf, radius_obj, surf_obj = CalcMapMaxObjRadius() + EditorViewMapObject(surf_obj, nil, true) +end + +function OnMsg.EditorObjectOperation(op_finished, objs) + if op_finished then + for _, obj in ipairs(objs) do + UpdateMapMaxObjRadius(obj) + end + end +end + +if Platform.developer then + ValidateAllMapsThread = false + + -- see ValidateMapObjects for documentation of options + function WaitValidateAllMaps(options, filter) + ValidateAllMapsThread = CurrentThread() + local old = LocalStorage.DisableDLC + SetAllDevDlcs(true) + + SuspendThreadDebugHook("ValidateAllMaps") + + filter = filter or GameMapFilter + + local mapdata = table.filter(MapData, filter) + local maps = table.keys(mapdata, true) + GameTestsPrintf("Validating %d maps %s...", #maps, ValueToLuaCode(options)) + for i, map in ipairs(maps) do + GameTestsPrintf("\n[%d/%d] Validating map \"%s\"", i, #maps, map) + ChangeMap(map) + ValidateMapObjects(options) + WaitGameTimeStart() + end + + LocalStorage.DisableDLC = old + SaveLocalStorage() + + ResumeThreadDebugHook("ValidateAllMaps") + ValidateAllMapsThread = false + end + + function OnMsg.PostNewMapLoaded(silent) + if IsValidThread(ValidateAllMapsThread) or config.NoMapValidation then + return + end + if not silent and Platform.desktop then + ValidateMapObjects() + end + UpdateCollectionsEditor() + end + function GameTestsNightly.ValidateAllMaps() + WaitValidateAllMaps{ validate_properties = true, validate_Object = true, validate_CObject = false } + end + + -- example usage: *r WaitResaveAllMapdata(UpdateTerrainStats) + function WaitResaveAllMapdata(callback, filter) + if not callback then return end + + PauseGame(8) + SuspendThreadDebugHook("WaitUpdateAllMapdata") + SuspendFileSystemChanged("WaitUpdateAllMapdata") + table.change(config, "WaitUpdateAllMapdata", { + NoMapValidation = true + }) + + filter = filter or GameMapFilter + local datas = table.filter(MapData, filter) + local i, count = 0, table.count(datas) + for map, mapdata in sorted_pairs(datas) do + i = i + 1 + GameTestsPrintf("\n[%d/%d] Updating map \"%s\"", i, count, map) + local game_logic = mapdata.GameLogic + mapdata.GameLogic = false + ChangeMap(map) + mapdata.GameLogic = game_logic + if not procall(callback) then + break + end + mapdata:Save() + DoneMap() + end + + ChangeMap("") + table.restore(config, "WaitUpdateAllMapdata") + ResumeFileSystemChanged("WaitUpdateAllMapdata") + ResumeThreadDebugHook("WaitUpdateAllMapdata") + ResumeGame(8) + end +end + +function EnterEditorSaveMap() + CreateRealTimeThread( function() + while IsChangingMap() or IsEditorSaving() do + WaitChangeMapDone() + if IsEditorSaving() then + WaitMsg("SaveMapDone") + end + end + if not IsEditorActive() then + EditorActivate() + end + SaveMap() + end) +end + +-- rotating objects feature +if FirstLoad then + rotation_thread = false + editor.RotatingObjects = {} +end + +function OnMsg.GameEnterEditor() + DeleteThread(rotation_thread) + rotation_thread = CreateRealTimeThread(function() + while true do + for i=1, #editor.RotatingObjects do + local item = editor.RotatingObjects[i] + if IsValid(item.obj) then + item.obj:SetAngle( item.obj:GetVisualAngle() + 60, 100 ) + end + end + Sleep(100) + end + end) +end + +function OnMsg.GameExitEditor() + DeleteThread(rotation_thread) + for i = 1, #editor.RotatingObjects do + local item = editor.RotatingObjects[i] + if IsValid(item.obj) then + item.obj:SetAngle(60 * item.angle) + end + end + editor.RotatingObjects = {} +end + +if Platform.developer then + function DumpEntitiesSurfaces() + local out = {} + local visited = {} + + ClassDescendants("CObject", function(class_name, class, out, visited) + local entity = class:GetEntity() + + if visited[entity] then return end + visited[entity] = 1 + + local num_col, num_occ = GetEntityNumSurfaces(entity, EntitySurfaces.Collision), GetEntityNumSurfaces(entity, EntitySurfaces.Occluder) + if num_col ~= 0 or num_occ ~= 0 then + local s = entity .. '\t\thas ' .. num_col .. ' collision and ' .. num_occ .. ' occlusion surfs' + out[#out + 1] = s + end + end, out, visited) + + table.sort(out) + local f = io.open('surfs.txt', 'w') + for _, l in ipairs(out) do + f:write(l .. '\r\n') + end + f:close() + end + + function RemoveAllOccluders() + for _, obj in ipairs(MapGet("map", "CObject")) do + obj:SetOccludes(false) + end + end +end + +-------------------------------------------------------------------------------------------- + +function SelectSameFloorObjects(sel) + local objs = MapGet("map", "attached", false, "collection", editor.GetLockedCollectionIdx(), true) + local same_floor = {} + local oztop, ozbottom = -1, 9999*guim + if IsValid(sel) then + sel = { sel } + end + + for i=1,#sel do + local o = sel[i] + local ocenter, oradius = o:GetBSphere() + local oz = o:GetVisualPos():z() + local obbox = GetEntityBoundingBox(o:GetEntity()) + oztop = Max(oztop, oz + obbox:max():z() + 50 * guic) + ozbottom = Min(ozbottom, oz + obbox:min():z()- 50 * guic) + end + + for i=1,#objs do + local p = objs[i] + local pz = p:GetVisualPos():z() + local pbbox = GetEntityBoundingBox(p:GetEntity()) + + local pztop = pz + pbbox:max():z() + local pzbottom = pz + pbbox:min():z() + + if pztop < oztop and pzbottom > ozbottom then + same_floor[1+#same_floor] = objs[i] + end + end + + editor.ClearSel() + editor.AddToSel(same_floor) +end \ No newline at end of file diff --git a/CommonLua/Editor/PropertyHelpers.lua b/CommonLua/Editor/PropertyHelpers.lua new file mode 100644 index 0000000000000000000000000000000000000000..b04d7884ef5103a0f8f7043184479dce3281110f --- /dev/null +++ b/CommonLua/Editor/PropertyHelpers.lua @@ -0,0 +1,1359 @@ +-- Property Helpers are ingame objects that visualize and(or) modify an object's property. They are requested per property +-- through the property metatable field "helper". If the main object's property can be changed from Hedge, the helper should implement +-- Update method to receive notification.If modifications of the helper from the ingame editor affect the main object(or the helper) - the helper should +-- implement EditorCallback method and should have cfEditorCallback set in order to receive notification. +-- (see RelativePos helper for reference). + +---------------------------------------------------------------------------------- +-- Helper classes definition -- +---------------------------------------------------------------------------------- + +DefineClass.PropertyHelper = { + __parents = { "Object" }, + flags = { cfEditorCallback = true }, + + parent = false, +} + +function PropertyHelper:Init() + self:ClearGameFlags(const.gofPermanent) +end + +function PropertyHelper:Create() +end + +function PropertyHelper:Update(obj, value, id) +end + +function PropertyHelper:EditorCallback(action) +end + +function PropertyHelper:GetHelperParent() + return self.parent +end + +function PropertyHelper:AddRef(helpers) +end + +--------------------------------------------- +--helper functions for creating PropHelpers +------------------------------------------- +local function GenericPropHelperCreate(type,info) + local no_edit = info.property_meta.no_edit or function() end + if GetMap() ~= "" and info.object and not no_edit(info.object, info.property_id) then + local marker = _G[type]:new() + marker:Create(info.object, info.property_id, info.property_meta, info.property_value, false) + return marker + end +end +local CreatePropertyHelpers = { + ["sradius"] = function(info,useSecondColor) + local helper = PropertyHelper_SphereRadius:new() + helper:Create(info.object, info.property_id, info.property_meta, info.property_value, useSecondColor) + return helper + end, + ["srange"] = function(info) + local helper = false + if info.object:IsKindOf("CObject") then + helper = PropertyHelper_SphereRange:new() + helper:Create(info.mainObject, info.object, info.property_id, info.property_meta, info.property_value) + end + return helper + end, + ["relative_pos"] = function(info) return GenericPropHelperCreate("PropertyHelper_RelativePos",info) end, + ["relative_pos_list"] = function(info) return GenericPropHelperCreate("PropertyHelper_RelativePosList",info) end, + ["relative_dist"] = function(info) return GenericPropHelperCreate("PropertyHelper_RelativeDist",info) end, + ["absolute_pos"] = function(info) + local helper_class = info.property_meta and info.property_meta.helper_class or "PropertyHelper_AbsolutePos" + return GenericPropHelperCreate(helper_class, info) + end, + ["terrain_rect"] = function(info) return GenericPropHelperCreate("PropertyHelper_TerrainRect",info) end, + ["volume"] = function(info) return GenericPropHelperCreate("PropertyHelper_VolumePicker", info) end, + ["box3"] = function(info) + local helpers = info.helpers + if helpers then + local helper = helpers.BoxWidth or helpers.BoxHeight or helpers.BoxDepth + if helper then + return helper + end + end + + local helper = PropertyHelper_Box3:new() + helper:Create(info.object) + return helper + end, + ["spotlighthelper"] = function(info) + local helpers = info.helpers + if helpers then + local helper = helpers.ConeInnerAngle + if helper then + return helper + end + end + + local helper = PropertyHelper_SpotLight:new() + helper:Create(info.object) + return helper + end, + ["scene_actor_orientation"] = function(info) + local main_obj = info.mainObject + local parent_helpers = info.helpers + if rawget(main_obj, "map") and main_obj.map ~= "All" and ("Maps/" .. main_obj.map .. "/"):lower() ~= GetMap():lower() then + return false + end + + for _ , helper in pairs(parent_helpers) do + if helper:IsKindOf("PropertyHelper_SceneActorOrientation") then + return helper + end + end + local helper = PropertyHelper_SceneActorOrientation:new() + helper:Create(info.object, info.property_id, info.property_meta) + return helper + end, + +} + +-- A helper that shows a marker and measures its relative pos to the main object. +-- Property must be from type point. +DefineClass.PropertyHelper_RelativePos = { + __parents = { "Shapeshifter", "EditorVisibleObject", "PropertyHelper" }, + entity = "WayPoint", + + use_object = false, + prop_id = false, + origin = false, + outside_object = false, + angle_prop = false, + no_z = false, + line = false, +} + +function PropertyHelper_RelativePos:Create(obj, prop_id, prop_meta, prop_value) + self.parent = obj + self.prop_id = prop_id + self.use_object = prop_meta.use_object + self.origin = prop_meta.helper_origin + self.outside_object = prop_meta.helper_outside_object + self.angle_prop = prop_meta.angle_prop + self.no_z = prop_meta.no_z + if self.use_object then + self:ChangeEntity( obj:GetEntity() ) + self:SetGameFlags(const.gofWhiteColored) + self:SetColorModifier( RGB(10, 10, 10)) + else + local entity = prop_meta.helper_entity + if type(entity) == "function" then + entity = entity(obj) + end + if entity then + self:ChangeEntity( entity ) + else + self:ChangeEntity( "WayPoint" ) + self:SetColorModifier(RGBA(255,255,0,100)) + end + end + self:Update(obj, prop_value) + + if prop_meta.helper_scale_with_parent then + local _, parent_radius = obj:GetBSphere() + local _, helper_radius = self:GetBSphere() + self:SetScale(10*parent_radius/Max(helper_radius, 1)) -- 10% of the parent + end + + self:SetEnumFlags(const.efVisible) + if prop_meta.color then + self:SetColorModifier(prop_meta.color) + end +end + +function PropertyHelper_RelativePos:Update(obj, value) + obj = obj or self.parent + local center, radius = obj:GetBSphere() + local rel_pos = point30 + if value then + if self.outside_object then + rel_pos = SetLen(value, Max(value:Len(), radius)) + else + rel_pos = value + end + end + local origin = self.origin and center or obj:GetVisualPos() + local pos = origin + rel_pos + if self.no_z then + pos = pos:SetInvalidZ() + end + self:SetPos(pos) + if self.angle_prop then + self:SetAngle(obj:GetProperty(self.angle_prop)) + else + self:SetAxis(obj:GetVisualAxis()) + self:SetAngle(obj:GetVisualAngle()) + end + if self.use_object then + self:SetScale(self.parent:GetScale()) + end + if IsValid(self.line) then + DoneObject(self.line) + end + self:DrawLine(origin) +end + +function PropertyHelper_RelativePos:DrawLine(origin) + if IsValid(self.line) then + DoneObject(self.line) + end + self.line = PlaceTerrainLine(self:GetPos(), origin) + self:Attach(self.line) +end + +function PropertyHelper_RelativePos:EditorCallback(action_id) + local parent = self.parent + if not parent then + return + end + if action_id == "EditorCallbackMove" then + local origin = self.origin and parent:GetBSphere() or parent:GetVisualPos() + local pos = self:GetVisualPos() - origin + if self.no_z then + pos = pos:SetInvalidZ() + end + parent:SetProperty(self.prop_id, pos) + self:DrawLine(origin) + elseif action_id == "EditorCallbackRotate" then + if self.angle_prop then + parent:SetProperty(self.angle_prop, self:GetVisualAngle()) + else + parent:SetAxis(self:GetVisualAxis()) + parent:SetAngle(self:GetVisualAngle()) + end + elseif action_id == "EditorCallbackScale" then + parent:SetScale(self:GetScale()) + else + return false + end + return parent +end + +-- A helper that shows a marker for each point and measures their relative pos to the main object. +-- Property must be from type point_list. +DefineClass.PropertyHelper_RelativePosList = { + __parents = { "PropertyHelper" }, + markers = false, + + prop_id = false, + origin = false, + no_z = false, + line = false, +} + +DefineClass.PropertyHelper_RelativePosList_Object = { + __parents = { "Shapeshifter", "EditorVisibleObject", "PropertyHelper" }, + entity = "WayPoint", + + obj = false, + prop_id = false, + prop_id_idx = false, + origin = false, + + line = false +} + +function PropertyHelper_RelativePosList_Object:Done() + if IsValid(self.line) then + DoneObject(self.line) + end + self.line = false +end + +function PropertyHelper_RelativePosList_Object:UpdateLine() + if IsValid(self.line) then + DoneObject(self.line) + end + self.line = PlaceTerrainLine(self:GetPos(), self.origin) + self:Attach(self.line) +end + +function PropertyHelper_RelativePosList_Object:EditorCallback(action_id) + local parent = self.obj + if not parent then + return + end + if action_id == "EditorCallbackMove" then + local origin = self.origin + if not origin then return end + local pos = self:GetVisualPos() - origin + parent[self.prop_id][self.prop_id_idx] = pos + parent:SetProperty(self.prop_id, parent[self.prop_id]) + self:UpdateLine() + else + return false + end + return parent +end + +function PropertyHelper_RelativePosList:Create(obj, prop_id, prop_meta, prop_value) + self.parent = obj + self.prop_id = prop_id + self.origin = prop_meta.helper_origin + self.no_z = prop_meta.no_z + + self:Update(obj, prop_value) +end + +function PropertyHelper_RelativePosList:Done() + for i, m in ipairs(self.markers or empty_table) do + if IsValid(m) then + DoneObject(m) + end + end + self.markers = false +end + +function PropertyHelper_RelativePosList:Update(obj, value) + obj = obj or self.parent + local center, radius = obj:GetBSphere() + + local markers = self.markers + if not markers then + markers = {} + self.markers = markers + end + + -- Sync count + local pInList = value and #value or 0 + local pSpawned = #markers + if pInList ~= pSpawned then + if pInList < pSpawned then + for i = pSpawned, pInList + 1, -1 do + local pToDelete = markers[i] + markers[i] = nil + if IsValid(pToDelete) then DoneObject(pToDelete) end + end + elseif pInList > 0 then -- less + for i = pSpawned + 1, pInList do + local newPoint = PlaceObject("PropertyHelper_RelativePosList_Object") + newPoint:ChangeEntity("WayPoint") + newPoint:SetEnumFlags(const.efVisible) + newPoint:SetColorModifier(RGB(125, 55, 0)) + newPoint.obj = obj + newPoint.prop_id = self.prop_id + newPoint.prop_id_idx = i + newPoint:AttachText("Point Helper " .. tostring(i)) + markers[i] = newPoint + end + end + end + + local origin = self.origin and center or obj:GetVisualPos() + for i, m in ipairs(markers) do + local pos = origin + value[i] + if self.no_z then + pos = pos:SetInvalidZ() + end + m.origin = origin + m:SetPos(pos) + m:UpdateLine() + end +end + +DefineClass.PropertyHelper_RelativeDist = { + __parents = { "Shapeshifter", "EditorVisibleObject", "PropertyHelper" }, + entity = "WayPoint", + + use_object = false, + orientation = false, + prop_id = false, + pos_update_thread = false, + rot_update_thread = false, +} + +function PropertyHelper_RelativeDist:Create(obj, prop_id, prop_meta, prop_value) + self.parent = obj + self.prop_id = prop_id + if prop_meta.orientation then + local x,y,z = unpack_params(prop_meta.orientation) + self.orientation = Normalize(x,y,z) + else + self.orientation = axis_z + end + self.use_object = prop_meta.use_object + if self.use_object then + self:ChangeEntity( obj:GetEntity() ) + self:SetGameFlags(const.gofWhiteColored) + self:SetColorModifier(RGB(10, 10, 10)) + else + local entity = prop_meta.helper_entity + if type(entity) == "function" then + entity = entity(obj) + end + if entity then + self:ChangeEntity( entity ) + else + self:ChangeEntity( "WayPoint" ) + self:SetColorModifier(RGBA(255,255,0,100)) + end + end + self:Update(obj, prop_value) + + self:SetEnumFlags(const.efVisible) + if prop_meta.color then + self:SetColorModifier(prop_meta.color) + end +end + +function PropertyHelper_RelativeDist:Update(obj, value) + DeleteThread(self.pos_update_thread) + DeleteThread(self.rot_update_thread) + + local parent = self.parent + local parent_pos = parent:GetVisualPos() + local pos = SetLen(parent:GetRelativePoint(self.orientation) - parent_pos, value or 0) -- SetLen to avoid Scale difference + self:SetPos(parent_pos + pos) + + if self.use_object then + self:SetAxis(parent:GetVisualAxis()) + self:SetAngle(parent:GetVisualAngle()) + self:SetScale(parent:GetScale()) + end +end + +function PropertyHelper_RelativeDist:EditorCallback(action_id) + local parent = self.parent + if action_id == "EditorCallbackMove" then + -- the target marker is allowed to move, but only its projection on the orientation vector is kept + local parent_pos = parent:GetVisualPos() + local orient = SetLen(parent:GetRelativePoint(self.orientation) - parent_pos, 4096) -- SetLen to avoid Scale difference + local vector = self:GetVisualPos() - parent_pos + local new_dist = Dot(orient, vector, 4096) + local target_pos = SetLen(orient, new_dist) + --[[ + DbgClearVectors() + DbgAddVector( parent_pos, orient, RGB(255,0,0)) + DbgAddVector( parent_pos, vector, RGB(0,255,0)) + DbgAddVector( parent_pos, target_pos, RGB(0,255,255)) + --]] + parent:SetProperty(self.prop_id, new_dist) + -- don't change the target_pos immediately to avoid flickering: + DeleteThread(self.pos_update_thread) + self.pos_update_thread = CreateRealTimeThread( function() + Sleep(200) + self:SetPos(parent_pos + target_pos) + end) + elseif action_id == "EditorCallbackScale" then + parent:SetScale(self:GetScale()) + elseif action_id == "EditorCallbackRotate" then + parent:SetScale(self:GetScale()) + -- don't change the orientation immediately to avoid flickering: + DeleteThread(self.rot_update_thread) + self.rot_update_thread = CreateRealTimeThread( function() + Sleep(200) + self:SetAxis(parent:GetAxis()) + self:SetAngle(parent:GetAngle()) + end) + else + --print(action_id) + return false + end + return parent +end + +DefineClass.PropertyHelper_TerrainRect = { + __parents = { "PropertyHelper", "EditorVisibleObject" }, + entity = "WayPoint", + lines = false, + step = guim/2, + count_x = -1, + count_y = -1, + color = RGBA(64, 196, 0, 96), + z_offset = guim/4, + depth_test = false, + parent = false, + pos = false, + prop_id = false, + value = false, + show_grid = false, + value_min = false, + value_max = false, + value_gran = false, + is_one_dim = false, + walkable = false, +} + +function PropertyHelper_TerrainRect:Create(obj, prop_id, prop_meta, prop_value) + self.prop_id = prop_id + self.color = prop_meta.terrain_rect_color + self.step = prop_meta.terrain_rect_step + self.walkable = prop_meta.terrain_rect_walkable + self.show_grid = prop_meta.terrain_rect_grid + self.z_offset = prop_meta.terrain_rect_zoffset + self.depth_test = prop_meta.terrain_rect_depth_test + self.value_min = prop_meta.min + self.value_max = prop_meta.max + self.value_gran = prop_meta.granularity + self.is_one_dim = prop_meta.editor ~= "point" + self.parent = obj + self:Update(obj, prop_value) + self:SetScale(obj:GetScale() * 80 / 100) + self:SetColorModifier(self.color) +end + +function PropertyHelper_TerrainRect:DestroyLines() + self.count_x = -1 + self.count_y = -1 + local lines = self.lines or "" + for i=1,#lines do + if IsValid(lines[i]) then + DoneObject(lines[i]) + end + end + self.lines = {} +end + +function PropertyHelper_TerrainRect:CalcValue(obj) + obj = obj or self.parent + local centered = not obj:HasMember("TerrainRectIsCentered") or obj:TerrainRectIsCentered(self.prop_id) + local coef = centered and 2 or 1 + local dx, dy = (self:GetVisualPos() - obj:GetVisualPos()):xy() + if not centered then + dx = Max(0, dx) + dy = Max(0, dy) + end + local value + if self.is_one_dim then + value = Max(1, coef * Max(abs(dx), abs(dy))) + if self.value_min then + value = Max(value, self.value_min) + end + if self.value_max then + value = Min(value, self.value_max) + end + else + dx = Max(1, coef*abs(dx)) + dy = Max(1, coef*abs(dy)) + if self.value_min then + dx = Max(dx, self.value_min) + dy = Max(dy, self.value_min) + end + if self.value_max then + dx = Min(dx, self.value_max) + dy = Min(dy, self.value_max) + end + if terminal.IsKeyPressed(const.vkAlt) then + local v = Max(dx, dy) + value = point(v, v) + else + value = point(dx, dy) + end + end + if self.value_gran then + value = round(value, self.value_gran) + end + return value +end + +function PropertyHelper_TerrainRect:Update(obj, value) + obj = obj or self.parent + if not IsValid(obj) then + return + end + if obj:HasMember("TerrainRectIsEnabled") and not obj:TerrainRectIsEnabled(self.prop_id) then + self:ClearEnumFlags(const.efVisible) + self:DestroyLines() + self.pos = false + return + end + self:SetEnumFlags(const.efVisible) + local pos = obj:GetVisualPos() + local my_pos = self:IsValidPos() and self:GetVisualPos() or pos + local centered = not obj:HasMember("TerrainRectIsCentered") or obj:TerrainRectIsCentered(self.prop_id) + local dont_move + if not value then + value = self:CalcValue(obj) + dont_move = true + end + if self.pos == pos and self.value == value and self.centered == centered then + return + end + self.centered = centered + self.pos = pos + self.value = value + local count_x, count_y + if self.step <= 0 then + count_x = 2 + count_y = 2 + elseif IsPoint(value) then + count_x = Min(100, Max(2, 1 + MulDivRound(2, value:x(), self.step))) + count_y = Min(100, Max(2, 1 + MulDivRound(2, value:y(), self.step))) + else + local count = Min(100, Max(2, 1 + MulDivRound(2, value, self.step))) + count_x = count + count_y = count + end + if count_x ~= self.count_x or count_y ~= self.count_y then + self:DestroyLines() + self.count_x = count_x + self.count_y = count_y + end + + local ox, oy, oz = pos:xyz() + local color = self.color + local offset = self.z_offset + local depth_test = self.depth_test + local lines = self.lines + local walkable = self.walkable + local grid = {} + + local offset_x, offset_y + if IsPoint(value) then + offset_x, offset_y = value:xy() + else + offset_x, offset_y = value, value + end + local startx, starty = ox, oy + if centered then + if not dont_move then + offset_x = abs(offset_x) + offset_y = abs(offset_y) + end + offset_x = offset_x / 2 + offset_y = offset_y / 2 + startx, starty = ox - offset_x, oy - offset_y + end + local endx, endy = ox + offset_x, oy + offset_y + local mapw, maph = terrain.GetMapSize() + local height_tile = const.HeightTileSize + endx = Clamp(endx, 0, mapw - height_tile - 1) + endy = Clamp(endy, 0, maph - height_tile - 1) + startx = Clamp(startx, 0, mapw - height_tile - 1) + starty = Clamp(starty, 0, maph - height_tile - 1) + if not dont_move then + self:SetPos(point(endx, endy)) + end + for yi = 1,count_y do + local y = starty + MulDivRound(endy - starty, yi - 1, count_y - 1) + local row = {} + for xi = 1,count_x do + local x = startx + MulDivRound(endx - startx, xi - 1, count_x - 1) + local z = terrain.GetHeight(x, y) + if walkable then + z = Max(z, GetWalkableZ(x, y)) + end + row[xi] = point(x, y, z + offset) + end + grid[yi] = row + end + + local li = 1 + local function SetNextLinePoints(points) + local line = lines[li] + if not line then + line = Polyline:new() + line:SetMeshFlags(const.mfWorldSpace) + line:SetDepthTest(depth_test) + lines[li] = line + obj:Attach(line, obj:GetSpotBeginIndex("Origin")) + end + line:SetMesh(points) + li = li + 1 + end + for yi = 1,count_y do + if self.show_grid or yi == 1 or yi == count_y then + local points = pstr("") + for xi = 1,count_x do + points:AppendVertex(grid[yi][xi], color) + end + SetNextLinePoints(points) + end + end + for xi = 1,count_x do + if self.show_grid or xi == 1 or xi == count_x then + local points = pstr("") + for yi = 1,count_y do + points:AppendVertex(grid[yi][xi], color) + end + SetNextLinePoints(points) + end + end +end + +function PropertyHelper_TerrainRect:EditorCallback(action_id) + if not IsValid(self.parent) then + return + end + if action_id == "EditorCallbackMove" then + self.parent:SetProperty(self.prop_id, self:CalcValue()) + self:Update() + return self.parent + end +end + +function PropertyHelper_TerrainRect:Done() + self:DestroyLines() +end + +DefineClass.PropertyHelper_AbsolutePos = { + __parents = { "Shapeshifter", "EditorVisibleObject", "PropertyHelper" }, + entity = "WayPoint", + + use_object = false, + prop_id = false, + angle_prop = false, +} + +function PropertyHelper_AbsolutePos:Create(obj, prop_id, prop_meta, prop_value) + self.parent = obj + self.prop_id = prop_id + self.use_object = prop_meta.use_object + if self.use_object then + self:ChangeEntity( obj:GetEntity() ) + self:SetGameFlags(const.gofWhiteColored) + self:SetColorModifier( RGB(255, 10, 10)) + else + local entity = prop_meta.helper_entity + if type(entity) == "function" then + entity = entity(obj) + end + if entity then + self:ChangeEntity( entity ) + else + self:ChangeEntity( "WayPoint" ) + self:SetColorModifier(RGBA(255,255,0,100)) + end + end + if obj:HasMember("OnHelperCreated") then + obj:OnHelperCreated(self) + end + local angle = prop_meta.angle_prop and obj:GetProperty(prop_meta.angle_prop) + if angle then + self.angle_prop = prop_meta.angle_prop + self:SetAngle(angle) + end + + self:Update(obj, prop_value) + + self:SetEnumFlags(const.efVisible) + if prop_meta.color then + self:SetColorModifier(prop_meta.color) + end +end + +function PropertyHelper_AbsolutePos:AddRef(helpers) + if self.angle_prop then + helpers[self.angle_prop] = self + end +end + +function PropertyHelper_AbsolutePos:Update(obj, value) + if type(value) ~= "number" then + if not value or value == InvalidPos() then + value = GetVisiblePos() + end + self:SetPos(value) + else + self:SetAngle(value) + end +end + +function PropertyHelper_AbsolutePos:EditorCallback(action_id) + local parent = self.parent + if parent then + parent:SetProperty(self.prop_id, self:GetVisualPos()) + if self.angle_prop then + parent:SetProperty(self.angle_prop, self:GetVisualAngle()) + end + end + return parent +end + +-- A helper that is cut scene editor specific. Only one is created per object and manages +-- pos, axis and angle per selected actor. +-- Only usable by ActionAnimation class (currently). +DefineClass.PropertyHelper_SceneActorOrientation = { + __parents = { "Shapeshifter", "EditorVisibleObject", "PropertyHelper" }, + + actor_entity = false, +} + +function PropertyHelper_SceneActorOrientation:Create(parent, prop_id, prop_meta ) + self.parent = parent + self:SetGameFlags(const.gofWhiteColored) + self:Update() + EditorActivate() + self:SetEnumFlags(const.efVisible) + self:SetRealtimeAnim(true) +end + +function PropertyHelper_SceneActorOrientation:Update(obj) + local parent = self.parent + local actor_entity = parent:GetActorEntity() + if actor_entity and actor_entity ~= self.actor_entity then + self:ChangeEntity(actor_entity) + self.actor_entity = actor_entity + end + if self.actor_entity and parent.pos ~= InvalidPos() then + self:SetPos(parent.pos) + self:SetAxis(parent.axis) + self:SetAngle(parent.angle) + if rawget(parent, "animation") and parent.animation ~= "" then + self:SetStateText(parent.animation) + end + if rawget(parent, "animation") then + self:SetStateText(parent.animation) + end + else + self:DetachFromMap() + end +end + +function PropertyHelper_SceneActorOrientation:EditorCallback(action_id) + if not self.parent then + return + end + if action_id == "EditorCallbackMove" or action_id == "EditorCallbackRotate" and self.actor_entity then + local parent = self.parent + parent.pos = self:GetPos() + parent.axis = self:GetAxis() + parent.angle = self:GetAngle() + return parent + end +end + +-- A helper that visualizes a single radius with one code renderable sphere. +-- Property must be from type number. +DefineClass.PropertyHelper_SphereRadius = { + __parents = { "PropertyHelper", "EditorObject" }, + + sphere = false, + color = false, + square = false, + square_divider = 1 +} + +function PropertyHelper_SphereRadius:Create(obj, prop_id, prop_meta, prop_value, useSecondColor) + if prop_meta.square then + self.square = true + if prop_meta.square_divider then + self.square_divider = prop_meta.square_divider + end + end + local radius + if self.square then + radius = prop_value * prop_value / self.square_divider + else + radius = prop_value + end + if useSecondColor and prop_meta.color2 then + self.color = prop_meta.color2 + elseif prop_meta.color then + self.color = prop_meta.color + else + self.color = RGB(255,255,255) + end + local sphere = CreateSphereMesh(radius, self.color) + sphere:SetDepthTest(true) + obj:Attach(sphere, obj:GetSpotBeginIndex("Origin")) + self.parent = obj + + self.sphere = sphere +end + +function PropertyHelper_SphereRadius:Update(obj, value) + local radius + if self.square then + radius = value * value / self.square_divider + else + radius = value + end + self.sphere:SetMesh(CreateSphereVertices(radius, self.color)) +end + +function PropertyHelper_SphereRadius:EditorEnter() + if IsValid(self.sphere) then + self.sphere:SetEnumFlags(const.efVisible) + end +end + +function PropertyHelper_SphereRadius:EditorExit() + if IsValid(self.sphere) then + self.sphere:ClearEnumFlags(const.efVisible) + end +end + +function PropertyHelper_SphereRadius:Done() + if IsValid(self.parent) and IsValid(self.sphere) then + self.sphere:Detach() + self.sphere:delete() + end +end + +-- A helper that visualizes numerical range with two spheres' radiuses. +-- Property must be from type table - { from = , to = } +DefineClass.PropertyHelper_SphereRange = { + __parents = { "PropertyHelper" }, + + sphere_from = false, + sphere_to = false, +} + +function PropertyHelper_SphereRange:Create(main_obj, obj, prop_id, prop_meta, prop_value) + local fromInfo = { + mainObject = main_obj, + object = obj, + property_id = prop_id, + property_meta = prop_meta, + property_value = prop_value.from, + } + local toInfo = table.copy(fromInfo) + toInfo.property_value = prop_value.to + + self.sphere_from = CreatePropertyHelpers["sradius"](fromInfo,false) + self.sphere_to = CreatePropertyHelpers["sradius"](toInfo,true) +end + +function PropertyHelper_SphereRange:Update(obj, value) + if IsValid(self.sphere_from) and IsValid(self.sphere_to) then + self.sphere_from:Update(obj, value.from) + self.sphere_to:Update(obj, value.to) + end +end + +function PropertyHelper_SphereRange:Done() + if IsValid(self.sphere_from) and IsValid(self.sphere_to) then + DoneObject(self.sphere_from) + DoneObject(self.sphere_to) + end +end + +----------------------------------------- custom helper for box lights +----------------------------------------- + +DefineClass.PropertyHelper_Box3 = { + __parents = { "PropertyHelper" }, + + box = false, +} + +function PropertyHelper_Box3:Create(parent_obj) + self.parent = parent_obj + self.box = PlaceObject("Mesh") + self.box:SetDepthTest(true) + self.box:SetShader(ProceduralMeshShaders.mesh_linelist) + parent_obj:Attach(self.box, parent_obj:GetSpotBeginIndex("Origin")) + self:Update() +end + +function PropertyHelper_Box3:Update(obj, value) + local width = self.parent:GetProperty("BoxWidth") or guim + local height = self.parent:GetProperty("BoxHeight") or guim + local depth = self.parent:GetProperty("BoxDepth") or guim + + width = width / 2 + height = height / 2 + depth = -depth + local p_pstr = pstr("") + local function AddPoint(x,y,z) p_pstr:AppendVertex(point(x*width, y*height, z*depth)) end + + AddPoint(-1,-1,0) AddPoint(-1,1,0) AddPoint(1,-1,0) AddPoint(1,1,0) AddPoint(-1,-1,0) AddPoint(1,-1,0) AddPoint(-1,1,0) AddPoint(1,1,0) + AddPoint(-1,-1,1) AddPoint(-1,1,1) AddPoint(1,-1,1) AddPoint(1,1,1) AddPoint(-1,-1,1) AddPoint(1,-1,1) AddPoint(-1,1,1) AddPoint(1,1,1) + AddPoint(-1,-1,0) AddPoint(-1,-1,1) AddPoint(-1,1,0) AddPoint(-1,1,1) AddPoint(1,-1,0) AddPoint(1,-1,1) AddPoint(1,1,0) AddPoint(1,1,1) + self.box:SetMesh(p_pstr) +end + +function PropertyHelper_Box3:Done() + if IsValid(self.box) then DoneObject(self.box) end +end + +------------------------------- + +DefineClass.PropertyHelper_VolumePicker = { + __parents = { "PropertyHelper" }, + + box = false, +} + +function PropertyHelper_VolumePicker:Create(parent_obj, prop_id, prop_meta, prop_value) + self.parent = parent_obj + self:Update(parent_obj, prop_value) +end + +function PropertyHelper_VolumePicker:Update(obj, value) + local target = value and value.box + self.box = PlaceBox(target or box(point(0,0,0), point(0,0,0)), RGBA(255, 255, 0, 255), self.box) +end + +function PropertyHelper_VolumePicker:Done() + if IsValid(self.box) then DoneObject(self.box) end +end + +----------------------------------------- custom helper for spot lights +----------------------------------------- + +DefineClass.PropertyHelper_SpotLight = { + __parents = { "PropertyHelper" }, + + box = false, +} + +function PropertyHelper_SpotLight:Create(parent_obj) + self.parent = parent_obj + self.box = PlaceObject("Mesh") + self.box:SetDepthTest(true) + self.box:SetShader(ProceduralMeshShaders.mesh_linelist) + parent_obj:Attach(self.box, parent_obj:GetSpotBeginIndex("Origin")) + self:Update() +end + +function BuildMeshCone(points_pstr, radius, angle) + local rad2 = radius * radius + local r = radius * sin(angle*60 / 2) / 4096 + local a = r*866/1000 + local b = r*2/3 + local c = r/2 + local d = r/3 + local e = r*577/1000 + local function addpt(x,y) + points_pstr:AppendVertex(point(x, y, -sqrt(rad2 - x*x - y*y))) + end + local function addcenter() + points_pstr:AppendVertex( point30) + end + local function quadrant(x,y) + addpt(x*0,y*0) addpt(x*d,y*e) + addpt(x*d,y*e) addpt(x*b,y*0) + addpt(x*b,y*0) addpt(x*a,y*c) + addpt(x*a,y*c) addpt(x*r,y*0) + addpt(x*d,y*e) addpt(x*a,y*c) + addpt(x*0,y*r) addpt(x*d,y*e) + addpt(x*d,y*e) addpt(x*c,y*a) + addpt(x*c,y*a) addpt(x*a,y*c) + addpt(x*0,y*r) addpt(x*c,y*a) + addpt(x*a,y*c) addcenter() + addpt(x*c,y*a) addcenter() + end + local function semicircle(x) + quadrant(x,1) + quadrant(x,-1) + addpt(0,0) addpt(x*b,0) + addpt(x*b,0) addpt(x*r,0) + addpt(x*r,0) addcenter() + end + semicircle(1) + semicircle(-1) + addpt(d,e) addpt(-d,e) + addpt(d,-e) addpt(-d,-e) + addpt(0,r) addcenter() + addpt(0,-r) addcenter() + return points_pstr +end + +function PropertyHelper_SpotLight:Update(obj, value) + local p_pstr = pstr("") + local radius = self.parent:GetProperty("AttenuationRadius") or 5000 + local spot_inner_angle = self.parent:GetProperty("ConeInnerAngle") or 45 + local spot_outer_angle = self.parent:GetProperty("ConeOuterAngle") or 90 + p_pstr = BuildMeshCone(p_pstr, radius, spot_inner_angle) + p_pstr = BuildMeshCone(p_pstr, radius, spot_outer_angle) + self.box:SetMesh(p_pstr) +end + +function PropertyHelper_SpotLight:Done() + if IsValid(self.box) then DoneObject(self.box) end +end + +---------------------------------------------------------------------------------- +-- PropertyHelpers Management System -- +---------------------------------------------------------------------------------- + +MapVar("PropertyHelpers", {}) +MapVar("PropertyHelpers_Refs",{}) + +function SelectHelperObject(helper_object, no_camera_move) + if not IsValid(helper_object) then + return + end + + if helper_object:IsValidPos() then + EditorActivate() + editor:ClearSel() + editor.AddToSel({helper_object}) + if not no_camera_move then + ViewObject(helper_object) + end + end +end + +-- Helper objects destructor function +local function PropertyHelpers_DoneHelpers(object) + local helpers = PropertyHelpers[object] + for _ , helper in pairs(helpers) do + if IsValid(helper) then + DoneObject(helper) + end + end + PropertyHelpers_Refs[object] = nil + PropertyHelpers[object] = nil +end + +-- Rebuilds helper objects references (usually when window is closed and window ids are changed) +function PropertyHelpers_RebuildRefs(ignore_ged) + if not PropertyHelpers then return end + PropertyHelpers_Refs = {} + if IsEditorActive() then return end + + for i, ged in pairs(GedConnections) do + if not ignore_ged or ged == ignore_ged then + local objects = ged:GetMatchingBoundObjects({ props = GedGetProperties, values = GedGetValues }) + for key, object in ipairs(objects) do + if object and PropertyHelpers[object] then + local ref_table = PropertyHelpers_Refs[object] or {} + table.insert(ref_table, i) + PropertyHelpers_Refs[object] = ref_table + end + end + end + end +end + +-- Creates helpers for object if not already referenced in other window +function PropertyHelpers_Init(obj, ged) + if GetMap() == "" or not PropertyHelpers then + return + end + + local objects = { obj } + local helpers_created = false + for i = 1, #objects do + local object = objects[i] + if IsKindOf(object, "AutoAttachRule") and IsKindOf(g_Classes[object.attach_class], "Light") then + local demo_obj = GedAutoAttachDemos[ged] + if demo_obj then + if (object.required_state or "") ~= "" then + demo_obj:SetAutoAttachMode(object.required_state) + end + end + end + if PropertyHelpers[object] then + if not table.find(PropertyHelpers_Refs[object], ged) then + table.insert(PropertyHelpers_Refs[object], ged) + end + local helpers = PropertyHelpers[object] + local selected = {} + for _, helper in pairs(helpers) do + if not selected[helper] and helper:IsKindOf("PropertyHelper_SceneActorOrientation") then + selected[helper] = true + SelectHelperObject(helper, not "no_camera_move") + end + end + elseif IsKindOf(object, "PropertyObject") then + local helpers = false + + local properties = object:GetProperties() + for i=1, #properties do + local property = properties[i] + local property_id = property.id + if not IsKindOf(object, "GedMultiSelectAdapter") and IsValid(object) then + local no_edit = property.no_edit + if type(no_edit) == "function" then + no_edit = no_edit(object, property_id) + end + if not no_edit and property.helper then + helpers = helpers or {} + local property_value = object:GetProperty(property_id) + assert(property_value ~= nil, "Could not get property value for "..tostring(property_id)) + + local info = { + object = object, + property_id = property_id, + property_meta = property, + property_value = property_value, + helpers = helpers, + } + + local helper_object = false + if CreatePropertyHelpers[property.helper] then + helper_object = CreatePropertyHelpers[property.helper](info) + else + assert(false, "Unknown property helper requested" .. (property.helper or "")) + end + + if helper_object then + if property.helper == "scene_actor_orientation" and obj == object then + SelectHelperObject(helper_object, not "no_camera_move") + end + + helpers[property_id] = helper_object + + helper_object:AddRef(helpers) + + local idx = editor.GetLockedCollectionIdx() + if idx ~= 0 then + helper_object:SetCollectionIndex(idx) + end + end + end + end + end + + if helpers then + PropertyHelpers[object] = helpers + PropertyHelpers_Refs[object] = { ged } + if object:HasMember("AdjustHelpers") then + object:AdjustHelpers() + end + helpers_created = true + end + end + end + if helpers_created then + UpdateCollectionsEditor() + end +end + +-- Destroys helpers for object if this window is its last reference +function PropertyHelpers_Done(object, window_id) + local objects = { object } + for i = 1, #objects do + local object = objects[i] + if PropertyHelpers and PropertyHelpers[object] then + local helpers_refs = PropertyHelpers_Refs[object] + assert(helpers_refs,"Object property helpers already destroyed") + + table.remove_value(helpers_refs, window_id) + if #helpers_refs == 0 then + PropertyHelpers_DoneHelpers(object) + end + end + end +end + +function PropertyHelpers_Refresh(object) + PropertyHelpers_UpdateAllHelpers(object) + local prop_helpers = {} + for object, geds in pairs(PropertyHelpers_Refs) do + prop_helpers[object] = table.copy(geds) + end + for object, ged in pairs(prop_helpers) do + if GedConnections[ged.ged_id] then + PropertyHelpers_Done(object, ged) + end + end + for object, ged in pairs(prop_helpers) do + if GedConnections[ged.ged_id] then + PropertyHelpers_Init(object, ged) + end + end +end +function PropertyHelpers_ViewHelper(object, prop_id, select) + local helper_object = PropertyHelpers and PropertyHelpers[object] and PropertyHelpers[object][prop_id] or false + if helper_object and GetMap() ~= "" then + EditorActivate() + if select and not editor.IsSelected(helper_object) then + editor.AddToSel({helper_object}) + end + ViewObject(helper_object) + end +end + +function PropertyHelpers_GetHelperObject(object, prop_id) + local helper_object = PropertyHelpers and PropertyHelpers[object] and PropertyHelpers[object][prop_id] or false + return helper_object +end + +-- Notifies each helper for property changes in the Property Editor +function OnMsg.GedPropertyEdited(ged_id, object, prop_id, old_value) + local helpers = PropertyHelpers and PropertyHelpers[object] + if not helpers then + return + end + + local prop_value = object:GetProperty(prop_id) + assert(prop_value ~= nil, "Could not get property value for prop: "..tostring(prop_id)) + local prop_metadata = object:GetPropertyMetadata(prop_id) + local prop_helper = helpers[prop_id] + if prop_helper then + prop_helper:Update(object, prop_value, prop_id) + end +end + +function PropertyHelpers_UpdateAllHelpers(object) + local helpers = PropertyHelpers[object] + if not helpers then + return + end + for prop_id, prop_helper in pairs(helpers) do + if prop_helper then + prop_helper:Update(object, object:GetProperty(prop_id), prop_id) + end + end +end + +-- Rebuilds helper refs (windows_ids are changed at window close) and calls destroy function +-- for unreferenced helper objects +function PropertyHelpers_RemoveUnreferenced(ignore_ged_instance) + PropertyHelpers_RebuildRefs(ignore_ged_instance) + for object, _ in pairs(PropertyHelpers or empty_table) do + if not PropertyHelpers_Refs[object] then + PropertyHelpers_DoneHelpers(object) + end + end +end + +function OnMsg.GedOnEditorSelect(obj, selected, ged) + if selected then + PropertyHelpers_Init(obj, ged) + else + PropertyHelpers_RemoveUnreferenced(ged) + end +end + +function OnMsg.GameExitEditor() + PropertyHelpers_RemoveUnreferenced() +end + +local PropertyHelpers_LastAutoUpdate = 0 +local PropertyHelpers_UpdateThread = false +local PropertyHelpers_ModifiedObjects = {} + +local function HandleAutoUpdate(object, action_id) + -- if the property helper has been changed from the ingame editor + if object:IsKindOf("PropertyHelper") then + local changed_object = object:EditorCallback(action_id) + if changed_object then + ObjModified(changed_object) + end + -- if the main object has been changed from the ingame editor - update its helpers + elseif PropertyHelpers[object] then + for property_id , helper in pairs(PropertyHelpers[object]) do + helper:Update(object, object:GetProperty(property_id), property_id) + end + end + --else object not relevant to PropertyHelpers System +end + +-- Notifies helpers that implement editor callback function for changes from the ingame editor. +-- Some updates are skipped(Hedge's column update speed is much slower than msg feedback). +-- Thread ensures that a final update is made. +function OnMsg.EditorCallback(action_id, objects, ...) + local time_now = RealTime() + if time_now - PropertyHelpers_LastAutoUpdate < 100 then + DeleteThread(PropertyHelpers_UpdateThread) + PropertyHelpers_UpdateThread = CreateRealTimeThread(function() + Sleep(100) + for objs, action_id in pairs(PropertyHelpers_ModifiedObjects) do + for _, o in ipairs(objs) do + if IsValid(o) then -- when action_id is EditorCallbackDelete, o can be invalid + HandleAutoUpdate(o, action_id) + end + end + end + table.clear(PropertyHelpers_ModifiedObjects) + PropertyHelpers_LastAutoUpdate = RealTime() + end) + PropertyHelpers_ModifiedObjects[objects] = action_id + return + end + + PropertyHelpers_LastAutoUpdate = time_now + for _, o in ipairs(objects) do HandleAutoUpdate(o, action_id) end +end diff --git a/CommonLua/Editor/XEditor/MoveGizmo.lua b/CommonLua/Editor/XEditor/MoveGizmo.lua new file mode 100644 index 0000000000000000000000000000000000000000..1347b8cd0c74a6e5c1ef5c25dab1ce7e5c6a5d4d --- /dev/null +++ b/CommonLua/Editor/XEditor/MoveGizmo.lua @@ -0,0 +1,399 @@ +DefineClass.MoveGizmoTool = { + __parents = { "Mesh" }, + + mesh_x = pstr(""), + mesh_y = pstr(""), + mesh_z = pstr(""), + mesh_xy = pstr(""), + mesh_xz = pstr(""), + mesh_yz = pstr(""), + + b_over_axis_x = false, + b_over_axis_y = false, + b_over_axis_z = false, + b_over_plane_xy = false, + b_over_plane_xz = false, + b_over_plane_yz = false, + + v_axis_x = axis_x, + v_axis_y = axis_y, + v_axis_z = axis_z, + + opacity = 110, + + r_color = RGB(192, 0, 0), + g_color = RGB(0, 192, 0), + b_color = RGB(0, 0, 192), + + -- rotation arrows, clockwise (cw) and counter-clockwise (ccw) + arrow_xcw = pstr(""), + arrow_xccw = pstr(""), + arrow_ycw = pstr(""), + arrow_yccw = pstr(""), + arrow_zcw = pstr(""), + arrow_zccw = pstr(""), + + -- settings + rotation_arrows_on = false, + rotation_arrows_z_only = false, +} + +function MoveGizmoTool:ApplyOpacity(color, opacity) + local r, g, b = GetRGB(color) + return RGBA(r, g, b, self.opacity) +end + +function MoveGizmoTool:RenderGizmo() + local vpstr = pstr("") + + self.mesh_x = self:RenderAxis(nil, axis_y, self.b_over_axis_x, false) + self.mesh_y = self:RenderAxis(nil, -axis_x, self.b_over_axis_y, false) + self.mesh_z = self:RenderAxis(nil, axis_z, self.b_over_axis_z, false) + self.mesh_xy = self:RenderPlane(nil, axis_z) + self.mesh_xz = self:RenderPlane(nil, axis_x) + self.mesh_yz = self:RenderPlane(nil, -axis_y) + + local r = self:ApplyOpacity(self.r_color) + local g = self:ApplyOpacity(self.g_color) + local b = self:ApplyOpacity(self.b_color) + + self.arrow_xcw = self:RenderRotationArrow(nil, axis_x, g, false) + self.arrow_xccw = self:RenderRotationArrow(nil, axis_x, g, true) + self.arrow_ycw = self:RenderRotationArrow(nil, -axis_y, r, false) + self.arrow_yccw = self:RenderRotationArrow(nil, -axis_y, r, true) + self.arrow_zcw = self:RenderRotationArrow(nil, axis_z, b, false) + self.arrow_zccw = self:RenderRotationArrow(nil, axis_z, b, true) + + vpstr = self:RenderAxis(vpstr, axis_y, self.b_over_axis_x, true, r) + vpstr = self:RenderAxis(vpstr, -axis_x, self.b_over_axis_y, true, g) + vpstr = self:RenderAxis(vpstr, axis_z, self.b_over_axis_z, true, b) + vpstr = self:RenderPlaneOutlines(vpstr) + + if self.b_over_plane_xy then vpstr = self:RenderPlane(vpstr, axis_z) end + if self.b_over_plane_xz then vpstr = self:RenderPlane(vpstr, axis_x) end + if self.b_over_plane_yz then vpstr = self:RenderPlane(vpstr, -axis_y) end + + if self.rotation_arrows_on then + if not self.rotation_arrows_z_only then + self:RenderRotationArrow(vpstr, axis_x, g, false) + self:RenderRotationArrow(vpstr, axis_x, g, true) + self:RenderRotationArrow(vpstr, -axis_y, r, false) + self:RenderRotationArrow(vpstr, -axis_y, r, true) + end + self:RenderRotationArrow(vpstr, axis_z, b, false) + self:RenderRotationArrow(vpstr, axis_z, b, true) + end + + return vpstr +end + +function MoveGizmoTool:ChangeScale() + local eye = camera.GetEye() + local dir = self:GetVisualPos() + local ray = dir - eye + local cameraDistanceSquared = ray:x() * ray:x() + ray:y() * ray:y() + ray:z() * ray:z() + local cameraDistance = 0 + if cameraDistanceSquared >= 0 then cameraDistance = sqrt(cameraDistanceSquared) end + self.scale = cameraDistance / 20 * self.scale / 100 +end + +function MoveGizmoTool:CursorIntersection(mouse_pos) + local pt1 = camera.GetEye() + local precision = 128 -- should not lead to overflow for maps up to 20x20 km (with guim == 1000) + local dir = ScreenToGame(mouse_pos, precision) - pt1 * precision + local pt2 = pt1 + dir + local pos = self:GetVisualPos() + if self.b_over_axis_x or self.b_over_axis_y or self.b_over_axis_z then + local axis + if self.b_over_axis_x then + axis = self.v_axis_x + elseif self.b_over_axis_y then + axis = self.v_axis_y + elseif self.b_over_axis_z then + axis = self.v_axis_z + end + local camDir = Normalize(camera.GetEye() - pos) + local camX = Normalize(Cross((camDir), axis_z)) + local planeB = pos + camX + local planeC = pos + Normalize(Cross((camDir), camX)) + local ptA = pos + local ptB = ptA + axis + local intersection = IntersectRayPlane(pt1, pt2, pos, planeB, planeC) + intersection = ProjectPointOnLine(ptA, ptB, intersection) + return intersection + elseif self.b_over_plane_xy or self.b_over_plane_xz or self.b_over_plane_yz then + local normal, planeB, planeC + if self.b_over_plane_xy then + normal = self.v_axis_z + planeB = pos + self.v_axis_x + planeC = pos + self.v_axis_y + elseif self.b_over_plane_xz then + normal = self.v_axis_y + planeB = pos + self.v_axis_x + planeC = pos + self.v_axis_z + elseif self.b_over_plane_yz then + normal = self.v_axis_x + planeB = pos + self.v_axis_y + planeC = pos + self.v_axis_z + end + local intersection = IntersectRayPlane(pt1, pt2, pos, planeB, planeC) + intersection = ProjectPointOnPlane(pos, normal, intersection) + return intersection + end +end + +function MoveGizmoTool:IntersectRay(pt1, pt2) + self.b_over_plane_xy = false + self.b_over_plane_xz = false + self.b_over_plane_yz = false + + self.b_over_axis_x = IntersectRayMesh(self, pt1, pt2, self.mesh_x) + self.b_over_axis_y = IntersectRayMesh(self, pt1, pt2, self.mesh_y) + self.b_over_axis_z = IntersectRayMesh(self, pt1, pt2, self.mesh_z) + + local hit, axis, ccw + if IntersectRayMesh(self, pt1, pt2, self.arrow_xcw ) then hit, axis, ccw = true, axis_x, false + elseif IntersectRayMesh(self, pt1, pt2, self.arrow_xccw) then hit, axis, ccw = true, axis_x, true + elseif IntersectRayMesh(self, pt1, pt2, self.arrow_ycw ) then hit, axis, ccw = true, -axis_y, false + elseif IntersectRayMesh(self, pt1, pt2, self.arrow_yccw) then hit, axis, ccw = true, -axis_y, true + elseif IntersectRayMesh(self, pt1, pt2, self.arrow_zcw ) then hit, axis, ccw = true, axis_z, false + elseif IntersectRayMesh(self, pt1, pt2, self.arrow_zccw) then hit, axis, ccw = true, axis_z, true end + self.b_over_rotation_arrow = hit + self.arrow_axis = axis + self.arrow_ccw = ccw + + if self.b_over_axis_x then + self.b_over_axis_y = false + self.b_over_axis_z = false + return true + elseif self.b_over_axis_y then + self.b_over_axis_x = false + self.b_over_axis_z = false + return true + elseif self.b_over_axis_z then + self.b_over_axis_x = false + self.b_over_axis_y = false + return true + else + local overPlaneXY, lenXY = IntersectRayMesh(self, pt1, pt2, self.mesh_xy) + local overPlaneXZ, lenXZ = IntersectRayMesh(self, pt1, pt2, self.mesh_xz) + local overPlaneYZ, lenYZ = IntersectRayMesh(self, pt1, pt2, self.mesh_yz) + + if not (overPlaneXY or overPlaneXZ or overPlaneYZ) then return end + + if overPlaneXY then + self.b_over_plane_xy = true + return true + elseif lenXZ and lenYZ then + if lenXZ < lenYZ then + self.b_over_plane_xz = overPlaneXZ + return overPlaneXZ + else + self.b_over_plane_yz = overPlaneYZ + return overPlaneYZ + end + elseif overPlaneXZ then + self.b_over_plane_xz = true + return true + elseif overPlaneYZ then + self.b_over_plane_yz = true + return true + end + end +end + +function MoveGizmoTool:RenderAxis(vpstr, axis, selected, visual, color) + vpstr = vpstr or pstr("") + local cylinderRadius = visual and 0.1 * self.scale * self.thickness / 100 or 0.1 * self.scale + local cylinderHeight = 4.0 * self.scale + local coneRadius = visual and 0.45 * self.scale * self.thickness / 100 or 0.45 * self.scale + local coneHeight = 1.0 * self.scale + color = selected and RGBA(255, 255, 0, self.opacity) or color + + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, cylinderHeight), cylinderRadius, cylinderRadius, axis, 90, color) + vpstr = AppendConeVertices(vpstr, point(0, 0, cylinderHeight), point(0, 0, coneHeight), coneRadius, 0, axis, 90, color) + return vpstr +end + +function MoveGizmoTool:RenderPlaneOutlines(vpstr) + local height = 2.5 * self.scale + local radius = 0.05 * self.scale * self.thickness / 100 + local r = self:ApplyOpacity(self.r_color) + local g = self:ApplyOpacity(self.g_color) + local b = self:ApplyOpacity(self.b_color) + + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, axis_z, 90, r, point(height, 0, 0)) + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, -axis_x, 90, r, point(height, 0, 0)) + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, axis_z, 90, g, point(0, height, 0)) + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, axis_y, 90, g, point(0, height, 0)) + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, axis_y, 90, b, point(0, 0, height)) + vpstr = AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, -axis_x, 90, b, point(0, 0, height)) + return vpstr +end + +function MoveGizmoTool:TransformPointsToAxisPlane(axis, ...) + local angle = (axis == -axis_y or axis == axis_x) and 90 * 60 or 0 + local pts = table.pack(...) + for i, pt in ipairs(pts) do + pts[i] = RotateAxis(pt, axis, angle) + end + return pts +end + +function MoveGizmoTool:RenderPlane(vpstr, axis) + vpstr = vpstr or pstr("") + local color = RGBA(255, 255, 0, self.opacity * 200 / 255) + local dist = 2.5 * self.scale + local pts = self:TransformPointsToAxisPlane(axis, point30, point(dist, 0, 0), point(dist, dist, 0), point(0, dist, 0)) + vpstr:AppendVertex(pts[1], color) + vpstr:AppendVertex(pts[2]) + vpstr:AppendVertex(pts[4]) + vpstr:AppendVertex(pts[3]) + vpstr:AppendVertex(pts[2]) + vpstr:AppendVertex(pts[4]) + return vpstr +end + +function MoveGizmoTool:RenderRotationArrow(vpstr, axis, color, ccw) + local size = 0.7 * self.scale + local dist = 2.1 * self.scale + local offs = 0.1 * self.scale + local corner = point(dist, dist, 0) + local offset = point(offs, offs, 0) + local a1, a2, a3 = point(size, 0, 0), point(0, size, 0), point(size, size, 0) + a1 = Rotate(a1 + offset, (ccw and 45 or -45) * 60) + corner + a2 = Rotate(a2 + offset, (ccw and 45 or -45) * 60) + corner + a3 = Rotate(a3 + offset, (ccw and 45 or -45) * 60) + corner + + if self.b_over_rotation_arrow and axis == self.arrow_axis and self.arrow_ccw == ccw then + color = RGBA(255, 255, 0, self.opacity * 200 / 255) + end + + vpstr = vpstr or pstr("") + local pts = self:TransformPointsToAxisPlane(axis, a1, a2, a3) + vpstr:AppendVertex(pts[1], color) + vpstr:AppendVertex(pts[2]) + vpstr:AppendVertex(pts[3]) + return vpstr +end + +DefineClass.MoveGizmo = { + __parents = { "XEditorGizmo", "MoveGizmoTool" }, + + HasLocalCSSetting = true, + HasSnapSetting = true, + + Title = "Move gizmo (W)", + Description = false, + ActionSortKey = "1", + ActionIcon = "CommonAssets/UI/Editor/Tools/MoveGizmo.tga", + ActionShortcut = "W", + UndoOpName = "Moved %d object(s)", + + rotation_arrows_on = true, + + operation_started = false, + initial_positions = false, + initial_pos = false, + initial_gizmo_pos = false, + move_by_slabs = false, +} + +function MoveGizmo:CheckStartOperation(pt, btn_pressed) + local objs = editor.GetSel() + if #objs == 0 then return end + + -- check move operations first + local ret = self:IntersectRay(camera.GetEye(), ScreenToGame(pt)) + if ret then return ret end + + if self.b_over_rotation_arrow and btn_pressed then + XEditorUndo:BeginOp{ objects = objs, name = string.format("Rotated %d objects", #objs) } + + local center = self:GetVisualPos() + local axis = self.arrow_axis + if axis ~= axis_z then + axis = SetLen(Cross(self.arrow_axis, axis_z), 4096) + end + local angle = self.arrow_ccw and 90*60 or -90*60 + if self.local_cs then + axis = self:GetRelativePoint(axis) - self:GetVisualPos() + end + + local rotate_logic = XEditorRotateLogic:new() + SuspendPassEditsForEditOp() + rotate_logic:InitRotation(objs, center, 0) + rotate_logic:Rotate(objs, "group_rotation", center, axis, angle) + ResumePassEditsForEditOp() + + XEditorUndo:EndOp(objs) + return "break" + end +end + +function MoveGizmo:StartOperation(pt) + self.initial_positions = {} + + local has_aligned, move_by_z = false, self.b_over_axis_z or self.b_over_plane_xz or self.b_over_plane_yz + for _, obj in ipairs(editor.GetSel()) do + local pos = obj:GetVisualPos() + if obj:IsKindOf("AlignedObj") then + pos = move_by_z and pos:AddZ((const.SlabSizeZ or 0) / 2) or obj:GetPos() + has_aligned = true + end + self.initial_positions[obj] = pos + end + + self.move_by_slabs = has_aligned + self.initial_pos = self:CursorIntersection(pt) + self.initial_gizmo_pos = self:GetVisualPos() + self.operation_started = true +end + +function MoveGizmo:PerformOperation(pt) + local intersection = self:CursorIntersection(pt) + if intersection then + local objs = {} + local vMove = intersection - self.initial_pos + for obj, pos in pairs(self.initial_positions) do + XEditorSnapPos(obj, pos, vMove, self.move_by_slabs) + objs[#objs + 1] = obj + end + self:SetPos(self.initial_gizmo_pos + vMove) + Msg("EditorCallback", "EditorCallbackMove", objs) + end +end + +function MoveGizmo:EndOperation() + self.initial_positions = false + self.initial_pos = false + self.operation_started = false + self.initial_gizmo_pos = false +end + +local saneBox = box(-const.SanePosMaxXY, -const.SanePosMaxXY, const.SanePosMaxXY - 1, const.SanePosMaxXY - 1) +local saneZ = const.SanePosMaxZ + +function MoveGizmo:Render() + local obj = not XEditorIsContextMenuOpen() and selo() + if obj then + if self.local_cs then + self.v_axis_x, self.v_axis_y, self.v_axis_z = GetAxisVectors(obj) + self:SetAxisAngle(obj:GetAxis(), obj:GetAngle()) + else + self.v_axis_x = axis_x + self.v_axis_y = axis_y + self.v_axis_z = axis_z + self:SetOrientation(axis_z, 0) + end + if not self.operation_started then + local pos = CenterOfMasses(editor.GetSel()) + local clamped_pos = ClampPoint(pos, saneBox):SetZ(Clamp(pos:z(), -saneZ, saneZ - 1)) + self:SetPos(clamped_pos, 0) + end + self:ChangeScale() + self:SetMesh(self:RenderGizmo()) + else + self:SetMesh(pstr("")) + end +end diff --git a/CommonLua/Editor/XEditor/RotateGizmo.lua b/CommonLua/Editor/XEditor/RotateGizmo.lua new file mode 100644 index 0000000000000000000000000000000000000000..a6483ac3edb0a5f63291c3ebdfc47041ac4e770f --- /dev/null +++ b/CommonLua/Editor/XEditor/RotateGizmo.lua @@ -0,0 +1,351 @@ +DefineClass.RotateGizmo = { + __parents = { "XEditorGizmo" }, + + HasLocalCSSetting = true, + HasSnapSetting = false, + + Title = "Rotate gizmo (E)", + Description = false, + ActionSortKey = "2", + ActionIcon = "CommonAssets/UI/Editor/Tools/RotateGizmo.tga", + ActionShortcut = "E", + UndoOpName = "Rotated %d object(s)", + + mesh_x = pstr(""), + mesh_y = pstr(""), + mesh_z = pstr(""), + mesh_big = pstr(""), + mesh_sphere = pstr(""), + + b_over_x = false, + b_over_y = false, + b_over_z = false, + b_over_big = false, + b_over_sphere = false, + + v_axis_x = axis_x, + v_axis_y = axis_y, + v_axis_z = axis_z, + + scale = 100, + thickness = 100, + opacity = 255, + sensitivity = 100, + + operation_started = false, + + tangent_vector = false, + tangent_offset = false, + tangent_axis = false, + tangent_angle = false, + + initial_orientations = false, + init_intersect = false, + init_pos = false, + rotation_center = false, + rotation_axis = false, + rotation_angle = 0, + rotation_snap = false, + + text = false, +} + +function RotateGizmo:Done() + self:DeleteText() +end + +function RotateGizmo:DeleteText() + if self.text then + self.text:delete() + self.text = nil + end +end + +function RotateGizmo:CheckStartOperation(pt) + return #editor.GetSel() > 0 and self:IntersectRay(camera.GetEye(), ScreenToGame(pt)) +end + +function RotateGizmo:StartOperation(pt) + if not self.b_over_sphere then + self.text = XTemplateSpawn("XFloatingText") + self.text:SetTextStyle("GizmoText") + self.text:AddDynamicPosModifier({id = "attached_ui", target = self:GetPos()}) + self.text.TextColor = RGB(255, 255, 255) + self.text.ShadowType = "outline" + self.text.ShadowSize = 1 + self.text.ShadowColor = RGB(64, 64, 64) + self.text.Translate = false + end + + -- with Slabs in the selection, rotate around a half-grid aligned point (if rotating around the Z axis) + -- to ensure Slab positions are aligned after the rotation + local center = self:GetPos() + local objs = editor.GetSel() + self:CursorIntersection(pt) -- initialize rotation_axis + if not self.b_over_sphere and self.rotation_axis == axis_z and HasAlignedObjs(objs) then + local snap = const.SlabSizeX + center = point(center:x() / snap * snap, center:y() / snap * snap, center:z()) or center + end + + self.init_intersect = self:CursorIntersection(pt) + self.init_pos = self:GetPos() + self.rotation_center = center + self.initial_orientations = {} + for i, obj in ipairs(objs) do + self.initial_orientations[obj] = { axis = obj:GetVisualAxis(), angle = obj:GetVisualAngle(), offset = obj:GetVisualPos() - center } + end + self.operation_started = true +end + +function RotateGizmo:PerformOperation(pt) + local intersection = self:CursorIntersection(pt) + if not intersection then return end + + self.rotation_angle = 0 + + local axis, angle + local offset = MulDivRound(intersection - self.init_intersect, 9 * self.sensitivity, self.scale) -- 9 is the magic number that gives +/-45 degree range + if self.b_over_sphere then + local normal = Normalize(self:GetVisualPos() - camera.GetEye()) + local axisX = Normalize(Cross(normal, axis_z)) + local axisY = Normalize(Cross(normal, axisX)) + local angleX = Dot(offset, axisY) / 4096 + local angleY = -Dot(offset, axisX) / 4096 + axis, angle = ComposeRotation(axisX, angleX, axisY, angleY) + else + axis, angle = self.rotation_axis, Dot(offset, self.tangent_vector) / 4096 + if XEditorSettings:GetGizmoRotateSnapping() then + local new_angle = self:SnapAngle(angle) + angle, self.rotation_snap = new_angle, new_angle ~= angle + end + self.rotation_angle = angle / 60.0 + end + + local center = self.rotation_center + for obj, data in pairs(self.initial_orientations) do + local newPos = center + RotateAxis(data.offset, axis, angle) + if not obj:IsValidZ() then + newPos = newPos:SetInvalidZ() + end + XEditorSetPosAxisAngle(obj, newPos, ComposeRotation(data.axis, data.angle, axis, angle)) + end + Msg("EditorCallback", "EditorCallbackRotate", table.keys(self.initial_orientations)) +end + +function RotateGizmo:EndOperation() + self.tangent_vector = false + self.tangent_offset = false + self.tangent_axis = false + self.tangent_angle = false + self.rotation_axis = false + self.initial_orientations = false + self.init_intersect = false + self.init_pos = false + self.operation_started = false + self:DeleteText() +end + +function RotateGizmo:SnapAngle(angle) + local snapAngle = 15 * 60 + local snapAngleTollerance = 120 + if abs(angle) > snapAngleTollerance then -- don't snap at the 0 degree rotation, allowing for small adjustments + if abs(angle % snapAngle) < snapAngleTollerance or abs(angle % snapAngle) > (snapAngle - snapAngleTollerance) then + angle = (angle + snapAngleTollerance) / snapAngle * snapAngle + end + end + return angle +end + +function RotateGizmo:Render() + local obj = not XEditorIsContextMenuOpen() and selo() + if obj then + if self.local_cs then + self.v_axis_x, self.v_axis_y, self.v_axis_z = GetAxisVectors(obj) + else + self.v_axis_x = axis_x + self.v_axis_y = axis_y + self.v_axis_z = axis_z + self:SetOrientation(axis_z, 0) + end + self:SetPos(self.init_pos or CenterOfMasses(editor.GetSel())) + self:CalculateScale() + self:SetMesh(self:RenderGizmo()) + else self:SetMesh(pstr("")) end +end + +function RotateGizmo:RenderGizmo() + local vpstr = pstr("") + local center = point(0, 0, 0) + local pos = selo() and selo():GetVisualPos() or GetTerrainCursor() + local normal = pos - camera.GetEye() + normal = Normalize(normal) + local bigTorusAxis, bigTorusAngle = GetAxisAngle(axis_z, normal) + bigTorusAxis = Normalize(camera.GetEye() - self:GetPos()) + bigTorusAngle = bigTorusAngle / 60 + + self.mesh_big = self:RenderBigTorus(nil, bigTorusAxis) + self.mesh_sphere = self:RenderCircle(nil, bigTorusAxis, bigTorusAngle) + self.mesh_x = self:RenderTorusAndAxis(nil, self.v_axis_x, self.b_over_x, normal) + self.mesh_y = self:RenderTorusAndAxis(nil, self.v_axis_y, self.b_over_y, normal) + self.mesh_z = self:RenderTorusAndAxis(nil, self.v_axis_z, self.b_over_z, normal) + + if self.text then + self.text:SetText((self.rotation_snap and "" or "") .. string.format("%.2f°", self.rotation_angle)) + end + + vpstr = self:RenderBigTorus(vpstr, bigTorusAxis, self.b_over_big, true) + vpstr = self:RenderOutlineTorus(vpstr, bigTorusAxis) + vpstr = self:RenderCircle(vpstr, bigTorusAxis, bigTorusAngle, self.b_over_sphere) + vpstr = self:RenderTorusAndAxis(vpstr, self.v_axis_x, self.b_over_x, normal, RGBA(192, 0, 0, self.opacity), true) + vpstr = self:RenderTorusAndAxis(vpstr, self.v_axis_y, self.b_over_y, normal, RGBA(0, 192, 0, self.opacity), true) + vpstr = self:RenderTorusAndAxis(vpstr, self.v_axis_z, self.b_over_z, normal, RGBA(0, 0, 192, self.opacity), true) + return self:RenderTangent(vpstr) +end + +function RotateGizmo:CalculateScale() + local eye = camera.GetEye() + local dir = self:GetVisualPos() + local ray = dir - eye + local cameraDistanceSquared = ray:x() * ray:x() + ray:y() * ray:y() + ray:z() * ray:z() + local cameraDistance = 0 + if cameraDistanceSquared >= 0 then cameraDistance = sqrt(cameraDistanceSquared) end + self.scale = cameraDistance / 20 * self.scale / 100 +end + +function RotateGizmo:IntersectRay(pt1, pt2) + self.b_over_z = IntersectRayMesh(self, pt1, pt2, self.mesh_z) + self.b_over_x = IntersectRayMesh(self, pt1, pt2, self.mesh_x) + self.b_over_y = IntersectRayMesh(self, pt1, pt2, self.mesh_y) + self.b_over_big = IntersectRayMesh(self, pt1, pt2, self.mesh_big) + + self.b_over_sphere = false + if self.b_over_z then + self.b_over_x = false + self.b_over_y = false + return true + elseif self.b_over_x then + self.b_over_y = false + self.b_over_z = false + return true + elseif self.b_over_y then + self.b_over_z = false + self.b_over_x = false + return true + elseif self.b_over_big then + return true + end + + self.b_over_sphere = IntersectRayMesh(self, pt1, pt2, self.mesh_sphere) + return self.b_over_sphere +end + +function RotateGizmo:CursorIntersection(mouse_pos) + local pt1 = camera.GetEye() + local pt2 = ScreenToGame(mouse_pos) + local pos = self:GetVisualPos() + local pt_intersection = self.b_over_x or self.b_over_y or self.b_over_z or self.b_over_big + if pt_intersection then + if not self.operation_started then + self.rotation_axis = + self.b_over_x and self.v_axis_x or + self.b_over_y and self.v_axis_y or + self.b_over_z and self.v_axis_z or + self.b_over_big and (camera.GetEye() - pos) + self.rotation_axis = Normalize(self.rotation_axis) + self.tangent_offset = pt_intersection - pos + self.tangent_vector = Cross(self.rotation_axis, Normalize(self.tangent_offset)) + self.tangent_vector = Normalize(self.tangent_vector) + self.tangent_axis, self.tangent_angle = GetAxisAngle(axis_z, self.tangent_vector) + self.tangent_axis, self.tangent_angle = Normalize(self.tangent_axis), self.tangent_angle / 60 + end + + local axis + if self.b_over_x then + axis = self.v_axis_x + elseif self.b_over_y then + axis = self.v_axis_y + elseif self.b_over_z then + axis = self.v_axis_z + elseif self.b_over_big then + axis = camera.GetEye() - pos + end + local camDir = Normalize(camera.GetEye() - pos) + local camX = Normalize(Cross((camDir), axis_z)) + local planeB = pos + camX + local planeC = pos + Normalize(Cross((camDir), camX)) + local ptA = pos + self.tangent_offset + local ptB = ptA + self.tangent_vector + local intersection = IntersectRayPlane(pt1, pt2, pos, planeB, planeC) + return ProjectPointOnLine(ptA, ptB, intersection) + elseif self.b_over_sphere then + local axis = Normalize(camera.GetEye() - pos) + local screenX = Cross(axis, axis_z) + local screenY = Cross(axis, axis_x) + local planeB = pos + screenX + local planeC = pos + screenY + return IntersectRayPlane(pt1, pt2, pos, planeB, planeC) + end +end + +function RotateGizmo:RenderTangent(vpstr) + if self.tangent_vector then + local radius = 0.1 * self.scale * self.thickness / 100 + local length = 2.5 * self.scale + local coneHeight = 0.50 * self.scale + local coneRadius = 0.30 * self.scale * self.thickness / 100 + local color = RGBA(255, 0, 255, self.opacity) + vpstr = AppendConeVertices(vpstr, point(0, 0, -length), point(0, 0, length * 2), radius, radius, self.tangent_axis, self.tangent_angle, color, self.tangent_offset) + vpstr = AppendConeVertices(vpstr, point(0, 0, -length), point(0, 0, -coneHeight), coneRadius, 0, self.tangent_axis, self.tangent_angle, color, self.tangent_offset) + vpstr = AppendConeVertices(vpstr, point(0, 0, length), point(0, 0, coneHeight), coneRadius, 0, self.tangent_axis, self.tangent_angle, color, self.tangent_offset) + end + return vpstr +end + +function RotateGizmo:RenderCircle(vpstr, axis, angle, selected) + vpstr = vpstr or pstr("") + local HSeg = 32 + local center = point(0, 0, 0) + local rad = Cross(axis, axis_z) + local radius = 2.3 * self.scale + local color = selected and RGBA(255, 255, 0, 70 * self.opacity / 255) or RGBA(0, 0, 0, 0) + rad = Normalize(rad) + rad = MulDivRound(rad, radius, 4096) + for i = 1, HSeg do + local pt = Rotate(rad, MulDivRound(360 * 60, i, HSeg)) + pt = RotateAxis(pt, rad, angle * 60) + local nextPt = Rotate(rad, MulDivRound(360 * 60, i + 1, HSeg)) + nextPt = RotateAxis(nextPt, rad, angle * 60) + vpstr:AppendVertex(center, color) + vpstr:AppendVertex(pt) + vpstr:AppendVertex(nextPt) + end + return vpstr +end + +function RotateGizmo:RenderBigTorus(vpstr, axis, selected, visual) + local radius1 = 3.5 * self.scale + local radius2 = visual and 0.15 * self.scale * self.thickness / 100 or 0.15 * self.scale + local color = selected and RGBA(255, 255, 0, self.opacity) or RGBA(0, 192, 192, self.opacity) + return AppendTorusVertices(vpstr, radius1, radius2, axis, color) +end + +function RotateGizmo:RenderTorusAndAxis(vpstr, axis, selected, normal, color, visual) + local radius1 = 2.3 * self.scale + local radius2 = visual and 0.15 * self.scale * self.thickness / 100 or 0.15 * self.scale + color = selected and RGBA(255, 255, 0, self.opacity) or color + + local height = 1.5 * self.scale + local radius = 0.05 * self.scale + vpstr = AppendTorusVertices(vpstr, radius1, radius2, axis, color, normal) + local axis, angle = GetAxisAngle(axis_z, axis) + axis = Normalize(axis) + angle = angle / 60 + return AppendConeVertices(vpstr, nil, point(0, 0, height), radius, radius, axis, angle, color) +end + +function RotateGizmo:RenderOutlineTorus(vpstr, axis) + local radius1 = 2.3 * self.scale + local radius2 = 0.15 * self.scale * self.thickness / 100 + local color = RGBA(128, 128, 128, 192 * self.opacity / 255) + return AppendTorusVertices(vpstr, radius1, radius2, axis, color) +end \ No newline at end of file diff --git a/CommonLua/Editor/XEditor/ScaleGizmo.lua b/CommonLua/Editor/XEditor/ScaleGizmo.lua new file mode 100644 index 0000000000000000000000000000000000000000..81dd758eea0716c3fcb4698cfa6dd64617b2d57d --- /dev/null +++ b/CommonLua/Editor/XEditor/ScaleGizmo.lua @@ -0,0 +1,209 @@ +DefineClass.ScaleGizmo = { + __parents = { "XEditorGizmo" }, + + HasLocalCSSetting = false, + HasSnapSetting = false, + + Title = "Scale gizmo (R)", + Description = false, + ActionSortKey = "3", + ActionIcon = "CommonAssets/UI/Editor/Tools/ScaleGizmo.tga", + ActionShortcut = "R", + UndoOpName = "Scaled %d object(s)", + + side_mesh_a = pstr(""), + side_mesh_b = pstr(""), + side_mesh_c = pstr(""), + + b_over_a = false, + b_over_b = false, + b_over_c = false, + + scale = 100, + thickness = 100, + opacity = 255, + sensitivity = 100, + + operation_started = false, + initial_scales = false, + text = false, + scale_text = "", + init_pos = false, + init_mouse_pos = false, + group_scale = false, +} + +function ScaleGizmo:Done() + self:DeleteText() +end + +function ScaleGizmo:DeleteText() + if self.text then + self.text:delete() + self.text = nil + self.scale_text = "" + end +end + +function ScaleGizmo:CheckStartOperation(pt) + return #editor.GetSel() > 0 and self:IntersectRay(camera.GetEye(), ScreenToGame(pt)) +end + +function ScaleGizmo:StartOperation(pt) + self.text = XTemplateSpawn("XFloatingText") + self.text:SetTextStyle("GizmoText") + self.text:AddDynamicPosModifier({id = "attached_ui", target = self:GetPos()}) + self.text.TextColor = RGB(255, 255, 255) + self.text.ShadowType = "outline" + self.text.ShadowSize = 1 + self.text.ShadowColor = RGB(64, 64, 64) + self.text.Translate = false + self.init_pos = self:GetPos() + self.init_mouse_pos = terminal.GetMousePos() + self.initial_scales = {} + for _, obj in ipairs(editor.GetSel()) do + self.initial_scales[obj] = { scale = obj:GetScale(), offset = obj:GetVisualPos() - self.init_pos } + end + self.group_scale = terminal.IsKeyPressed(const.vkAlt) + self.operation_started = true +end + +function ScaleGizmo:PerformOperation(pt) + local screenHeight = UIL.GetScreenSize():y() + local mouseY = 4096.0 * (terminal.GetMousePos():y() - screenHeight / 2) / screenHeight + local initY = 4096.0 * (self.init_mouse_pos:y() - screenHeight / 2) / screenHeight + local scale + if mouseY < initY then + scale = 100 * (mouseY + 4096) / (initY + 4096) + 250 * (initY - mouseY) / (initY + 4096) + else + scale = 100 * (4096 - mouseY) / (4096 - initY) + 10 * (mouseY - initY) / (4096 - initY) + end + scale = 100 + MulDivRound(scale - 100, self.sensitivity, 100) + self:SetScaleClamped(scale) + + for obj, data in pairs(self.initial_scales) do + obj:SetScaleClamped(MulDivRound(data.scale, scale, 100)) + if self.group_scale then + XEditorSetPosAxisAngle(obj, self.init_pos + data.offset * scale / 100) + end + end + + local objs = table.keys(self.initial_scales) + self.scale_text = #objs == 1 and + string.format("%.2f", objs[1]:GetScale() / 100.0) or + ((scale >= 100 and "+" or "-") .. string.format("%d%%", abs(scale - 100))) + Msg("EditorCallback", "EditorCallbackScale", objs) +end + +function ScaleGizmo:EndOperation() + self:DeleteText() + self:SetScale(100) + self.init_pos = false + self.init_mouse_pos = false + self.initial_scales = false + self.group_scale = false + self.operation_started = false +end + +function ScaleGizmo:RenderGizmo() + local FloorPtA = MulDivRound(point(0, 4096, 0), self.scale * 25, 40960) + local FloorPtB = MulDivRound(point(-3547, -2048, 0), self.scale * 25, 40960) + local FloorPtC = MulDivRound(point(3547, -2048, 0), self.scale * 25, 40960) + local UpperPt = MulDivRound(point(0, 0, 5900), self.scale * 25, 40960) + local PyramidSize = FloorPtA:Dist(FloorPtB) + + self.side_mesh_a = self:RenderPlane(nil, UpperPt, FloorPtB, FloorPtC) + self.side_mesh_b = self:RenderPlane(nil, FloorPtA, UpperPt, FloorPtC) + self.side_mesh_c = self:RenderPlane(nil, FloorPtA, UpperPt, FloorPtB) + + if self.text then + self.text:SetText(self.scale_text) + end + + local vpstr = pstr("") + vpstr = self:RenderCylinder(vpstr, PyramidSize, FloorPtA, 90, FloorPtB) + vpstr = self:RenderCylinder(vpstr, PyramidSize, FloorPtB, 90, FloorPtC) + vpstr = self:RenderCylinder(vpstr, PyramidSize, FloorPtC, 90, FloorPtA) + vpstr = self:RenderCylinder(vpstr, PyramidSize, Cross(FloorPtA, axis_z), 35, FloorPtA) + vpstr = self:RenderCylinder(vpstr, PyramidSize, Cross(FloorPtB, axis_z), 35, FloorPtB) + vpstr = self:RenderCylinder(vpstr, PyramidSize, Cross(FloorPtC, axis_z), 35, FloorPtC) + + if self.b_over_a then vpstr = self:RenderPlane(vpstr, UpperPt, FloorPtB, FloorPtC) + elseif self.b_over_b then vpstr = self:RenderPlane(vpstr, FloorPtA, UpperPt, FloorPtC) + elseif self.b_over_c then vpstr = self:RenderPlane(vpstr, FloorPtA, UpperPt, FloorPtB) end + return vpstr +end + +function ScaleGizmo:ChangeScale() + local eye = camera.GetEye() + local dir = self:GetVisualPos() + local ray = dir - eye + local cameraDistanceSquared = ray:x() * ray:x() + ray:y() * ray:y() + ray:z() * ray:z() + local cameraDistance = 0 + if cameraDistanceSquared >= 0 then cameraDistance = sqrt(cameraDistanceSquared) end + self.scale = cameraDistance / 20 * self.scale / 100 +end + +function ScaleGizmo:Render() + local obj = not XEditorIsContextMenuOpen() and selo() + if obj then + self:SetPos(CenterOfMasses(editor.GetSel())) + self:ChangeScale() + self:SetMesh(self:RenderGizmo()) + else self:SetMesh(pstr("")) end +end + +function ScaleGizmo:CursorIntersection(mouse_pos) + if self.b_over_a or self.b_over_b or self.b_over_c then + local pos = self:GetVisualPos() + local planeB = pos + axis_z + local planeC = pos + axis_x + local pt1 = camera.GetEye() + local pt2 = ScreenToGame(mouse_pos) + local intersection = IntersectRayPlane(pt1, pt2, pos, planeB, planeC) + return ProjectPointOnLine(pos, pos + axis_z, intersection) + end +end + +function ScaleGizmo:IntersectRay(pt1, pt2) + self.b_over_a = false + self.b_over_b = false + self.b_over_c = false + + local overA, lenA = IntersectRayMesh(self, pt1, pt2, self.side_mesh_a) + local overB, lenB = IntersectRayMesh(self, pt1, pt2, self.side_mesh_b) + local overC, lenC = IntersectRayMesh(self, pt1, pt2, self.side_mesh_c) + + if not (overA or overB or overC) then return end + + if lenA and lenB then + if lenA < lenB then self.b_over_a = overA + else self.b_over_b = overB end + elseif lenA and lenC then + if lenA < lenC then self.b_over_a = overA + else self.b_over_c = overC end + elseif lenB and lenC then + if lenB < lenC then self.b_over_b = overB + else self.b_over_c = overC end + elseif lenA then self.b_over_a = overA + elseif lenB then self.b_over_b = overB + elseif lenC then self.b_over_c = overC end + + return self.b_over_a or self.b_over_b or self.b_over_c +end + +function ScaleGizmo:RenderPlane(vpstr, ptA, ptB, ptC) + vpstr = vpstr or pstr("") + vpstr:AppendVertex(ptA, RGBA(255, 255, 0, MulDivRound(200, self.opacity, 255))) + vpstr:AppendVertex(ptB) + vpstr:AppendVertex(ptC) + return vpstr +end + +function ScaleGizmo:RenderCylinder(vpstr, height, axis, angle, offset) + vpstr = vpstr or pstr("") + local center = point(0, 0, 0) + local radius = 0.10 * self.scale * self.thickness / 100 + local color = RGBA(0, 192, 192, self.opacity) + return AppendConeVertices(vpstr, center, point(0, 0, height), radius, radius, axis, angle, color, offset) +end \ No newline at end of file diff --git a/CommonLua/Editor/XEditor/XAreaCopyTool.lua b/CommonLua/Editor/XEditor/XAreaCopyTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..b218fcea390126c022750dccb7ac3487c216bb13 --- /dev/null +++ b/CommonLua/Editor/XEditor/XAreaCopyTool.lua @@ -0,0 +1,1096 @@ +if FirstLoad then + g_TerrainAreaMeshes = {} + g_AreaUndoQueue = false -- a separate undo queue, active while the area copy tool is active + LocalStorage.XAreaCopyTool = LocalStorage.XAreaCopyTool or {} + AreaCopyBrushGrid = false +end + +local snap_size = Max(const.SlabSizeX or 0, const.HeightTileSize, const.TypeTileSize) + +local function SnapPt(pt) + local x, y = pt:xy() + return point(x / snap_size * snap_size, y / snap_size * snap_size) +end + +DefineClass.XAreaCopyTool = { + __parents = { "XEditorTool", "XMapGridAreaBrush" }, + properties = { + { id = "DrawingMode", name = "Drawing Mode", editor = "text_picker", name_on_top = true, + max_rows = 2, default = "Box areas", items = { "Box areas", "Brush" }, + }, + + -- Overridden brush properties + { id = "Size", editor = "number", default = 3 * guim, scale = "m", min = const.HeightTileSize, + max = 300 * guim, step = guim / 10, slider = true, persisted_setting = true, auto_select_all = true, + sort_order = -1, exponent = 3, no_edit = function(self) return self.DrawingMode ~= "Brush" end + }, + + { id = "TerrainDebugAlphaPerc", name = "Opacity", editor = "number", + default = 50, min = 0, max = 100, slider = true, no_edit = function(self) return self.DrawingMode ~= "Brush" end + }, + { id = "WriteValue", name = "Value", editor = "texture_picker", default = 1, + thumb_width = 101, thumb_height = 35, small_font = true, items = function(self) return self:GetGridPaletteItems() end, + max_rows = 2, no_edit = function(self) return self.DrawingMode ~= "Brush" end + }, + }, + + ToolTitle = "Copy terrain & objects", + ToolSection = "Misc", + Description = { "Copies an entire area of a map.\n\nDrag to define selection areas, then\nuse to copy and twice to paste." }, + ActionIcon = "CommonAssets/UI/Editor/Tools/EnrichTerrain.tga", + ActionShortcut = "O", + ActionSortKey = "5", + UsesCodeRenderables = true, + + GridName = "AreaCopyBrushGrid", + GridTileSize = const.SlabSizeX or const.HeightTileSize, + + brush_area_boxes = false, + + old_undo = false, + start_pos = false, + operation = false, + current_box = false, + highlighted_objset = false, + drag_area = false, + drag_helper_id = false, + + filter_roofs = false, + filter_floor = false, +} + +function XAreaCopyTool:Init() + -- make areas visible and highlight selected objects + Collection.UnlockAll() + for _, a in ipairs(self:GetAreas()) do + a:SetVisible(true) + a:Setbox(a.box) -- update according to current terrain height + end + + -- set default roof visibility settings + self.filter_floor = LocalStorage.FilteredCategories["HideFloor"] + self.filter_roofs = LocalStorage.FilteredCategories["Roofs"] + LocalStorage.FilteredCategories["HideFloor"] = 0 + LocalStorage.FilteredCategories["Roofs"] = true + XEditorFilters:UpdateHiddenRoofsAndFloors() + XEditorFilters:SuspendHighlights() + + -- highlight objects; this makes all non-highlighted objects have gofWhiteColored + local objset = {} + MapGet(true, function(obj) objset[obj] = true end) + self.highlighted_objset = objset + self:UpdateHighlights(true) + + -- replace undo queue + self.old_undo = XEditorUndo + g_AreaUndoQueue = g_AreaUndoQueue or XEditorUndoQueue:new() + XEditorUndo = g_AreaUndoQueue + XEditorUpdateToolbars() + + -- brush mode + self.brush_area_boxes = {} +end + +function XAreaCopyTool:Done() + -- make areas invisible, remove highlights + for _, a in ipairs(self:GetAreas()) do + a:SetVisible(false) + end + MapGet(true, function(obj) obj:ClearGameFlags(const.gofWhiteColored) end) + self.highlighted_objset = false + + -- restore filters + LocalStorage.FilteredCategories["HideFloor"] = self.filter_floor + LocalStorage.FilteredCategories["Roofs"] = self.filter_roofs + XEditorFilters:UpdateHiddenRoofsAndFloors() + XEditorFilters:ResumeHighlights() + + -- restore undo queue + XEditorUndo = self.old_undo + if GetDialog("XEditor") then + XEditorUpdateToolbars() + end + + -- free allocated grids + if self.Grid then + self.Grid:free() + end + + -- delete brush areas + for _, a in ipairs(self.brush_area_boxes) do + a:delete() + end +end + +function OnMsg.OnMapPatchBegin() + local editor_tool = XEditorGetCurrentTool() + if IsKindOf(editor_tool, "XAreaCopyTool") then + XEditorUndo = editor_tool.old_undo + end +end + +function OnMsg.OnMapPatchEnd() + if IsKindOf(XEditorGetCurrentTool(), "XAreaCopyTool") then + XEditorUndo = g_AreaUndoQueue + end +end + +function XAreaCopyTool:CreateBrushGrid() + if self.Grid then + return self.Grid + end + + local map_data = mapdata or _G.mapdata + local map_size = point(map_data.Width - 1, map_data.Height - 1) * const.HeightTileSize + local width, height = (map_size / self.GridTileSize):xy() + -- brush grid to be used by XMapGridAreaBrush + self.Grid = NewHierarchicalGrid(width, height, 64, 1) -- patch_size = 64 + + return self.Grid +end + +function XAreaCopyTool:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "DrawingMode" then + if self.DrawingMode == "Brush" then + -- Hide previously created area meshes + for _, a in ipairs(g_TerrainAreaMeshes) do + a:SetVisible(false) + end + + self.WriteValue = 1 -- select the "Copy Area" brush value + + -- Show debug overlay + hr.TerrainDebugDraw = 1 + + -- Create the brush cursor + self:CreateCursor() + + return XMapGridAreaBrush.OnEditorSetProperty(prop_id, old_value, ged) + elseif self.DrawingMode == "Box areas" then + -- Show previously create area meshes + for _, a in ipairs(g_TerrainAreaMeshes) do + a:SetVisible(true) + end + + -- Delete existing area boxes + for _, a in ipairs(self.brush_area_boxes) do + a:delete() + end + + -- Hide debug overlay + hr.TerrainDebugDraw = 0 + + -- Destroy the brush cursor + self:DestroyCursor() + end + end +end + +function XAreaCopyTool:GetGridPaletteItems() + local white = "CommonAssets/System/white.dds" + local items = {} + + table.insert(items, { text = "Blank", value = 0, image = white, color = RGB(0, 0, 0) }) + table.insert(items, { text = "Copy Area", value = 1, image = white, color = RGB(10, 10, 150) }) + return items +end + +function XAreaCopyTool:GetPalette() + local palette = { + [0] = RGB(0, 0, 0), + [1] = RGB(10, 10, 150) + } + return palette +end + +function XAreaCopyTool:OnCursorCreate(cursor_mesh) + -- Destroy the brush cursor if we're not in brush mode + if self.DrawingMode ~= "Brush" then + self:DestroyCursor() + end +end + +function XAreaCopyTool:GetAreas() + local area_list + if self.DrawingMode == "Box areas" then + area_list = g_TerrainAreaMeshes + elseif self.DrawingMode == "Brush" then + area_list = self.brush_area_boxes + end + + for i = #area_list, 1, -1 do + local area = area_list[i] + if not IsValid(area) or area.box:IsEmpty() then + table.remove(area_list, i) + end + end + return area_list +end + +function XAreaCopyTool:UpdateBrushAreaBoxes() + if self.DrawingMode ~= "Brush" then return end + + -- Delete existing area boxes + for _, a in ipairs(self.brush_area_boxes) do + a:delete() + end + + -- Create areas on the non-zero boxes of the brush grid + local non_zero_boxes = editor.GetNonZeroInvalidationBoxes(self.Grid) + for idx, bx in ipairs(non_zero_boxes) do + local area = XEditableTerrainAreaMesh:new() + local no_mesh = self.DrawingMode == "Brush" + area:Setbox(bx * self.GridTileSize, "force_setpos", no_mesh) + self.brush_area_boxes[idx] = area + end + self:UpdateHighlights(true) +end + +function CanSelectWithMaskGrid(obj) + if not obj then return CanSelect(obj) end + + if obj and mask_grid and mask_grid_tile and obj:IsValidPos() then + local pos = obj:GetPos() + if mask_grid:get(pos / mask_grid_tile) then + return CanSelect(obj) + end + end + + return false +end + +function XAreaCopyTool:GetBrushObjectSelector() + local CanSelectWithMaskGrid = function(obj) + if not obj then return CanSelect(obj) end + + if obj and self.Grid and self.GridTileSize and obj:IsValidPos() then + local pos = obj:GetPos() + if self.Grid:get(pos / self.GridTileSize) > 0 then + return CanSelect(obj) + end + end + + return false + end + + return CanSelectWithMaskGrid +end + +function XAreaCopyTool:GetObjects(box_list) + local selector_fn = self.DrawingMode == "Brush" and self:GetBrushObjectSelector() or CanSelect + local objset = {} + for _, b in ipairs(box_list) do + b = IsKindOf(b, "XTerrainAreaMesh") and b.box or b + for _, obj in ipairs(MapGet(b, "attached", false, selector_fn)) do + objset[obj] = obj:GetGameFlags(const.gofPermanent) ~= 0 and not IsKindOf(obj, "XTerrainAreaMesh") or nil + end + end + return XEditorPropagateParentAndChildObjects(table.keys(objset)) +end + +function XAreaCopyTool:UpdateHighlights(highlight) + PauseInfiniteLoopDetection("XAreaCopyTool:UpdateHighlights") + + local new = highlight and self:GetObjects(self:GetAreas()) or empty_table + local old_set = self.highlighted_objset or empty_table + local new_set = {} + for _, obj in ipairs(new) do + if not old_set[obj] then + obj:ClearHierarchyGameFlags(const.gofWhiteColored) + else + old_set[obj] = nil + end + new_set[obj] = true + end + for obj in pairs(old_set) do + obj:SetHierarchyGameFlags(const.gofWhiteColored) + end + self.highlighted_objset = new_set + + ResumeInfiniteLoopDetection("XAreaCopyTool:UpdateHighlights") +end + +function XAreaCopyTool:EndDraw(pt1, pt2, invalid_box) + XMapGridAreaBrush.EndDraw(self, pt1, pt2, invalid_box) + self:UpdateBrushAreaBoxes() +end + +function XAreaCopyTool:OnMouseButtonDown(pt, button) + if self.DrawingMode == "Box areas" then + if button == "L" then + self.desktop:SetMouseCapture(self) + self.start_pos = SnapPt(GetTerrainCursor()) + + -- are we starting a drag to move/resize an area? + for _, a in ipairs(self:GetAreas()) do + local helper_id = a:UpdateHelpers(pt) + if helper_id then + self.operation = "movesize" + self.drag_area = a + self.drag_helper_id = helper_id + + XEditorUndo:BeginOp{ name = "Moves/sized area", objects = { self.drag_area } } + self.drag_area:DragStart(self.drag_helper_id, self.start_pos) + return "break" + end + end + + self.operation = "place" + self.current_box = XEditableTerrainAreaMesh:new() + g_TerrainAreaMeshes[#g_TerrainAreaMeshes + 1] = self.current_box + return "break" + end + + if button == "R" then + for _, a in ipairs(self:GetAreas()) do + a:delete() + end + self:UpdateHighlights(true) + return "break" + end + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnMouseButtonDown(self, pt, button) + end +end + +local function MinMaxPtXY(f, p1, p2) + return point(f(p1:x(), p2:x()), f(p1:y(), p2:y())) +end + +function XAreaCopyTool:OnMousePos(pt) + if self.DrawingMode == "Box areas" then + XEditorRemoveFocusFromToolbars() + + if self.operation == "place" then + local pt1, pt2 = self.start_pos, SnapPt(GetTerrainCursor()) + local new_box = box(MinMaxPtXY(Min, pt1, pt2), MinMaxPtXY(Max, pt1, pt2) + point(snap_size, snap_size)) + local old_box = self.current_box.box + local no_mesh = self.DrawingMode == "Brush" + self.current_box:Setbox(new_box, "force_setpos", no_mesh) + self:UpdateHighlights(true) + return "break" + end + + if self.operation == "movesize" then + self.drag_area:DragMove(self.drag_helper_id, SnapPt(GetTerrainCursor())) + self:UpdateHighlights(true) + return "break" + end + + local areas = self:GetAreas() + for _, a in ipairs(areas) do + a:UpdateHelpers(pt) + end + local hovered + for _, a in ipairs(areas) do + a:UpdateHover(hovered) + hovered = hovered or a.hovered + end + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnMousePos(self, pt) + end +end + +function XAreaCopyTool:OnMouseButtonUp(pt, button) + if self.DrawingMode == "Box areas" then + if self.operation then + self.desktop:SetMouseCapture() + return "break" + end + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnMouseButtonUp(self, pt, button) + end +end + +function XAreaCopyTool:OnCaptureLost() + if self.DrawingMode == "Box areas" then + if self.operation == "place" then + XEditorUndo:BeginOp{ name = "Added area" } + XEditorUndo:EndOp{ self.current_box } + end + if self.operation == "movesize" then + XEditorUndo:EndOp{ self.drag_area } + end + self.start_pos = nil + self.operation = nil + self.current_box = nil + self.drag_area = nil + self.drag_helper_id = nil + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnCaptureLost(self) + end +end + +function XAreaCopyTool:OnShortcut(shortcut, source, ...) + -- don't change tool modes, allow undo, etc. while in the process of dragging + if terminal.desktop:GetMouseCapture() and shortcut ~= "Ctrl-F1" and shortcut ~= "Escape" then + return "break" + end + + if shortcut == "Ctrl-C" then + ExecuteWithStatusUI("Copying terrain & objects...", function() self:CopyToClipboard() end) + return "break" + end + + if self.DrawingMode == "Box areas" then + if shortcut == "Delete" then + for _, a in ipairs(self:GetAreas()) do + if a.hovered then + XEditorUndo:BeginOp{ name = "Deleted area", objects = { a } } + a:delete() + XEditorUndo:EndOp() + self:UpdateHighlights(true) + return "break" + end + end + end + return XEditorTool.OnShortcut(self, shortcut, source, ...) + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnShortcut(self, shortcut, source, ...) + end +end + +function XAreaCopyTool:OnKbdKeyDown(vkey) + if self.DrawingMode == "Box areas" then + return XEditorTool.OnKbdKeyDown(self, vkey) + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnKbdKeyDown(self, vkey) + end +end + +function XAreaCopyTool:OnKbdKeyUp(vkey) + if self.DrawingMode == "Box areas" then + return XEditorTool.OnKbdKeyUp(self, vkey) + elseif self.DrawingMode == "Brush" then + return XMapGridAreaBrush.OnKbdKeyUp(self, vkey) + end +end + +function XAreaCopyTool:CopyToClipboard() + self:UpdateBrushAreaBoxes() + + local areas = self:GetAreas() + if #areas == 0 then return end + + -- create XTerrainGridData for each area to capture all grids + local area_datas = {} + for _, a in ipairs(areas) do + local data = XTerrainGridData:new() + local mask = self.DrawingMode == "Brush" and self.Grid or nil + data:CaptureData(a.box, mask, mask and self.GridTileSize or nil) + area_datas[#area_datas + 1] = data + end + + -- copy area data and objects to clipboard with a custom PasteTerrainAndObjects paste function + local data = XEditorSerialize(area_datas) + data.objs = XEditorSerialize(XEditorCollapseChildObjects(self:GetObjects(areas))) + data.pivot = CenterPointOnBase(areas) + data.paste_fn = "PasteTerrainAndObjects" + data.mask_grid_tile_size = self.GridTileSize + CopyToClipboard(XEditorToClipboardFormat(data)) + + -- delete areas and select default editor tool + XEditorUndo:BeginOp{ objects = table.copy(areas), name = "Copied terrain & objects" } + for _, a in ipairs(areas) do + a:delete() + end + for _, a in ipairs(area_datas) do + a:delete() + end + XEditorUndo:EndOp() + XEditorSetDefaultTool() +end + + +----- XTerrainAreaMesh + +function OnMsg.PreSaveMap() MapForEach("map", "XTerrainAreaMesh", function(obj) obj:ClearGameFlags(const.gofPermanent) end) end +function OnMsg.PostSaveMap() MapForEach("map", "XTerrainAreaMesh", function(obj) obj: SetGameFlags(const.gofPermanent) end) end + +DefineClass.XTerrainAreaMesh = { + __parents = { "Mesh", "EditorCallbackObject" }, + properties = { + { id = "box", editor = "box" }, + }, + + outer_color = RGB(255, 255, 255), + inner_color = RGBA(255, 255, 255, 80), + outer_border = 6 * guic, + inner_border = 4 * guic, + box = empty_box, +} + +function XTerrainAreaMesh:Init() + self:SetGameFlags(const.gofPermanent) -- so it can be copied by XEditorSerialize + self:SetShader(ProceduralMeshShaders.default_mesh) + self:SetDepthTest(true) +end + +function XTerrainAreaMesh:GetPivot() + local pivot = self.box:Center() + return pivot:SetZ(self:GetHeight(pivot)) +end + +function XTerrainAreaMesh:GetHeight(pt) + return terrain.GetHeight(pt) +end + +function XTerrainAreaMesh:AddQuad(v_pstr, pivot, pt1, pt2, pt3, pt4, color) + local offs = 30 * guic + pt1 = (pt1 - pivot):SetZ(self:GetHeight(pt1) - pivot:z() + offs) + pt2 = (pt2 - pivot):SetZ(self:GetHeight(pt2) - pivot:z() + offs) + pt3 = (pt3 - pivot):SetZ(self:GetHeight(pt3) - pivot:z() + offs) + pt4 = (pt4 - pivot):SetZ(self:GetHeight(pt4) - pivot:z() + offs) + v_pstr:AppendVertex(pt1, color) + v_pstr:AppendVertex(pt2) + v_pstr:AppendVertex(pt3) + v_pstr:AppendVertex(pt2) + v_pstr:AppendVertex(pt3) + v_pstr:AppendVertex(pt4) +end + +function XTerrainAreaMesh:AddTriangle(v_pstr, pivot, pt1, pt2, pt3, color) + local offs = 30 * guic + pt1 = (pt1 - pivot):SetZ(self:GetHeight(pt1) - pivot:z() + offs) + pt2 = (pt2 - pivot):SetZ(self:GetHeight(pt2) - pivot:z() + offs) + pt3 = (pt3 - pivot):SetZ(self:GetHeight(pt3) - pivot:z() + offs) + v_pstr:AppendVertex(pt1, color) + v_pstr:AppendVertex(pt2) + v_pstr:AppendVertex(pt3) +end + +function XTerrainAreaMesh:Setbox(bbox, force_setpos, no_mesh) + self.box = bbox + + if no_mesh then + if force_setpos or self:GetPos() == InvalidPos() then + self:SetPos(self:GetPivot()) + end + return + end + + -- for too large areas, we make the lines sparser for better performance + -- 'n' below is the n in the statement "we will only draw every nth line" + local treshold_size = snap_size * 32 + local n = Max(bbox:sizex(), bbox:sizey()) / treshold_size + 1 + local inner_border = self.inner_border + self.inner_border * (n - 1) / 2 + local outer_border = self.outer_border + self.outer_border * (n - 1) / 2 + + -- generate mesh + local step = snap_size + local v_pstr = pstr("", 65536) + local pivot = self:GetPivot() + for x = bbox:minx(), bbox:maxx(), step do + for y = bbox:miny(), bbox:maxy(), step do + if x + step <= bbox:maxx() then + local outer = y == bbox:miny() or y + step > bbox:maxy() + if outer or (y - bbox:miny()) / step % n == 0 then + local d = outer and outer_border or inner_border + local pt1, pt2 = point(x, y - d), point(x + step, y - d) + local pt3, pt4 = point(x, y + d), point(x + step, y + d) + self:AddQuad(v_pstr, pivot, pt1, pt2, pt3, pt4, outer and self.outer_color or self.inner_color) + end + end + if y + step <= bbox:maxy() then + local outer = x == bbox:minx() or x + step > bbox:maxx() + if outer or (x - bbox:minx()) / step % n == 0 then + local d = outer and outer_border or inner_border + local pt1, pt2 = point(x - d, y), point(x - d, y + step) + local pt3, pt4 = point(x + d, y), point(x + d, y + step) + self:AddQuad(v_pstr, pivot, pt1, pt2, pt3, pt4, outer and self.outer_color or self.inner_color) + end + end + end + end + if force_setpos or self:GetPos() == InvalidPos() then + self:SetPos(pivot) + end + self:SetMesh(v_pstr) +end + +function XTerrainAreaMesh:Getbox(bbox) + return self.box +end + +-- handles updating the area tool when undo/redo places or deletes an area +local function UpdateAreas(self) + if self then + table.insert_unique(g_TerrainAreaMeshes, self) + end + if GetDialogMode("XEditor") == "XAreaCopyTool" then + CreateRealTimeThread(function() XAreaCopyTool:UpdateHighlights(true) end) + end +end + +XTerrainAreaMesh.EditorCallbackPlace = UpdateAreas +XTerrainAreaMesh.EditorCallbackDelete = UpdateAreas +OnMsg.EditorFiltersChanged = UpdateAreas + + +----- XEditableTerrainAreaMesh + +local helpers_data = { + { x = 0, y = 0, x1 = true, y1 = true, x2 = false, y2 = false, point(0, 0), point(3, 0), point(0, 3) }, + { x = 1, y = 0, x1 = false, y1 = true, x2 = false, y2 = false, point(-2, 0), point(2, 0), point(0, 2), stretch_x = true }, + { x = 2, y = 0, x1 = false, y1 = true, x2 = true, y2 = false, point(-3, 0), point(0, 0), point(0, 3) }, + { x = 0, y = 1, x1 = true, y1 = false, x2 = false, y2 = false, point(0, -2), point(0, 2), point(2, 0), stretch_y = true }, + { x = 1, y = 1, x1 = true, y1 = true, x2 = true, y2 = true, point(-3, 0), point(3, 0), point(0, 3), point(-3, 0), point(3, 0), point(0, -3) }, + { x = 2, y = 1, x1 = false, y1 = false, x2 = true, y2 = false, point(0, -2), point(0, 2), point(-2, 0), stretch_y = true }, + { x = 0, y = 2, x1 = true, y1 = false, x2 = false, y2 = true, point(0, 0), point(3, 0), point(0, -3) }, + { x = 1, y = 2, x1 = false, y1 = false, x2 = false, y2 = true, point(-2, 0), point(2, 0), point(0, -2), stretch_x = true }, + { x = 2, y = 2, x1 = false, y1 = false, x2 = true, y2 = true, point(-3, 0), point(0, 0), point(0, -3) }, +} + +DefineClass.XEditableTerrainAreaMesh = { + __parents = { "XTerrainAreaMesh" }, + + hover_color = RGBA(240, 230, 150, 100), + helper_color = RGBA(255, 255, 255, 30), + helper_size = 40 * guic, + helpers = false, + hovered = false, + start_pt = false, + start_box = false, + last_delta = false, +} + +function XEditableTerrainAreaMesh:Done() + self:DoneHelpers() +end + +function XEditableTerrainAreaMesh:DoneHelpers() + for _, helper in ipairs(self.helpers) do + helper:delete() + end +end + +function XEditableTerrainAreaMesh:SetVisible(value) + for _, helper in ipairs(self.helpers) do + helper:SetVisible(value) + end + XTerrainAreaMesh.SetVisible(self, value) +end + +function XEditableTerrainAreaMesh:Setbox(bbox, force_setpos, no_mesh) + XTerrainAreaMesh.Setbox(self, bbox, force_setpos, no_mesh) + if no_mesh then + return + end + self:UpdateHelpers() +end + +function XEditableTerrainAreaMesh:UpdateHelpers(pt, active_idx) + -- ray for checks whether helpers are under the mouse cursor + local pt1, pt2 + if pt then + pt1, pt2 = camera.GetEye(), ScreenToGame(pt) + end + + -- scale up for larger areas + local treshold_size = snap_size * 32 + local n = Max(self.box:sizex(), self.box:sizey()) / treshold_size + 1 + local helper_size = self.helper_size + self.helper_size * (n - 1) / 2 + if self.box:sizex() <= snap_size * 2 or self.box:sizey() <= snap_size * 2 then + helper_size = helper_size / 2 + end + + local pivot = self:GetPivot() + self.helpers = self.helpers or {} + for idx, data in ipairs(helpers_data) do + local active = idx == active_idx or pt and self.helpers[idx] and IntersectRayMesh(self, pt1, pt2, self.helpers[idx].vertices_pstr) + active_idx = active_idx or active and idx + + local color = active and self.hover_color or self.helper_color + local helper = self.helpers[idx] or Mesh:new() + local v_pstr = pstr("", 64) + helper:SetShader(ProceduralMeshShaders.default_mesh) + helper:SetDepthTest(false) + for t = 1, #data, 3 do + local function trans(pt) + if data.stretch_x then + pt = pt:SetX(pt:x() * self.box:sizex() / (helper_size * 6)) + end + if data.stretch_y then + pt = pt:SetY(pt:y() * self.box:sizey() / (helper_size * 6)) + end + return pt * helper_size + point(self.box:minx() + data.x * self.box:sizex() / 2, self.box:miny() + data.y * self.box:sizey() / 2) + end + self:AddTriangle(v_pstr, pivot, trans(data[t]), trans(data[t + 1]), trans(data[t + 2]), color) + end + helper:SetMesh(v_pstr) + helper:SetPos(self:GetPos()) + self.helpers[idx] = helper + end + return active_idx +end + +function XEditableTerrainAreaMesh:UpdateHover(unhover_only) + local hovered = not unhover_only and GetTerrainCursor():InBox2D(self.box) + if hovered ~= self.hovered then + self.hovered = hovered + self.outer_color = hovered and RGB(240, 220, 120) or nil + XTerrainAreaMesh.Setbox(self, self.box) + end + return hovered +end + +function XEditableTerrainAreaMesh:DragStart(idx, pt) + self.start_pt = pt + self.start_box = self.box + self.last_delta = nil +end + +function XEditableTerrainAreaMesh:DragMove(idx, pt) + local data = helpers_data[idx] + local x1, y1, x2, y2 = self.start_box:xyxy() + local delta = pt - self.start_pt + if delta ~= self.last_delta then + if data.x1 then x1 = Min(x2 - snap_size, x1 + delta:x()) end + if data.y1 then y1 = Min(y2 - snap_size, y1 + delta:y()) end + if data.x2 then x2 = Max(x1 + snap_size, x2 + delta:x()) end + if data.y2 then y2 = Max(y1 + snap_size, y2 + delta:y()) end + self:Setbox(box(x1, y1, x2, y2), "force_setpos") + self:UpdateHelpers(pt, idx) + self.last_delta = delta + end +end + + +----- XTerrainGridData + +DefineClass.XTerrainGridData = { + __parents = { "XTerrainAreaMesh", "AlignedObj" }, +} + +function XTerrainGridData:Done() + for _, grid in ipairs(editor.GetGridNames()) do + local data = rawget(self, grid .. "_grid") + if data then + data:free() + end + + local mask = rawget(self, grid .. "_grid_mask") + if mask then + mask:free() + end + end +end + +function XTerrainGridData:AlignObj(pos, angle) + -- keep a full slab offset from the original position to make sure aligned objects + -- being pasted won't be displaced relative to the terrain and other objects + local pivot = self:GetPivot() + local offs = (pos or self:GetPos()) - pivot + if const.SlabSizeX then + local x = offs:x() / const.SlabSizeX * const.SlabSizeX + local y = offs:y() / const.SlabSizeY * const.SlabSizeY + local z = offs:z() and (offs:z() + const.SlabSizeZ / 2) / const.SlabSizeZ * const.SlabSizeZ + offs = point(x, y, z) + end + if XEditorSettings:GetSnapMode() == "BuildLevel" and offs:z() then + local step = const.BuildLevelHeight + offs = offs:SetZ((offs:z() + step / 2) / step * step) + end + self:SetPosAngle(pivot + offs, angle or self:GetAngle()) +end + +-- generated properties to persist all terrain grids +function XTerrainGridData:GetProperties() + local props = table.copy(XTerrainAreaMesh:GetProperties()) + for _, grid in ipairs(editor.GetGridNames()) do + props[#props + 1] = { id = grid .. "_grid", editor = "grid", default = false } + props[#props + 1] = { id = grid .. "_grid_mask", editor = "grid", default = false } + end + return props +end + +function XTerrainGridData:SetProperty(prop_id, value) + if prop_id == "box" then + self.box = value -- just store the value, as we need height_grid to update the mesh + return + end + if prop_id:ends_with("_grid") then + rawset(self, prop_id, value) + return + end + PropertyObject.SetProperty(self, prop_id, value) +end + +function XTerrainGridData:PostLoad(reason) + self:Setbox(self.box) -- update the mesh after height_grid is restored +end + +function XTerrainGridData:CaptureData(bbox, mask_grid, mask_grid_tile_size) + for _, grid in ipairs(editor.GetGridNames()) do + local copied_area, mask_area = editor.GetGrid(grid, bbox, nil, mask_grid or nil, mask_grid_tile_size or nil) + rawset(self, grid .. "_grid", copied_area or false) + rawset(self, grid .. "_grid_mask", mask_area) + end + self.box = bbox +end + +function XTerrainGridData:RotateGrids() + local angle = self:GetAngle() / 60 + if angle == 0 then return end + + local transform, transpose + if angle == 90 then + transform = function(x, y, w, h) return y, w - x end + transpose = true + elseif angle == 180 then + transform = function(x, y, w, h) return w - x, h - y end + transpose = false + elseif angle == 270 then + transform = function(x, y, w, h) return h - y, x end + transpose = true + end + + for _, grid in ipairs(editor.GetGridNames()) do + local old = rawget(self, grid .. "_grid") + if old then + local new = old:clone() + local sx, sy = old:size() + if transpose then + sx, sy = sy, sx + new:resize(sx, sy) + end + local sx1, sy1 = sx - 1, sy - 1 + for x = 0, sx do + for y = 0, sy do + new:set(x, y, old:get(transform(x, y, sx1, sy1))) + end + end + rawset(self, grid .. "_grid", new) + end + end + + if transpose then + local b = self.box - self:GetPivot() + b = box(b:miny(), b:minx(), b:maxy(), b:maxx()) + self.box = b + self:GetPivot() + end +end + +function XTerrainGridData:ApplyData(paste_grids, mask_grid_tile_size) + local pos = self:GetPos() + if not pos:IsValidZ() then + pos = pos:SetTerrainZ() + end + local offset = pos - self:GetPivot() + for _, grid in ipairs(editor.GetGridNames()) do + if paste_grids[grid] then + local data = rawget(self, grid .. "_grid") + if data then + local mask_grid = rawget(self, grid .. "_grid_mask") + if grid == "height" then + data = data:clone() + -- Calculate Z offset for the terrain height grid + local offset_z_scaled = offset:z() / const.TerrainHeightScale + local sx, sy = data:size() + for x = 0, sx do + for y = 0, sy do + -- If there's a mask, offset z only for tiles where the mask is 1 + if not mask_grid or mask_grid:get(x, y) ~= 0 then + local new_z = data:get(x, y) + offset_z_scaled + new_z = Clamp(new_z, 0, const.MaxTerrainHeight / const.TerrainHeightScale) + data:set(x, y, new_z) + end + end + end + -- The mask slice for the height grid uses the height tile + editor.SetGrid(grid, data, self.box + offset, mask_grid or nil, mask_grid and const.HeightTileSize or nil) + data:free() + else + editor.SetGrid(grid, data, self.box + offset, mask_grid or nil, mask_grid and mask_grid_tile_size or nil) + end + end + end + end +end + +function XTerrainGridData:GetHeight(pt) + pt = (pt - self.box:min() + point(const.HeightTileSize / 2, const.HeightTileSize / 2)) / const.HeightTileSize + return self.height_grid:get(pt) * const.TerrainHeightScale +end + +XTerrainGridData.EditorCallbackPlace = UpdateAreas +XTerrainGridData.EditorCallbackDelete = UpdateAreas + + +----- Two-step pasting logic +-- a) paste the areas first and let the user move them with Move Gizmo +-- b) the second Ctrl-V pastes the stored terrain and objects +-- c) cancel the entire operation if the editor tool is changed from Move Gizmo + +local areas, undo_index, op_in_progress + +local function UpdatePasteOpState() + if op_in_progress then return end + op_in_progress = true + if not areas then + if IsKindOf(selo(), "XTerrainGridData") then + local ops = XEditorUndo:GetOpNames() + local index + for i = #ops, 2, -1 do + if string.find(ops[i], "Started pasting", 1, true) then + index = i - 1 + break + end + end + undo_index = index or XEditorUndo:GetCurrentOpNameIdx() + areas = editor.GetSel() + XEditorSetDefaultTool("MoveGizmo", { + rotation_arrows_on = true, + rotation_arrows_z_only = true, + }) + end + else + if not XEditorUndo.undoredo_in_progress then + XEditorUndo:RollToOpIndex(undo_index) + end + areas = nil + undo_index = nil + end + op_in_progress = false +end + +OnMsg.EditorToolChanged = UpdatePasteOpState +OnMsg.EditorSelectionChanged = UpdatePasteOpState + +function calculate_center(objs, method) + local pos = point30 + for _, obj in ipairs(objs) do + pos = pos + ValidateZ(obj[method](obj)) + end + return pos / #objs +end + +function XEditorPasteFuncs.PasteTerrainAndObjects(clipboard_data, clipboard_text) + CreateRealTimeThread(function() + PauseInfiniteLoopDetection("PasteTerrainAndObjects") + + local grid_to_item_map = { + BiomeGrid = "Biome", + height = "Terrain height", + terrain_type = "Terrain texture", + grass_density = "Grass density", + impassability = "Impassability", + passability = "Passability", + colorize = "Terrain colorization", + --- + ["Biome"] = "BiomeGrid", + ["Terrain height"] = "height", + ["Terrain texture"] = "terrain_type", + ["Grass density"] = "grass_density", + ["Impassability"] = "impassability", + ["Passability"] = "passability", + ["Terrain colorization"] = "colorize", + } + + if const.CaveTileSize then + grid_to_item_map.CaveGrid = "Caves" + grid_to_item_map.Caves = "CaveGrid" + end + + -- first step + if not areas or #table.validate(areas) == 0 then + op_in_progress = true + XEditorUndo:BeginOp{ name = "Started pasting", clipboard = clipboard_text } + local areas = XEditorDeserialize(clipboard_data) + XEditorSelectAndMoveObjects(areas, editor.GetPlacementPoint(GetTerrainCursor()) - clipboard_data.pivot) + XEditorUndo:EndOp(areas) + op_in_progress = false + UpdatePasteOpState() -- activates the Move Gizmo and starts the second paste step + else -- second step + op_in_progress = true + XEditorSetDefaultTool() + + local op = {} + local grids = {} + local items = {} + local starting_selection = {} + for idx, grid in ipairs(editor.GetGridNames()) do + op[grid] = true + grids[idx] = grid + -- Read last choice from local storage + if LocalStorage.XAreaCopyTool[grid] then + table.insert(starting_selection, idx) + end + -- Map from grid name to item name + if grid_to_item_map[grid] then + table.insert(items, grid_to_item_map[grid]) + end + end + + local result = WaitListMultipleChoice(nil, items, "Choose grids to paste:", starting_selection) + if not result then + -- Cancel + for _, a in ipairs(areas) do + a:delete() + end + + areas = nil + undo_index = nil + op_in_progress = nil + ResumeInfiniteLoopDetection("PasteTerrainAndObjects") + return + end + if #result == 0 then + -- Nothing chosen + result = items + end + + local paste_grids = {} + for _, item in ipairs(result) do + -- Map from item name to grid name + if grid_to_item_map[item] then + local grid = grid_to_item_map[item] + paste_grids[grid] = true + end + end + + -- Save choice in local storage + LocalStorage.XAreaCopyTool = table.copy(paste_grids) + SaveLocalStorage() + + op.name = "Pasted terrain & objects" + op.objects = areas + op.clipboard = clipboard_text + XEditorUndo:BeginOp(op) + + local offs = calculate_center(areas, "GetPos") - calculate_center(areas, "GetPivot") + local angle = areas[1]:GetAngle() + local center = CenterOfMasses(areas) + for _, a in ipairs(areas) do + a:RotateGrids() + a:ApplyData(paste_grids, clipboard_data.mask_grid_tile_size) + a:delete() + end + + local objs = XEditorDeserialize(clipboard_data.objs, nil, "paste") + objs = XEditorSelectAndMoveObjects(objs, offs) + editor.ClearSel() + + if angle ~= 0 then + local rotate_logic = XEditorRotateLogic:new() + SuspendPassEditsForEditOp() + rotate_logic:InitRotation(objs, center, 0) + rotate_logic:Rotate(objs, "group_rotation", center, axis_z, angle) + ResumePassEditsForEditOp() + end + + XEditorUndo:EndOp(objs) + areas = nil + undo_index = nil + op_in_progress = nil + end + + ResumeInfiniteLoopDetection("PasteTerrainAndObjects") + end) +end + +function OnMsg.ChangeMap() + areas = nil + undo_index = nil + op_in_progress = nil +end diff --git a/CommonLua/Editor/XEditor/XArrayPlacementHelper.lua b/CommonLua/Editor/XEditor/XArrayPlacementHelper.lua new file mode 100644 index 0000000000000000000000000000000000000000..5bc7b9196bb61400161b3836f044905d905f2802 --- /dev/null +++ b/CommonLua/Editor/XEditor/XArrayPlacementHelper.lua @@ -0,0 +1,138 @@ +DefineClass.XArrayPlacementHelper = { + __parents = { "XEditorPlacementHelper" }, + + -- these properties get appended to the tool that hosts this helper + properties = { + persisted_setting = true, + { id = "RepeatCount", name = "Repeat Count", editor = "number", default = 2, + min = 1, max = 20, help = "Number of times to clone the selected objects", + }, + }, + + HasLocalCSSetting = false, + HasSnapSetting = true, + InXSelectObjectsTool = true, + + clones = false, + + Title = "Array placement (3)", + Description = false, + ActionSortKey = "8", + ActionIcon = "CommonAssets/UI/Editor/Tools/PlaceObjectsInARow.tga", + ActionShortcut = "3", + UndoOpName = "Placed array of objects", +} + +function XArrayPlacementHelper:Clone(count) + local objs = {} + local sel = editor.GetSel() + for i = 1, count do + local clones = {} + for j, obj in ipairs(sel) do + clones[j] = obj:Clone() + objs[#objs + 1] = obj + end + if XEditorSelectSingleObjects == 0 then + Collection.Duplicate(clones) + end + self.clones[#self.clones + 1] = clones + end + Msg("EditorCallback", "EditorCallbackPlace", objs) +end + +function XArrayPlacementHelper:Move() + local objs = editor.GetSel() + local start_point = CenterOfMasses(objs) + local end_point = GetTerrainCursor() + local interval = (end_point - start_point) / #self.clones + + local clones = {} + local snapBySlabs = HasAlignedObjs(objs) + local start_height = terrain.GetHeight(start_point) + for i, group in ipairs(self.clones) do + local vMove = interval * i + vMove = vMove:SetZ(terrain.GetHeight(start_point + vMove) - start_height) + for j, obj in ipairs(group) do + XEditorSnapPos(obj, objs[j]:GetPos(), vMove, snapBySlabs) + clones[#clones + 1] = obj + end + end + Msg("EditorCallback", "EditorCallbackMove", clones) +end + +function XArrayPlacementHelper:Remove(count) + for i = 1, count do + local objs = self.clones[#self.clones] + Msg("EditorCallback", "EditorCallbackDelete", objs) + DoneObjects(objs) + self.clones[#self.clones] = nil + end +end + +function XArrayPlacementHelper:ChangeCount(count) + local newCount = count - #self.clones + if newCount > 0 then + self:Clone(newCount) + elseif newCount < 0 then + self:Remove(-newCount) + end + self:Move() +end + +function XArrayPlacementHelper:GetDescription() + return "(drag to clone objects in a straight line)\n(use [ and ] to change number of copies)" +end + +function XArrayPlacementHelper:CheckStartOperation(pt) + return not terminal.IsKeyPressed(const.vkShift) and editor.IsSelected(GetObjectAtCursor()) +end + +function XArrayPlacementHelper:StartOperation(pt) + local dlg = GetDialog("XSelectObjectsTool") + local clones_count = dlg:GetProperty("RepeatCount") + self.clones = {} + self:Clone(clones_count) + self.operation_started = true +end + +function XArrayPlacementHelper:PerformOperation(pt) + self:Move() +end + +function XArrayPlacementHelper:EndOperation(objects) + local selCoM = CenterOfMasses(editor.GetSel()) + local CoMs = {} + CoMs[selCoM:x()] = {} + CoMs[selCoM:x()][selCoM:y()] = true + local groupCount = #self.clones + for i = 1, groupCount do + local group = self.clones[i] + local CoM = CenterOfMasses(group) + if not CoMs[CoM:x()] then CoMs[CoM:x()] = {} end + if not CoMs[CoM:x()][CoM:y()] then + CoMs[CoM:x()][CoM:y()] = true + editor.AddToSel(group) + else + DoneObjects(group) + self.clones[i] = nil + end + end + local objectsCloned = self.clones and #self.clones > 0 + self.clones = false + self.operation_started = false + if objectsCloned then + local dlg = GetDialog("XSelectObjectsTool") + dlg:SetHelperClass("XSelectObjectsHelper") + end +end + +function XArrayPlacementHelper:OnShortcut(shortcut, source, ...) + if shortcut == "[" or shortcut == "]" then + local dir = shortcut == "[" and -1 or 1 + self:SetProperty("RepeatCount", self:GetProperty("RepeatCount") + dir) + if self.operation_started then + self:ChangeCount(self:GetProperty("RepeatCount")) + end + return "break" + end +end diff --git a/CommonLua/Editor/XEditor/XBiomeBrush.lua b/CommonLua/Editor/XEditor/XBiomeBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..c1fb41d2209e59d28aff211e7c28ed988495b257 --- /dev/null +++ b/CommonLua/Editor/XEditor/XBiomeBrush.lua @@ -0,0 +1,51 @@ +if not config.EditableBiomeGrid then return end + +function OnMsg.PresetSave(class) + local brush = XEditorGetCurrentTool() + local classdef = g_Classes[class] + if IsKindOf(classdef, "Biome") and IsKindOf(brush, "XBiomeBrush") then + brush:UpdateItems() + end +end + +DefineClass.XBiomeBrush = { + __parents = { "XMapGridAreaBrush" }, + properties = { + { id = "edit_button", editor = "buttons", default = false, + buttons = { { name = "Edit biome presets", func = function() OpenPresetEditor("Biome") end } }, + no_edit = function(self) return self.selection_available end, + }, + }, + + GridName = "BiomeGrid", + + ToolSection = "Terrain", + ToolTitle = "Biome", + Description = { + "Defines the biome areas on the map.", + "( to select & lock areas)\n" .. + "( to select entire biomes)\n" .. + "( to get the biome at the cursor)" + }, + ActionSortKey = "22", + ActionIcon = "CommonAssets/UI/Editor/Tools/TerrainBiome.tga", + ActionShortcut = "B", +} + +function XBiomeBrush:GetGridPaletteItems() + local white = "CommonAssets/System/white.dds" + local items = {{text = "Blank", value = 0, image = white, color = RGB(0, 0, 0)}} + local only_id = #(Presets.Biome or "") < 2 + ForEachPreset("Biome", function(preset) + table.insert(items, { + text = only_id and preset.id or (preset.id .. "\n" .. preset.group), + value = preset.grid_value, + image = white, + color = preset.palette_color}) + end) + return items +end + +function XBiomeBrush:GetPalette() + return DbgGetBiomePalette() +end diff --git a/CommonLua/Editor/XEditor/XCaveBrush.lua b/CommonLua/Editor/XEditor/XCaveBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..734a1382445d9a640d4ae50f1c31020c83faebb0 --- /dev/null +++ b/CommonLua/Editor/XEditor/XCaveBrush.lua @@ -0,0 +1,126 @@ +if not const.CaveTileSize then return end + +DefineMapGrid("CaveGrid", const.CaveGridBits or 8, const.CaveTileSize, 64, "save_in_map") +MapVar("CavesOpened", false) + +----- Open caves mode for Map Editor + +if FirstLoad then + EditorCaveBoxes = false +end + +function UpdateEditorCaveBoxes() + EditorCaveBoxes = editor.GetNonZeroInvalidationBoxes(CaveGrid) + for idx, bx in ipairs(EditorCaveBoxes) do + EditorCaveBoxes[idx] = bx * const.CaveTileSize + end +end + +function EditorSetCavesOpen(open) + if not EditorCaveBoxes then + UpdateEditorCaveBoxes() + end + + CavesOpened = open + Msg("EditorCavesOpen", open) + if open then + local grid = IsEditorActive() and CaveGrid or DiscoveredCavesGrid + terrain.SetTerrainHolesBaseGridRect(grid, EditorCaveBoxes) + else + terrain.ClearTerrainHolesBaseGrid(EditorCaveBoxes) + end + + local statusbar = GetDialog("XEditorStatusbar") + if statusbar then + statusbar:ActionsUpdated() + end +end + +-- Messages + +function OnMsg.LoadGame() + -- Open caves based on the loaded map var + if CavesOpened then + EditorSetCavesOpen(true) + end +end + +function OnMsg.ChangeMapDone(map) + if map ~= "" then + EditorCaveBoxes = false + EditorSetCavesOpen(false) + end +end + +function OnMsg.GameExitEditor() + EditorSetCavesOpen(false) +end + +----- Cave brush + +local hash_color_multiplier = 10 -- for a more diverse color palette +local function RandCaveColor(idx) + return RandColor(xxhash(idx * hash_color_multiplier)) +end + +DefineClass.XCaveBrush = { + __parents = { "XMapGridAreaBrush" }, + + GridName = "CaveGrid", + + ToolSection = "Terrain", + ToolTitle = "Caves", + Description = { + "Defines the cave areas on the map.", + "( to select & lock areas)\n" .. + "( to select entire caves)\n" .. + "( to get cave value at cursor)" + }, + ActionSortKey = "23", + ActionIcon = "CommonAssets/UI/Editor/Tools/Caves.tga", + ActionShortcut = "C", +} + +function XCaveBrush:GetGridPaletteItems() + local white = "CommonAssets/System/white.dds" + local items = {} + local grid_values = editor.GetUniqueGridValues(_G[self.GridName], MapGridTileSize(self.GridName), const.MaxCaves) + local max_val = 0 + + table.insert(items, { text = "Blank", value = 0, image = white, color = RGB(0, 0, 0) }) + for _, val in ipairs(grid_values) do + if val ~= 0 then + table.insert(items, { + text = string.format("Cave %d", val), + value = val, + image = white, + color = RandCaveColor(val) + }) + if max_val < val then + max_val = val + end + end + end + + table.insert(items, { text = "New Cave...", value = max_val + 1, image = white, color = RandCaveColor(max_val + 1) }) + return items +end + +function XCaveBrush:GetPalette() + local palette = { [0] = RGB(0, 0, 0) } + for i = 1, 254 do + palette[i] = RandCaveColor(i) + end + palette[255] = RGBA(255, 255, 255, 128) + return palette +end + +function OnMsg.OnMapGridChanged(name, bbox) + local brush = XEditorGetCurrentTool() + if name == "CaveGrid" and IsKindOf(brush, "XCaveBrush") then + if CavesOpened then + table.insert(EditorCaveBoxes, bbox) + terrain.SetTerrainHolesBaseGridRect(CaveGrid, bbox) + end + end +end diff --git a/CommonLua/Editor/XEditor/XChangeHeightBrush.lua b/CommonLua/Editor/XEditor/XChangeHeightBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..0fde164e1d67ad553e61048992b4dc83d619b7cf --- /dev/null +++ b/CommonLua/Editor/XEditor/XChangeHeightBrush.lua @@ -0,0 +1,151 @@ +DefineClass.XChangeHeightBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + editor = "number", slider = true, persisted_setting = true, auto_select_all = true, + { id = "ClampToLevels", name = "Clamp to levels", editor = "bool", default = true, no_edit = not const.SlabSizeZ }, + { id = "SquareBrush", name = "Square brush", editor = "bool", default = true, no_edit = not const.SlabSizeZ }, + { id = "Height", default = 10 * guim, scale = "m", min = guic, max = 100 * guim, step = guic }, -- see GetPropertyMetadata + { id = "Smoothness", default = 75, scale = "%", min = 0, max = 100, no_edit = function(self) return self:IsCursorSquare() end }, + { id = "DepositionMode", name = "Deposition mode", editor = "bool", default = true }, + { id = "Strength", default = 50, scale = "%", min = 10, max = 100, no_edit = function(self) return not self:GetDepositionMode() end }, + { id = "RegardWalkables", name = "Limit to walkables", editor = "bool", default = false }, + }, + + ToolSection = "Height", + Description = { + "Use deposition mode to gradually add/remove height as you drag the mouse.", + "( to align to world directions)\n( for inverse operation)", + }, + + mask_grid = false, +} + +function XChangeHeightBrush:Init() + local w, h = terrain.HeightMapSize() + self.mask_grid = NewComputeGrid(w, h, "F") +end + +function XChangeHeightBrush:Done() + editor.ClearOriginalHeightGrid() + self.mask_grid:free() +end + +if const.SlabSizeZ then -- modify Size/Height properties depending on SquareBrush/ClampToLevels properties + function XChangeHeightBrush:GetPropertyMetadata(prop_id) + local sizex, sizez = const.SlabSizeX, const.SlabSizeZ + if prop_id == "Size" and self:IsCursorSquare() then + local help = string.format("1 tile = %sm", _InternalTranslate(FormatAsFloat(sizex, guim, 2))) + return { id = "Size", name = "Size (tiles)", help = help, default = sizex, scale = sizex, min = sizex, max = 100 * sizex, step = sizex, editor = "number", slider = true, persisted_setting = true, auto_select_all = true, } + end + if prop_id == "Height" and self:GetClampToLevels() then + local help = string.format("1 step = %sm", _InternalTranslate(FormatAsFloat(sizez, guim, 2))) + return { id = "Height", name = "Height (steps)", help = help, default = sizez, scale = sizez, min = sizez, max = self.cursor_max_tiles * sizez, step = sizez, editor = "number", slider = true, persisted_setting = true, auto_select_all = true, } + end + return table.find_value(self.properties, "id", prop_id) + end + + function XChangeHeightBrush:GetProperties() + local props = {} + for _, prop in ipairs(self.properties) do + props[#props + 1] = self:GetPropertyMetadata(prop.id) + end + return props + end + + function XChangeHeightBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "SquareBrush" or prop_id == "ClampToLevels" then + self:SetSize(self:GetSize()) + self:SetHeight(self:GetHeight()) + end + end +end + +function XChangeHeightBrush:StartDraw(pt) + XEditorUndo:BeginOp{ height = true, name = "Changed height" } + editor.StoreOriginalHeightGrid(true) -- true = use for GetTerrainCursor + self.mask_grid:clear() +end + +function XChangeHeightBrush:Draw(pt1, pt2) + local inner_radius, outer_radius = self:GetCursorRadius() + local op = self:GetDepositionMode() and "add" or "max" + local strength = self:GetDepositionMode() and self:GetStrength() / 5000.0 or 1.0 + local bbox = editor.DrawMaskSegment(self.mask_grid, pt1, pt2, inner_radius, outer_radius, op, strength, strength, self:IsCursorSquare()) + editor.AddToHeight(self.mask_grid, self:GetCursorHeight() / const.TerrainHeightScale, bbox) + + if const.SlabSizeZ and self:GetClampToLevels() then + editor.ClampHeightToLevels(config.TerrainHeightSlabOffset, const.SlabSizeZ, bbox, self.mask_grid) + end + if self:GetRegardWalkables() then + editor.ClampHeightToWalkables(bbox) + end + Msg("EditorHeightChanged", false, bbox) +end + +function XChangeHeightBrush:EndDraw(pt1, pt2, invalid_box) + local _, outer_radius = self:GetCursorRadius() + local bbox = editor.GetSegmentBoundingBox(pt1, pt2, outer_radius, self:IsCursorSquare()) + Msg("EditorHeightChanged", true, bbox) + XEditorUndo:EndOp(nil, invalid_box) +end + +function XChangeHeightBrush:OnShortcut(shortcut, source, controller_id, repeated, ...) + if XEditorBrushTool.OnShortcut(self, shortcut, source, controller_id, repeated, ...) then + return "break" + end + + local key = string.gsub(shortcut, "^Shift%-", "") + local divisor = terminal.IsKeyPressed(const.vkShift) and 10 or 1 + if key == "+" or key == "Numpad +" then + self:SetHeight(self:GetHeight() + (self:GetClampToLevels() and const.SlabSizeZ or guim / divisor)) + return "break" + elseif key == "-" or key == "Numpad -" then + self:SetHeight(self:GetHeight() - (self:GetClampToLevels() and const.SlabSizeZ or guim / divisor)) + return "break" + end + + if not repeated and (shortcut == "Ctrl" or shortcut == "-Ctrl") then + editor.StoreOriginalHeightGrid(true) + self.mask_grid:clear() + end +end + +function XChangeHeightBrush:GetCursorRadius() + local inner_size = self:GetSize() * (100 - self:GetSmoothness()) / 100 + return inner_size / 2, self:GetSize() / 2 +end + +function XChangeHeightBrush:GetCursorHeight() + local ctrlKey = terminal.IsKeyPressed(const.vkControl) + return ctrlKey ~= self.LowerTerrain and -self:GetHeight() or self:GetHeight() +end + +function XChangeHeightBrush:IsCursorSquare() + return const.SlabSizeZ and self:GetSquareBrush() +end + +function XChangeHeightBrush:GetCursorExtraFlags() + return self:IsCursorSquare() and const.mfTerrainHeightFieldSnapped or 0 +end + +function XChangeHeightBrush:GetCursorColor() + return self:IsCursorSquare() and RGB(16, 255, 16) or RGB(255, 255, 255) +end + +DefineClass.XRaiseHeightBrush = { + __parents = { "XChangeHeightBrush" }, + LowerTerrain = false, + ToolTitle = "Raise height", + ActionSortKey = "09", + ActionIcon = "CommonAssets/UI/Editor/Tools/Raise.tga", + ActionShortcut = "H", +} + +DefineClass.XLowerHeightBrush = { + __parents = { "XChangeHeightBrush" }, + LowerTerrain = true, + ToolTitle = "Lower height", + ActionSortKey = "10", + ActionIcon = "CommonAssets/UI/Editor/Tools/Lower.tga", + ActionShortcut = "L", +} diff --git a/CommonLua/Editor/XEditor/XColorizeTerrainBrush.lua b/CommonLua/Editor/XEditor/XColorizeTerrainBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..ddf70cb3fa5d22b62a9689fc7f78f5f1b2479607 --- /dev/null +++ b/CommonLua/Editor/XEditor/XColorizeTerrainBrush.lua @@ -0,0 +1,245 @@ +DefineClass.TerrainColor = { + __parents = { "Preset" }, + properties = { + { id = "value", editor = "number", default = 0, }, + }, + GedEditor = false, +} + +function TerrainColor:PostLoad() + if self.value == 0 then + local r, g, b = self.id:match("^hold Ctrl to draw over select terrain)\n" .. + "(use default color to clear colorization)\n" .. + "( to get the color at a point)" + }, + ActionSortKey = "27", + ActionIcon = "CommonAssets/UI/Editor/Tools/TerrainColorization.tga", + ActionShortcut = "Alt-Q", + + mask_grid = false, + init_grid = false, + pattern_grid = false, + start_pt = false, + init_terrain_type = false, + only_on_type = false, +} + +function XColorizeTerrainBrush:Init() + local w, h = terrain.ColorizeMapSize() + self.mask_grid = NewComputeGrid(w, h, "F") + self:GatherPattern() +end + +function XColorizeTerrainBrush:Done() + self.mask_grid:free() + if self.pattern_grid then + self.pattern_grid:free() + end +end + +function XColorizeTerrainBrush:OnMouseButtonDown(pt, button) + if button == "L" and terminal.IsKeyPressed(const.vkAlt) then + local grid = editor.GetGridRef("colorize") + local value = grid:get(GetTerrainCursor() / const.ColorizeTileSize) + local r, g, b, a = GetRGBA(value) + self:SetColor(RGB(r, g, b)) + ObjModified(self) + return "break" + end + return XEditorBrushTool.OnMouseButtonDown(self, pt, button) +end + +function XColorizeTerrainBrush:StartDraw(pt) + XEditorUndo:BeginOp{colorize = true, name = "Changed terrain colorization"} + self.mask_grid:clear() + self.init_grid = terrain.GetColorizeGrid() + self.start_pt = pt + self.init_terrain_type = terrain.GetTerrainType(pt) + if terminal.IsKeyPressed(const.vkControl) then self.only_on_type = true end +end + +function XColorizeTerrainBrush:Draw(pt1, pt2) + local inner_radius, outer_radius = self:GetCursorRadius() + editor.SetColorizationInSegment(self.mask_grid, self.init_grid, self.start_pt, pt1, pt2, self:GetBlending(), inner_radius, outer_radius, + self:GetColor(), self:GetRoughness(), self.init_terrain_type, self.only_on_type, + self.pattern_grid or nil, self:GetPatternScale()) +end + +function XColorizeTerrainBrush:EndDraw(pt1, pt2, invalid_box) + self.init_grid:free() + self.start_pt = false + self.init_terrain_type = false + self.only_on_type = false + XEditorUndo:EndOp(nil, GrowBox(invalid_box, const.ColorizeTileSize / 2)) -- the box is extended internally in editor.SetColorizationInSegment +end + +function XColorizeTerrainBrush:GatherPattern() + if self.pattern_grid then + self.pattern_grid:free() + self.pattern_grid = false + end + self.pattern_grid = ImageToGrids(self:GetPattern(), false) + if not self.pattern_grid then + self:SetPattern(self:GetDefaultPropertyValue("Pattern")) + self.pattern_grid = ImageToGrids(self:GetPattern(), false) + end +end + +function XColorizeTerrainBrush:GetCursorRadius() + local inner_size = self:GetSize() * (100 - self:GetSmoothness()) / 100 + return inner_size / 2, self:GetSize() / 2 +end + +function XColorizeTerrainBrush:OnEditorSetProperty(prop_id) + if prop_id == "ColorPalette" then + local preset = Presets.TerrainColor.Default[self:GetColorPalette()] + local color = preset and preset.value + if color then + self:SetColor(color) + end + elseif prop_id == "Pattern" then + self:GatherPattern() + elseif prop_id == "Color" then + self:SetColorPalette(false) -- clear the selected color + end +end + +function XColorizeTerrainBrush:AddColorToPalette() + local name = WaitInputText(nil, "Name Your Color:") + local r, g, b = GetRGB(self:GetColor()) + name = name and string.format("%s", r, g, b, name) + if self:GetColor() and name and not table.find(Presets.TerrainColor.Default, "id", name) then + local color = TerrainColor:new() + color:SetGroup("Default") + color:SetId(name) + color.value = self:GetColor() + TerrainColor:SaveAll("force") + ObjModified(self) + end +end + +function XColorizeTerrainBrush:RemoveColorFromPalette() + local name = self:GetColorPalette() + local index = table.find(Presets.TerrainColor.Default, "id", name) + if index then + Presets.TerrainColor.Default[index]:delete() + end + TerrainColor:SaveAll("force") + ObjModified(self) +end + +function XColorizeTerrainBrush:RenamePaletteColor() + local name = self:GetColorPalette() + self:SetColor(Presets.TerrainColor.Default[name].value) + self:RemoveColorFromPalette() + self:AddColorToPalette() +end + +DefineClass.XColorizeObjectsTool = { + __parents = { "XEditorBrushTool" }, + properties = { + persisted_setting = true, slider = true, + { id = "ColorizationMode", name = "Colorization Mode", editor = "text_picker", items = function() return { "Colorize", "Clear" } end, default = "Colorize", horizontal = true, }, + { id = "Affect", editor = "set", default = {}, items = function() return table.subtraction(ArtSpecConfig.Categories, {"Markers"}) end, horizontal = true, }, + { id = "HeightTreshold", name = "Height Treshold", editor = "number", min = 0 * guim, max = 100 * guim, default = 5 * guim, step = guim, scale = "m", }, + }, + + ActionSortKey = "28", + ActionIcon = "CommonAssets/UI/Editor/Tools/TerrainObjectsColorization.tga", + ActionShortcut = "Alt-W", + + ToolSection = "Colorization", + ToolTitle = "Terrain objects colorization", + Description = { + "Changes the tint of objects close to the terrain surface." + }, +} + +function XColorizeObjectsTool:Draw(pt1, pt2) + MapForEach(pt1, pt2, self:GetCursorRadius(), function (o) + local entityData = EntityData[o:GetEntity()] + local ZOverTerrain = o:GetVisualPos():z() - terrain.GetHeight(o:GetPos()) + if type(entityData) == "table" and entityData.editor_category and self:GetAffect()[entityData.editor_category] and ZOverTerrain <= self:GetHeightTreshold() then + if self:GetColorizationMode() == "Colorize" then o:SetHierarchyGameFlags(const.gofTerrainColorization) + else o:ClearHierarchyGameFlags(const.gofTerrainColorization) end + end + end) +end + +function XColorizeObjectsTool:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end + +end -- ColorizeGrid support diff --git a/CommonLua/Editor/XEditor/XDeleteObjectsTool.lua b/CommonLua/Editor/XEditor/XDeleteObjectsTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..38fee5621035829743fde53b7b53195ce0ab1db8 --- /dev/null +++ b/CommonLua/Editor/XEditor/XDeleteObjectsTool.lua @@ -0,0 +1,66 @@ +DefineClass.XDeleteObjectsTool = { + __parents = { "XEditorBrushTool", "XEditorObjectPalette" }, + properties = { + { id = "buttons", editor = "buttons", default = false, buttons = {{name = "Clear selected objects", func = "ClearSelection"}} }, + }, + + ToolTitle = "Delete objects", + Description = { + "( to delete objects on a select terrain)", + }, + ActionSortKey = "07", + ActionIcon = "CommonAssets/UI/Editor/Tools/DeleteObjects.tga", + ActionShortcut = "D", + + deleted_objects = false, + start_terrain = false, +} + +function XDeleteObjectsTool:StartDraw(pt) + SuspendPassEdits("XEditorDeleteObjects") + self.deleted_objects = {} + self.start_terrain = terminal.IsKeyPressed(const.vkControl) and terrain.GetTerrainType(pt) +end + +function XDeleteObjectsTool:Draw(pt1, pt2) + local classes = self:GetObjectClass() + local radius = self:GetCursorRadius() + local callback = function(o) + if not self.deleted_objects[o] and XEditorFilters:IsVisible(o) and o:GetGameFlags(const.gofPermanent) ~= 0 then + if not self.start_terrain or terrain.GetTerrainType(o:GetPos()) == self.start_terrain then + self.deleted_objects[o] = true + o:ClearEnumFlags(const.efVisible) + end + end + end + if #classes > 0 then + for _, class in ipairs(classes) do + MapForEach(pt1, pt2, radius, class, callback) + end + else + MapForEach(pt1, pt2, radius, callback) + end +end + +function XDeleteObjectsTool:EndDraw(pt) + if next(self.deleted_objects) then + local objs = table.validate(table.keys(self.deleted_objects)) + for _, obj in ipairs(objs) do obj:SetEnumFlags(const.efVisible) end + XEditorUndo:BeginOp({ objects = objs, name = string.format("Deleted %d objects", #objs) }) + Msg("EditorCallback", "EditorCallbackDelete", objs) + for _, obj in ipairs(objs) do obj:delete() end + XEditorUndo:EndOp() + end + ResumePassEdits("XEditorDeleteObjects") + self.deleted_objects = false +end + +function XDeleteObjectsTool:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end + +function XDeleteObjectsTool:ClearSelection() + self:SetObjectClass({}) + ObjModified(self) +end diff --git a/CommonLua/Editor/XEditor/XEditor.lua b/CommonLua/Editor/XEditor/XEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..6d04c1f59f2c127c86e7f25ca2956ef33a2624a8 --- /dev/null +++ b/CommonLua/Editor/XEditor/XEditor.lua @@ -0,0 +1,584 @@ +SetupVarTable(editor, "editor.") + +if FirstLoad then + XEditorHideTexts = false + XEditorOriginalHandleRand = HandleRand +end + +XEditorHRSettings = { + ResolutionPercent = 100, + EnablePreciseSelection = 1, + ObjectCounter = 1, + VerticesCounter = 1, + TR_MaxChunksPerFrame=100000, +} + +-- function for generating random handles (used by undo / map patches / map modding) +local handle_seed = AsyncRand() +function XEditorNewHandleRand(rand) rand, handle_seed = BraidRandom(handle_seed, rand) return rand end +function XEditorGetHandleSeed(seed) return handle_seed end +function XEditorSetHandleSeed(seed) handle_seed = seed end + +function IsEditorActive() + return editor.Active +end + +function EditorActivate() + if Platform.editor and not editor.Active and GetMap() ~= "" then + editor.Active = true + NetPauseUpdateHash("Editor") + local executeBeforeEnter = {} + Msg("GameEnteringEditor", executeBeforeEnter) + for _, fn in ipairs(executeBeforeEnter) do + fn() + end + OpenDialog("XEditor") + HandleRand = XEditorNewHandleRand + Msg("GameEnterEditor") + SuspendDesyncErrors("Editor") + end +end + +function EditorDeactivate() + if editor.Active then + editor.Active = false + local executeBeforeExit = {} + Msg("GameExitEditor", executeBeforeExit) + for _, fn in ipairs(executeBeforeExit) do + fn() + end + HandleRand = XEditorOriginalHandleRand + CloseDialog("XEditor") + NetResumeUpdateHash("Editor") + ResumeDesyncErrors("Editor") + end +end + +function OnMsg.ChangeMap(map) + if map == "" then + EditorDeactivate() + end +end + +if FirstLoad then + CameraMaxZoomSpeed = tonumber(hr.CameraMaxZoomSpeed) + CameraMaxZoomSpeedSlow = tonumber(hr.CameraMaxZoomSpeedSlow) + CameraMaxZoomSpeedFast = tonumber(hr.CameraMaxZoomSpeedFast) +end + +function OnMsg.ChangeMapDone(map) + if map == "" then return end + + local small_map_size = 1024 * guim + local map_size = Max(terrain.GetMapSize()) + local coef = Max(map_size * 1.0 / small_map_size, 1.0) + hr.CameraMaxZoomSpeed = tostring(CameraMaxZoomSpeed * coef) + hr.CameraMaxZoomSpeedSlow = tostring(CameraMaxZoomSpeedSlow * coef) + hr.CameraMaxZoomSpeedFast = tostring(CameraMaxZoomSpeedFast * coef) +end + + +----- XEditor (the main fullscreen transparent dialog for the map editor) +-- +-- This dialog's mode is the name of the XEditorTool currently active and created as a child dialog + +DefineClass.XEditor = { + __parents = { "XDialog" }, + Dock = "box", + InitialMode = "XEditorTool", + ZOrder = -1, + + mode = false, + mode_dialog = false, + play_box = false, + toolbar_context = false, + help_popup = false, +} + +function XEditor:Open(...) + local size = terrain.GetMapSize() + XChangeCameraTypeLayer:new({ CameraType = "cameraMax", CameraClampXY = size, CameraClampZ = 2 * size }, self) + XPauseLayer:new({ togglePauseDialog = false, keep_sounds = true }, self) + + -- editor mode init + XShortcutsSetMode("Editor", function() EditorDeactivate() end) + XEditorHRSettings.EnableCloudsShadow = EditorSettings:GetCloudShadows() and 1 or 0 + table.change(hr, "Editor", XEditorHRSettings) + SetSplitScreenEnabled(false, "Editor") + ShowMouseCursor("Editor") + + self.toolbar_context = { + filter_buttons = LocalStorage.FilteredCategories, + roof_visuals_enabled = LocalStorage.FilteredCategories["Roofs"], + } + OpenDialog("XEditorToolbar", XShortcutsTarget, self.toolbar_context):SetVisible(EditorSettings:GetEditorToolbar()) + OpenDialog("XEditorStatusbar", XShortcutsTarget, self.toolbar_context) + + if EditorSettings:GetShowPlayArea() then + self.play_box = PlaceTerrainBox(GetPlayBox(), nil, nil, nil, nil, "depth test") + end + + -- open editor + XDialog.Open(self, ...) + CreateRealTimeThread(XEditorUpdateHiddenTexts) + self:NotifyEditorObjects("EditorEnter") + ShowConsole(false) + + if IsKindOf(XShortcutsTarget, "XDarkModeAwareDialog") then + XShortcutsTarget:SetDarkMode(GetDarkModeSetting()) + end + + -- set up default tool + self:SetMode("XSelectObjectsTool") + editor.SetSel(SelectedObj and { SelectedObj } or Selection) + + -- open help the first time + if not LocalStorage.editor_help_shown then + self:ShowHelpText() + LocalStorage.editor_help_shown = true + SaveLocalStorage() + end +end + +function XEditor:Close(...) + -- editor mode deinit + XShortcutsSetMode("Game") + table.restore(hr, "Editor") + SetSplitScreenEnabled(true, "Editor") + HideMouseCursor("Editor") + CloseDialog("XEditorToolbar") + CloseDialog("XEditorStatusbar") + CloseDialog("XEditorRoomTools") + editor.ClearSel() + XShortcutsTarget:SetStatusTextLeft("") + XShortcutsTarget:SetStatusTextRight("") + XEditorDeleteMapButtons() + if self.help_popup and self.help_popup.window_state == "open" then + self.help_popup:Close() + end + + if IsValid(self.play_box) then + DoneObject(self.play_box) + end + + -- close editor + self:NotifyEditorObjects("EditorExit") + XDialog.Close(self, ...) +end + +function XEditor:NotifyEditorObjects(method) + SuspendPassEdits("Editor") + MapForEach(true, "EditorObject", function(obj) + if not EditorCursorObjs[obj] then + obj[method](obj) + end + end) + ResumePassEdits("Editor") +end + +function XEditor:SetMode(mode, context) + if mode == self.Mode and (context or false) == self.mode_param then return end + if self.mode_dialog then + self.mode_dialog:Close() + XPopupMenu.ClosePopupMenus() + end + + self:UpdateStatusText() + + assert(IsKindOf(g_Classes[mode], "XEditorTool")) + self.mode_dialog = OpenDialog(mode, self, context) + self.mode_param = context + self.Mode = mode + self:ActionsUpdated() + GetDialog("XEditorToolbar"):ActionsUpdated() + GetDialog("XEditorStatusbar"):ActionsUpdated() + XEditorUpdateToolbars() + if not self.mode_dialog.ToolKeepSelection then + editor.ClearSel() + end + self.mode_dialog:SetFocus() + + Msg("EditorToolChanged", mode, IsKindOf(self.mode_dialog, "XEditorPlacementHelperHost") and self.mode_dialog.helper_class) +end + +function XEditor:UpdateStatusText() + local left_status = mapdata.ModMapPath and _InternalTranslate(mapdata.DisplayName, nil, false) or mapdata.id + if config.ModdingToolsInUserMode then + local extra_row = + (not mapdata.ModMapPath and not editor.ModItem) and "Original map - saving disabled!" or + not editor.IsModdingEditor() and "Editor not opened from a mod item - saving disabled!" or + editor.ModItem:IsPacked() and "The map's mod is not unpacked for editing - saving disabled!" or + string.format("%s%s", editor.ModItem:GetEditorMessage(), Literal(editor.ModItem.mod.title)) -- a saveable mod map + left_status = string.format("%s\n%s", left_status, extra_row) + else + left_status = left_status .. (mapdata.group ~= "Default" and " (" .. mapdata.group .. ")" or "") + if EditedMapVariation then + left_status = string.format("%s\n\n" .. table.concat(description, "\n\n") + XAction:new({ + ActionId = tool.ToolTitle, + ActionMode = "Editor", + ActionName = tool.ToolTitle, + ActionTranslate = false, + ActionIcon = tool.ActionIcon, + ActionShortcut = tool.ActionShortcut, + ActionShortcut2 = tool.ActionShortcut2, + ActionSortKey = tool.ActionSortKey, + ActionToolbarSection = tool.ToolSection or action_toolbar_section, + RolloverText = rolloverText, + ActionToolbar = "XEditorToolbar", + ActionToggle = true, + ActionToggled = function(self, host) + return GetDialogMode("XEditor") == class_name + end, + ActionState = tool.ToolActionState or empty_func, + OnAction = function(self, host) + if GetDialogMode("XEditor") ~= class_name then + SetDialogMode("XEditor", class_name) + end + end, + }, XShortcutsTarget) + end + end + + for _, class_name in ipairs(ClassLeafDescendantsList("XEditorPlacementHelper")) do + local tool = g_Classes[class_name] + if tool.Title ~= "None" and tool.ActionIcon then + local shortcut = tool.ActionShortcut and " (" .. tool.ActionShortcut .. ")" or "" + local rolloverText = "" + XAction:new({ + ActionId = tool.Title, + ActionMode = "Editor", + ActionName = tool.Title, + ActionIcon = tool.ActionIcon, + ActionShortcut = tool.ActionShortcut, + ActionShortcut2 = tool.ActionShortcut2, + ActionSortKey = tool.ActionSortKey, + RolloverText = rolloverText, + ActionToolbar = "XEditorStatusbar", + ActionToggle = true, + ActionToggled = function(self, host) + local dialog = GetDialog("XSelectObjectsTool") or GetDialog("XPlaceObjectTool") + return dialog and dialog:GetHelperClass() == class_name + end, + ActionState = function(self, host) + if GetDialog("XSelectObjectsTool") and tool.InXSelectObjectsTool then return true + elseif GetDialog("XPlaceObjectTool") and tool.InXPlaceObjectTool then return true end + return "hidden" + end, + OnAction = function(self, host) + local dialog = GetDialog("XSelectObjectsTool") or GetDialog("XPlaceObjectTool") + if not dialog then + SetDialogMode("XEditor", "XSelectObjectsTool") + dialog = GetDialog("XSelectObjectsTool") + end + GetDialog(dialog.class):SetHelperClass(class_name) + end, + }, XShortcutsTarget) + end + end +end diff --git a/CommonLua/Editor/XEditor/XEditorToolSettings.lua b/CommonLua/Editor/XEditor/XEditorToolSettings.lua new file mode 100644 index 0000000000000000000000000000000000000000..403347dc0a6c057396e6f83c55f21e1a476eddb6 --- /dev/null +++ b/CommonLua/Editor/XEditor/XEditorToolSettings.lua @@ -0,0 +1,104 @@ +if FirstLoad then + XEditorToolSettingsUpdateThreads = {} +end + +----- XEditorToolSettings +-- +-- Properties with 'persisted_setting = true' are stored directly in LocalStorage (and saved when changed) +-- Generated Get/Set methods store the properties in LocalStorage[class_name][property_name] +-- 'shared_setting = true' stores in LocalStorage[property_name] instead, so that multiple tools can share the save value + +DefineClass.XEditorToolSettings = { + __parents = { "PropertyObject" }, +} + +local function resolve_default(value, default) + if value == nil then + return default + end + return value +end + +function OnMsg.ClassesPostprocess() + -- generate getters/setters that actually store in LocalStorage + ClassDescendantsList("XEditorToolSettings", function(name, classdef) + for _, prop_meta in ipairs(classdef.properties or empty_table) do + if prop_meta.editor then + local prop_id = prop_meta.id + if prop_meta.shared_setting then + rawset(classdef, "Get" .. prop_id, function(self) + local meta = self:GetPropertyMetadata(prop_id) + local store_as = prop_eval(prop_meta.store_as, self, prop_meta) or prop_meta.id + return resolve_default(LocalStorage[store_as], meta.default) + end) + rawset(classdef, "Set" .. prop_id, function(self, value) + local meta = self:GetPropertyMetadata(prop_id) + local store_as = prop_eval(prop_meta.store_as, self, prop_meta) or prop_meta.id + value = ValidateNumberPropValue(value, meta) + if resolve_default(LocalStorage[store_as], meta.default) ~= value then + LocalStorage[store_as] = value + if not IsValidThread(XEditorToolSettingsUpdateThreads[self]) then + -- Update local storage asynchronously + XEditorToolSettingsUpdateThreads[self] = CreateRealTimeThread(function() + Sleep(150) + SaveLocalStorage() + ObjModified(self) + end) + end + end + end) + rawset(classdef, prop_id, nil) + elseif prop_meta.persisted_setting then + rawset(classdef, "Get" .. prop_id, function(self) + local storage = LocalStorage[classdef.class] + local meta = self:GetPropertyMetadata(prop_id) + local store_as = prop_eval(prop_meta.store_as, self, prop_meta) or prop_meta.id + return resolve_default(storage and storage[store_as], meta.default) + end) + rawset(classdef, "Set" .. prop_id, function(self, value) + local meta = self:GetPropertyMetadata(prop_id) + local store_as = prop_eval(prop_meta.store_as, self, prop_meta) or prop_meta.id + value = ValidateNumberPropValue(value, meta) + local storage = LocalStorage[classdef.class] or {} + if resolve_default(storage and storage[store_as], meta.default) ~= value then + LocalStorage[classdef.class] = storage + storage[store_as] = value + if not IsValidThread(XEditorToolSettingsUpdateThreads[self]) then + -- Update local storage asynchronously + XEditorToolSettingsUpdateThreads[self] = CreateRealTimeThread(function() + Sleep(150) + SaveLocalStorage() + ObjModified(self) + end) + end + end + end) + rawset(classdef, prop_id, nil) + end + end + end + end) +end + +function OnMsg.ClassesBuilt() + ClassDescendants("XEditorToolSettings", function(class_name, class) + -- gather all parent properties + local parent_props = {} + for parent, _ in pairs(class.__ancestors) do + if g_Classes[parent] and not IsKindOf(g_Classes[parent], "XEditorToolSettings") then + for _, prop_meta in ipairs(g_Classes[parent].properties) do + parent_props[prop_meta.id] = true + end + end + end + + -- only leave our classes' properties + local props = class.properties or empty_table + for idx, prop_meta in ipairs(props) do + if parent_props[prop_meta.id] then + props[idx] = table.copy(prop_meta) + props[idx].no_edit = true + end + end + end) +end diff --git a/CommonLua/Editor/XEditor/XEditorUndo.lua b/CommonLua/Editor/XEditor/XEditorUndo.lua new file mode 100644 index 0000000000000000000000000000000000000000..ec984173ebc7249fc02c7063f0b5e66cccce2205 --- /dev/null +++ b/CommonLua/Editor/XEditor/XEditorUndo.lua @@ -0,0 +1,1289 @@ +XEditorCopyScriptTag = "--[[HGE place script 2.0]]" +if FirstLoad then + XEditorUndo = false + EditorMapDirty = false + EditorDirtyObjects = false + EditorPasteInProgress = false + EditorUndoPreserveHandles = false +end + +local function init_undo() + XEditorUndo = XEditorUndoQueue:new() + SetEditorMapDirty(false) +end +OnMsg.ChangeMap = init_undo +OnMsg.LoadGame = init_undo + +function OnMsg.SaveMapDone() + SetEditorMapDirty(false) +end + +function SetEditorMapDirty(dirty) + EditorMapDirty = dirty + if dirty then + Msg("EditorMapDirty") + end +end + +local s_IsEditorObjectOperation = { + ["EditorCallbackMove"] = true, + ["EditorCallbackRotate"] = true, + ["EditorCallbackScale"] = true, + ["EditorCallbackClone"] = true, +} + +function OnMsg.EditorCallback(id, objects) + if s_IsEditorObjectOperation[id] then + Msg("EditorObjectOperation", false, objects) + end +end + +-- the following object data keys are undo-related and not actual object properties +local special_props = { __undo_handle = true, class = true, op = true, after = true, eFlags = true, gFlags = true } +local ef_to_restore = const.efVisible | const.efCollision | const.efApplyToGrids +local gf_to_restore = const.gofPermanent | const.gofMirrored +local ef_to_ignore = const.efSelectable | const.efAudible +local gf_to_ignore = const.gofEditorHighlight | const.gofSolidShadow | const.gofRealTimeAnim | const.gofEditorSelection | const.gofAnimated + +DefineClass.XEditorUndoQueue = { + __parents = { "InitDone" }, + + last_handle = 0, + obj_to_handle = false, + handle_to_obj = false, + handle_remap = false, -- when pasting, store old_handle => new_handle for each pasted object here + + current_op = false, + tracked_obj_data = false, + collapse_with_previous = false, + op_depth = 0, + + undo_queue = false, + undo_index = 0, + last_save_undo_index = 0, + names_index = 1, + names_to_queue_idx_map = false, + watch_thread = false, + undoredo_in_progress = false, + update_collections_thread = false, +} + +function XEditorUndoQueue:Init() + self.obj_to_handle = {} + self.handle_to_obj = {} + self.undo_queue = {} + self.names_to_queue_idx_map = {} + self.watch_thread = CreateRealTimeThread(function() + while true do + while self.op_depth == 0 or terminal.desktop:GetMouseCapture() do + Sleep(250) + end + --assert(false, "Undo error detected - please report this and the last thing you did in the editor!") + self.op_depth = 0 + Sleep(250) + end + end) +end + +function XEditorUndoQueue:Done() + DeleteThread(self.watch_thread) +end + + +----- Handles + +function XEditorUndoQueue:GetUndoRedoHandle(obj) + assert(type(obj) == "table" and (obj.class or obj.Index)) + local handle = self.obj_to_handle[obj] + if not handle then + handle = self.last_handle + 1 + self.last_handle = handle + self.obj_to_handle[obj] = handle + self.handle_to_obj[handle] = obj + end + return handle +end + +function XEditorUndoQueue:GetUndoRedoObject(handle, is_collection, assign_specific_object) + if not handle then return false end + + -- support for pasting objects + local obj = self.handle_to_obj[handle] + if self.handle_remap then + local new_handle = self.handle_remap[handle] + if new_handle then + return self.handle_to_obj[new_handle] + else + new_handle = assign_specific_object and self.obj_to_handle[assign_specific_object] or self.last_handle + 1 + + self.handle_remap[handle] = new_handle + handle = new_handle + self.last_handle = Max(self.last_handle, handle) + obj = nil + end + end + + if not obj then + obj = assign_specific_object or {} + self.handle_to_obj[handle] = obj + self.obj_to_handle[obj] = handle + if is_collection then + Collection.SetIndex(obj, -1) + end + end + return obj +end + +function XEditorUndoQueue:UndoRedoHandleClear(handle) + handle = self.handle_remap and self.handle_remap[handle] or handle + local obj = self.handle_to_obj[handle] + self.handle_to_obj[handle] = nil + self.obj_to_handle[obj] = nil +end + + +----- Storing/restoring object properties + +local function store_objects_prop(value) + if not value then return false end + local ret = {} + for k, v in pairs(value) do + ret[k] = IsValid(v) and XEditorUndo:GetUndoRedoHandle(v) or store_objects_prop(v) + end + return ret +end + +local function restore_objects_prop(value) + if not value then return false end + local ret = {} + for k, v in pairs(value) do + ret[k] = type(v) == "table" and restore_objects_prop(v) or XEditorUndo:GetUndoRedoObject(v) + end + return ret +end + +function XEditorUndoQueue:ProcessPropertyValue(obj, id, prop_meta, value) + local editor = prop_meta.editor + if id == "CollectionIndex" then + return self:GetUndoRedoHandle(obj:GetCollection()) + elseif editor == "objects" then + return store_objects_prop(value) + elseif editor == "object" then + return self:GetUndoRedoHandle(value) + elseif editor == "nested_list" then + local ret = value and {} + for i, o in ipairs(value) do ret[i] = o:Clone() end + return ret + elseif editor == "nested_obj" or editor == "script" then + return value and value:Clone() + elseif editor == "grid" and value then + return value:clone() + else + return value + end +end + +function XEditorUndoQueue:GetObjectData(obj) + local data = { + __undo_handle = self:GetUndoRedoHandle(obj), + class = obj.class + } + for _, prop_meta in ipairs(obj:GetProperties()) do + local id = prop_meta.id + assert(not special_props[id]) + local value = obj:GetProperty(id) + if (EditorUndoPreserveHandles and id == "Handle") or not obj:ShouldCleanPropForSave(id, prop_meta, value) then + data[id] = self:ProcessPropertyValue(obj, id, prop_meta, value) + end + end + data.eFlags = band(obj:GetEnumFlags(), ef_to_restore) + data.gFlags = band(obj:GetGameFlags(), gf_to_restore) + return data +end + +local function get_flags_xor(flags1, flags2, flagsList) + local result = {} + for i, flag in pairs(flagsList) do + if flag ~= "gofDirtyTransform" and flag ~= "gofDirtyVisuals" and flag ~= "gofEditorSelection" then + if band(flags1, shift(1, i - 1)) ~= band(flags2, shift(1, i - 1)) then + table.insert(result, flag.name or flag) + end + end + end + return table.concat(result, ", ") +end + +function XEditorUndoQueue:RestoreObject(obj, obj_data, prev_data) + if not IsValid(obj) then return end + assert(obj.class ~= "CollectionsToHideContainer") + for _, prop_meta in ipairs(obj:GetProperties()) do + local id = prop_meta.id + local value = obj_data[id] + if value == nil and prev_data and prev_data[id] then + value = obj:GetDefaultPropertyValue(id, prop_meta) + end + if value ~= nil then + local prop = prop_meta.editor + if id == "CollectionIndex" then + if value == 0 then + CObject.SetCollectionIndex(obj, 0) + else + local collection = self:GetUndoRedoObject(value, "Collection") + if obj_data.class == "Collection" and collection.Index == editor.GetLockedCollectionIdx() then + editor.AddToLockedCollectionIdx(obj.Index) + end + CObject.SetCollectionIndex(obj, collection.Index) + end + elseif prop == "objects" then + obj:SetProperty(id, restore_objects_prop(value)) + elseif prop == "object" then + obj:SetProperty(id, self:GetUndoRedoObject(value)) + elseif prop == "nested_list" then + local objects = {} + for i, o in ipairs(value) do objects[i] = o:Clone() end + obj:SetProperty(id, value and objects) + elseif prop == "nested_obj" then + obj:SetProperty(id, value and value:Clone()) + elseif id == "Handle" then + if EditorUndoPreserveHandles and not EditorPasteInProgress then + -- resolve handle collisions, e.g. from multiple applied map patches + local start, size = GetHandlesAutoLimits() + while HandleToObject[value] do + value = value + 1 + if value >= start + size then + value = start + end + end + obj:SetProperty(id, value) + end + else + obj:SetProperty(id, value) + end + end + end + if obj_data.eFlags then + obj:SetEnumFlags(obj_data.eFlags) obj:ClearEnumFlags(band(bnot(obj_data.eFlags), ef_to_restore)) + obj:SetGameFlags(obj_data.gFlags) obj:ClearGameFlags(band(bnot(obj_data.gFlags), gf_to_restore)) + obj:ClearGameFlags(const.gofEditorHighlight) + end + return obj +end + + +----- Undo/redo operations +-- +-- Capturing undo data works using the concept of tracked objects. Start capturing an undo operation +-- by calling BeginOp; complete it with EndOp; in-between add extra tracked objects via StartTracking. +-- +-- Objects are assigned "undo handles" to keep their identity between undo & redo operations that might +-- delete them. The tracked objects' initial states are kept by handle in 'tracked_obj_data'. For newly +-- created objects the value kept will be 'false'. +-- +-- Complex objects such as Volumes/Rooms are handles via the concept of "children" objects (e.g. Slab). +-- Whenever the an object is tracked, we get related objects via GetEditorRelatedObjects/GetEditorParentObject. +-- The state of those object also get tracked automatically. +-- +-- BeginOp takes a table of settings to provide it with information about what needs to be tracked: +-- 1. Pass a list of objects in the "objects" field. +-- 2. Mark any grid that will be changed as a "true" field in settings, e.g. { terrain_type = true }. +-- 3. Pass the operation name for the list of operations combo as e.g. { name = "Deleted objects" }. +-- +-- EndOp only takes a list of extra objects to be tracked - usually newly created objects during the operation. +-- +-- BeginOp/EndOp calls can be nested - a new undo operation is created and pushed into the undo queue when +-- the last EndOp call balances out with BeginOp calls. This allows for easy tracking of editor operations +-- that use other operations to complete, or merging different editor operations into a single one. +-- +-- The editor's copy/paste & map patching funcionalities uses the same mechanism for capturing/storing objects. +-- When pasting or applying a patch, newly created objects are assigned new handles via a handle remapping +-- mechanism to prevent collisions with existing handles (see handle_remap member). +-- +-- The 'data' member of ObjectsEditOp is a single table with entries for each affected object in order: +-- { op = "delete", __undo_handle = 1, ... }, +-- { op = "create", __undo_handle = 1, ... }, +-- { op = "update", __undo_handle = 1, after = { ... }, ... }, + +local function add_child_objects(objects, method, param) + local added = {} + for _, obj in ipairs(objects) do + added[obj] = true + end + for _, obj in ipairs(objects) do + for _, related in ipairs(obj[method or "GetEditorRelatedObjects"](obj, param)) do + if IsValid(related) and not added[related] then + objects[#objects + 1] = related + added[related] = true + end + end + end +end + +local function add_parent_objects(objects, for_copy, locked_collection) + local added = {} + for _, obj in ipairs(objects) do + added[obj] = true + end + local i = 1 + while i <= #objects do + local obj = objects[i] + local parent = obj:GetEditorParentObject() + if not for_copy and IsValid(parent) and not added[parent] then + objects[#objects + 1] = parent + added[parent] = true + end + local collection = obj:GetCollection() + if IsValid(collection) and collection ~= locked_collection and not added[collection] then + objects[#objects + 1] = collection + added[collection] = true + end + i = i + 1 + end +end + +function XEditorUndoQueue:TrackInternal(objects, idx, created) + local data = self.tracked_obj_data + assert(data) -- tracking an object is only possible after :BeginOp is called to create an undo operation + if not data then return end + for i = idx, #objects do + local obj = objects[i] + local handle = self:GetUndoRedoHandle(obj) + if data[handle] == nil then + data[handle] = not created and self:GetObjectData(obj) + end + end +end + +function XEditorUndoQueue:StartTracking(objects, created, omit_children) + objects = table.copy_valid(objects) + for idx, obj in ipairs(objects) do + assert(obj.class ~= "CollectionsToHideContainer") + end + if #objects == 0 then return end + if not omit_children then + add_child_objects(objects) + end + self:TrackInternal(objects, 1, created) + + local start_idx = #objects + 1 + add_parent_objects(objects) + self:TrackInternal(objects, start_idx) -- non-explicit parents are assumed to have existed before the operation + + Msg("EditorObjectOperation", false, objects) + EditorDirtyObjects = table.union(objects, table.validate(EditorDirtyObjects)) +end + +function XEditorUndoQueue:BeginOp(settings) + if self.undoredo_in_progress then return end + + settings = settings or empty_table + self.current_op = self.current_op or { clipboard = settings.clipboard } + self.tracked_obj_data = self.tracked_obj_data or {} + self.op_depth = self.op_depth + 1 + if self.op_depth == 1 then + self.collapse_with_previous = settings.collapse_with_previous + EditorDirtyObjects = empty_table + end + + PauseInfiniteLoopDetection("Undo") + + if settings.objects then + self:StartTracking(settings.objects) + end + + -- store the "before" state of selection and edited grids + local op = self.current_op + if not op.selection then + op.selection = SelectionEditOp:new() + for i, obj in ipairs(editor.GetSel()) do + op.selection.before[i] = self:GetUndoRedoHandle(obj) + end + end + for _, grid in ipairs(editor.GetGridNames()) do + if settings[grid] and not op[grid] then + op[grid] = GridEditOp:new{ name = grid, before = editor.GetGrid(grid) } + end + end + + op.name = op.name or settings.name + ResumeInfiniteLoopDetection("Undo") +end + +-- collections must be at the front of undo data; collections need to be created first +-- and allocate/restore their indexes before objects are added to them via SetCollection +local function add_obj_data(data, obj_data) + if obj_data then + if obj_data.class == "Collection" then + table.insert(data, 1, obj_data) + else + data[#data + 1] = obj_data + end + end +end + +local function is_nop(obj_data) + local after = obj_data.after + for k, v in pairs(after) do + if not special_props[k] and not CompareValues(obj_data[k], v) then + return false + end + end + for k in pairs(obj_data) do + if after[k] == nil then + return false + end + end + return true +end + +function XEditorUndoQueue:OpCaptureInProgress() + return self.op_depth > 0 +end + +function XEditorUndoQueue:AssertOpCapture() + return not IsEditorActive() or IsChangingMap() or XEditorUndo.undoredo_in_progress or XEditorUndo:OpCaptureInProgress() +end + +function XEditorUndoQueue:EndOpInternal(objects, bbox) + assert(self:OpCaptureInProgress(), "Unbalanced calls between BeginOp and EndOp") + if not self:OpCaptureInProgress() then return end + + PauseInfiniteLoopDetection("Undo") + + if objects then + self:StartTracking(objects, "created") + end + + -- messages for final cleanup when an editor operation involving objects ends + if self.op_depth == 1 then + -- keeping op_depth == 1 at this point prevents an infinite loop if the Msgs invoke undo ops + if next(self.tracked_obj_data) then + Msg("EditorObjectOperation", true, table.validate(EditorDirtyObjects)) + Msg("EditorObjectOperationEnding") + end + EditorDirtyObjects = false + end + self.op_depth = self.op_depth - 1 + + -- finalize operation when the BeginOp/EndOp calls become balanced + if self.op_depth == 0 then + local edit_operation = self.current_op + + -- drop selection op if selection is the same + if edit_operation.selection then + local selDiff = #editor.GetSel() ~= #edit_operation.selection.before + for i, obj in ipairs(editor.GetSel()) do + edit_operation.selection.after[i] = self:GetUndoRedoHandle(obj) + if edit_operation.selection.after[i] ~= edit_operation.selection.before[i] then + selDiff = true + end + end + if not selDiff then + edit_operation.selection:delete() + edit_operation.selection = nil + end + end + + -- calculate grid diffs + for _, grid in ipairs(editor.GetGridNames()) do + local grid_op = edit_operation[grid] + if grid_op then + local before, after = grid_op.before, editor.GetGrid(grid) + -- Find the boxes where there are differences between the two grids and save them in the op's array part + local diff_boxes = editor.GetGridDifferenceBoxes(grid, after, before, bbox) + if diff_boxes then + for idx, box in ipairs(diff_boxes) do + local change = { + box = box, + before = editor.GetGrid(grid, box, before), + after = editor.GetGrid(grid, box, after), + } + table.insert(grid_op, change) + end + end + before:free() + after:free() + grid_op.before = nil + end + end + + -- capture the "after" data and create the object undo operation + self.handle_remap = nil + if next(self.tracked_obj_data) then + local data = {} + for handle, obj_data in sorted_pairs(self.tracked_obj_data) do + local obj = self.handle_to_obj[handle] + if obj_data then + if IsValid(obj) then + obj_data.after = self:GetObjectData(obj) + obj_data.op = "update" + if is_nop(obj_data) then + obj_data = nil + end + else + if self.handle_to_obj[handle] then + self:UndoRedoHandleClear(handle) + end + obj_data.op = "delete" + end + elseif IsValid(obj) then + obj_data = self:GetObjectData(obj) + obj_data.op = "create" + end + add_obj_data(data, obj_data) + end + edit_operation.objects = ObjectsEditOp:new{ data = data } + end + + self.current_op = false + self.tracked_obj_data = false + ResumeInfiniteLoopDetection("Undo") + return edit_operation + end + + ResumeInfiniteLoopDetection("Undo") +end + +function XEditorUndoQueue:EndOp(objects, bbox) + if self.undoredo_in_progress then return end + + local edit_operation = self:EndOpInternal(objects, bbox) + if edit_operation then + self:AddEditOp(edit_operation) + if self.collapse_with_previous and self:CanMergeOps(self.undo_index - 1, self.undo_index, "same_names") then + self:MergeOps(self.undo_index - 1, self.undo_index) + end + self.collapse_with_previous = false + + self:UpdateOnOperationEnd(edit_operation) + end +end + +function XEditorUndoQueue:AddEditOp(edit_operation) + self.undo_index = self.undo_index + 1 + self.undo_queue[self.undo_index] = edit_operation + for i = self.undo_index + 1, #self.undo_queue do + self.undo_queue[i] = nil + end +end + +local allowed_keys = { name = true, objects = true } +function XEditorUndoQueue:CanMergeOps(idx1, idx2, same_names) + if idx1 < 0 then return end + local name = same_names and self.undo_queue[idx1].name + for idx = idx1, idx2 do + local edit_op = self.undo_queue[idx] + for k in pairs(edit_op) do + if not allowed_keys[k] then return end + end + if name and edit_op.name ~= name then return end + end + return true +end + +function XEditorUndoQueue:MergeOps(idx1, idx2, name) + local before, after = {}, {} -- these store object data by handle, just like in tracked_obj_data + for idx = idx1, idx2 do + local edit_op = self.undo_queue[idx] + local objs_data = edit_op and edit_op.objects and edit_op.objects.data + for _, obj_data in ipairs(objs_data) do + local op = obj_data.op + local handle = obj_data.__undo_handle + if before[handle] == nil then + before[handle] = op ~= "create" and obj_data or false + end + after[handle] = op == "create" and obj_data or op == "update" and obj_data.after or false + end + end + + local data = {} + for handle, obj_data in sorted_pairs(before) do + if not obj_data then + obj_data = after[handle] + if obj_data then + obj_data.op = "create" + end + elseif after[handle] then + obj_data.after = after[handle] + obj_data.op = "update" + else + obj_data.op = "delete" + end + add_obj_data(data, obj_data) + end + + name = name or self.undo_queue[idx1].name + for idx = idx1, #self.undo_queue do + self.undo_queue[idx] = nil + end + table.insert(self.undo_queue, { name = name, objects = ObjectsEditOp:new{ data = data }}) + self.undo_index = idx1 +end + +function XEditorUndoQueue:UndoRedo(op_type, update_map_hashes) + local undo = op_type == "undo" + local edit_op = undo and self.undo_queue[self.undo_index] or self.undo_queue[self.undo_index + 1] + if not edit_op then return end + self.undo_index = undo and self.undo_index - 1 or self.undo_index + 1 + if self.undo_index < 0 or self.undo_index > #self.undo_queue then + self.undo_index = Clamp(self.undo_index, 0, #self.undo_queue) + return + end + + self.undoredo_in_progress = true + SuspendPassEditsForEditOp(edit_op.objects and edit_op.objects.data or empty_table) + PauseInfiniteLoopDetection("XEditorEditOps") + SuspendObjModified("XEditorEditOps") + for _, op in sorted_pairs(edit_op) do + if IsKindOf(op, "EditOp") then + procall(undo and op.Undo or op.Do, op) + if update_map_hashes then + op:UpdateMapHashes() + end + end + end + if edit_op.clipboard then + CopyToClipboard(edit_op.clipboard) + end + self:UpdateOnOperationEnd(edit_op) + ResumeObjModified("XEditorEditOps") + ResumeInfiniteLoopDetection("XEditorEditOps") + ResumePassEditsForEditOp() + self.undoredo_in_progress = false +end + +function XEditorUndoQueue:UpdateOnOperationEnd(edit_op) + for key in pairs(edit_op) do + if key ~= "selection" and key ~= "clipboard" then + SetEditorMapDirty(true) + end + end + XEditorUpdateToolbars() -- doesn't update the toolbar if it was updated soon + + -- these are okay to be delayed by 1 sec. + if edit_op.objects and not self.update_collections_thread then + self.update_collections_thread = CreateRealTimeThread(function() + Sleep(1000) + UpdateCollectionsEditor() + self.update_collections_thread = false + end) + end +end + + +----- Editor statusbar combo + +function XEditorUndoQueue:GetOpNames(plain) + local names = { "No operations" } + local idx_map = { 0 } + local cur_op_passed, cur_op_idx = false, false + for i = 1, #self.undo_queue do + local cur = self.undo_queue[i] and self.undo_queue[i].name + cur_op_passed = cur_op_passed or i == self.undo_index + 1 + if cur then + local prev = names[#names] + if prev and string.ends_with(prev, cur) and not cur_op_passed then + local n = (tonumber(string.match(prev, "%s(%d+)[^%s%d]")) or 1) + 1 + cur = string.format("%d. %dX %s", #names - 1, n, cur) + names[#names] = cur + idx_map[#idx_map] = i + else + if cur_op_passed then + cur_op_idx = #idx_map + cur_op_passed = false + end + table.insert(names, string.format("%d. %s", #names, cur)) + table.insert(idx_map, i) + end + end + end + + if not plain then + self.names_to_queue_idx_map = idx_map + self.names_index = cur_op_idx or Max(#idx_map, 1) + for i = self.names_index + 1, #names do + names[i] = "" .. names[i] .. "" + end + end + return names +end + +function XEditorUndoQueue:GetCurrentOpNameIdx() + return self.names_index +end + +function XEditorUndoQueue:RollToOpIndex(new_index) + if new_index ~= self.names_index then + local new_undo_index = self.names_to_queue_idx_map[new_index] + local op = self.undo_index > new_undo_index and "undo" or "redo" + while self.undo_index ~= new_undo_index do + self:UndoRedo(op) + end + self.names_index = new_index + end +end + + +----- EditOp classes + +DefineClass.EditOp = { + __parents = { "InitDone" }, + StoreAsTable = true, +} + +function EditOp:Do() +end + +function EditOp:Undo() +end + +function EditOp:UpdateMapHashes() +end + +DefineClass.ObjectsEditOp = { + __parents = { "EditOp" }, + data = false, -- see comments above XEditorUndo:BeginOp for details + by_handle = false, +} + +function ObjectsEditOp:GetAffectedObjectsBefore() + local ret = {} + for _, obj_data in ipairs(self.data) do + local op = obj_data.op + if op == "delete" or op == "update" then + local handle = obj_data.__undo_handle + table.insert(ret, XEditorUndo:GetUndoRedoObject(handle)) + end + end + return ret +end + +function ObjectsEditOp:GetAffectedObjectsAfter() + local ret = {} + for _, obj_data in ipairs(self.data) do + local op = obj_data.op + if op == "create" or op == "update" then + local handle = obj_data.__undo_handle + table.insert(ret, XEditorUndo:GetUndoRedoObject(handle)) + end + end + return ret +end + +function ObjectsEditOp:EditorCallbackPreUndoRedo() + local objs = {} + for _, obj_data in ipairs(self.data) do + table.insert(objs, XEditorUndo.handle_to_obj[obj_data.__undo_handle]) -- don't use GetUndoRedoObject, it has side effects + end + Msg("EditorCallbackPreUndoRedo", table.validate(objs)) +end + +function ObjectsEditOp:Do() + self:EditorCallbackPreUndoRedo() + local newobjs = {} + local oldobjs = {} + local movedobjs = {} + for _, obj_data in ipairs(self.data) do + local op = obj_data.op + local handle = obj_data.__undo_handle + local obj = XEditorUndo:GetUndoRedoObject(handle) + if op == "delete" then + XEditorUndo:UndoRedoHandleClear(handle) + oldobjs[#oldobjs + 1] = obj + elseif op == "create" then + obj = XEditorPlaceObjectByClass(obj_data.class, obj) + XEditorUndo:RestoreObject(obj, obj_data) + newobjs[#newobjs + 1] = obj + else -- update + XEditorUndo:RestoreObject(obj, obj_data.after, obj_data) + if obj_data.after and obj_data.Pos ~= obj_data.after.Pos then + movedobjs[#movedobjs + 1] = obj + end + ObjModified(obj) + end + end + + for _, obj_data in ipairs(self.data) do + if obj_data.op ~= "delete" then + local obj = XEditorUndo:GetUndoRedoObject(obj_data.__undo_handle) + if IsValid(obj) and obj:HasMember("PostLoad") then + obj:PostLoad("undo") + end + end + end + Msg("EditorCallback", "EditorCallbackPlace", table.validate(newobjs), "undo") + Msg("EditorCallback", "EditorCallbackDelete", table.validate(oldobjs), "undo") + Msg("EditorCallback", "EditorCallbackMove", table.validate(movedobjs), "undo") + DoneObjects(oldobjs) +end + +function ObjectsEditOp:Undo() + self:EditorCallbackPreUndoRedo() + local newobjs = {} + local oldobjs = {} + local movedobjs = {} + for _, obj_data in ipairs(self.data) do + local op = obj_data.op + local handle = obj_data.__undo_handle + local obj = XEditorUndo:GetUndoRedoObject(handle) + if op == "delete" then + obj = XEditorPlaceObjectByClass(obj_data.class, obj) + XEditorUndo:RestoreObject(obj, obj_data) + newobjs[#newobjs + 1] = obj + elseif op == "create" then + XEditorUndo:UndoRedoHandleClear(handle) + oldobjs[#oldobjs + 1] = obj + else -- update + XEditorUndo:RestoreObject(obj, obj_data, obj_data.after) + if obj_data.after and obj_data.Pos ~= obj_data.after.Pos then + movedobjs[#movedobjs + 1] = obj + end + ObjModified(obj) + end + end + + for _, obj_data in ipairs(self.data) do + if obj_data.op ~= "create" then + local obj = XEditorUndo:GetUndoRedoObject(obj_data.__undo_handle) + if IsValid(obj) and obj:HasMember("PostLoad") then + obj:PostLoad("undo") + end + end + end + Msg("EditorCallback", "EditorCallbackPlace", table.validate(newobjs), "undo") + Msg("EditorCallback", "EditorCallbackDelete", table.validate(oldobjs), "undo") + Msg("EditorCallback", "EditorCallbackMove", table.validate(movedobjs), "undo") + DoneObjects(oldobjs) +end + +function ObjectsEditOp:UpdateMapHashes() + local hash = table.hash(self.data) + mapdata.ObjectsHash = xxhash(mapdata.ObjectsHash, hash) + mapdata.NetHash = xxhash(mapdata.NetHash, hash) +end + +DefineClass.SelectionEditOp = { + __parents = { "EditOp" }, + before = false, + after = false, +} + +function SelectionEditOp:Init() + self.before = {} + self.after = {} +end + +function SelectionEditOp:Do() + editor.SetSel(table.map(self.after, function(handle) return XEditorUndo:GetUndoRedoObject(handle) end)) +end + +function SelectionEditOp:Undo() + editor.SetSel(table.map(self.before, function(handle) return XEditorUndo:GetUndoRedoObject(handle) end)) +end + +DefineClass.GridEditOp = { + __parents = { "EditOp" }, + name = false, + before = false, + after = false, + box = false, +} + +function GridEditOp:Do() + for _, change in ipairs(self) do + editor.SetGrid(self.name, change.after, change.box) + if self.name == "height" then + Msg("EditorHeightChanged", true, change.box) + end + if self.name == "terrain_type" then + Msg("EditorTerrainTypeChanged", change.box) + end + end +end + +function GridEditOp:Undo() + for _, change in ipairs(self) do + editor.SetGrid(self.name, change.before, change.box) + if self.name == "height" then + Msg("EditorHeightChanged", true, change.box) + end + if self.name == "terrain_type" then + Msg("EditorTerrainTypeChanged", change.box) + end + end +end + +function GridEditOp:UpdateMapHashes() + if self.name == "height" or self.name == "terrain_type" then + for _, change in ipairs(self) do + local hash = change.after:hash() + mapdata.TerrainHash = xxhash(mapdata.TerrainHash, hash) + mapdata.NetHash = xxhash(mapdata.NetHash, hash) + end + end +end + + +----- Serialization for copy/paste/duplicate + +function XEditorSerialize(objs, root_collection) + local obj_data = {} + local org_count = #objs + + objs = table.copy(objs) + add_child_objects(objs) + add_parent_objects(objs, "for_copy", root_collection) + table.remove_value(objs, root_collection) + + Msg("EditorPreSerialize", objs) -- some debug functionalities hook up here to clear temporary visualization properties + PauseInfiniteLoopDetection("XEditorSerialize") + for idx, obj in ipairs(objs) do + local data = XEditorUndo:GetObjectData(obj) + if obj.class == "Collection" then + data.Index = -1 -- force creation of new collections indexes when pasting collections + end + if obj:GetCollection() == root_collection or XEditorSelectSingleObjects == 1 then + data.CollectionIndex = nil -- ignore collection + end + data.__original_object = idx <= org_count or nil + add_obj_data(obj_data, data) + end + ResumeInfiniteLoopDetection("XEditorSerialize") + Msg("EditorPostSerialize", objs) + return { obj_data = obj_data } +end + +function XEditorDeserialize(data, root_collection, ...) + EditorPasteInProgress = true + PauseInfiniteLoopDetection("XEditorPaste") + SuspendPassEditsForEditOp(data.obj_data) + XEditorUndo:BeginOp() + XEditorUndo.handle_remap = {} -- will force the creation of new objects when resolving handles + + local objs, orig_objs = {}, {} + for _, obj_data in ipairs(data.obj_data) do + local obj = XEditorUndo:GetUndoRedoObject(obj_data.__undo_handle) + obj = XEditorPlaceObjectByClass(obj_data.class, obj) + obj = XEditorUndo:RestoreObject(obj, obj_data) + if root_collection and not obj:GetCollection() then + obj:SetCollection(root_collection) -- paste in the currently locked collection + end + objs[#objs + 1] = obj + if obj_data.__original_object then + orig_objs[#orig_objs + 1] = obj + end + end + + -- call PostLoad; it sometimes deletes objects (e.g. wires if they are partially unattached) + for _, obj in ipairs(objs) do + if obj:HasMember("PostLoad") then + obj:PostLoad("paste") + end + end + Msg("EditorCallback", "EditorCallbackPlace", table.validate(table.copy(orig_objs)), ...) + + XEditorUndo:EndOp(table.validate(objs)) + ResumePassEditsForEditOp() + ResumeInfiniteLoopDetection("XEditorPaste") + EditorPasteInProgress = false + return orig_objs +end + +function XEditorToClipboardFormat(data) + return ValueToLuaCode(data, nil, pstr(XEditorCopyScriptTag, 32768)):str() +end + +function XEditorPaste(lua_code) + local err, data = LuaCodeToTuple(lua_code, LuaValueEnv{ GridReadStr = GridReadStr }) + if err or type(data) ~= "table" or not data.obj_data then + print("Error restoring objects:", err) + return + end + local fn = data.paste_fn or "Default" + if not XEditorPasteFuncs[fn] then + print("Error restoring objects: invalid paste function ", fn) + return + end + procall(XEditorPasteFuncs[fn], data, lua_code, "paste") +end + + +----- Interface functions for copy/paste/duplicate + +function XEditorPasteFuncs.Default(data, lua_code, ...) + XEditorUndo:BeginOp{ name = "Paste" } + + local objs = XEditorDeserialize(data, Collection.GetLockedCollection(), ...) + local place = editor.GetPlacementPoint(GetTerrainCursor()) + local offs = (place:IsValidZ() and place or place:SetTerrainZ()) - data.pivot + objs = XEditorSelectAndMoveObjects(objs, offs) + + XEditorUndo.current_op.name = string.format("Pasted %d objects", #objs) + XEditorUndo:EndOp(objs) +end + +function XEditorCopyToClipboard() + local objs = editor.GetSel("permanent") + + local data = XEditorSerialize(objs, Collection.GetLockedCollection()) + data.pivot = CenterPointOnBase(objs) + CopyToClipboard(XEditorToClipboardFormat(data)) +end + +function XEditorPasteFromClipboard() + local lua_code = GetFromClipboard(-1) + if lua_code:starts_with(XEditorCopyScriptTag) then + XEditorPaste(lua_code) + end +end + +function XEditorClone(objs) + -- cloned objects from a single selected collection are added to their current collection, as per level designers request + local locked_collection = Collection.GetLockedCollection() + local single_collection = editor.GetSingleSelectedCollection(objs) + if single_collection and #objs < MapCount("map", "collection", single_collection.Index, true) then + locked_collection = single_collection + end + return XEditorDeserialize(XEditorSerialize(objs, locked_collection), locked_collection, "clone") +end + + +----- Map patches (storing and restoring map changes from their undo operations) + +function OnMsg.SaveMapDone() + XEditorUndo.last_save_undo_index = XEditorUndo.undo_index +end + +local function redo_and_capture(name) + local op = XEditorUndo.undo_queue[XEditorUndo.undo_index + 1] + local affected = { name = name } + for key in pairs(op) do + if key ~= "name" then + affected[key] = true + end + end + if op.objects then + affected.objects = op.objects:GetAffectedObjectsBefore() + end + + XEditorUndo:BeginOp(affected) + XEditorUndo:UndoRedo("redo", IsChangingMap() and "update_map_hashes") + XEditorUndo:EndOp(op.objects and op.objects:GetAffectedObjectsAfter()) +end + +-- TODO: Only save hash_to_handle information for handles that are actually referenced in the patch +local function create_combined_patch_edit_op() + if XEditorUndo.undo_index <= XEditorUndo.last_save_undo_index then + return {} + end + + Msg("OnMapPatchBegin") + SuspendPassEditsForEditOp() + PauseInfiniteLoopDetection("XEditorCreateMapPatch") + + -- undo operations back to the last map save + local undo_index = XEditorUndo.undo_index + while XEditorUndo.undo_index ~= XEditorUndo.last_save_undo_index do + XEditorUndo:UndoRedo("undo") + end + + -- store object identifying information (for objects that are to be deleted or modified - all they have undo handles) + local hash_to_handle = {} + for handle, obj in pairs(XEditorUndo.handle_to_obj) do + if IsValid(obj) then + assert(not hash_to_handle[obj:GetObjIdentifier()]) -- hash collision, likely the data used to construct the hash is identical + hash_to_handle[obj:GetObjIdentifier()] = handle + end + end + + -- redo all undo operations, collapsing them into a single one + EditorUndoPreserveHandles = true + XEditorUndo:BeginOp() + + for idx = XEditorUndo.undo_index, undo_index - 1 do + assert(XEditorUndo.undo_index == idx) + redo_and_capture() + end + ResumeInfiniteLoopDetection("XEditorCreateMapPatch") + ResumePassEditsForEditOp() + + -- get and cleanup the combined operation + local edit_op = XEditorUndo:EndOpInternal() + local obj_datas = edit_op.objects and edit_op.objects.data or empty_table + for idx, obj_data in ipairs(obj_datas) do + local op, handle = obj_data.op, obj_data.__undo_handle + if op == "delete" then + obj_datas[idx] = { op = op, __undo_handle = handle } + elseif op == "update" then + local after = obj_data.after + for k, v in pairs(after) do + if not special_props[k] and CompareValues(obj_data[k], v) then + after[k] = nil + end + end + obj_datas[idx] = { op = op, __undo_handle = handle, after = obj_data.after } + end + end + edit_op.hash_to_handle = hash_to_handle + edit_op.selection = nil + + assert(XEditorUndo.undo_index == undo_index) + EditorUndoPreserveHandles = false + Msg("OnMapPatchEnd") + + return edit_op +end + +function XEditorCreateMapPatch(filename, add_to_svn) + local edit_op = create_combined_patch_edit_op() + + -- serialize this combined operation, along with the object identifiers + local str = "return " .. ValueToLuaCode(edit_op, nil, pstr("", 32768)):str() + filename = filename or "svnAssets/Bin/win32/Bin/map.patch" + local path = SplitPath(filename) + AsyncCreatePath(path) + local err = AsyncStringToFile(filename, str) + if err then + print("Failed to write patch file", filename) + return + end + if add_to_svn then + SVNAddFile(path) + SVNAddFile(filename) + end + + local affected_grids = {} + for _, grid in ipairs(editor.GetGridNames()) do + if edit_op[grid] then + affected_grids[grid] = edit_op[grid].box + end + end + + edit_op.compacted_obj_boxes = empty_table + if edit_op.objects then + local affected_objs = edit_op.objects:GetAffectedObjectsAfter() + local obj_box_list = {} + for _, obj in ipairs(affected_objs) do + assert(IsValid(obj)) + if IsValid(obj) then + table.insert(obj_box_list, obj:GetObjectBBox()) + end + end + edit_op.compacted_obj_boxes = CompactAABBList(obj_box_list, 4 * guim, "optimize_boxes") + end + + -- return: + -- - hashes of changed objects (newly created objects are not included), + -- - affected grid boxes + -- - a compacted list of the affected objects' bounding boxes (updated and created objects) + return (edit_op.hash_to_handle and table.keys(edit_op.hash_to_handle)), affected_grids, edit_op.compacted_obj_boxes +end + +function XEditorApplyMapPatch(filename) + filename = filename or "svnAssets/Bin/win32/Bin/map.patch" + + local func, err = loadfile(filename) + if err then + print("Failed to load patch", filename) + return + end + + local edit_op = func() + if not next(edit_op) then return end + + Msg("OnMapPatchBegin") + XEditorUndo.handle_remap = {} -- as with pasting, generate new undo handles for the objects from the patch + EditorUndoPreserveHandles = true -- restore object handles that were stored in the patch + + -- lookup objects to be deleted/modified by their stored identifier hashes + local hash_to_handle = edit_op.hash_to_handle + MapForEach(true, "attached", false, function(obj) + local hash = obj:GetObjIdentifier() + local handle = hash_to_handle[hash] + if handle then + XEditorUndo:GetUndoRedoObject(handle, nil, obj) -- "assign" this object to the handle, via handle_remap + end + end) + + -- apply the changes via the "redo" mechanism + XEditorUndo:AddEditOp(edit_op) + XEditorUndo.undo_index = XEditorUndo.undo_index - 1 + redo_and_capture("Applied map patch") + + -- remove the added edit op and readjust the undo index + table.remove(XEditorUndo.undo_queue, XEditorUndo.undo_index - 1) + XEditorUndo.undo_index = XEditorUndo.undo_index - 1 + + EditorUndoPreserveHandles = false + MapPatchesApplied = true + Msg("OnMapPatchEnd") +end + + +----- Misc + +function CenterPointOnBase(objs) + local minz + for _, obj in ipairs(objs) do + local pos = obj:GetVisualPos() + local z = Max(terrain.GetHeight(pos), pos:z()) + if not minz or minz > z then + minz = z + end + end + return CenterOfMasses(objs):SetZ(minz) +end + +function XEditorSelectAndMoveObjects(objs, offs) + editor.SetSel(objs) + SuspendPassEditsForEditOp() + objs = editor.SelectionCollapseChildObjects() + if const.SlabSizeX and HasAlignedObjs(objs) then -- snap offset to a whole number of voxels, so auto-snapped object don't get displaced + local x = offs:x() / const.SlabSizeX * const.SlabSizeX + local y = offs:y() / const.SlabSizeY * const.SlabSizeY + local z = offs:z() and (offs:z() + const.SlabSizeZ / 2) / const.SlabSizeZ * const.SlabSizeZ or 0 + offs = point(x, y, z) + end + for _, obj in ipairs(objs) do + if obj:IsKindOf("AlignedObj") then + obj:AlignObj(obj:GetPos() + offs) + elseif obj:IsValidPos() then + obj:SetPos(obj:GetPos() + offs) + end + end + Msg("EditorCallback", "EditorCallbackMove", objs) + ResumePassEditsForEditOp() + return objs +end + +-- Makes sure that if a parent object (as per GetEditorParentObject) is in the input list, +-- then all children objects are in the output, and vice versa. Used by XAreaCopyTool. +function XEditorPropagateParentAndChildObjects(objs) + add_parent_objects(objs) + add_child_objects(objs) + return objs +end + +function XEditorPropagateChildObjects(objs) + add_child_objects(objs) + return objs +end + +function XEditorCollapseChildObjects(objs) + local objset = {} + for _, obj in ipairs(objs) do + objset[obj] = true + end + + local i, count = 1, #objs + while i <= count do + local obj = objs[i] + if objset[obj:GetEditorParentObject()] then + objs[i] = objs[count] + objs[count] = nil + count = count - 1 + else + i = i + 1 + end + end + return objs +end diff --git a/CommonLua/Editor/XEditor/XEnrichTerrainTool.lua b/CommonLua/Editor/XEditor/XEnrichTerrainTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..14ee6389e623664e9de6fafe5d0801460432320c --- /dev/null +++ b/CommonLua/Editor/XEditor/XEnrichTerrainTool.lua @@ -0,0 +1,116 @@ +DefineClass.EnrichBrushObjectSet = { + __parents = { "PropertyObject", }, + properties = { + { id = "Name", editor = "text", default = "", translate = false, }, + { id = "Objects", editor = "string_list", default = {}, items = ClassDescendantsCombo("CObject"), }, + { id = "Weight", editor = "number", default = 100, min = 1, max = 1000, step = 1, slider = true, }, + { id = "AngleDeviation", name = "Angle deviation", editor = "number", default = 0, min = 0, max = 180, step = 1, slider = true, }, + { id = "Scale", editor = "number", default = 100, min = 10, max = 250, step = 1, slider = true, }, + { id = "ScaleDeviation", name = "Scale deviation", editor = "number", default = 0, min = 0, max = 100, step = 1, slider = true, }, + { id = "ColorMin", name = "Color min", editor = "color", default = RGB(100, 100, 100), }, + { id = "ColorMax", name = "Color max", editor = "color", default = RGB(100, 100, 100), }, + }, + + EditorName = "Object Set", +} + +function EnrichBrushObjectSet:GetEditorView() + local name = self.Name + local count = #self.Objects + local weight = self.Weight + return string.format("%s (%s objects, weight %s)", name, count, weight) +end + +DefineClass.EnrichBrushRule = { + __parents = { "PropertyObject" }, + properties = { + { id = "Terrains", editor = "texture_picker", default = {}, thumb_size = 100, small_font = true, max_rows = 3, multiple = true, + items = GetTerrainTexturesItems, }, + { id = "ObjectSets", name = "Object Sets", editor = "nested_list", base_class = "EnrichBrushObjectSet", default = {}, inclusive = true, }, + }, +} + +function EnrichBrushRule:GetEditorView() + local objects = {} + for _, set in ipairs(self.ObjectSets) do objects = table.union(objects, set.Objects) end + if #objects ~= 0 then table.sort(objects) end + return string.format("Terrains: %s\nObjects: %s", table.concat(self.Terrains, ", "), table.concat(objects, ", ")) +end + +DefineClass.EnrichTerrainPreset = { + __parents = { "Preset" }, + + EditorMenubarName = "Enrich Terrain Presets", + EditorMenubar = "Map", + ContainerClass = "EnrichBrushRule", + GlobalMap = "EnrichTerrainPresets", + PresetClass = "EnrichTerrainPreset", +} + +function EnrichTerrainPreset:GetError() + local terrains = {} + for _, rule in ipairs(self or empty_table) do + for i, ter in ipairs(rule.Terrains) do + if terrains[ter] then + return string.format("Terrain '%s' is already used by another rule.", ter) + else + terrains[ter] = true + end + end + end +end + +DefineClass.XEnrichTerrainTool = { + __parents = { "XPlaceMultipleObjectsToolBase" }, + properties = { + { id = "Preset", name = "Enrich preset", editor = "preset_id", default = "", preset_class = "EnrichTerrainPreset", }, + }, + + ToolTitle = "Enrich terrain", + Description = { + "Adds randomized objects according to predefined presets.", + }, + + classes = false, + terrains = false, + + ToolSection = "Terrain", + ActionIcon = "CommonAssets/UI/Editor/Tools/EnrichTerrain.tga", + ActionShortcut = "Ctrl-T", +} + +function XEnrichTerrainTool:OnEditorSetProperty(prop_id) + if prop_id == "Preset" then + local preset = EnrichTerrainPresets[self:GetPreset()] + self.classes, self.terrains = {}, {} + if not preset then return end + for _, rule in ipairs(preset) do + local terrains = rule.Terrains + local sets = rule.ObjectSets + for _, ter in ipairs(terrains) do + if not self.terrains[ter] then self.terrains[ter] = sets end + end + for _, set in ipairs(sets) do self.classes = table.union(self.classes, set.Objects) end + end + end +end + +function XEnrichTerrainTool:GetObjSet(pt) + if not self:GetPreset() or self:GetPreset() == "" then return end + local ter = pt and TerrainTextures[terrain.GetTerrainType(pt)] + return self.terrains[ter.id] and table.weighted_rand(self.terrains[ter.id], "Weight") +end + +function XEnrichTerrainTool:GetParams(pt) + local set = self:GetObjSet(pt) + if set then return self.terrain_normal, set.Scale, set.ScaleDeviation, set.AngleDeviation, set.ColorMin, set.ColorMax end +end + +function XEnrichTerrainTool:GetClassesForPlace(pt) + local set = self:GetObjSet(pt) + return set and set.Objects +end + +function XEnrichTerrainTool:GetClassesForDelete() + return self.classes +end diff --git a/CommonLua/Editor/XEditor/XGrassDensityBrush.lua b/CommonLua/Editor/XEditor/XGrassDensityBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..386de20fd3b3d86463f4e15202835719917bf5ab --- /dev/null +++ b/CommonLua/Editor/XEditor/XGrassDensityBrush.lua @@ -0,0 +1,101 @@ +DefineClass.XGrassDensityBrush = +{ + __parents = { "XEditorBrushTool" }, + properties = + { + persisted_setting = true, auto_select_all = true, slider = true, + { id = "LevelMode", name = "Mode", editor = "dropdownlist", default = "Lower & Raise", items = { "Lower & Raise", "Raise Only", "Lower Only", "Draw on Empty" } }, + { id = "MinDensity", name = "Min grass density", editor = "number", min = 0, max = 100, default = 0}, + { id = "MaxDensity", name = "Max grass density", editor = "number", min = 0, max = 100, default = 100}, + { id = "GridVisible", name = "Toggle grid visibilty", editor = "bool", default = true}, + { id = "TerrainDebugAlphaPerc", name = "Grid opacity", editor = "number", + default = 80, min = 0, max = 100, slider = true, no_edit = function(self) return not self:GetGridVisible() end }, + }, + + ToolSection = "Terrain", + ToolTitle = "Terrain grass density", + Description = { + "Defines the grass density of the terrain.", + "( to draw on a select terrain)\n( to see grass density at the cursor)" + }, + ActionSortKey = "21", + ActionIcon = "CommonAssets/UI/Editor/Tools/GrassDensity.tga", + ActionShortcut = "Alt-N", + + prev_alpha = false, + start_terrain = false, +} + +function XGrassDensityBrush:Init() + if self:GetProperty("GridVisible") then + self:ShowGrid() + end +end + +function XGrassDensityBrush:Done() + self:HideGrid() +end + +function XGrassDensityBrush:ShowGrid() + hr.TerrainDebugDraw = 1 + self.prev_alpha = hr.TerrainDebugAlphaPerc + hr.TerrainDebugAlphaPerc = self:GetTerrainDebugAlphaPerc() + DbgSetTerrainOverlay("grass") +end + +function XGrassDensityBrush:HideGrid() + hr.TerrainDebugDraw = 0 + hr.TerrainDebugAlphaPerc = self.prev_alpha +end + +function XGrassDensityBrush:OnMouseButtonDown(pt, button) + if button == "L" and terminal.IsKeyPressed(const.vkAlt) then + local grid = editor.GetGridRef("grass_density") + local value = grid:get(GetTerrainCursor() / const.GrassTileSize) + print("Grass density at cursor:", value) + return "break" + end + return XEditorBrushTool.OnMouseButtonDown(self, pt, button) +end + +function XGrassDensityBrush:StartDraw(pt) + XEditorUndo:BeginOp{grass_density = true, name = "Changed grass density"} + self.start_terrain = terminal.IsKeyPressed(const.vkControl) and terrain.GetTerrainType(pt) +end + +function XGrassDensityBrush:Draw(pt1, pt2) + editor.SetGrassDensityInSegment(pt1, pt2, self:GetSize() / 2, self:GetMinDensity(), self:GetMaxDensity(), self:GetLevelMode(), self.start_terrain or -1) +end + +function XGrassDensityBrush:EndDraw(pt1, pt2, invalid_box) + XEditorUndo:EndOp(nil, invalid_box) +end + +function XGrassDensityBrush:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end + +function XGrassDensityBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "GridVisible" then + if self:GetProperty("GridVisible") then + self:ShowGrid() + else + self:HideGrid() + end + elseif prop_id == "MinDensity" or prop_id == "MaxDensity" then + local min = self:GetProperty("MinDensity") + local max = self:GetProperty("MaxDensity") + if prop_id == "MinDensity" then + if min > max then + self:SetProperty("MaxDensity", min) + end + else + if max < min then + self:SetProperty("MinDensity", max) + end + end + elseif prop_id == "TerrainDebugAlphaPerc" then + hr.TerrainDebugAlphaPerc = self:GetTerrainDebugAlphaPerc() + end +end diff --git a/CommonLua/Editor/XEditor/XLODTestingTool.lua b/CommonLua/Editor/XEditor/XLODTestingTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..40c212bf55954bdfc88afe40a0be1d7f5ec01788 --- /dev/null +++ b/CommonLua/Editor/XEditor/XLODTestingTool.lua @@ -0,0 +1,153 @@ +if config.ModdingToolsInUserMode then return end + +local lod_colors = { + RGB(235, 18, 18), + RGB( 18, 235, 18), + RGB( 18, 18, 235), + RGB(235, 235, 18), + RGB(235, 18, 235), + RGB( 18, 235, 235), +} + +function MoveToLOD(obj, i) + if not obj then + CreateMessageBox(nil, Untranslated("Error"), Untranslated("Please select an object first")) + return + end + if i < 0 or i >= (GetStateLODCount(obj, obj:GetState()) or 5) then + return + end + + if XLODTestingTool:GetMoveCamera() then + obj:SetForcedLOD(const.InvalidLODIndex) + else + obj:SetForcedLOD(i) + return + end + + local lod_dist = i == 0 and + MulDivRound(GetStateLODDistance(obj, obj:GetState(), 1), 80 * obj:GetScale(), 10000) - guim or + MulDivRound(GetStateLODDistance(obj, obj:GetState(), i), 110 * obj:GetScale(), 10000) + guim + local pos, lookat = GetCamera() + local offs = lookat - pos + pos = obj:GetVisualPos() + SetLen(pos - obj:GetVisualPos(), lod_dist) + SetCamera(pos, pos + offs) +end + +DefineClass.XLODTestingTool = { + __parents = { "XEditorTool" }, + properties = { + { id = "_1", editor = "help", default = false, + help = function(self) return "
" .. (self.obj and self.obj:GetEntity() or "No object selected") end, + }, + { id = "_2", editor = "buttons", default = false, buttons = function(self) + local buttons = {} + local obj = self.obj + local lods = obj and GetStateLODCount(obj, obj and obj:GetState()) or 5 + for i = 0, lods - 1 do + buttons[#buttons + 1] = { + name = "LOD " .. i, + func = function() MoveToLOD(obj, i) end, + } + end + return buttons + end + }, + { id = "MoveCamera", name = "Move camera", editor = "bool", default = true, persisted_setting = true, }, + }, + ToolTitle = "LOD Testing", + Description = { + "Select an object then use NumPad +/- to zoom in/out and observe LODs change.", + "(use / to change current LOD)\n" .. + "(use to center the object in the view)" + }, + ActionSortKey = "6", + ActionIcon = "CommonAssets/UI/Editor/Tools/RoomTools.tga", + ToolSection = "Misc", + UsesCodeRenderables = true, + + obj = false, + highlighed_obj = false, + text = false, +} + +function XLODTestingTool:Init() + self:CreateThread("UpdateTextThread", function() + while true do + local time = GetPreciseTicks() + if self.obj then + local cam_pos = GetCamera() + local dist = self.obj:GetVisualDist(cam_pos) + local lod = self.obj:GetCurrentLOD() + local text = string.format("%dm LOD %d", dist / guim, lod) + if self.text.text ~= text then + self.text:SetText(text) + self.text:SetColor(lod_colors[lod + 1]) + end + end + Sleep(Max(50 - (GetPreciseTicks() - time), 1)) + end + end) + self:SetObj(selo()) +end + +function XLODTestingTool:Done() + self:SetObj(false) + self:Highlight(false) + XEditorSelection = {} +end + +function XLODTestingTool:OnMouseButtonDown(pt, button) + if button == "L" then + local obj = GetObjectAtCursor() + if obj then + self:SetObj(obj) + end + return "break" + end + return XEditorTool.OnMouseButtonDown(self, pt, button) +end + +function XLODTestingTool:SetObj(obj) + if self.text then + self.text:delete() + end + if obj then + self.text = Text:new{ hide_in_editor = false } + local b = obj:GetObjectBBox() + local max = Max(b:sizex(), b:sizey(), b:sizez()) + self.text:SetPos(obj:GetVisualPos() + point(0, 0, -max / 2)) + end + self.obj = obj + CreateRealTimeThread(function() + XEditorSelection = {obj} -- set as selected without setting const.gofEditorSelection, as we want the Z shortcut to work + ObjModified(self) + end) +end + +function XLODTestingTool:OnMousePos(pt) + self:Highlight(GetObjectAtCursor() or false) +end + +function XLODTestingTool:Highlight(obj) + if obj ~= self.highlighted_obj then + if IsValid(self.highlighted_obj) then + self.highlighted_obj:ClearHierarchyGameFlags(const.gofEditorHighlight) + end + if obj then + obj:SetHierarchyGameFlags(const.gofEditorHighlight) + end + self.highlighted_obj = obj + end +end + +function XLODTestingTool:OnShortcut(shortcut, source, ...) + if shortcut == "Pageup" then + MoveToLOD(self.obj, self.obj:GetCurrentLOD() + 1) + return "break" + elseif shortcut == "Pagedown" then + MoveToLOD(self.obj, self.obj:GetCurrentLOD() - 1) + return "break" + end + return XEditorTool.OnShortcut(self, shortcut, source, ...) +end diff --git a/CommonLua/Editor/XEditor/XLevelHeightBrush.lua b/CommonLua/Editor/XEditor/XLevelHeightBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..bf8d19ce7ea71a50a94dac6800fd03acc637cc8e --- /dev/null +++ b/CommonLua/Editor/XEditor/XLevelHeightBrush.lua @@ -0,0 +1,124 @@ +DefineClass.XLevelHeightBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + editor = "number", slider = true, persisted_setting = true, auto_select_all = true, + { id = "LevelMode", name = "Mode", editor = "dropdownlist", default = "Lower & Raise", items = {"Lower & Raise", "Raise Only", "Lower Only"} }, + { id = "ClampToLevels", name = "Clamp to levels", editor = "bool", default = true, no_edit = not const.SlabSizeZ }, + { id = "SquareBrush", name = "Square brush", editor = "bool", default = true, no_edit = not const.SlabSizeZ }, + { id = "Height", default = 10 * guim, scale = "m", min = guic, max = const.MaxTerrainHeight, step = guic }, + { id = "Falloff", default = 100, scale = "%", min = 0, max = 250, no_edit = function(self) return self:IsCursorSquare() end }, + { id = "Strength", default = 100, scale = "%", min = 10, max = 100 }, + { id = "RegardWalkables", name = "Limit to walkables", editor = "bool", default = false }, + }, + + ToolSection = "Height", + ToolTitle = "Level height", + Description = { + "Levels the terrain at the height of the starting point, creating a flat area.", + "( to align to world directions)\n( to use the value in Height)\n( to get the height at the cursor)", + }, + ActionSortKey = "11", + ActionIcon = "CommonAssets/UI/Editor/Tools/Level.tga", + ActionShortcut = "P", + + mask_grid = false, +} + +function XLevelHeightBrush:Init() + local w, h = terrain.HeightMapSize() + self.mask_grid = NewComputeGrid(w, h, "F") +end + +function XLevelHeightBrush:Done() + editor.ClearOriginalHeightGrid() + self.mask_grid:free() +end + +if const.SlabSizeZ then -- modify Size/Height properties depending on SquareBrush/ClampToLevels properties + function XLevelHeightBrush:GetPropertyMetadata(prop_id) + local sizex, sizez = const.SlabSizeX, const.SlabSizeZ + if prop_id == "Size" and self:IsCursorSquare() then + local help = string.format("1 tile = %sm", _InternalTranslate(FormatAsFloat(sizex, guim, 2))) + return { id = "Size", name = "Size (tiles)", help = help, default = sizex, scale = sizex, min = sizex, max = 100 * sizex, step = sizex, editor = "number", slider = true, persisted_setting = true, auto_select_all = true, } + end + if prop_id == "Height" and self:GetClampToLevels() then + local help = string.format("1 step = %sm", _InternalTranslate(FormatAsFloat(sizez, guim, 2))) + return { id = "Height", name = "Height (steps)", help = help, default = sizez, scale = sizez, min = sizez, max = self.cursor_max_tiles * sizez, step = sizez, editor = "number", slider = true, persisted_setting = true, auto_select_all = true } + end + return table.find_value(self.properties, "id", prop_id) + end + + function XLevelHeightBrush:GetProperties() + local props = {} + for _, prop in ipairs(self.properties) do + props[#props + 1] = self:GetPropertyMetadata(prop.id) + end + return props + end + + function XLevelHeightBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "SquareBrush" or prop_id == "ClampToLevels" then + self:SetSize(self:GetSize()) + self:SetHeight(self:GetHeight()) + end + end +end + +function XLevelHeightBrush:OnMouseButtonDown(pt, button) + if button == "L" and terminal.IsKeyPressed(const.vkAlt) then + self:SetHeight(GetTerrainCursor():z()) + ObjModified(self) + return "break" + end + return XEditorBrushTool.OnMouseButtonDown(self, pt, button) +end + +function XLevelHeightBrush:StartDraw(pt) + XEditorUndo:BeginOp{ height = true, name = "Changed height" } + editor.StoreOriginalHeightGrid(false) -- false = don't use for GetTerrainCursor + self.mask_grid:clear() + if not terminal.IsKeyPressed(const.vkControl) then + self:SetHeight(terrain.GetHeight(pt)) + ObjModified(self) + end +end + +function XLevelHeightBrush:Draw(pt1, pt2) + local inner_radius, outer_radius = self:GetCursorRadius() + local op = self:GetStrength() ~= 100 and "add" or "max" + local strength = self:GetStrength() ~= 100 and self:GetStrength() / 5000.0 or 1.0 + local bbox = editor.DrawMaskSegment(self.mask_grid, pt1, pt2, inner_radius, outer_radius, op, strength, strength, self:IsCursorSquare()) + editor.SetHeightWithMask(self:GetHeight() / const.TerrainHeightScale, self.mask_grid, bbox, self:GetLevelMode()) + + if const.SlabSizeZ and self:GetClampToLevels() then + editor.ClampHeightToLevels(config.TerrainHeightSlabOffset, const.SlabSizeZ, bbox, self.mask_grid) + end + if self:GetRegardWalkables() then + editor.ClampHeightToWalkables(bbox) + end + Msg("EditorHeightChanged", false, bbox) +end + +function XLevelHeightBrush:EndDraw(pt1, pt2, invalid_box) + local _, outer_radius = self:GetCursorRadius() + local bbox = editor.GetSegmentBoundingBox(pt1, pt2, outer_radius, self:IsCursorSquare()) + Msg("EditorHeightChanged", true, bbox) + XEditorUndo:EndOp(nil, invalid_box) +end + +function XLevelHeightBrush:GetCursorRadius() + local inner_size = self:GetSize() * 100 / (100 + 2 * self:GetFalloff()) + return inner_size / 2, self:GetSize() / 2 +end + +function XLevelHeightBrush:IsCursorSquare() + return const.SlabSizeZ and self:GetSquareBrush() +end + +function XLevelHeightBrush:GetCursorExtraFlags() + return self:IsCursorSquare() and const.mfTerrainHeightFieldSnapped or 0 +end + +function XLevelHeightBrush:GetCursorColor() + return self:IsCursorSquare() and RGB(16, 255, 16) or RGB(255, 255, 255) +end diff --git a/CommonLua/Editor/XEditor/XMapGridAreaBrush.lua b/CommonLua/Editor/XEditor/XMapGridAreaBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..c328b5107930558cabf6b106cb67177508d647a5 --- /dev/null +++ b/CommonLua/Editor/XEditor/XMapGridAreaBrush.lua @@ -0,0 +1,215 @@ +-- Override this class to create editor brushes for grids defined with DefineMapGrid + +DefineClass.XMapGridAreaBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + auto_select_all = true, + { id = "TerrainDebugAlphaPerc", name = "Opacity", editor = "number", + default = 50, min = 0, max = 100, slider = true, + }, + { id = "WriteValue", name = "Value", editor = "texture_picker", default = "Blank", + thumb_width = 101, thumb_height = 35, small_font = true, items = function(self) return self:GetGridPaletteItems() end, + }, + { id = "mask_help", editor = "help", help = "
\n", + no_edit = function(self) return not self.selection_available end, + }, + { id = "mask_buttons", editor = "buttons", default = false, + buttons = { + { name = "Clear (Esc)", func = "ClearSelection" }, + { name = "Invert (I)", func = "InvertSelection" }, + { name = "Fill area (F)", func = "FillSelection" }, + }, + no_edit = function(self) return not self.selection_available end, + }, + }, + + -- settings + GridName = false, -- grid name, as defined with DefineMapGrid + GridTileSize = false, + Grid = false, + + -- state + saved_alpha = false, + add_connected_area = false, + add_every_tile = false, + selection_grid = false, + selection_available = false, + + -- overrides + CreateBrushGrid = empty_func, + GetGridPaletteItems = empty_func, + GetPalette = empty_func, +} + +function XMapGridAreaBrush:Init() + self:Initialize() +end + +function XMapGridAreaBrush:Initialize() + if (not self.GridName or not _G[self.GridName]) and not self.Grid and not self:CreateBrushGrid() then + assert(false, "Grid area brush has no configured grid or configured grid was not found") + return + end + + local items = self:GetGridPaletteItems() + if not table.find(items, "value", self:GetWriteValue()) then + self:SetWriteValue(items[1].value) + end + + local grid = self.Grid or _G[self.GridName] + local w, h = grid:size() + self.selection_grid = NewHierarchicalGrid(w, h, 64, 1) + self:SelectionOp("clear") + + self:UpdateItems() + self.saved_alpha = hr.TerrainDebugAlphaPerc + hr.TerrainDebugDraw = 1 + hr.TerrainDebugAlphaPerc = self:GetTerrainDebugAlphaPerc() +end + +function XMapGridAreaBrush:Done() + if (not self.GridName and not self.Grid) or not self.selection_grid then return end + + hr.TerrainDebugDraw = 0 + hr.TerrainDebugAlphaPerc = self.saved_alpha + DbgSetTerrainOverlay("") -- prevent further access to self.selection_grid (which is getting freed) from C++ + self.selection_grid:free() +end + +function XMapGridAreaBrush:UpdateItems() + -- Upload new palette to the debug overlay + DbgSetTerrainOverlay("grid", self:GetPalette(), self.Grid or _G[self.GridName], self.selection_grid) + -- Update UI + ObjModified(self) +end + +function XMapGridAreaBrush:HideDebugOverlay() + DbgSetTerrainOverlay("") +end + +function XMapGridAreaBrush:StartDraw(pt) + XEditorUndo:BeginOp{[self.GridName] = true, name = string.format("Edited grid - %s", self.GridName)} +end + +function XMapGridAreaBrush:Draw(pt1, pt2) + local tile_size = MapGridTileSize(self.GridName) or self.GridTileSize + local bbox = editor.SetGridSegment(self.Grid or _G[self.GridName], tile_size, pt1, pt2, self:GetSize() / 2, self:GetWriteValue(), self.selection_grid) + Msg("OnMapGridChanged", self.GridName, bbox) +end + +function XMapGridAreaBrush:EndDraw(pt1, pt2, invalid_box) + XEditorUndo:EndOp(nil, invalid_box) + self.start_pt = false + ObjModified(self) -- update palette items +end + +function XMapGridAreaBrush:SelectionOp(op, param) + local tile_size = MapGridTileSize(self.GridName) or self.GridTileSize + local bbox = editor.GridSelectionOp(self.Grid or _G[self.GridName], self.selection_grid, tile_size, op, param) + if bbox and not bbox:IsEmpty() then + Msg("OnMapGridChanged", self.GridName, bbox) + end +end + +function XMapGridAreaBrush:ClearSelection() + self:SelectionOp("clear") + self.selection_available = false + ObjModified(self) +end + +function XMapGridAreaBrush:InvertSelection() + self:SelectionOp("invert") +end + +function XMapGridAreaBrush:FillSelection() + XEditorUndo:BeginOp{[self.GridName] = true, name = string.format("Edited grid - %s", self.GridName)} + + if not self.selection_available then + self:SelectionOp("invert") -- select entire map + end + self:SelectionOp("fill", self:GetWriteValue()) + self:SelectionOp("clear") + self.selection_available = false + ObjModified(self) + + XEditorUndo:EndOp() +end + +function XMapGridAreaBrush:OnMouseButtonDown(pt, button) + if button == "L" then + local selecting = self.add_connected_area or self.add_every_tile + if selecting then + local world_pt = self:GetWorldMousePos() + if self.add_every_tile then + self:SelectionOp("add every tile", world_pt) + elseif self.add_connected_area then + self:SelectionOp("add connected area", world_pt) + end + self.selection_available = true + ObjModified(self) + return "break" + elseif terminal.IsKeyPressed(const.vkAlt) then + local tile_size = MapGridTileSize(self.GridName) + local value = self.Grid or _G[self.GridName]:get(GetTerrainCursor() / tile_size) + self:SetWriteValue(value) + ObjModified(self) + return "break" + end + elseif button == "R" then + if self.selection_available then + self:ClearSelection() + return "break" + end + end + + return XEditorBrushTool.OnMouseButtonDown(self, pt, button) +end + +function XMapGridAreaBrush:OnKbdKeyDown(vkey) + local result + if vkey == const.vkControl then + self.add_connected_area = true + result = "break" + elseif vkey == const.vkShift then + self.add_every_tile = true + result = "break" + end + return result or XEditorBrushTool.OnKbdKeyDown(self, vkey) +end + +function XMapGridAreaBrush:OnKbdKeyUp(vkey) + local result + if vkey == const.vkControl then + self.add_connected_area = false + result = "break" + elseif vkey == const.vkShift then + self.add_every_tile = false + result = "break" + end + return result or XEditorBrushTool.OnKbdKeyDown(self, vkey) +end + +function XMapGridAreaBrush:OnShortcut(shortcut, source, ...) + if shortcut == "Escape" and self.selection_available then + self:ClearSelection() + return "break" + elseif shortcut == "I" then + self:InvertSelection() + return "break" + elseif shortcut == "F" then + self:FillSelection() + return "break" + end + return XEditorBrushTool.OnShortcut(self, shortcut, source, ...) +end + +function XMapGridAreaBrush:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end + +function XMapGridAreaBrush:OnEditorSetProperty(prop_id) + if prop_id == "TerrainDebugAlphaPerc" then + hr.TerrainDebugAlphaPerc = self:GetTerrainDebugAlphaPerc() + end +end diff --git a/CommonLua/Editor/XEditor/XMeasureTool.lua b/CommonLua/Editor/XEditor/XMeasureTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..86edf8a5d808befdc4728fe791f07ec6176ded97 --- /dev/null +++ b/CommonLua/Editor/XEditor/XMeasureTool.lua @@ -0,0 +1,317 @@ +if FirstLoad then + EditorMeasureLines = false +end + +function AddEditorMeasureLine(line) + EditorMeasureLines = EditorMeasureLines or {} + EditorMeasureLines[#EditorMeasureLines + 1] = line +end + +function DestroyEditorMeasureLines() + for _, line in ipairs(EditorMeasureLines or empty_table) do + line:Done() + end + EditorMeasureLines = false +end + +function UpdateEditorMeasureLines() + for _, line in ipairs(EditorMeasureLines or empty_table) do + line:Move(line.point0, line.point1) + end +end + +OnMsg.EditorHeightChanged = UpdateEditorMeasureLines +OnMsg.EditorPassabilityChanged = UpdateEditorMeasureLines +OnMsg.ChangeMap = DestroyEditorMeasureLines + + +----- XMeasureTool + +DefineClass.XMeasureTool = { + __parents = { "XEditorTool" }, + properties = { + persisted_setting = true, + { id = "CamDist", editor = "help", default = false, persisted_setting = false, + help = function(self) + local frac = self.cam_dist % guim + return string.format("Distance to screen: %d.%0".. (#tostring(guim) - 1) .."dm", self.cam_dist / guim, frac) + end + }, + { id = "Slope", editor = "help", default = false, persisted_setting = false, + help = function(self) + return string.format("Terrain slope: %.1f°", self.slope / 60.0) + end + }, + { id = "MeasureInSlabs", name = "Measure in slabs", editor = "bool", default = false, no_edit = not const.SlabSizeZ }, + { id = "FollowTerrain", name = "Follow terrain", editor = "bool", default = false, }, + { id = "IgnoreWalkables", name = "Ignore walkables", editor = "bool", default = false, }, + { id = "MeasurePath", name = "Measure path", editor = "bool", default = false, }, + { id = "StayOnScreen", name = "Stay on screen", editor = "bool", default = false, }, + }, + ToolTitle = "Measure", + Description = { + "Measures distance between two points.", + "The path found using the pathfinder and slope in degrees are also displayed.", + }, + ActionSortKey = "1", + ActionIcon = "CommonAssets/UI/Editor/Tools/MeasureTool.tga", + ActionShortcut = "Alt-M", + ToolSection = "Misc", + UsesCodeRenderables = true, + + measure_line = false, + measure_cam_dist_thread = false, + cam_dist = 0, + slope = 0, +} + +function XMeasureTool:Init() + self.measure_cam_dist_thread = CreateRealTimeThread(function() + while true do + local mouse_pos = terminal.GetMousePos() + if mouse_pos:InBox2D(terminal.desktop.box) then + RequestPixelWorldPos(mouse_pos) + WaitNextFrame(6) + self.cam_dist = camera.GetEye():Dist2D(ReturnPixelWorldPos()) + self.slope = terrain.GetTerrainSlope(GetTerrainCursor()) + ObjModified(self) + end + Sleep(50) + end + end) +end + +function XMeasureTool:Done() + if not self:GetStayOnScreen() then + DestroyEditorMeasureLines() + end + DeleteThread(self.measure_cam_dist_thread) +end + +function XMeasureTool:OnMouseButtonDown(pt, button) + if button == "L" then + local terrain_cursor = GetTerrainCursor() + if self.measure_line then + self.measure_line = false + else + if not self:GetStayOnScreen() then + DestroyEditorMeasureLines() + end + self.measure_line = PlaceObject("MeasureLine", { + measure_in_slabs = self:GetMeasureInSlabs(), + follow_terrain = self:GetFollowTerrain(), + ignore_walkables = self:GetIgnoreWalkables(), + show_path = self:GetMeasurePath() + }) + self.measure_line:Move(terrain_cursor, terrain_cursor) + AddEditorMeasureLine(self.measure_line) + end + return "break" + end + return XEditorTool.OnMouseButtonDown(self, pt, button) +end + +function XMeasureTool:UpdatePoints() + local obj = self.measure_line + if obj and IsValid(obj) then + local pt = GetTerrainCursor() + obj:Move(obj.point0, pt) + obj:UpdatePath() + end +end + +function XMeasureTool:OnMousePos(pt, button) + self:UpdatePoints() +end + +function XMeasureTool:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "MeasureInSlabs" then + for _, line in ipairs(EditorMeasureLines or empty_table) do + line.measure_in_slabs = self:GetMeasureInSlabs() + line:UpdateText() + end + elseif prop_id == "FollowTerrain" then + for _, line in ipairs(EditorMeasureLines or empty_table) do + line.follow_terrain = self:GetFollowTerrain() + line:Move(line.point0, line.point1) + end + elseif prop_id == "IgnoreWalkables" then + for _, line in ipairs(EditorMeasureLines or empty_table) do + line.ignore_walkables = self:GetIgnoreWalkables() + line:Move(line.point0, line.point1) + end + elseif prop_id == "MeasurePath" then + for _, line in ipairs(EditorMeasureLines or empty_table) do + line.show_path = self:GetMeasurePath() + line:UpdatePath() + end + end + if prop_id == "StayOnScreen" and not self:GetStayOnScreen() then + DestroyEditorMeasureLines() + self.measure_line = false + end +end + + +----- MeasureLine + +DefineClass.MeasureLine = { + __parents = { "Object" }, + + point0 = point30, + point1 = point30, + path_distance = -1, + line_distance = -1, + horizontal_distance = -1, + vertical_distance = -1, + measure_in_slabs = false, + show_path = false, + follow_terrain = true, +} + +function MeasureLine:Init() + self.line = PlaceObject("Polyline") + self.path = PlaceObject("Polyline") + self.label = PlaceObject("Text") +end + +function MeasureLine:Done() + DoneObject(self.line) + DoneObject(self.path) + DoneObject(self.label) +end + +function MeasureLine:DistanceToString(dist, slab_size, skip_slabs) + dist = Max(0, dist) + if self.measure_in_slabs then + local whole = dist / slab_size + return string.format(skip_slabs and "%d.%d" or "%d.%d slabs", whole, dist * 10 / slab_size - whole * 10) + else + local frac = dist % guim + return string.format("%d.%0".. (#tostring(guim) - 1) .."dm", dist / guim, frac) + end +end + +function MeasureLine:UpdateText() + local dist_string + if self.measure_in_slabs then + local x = self:DistanceToString(abs(self.point0:x() - self.point1:x()), const.SlabSizeX, true) + local y = self:DistanceToString(abs(self.point0:y() - self.point1:y()), const.SlabSizeY, true) + local z = self:DistanceToString(self.vertical_distance, const.SlabSizeZ, true) + dist_string = string.format("x%s, y%s, z%s", x, y, z) + else + local h = self:DistanceToString(self.horizontal_distance) + local v = self:DistanceToString(self.vertical_distance) + dist_string = string.format("h%s, v%s", h, v) + end + local angle = atan(self.vertical_distance, self.horizontal_distance) / 60.0 + local l = self:DistanceToString(self.line_distance, const.SlabSizeX) + if self.show_path then + local p = "No path" + if self.show_path and self.path_distance ~= -1 then + p = self:DistanceToString(self.path_distance, const.SlabSizeX) + end + self.label:SetText(string.format("%s (%s, %.1f°) : %s", l, dist_string, angle, p)) + else + self.label:SetText(string.format("%s (%s, %.1f°)", l, dist_string, angle)) + end +end + +local function _GetZ(pt, ignore_walkables) + if ignore_walkables then + return terrain.GetHeight(pt) + else + return Max(GetWalkableZ(pt), terrain.GetSurfaceHeight(pt)) + end +end + +local function SetLineMesh(line, p_pstr) + line:SetMesh(p_pstr) + return line +end + +function MeasureLine:Move(point0, point1) + self.point0 = point0:SetInvalidZ() + self.point1 = point1:SetInvalidZ() + + local point0t = point(point0:x(), point0:y(), _GetZ(point0)) + local point1t = point(point1:x(), point1:y(), _GetZ(point1)) + local len = (point0t - point1t):Len() + + local points_pstr = pstr("") + points_pstr:AppendVertex(point0t) + points_pstr:AppendVertex(point0t + point(0, 0, 5 * guim)) + points_pstr:AppendVertex(point0t + point(0, 0, guim)) + + local steps = len / (guim / 2) + steps = steps > 0 and steps or 1 + local distance = 0 + local prev_point = point0t + point(0, 0, guim) + for i = 0, steps do + local pt = point0t + (point1t - point0t) * i / steps + if self.follow_terrain then + pt = point(pt:x(), pt:y(), _GetZ(pt, self.ignore_walkables)) + distance = distance + (prev_point - point(0, 0, guim)):Dist(pt) + end + prev_point = pt + point(0, 0, guim) + points_pstr:AppendVertex(prev_point) + end + + points_pstr:AppendVertex(point1t + point(0, 0, guim)) + points_pstr:AppendVertex(point1t + point(0, 0, 5 * guim)) + points_pstr:AppendVertex(point1t) + + self.line = SetLineMesh(self.line, points_pstr) + + -- update text label + local middlePoint = (point0 + point1) / 2 + self.line:SetPos(middlePoint) + self.path:SetPos(middlePoint) + self.label:SetPos(middlePoint + point(0, 0, 4 * guim)) + self.label:SetTextStyle("EditorTextBold") + + self.line_distance = self.follow_terrain and distance or len + self.horizontal_distance = (point0t - point1t):Len2D() + self.vertical_distance = abs(point0t:z() - point1t:z()) + self:UpdateText() +end + +local function SetWalkableHeight(pt) + return pt:SetZ(_GetZ(pt)) +end + +-- will create a red line if *delayed* == true and a green line if *delayed* == false +function MeasureLine:SetPath(path, delayed) + local v_points_pstr = pstr("") + if path and #path > 0 then + local v_prev = {} + v_points_pstr:AppendVertex(SetWalkableHeight(self.point0), delayed and const.clrRed or const.clrGreen) + v_points_pstr:AppendVertex(SetWalkableHeight(self.point0)) + local dist = 0 + for i = 1, #path do + v_points_pstr:AppendVertex(SetWalkableHeight(path[i])) + if i > 1 then + dist = dist + path[i]:Dist(path[i - 1]) + end + end + self.path_distance = dist + else + v_points_pstr:AppendVertex(self.point0, delayed and const.clrRed or const.clrGreen) + v_points_pstr:AppendVertex(self.point0) + self.path_distance = -1 + end + + self.path = SetLineMesh(self.path, v_points_pstr) + self:UpdateText() +end + +function MeasureLine:UpdatePath() + if self.show_path then + local pts, delayed = pf.GetPosPath(self.point0, self.point1) + self:SetPath(pts, delayed) + self.path:SetEnumFlags(const.efVisible) + else + self:UpdateText() + self.path:ClearEnumFlags(const.efVisible) + end +end diff --git a/CommonLua/Editor/XEditor/XPassabilityBrush.lua b/CommonLua/Editor/XEditor/XPassabilityBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..f78319c9623a42be3ba9957ba905407a6208a60d --- /dev/null +++ b/CommonLua/Editor/XEditor/XPassabilityBrush.lua @@ -0,0 +1,164 @@ +local work_modes = { + { id = 1, name = "Make passable(Alt-1)" }, + { id = 2, name = "Make impassable(Alt-2)" }, + { id = 3, name = "Clear both(Alt-3)" } +} + +DefineClass.XPassabilityBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + persisted_setting = true, auto_select_all = true, + { id = "WorkMode", name = "Work Mode", editor = "text_picker", default = 1, max_rows = 3, items = work_modes, }, + { id = "SquareBrush", name = "Square brush", editor = "bool", default = true, no_edit = not const.PassTileSize }, + }, + + ToolSection = "Terrain", + ToolTitle = "Forced passability", + Description = { + "Force sets/clears passability." + }, + ActionSortKey = "20", + ActionIcon = "CommonAssets/UI/Editor/Tools/Passability.tga", + ActionShortcut = "Alt-P", + cursor_tile_size = const.PassTileSize +} + +function XPassabilityBrush:Init() + hr.TerrainDebugDraw = 1 + DbgSetTerrainOverlay("passability") +end + +function XPassabilityBrush:Done() + hr.TerrainDebugDraw = 0 +end + +-- Modify Size metadata depending on the SquareBrush property +if const.PassTileSize then + function XPassabilityBrush:GetPropertyMetadata(prop_id) + local sizex = const.PassTileSize + if prop_id == "Size" and self:IsCursorSquare() then + local help = string.format("1 tile = %sm", _InternalTranslate(FormatAsFloat(sizex, guim, 2))) + return { + id = "Size", name = "Size (tiles)", help = help, editor = "number", slider = true, + default = sizex, scale = sizex, min = sizex, max = 100 * sizex, step = sizex, + persisted_setting = true, auto_select_all = true, + } + end + + return table.find_value(self.properties, "id", prop_id) + end + + function XPassabilityBrush:GetProperties() + local props = {} + for _, prop in ipairs(self.properties) do + props[#props + 1] = self:GetPropertyMetadata(prop.id) + end + return props + end + + function XPassabilityBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "SquareBrush" then + self:SetSize(self:GetSize()) + end + end +end + +function XPassabilityBrush:StartDraw(pt) + XEditorUndo:BeginOp{ passability = true, impassability = true, name = "Changed passability" } +end + +function XPassabilityBrush:GetBrushBox() + local radius_in_tiles = self:GetCursorRadius() / self.cursor_tile_size + local normal_radius = (self.cursor_tile_size / 2) + self.cursor_tile_size * radius_in_tiles + local small_radius = normal_radius - self.cursor_tile_size + + local cursor_pt = GetTerrainCursor() + local center = point( + DivRound(cursor_pt:x(), self.cursor_tile_size) * self.cursor_tile_size, + DivRound(cursor_pt:y(), self.cursor_tile_size) * self.cursor_tile_size + ):SetTerrainZ() + local min = center - point(normal_radius, normal_radius) + local max = center + point(normal_radius, normal_radius) + + local size_in_tiles = self:GetSize() / self.cursor_tile_size + -- For an odd-sized brush the radius is asymetrical and needs adjustment + if size_in_tiles > 1 and size_in_tiles % 2 == 0 then + local diff = cursor_pt - center + if diff:x() < 0 and diff:y() < 0 then + min = center - point(normal_radius, normal_radius) + max = center + point(small_radius, small_radius) + elseif diff:x() > 0 and diff:y() < 0 then + min = center - point(small_radius, normal_radius) + max = center + point(normal_radius, small_radius) + elseif diff:x() < 0 and diff:y() > 0 then + min = center - point(normal_radius, small_radius) + max = center + point(small_radius, normal_radius) + else + min = center - point(small_radius, small_radius) + max = center + point(normal_radius, normal_radius) + end + end + + return box(min, max) +end + +function XPassabilityBrush:Draw(last_pos, pt) + if self:GetSquareBrush() then + local mode = self:GetWorkMode() + local brush_box = self:GetBrushBox() + + if mode == 1 then + editor.SetPassableBox(brush_box, true) + elseif mode == 2 then + editor.SetPassableBox(brush_box, false) + editor.SetImpassableBox(brush_box, true) + else + editor.SetPassableBox(brush_box, false) + editor.SetImpassableBox(brush_box, false) + end + return + end + + local radius = self:GetSize() / 2 + local mode = self:GetWorkMode() + if mode == 1 then + editor.SetPassableCircle(pt, radius, true) + elseif mode == 2 then + editor.SetPassableCircle(pt, radius, false) + editor.SetImpassableCircle(pt, radius, true) + else + editor.SetPassableCircle(pt, radius, false) + editor.SetImpassableCircle(pt, radius, false) + end +end + +function XPassabilityBrush:EndDraw(pt1, pt2, invalid_box) + invalid_box = GrowBox(invalid_box, const.PassTileSize * 2) + + XEditorUndo:EndOp(nil, invalid_box) + terrain.RebuildPassability(invalid_box) + Msg("EditorPassabilityChanged") +end + +function XPassabilityBrush:IsCursorSquare() + return const.PassTileSize and self:GetSquareBrush() +end + +function XPassabilityBrush:GetCursorExtraFlags() + return self:IsCursorSquare() and const.mfPassabilityFieldSnapped or 0 +end + +function XPassabilityBrush:OnShortcut(shortcut, ...) + if shortcut == "Alt-1" or shortcut == "Alt-2" or shortcut == "Alt-3" then + self:SetWorkMode(tonumber(shortcut:sub(-1))) + ObjModified(self) + return "break" + else + return XEditorBrushTool.OnShortcut(self, shortcut, ...) + end +end + +function XPassabilityBrush:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end diff --git a/CommonLua/Editor/XEditor/XPlaceMultipleObjectsTool.lua b/CommonLua/Editor/XEditor/XPlaceMultipleObjectsTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..f70dfcab883b4410a4051dbb21e2295daa12aca6 --- /dev/null +++ b/CommonLua/Editor/XEditor/XPlaceMultipleObjectsTool.lua @@ -0,0 +1,29 @@ +DefineClass.XPlaceMultipleObjectsTool = { + __parents = { "XEditorBrushTool", "XEditorObjectPalette", "XPlaceMultipleObjectsToolBase" }, + properties = { + slider = true, persisted_setting = true, auto_select_all = true, + { id = "AngleDeviation", name = "Angle deviation", editor = "number", default = 0, min = 0, max = 180, step = 1, }, + { id = "Scale", editor = "number", default = 100, min = 10, max = 250, step = 1, }, + { id = "ScaleDeviation", name = "Scale deviation", editor = "number", default = 0, min = 0, max = 100, step = 1, }, + { id = "ColorMin", name = "Color min", editor = "color", default = RGB(100, 100, 100), }, + { id = "ColorMax", name = "Color max", editor = "color", default = RGB(100, 100, 100), }, + }, + + ToolTitle = "Place multiple objects", + + ActionSortKey = "06", + ActionIcon = "CommonAssets/UI/Editor/Tools/PlaceMultipleObject.tga", + ActionShortcut = "A", +} + +function XPlaceMultipleObjectsTool:GetParams() + return self.terrain_normal, self:GetScale(), self:GetScaleDeviation(), self:GetAngleDeviation(), self:GetColorMin(), self:GetColorMax() +end + +function XPlaceMultipleObjectsTool:GetClassesForDelete() + return self:GetObjectClass() +end + +function XPlaceMultipleObjectsTool:GetClassesForPlace() + return self:GetObjectClass() +end \ No newline at end of file diff --git a/CommonLua/Editor/XEditor/XPlaceMultipleObjectsToolBase.lua b/CommonLua/Editor/XEditor/XPlaceMultipleObjectsToolBase.lua new file mode 100644 index 0000000000000000000000000000000000000000..9610b92cec856dbe4bdf42547074354a52ae2172 --- /dev/null +++ b/CommonLua/Editor/XEditor/XPlaceMultipleObjectsToolBase.lua @@ -0,0 +1,175 @@ +DefineClass.XPlaceMultipleObjectsToolBase = { + __parents = { "XEditorBrushTool" }, + properties = { + { id = "Distance", editor = "number", default = 5 * guim, scale = "m", min = guim , max = 100 * guim, step = guim / 10, + slider = true, persisted_setting = true, auto_select_all = true, sort_order = -1, }, + { id = "MinDistance", name = "Min distance", editor = "number", default = 0, scale = "m", min = 0, max = function(self) return self:GetDistance() end, step = guim / 10, + slider = true, persisted_setting = true, auto_select_all = true, sort_order = -1, }, + }, + + deleted_objects = false, + new_objects = false, + new_positions = false, + box_changed = false, + init_terrain_type = false, + terrain_normal = false, + distance_visualization = false, +} + +function XPlaceMultipleObjectsToolBase:Init() + self.distance_visualization = Mesh:new() + self.distance_visualization:SetMeshFlags(const.mfWorldSpace) + self.distance_visualization:SetShader(ProceduralMeshShaders.mesh_linelist) + self.distance_visualization:SetPos(point(0, 0)) +end + +function XPlaceMultipleObjectsToolBase:Done() + self.distance_visualization:delete() +end + +function XPlaceMultipleObjectsToolBase:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Distance" then + self:SetMinDistance(Min(self:GetMinDistance(), self:GetDistance())) + end +end + +function XPlaceMultipleObjectsToolBase:UpdateCursor() + local v_pstr = self:CreateCircleCursor() + + local strength = self:GetCursorHeight() + if strength then + v_pstr:AppendVertex(point(0, 0, 0)) + v_pstr:AppendVertex(point(0, 0, strength)) + end + + self.cursor_mesh:SetMeshFlags(self.cursor_default_flags + self:GetCursorExtraFlags()) + local pt = self:GetWorldMousePos() + local radius = self:GetCursorRadius() + self.box_changed = box(pt:x() - radius, pt:y() - radius, pt:x() + radius, pt:y() + radius) + self.box_changed = terrain.ClampBox(self.box_changed) + local distance = self:GetDistance() + local bxDistanceGrid = self.box_changed / distance + local vpstr = pstr("") + for i = bxDistanceGrid:miny(), bxDistanceGrid:maxy() do + for j = bxDistanceGrid:minx(), bxDistanceGrid:maxx() do + local ptDistance = point(j, i) + local ptReal = ptDistance * distance + local color = self:GetCursorColor() + if ptReal:Dist(pt) <= self:GetCursorRadius() then + ptReal = ptReal:SetZ(terrain.GetHeight(ptReal)) + vpstr:AppendVertex(ptReal + point(-200, -200, 0), color) + vpstr:AppendVertex(ptReal + point(200, 200, 0)) + vpstr:AppendVertex(ptReal + point(-200, 200, 0), color) + vpstr:AppendVertex(ptReal + point(200, -200, 0)) + end + end + end + self.distance_visualization:SetMesh(vpstr) + self.cursor_mesh:SetMesh(v_pstr) + self.cursor_mesh:SetPos(GetTerrainCursor()) +end + +function XPlaceMultipleObjectsToolBase:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end + +function XPlaceMultipleObjectsToolBase:MarkObjectsForDelete() + local radius = self:GetCursorRadius() + MapForEach(GetTerrainCursor(), radius, function(o) + local classes = self:GetClassesForDelete() or {} + if not self.deleted_objects[o] and not self.new_objects[o] and XEditorFilters:IsVisible(o) and IsKindOfClasses(o, classes) + and (not self.init_terrain_type or self.init_terrain_type[terrain.GetTerrainType(o:GetPos())])then + self.deleted_objects[o] = true + o:ClearEnumFlags(const.efVisible) + end + end) +end + +function XPlaceMultipleObjectsToolBase:PlaceObjects(pt) + local newobjs = {} + local distance = self:GetDistance() + local min_distance = self:GetMinDistance() + local bxDistanceGrid = self.box_changed / distance + for i = bxDistanceGrid:miny(), bxDistanceGrid:maxy() do + for j = bxDistanceGrid:minx(), bxDistanceGrid:maxx() do + local ptDistance = point(j, i) + local ptReal = ptDistance * distance + local offset = (distance - min_distance) / 2 + local randX = -offset + AsyncRand(2 * offset) + local randY = -offset + AsyncRand(2 * offset) + ptReal = ptReal + point(randX, randY) + local classes = self:GetClassesForPlace(ptReal) + local class = classes and classes[AsyncRand(#classes) + 1] + local terrainNormal, scale, scaleDeviation, angle, colorMin, colorMax = self:GetParams(ptReal) + if ptReal:InBox2D(GetMapBox()) and ptReal:Dist(pt) <= self:GetCursorRadius() and (not self.new_positions[j] or not self.new_positions[j][i]) and class and scale + and (not self.init_terrain_type or self.init_terrain_type[terrain.GetTerrainType(ptReal)]) then + local axis = terrainNormal and terrain.GetTerrainNormal(ptReal) or axis_z + local obj = XEditorPlaceObject(class) + scaleDeviation = scaleDeviation == 0 and 0 or -scaleDeviation + AsyncRand(2 * scaleDeviation) + scale = Clamp(MulDivRound(scale, 100 + scaleDeviation, 100), 10, 250) + angle = angle * 60 + angle = AsyncRand(-angle, angle) + local minR, minG, minB = GetRGB(colorMin) + local maxR, maxG, maxB = GetRGB(colorMax) + minR, maxR = MinMax(minR, maxR) + minG, maxG = MinMax(minG, maxG) + minB, maxB = MinMax(minB, maxB) + local color = RGB(AsyncRand(minR, maxR), AsyncRand(minG, maxG), AsyncRand(minB, maxB)) + obj:SetPos(ptReal) + obj:SetInvalidZ() + obj:SetScale(scale) + obj:SetOrientation(axis, angle) + obj:SetColorModifier(color) + obj:RestoreHierarchyEnumFlags() -- will rebuild surfaces if required + obj:SetHierarchyEnumFlags(const.efVisible) + obj:SetGameFlags(const.gofPermanent) + obj:SetCollection(Collections[editor.GetLockedCollectionIdx()]) + self.new_positions[j] = self.new_positions[j] or {} + self.new_positions[j][i] = true + self.new_objects[obj] = true + newobjs[#newobjs + 1] = obj + end + end + end + Msg("EditorCallback", "EditorCallbackPlace", newobjs) +end + +function XPlaceMultipleObjectsToolBase:StartDraw(pt) + SuspendPassEdits("XEditorPlaceMultipleObjects") + self.deleted_objects = {} + self.new_objects = {} + self.new_positions = {} + if terminal.IsKeyPressed(const.vkControl) then self.init_terrain_type = {[terrain.GetTerrainType(pt)] = true} end + if terminal.IsKeyPressed(const.vkAlt) then self.terrain_normal = true end +end + +function XPlaceMultipleObjectsToolBase:Draw(pt1, pt2) + self:MarkObjectsForDelete() + self:PlaceObjects(pt2) +end + +function XPlaceMultipleObjectsToolBase:EndDraw(pt) + local objs = table.validate(table.keys(self.deleted_objects)) + for _, obj in ipairs(objs) do obj:SetEnumFlags(const.efVisible) end + XEditorUndo:BeginOp({ objects = objs, name = "Placed multiple objects" }) + Msg("EditorCallback", "EditorCallbackDelete", objs) + for _, obj in ipairs(objs) do obj:delete() end + XEditorUndo:EndOp(table.keys(self.new_objects)) + + ResumePassEdits("XEditorPlaceMultipleObjects", true) + self.deleted_objects = false + self.new_objects = false + self.new_positions = false + self.init_terrain_type = false + self.terrain_normal = false +end + +function XPlaceMultipleObjectsToolBase:GetParams() +end + +function XPlaceMultipleObjectsToolBase:GetClassesForDelete() +end + +function XPlaceMultipleObjectsToolBase:GetClassesForPlace(pt) +end \ No newline at end of file diff --git a/CommonLua/Editor/XEditor/XPlaceObjectTool.lua b/CommonLua/Editor/XEditor/XPlaceObjectTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..8d8ea9901ee3b9001cb4ee126e77094a08e5f473 --- /dev/null +++ b/CommonLua/Editor/XEditor/XPlaceObjectTool.lua @@ -0,0 +1,246 @@ +-- dummy placement helper for the default "Select" mode +DefineClass.XPlaceObjectsHelper = { + __parents = { "XEditorPlacementHelper" }, + + InXPlaceObjectTool = true, + AllowRotationAfterPlacement = true, + HasSnapSetting = true, + Title = "Place objects (N)", + ActionIcon = "CommonAssets/UI/Editor/Tools/SelectObjects.tga", + ActionShortcut = "N", +} + +DefineClass.XPlaceObjectTool = { + __parents = { "XEditorObjectPalette", "XEditorPlacementHelperHost" }, + + ToolTitle = "Place single object", + Description = { + "(drag after placement to rotate object)", + }, + ActionSortKey = "05", + ActionIcon = "CommonAssets/UI/Editor/Tools/PlaceSingleObject.tga", + ActionShortcut = "N", + + helper_class = "XPlaceObjectsHelper", + ui_state = "none", -- "none" - no object attached to cursor, "cursor" - dragging an object to place, "rotate" - dragging with LMB held to rotate + cursor_object = false, + objects = false, -- stores cursor_object in a table to pass to placement helpers and undo operations + feedback_line = false, +} + +function XPlaceObjectTool:Init() + self:CreateCursorObject() +end + +function XPlaceObjectTool:Done() + self.desktop:SetMouseCapture() -- finalize pending operation + self:DeleteCursorObject() +end + +function XPlaceObjectTool:OnEditorSetProperty(prop_id, ...) + if prop_id == "ObjectClass" or prop_id == "Category" then + self:CreateCursorObject() + end + XEditorObjectPalette.OnEditorSetProperty(self, prop_id, ...) +end + +function XEditorPlacementHelperHost:UpdatePlacementHelper() + self:CreateCursorObject() +end + +function XPlaceObjectTool:CreateCursorObject(id) + self:DeleteCursorObject() + id = id or table.rand(self:GetObjectClass()) + if id then + local obj = XEditorPlaceObject(id, "cursor_object") + if obj then + obj:SetHierarchyEnumFlags(const.efVisible) + obj:ClearHierarchyEnumFlags(const.efCollision + const.efWalkable + const.efApplyToGrids) + EditorCursorObjs[obj] = true -- excludes the object from being processed in certain cases + obj:SetCollection(Collections[editor.GetLockedCollectionIdx()]) + self.cursor_object = obj + self.objects = { self.cursor_object } + self.ui_state = "cursor" + self:UpdateCursorObject() + assert(not self.placement_helper.operation_started) + self.placement_helper:StartOperation(terminal.GetMousePos(), self.objects) + Msg("EditorCallback", "EditorCallbackPlaceCursor", table.copy(self.objects)) + return obj + end + end +end + +function XPlaceObjectTool:UpdateCursorObject() + XEditorSnapPos(self.cursor_object, editor.GetPlacementPoint(GetTerrainCursor()), point30) +end + +function XPlaceObjectTool:PlaceCursorObject() + local obj = self.cursor_object + obj:RestoreHierarchyEnumFlags() -- will rebuild surfaces if required + obj:SetHierarchyEnumFlags(const.efVisible) + EditorCursorObjs[obj] = nil + obj:SetGameFlags(const.gofPermanent) + + XEditorUndo:BeginOp{ name = "Placed 1 object" } + editor.AddToSel(obj) + + if self.placement_helper.AllowRotationAfterPlacement then + self.desktop:SetMouseCapture(self) + ForceHideMouseCursor("XPlaceObjectTool") + SuspendPassEdits("XPlaceObjectTool") + self.ui_state = "rotate" + else + self:FinalizePlacement() + end +end + +function XPlaceObjectTool:FinalizePlacement() + XEditorUndo:EndOp(self.objects) + Msg("EditorCallback", "EditorCallbackPlace", table.copy(self.objects)) + self.cursor_object = nil + self:CreateCursorObject() +end + +function XPlaceObjectTool:DeleteCursorObject() + if self.placement_helper.operation_started then + self.placement_helper:EndOperation(self.objects) + end + local obj = self.cursor_object + if obj then + -- use pcall, as some objects involed in gameplay will crash when created/deleted from the editor + local ok = pcall(obj.delete, self.cursor_object) + if not ok and IsValid(obj) then -- a Done method failed, at least delete the C object + CObject.delete(obj) + end + self.cursor_object = nil + end + self.objects = nil + self.ui_state = "none" +end + + +----- Mouse behavior - rotate object after placement + +function XPlaceObjectTool:OnMouseButtonDown(pt, button) + if button == "L" and self.ui_state == "cursor" then + assert(self.placement_helper.operation_started) + self.placement_helper:EndOperation(self.objects) + self:PlaceCursorObject() + return "break" + end + return XEditorTool.OnMouseButtonDown(self, pt, button) +end + +function XPlaceObjectTool:OnMousePos(pt, button) + XEditorRemoveFocusFromToolbars() + + if self.ui_state == "cursor" then + if self.helper_class == "XPlaceObjectsHelper" then + self:UpdateCursorObject() + else + assert(self.placement_helper.operation_started) + self.placement_helper:PerformOperation(pt, self.objects) + end + return "break" + elseif self.ui_state == "rotate" then + local obj = self.cursor_object + local pt1, pt2 = obj:GetPos(), GetTerrainCursor() + if pt1:Dist2D(pt2) > 10 * guic then + local angle = XEditorSettings:AngleSnap(CalcOrientation(pt2, pt1)) + XEditorSetPosAxisAngle(obj, pt1, obj:GetAxis(), angle) + self:CreateFeedbackLine(pt1, pt2) + end + return "break" + end + return XEditorTool.OnMousePos(self, pt, button) +end + +function XPlaceObjectTool:OnMouseButtonUp(pt, button) + if button == "L" and self.ui_state == "rotate" then + self.desktop:SetMouseCapture() -- will call OnCaptureLost + return "break" + end + return XEditorTool.OnMouseButtonUp(self, pt, button) +end + +function XPlaceObjectTool:OnCaptureLost() + self:DeleteFeedbackLine() + UnforceHideMouseCursor("XPlaceObjectTool") + ResumePassEdits("XPlaceObjectTool", "ignore_errors") + self:FinalizePlacement() +end + +function XPlaceObjectTool:CreateFeedbackLine(pt1, pt2) + if not self.feedback_line then + self.feedback_line = Mesh:new() + self.feedback_line:SetShader(ProceduralMeshShaders.mesh_linelist) + self.feedback_line:SetMeshFlags(const.mfWorldSpace) + self.feedback_line:SetPos(point30) + end + local str = pstr() + str:AppendVertex(pt1:SetTerrainZ()) + str:AppendVertex(pt2:SetTerrainZ()) + self.feedback_line:SetMesh(str) +end + +function XPlaceObjectTool:DeleteFeedbackLine() + if self.feedback_line then + self.feedback_line:delete() + self.feedback_line = nil + end +end + +----- Keyboard - auto-focus Filter field in the tool settings, route keystrokes to Ged if outside the game, shortcuts, etc. + +function XPlaceObjectTool:OnShortcut(shortcut, source, ...) + -- don't change tool modes, allow undo, etc. while in the process of dragging + if terminal.desktop:GetMouseCapture() and shortcut ~= "Ctrl-F1" and shortcut ~= "Escape" then + return "break" + end + + if XEditorPlacementHelperHost.OnShortcut(self, shortcut, source, ...) == "break" then + return "break" + end + + if self.ui_state == "cursor" then + if shortcut == "[" or shortcut == "]" then + local dir = shortcut == "[" and -1 or 1 + local classes = self:GetObjectClass() + SuspendPassEdits("XPlaceObjectToolCycle") + if #classes > 1 then + -- cycle between selected objects + local idx = table.find(classes, self.cursor_object.class) + dir + if idx <= 0 then + idx = #classes + elseif idx > #classes then + idx = 1 + end + self:CreateCursorObject(classes[idx]) + else + -- cycle using the standard editor cycling logic + local obj = CycleObjSubvariant(self.cursor_object, dir) + self:CreateCursorObject(obj.class) + obj:delete() + self:SetObjectClass{obj.class} + ObjModified(self) + end + ResumePassEdits("XPlaceObjectToolCycle") + return "break" + elseif shortcut == "Up" then + return "break" + elseif shortcut == "Down" then + return "break" + end + end + return XEditorSettings.OnShortcut(self, shortcut, source, ...) +end + +function OnMsg.EditorCallback(id, objects) + if id == "EditorCallbackPlace" and rawget(CObject, "GenerateFadeDistances") then + for _, obj in ipairs(objects) do + if IsValid(obj) then + obj:GenerateFadeDistances() + end + end + end +end diff --git a/CommonLua/Editor/XEditor/XRampBrush.lua b/CommonLua/Editor/XEditor/XRampBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..c80bdff896a73b1f4acb59208de5c2b65a527045 --- /dev/null +++ b/CommonLua/Editor/XEditor/XRampBrush.lua @@ -0,0 +1,70 @@ +DefineClass.XRampBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + editor = "number", slider = true, persisted_setting = true, auto_select_all = true, + { id = "Falloff", default = 100, scale = "%", min = 0, max = 250 }, + }, + + ToolSection = "Height", + ToolTitle = "Ramp", + Description = { + "Creates an inclined plane between two points.", + "( to align to world directions)", + }, + ActionSortKey = "14", + ActionIcon = "CommonAssets/UI/Editor/Tools/slope.tga", + ActionShortcut = "/", + + old_bbox = false, + ramp_grid = false, + mask_grid = false, +} + +function XRampBrush:Init() + local w, h = terrain.HeightMapSize() + self.ramp_grid = NewComputeGrid(w, h, "F") + self.mask_grid = NewComputeGrid(w, h, "F") +end + +function XRampBrush:Done() + editor.ClearOriginalHeightGrid() + self.ramp_grid:free() + self.mask_grid:free() +end + +function XRampBrush:StartDraw(pt) + XEditorUndo:BeginOp{ height = true, name = "Changed height" } + editor.StoreOriginalHeightGrid(true) -- true = use for GetTerrainCursor +end + +function XRampBrush:Draw(pt1, pt2) + pt1 = self.first_pos + if pt1 == pt2 then return end + self.mask_grid:clear() + self.ramp_grid:clear() + + local h1 = editor.GetOriginalHeight(pt1) / const.TerrainHeightScale + local h2 = editor.GetOriginalHeight(pt2) / const.TerrainHeightScale + local inner_radius, outer_radius = self:GetCursorRadius() + local bbox = editor.DrawMaskSegment(self.mask_grid, pt1, pt2, inner_radius, outer_radius, "max") + editor.DrawMaskSegment(self.ramp_grid, pt1, pt2, outer_radius, outer_radius, "set", h1, h2) + + local extended_box = AddRects(self.old_bbox or bbox, bbox) + editor.SetHeightWithMask(self.ramp_grid, self.mask_grid, extended_box) + Msg("EditorHeightChanged", false, extended_box) + self.old_bbox = bbox +end + +function XRampBrush:EndDraw(pt1, pt2) + local _, outer_radius = self:GetCursorRadius() + local bbox = editor.GetSegmentBoundingBox(pt1, pt2, outer_radius, self:IsCursorSquare()) + local extended_box = AddRects(self.old_bbox or bbox, bbox) + self.old_bbox = nil + Msg("EditorHeightChanged", true, extended_box) + XEditorUndo:EndOp(nil, extended_box) +end + +function XRampBrush:GetCursorRadius() + local inner_size = self:GetSize() * 100 / (100 + 2 * self:GetFalloff()) + return inner_size / 2, self:GetSize() / 2 +end diff --git a/CommonLua/Editor/XEditor/XSelectObjectsTool.lua b/CommonLua/Editor/XEditor/XSelectObjectsTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..ea94bbdcfde57a319533d91410745cd4bde0eb0d --- /dev/null +++ b/CommonLua/Editor/XEditor/XSelectObjectsTool.lua @@ -0,0 +1,524 @@ +-- dummy placement helper for the default "Select" mode +DefineClass.XSelectObjectsHelper = { + __parents = { "XEditorPlacementHelper" }, + + InXSelectObjectsTool = true, + HasSnapSetting = true, + Title = "Edit objects (Q)", + ActionIcon = "CommonAssets/UI/Editor/Tools/SelectObjects.tga", + ActionShortcut = "Escape", + ActionShortcut2 = "Q", +} + +DefineClass.XSelectObjectsTool = { + __parents = { "XEditorTool", "XEditorPlacementHelperHost", "XEditorRotateLogic", "XSelectObjectsToolCustomFilter" }, + + ToolTitle = "Edit objects", + ToolSection = "Objects", + Description = function(self) + local descr = self and self.placement_helper:GetDescription() + if descr then return { descr } end + return { "(hold to clone, to rotate, to scale)\n(use and to cycle between object variants)\n( to select/filter by class)" } + end, + ActionIcon = "CommonAssets/UI/Editor/Tools/SelectObjects.tga", + ActionSortKey = "01", + ActionShortcut = "Q", + ToolKeepSelection = true, + + helper_class = "XSelectObjectsHelper", + edit_operation = false, + highlighted_objs = false, + selection_box = false, + selection_box_mesh = false, + selection_box_enable = false, + editing_line_mesh = false, + init_selection = false, + init_mouse_pos = false, + init_move_positions = false, + init_rotate_data = false, + init_scales = false, + last_mouse_pos = false, + last_mouse_obj = false, + last_mouse_click = false, +} + +function XSelectObjectsTool:Init() + self:CreateThread("fixup_hovered_object", self.FixupHoveredObject, self) +end + +function XSelectObjectsTool:Done() + self.desktop:SetMouseCapture() -- finalize pending operation + self:HighlightObjects(false) +end + +function XSelectObjectsTool:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "WireCurve" then + Msg("WireCurveTypeChanged", self:GetProperty("WireCurve"), old_value) + end +end + +function XSelectObjectsTool:UpdatePlacementHelper() + local helper = self.placement_helper + helper.local_cs = helper.HasLocalCSSetting and GetLocalCS() + helper.snap = helper.HasSnapSetting and XEditorSettings:GetSnapEnabled() + XEditorUpdateToolbars() +end + +function XSelectObjectsTool:CantSnapObjects() + return not g_Classes[self:GetHelperClass()].HasSnapSetting and "This mode does not support snapping." +end + +function XSelectObjectsTool:HighlightObjects(objs) + objs = XEditorSettings:GetHighlightOnHover() and objs + local highlighted = {} + if objs then + objs = editor.SelectionPropagate(objs, "for_rollover") + for _, obj in ipairs(objs) do + if IsValid(obj) then + if IsKindOf(obj, "CollideLuaObject") then + obj:SetHighlighted(true) + else + obj:SetHierarchyGameFlags(const.gofEditorHighlight) + end + highlighted[obj] = true + end + end + end + for _, obj in ipairs(self.highlighted_objs) do + if IsValid(obj) and not highlighted[obj] then + if IsKindOf(obj, "CollideLuaObject") then + obj:SetHighlighted(false) + else + obj:ClearHierarchyGameFlags(const.gofEditorHighlight) + end + end + end + self.highlighted_objs = objs and table.copy(objs) +end + +function XSelectObjectsTool:StartEditOperation(operation) + if not self.edit_operation then + XEditorUndo:BeginOp({ + name = operation == "PlacementHelper" and + string.format(self.placement_helper.UndoOpName, #editor.GetSel()) or + string.format("%sd %d object(s)", operation, #editor.GetSel()), + objects = operation == "Clone" and empty_table or editor.GetSel(), + edit_op = true, + }) + SuspendPassEditsForEditOp() + self.edit_operation = operation + end +end + +function XSelectObjectsTool:EndEditOperation() + if self.edit_operation then + ResumePassEditsForEditOp() + local sel = editor.GetSel() + editor.SetSel(editor.SelectionPropagate(sel)) + XEditorUndo:EndOp(sel) + self.edit_operation = false + end +end + +function XSelectObjectsTool:SelectNextObjectAtCursor() + XEditorUndo:BeginOp() + local obj = XEditorSelectSingleObjects == 1 and + GetNextObjectAtScreenPos(CanSelect, "topmost", selo()) or + GetNextObjectAtScreenPos(CanSelect, "topmost", "collection", selo()) + editor.SetSel(editor.SelectionPropagate({obj})) + XEditorUndo:EndOp() +end + +function XSelectObjectsTool:OnMouseButtonDoubleClick(pt, button) + local obj = GetObjectAtCursor() + if button == "L" and obj then + if terminal.IsKeyPressed(const.vkRalt) then + self:SelectNextObjectAtCursor() + return "break" + elseif terminal.IsKeyPressed(const.vkAlt) then + local sel = table.copy(editor.GetSel()) + XEditorUndo:BeginOp() + + -- if selection is a single object, select objects of that class on the screen; otherwise, filter current selection + if #sel == 1 or terminal.IsKeyPressed(const.vkShift) then + if not terminal.IsKeyPressed(const.vkShift) then + editor.ClearSel() + end + local locked = Collection.GetLockedCollection() + editor.AddToSel(XEditorGetVisibleObjects(function(o) + return o.class == obj.class and (not locked or o:GetRootCollection() == locked) + end)) + else + for i = #sel, 1, -1 do + if sel[i].class ~= obj.class then + table.remove(sel, i) + end + end + editor.ClearSel() + editor.AddToSel(sel) + end + + XEditorUndo:EndOp() + return "break" + end + end +end + +function XSelectObjectsTool:OnMouseButtonDown(pt, button) + self:SetFocus() + + if XEditorPlacementHelperHost.OnMouseButtonDown(self, pt, button) then + XPopupMenu.ClosePopupMenus() + return "break" + end + + if button == "L" then + XPopupMenu.ClosePopupMenus() + self.desktop:SetMouseCapture(self) + + local obj = GetObjectAtCursor() + local terrain_pos = GetTerrainCursor() + self.init_mouse_pos = { terrain = terrain_pos, screen = pt, time = GetPreciseTicks() } + if obj then -- prepare data for a move operation + local ptBase = obj:GetPos():SetTerrainZ() + local _, ptScreenAtBase = GameToScreen(ptBase) + self.init_mouse_pos.mouse_offset = ptScreenAtBase - pt + self.init_mouse_pos.terrain_base = ptBase + end + self.last_mouse_click = terrain_pos + + if obj and terminal.IsKeyPressed(const.vkRalt) then + self:SelectNextObjectAtCursor() + elseif not (selo() and terminal.IsKeyPressed(const.vkAlt)) then + if obj and terminal.IsKeyPressed(const.vkRshift) then + XEditorUndo:BeginOp() + if editor.IsSelected(obj) then + editor.RemoveFromSel(editor.SelectionPropagate({obj})) + else + editor.AddToSel(editor.SelectionPropagate({obj})) + end + XEditorUndo:EndOp() + return "break" + end + if not obj or terminal.IsKeyPressed(const.vkShift) and not editor.IsSelected(obj) then + XEditorUndo:BeginOp() + if not terminal.IsKeyPressed(const.vkShift) then + editor.ClearSel() + elseif obj then + editor.AddToSel(editor.SelectionPropagate({obj})) + end + self.init_selection = table.copy(editor.GetSel()) + self.selection_box_enable = true + return "break" + end + if not (obj and editor.IsSelected(obj)) then + editor.ChangeSelWithUndoRedo(editor.SelectionPropagate({obj})) + end + XEditorPlacementHelperHost.OnMouseButtonDown(self, pt, button) -- try again after object is selected + end + return "break" + elseif button == "R" then + if XEditorIsContextMenuOpen() and #editor.GetSel() > 0 then + editor.ClearSelWithUndoRedo() + end + XPopupMenu.ClosePopupMenus() + end + return XEditorTool.OnMouseButtonDown(self, pt, button) +end + +function XSelectObjectsTool:OnMousePos(pt) + local obj = GetObjectAtCursor() + self.last_mouse_pos = pt + self.last_mouse_obj = obj + XEditorRemoveFocusFromToolbars() + + local operation = self.edit_operation or + terminal.IsKeyPressed(const.vkControl) and "Clone" or + terminal.IsKeyPressed(const.vkAlt) and "Rotate" or + terminal.IsKeyPressed(const.vkShift) and "Scale" + + if self.placement_helper.operation_started then + if operation == "Clone" then + self.placement_helper:EndOperation() + self:StartEditOperation("Clone") + XEditorPlacementHelperHost.OnMousePos(self, pt) + self:Clone() + self.placement_helper:StartOperation(pt, editor.GetSel()) + self.edit_operation = "PlacementHelper" + else + self:StartEditOperation("PlacementHelper") + XEditorPlacementHelperHost.OnMousePos(self, pt) + end + self:HighlightObjects(false) + return "break" + end + + if self.init_mouse_pos then + if self.selection_box_enable then + self:SelectWithSelectionBox() + self:HighlightObjects(false) + elseif selo() then + -- call StartEditOperation only when objects are actually modified to prevent empty undo operations + local mouse_moved = self.init_mouse_pos.screen:Dist(pt) >= 7 + if operation == "Clone" and obj and editor.IsSelected(obj) and mouse_moved then + self:StartEditOperation("Clone") + self:Clone() + self:Move(pt) + self.edit_operation = "Move" + elseif (operation == "Move" or not operation) and GetPreciseTicks() - self.init_mouse_pos.time > 70 then + self:StartEditOperation("Move") + self:Move(pt) + elseif operation == "Rotate" then + self:StartEditOperation("Rotate") + self:CreateEditingLine() + if not self.init_rotate_data then + self:InitRotation(editor.GetSel()) + else + self:Rotate(editor.GetSel(), not terminal.IsKeyPressed(const.vkShift)) + end + elseif operation == "Scale" then + self:StartEditOperation("Scale") + self:Scale(pt) + end + self:HighlightObjects(editor.GetSel()) + end + return "break" + end + + if not terminal.IsKeyPressed(const.vkMbutton) then -- camera orbit not active + local op_check = self.placement_helper:CheckStartOperation(pt, not "btn_pressed") + if op_check or obj then + local two_pt = self.placement_helper:IsKindOf("XTwoPointAttachHelper") + local objects = (not two_pt and (op_check or obj and editor.IsSelected(obj))) and editor.GetSel() or {obj} + self:HighlightObjects(objects) + else + self:HighlightObjects(false) + end + return "break" + end + + self:HighlightObjects(false) + return "break" +end + +function XSelectObjectsTool:FixupHoveredObject() + -- GetObjectAtCursor uses GetPreciseCursorObj, which needs several frames to get updated, + -- so call OnMousePos for the next several frames after the mouse stopped moving + while true do + if terminal.GetMousePos() == self.last_mouse_pos then + local obj = GetObjectAtCursor() or false + if obj ~= self.last_mouse_obj then + self:OnMousePos(self.last_mouse_pos) + self.last_mouse_obj = obj + end + end + WaitNextFrame() + end +end + +function XSelectObjectsTool:OnMouseButtonUp(pt, button) + if XEditorPlacementHelperHost.OnMouseButtonUp(self, pt, button) then + return "break" + elseif self.init_mouse_pos then + self.desktop:SetMouseCapture() + return "break" + end +end + +function XSelectObjectsTool:OnCaptureLost() + self.init_mouse_pos = false + self.init_move_positions = false + self.init_scales = false + if self.selection_box_enable then + self.selection_box_enable = false + if self.selection_box_mesh then + self.selection_box_mesh:delete() + self.selection_box_mesh = false + end + XEditorUndo:EndOp() + editor.SelectionChanged() + end + if self.editing_line_mesh then + self.editing_line_mesh:delete() + self.editing_line_mesh = false + end + self:CleanupRotation() + XEditorPlacementHelperHost.OnCaptureLost(self) + self:EndEditOperation() +end + +function XSelectObjectsTool:CreateSelectionBox() + local ptOne = self.init_mouse_pos.terrain:SetInvalidZ() + local ptThree = GetTerrainCursor():SetInvalidZ() + local localY = camera.GetDirection() + local localX = Normalize(Cross(axis_z, localY):SetInvalidZ()) + local diagonalNorm = Normalize(ptOne - ptThree) + localX = Dot(diagonalNorm, localX) > 0 and localX or -localX + local angle = diagonalNorm:Len() ~= 0 and Angle3dVectors(diagonalNorm, localX) or 0 + local sin, cos = sincos(angle) + local diagonal = ptOne - ptThree + local localWidth = MulDivRound(diagonal, cos, 4096):Len() + local ptTwo = ptThree + MulDivRound(localX, localWidth, 4096) + local ptFour = ptOne - MulDivRound(localX, localWidth, 4096) + return {ptOne, ptTwo, ptThree, ptFour} +end + +function XSelectObjectsTool:SelectWithSelectionBox() + local selection_box = self:CreateSelectionBox() + local selection_box_mesh = self.selection_box_mesh + if not selection_box_mesh then + selection_box_mesh = Mesh:new() + selection_box_mesh:SetShader(ProceduralMeshShaders.default_polyline) + selection_box_mesh:SetMeshFlags(const.mfWorldSpace + const.mfTerrainDistorted) + selection_box_mesh:SetDepthTest(false) + self.selection_box_mesh = selection_box_mesh + end + + local minX, maxX = MinMax(selection_box[1]:x(), selection_box[2]:x(), selection_box[3]:x(), selection_box[4]:x()) + local minY, maxY = MinMax(selection_box[1]:y(), selection_box[2]:y(), selection_box[3]:y(), selection_box[4]:y()) + local box = box(minX, minY, maxX, maxY) + local w, h = box:sizexyz() + local p, tile = (w + h) / guim, const.HeightTileSize + local step = Max(p, 50) * tile / 100 + PlaceTerrainPoly(selection_box, RGB(255, 255, 255), step, 10, selection_box_mesh) + + PauseInfiniteLoopDetection("SelectWithSelectionBox") + local objects = MapGet(box, "attached", false, "CObject", function(o) + return IsPointInsidePoly2D(o, selection_box) and CanSelect(o) + end) + local sel = editor.SelectionPropagate(objects) + if terminal.IsKeyPressed(const.vkShift) then + table.iappend(sel, self.init_selection) + end + editor.SetSel(sel, "dont_notify") + ResumeInfiniteLoopDetection("SelectWithSelectionBox") +end + +function XSelectObjectsTool:Clone() + local objs = editor.GetSel("permanent") + local clones = XEditorClone(objs) + Msg("EditorCallback", "EditorCallbackClone", clones, objs) + editor.SetSel(clones) +end + +function XSelectObjectsTool:Move(pt) + local objs = editor.GetSel() + if not self.init_move_positions then + self.init_move_positions = {} + for i, o in ipairs(objs) do + self.init_move_positions[i] = o:GetPos() + end + end + + local data = self.init_mouse_pos + local vMove = (ScreenToTerrainPoint(pt + data.mouse_offset) - data.terrain_base):SetZ(0) + local snapBySlabs = HasAlignedObjs(objs) + for i, obj in ipairs(objs) do + XEditorSnapPos(obj, self.init_move_positions[i], vMove, snapBySlabs) + end + Msg("EditorCallback", "EditorCallbackMove", objs) +end + +function XSelectObjectsTool:Scale(pt) + self:CreateEditingLine() + + local objs = editor.GetSel() + if not self.init_scales then + self.init_scales = {} + for i, obj in ipairs(objs) do + self.init_scales[i] = obj:GetScale() + end + end + + local screenHeight = UIL.GetScreenSize():y() + local mouseY = 4096 * (pt:y() - screenHeight / 2) / screenHeight + local initY = 4096 * (self.init_mouse_pos.screen:y() - screenHeight / 2) / screenHeight + local scale + if mouseY < initY then + scale = 100 * (mouseY + 4096)/(initY + 4096) + 300 * (initY - mouseY)/(initY + 4096) + else + scale = 100 * (4096 - mouseY)/(4096 - initY) + 30 * (mouseY - initY)/(4096 - initY) + end + for i, obj in ipairs(objs) do + obj:SetScaleClamped(self.init_scales[i] * scale / 100) + end + Msg("EditorCallback", "EditorCallbackScale", objs) +end + +function XSelectObjectsTool:CreateEditingLine() + local vpstr = pstr("") + local pt = CenterOfMasses(editor.GetSel()) + vpstr:AppendVertex(pt, RGB(255, 255, 255)) + vpstr:AppendVertex(GetTerrainCursor():SetZ(pt:z())) + + if not self.editing_line_mesh then self.editing_line_mesh = PlaceObject("Polyline") end + self.editing_line_mesh:SetMesh(vpstr) + self.editing_line_mesh:SetPos(pt) + self.editing_line_mesh:AddMeshFlags(const.mfWorldSpace) +end + +function XSelectObjectsTool:GetRotateAngle() + local _, pt1 = GameToScreen(self.init_rotate_center) + local _, pt2 = GameToScreen(GetTerrainCursor()) + return CalcOrientation(pt1, pt2) +end + +function XSelectObjectsTool:OnShortcut(shortcut, source, ...) + if shortcut == "Escape" and self:GetHelperClass() == "XSelectObjectsHelper" and #editor.GetSel() > 0 then + editor.ClearSelWithUndoRedo() + return "break" + end + if XEditorPlacementHelperHost.OnShortcut(self, shortcut, source, ...) == "break" then + return "break" + end + + -- don't change tool modes, allow undo, etc. while in the process of dragging + if terminal.desktop:GetMouseCapture() and shortcut ~= "Ctrl-F1" then + return "break" + end + + if shortcut == "Delete" then + CreateRealTimeThread(function() + if self:PreDeleteConfirmation() then + editor.DelSelWithUndoRedo() + end + end) + return "break" + elseif shortcut == "[" or shortcut == "]" then + local dir = shortcut == "[" and -1 or 1 + -- cycle selected objects among available variants + local sel = editor.GetSel() + if sel and #sel > 0 and not self.edit_operation then + local dir = shortcut == "[" and -1 or 1 + XEditorUndo:BeginOp{ objects = sel, name = string.format("Cycled %d objects", #sel) } + SuspendPassEditsForEditOp() + local newsel = {} + for _, obj in ipairs(sel) do + table.insert(newsel, CycleObjSubvariant(obj, dir)) -- produces an undo op for obj + end + ResumePassEditsForEditOp() + XEditorUndo:EndOp() + editor.SetSel(newsel) -- must be AFTER the editor op + end + return "break" + elseif shortcut == "Pageup" or shortcut == "Pagedown" or shortcut == "Shift-Pageup" or shortcut == "Shift-Pagedown" then + local sel = editor.GetSel() + local down = shortcut:ends_with("down") + local dir = (down and point(0, 0, -1) or point(0, 0, 1)) * (terminal.IsKeyPressed(const.vkShift) and guic or 1) + XEditorUndo:BeginOp{ objects = sel, name = string.format("Moved %d objects %s", #sel, down and "down" or "up") } + for _, obj in ipairs(sel) do + obj:SetPos(obj:GetVisualPos() + dir) + end + XEditorUndo:EndOp(sel) + return "break" + end + return XEditorSettings.OnShortcut(self, shortcut, source, ...) +end + +function XSelectObjectsTool:PreDeleteConfirmation() + return true +end + +function XSelectObjectsTool:GetToolTitle() + return XEditorShowCustomFilters and "Custom Selection Filter" or XEditorPlacementHelperHost.GetToolTitle(self) +end diff --git a/CommonLua/Editor/XEditor/XSelectObjectsToolCustomFilter.lua b/CommonLua/Editor/XEditor/XSelectObjectsToolCustomFilter.lua new file mode 100644 index 0000000000000000000000000000000000000000..974a29e9a78e929c93dac97bccc6b4f890d806fc --- /dev/null +++ b/CommonLua/Editor/XEditor/XSelectObjectsToolCustomFilter.lua @@ -0,0 +1,56 @@ +if FirstLoad then + XEditorShowCustomFilters = false +end + +function OnMsg.GameEnterEditor() XEditorShowCustomFilters = false end +function custom_filter_disabled() return not XEditorShowCustomFilters end + +DefineClass.XSelectObjectsToolCustomFilter = { + __parents = { "XEditorObjectPalette" }, + + properties = { + no_edit = custom_filter_disabled, + { id = "ArtSets", editor = false, }, + { id = "Category", editor = false, }, + { id = "_but", editor = "buttons", buttons = { + { name = "Add/remove object(s)", func = "AddRemoveObjects" }, + { name = "Clear all", func = function(self) self:SetFilterObjects(false) end }, + }}, + { id = "SelectionFilter", name = "Selection filter", editor = "text_picker", default = empty_table, multiple = true, + items = function(self) return table.keys2(self:GetFilterObjects(), "sorted") end, + max_rows = 10, small_font = true, read_only = true, + }, + { id = "FilterObjects", name = "Filter objects", editor = "table", default = false, no_edit = true, persisted_setting = true, }, + { id = "FilterMode", name = "Filter mode", editor = "text_picker", default = "On", horizontal = true, items = { "On", "Negate" }, persisted_setting = true, }, + }, + + FocusPropertySingleTime = true, +} + +local prop = table.find_value(XEditorObjectPalette.properties, "id", "Filter") +prop = table.copy(prop) +prop.no_edit = custom_filter_disabled +table.insert(XSelectObjectsToolCustomFilter.properties, 1, prop) + +local prop = table.find_value(XEditorObjectPalette.properties, "id", "ObjectClass") +prop = table.copy(prop) +prop.no_edit = custom_filter_disabled +prop.small_font = true +table.insert(XSelectObjectsToolCustomFilter.properties, 1, prop) + +function XSelectObjectsToolCustomFilter:AddRemoveObjects() + local all_present = true + local objects = self:GetFilterObjects() or {} + for _, value in ipairs(self:GetObjectClass()) do + if not objects[value] then + all_present = false + end + end + + -- remove or add the objects, depending on all_present + for _, value in ipairs(self:GetObjectClass()) do + objects[value] = not all_present or nil + end + self:SetFilterObjects(objects) + ObjModified(self) +end diff --git a/CommonLua/Editor/XEditor/XSmoothHeightBrush.lua b/CommonLua/Editor/XEditor/XSmoothHeightBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..03dbacd25c4a49b2683b0d42687169e5caf26f73 --- /dev/null +++ b/CommonLua/Editor/XEditor/XSmoothHeightBrush.lua @@ -0,0 +1,121 @@ + +DefineClass.XSmoothHeightBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + editor = "number", slider = true, persisted_setting = true, auto_select_all = true, + { id = "Strength", default = 50, scale = "%", min = 10, max = 100, step = 10 }, + { id = "RegardWalkables", name = "Limit to walkables", editor = "bool", default = false }, + }, + + ToolSection = "Height", + ToolTitle = "Smooth height", + Description = { + "Removes jagged edges and softens terrain features." + }, + ActionSortKey = "12", + ActionIcon = "CommonAssets/UI/Editor/Tools/Smooth.tga", + ActionShortcut = "S", + + blurred_grid = false, + mask_grid = false, +} + +function XSmoothHeightBrush:Init() + local w, h = terrain.HeightMapSize() + self.blurred_grid = NewComputeGrid(w, h, "F") + self.mask_grid = NewComputeGrid(w, h, "F") + self:InitBlurredGrid() +end + +function XSmoothHeightBrush:Done() + editor.ClearOriginalHeightGrid() + self.blurred_grid:free() + self.mask_grid:free() +end + +function XSmoothHeightBrush:InitBlurredGrid() + editor.StoreOriginalHeightGrid(false) -- false = don't use for GetTerrainCursor + editor.CopyFromOriginalHeight(self.blurred_grid) + + local blur_size = MulDivRound(self:GetStrength(), self:GetSize(), guim * const.HeightTileSize * 3) + AsyncBlurGrid(self.blurred_grid, Max(blur_size, 1)) +end + +-- called via Msg when height is changed via this brush, or via undo +function XSmoothHeightBrush:UpdateBlurredGrid(bbox) + bbox = terrain.ClampBox(GrowBox(bbox, 512 * guim)) -- TODO: make updating the blurred grid in the changed area only not produce sharp changes at the edges + + local grid_box = bbox / const.HeightTileSize + + -- copy the changed area back into blurred grid, and blur only that area to update it + local height_part = editor.GetGrid("height", bbox) + self.blurred_grid:copyrect(height_part, grid_box - grid_box:min(), grid_box:min()) + height_part:free() + + local blur_size = MulDivRound(self:GetStrength(), self:GetSize(), guim * const.HeightTileSize * 3) + AsyncBlurGrid(self.blurred_grid, grid_box, Max(blur_size, 1)) -- update the blurred version of the terrain grid in the edited box +end + +function OnMsg.EditorHeightChangedFinal(bbox) + local brush = XEditorGetCurrentTool() + if IsKindOf(brush, "XSmoothHeightBrush") then + brush:UpdateBlurredGrid(bbox) + end +end + +function XSmoothHeightBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Strength" or prop_id == "Size" then + self:InitBlurredGrid() + end +end + +function XSmoothHeightBrush:StartDraw(pt) + XEditorUndo:BeginOp{ height = true , name = "Changed height" } + editor.StoreOriginalHeightGrid(false) -- false = don't use for GetTerrainCursor + self.mask_grid:clear() +end + +function XSmoothHeightBrush:Draw(pt1, pt2) + local _, outer_radius = self:GetCursorRadius() + local bbox = editor.DrawMaskSegment(self.mask_grid, pt1, pt2, self:GetSize() / 4, self:GetSize(), "max", self:GetStrength() * 1.0 / 100.0) + editor.SetHeightWithMask(self.blurred_grid, self.mask_grid, bbox) + + if self:GetRegardWalkables() then + editor.ClampHeightToWalkables(bbox) + end + Msg("EditorHeightChanged", false, bbox) +end + +function XSmoothHeightBrush:EndDraw(pt1, pt2, invalid_box) + local bbox = editor.GetSegmentBoundingBox(pt1, pt2, self:GetSize(), self:IsCursorSquare()) + Msg("EditorHeightChanged", true, bbox) + XEditorUndo:EndOp(nil, invalid_box) +end + +function XSmoothHeightBrush:OnShortcut(shortcut, source, ...) + if XEditorBrushTool.OnShortcut(self, shortcut, source, ...) then + return "break" + end + + local key = string.gsub(shortcut, "^Shift%-", "") -- ignore Shift, use it to decrease step size + local divisor = terminal.IsKeyPressed(const.vkShift) and 10 or 1 + if key == "+" or key == "Numpad +" then + self:SetStrength(self:GetStrength() + 10) + return "break" + elseif key == "-" or key == "Numpad -" then + self:SetStrength(self:GetStrength() - 10) + return "break" + end +end + +function XSmoothHeightBrush:GetCursorHeight() + return self:GetStrength() / 3 * guim +end + +function XSmoothHeightBrush:GetCursorRadius() + return self:GetSize() / 2, self:GetSize() / 2 +end + +function XSmoothHeightBrush:GetAffectedRadius() + return self:GetSize() +end diff --git a/CommonLua/Editor/XEditor/XStickToCollisionHelper.lua b/CommonLua/Editor/XEditor/XStickToCollisionHelper.lua new file mode 100644 index 0000000000000000000000000000000000000000..25d2496ce326697f0456b0dec3aa871461997059 --- /dev/null +++ b/CommonLua/Editor/XEditor/XStickToCollisionHelper.lua @@ -0,0 +1,38 @@ +if const.maxCollidersPerObject == 0 then + return -- TODO: Remove this check when Bacon is deprecated. All new projects should use the new collisions. +end + +DefineClass.XStickToCollisionHelper = { + __parents = { "XObjectPlacementHelper" }, + + InXSelectObjectsTool = true, + + Title = "Stick to terrain/collision (F)", + Description = false, + ActionSortKey = "5", + ActionIcon = "CommonAssets/UI/Editor/Tools/StickToTerrain.tga", + ActionShortcut = "F", + UndoOpName = "Stuck %d object(s) to collision", + + init_drag_position = false, + init_move_positions = false, +} + +function XStickToCollisionHelper:MoveObjects(mouse_pos, objects) + local vMove = GetTerrainCursor() - self.init_drag_position:SetZ(0) + for i, obj in ipairs(objects) do + local pos = (self.init_move_positions[i] + vMove):SetZ(0) + local offset = (self.init_move_positions[i] - self.init_move_positions[1]):SetZ(0) + local eye, cursor = camera.GetEye(), GetTerrainCursor() + local o, closest, normal = IntersectSegmentWithClosestObj(eye + offset, cursor + offset) + if closest and normal and o ~= obj then + obj:SetPos(closest + normal / 4096) + local rotAxis, rotAngle = GetAxisAngle(normal, self.init_orientations[i][1]) + rotAxis = Normalize(rotAxis) + obj:SetAxisAngle(ComposeRotation(self.init_orientations[i][1], self.init_orientations[i][2], rotAxis, rotAngle)) + else + CollisionAdjustObject(obj, pos, self.init_orientations[i][2]) + end + end + Msg("EditorCallback", "EditorCallbackMove", objects) +end diff --git a/CommonLua/Editor/XEditor/XTerrainTypeBrush.lua b/CommonLua/Editor/XEditor/XTerrainTypeBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..6b16902f8092ffa2fe36e9252c11972d7102ed74 --- /dev/null +++ b/CommonLua/Editor/XEditor/XTerrainTypeBrush.lua @@ -0,0 +1,242 @@ +DefineClass.XTerrainTypeBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + persisted_setting = true, + { id = "FlatOnly", name = "Draw on flat only", editor = "bool", default = false, + persisted_setting = false, no_edit = function(self) return self.draw_on_height end, }, + { id = "DrawOnHeight", name = "Draw on height", editor = "number", scale = "m", default = false, + persisted_setting = false, no_edit = function(self) return not self.draw_on_height end, }, + + { id = "Filter", editor = "text", default = "", allowed_chars = EntityValidCharacters, translate = false, }, + { id = "Texture", name = "Texture ", editor = "texture_picker", default = false, + filter_by_prop = "Filter", + alt_prop = "VerticalTexture", base_color_map = true, + thumb_width = 101, thumb_height = 49, + small_font = true, multiple = true, items = GetTerrainTexturesItems, + help = "Select multiple textures (placed according to pattern's gray levels) by holding Ctrl and/or Shift.\nSelect a vertical texture for sloped terrain by holding Alt.", + }, + + { id = "VerticalTexture", name = "Vertical texture", editor = "text", default = "", no_edit = true }, + { id = "VerticalTexturePreview", name = "Vertical texture", editor = "image", default = "", base_color_map = true, + img_width = 101, img_height = 49, + no_edit = function(self) return self:GetVerticalTexture() == "" end, + persisted_setting = false, + buttons = {{name = "Clear", func = function(self) + self:SetProperty("VerticalTexture", "") + self:GatherTerrainIndices() + ObjModified(self) + end}}, + }, + { id = "VerticalThreshold", name = "Vertical threshold", + editor = "number", default = 45 * 60, min = 0, max = 90 * 60, slider = true, scale = "deg", + no_edit = function(self) return self:GetVerticalTexture() == "" end, + }, + + { id = "Pattern", editor = "texture_picker", default = "CommonAssets/UI/Editor/TerrainBrushesThumbs/default.tga", + thumb_size = 74, + small_font = true, max_rows = 2, base_color_map = true, + items = function() + local files = io.listfiles("CommonAssets/UI/Editor/TerrainBrushesThumbs", "*") + local items = {} + local default + for _, file in ipairs(files) do + local name = file:match("/(%w+)[ .A-Za-z0-1]*$") + if name then + if name == "default" then + default = file + else + items[#items + 1] = { text = name, image = file, value = file, } + end + end + end + table.sortby_field(items, "text") + if default then + table.insert(items, 1, { text = "default", image = default, value = default, }) + end + return items + end + }, + { id = "PatternScale", name = "Pattern scale", + editor = "number", default = 100, min = 10, max = 1000, step = 10, scale = 100, slider = true, exponent = 2, + no_edit = function(self) return self:GetPattern() == self:GetDefaultPropertyValue("Pattern") end, + }, + { id = "PatternThreshold", name = "Pattern threshold", + editor = "number", default = 50, min = 1, max = 99, scale = 100, slider = true, + no_edit = function(self) return self:GetPattern() == self:GetDefaultPropertyValue("Pattern") end, + }, + }, + + terrain_indices = false, + terrain_vertical_index = false, + pattern_grid = false, + start_pt = false, + partial_invalidate_time = 0, + partial_invalidate_box = false, + + draw_on_height = false, + GetDrawOnHeight = function(self) return self.draw_on_height end, + SetDrawOnHeight = function(self, v) self.draw_on_height = v self.FlatOnly = v and true end, + + ToolSection = "Terrain", + ToolTitle = "Terrain texture", + Description = { + "( to draw only over a single terrain)\n( to pick texture / vertical texture)" + }, + ActionSortKey = "19", + ActionIcon = "CommonAssets/UI/Editor/Tools/Terrain.tga", + ActionShortcut = "T", +} + +function XTerrainTypeBrush:Init() + self:GatherTerrainIndices() + self:GatherPattern() + if not self:GetTexture() then + self:SetTexture{GetTerrainTexturesItems()[1].value} + end +end + +function XTerrainTypeBrush:Done() + if self.pattern_grid then + self.pattern_grid:free() + end +end + +local function GetTerrainIndex(texture) + for idx, preset in pairs(TerrainTextures) do + if preset.id == texture then + return idx + end + end +end + +function XTerrainTypeBrush:GatherTerrainIndices() + self.terrain_indices = {} + local textures = self:GetTexture() + for _, texture in ipairs(textures) do + local index = GetTerrainIndex(texture) + if index then + table.insert(self.terrain_indices, index) + end + end + self.terrain_vertical_index = GetTerrainIndex(self:GetProperty("VerticalTexture")) or -1 +end + +function XTerrainTypeBrush:GatherPattern() + if self.pattern_grid then + self.pattern_grid:free() + self.pattern_grid = false + end + if self:GetPattern() ~= self:GetDefaultPropertyValue("Pattern") then + self.pattern_grid = ImageToGrids(self:GetPattern(), false) + end +end + +function XTerrainTypeBrush:GetVerticalTexturePreview() + local terrain_data = table.find_value(GetTerrainTexturesItems(), "value", self:GetVerticalTexture()) + return terrain_data and GetTerrainImage(terrain_data.image) +end + +function XTerrainTypeBrush:IsTerrainSlopeVertical(pt) + local cos = cos(self:GetVerticalThreshold()) + local tile = const.TypeTileSize / 2 + return terrain.GetTerrainNormal(pt):z() <= cos and + terrain.GetTerrainNormal(pt + point(-tile, -tile)):z() <= cos and + terrain.GetTerrainNormal(pt + point( tile, -tile)):z() <= cos and + terrain.GetTerrainNormal(pt + point(-tile, tile)):z() <= cos and + terrain.GetTerrainNormal(pt + point( tile, tile)):z() <= cos +end + +function XTerrainTypeBrush:OnMouseButtonDown(pt, button) + if button == "L" and terminal.IsKeyPressed(const.vkAlt) then + local index = terrain.GetTerrainType(self:GetWorldMousePos()) + if not TerrainTextures[index] then + return "break" + end + local texture = TerrainTextures[index].id + if self:IsTerrainSlopeVertical(GetTerrainCursor()) then + self:SetVerticalTexture(texture) + else + self:SetTexture({texture}) + end + self:GatherTerrainIndices() + ObjModified(self) + return "break" + end + return XEditorBrushTool.OnMouseButtonDown(self, pt, button) +end + +function XTerrainTypeBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Texture" or prop_id == "VerticalTexture" then + self:GatherTerrainIndices() + elseif prop_id == "Pattern" then + self:GatherPattern() + end +end + +function XTerrainTypeBrush:StartDraw(pt) + self.start_pt = pt + self.start_terrain = terminal.IsKeyPressed(const.vkControl) and terrain.GetTerrainType(pt) + self.partial_invalidate_time = 0 + self.partial_invalidate_box = box() + if self.FlatOnly then + self.draw_on_height = self.draw_on_height or terrain.GetHeight(pt) + ObjModified(self) + end + XEditorUndo:BeginOp{terrain_type = true, name = "Changed terrain type"} +end + +function XTerrainTypeBrush:Draw(pt1, pt2) + if #self.terrain_indices == 0 then return end + + local bbox = editor.SetTerrainTypeInSegment( + self.start_pt, self.start_terrain or -1, pt1, pt2, self:GetSize() / 2, + self.terrain_indices, self.terrain_vertical_index, self:GetProperty("VerticalThreshold"), + self.pattern_grid or nil, self:GetProperty("PatternScale"), self:GetProperty("PatternThreshold"), + self.draw_on_height or nil) + + -- the call above does not invalidate the terrain; take care to not invalidate it too often + local time = GetPreciseTicks() + self.partial_invalidate_box:InplaceExtend(bbox) + if time - self.partial_invalidate_time > 30 then + terrain.InvalidateType(self.partial_invalidate_box) + self.partial_invalidate_time = time + self.partial_invalidate_box = box() + end + + hr.TemporalReset() +end + +function XTerrainTypeBrush:EndDraw(pt1, pt2, invalid_box) + terrain.InvalidateType(self.partial_invalidate_box) + Msg("EditorTerrainTypeChanged", invalid_box) + XEditorUndo:EndOp(nil, invalid_box) + self.start_pt = false +end + +function XTerrainTypeBrush:OnShortcut(shortcut, source, ...) + if shortcut == "+" or shortcut == "-" or shortcut == "Numpad +" or shortcut == "Numpad -" then + local textures = self:GetTexture() + if #textures ~= 1 then return end + + local terrains = GetTerrainTexturesItems() + local index = table.find(terrains, "value", textures[1]) + if shortcut == "+" or shortcut == "Numpad +" then + index = index + 1 + index = index > #terrains and 1 or index + else + index = index - 1 + index = index < 1 and #terrains or index + end + self:SetTexture({terrains[index].value}) + self:GatherTerrainIndices() + + return "break" + else + return XEditorBrushTool.OnShortcut(self, shortcut, source, ...) + end +end + +function XTerrainTypeBrush:GetCursorRadius() + local radius = self:GetSize() / 2 + return radius, radius +end diff --git a/CommonLua/Editor/XEditor/XTwoPointAttachHelper.lua b/CommonLua/Editor/XEditor/XTwoPointAttachHelper.lua new file mode 100644 index 0000000000000000000000000000000000000000..ee1663441953364f0acf0ff74b78883d15fcaa16 --- /dev/null +++ b/CommonLua/Editor/XEditor/XTwoPointAttachHelper.lua @@ -0,0 +1,548 @@ +MapVar("g_CollideLuaObjects", false) +MapVar("s_SelectedWires", false) + +DefineClass.CollideLuaObject = { + __parents = { "Object", "EditorObject", "EditorCallbackObject" }, +} + +function CollideLuaObject:Done() + table.remove_entry(g_CollideLuaObjects, self) +end + +function CollideLuaObject:EditorCallbackPlace() + g_CollideLuaObjects = g_CollideLuaObjects or {} + table.insert(g_CollideLuaObjects, self) +end + +function CollideLuaObject:EditorCallbackDelete() + table.remove_entry(g_CollideLuaObjects, self) +end + +CollideLuaObject.EditorEnter = CollideLuaObject.EditorCallbackPlace +CollideLuaObject.EditorExit = CollideLuaObject.EditorCallbackDelete + +function CollideLuaObject:GetBBox() + return box(0, 0, 0, 0) +end + +function CollideLuaObject:TestRay(pos, dir) + return RayIntersectsAABB(pos, dir, self:GetBBox()) +end + +function CollideLuaObject:SetHighlighted(highlight) + if highlight then + self:SetHierarchyGameFlags(const.gofEditorHighlight) + else + self:ClearHierarchyGameFlags(const.gofEditorHighlight) + end +end + +function CollideLuaObjectGetBBox(obj) + return obj:GetBBox() +end + +function CollideLuaObjectTestRay(obj, pos, dir) + return obj:TestRay(pos, dir) +end + +DefineClass.Wire = { + __parents = {"Mesh"}, + + pos1 = false, + pos2 = false, + curve_type = false, + curve_length_percents = false, + color = false, + points_step = false, + + bbox = false, + samples_bboxes = false, +} + +function Wire:CreatePstr() + return pstr("") +end + +DefineClass.SpotHelper = { + __parents = {"Object", "EditorObject", "EditorCallbackObject"}, + + obj = false, + spot_type = false, + spot_relative_index = false, +} + +function SpotHelper:GetAttachPos() + local first = self.obj:GetSpotRange(self.spot_type) + local index = first + self.spot_relative_index + + return self.obj:GetSpotPos(index) +end + +function SpotHelper:GetEditorRelatedObjects() + return { self.obj } +end + +local s_DefaultHelperSpot = "Wire" + +DefineClass.TwoPointsAttachParent = { -- only these support 2-point attaches + __parents = {"Object", "EditorCallbackObject"}, +} + +function TwoPointsAttachParent:EditorCallbackClone(source) + local dlg = GetDialog("XSelectObjectsTool") + if dlg and IsKindOf(dlg.placement_helper, "XTwoPointAttachHelper") then + self:AttachSpotHelpers() + end + local wires = MapGet(true, "TwoPointsAttach") + for _, wire in ipairs(wires) do + if wire.obj1 == source then + CreateTwoPointsAttach(self, wire.spot_type1, wire.spot_index1, wire.obj2, wire.spot_type2, wire.spot_index2, wire.curve, wire.length_percents) + elseif wire.obj2 == source then + CreateTwoPointsAttach(wire.obj1, wire.spot_type1, wire.spot_index1, self, wire.spot_type2, wire.spot_index2, wire.curve, wire.length_percents) + end + end + EditorCallbackObject.EditorCallbackClone(self, source) +end + +function TwoPointsAttachParent:GetEditorRelatedObjects() + return MapGet(true, "TwoPointsAttach", function(obj) return obj.obj1 == self or obj.obj2 == self end) +end + +function TwoPointsAttachParent:AttachSpotHelpers() + local first, last = self:GetSpotRange(s_DefaultHelperSpot) + for spot = first, last do + local helper = PlaceObject("SpotHelper") + self:Attach(helper, spot) + helper.obj = self + helper.spot_type = s_DefaultHelperSpot + helper.spot_relative_index = spot - first + end +end + +local function CreateOrUpdateWire(pos1, pos2, wire, curve_type, curve_length_percents, color, points_step) + color = color or const.clrBlack + points_step = points_step or guim/10 + + -- Do not recreate if all input parameteres are the same + if wire then + if wire.pos1 == pos1 and wire.pos2 == pos2 and wire.curve_type == curve_type and wire.curve_length_percents == curve_length_percents and wire.color == color and wire.points_step == points_step then + return wire + end + end + + local catenary = curve_type == "Catenary" + local axis = pos2 - pos1 + local axis_len = axis:Len() + local wire_length = MulDivTrunc(axis_len, Max(100, curve_length_percents), 100) + local get_curve_params = catenary and CatenaryToPointArcLength or ParabolaToPointArcLength + local a, b, c = get_curve_params(axis, wire_length, 10000) + if not (a and b) then + return wire + end + + local wire = wire or PlaceObject("Wire") + wire.pos1 = pos1 + wire.pos2 = pos2 + wire.curve_length_percents = curve_length_percents + wire.curve_type = curve_type + wire.color = color + wire.points_step = points_step + + local points = wire_length / points_step + local geometry_pstr = wire:CreatePstr() + local axis_len_2d = axis:Len2D() + local samples_bboxes = {} + local wire_pos = (pos1 + pos2) / 2 + local local_pos1 = pos1 - wire_pos + local local_pos2 = pos2 - wire_pos + local wire_width = 30 * guim / 100 + local width_vec = SetLen(Rotate(axis:SetInvalidZ(), 90 * 60), wire_width / 2):SetZ(0) + local curve_value_at = catenary and CatenaryValueAt or ParabolaValueAt + + local thickness = MulDivRound(guim, 1, 100) + local roundness = 10 + local axis2d = axis:SetZ(0) + + local last_pt = point() + local tempPt = point() + local CreateOrUpdateWire_AppendPt = CreateOrUpdateWire_AppendPt + if points > 0 then + for i = 0, points do + local x = axis_len_2d * i / points + local y = curve_value_at(x, a, b, c) + + tempPt:InplaceSet(axis) + InplaceMulDivRound(tempPt, i, points) + tempPt:InplaceSetZ(y) + tempPt:InplaceAdd(local_pos1) + if i > 0 then + CreateOrUpdateWire_AppendPt(geometry_pstr, samples_bboxes, thickness, roundness, color, width_vec, axis2d, last_pt, tempPt) + end + last_pt:InplaceSet(tempPt) + end + end + if last_pt ~= local_pos2 then + CreateOrUpdateWire_AppendPt(geometry_pstr, samples_bboxes, thickness, roundness, color, width_vec, axis2d, last_pt, local_pos2) + end + + -- calc bounding box - needed by the CollideLuaObject:TestRay() + local bbox = samples_bboxes[1] + for i = 2, #samples_bboxes do + bbox:InplaceExtend(samples_bboxes[i]) + end + + wire:SetMesh(geometry_pstr) + wire:SetShader(ProceduralMeshShaders.defer_mesh) + wire:SetDepthTest(true) + wire:SetPos(wire_pos) + wire.samples_bboxes = samples_bboxes + wire.bbox = bbox + + return wire +end + +function CreateWire(pos1, pos2, curve_type, curve_length_percents, color, points_step) + return CreateOrUpdateWire(pos1, pos2, nil, curve_type or "Parabola", curve_length_percents or 101, color, points_step) +end + +DefineClass.XTwoPointAttachHelper = { + __parents = { "XEditorPlacementHelper", "XEditorToolSettings" }, + + -- these properties get appended to the tool that hosts this helper + properties = { + persisted_setting = true, + { id = "WireLength", name = "Wire Length Increase %", editor = "number", min = 101, max = 1000, + persisted_setting = true, default = 150, help = "Percents increase of straight line length", + }, + { id = "WireCurve", name = "Wire Curve", editor = "dropdownlist", persisted_setting = true, + items = {"Parabola", "Catenary"}, default = "Catenary", + }, + { id = "Buttons", name = "Wire Length Increase %", editor = "buttons", default = false, + buttons = {{name = "Clear All Wires", func = function(self) + MapDelete(true, "TwoPointsAttach") + end}}, + }, + }, + + HasLocalCSSetting = false, + HasSnapSetting = false, + InXSelectObjectsTool = true, + UsesCodeRenderables = true, + + Title = "Place wires (2)", + Description = false, + ActionSortKey = "32", + ActionIcon = "CommonAssets/UI/Editor/Tools/PlaceWires.tga", + ActionShortcut = "2", + UndoOpName = "Attached wire", + + wire = false, + start_helper = false, +} + +function XTwoPointAttachHelper:Init() + MapForEach("map", "TwoPointsAttachParent", function(obj) + obj:AttachSpotHelpers() + end) +end + +function XTwoPointAttachHelper:Done() + if self.wire then + DoneObject(self.wire) + end + MapForEach(true, "SpotHelper", function(spot_helper) + DoneObject(spot_helper) + end) +end + +function XTwoPointAttachHelper:GetDescription() + return "(drag to place wires between electricity poles)\n(Shift-Mousewheel changes wire length)" +end + +function XTwoPointAttachHelper:GetSpotHelperCursorObj() + return GetNextObjectAtScreenPos(function(obj) return IsKindOf(obj, "SpotHelper") end) +end + +function XTwoPointAttachHelper:CheckStartOperation(pt) + return not not self:GetSpotHelperCursorObj() +end + +function XTwoPointAttachHelper:StartOperation(pt) + local obj = self:GetSpotHelperCursorObj() + if not obj then return end + + self.operation_started = true + self.start_helper = obj + self:UpdateWire() +end + +function XTwoPointAttachHelper:EndOperation() + DoneObject(self.wire) + self.wire = false + local spot_helper = self:GetSpotHelperCursorObj() + if spot_helper then + local obj1, obj2 = self.start_helper.obj, spot_helper.obj + local spot_type1, spot_type2 = self.start_helper.spot_type, spot_helper.spot_type + local spot_index1, spot_index2 = self.start_helper.spot_relative_index, spot_helper.spot_relative_index + local dlg = GetDialog("XSelectObjectsTool") + local curve = dlg:GetProperty("WireCurve") + local length_percents = dlg:GetProperty("WireLength") + if obj1 ~= obj2 and not GetTwoPointsAttach(obj1, spot_type1, spot_index1, obj2, spot_type2, spot_index2) then + XEditorUndo:BeginOp{name = "Created wire"} + XEditorUndo:EndOp({CreateTwoPointsAttach(obj1, spot_type1, spot_index1, obj2, spot_type2, spot_index2, curve, length_percents)}) + end + end + self.start_helper = false + self.operation_started = false +end + +function XTwoPointAttachHelper:PerformOperation(pt) + self:UpdateWire() +end + +function XTwoPointAttachHelper:UpdateWire() + if not self.start_helper then return end + + local pos1 = self.start_helper:GetAttachPos() + local spot_helper = self:GetSpotHelperCursorObj() + local pos2 = spot_helper and spot_helper:GetAttachPos() or GetTerrainCursor() + local dlg = GetDialog("XSelectObjectsTool") + local curve_type = dlg:GetProperty("WireCurve") + local curve_length = dlg:GetProperty("WireLength") + self.wire = CreateOrUpdateWire(pos1, pos2, self.wire, curve_type, curve_length) +end + +function XTwoPointAttachHelper:OnShortcut(shortcut, source, ...) + local delta + if terminal.IsKeyPressed(const.vkControl) then + if shortcut:ends_with("MouseWheelFwd") then + delta = 1 + elseif shortcut:ends_with("MouseWheelBack") then + delta = -1 + end + end + + if delta then + local tool = XEditorGetCurrentTool() + local meta = self:GetPropertyMetadata("WireLength") + tool:SetProperty("WireLength", Clamp(self:GetProperty("WireLength") + delta, meta.min, meta.max)) + ObjModified(tool) + self:UpdateWire() + return "break" + end +end + +DefineClass.TwoPointsAttach = { + __parents = {"Object", "EditorCallbackObject", "CollideLuaObject"}, + flags = {gofPermanent = true}, + + properties = { + {id = "obj1", name = "Object 1", editor = "object", default = false}, + {id = "spot_type1", name = "Spot Type 1", editor = "text", default = s_DefaultHelperSpot}, + {id = "spot_index1", name = "Spot Index 1", editor = "text", default = "invalid"}, + {id = "obj2", name = "Object 2", editor = "object", default = false}, + {id = "spot_type2", name = "Spot Type 2", editor = "text", default = s_DefaultHelperSpot}, + {id = "spot_index2", name = "Spot Index 2", editor = "text", default = "invalid"}, + {id = "curve", name = "Curve", editor = "text", default = "Catenary"}, + {id = "length_percents", name = "Length Percents", editor = "number", default = 150}, + {id = "Pos", dont_save = true}, + }, + + wire = false, +} + +function TwoPointsAttach:Done() + DoneObject(self.wire) +end + +function TwoPointsAttach:SetPositions(obj1, spot_type1, spot_index1, obj2, spot_type2, spot_index2, curve, length_percents, color, points_step) + self.obj1, self.spot_type1, self.spot_index1 = obj1, spot_type1, spot_index1 + self.obj2, self.spot_type2, self.spot_index2 = obj2, spot_type2, spot_index2 + self.curve, self.length_percents = curve, length_percents + if IsValid(self.obj1) and IsValid(self.obj2) and type(self.spot_index1) == "number" and type(self.spot_index2) == "number" then + local start1 = obj1:GetSpotRange(spot_type1) + local start2 = obj2:GetSpotRange(spot_type2) + local pos1 = obj1:GetSpotLocPos(start1 + spot_index1, obj1:TimeToInterpolationEnd()) + local pos2 = obj2:GetSpotLocPos(start2 + spot_index2, obj2:TimeToInterpolationEnd()) + self.wire = CreateOrUpdateWire(pos1, pos2, self.wire, curve, length_percents, color, points_step) + self:SetPos(self.wire:GetPos()) + end +end + +function TwoPointsAttach:UpdatePositions(color, points_step) + self:SetPositions(self.obj1, self.spot_type1, self.spot_index1, + self.obj2, self.spot_type2, self.spot_index2, self.curve, self.length_percents, color, points_step) +end + +function TwoPointsAttach:GetBBox() + return self.wire.bbox +end + +function TwoPointsAttach:TestRay(pos, dir) + local samples_bboxes = self.wire.samples_bboxes + local dest = pos + dir + for _, bbox in ipairs(samples_bboxes) do + if RayIntersectsAABB(pos, dest, bbox) then + return true + end + end +end + +function TwoPointsAttach:SetHighlighted(highlighted) + highlighted = highlighted or (s_SelectedWires and s_SelectedWires[self]) + self:UpdatePositions(highlighted and const.clrGray or const.clrBlack) +end + +function TwoPointsAttach:SetVisible(visible) + if not IsValid(self.wire) then return end + if visible then + self.wire:SetEnumFlags(const.efVisible) + else + self.wire:ClearEnumFlags(const.efVisible) + end +end + +function TwoPointsAttach:PostLoad(reason) + if not IsValid(self.obj1) or not IsValid(self.obj2) then + DoneObject(self) + else + self:UpdatePositions() + end +end + +local function CheckValidTwoPointsAttach(obj) + if not IsValid(obj.obj1) then + StoreErrorSource(obj, "Wire obj1 is invalid!", obj.handle) + end + if not IsValid(obj.obj2) then + StoreErrorSource(obj, "Wire obj2 is invalid!", obj.handle) + end +end + +function OnMsg.PreSaveMap() + MapForEach(true, "TwoPointsAttach", CheckValidTwoPointsAttach) +end + +function OnMsg.NewMapLoaded() + MapForEach(true, "TwoPointsAttach", function(obj) + if obj.spot_index1 == "invalid" then + obj.spot_index1 = obj.spot1 + end + if obj.spot_index2 == "invalid" then + obj.spot_index2 = obj.spot2 + end + obj:UpdatePositions() + CheckValidTwoPointsAttach(obj) + if not obj.wire and IsValid(obj.obj1) and IsValid(obj.obj2) then + StoreErrorSource(obj, "Wire is invalid!", obj.handle, obj.obj1, obj.obj2) + end + end) +end + +local function FilterTwoPointsAttachParents(objects) + local two_points_parents = {} + for _, obj in ipairs(objects) do + if IsKindOf(obj, "TwoPointsAttachParent") then + table.insert(two_points_parents, obj) + end + end + + return two_points_parents +end + +function ForEachConnectedWire(objects, func) + local two_points_parents = FilterTwoPointsAttachParents(objects) + if #two_points_parents == 0 then return end + + local wires = MapGet(true, "TwoPointsAttach") + for _, obj in ipairs(two_points_parents) do + for _, wire in ipairs(wires) do + if wire.obj1 == obj or wire.obj2 == obj then + func(wire) + end + end + end +end + +function OnMsg.EditorCallback(id, objects) + if id == "EditorCallbackDelete" then + ForEachConnectedWire(objects, function(wire) + if IsValid(wire) then + DoneObject(wire) + end + end) + elseif id == "EditorCallbackMove" or id == "EditorCallbackRotate" or id == "EditorCallbackScale" then + ForEachConnectedWire(objects, function(wire) + wire:UpdatePositions() + end) + end +end + +function OnMsg.WireCurveTypeChanged(new_curve_type) + s_SelectedWires = s_SelectedWires or {} + for wire in pairs(s_SelectedWires) do + if wire.curve ~= new_curve_type then + wire.curve = new_curve_type + wire:UpdatePositions() + end + end +end + +function OnMsg.EditorSelectionChanged(objects) + s_SelectedWires = s_SelectedWires or {} + local cur_sel = {} + for _, obj in ipairs(objects) do + if IsKindOf(obj, "TwoPointsAttach") then + cur_sel[obj] = true + if not s_SelectedWires[obj] then + s_SelectedWires[obj] = true + obj:SetHighlighted("highlighted") + end + end + end + local to_unselect = {} + for wire in pairs(s_SelectedWires) do + if not cur_sel[wire] then + table.insert(to_unselect, wire) + end + end + for _, wire in ipairs(to_unselect) do + s_SelectedWires[wire] = nil + if IsValid(wire) then + wire:SetHighlighted(false) + end + end + local sel_types = {} + for wire in pairs(s_SelectedWires) do + if not sel_types[wire.curve] then + sel_types[wire.curve] = true + table.insert(sel_types, wire.curve) + end + end + if #sel_types == 1 then + local dlg = GetDialog("XSelectObjectsTool") + if dlg then + dlg:SetProperty("WireCurve", sel_types[1]) + end + end +end + +function CreateTwoPointsAttach(obj1, spot_type1, spot_index1, obj2, spot_type2, spot_index2, curve, length) + local real_wire = PlaceObject("TwoPointsAttach") + real_wire:SetPositions(obj1, spot_type1, spot_index1, obj2, spot_type2, spot_index2, curve, length) + return real_wire +end + +function GetTwoPointsAttach(obj1, spot_type1, spot_index1, obj2, spot_type2, spot_index2) + local wires = MapGet(true, "TwoPointsAttach") + for _, wire in ipairs(wires) do + if wire.obj1 == obj1 and wire.spot_type1 == spot_type1 and wire.spot_index1 == spot_index1 and + wire.obj2 == obj2 and wire.spot_type2 == spot_type2 and wire.spot_index2 == spot_index2 then + return wire + end + if wire.obj1 == obj2 and wire.spot_type1 == spot_type2 and wire.spot_index1 == spot_index2 and + wire.obj2 == obj1 and wire.spot_type2 == spot_type1 and wire.spot_index2 == spot_index1 then + return wire + end + end +end diff --git a/CommonLua/Editor/XEditor/XVertexPushBrush.lua b/CommonLua/Editor/XEditor/XVertexPushBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..25173a499275379b1200b1f26c13755e1313bf0a --- /dev/null +++ b/CommonLua/Editor/XEditor/XVertexPushBrush.lua @@ -0,0 +1,150 @@ +DefineClass.XVertexPushBrush = { + __parents = { "XEditorBrushTool" }, + properties = { + editor = "number", slider = true, persisted_setting = true, auto_select_all = true, + { id = "ClampToLevels", name = "Clamp to levels", editor = "bool", default = true, no_edit = not const.SlabSizeZ }, + { id = "SquareBrush", name = "Square brush", editor = "bool", default = true, no_edit = not const.SlabSizeZ }, + { id = "Strength", name = "Strength", editor = "number", default = 50, scale = "%", min = 1, max = 100, step = 1 }, + { id = "Falloff", default = 100, scale = "%", min = 0, max = 250, no_edit = function(self) return self:IsCursorSquare() end }, + }, + + ToolSection = "Height", + ToolTitle = "Vertex push", + Description = { + "Precisely pushes terrain up or down.", + "(hold left button and drag)" + }, + ActionSortKey = "13", + ActionIcon = "CommonAssets/UI/Editor/Tools/VertexNudge.tga", + ActionShortcut = "Ctrl-W", + + mask_grid = false, + offset = 0, + last_mouse_pos = false, +} + +function XVertexPushBrush:Init() + local w, h = terrain.HeightMapSize() + self.mask_grid = NewComputeGrid(w, h, "F") +end + +function XVertexPushBrush:Done() + editor.ClearOriginalHeightGrid() + self.mask_grid:free() +end + +function XVertexPushBrush:StartDraw(pt) + self.mask_grid:clear() + + PauseTerrainCursorUpdate() + XEditorUndo:BeginOp{ height = true, name = "Changed height" } + editor.StoreOriginalHeightGrid(true) -- true = use for GetTerrainCursor +end + +function XVertexPushBrush:OnMouseButtonDown(pt, button) + if button == "L" then + self.last_mouse_pos = pt + self.offset = 0 + end + return XEditorBrushTool.OnMouseButtonDown(self, pt, button) +end + +function XVertexPushBrush:OnMouseButtonUp(pt, button) + if button == "L" then + self.last_mouse_pos = false + self.offset = 0 + end + + return XEditorBrushTool.OnMouseButtonUp(self, pt, button) +end + +function XVertexPushBrush:OnMousePos(pt, button) + if self.last_mouse_pos then + self.offset = self.offset + (self.last_mouse_pos:y() - pt:y()) * (guim / const.TerrainHeightScale) + self.last_mouse_pos = pt + end + XEditorBrushTool.OnMousePos(self, pt, button) +end + +function XVertexPushBrush:Draw(pt1, pt2) + local inner_radius, outer_radius = self:GetCursorRadius() + local bbox = editor.DrawMaskSegment(self.mask_grid, self.first_pos, self.first_pos, inner_radius, outer_radius, "max", 1.0, 1.0, self:IsCursorSquare()) + editor.AddToHeight(self.mask_grid, MulDivRound(self.offset, self:GetStrength(), const.TerrainHeightScale * 100), bbox) + + if const.SlabSizeZ and self:GetClampToLevels() then + editor.ClampHeightToLevels(config.TerrainHeightSlabOffset, const.SlabSizeZ, bbox, self.mask_grid) + end + Msg("EditorHeightChanged", false, bbox) +end + +function XVertexPushBrush:EndDraw(pt1, pt2) + local _, outer_radius = self:GetCursorRadius() + local bbox = editor.GetSegmentBoundingBox(pt1, pt2, outer_radius, self:IsCursorSquare()) + Msg("EditorHeightChanged", true, bbox) + XEditorUndo:EndOp(nil, bbox) + + ResumeTerrainCursorUpdate() + self.cursor_default_flags = XEditorBrushTool.cursor_default_flags + self.offset = guim +end + +function XVertexPushBrush:GetCursorRadius() + local inner_size = self:GetSize() * 100 / (100 + 2 * self:GetFalloff()) + return inner_size / 2, self:GetSize() / 2 +end + +function XVertexPushBrush:GetCursorHeight() + return MulDivRound( self.offset, self:GetStrength(), 100) +end + +function XVertexPushBrush:IsCursorSquare() + return const.SlabSizeZ and self:GetSquareBrush() +end + +function XVertexPushBrush:GetCursorExtraFlags() + return const.SlabSizeZ and (self:GetSquareBrush() or self:GetClampToLevels()) and const.mfTerrainHeightFieldSnapped or 0 +end + +function XVertexPushBrush:GetCursorColor() + return self:IsCursorSquare() and RGB(16, 255, 16) or RGB(255, 255, 255) +end + +----- Shortcuts + +function XVertexPushBrush:OnShortcut(shortcut, source, ...) + if XEditorBrushTool.OnShortcut(self, shortcut, source, ...) then + return "break" + elseif shortcut == "+" or shortcut == "Numpad +" then + self:SetStrength(self:GetStrength() + 1) + return "break" + elseif shortcut == "-" or shortcut == "Numpad -" then + self:SetStrength(self:GetStrength() - 1) + return "break" + end +end + +----- +if const.SlabSizeZ then -- modify Size/Height properties depending on SquareBrush/ClampToLevels properties + function XVertexPushBrush:GetPropertyMetadata(prop_id) + if prop_id == "Size" and self:IsCursorSquare() then + local sizex = const.SlabSizeX + local help = string.format("1 tile = %sm", _InternalTranslate(FormatAsFloat(sizex, guim, 2))) + return { id = "Size", name = "Size (tiles)", help = help, default = sizex, scale = sizex, min = 0, max = 50 * sizex, step = sizex, editor = "number", slider = true, persisted_setting = true, auto_select_all = true, } + end + return table.find_value(self.properties, "id", prop_id) + end + + function XVertexPushBrush:GetProperties() + local props = {} + for _, prop in ipairs(self.properties) do + props[#props + 1] = self:GetPropertyMetadata(prop.id) + end + return props + end + + function XVertexPushBrush:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "SquareBrush" then + self:SetSize(self:GetSize()) + end + end +end diff --git a/CommonLua/Editor/XEditor/XZBrush.lua b/CommonLua/Editor/XEditor/XZBrush.lua new file mode 100644 index 0000000000000000000000000000000000000000..62308a475ec6344e46fd3bc98c7e1a9486066a27 --- /dev/null +++ b/CommonLua/Editor/XEditor/XZBrush.lua @@ -0,0 +1,399 @@ +local supported_fmt = {[".tga"] = true, [".raw"] = true} +local thumb_fmt = {[".tga"] = true, [".png"] = true } +local textures_folders = {"svnAssets/Source/Editor/ZBrush"} +local thumbs_folders = {"svnAssets/Source/Editor/ZBrushThumbs"} + +local function store_as_by_category(self, prop_meta) return prop_meta.id .. "_for_" .. self:GetCategory() end + +DefineClass.XZBrush = { + __parents = { "XEditorTool" }, + properties = { + persisted_setting = true, + store_as = function(self, prop_meta) -- store settings per texture + if prop_meta.id == "BrushPattern" then + return prop_meta.id + else + return prop_meta.id .. "_for_" .. self:GetBrushPattern() + end + end, + { id = "BrushHeightChange", name = "Height change", editor = "number", default = 500*guim, min = -1000*guim, max = 1000*guim, scale = "m", slider = true, help = "Height change corresponding to the texture levels", buttons = {{name = "Invert", func = "ActionHeightChangeInvert"}} }, + { id = "BrushZeroLevel", name = "Texture zero level", editor = "number", default = -1, min = -1, max = 255, slider = true, help = "The grayscale level corresponding to zero height. If negative, the top-left corner value would be used." }, + { id = "BrushDistortAmp", name = "Distort amplitude", editor = "number", default = 10, min = 1, max = 30, slider = true }, + { id = "BrushDistortFreq", name = "Distort frequency", editor = "number", default = 1, min = 1, max = 10, slider = true }, + { id = "BrushMode", name = "Mode", editor = "text_picker", default = "Add", items = { "Add", "Max", "Min" }, horizontal = true, store_as = false, }, + { id = "ClampMin", name = "Min ", editor = "number", scale = "m", default = 0 }, + { id = "ClampMax", name = "Max ", editor = "number", scale = "m", default = 0 }, + { id = "BrushPattern", name = "Pattern", editor = "texture_picker", default = "", thumb_size = 100, items = function(self) return self:GetZBrushTexturesList() end, small_font = true }, + + { id = "TerrainR", name = "Terrain red", editor = "choice", default = "", items = GetTerrainNamesCombo, no_edit = function(self) return not self.pattern_terrains_file end, }, + { id = "TerrainG", name = "Terrain green", editor = "choice", default = "", items = GetTerrainNamesCombo, no_edit = function(self) return not self.pattern_terrains_file end, }, + { id = "TerrainB", name = "Terrain blue", editor = "choice", default = "", items = GetTerrainNamesCombo, no_edit = function(self) return not self.pattern_terrains_file end, }, + + { id = "_", editor = "buttons", buttons = {{name = "See Texture Locations", func = "OpenTextureLocationHelp"}}, default = false }, + }, + + ToolSection = "Height", + ToolTitle = "Z Brush", + Description = { + "Select pattern and drag to place and size it.", + " - Move - Rotate \n - Height - Distort" + }, + ActionSortKey = "15", + ActionIcon = "CommonAssets/UI/Editor/Tools/Zbrush.tga", + ActionShortcut = "Ctrl-H", + + pattern_grid = false, + pattern_raw = false, + pattern_terrains_file = false, + + height_change = false, + + -- bools + distorting = false, + + -- resizing + z_resize_start = false, + + resize_start = false, + last_resize_delta = false, + + -- angle of brush rotation in minutes + last_rotation_delta = false, + angle_start = false, + angle = false, + + distort_grid_x = false, + distort_grid_y = false, + distorting_start = false, + + distort_amp_xy = false, + distort_distance = 0, + + initial_point_z = false, + center_point = false, + current_point = false, + + box_radius = 0, + box_size = 0, + old_box = false, + + cursor_start_pos = false, -- used with rotation (Shift) + is_editing = false, +} + +function XZBrush:Init() + self:InitDistort() + self:InitBrushPattern() + XShortcutsTarget:SetStatusTextRight("ZBrush Editor") +end + +function XZBrush:Done() + self:CancelOperation() + + if self.pattern_grid then self.pattern_grid:free() end + if self.distort_grid_x then self.distort_grid_x:free() end + if self.distort_grid_y then self.distort_grid_y:free() end + + editor.ClearOriginalHeightGrid() +end + +function XZBrush:InitBrushPattern() + local brush_pattern = self:GetBrushPattern() + local had_terrains = not not self.pattern_terrains_file + if brush_pattern then + local dir, name, ext = SplitPath(brush_pattern) + XShortcutsTarget:SetStatusTextRight(name) + + if self.pattern_grid then + self.pattern_grid:free() + end + self.pattern_raw = string.find(brush_pattern, ".raw") and true or false + self.pattern_grid = ImageToGrids(brush_pattern, self.pattern_raw) + self.pattern_terrains_file = dir .. name .. "_Mask.png" + self.pattern_terrains_file = io.exists(self.pattern_terrains_file) and self.pattern_terrains_file + end + if had_terrains ~= not not self.pattern_terrains_file then + ObjModified(self) + end +end + +function XZBrush:InitDistort() + if self.distort_grid_x then self.distort_grid_x:free() end + if self.distort_grid_y then self.distort_grid_y:free() end + + local dist_size = editor.ZBrushDistortSize + self.distort_grid_x = NewComputeGrid(dist_size, dist_size, "F") + self.distort_grid_y = NewComputeGrid(dist_size, dist_size, "F") + + local seed = AsyncRand() + local noise = PerlinNoise:new() + noise:SetMainOctave(1 + MulDivRound( editor.ZBrushParamsCount - 1, self:GetBrushDistortFreq()* 100 , 1024)) + + noise:GetNoise(seed, self.distort_grid_x, self.distort_grid_y) + GridNormalize(self.distort_grid_x, 0, 1) + GridNormalize(self.distort_grid_y, 0, 1) + + self.distort_amp_xy = point(0, 0) + self.distort_distance = 0 +end + +function XZBrush:OnEditorSetProperty(prop_id, old_value, ged) + local brush_pattern = false + if prop_id == "BrushPattern" then + self:InitBrushPattern() + elseif prop_id == "BrushDistortFreq" then + self:InitDistort() + end +end + +function XZBrush:ActionHeightChangeInvert() + self:SetBrushHeightChange(-self:GetBrushHeightChange()) + self:OnEditorSetProperty("SetBrushHeightChange") + ObjModified(self) +end + +function XZBrush:CalculateResizeDelta() + if not self.resize_start then + return self.last_resize_delta + end + if self.last_resize_delta ~= point(0, 0, 0) then + local dCCP = self.current_point:Dist2D(self.center_point) + local dCLP = self.resize_start:Dist2D(self.center_point) + return MulDivRound(self.last_resize_delta, dCCP, dCLP) + else + return self.current_point - self.resize_start + end +end + +function XZBrush:UpdateParameters(screen_point) + local isRotating = false + local isScalingZ = false + local isMoving = false + if terminal.IsKeyPressed(const.vkAlt) then + isScalingZ = true + SetMouseDeltaMode(true) + self.height_change = self.height_change - MulDivRound(GetMouseDelta():y(), self:GetBrushHeightChange(), 100) + else + SetMouseDeltaMode(false) + end + + local isDistorting = false + if terminal.IsKeyPressed(const.vkSpace) then + isDistorting = true + if not self.distorting then + self.distorting_start = screen_point + end + self.distort_distance = self.distorting_start:Dist2D(screen_point) + self.distort_amp_xy = self:GetBrushDistortAmp() * (self.distorting_start - screen_point) + end + self.distorting = isDistorting + + local ptDelta = screen_point - self.cursor_start_pos + if terminal.IsKeyPressed(const.vkShift) then + local absDiff = Max(abs(ptDelta:x()), abs(ptDelta:y())) + if absDiff > 0 then + self.angle = atan(ptDelta:y(), ptDelta:x()) + if not self.angle_start then + self.angle_start = self.angle + end + end + isRotating = true + else + if self.angle_start then + self.last_rotation_delta = self.last_rotation_delta + (self.angle - self.angle_start) + self.angle_start = false + end + end + + local mouse_world_pos = GetTerrainCursor() + if terminal.IsKeyPressed(const.vkControl) then + self.center_point = self.center_point + mouse_world_pos - self.current_point + isMoving = true + end + self.current_point = mouse_world_pos + if not isScalingZ and not isDistorting and not isRotating and not isMoving then + -- if not all that, then we do default action - resize + if not self.resize_start then + self.resize_start = self.current_point + end + else + if self.resize_start then + self.last_resize_delta = self:CalculateResizeDelta() + self.resize_start = false + end + end +end + +function XZBrush:OnMouseButtonDown(screen_point, button) + if button == "R" and self.is_editing then + self:CancelOperation() + return "break" + end + if button == "L" then + if terminal.IsKeyPressed(const.vkControl) then + self:SetClampMin(GetTerrainCursor():z()) + ObjModified(self) + return "break" + end + if terminal.IsKeyPressed(const.vkShift) then + self:SetClampMax(GetTerrainCursor():z()) + ObjModified(self) + return "break" + end + + XEditorUndo:BeginOp{ height = true, terrain_type = not not self.pattern_terrains_file, name = "Z Brush" } + editor.StoreOriginalHeightGrid(true) + + self.is_editing = true + self.cursor_start_pos = screen_point + + local game_pt = GetTerrainCursor() + self.center_point = game_pt + self.current_point = game_pt + self.resize_start = game_pt + self.last_resize_delta = point30 + self.initial_point_z = game_pt:z() + + local w, h = terrain.HeightMapSize() + self.height_change = self:GetBrushHeightChange() / const.TerrainHeightScale + self.last_rotation_delta = 0 + + self.desktop:SetMouseCapture(self) + return "break" + end + return XEditorTool.OnMouseButtonDown(self, screen_point, button) +end + +function XZBrush:OnMouseButtonUp(screen_point, button) + if self.is_editing then + self:UpdateParameters(screen_point) + local bbox = editor.GetSegmentBoundingBox(self.center_point, self.center_point, self.box_radius, true) + Msg("EditorHeightChanged", true, bbox) + if self.pattern_terrains_file then + self:ApplyTerrainTextures(self.pattern_terrains_file) + Msg("EditorTerrainTypeChanged", bbox) + end + XEditorUndo:EndOp() + + self.is_editing = false + self.center_point = false + self.current_point = false + self.scalingZ = false + self.distorting = false + self.angle_start = false + self.last_rotation_delta = 0 + self.distort_amp_xy = point(0, 0) + self.distort_distance = 0 + + local dir, name, ext = SplitPath(self:GetBrushPattern()) + XShortcutsTarget:SetStatusTextRight(name or "ZBrush Editor") + + SetMouseDeltaMode(false) + self.desktop:SetMouseCapture() + UnforceHideMouseCursor("XEditorBrushTool") + return "break" + end + return XEditorTool.OnMouseButtonUp(self, screen_point, button) +end + +function XZBrush:OnMousePos(screen_point, button) + if self.is_editing and self.pattern_grid then + if terminal.IsKeyPressed(const.vkEsc) then + self:CancelOperation() + return "break" + end + + self:UpdateParameters(screen_point) + local angleDelta = self.last_rotation_delta + (self.angle_start and (self.angle - self.angle_start) or 0) + local sin, cos = sincos(angleDelta) + local ptDelta = self:CalculateResizeDelta() + local box_size = Max(abs(ptDelta:x()), abs(ptDelta:y())) + self.box_radius = box_size > 0 and MulDivRound(box_size, abs(sin) + abs(cos), 4096) or const.HeightTileSize / 2 + + local bBox = editor.GetSegmentBoundingBox(self.center_point, self.center_point, self.box_radius, true) + local extended_box = AddRects(self.old_box or bBox, bBox) + local min, max = editor.ApplyZBrushToGrid(self.pattern_grid, self.distort_grid_x, self.distort_grid_y, extended_box, self.center_point:SetZ(self.initial_point_z), + self.distort_amp_xy, self.distort_distance, angleDelta, box_size, self.height_change, self:GetBrushZeroLevel(), self.pattern_raw, + self:GetBrushMode(), self:GetClampMin(), self:GetClampMax()) + if max and min then + XShortcutsTarget:SetStatusTextRight(string.format("Size %d, Min height %dm, Max height %dm", (2 * box_size) / guim, min * const.TerrainHeightScale / guim, max * const.TerrainHeightScale / guim)) + end + self.old_box = bBox + self.box_size = box_size + + Msg("EditorHeightChanged", false, extended_box) + return "break" + end + XEditorTool.OnMousePos(self, screen_point, button) +end + +function XZBrush:OnKbdKeyDown(key, ...) + if self.is_editing and key == const.vkEsc then + self:CancelOperation() + return "break" + end + XEditorTool.OnKbdKeyDown(self, key, ...) +end + +function XZBrush:CancelOperation() + if self.editing then + local w, h = terrain.HeightMapSize() + local mask = NewComputeGrid(w, h, "F") + local box = editor.DrawMaskSegment(mask, self.center_point, self.center_point, self.box_radius, self.box_radius, "min") + editor.SetHeightWithMask(0, mask, box) + mask:clear() + + self:OnMouseButtonUp(self.center_point, 'L') + end +end + +function XZBrush:ApplyTerrainTextures(filename) + local r, g, b = ImageToGrids(filename) + local bbox = editor.GetSegmentBoundingBox(self.center_point, self.center_point, self.box_radius, true) + local angle = self.last_rotation_delta + (self.angle_start and (self.angle - self.angle_start) or 0) + -- places terrain based on the texture in filename (different terrain for R, G, B channels); pattern_grid, pattern_raw are ignored + editor.ApplyZBrushToGrid(self.pattern_grid, self.distort_grid_x, self.distort_grid_y, bbox, self.center_point:SetZ(self.initial_point_z), + self.distort_amp_xy, self.distort_distance, angle, self.box_size, self.height_change, self:GetBrushZeroLevel(), self.pattern_raw, + self:GetBrushMode(), self:GetClampMin(), self:GetClampMax(), + r, g, b, self:GetTerrainR(), self:GetTerrainG(), self:GetTerrainB()) +end + +function XZBrush:OpenTextureLocationHelp() + local paths = { "Texture folders:" } + for i = 1, #textures_folders do + paths[#paths + 1] = ConvertToOSPath(textures_folders[i]) + end + paths[#paths + 1] = "Thumb folders:" + for i = 1, #thumbs_folders do + paths[#paths + 1] = ConvertToOSPath(thumbs_folders[i]) + end + CreateMessageBox(self, Untranslated("Texture Location"), Untranslated(table.concat(paths, "\n"))) +end + +function XZBrush:GetZBrushTexturesList() + local texture_list = {} + for i = 1, #textures_folders do + local textures_folder = textures_folders[i] or "" + local thumbs_folder = thumbs_folders[i] or "" + local err, thumbs, textures + if thumbs_folder ~= "" then + err, thumbs = AsyncListFiles(thumbs_folder, "*.png") + end + if textures_folder ~= "" then + err, textures = AsyncListFiles(textures_folder) + end + + for _, texture in ipairs(textures or empty_table) do + local dir, name, ext = SplitPath(texture) + if supported_fmt[ext] then + local thumb = thumbs_folder .. "/" .. name .. ".png" + if not table.find(thumbs or empty_table, thumb) and thumb_fmt[ext] then + thumb = texture + end + texture_list[#texture_list + 1] = { text = name, value = texture, image = thumb } + end + end + end + table.sort(texture_list, function(a, b) return a.text < b.text or a.text == b.text and a.value < b.value end ) + return texture_list +end \ No newline at end of file diff --git a/CommonLua/Editor/collection.lua b/CommonLua/Editor/collection.lua new file mode 100644 index 0000000000000000000000000000000000000000..203c88c566b53f145a416c9707641ae3ce4b193c --- /dev/null +++ b/CommonLua/Editor/collection.lua @@ -0,0 +1,428 @@ +local unavailable_msg = "Not available in game mode! Retry in the editor!" + +function Collection:GetLocked() + return self.Index == editor.GetLockedCollectionIdx() +end + +function Collection:SetLocked(locked) + local idx = self.Index + if idx == 0 then + return + end + local prev_locked = self:GetLocked() + if locked and prev_locked or not locked and not prev_locked then + return + end + Collection.UnlockAll() + if prev_locked then + return + end + editor.ClearSel() + editor.SetLockedCollectionIdx(idx) + MapSetGameFlags(const.gofWhiteColored, "map", "CObject") + MapForEach("map", "collection", idx, true, function(o) o:ClearHierarchyGameFlags(const.gofWhiteColored) end) +end + +function Collection.GetLockedCollection() + local locked_idx = editor.GetLockedCollectionIdx() + return locked_idx ~= 0 and Collections[locked_idx] +end + +function Collection.UnlockAll() + if editor.GetLockedCollectionIdx() == 0 then + return false + end + editor.SetLockedCollectionIdx(0) + MapClearGameFlags(const.gofWhiteColored, "map", "CObject") + return true +end + +-- clone the collections in the given group of objects +function Collection.Duplicate(objects) + local duplicated = {} + local collections = {} + -- clone and assign collections: + local locked_idx = editor.GetLockedCollectionIdx() + for i = 1, #objects do + local obj = objects[i] + if IsValid(obj) then + local col = obj:GetCollection() + if not col then + obj:SetCollectionIndex(locked_idx) + elseif col.Index ~= locked_idx then + local new_col = duplicated[col] + if not new_col then + new_col = col:Clone() + duplicated[col] = new_col + collections[#collections + 1] = col + end + obj:SetCollection(new_col) + else + obj:SetCollection(col) + end + end + end + + -- fix collection hierarchy + local i = #collections + while i > 0 do + local col = collections[i] + local new_col = duplicated[col] + local parent = col:GetCollection() + i = i - 1 + + if parent and parent.Index ~= locked_idx then + local new_parent = duplicated[parent] + if not duplicated[parent] then + new_parent = parent:Clone() + duplicated[parent] = new_parent + i = i + 1 + collections[i] = parent + end + new_col:SetCollection(new_parent) + else + new_col:SetCollectionIndex(locked_idx) + end + end + + UpdateCollectionsEditor() +end + +function Collection.UpdateLocked() + editor.SetLockedCollectionIdx(editor.GetLockedCollectionIdx()) +end + +function OnMsg.NewMap() + editor.SetLockedCollectionIdx(0) +end + +---- + +DefineClass.CollectionContent = { + __parents = { "PropertyObject" }, + properties = {}, + col = false, + children = false, + objects = false, + + EditorView = Untranslated(" "), +} + +function CollectionContent:GedTreeChildren() + return self.children +end + +function CollectionContent:GetName() + local name = self.col.Name + return #name > 0 and name or "[Unnamed]" +end + +function CollectionContent:GetIndex() + local index = self.col.Index + return index > 0 and index or "" +end + +function CollectionContent:SelectInEditor() + local ged = GetCollectionsEditor() + if not ged then + return + end + + local root = ged:ResolveObj("root") + + local path = {} + local iter = self + while iter and iter ~= root do + local parent_idx = iter.col:GetCollectionIndex() + if parent_idx and parent_idx > 0 then + local parent = root.collection_to_gedrepresentation[Collections[parent_idx]] + table.insert(path, 1, table.find(parent.children, iter)) + iter = parent + else + table.insert(path, 1, table.find(root, iter)) + break + end + end + ged:SetSelection("root", path) +end + + +function CollectionContent:OnEditorSelect(selected, ged) + local is_initial_selection = not ged:ResolveObj("CollectionObjects") + + if selected then + ged:BindObj("CollectionObjects", self.objects) -- for idObjects panel + ged:BindObj("SelectedObject", self.col) -- for idProperties panel + end + + if not IsEditorActive() then + return + end + if selected then + -- If this is the initial selection (when the editor is first opened) => don't move the camera + ged:ResolveObj("root"):Select(self, not is_initial_selection and "show_in_editor") + end +end + + +function CollectionContent:ActionUnlockAll() + if not IsEditorActive() then + print(unavailable_msg) + return + end + Collection.UnlockAll() +end + +---- + +DefineClass.CollectionRoot = { + __parents = { "InitDone" }, + + collection_to_gedrepresentation = false, + selected_col = false, +} + +function GedCollectionEditorOp(ged, name) + if not IsEditorActive() then + print(unavailable_msg) + return + end + + local gedcol = ged:ResolveObj("SelectedCollection") + local root = ged:ResolveObj("root") + local col = gedcol and gedcol.col + local col_to_select = false + + if not col then + return + end + + if name == "new" then + Collection.Collect() + elseif name == "delete" then + -- Prepare next collection to be selected in the editor + local root_index = table.find(root, gedcol) or 0 + local nextColContent = root[root_index + 1] + if nextColContent and nextColContent:GetIndex() ~= 0 then + col_to_select = Collections[nextColContent:GetIndex()] + end + col:Destroy() + elseif name == "lock" then + col:SetLocked(true) + elseif name == "unlock" then + Collection.UnlockAll() + elseif name == "collect" then + col_to_select = Collection.Collect(editor.GetSel()) + elseif name == "uncollect" then + DoneObject(col) + elseif name == "view" then + if gedcol and gedcol.objects then + ViewObjects(gedcol.objects) + end + end + root:UpdateTree() + + if root.collection_to_gedrepresentation and col_to_select then + -- Select a new collection in the editor + local gedrepr = root.collection_to_gedrepresentation[col_to_select] + if gedrepr then + gedrepr:SelectInEditor() + end + end +end + +function CollectionRoot:Select(obj, show_in_editor) + if not IsEditorActive() or self.selected_collection == obj.col then + return + end + + local col = obj.col + if not col:GetLocked() then + local parent = col:GetCollection() + if parent then + parent:SetLocked(true) + else + Collection.UnlockAll() + end + end + + if show_in_editor then + local col_objects = MapGet("map", "attached", false, "collection", col.Index) + editor.ChangeSelWithUndoRedo(col_objects, "dont_notify") + ViewObjects(col_objects) + end + + self.selected_collection = obj.col +end + +function CollectionRoot:Init() + self:UpdateTree() +end + +function CollectionRoot:SelectPlainCollection(col) + local obj = self.collection_to_gedrepresentation[col] + if obj then + self.selected_collection = obj.col + obj:SelectInEditor() + end +end + +function CollectionRoot:UpdateTree() + table.iclear(self) + if not Collections then + return + end + self.collection_to_gedrepresentation = {} + local collection_to_children = {} + local col_to_objs = {} + MapForEach("map", "attached", false, "collected", true, function(obj, col_to_objs) + local idx = obj:GetCollectionIndex() + col_to_objs[idx] = table.create_add(col_to_objs[idx], obj) + end, col_to_objs) + local count = 0 + for col_idx, col_obj in sorted_pairs(Collections) do + local objects = col_to_objs[col_idx] or {} + table.sortby_field(objects, "class") + collection_to_children[col_obj.Index] = collection_to_children[col_obj.Index] or {} + local children = collection_to_children[col_obj.Index] + local gedrepr = CollectionContent:new({col = col_obj, objects = objects, children = children}) + self.collection_to_gedrepresentation[col_obj] = gedrepr + + local parent_index = col_obj:GetCollectionIndex() + if parent_index > 0 then + collection_to_children[parent_index] = collection_to_children[parent_index] or {} + table.insert(collection_to_children[parent_index], gedrepr) + else + count = count + 1 + self[count] = gedrepr + end + end + table.sort(self, function(a, b) a, b = a.col.Name, b.col.Name return #a > 0 and #b == 0 or #a > 0 and a < b end) + ObjModified(self) +end + +function OnMsg.EditorCallback(id) + if id == "EditorCallbackPlace" then + UpdateCollectionsEditor() + end +end + +local openingCollectionEditor = false +function OpenCollectionEditorAndSelectCollection(obj) + if openingCollectionEditor then return end + openingCollectionEditor = true -- deal with multi selection and multiple calls from the button + CreateRealTimeThread(function() + local col = obj and obj:GetRootCollection() + if not col then + return + end + local ged = GetCollectionsEditor() + if not ged then + OpenCollectionsEditor(col) + while not ged do + Sleep(100) + ged = GetCollectionsEditor() + end + end + + openingCollectionEditor = false + end) +end + +function OnMsg.EditorSelectionChanged(objects) + local ged = GetCollectionsEditor() + if not ged then + return + end + local col = objects and objects[1] and objects[1]:GetRootCollection() + if not col then return end + + local root = ged:ResolveObj("root") + root:SelectPlainCollection(col) +end + +local function get_auto_selected_collection() + -- is the editor selection a single collection? + local count, collections = editor.GetSelUniqueCollections() + if count == 1 then + return next(collections) + end + + return Collection.GetLockedCollection() +end + +function OpenCollectionsEditor(collection_to_select) + local ged = GetCollectionsEditor() + if not ged then + collection_to_select = collection_to_select or get_auto_selected_collection() + + CreateRealTimeThread(function() + ged = OpenGedApp("GedCollectionsEditor", CollectionRoot:new{}) or false + + while not ged do + Sleep(100) + ged = GetCollectionsEditor() + end + + local root = ged:ResolveObj("root") + + if collection_to_select then + -- Wait for the initial GedPanel selection to finish (to call OnEditorSelect()) to avoid an infinite selection loop + Sleep(100) + root:SelectPlainCollection(collection_to_select) + return + end + + local firstColContent = root and root[1] + local select_col = collection_to_select or (root and root[1]) + + -- Select the first collection in the editor + if firstColContent and firstColContent:GetIndex() ~= 0 then + local firstCollection = Collections[firstColContent:GetIndex()] + root:SelectPlainCollection(firstCollection) + end + end) + end + return ged +end + +function GetCollectionsEditor() + for id, ged in pairs(GedConnections) do + if IsKindOf(ged:ResolveObj("root"), "CollectionRoot") then + return ged + end + end +end + +function UpdateCollectionsEditor(ged) + if ged then + local root = ged:ResolveObj("root") + if root then + root:UpdateTree() + end + else + ged = GetCollectionsEditor() + if ged then + DelayedCall(0, UpdateCollectionsEditor, ged) + end + end +end + +function Collection:SetParentButton(_, __, ged) + local parent = self.Graft ~= "" and CollectionsByName[ self.Graft ] + if parent then + local col = parent.Index + while true do + if col == 0 then break end + if col == self.Index then + printf("Can't set %s as parent, because it is a child of %s", self.Graft, self.Name) + return + end + col = Collections[col]:GetCollectionIndex() + end + self:SetCollectionIndex( parent.Index ) + else + self:SetCollectionIndex(0) + end + UpdateCollectionsEditor(ged) +end \ No newline at end of file diff --git a/CommonLua/Editor/editor.lua b/CommonLua/Editor/editor.lua new file mode 100644 index 0000000000000000000000000000000000000000..538d5fc107d08dcd83480a4a80cb54f4f54d1444 --- /dev/null +++ b/CommonLua/Editor/editor.lua @@ -0,0 +1,753 @@ +XEditorPasteFuncs = {} + +function editor.SelectByClass(...) + editor.ClearSel() + editor.AddToSel(MapGet("map", ...) or empty_table) +end + +function ToggleEnterExitEditor() + if Platform.editor then + CreateRealTimeThread(function() + while IsChangingMap() or IsEditorSaving() do + WaitChangeMapDone() + if IsEditorSaving() then + WaitMsg("SaveMapDone") + end + end + if GetMap() == "" then + print("There is no map loaded") + return + end + if IsEditorActive() then + EditorDeactivate() + else + EditorActivate() + end + end) + end +end + +function EditorViewMapObject(obj, dist, selection) + local la = IsValid(obj) and obj:GetVisualPos() or InvalidPos() + if la == InvalidPos() then + return + end + if not cameraMax.IsActive() then cameraMax.Activate(1) end + local cur_pos, cur_la = cameraMax.GetPosLookAt() + if cur_la == cur_pos then + -- cam not initialized + cur_pos = cur_la - point(guim, guim, guim) + end + la = la:SetTerrainZ() + local pos = la - SetLen(cur_la - cur_pos, (dist or 40*guim) + obj:GetRadius()) + cameraMax.SetCamera(pos, la, 200, "Sin in/out") + + if selection then + editor.ClearSel() + editor.AddToSel{obj} + OpenGedGameObjectEditor(editor.GetSel()) + end +end + +local objs +function OnMsg.DoneMap() + objs = nil +end +function _OpenGedGameObjectEditorInGame(reopen_only) + if not GedObjectEditor then + GedObjectEditor = OpenGedApp("GedObjectEditor", objs, { WarningsUpdateRoot = "root" }) or false + else + GedObjectEditor:UnbindObjs("root") + if not reopen_only then + GedObjectEditor:Call("rfnApp", "Activate") + end + GedObjectEditor:BindObj("root", objs) + end + GedObjectEditor:SelectAll("root") + objs = nil +end + +function OpenGedGameObjectEditorInGame(obj, reopen_only) + if not obj or not GedObjectEditor and reopen_only then return end + objs = table.create_add_unique(objs, obj) + DelayedCall(0, _OpenGedGameObjectEditorInGame, reopen_only) +end + +function CObject:AsyncCheatProperties() + OpenGedGameObjectEditorInGame(self) +end + +function OnMsg.SelectedObjChange(obj) + OpenGedGameObjectEditorInGame(obj, "reopen_only") +end + +function EditorWaitViewMapObjectByHandle(handle, map, ged) + if GetMapName() ~= map then + if ged then + local answer = ged:WaitQuestion("Change map required!", string.format("Change map to %s?", map)) + if answer ~= "ok" then + return + end + end + CloseMenuDialogs() + ChangeMap(map) + StoreLastLoadedMap() + end + EditorActivate() + WaitNextFrame() + local obj = HandleToObject[handle] + if not IsValid(obj) then + print("ERROR: no such object") + return + end + editor.ChangeSelWithUndoRedo({obj}) + EditorViewMapObject(obj) +end + +---- + +if FirstLoad then + GedSingleObjectPropEditor = false +end + +function OpenGedSingleObjectPropEditor(obj, reopen_only) + if not obj then return end + CreateRealTimeThread(function() + if not GedSingleObjectPropEditor and reopen_only then + return + end + if not GedSingleObjectPropEditor then + GedSingleObjectPropEditor = OpenGedApp("GedSingleObjectPropEditor", obj) or false + else + GedSingleObjectPropEditor:UnbindObjs("root") + GedSingleObjectPropEditor:Call("rfnApp", "Activate") + GedSingleObjectPropEditor:BindObj("root", obj) + end + end) +end + +function OnMsg.GedClosing(ged_id) + if GedSingleObjectPropEditor and GedSingleObjectPropEditor.ged_id == ged_id then + GedSingleObjectPropEditor = false + end +end + +---- + +-- Grounds all selected objects. +-- If *relative* is true, grounds only the lowest one and then sets the rest of them relative to it +function editor.ResetZ(relative) + local sel = editor:GetSel() + if #sel < 1 then + return + end + + + local min_z_ground = false + local min_z_air = false + local min_idx = false + + if relative then + -- Get the lowest of all objects and where it will be grounded + for i = 1, #sel do + local obj = sel[i] + + local _, _, pos_z = obj:GetVisualPosXYZ() + + if not min_z_air or min_z_air > pos_z then + min_z_air = pos_z + min_idx = i + end + end + + if min_idx then + local obj = sel[min_idx] + + local pos = obj:GetVisualPos2D() + local o, z = GetWalkableObject( pos ) + if o ~= nil and not IsFlagSet( obj:GetEnumFlags(), const.efWalkable ) then + min_z_ground = z + else + min_z_ground = terrain.GetSurfaceHeight(pos) + end + end + end + + SuspendPassEditsForEditOp() + XEditorUndo:BeginOp{ objects = sel, name = "Snap to terrain" } + for i = 1, #sel do + local obj = sel[i] + + obj:ClearGameFlags(const.gofOnRoof) + + local pos = obj:GetVisualPos() + local pos_z = pos:z() + pos = pos:SetInvalidZ() + local o, z = GetWalkableObject( pos ) + + if o ~= nil and not IsFlagSet( obj:GetEnumFlags(), const.efWalkable ) then + if relative and min_z_air and min_z_ground then + pos = point( pos:x(), pos:y(), pos_z - min_z_air + min_z_ground) + else + pos = point( pos:x(), pos:y()) + end + elseif relative and min_z_air and min_z_ground then + pos = point( pos:x(), pos:y(), pos_z - min_z_air + min_z_ground) + end + + obj:SetPos( pos ) + end + local objects = {} + local cfEditorCallback = const.cfEditorCallback + for i = 1, #sel do + if sel[i]:GetClassFlags(cfEditorCallback) ~= 0 then + objects[#objects + 1] = sel[i] + end + end + if #objects > 0 then + Msg("EditorCallback", "EditorCallbackMove", objects) + end + Msg("EditorResetZ") + XEditorUndo:EndOp(sel) + ResumePassEditsForEditOp() +end + +-- this method correctly resets anim back to game time, i.e. it resets the anim timestamps so it doesn't have to wait far in the future to start +function ObjectAnimToGameTime(object) + object:SetRealtimeAnim(false) + local e = object:GetEntity() + if IsValidEntity(e) then + object:SetAnimSpeed(1, object:GetAnimSpeed(), 0) -- so the obj can set its anim timestamps correctly in game time + end +end + +function editor.GetObjectsCenter(objs) + local min_z + local b = box() + for i = 1, #objs do + local pos = objs[i]:GetPos() + b = Extend(b, pos) + if pos:IsValidZ() then + min_z = Min(min_z or pos:z(), pos:z()) + end + end + local center = b:Center() + center = min_z and center:SetZ(min_z) or center + return center +end + +function editor.Serialize(objs, collections, center, options) + local objs_orig = objs + center = center or editor.GetObjectsCenter(objs) + options = options or empty_table + + local GetVisualPosXYZ = CObject.GetVisualPosXYZ + local GetHeight = terrain.GetHeight + local GetTerrainNormal = terrain.GetTerrainNormal + local GetOrientation = CObject.GetOrientation + local IsValidZ = CObject.IsValidZ + local GetClassFlags = CObject.GetClassFlags + local GetGameFlags = CObject.GetGameFlags + local GetCollectionIndex = CObject.GetCollectionIndex + local IsValidPos = CObject.IsValidPos + local cfLuaObject = const.cfLuaObject + local InvalidPos = InvalidPos + local IsT = IsT + + if not collections then + collections = {} + for i = 1, #objs do + local col_idx = GetCollectionIndex(objs[i]) + if col_idx ~= 0 then + collections[col_idx] = true + end + end + end + local cols = {} + for idx in pairs(collections) do + cols[#cols + 1] = Collections[idx] + end + + local cobjects + if options.compact_cobjects then + if objs == objs_orig then objs = table.icopy(objs) end + for i = #objs,1,-1 do + local obj = objs[i] + if GetClassFlags(obj, cfLuaObject) == 0 then + cobjects = cobjects or {} + cobjects[#cobjects + 1] = obj + table.remove(objs, i) + end + end + end + + local get_collection_index_func + local locked_idx = editor.GetLockedCollectionIdx() + if locked_idx ~= 0 and not options.ignore_locked_coll then + get_collection_index_func = function(obj) + local idx = GetCollectionIndex(obj) + if idx ~= locked_idx then + return idx + end + end + end + + local no_translation = options.no_translation + local function get_prop(obj, prop_id) + if prop_id == "CollectionIndex" and get_collection_index_func then + return get_collection_index_func(obj) + elseif prop_id == "Pos" then + return IsValidPos(obj) and (obj:GetPos() - center) or InvalidPos() + end + local value = obj:GetProperty(prop_id) + if no_translation and value ~= "" and IsT(value) then + StoreErrorSource(obj, "Translation found for property", prop_id) + return "" + end + return value + end + + local code = pstr("", 1024) + code:append(options.comment_tag or "--[[HGE place script]]--") + code:append("\nSetObjectsCenter(") + code:appendv(center) + code:append(")\n") + + ObjectsToLuaCode(cols, code, get_prop) + ObjectsToLuaCode(objs, code, get_prop) + + local err + if cobjects then + local test_encoding = options.test_encoding + local collection_remap + if get_collection_index_func then + collection_remap = {} + for _, obj in ipairs(cobjects) do + collection_remap[obj] = get_collection_index_func(obj) + end + end + code:append("\n--[[COBJECTS]]--\n") + code:append("PlaceCObjects(\"") + code, err = __DumpObjPropsForSave(code, cobjects, true, center, nil, nil, collection_remap, test_encoding) + code:append("\")\n") + end + if not options.pstr then + local str = code:str() + code:free() + code = str + end + return code, err +end + +function editor.Unserialize(script, no_z, forced_center) + return LuaCodeToObjs(script, { + no_z = no_z, + pos = forced_center or (terminal.desktop.inactive and GetTerrainGamepadCursor() or GetTerrainCursor()) + }) +end + +function editor.CopyToClipboard() + if IsEditorActive() and #editor.GetSel() > 0 then + local objs = editor.GetSel() + local script = editor.Serialize(objs, empty_table) + CopyToClipboard(script) + end +end + +function editor.PasteFromClipboard(no_z) + if not IsEditorActive() then + return + end + + local script = GetFromClipboard(-1) + local objs = editor.Unserialize(script, no_z) + if not objs then + return + end + + objs = table.ifilter(objs, function (idx, o) return not o:IsKindOf("Collection") end) + + XEditorUndo:BeginOp{name = "Pasted objects"} + XEditorUndo:EndOp(objs) + + editor.ClearSel() + editor.AddToSel(objs) + + local objects = {} + for i = 1,#objs do + if IsFlagSet(objs[i]:GetClassFlags(), const.cfEditorCallback) then + objects[#objects + 1] = objs[i] + end + end + if #objects > 0 then + SuspendPassEditsForEditOp() + Msg("EditorCallback", "EditorCallbackPlace", objects) + ResumePassEditsForEditOp() + end + return objs +end + +editor.SelectDuplicates = function() + local l = MapGet("map") or empty_table + local num = #l + + print( num ) + + local function cmp_x(o1,o2) return o1:GetPos():x() < o2:GetPos():x() end + table.sort( l, cmp_x ) + + editor.ClearSel() + + for i = 1,num do + local pt = l[i]:GetPos() + local axis = l[i]:GetAxis() + local angle = l[i]:GetAngle() + local class = l[i].class + + local function TestDuplicate(idx) + local obj = l[idx] + if pt == obj:GetPos() and axis == obj:GetAxis() and angle == obj:GetAngle() and class == obj.class then + editor.AddToSel({obj}) + return true + end + return false + end + + local j = i + 1 + local x = pt:x() + while j <= num and x == l[j]:GetPos():x() do + if TestDuplicate(j) then + table.remove( l, j ) + num = num-1 + else + j = j+1 + end + end + end +end + +local function SetReplacedObjectDefaultFlags(new_obj) + new_obj:SetGameFlags(const.gofPermanent) + new_obj:SetEnumFlags(const.efVisible) + local entity = new_obj:GetEntity() + local passability_mesh = HasMeshWithCollisionMask(entity, const.cmPassability) + local entity_collisions = HasAnySurfaces(entity, EntitySurfaces.Collision) or passability_mesh + local entity_apply_to_grids = HasAnySurfaces(entity, EntitySurfaces.ApplyToGrids) or passability_mesh + new_obj:SetCollision(entity_collisions) + new_obj:SetApplyToGrids(entity_apply_to_grids) +end + +function editor.ReplaceObject(obj, class) + if g_Classes[class] and IsValid(obj) then + XEditorUndo:BeginOp{ objects = {obj}, name = "Replaced 1 objects" } + Msg("EditorCallback", "EditorCallbackDelete", {obj}, "replace") + local new_obj = PlaceObject(class) + new_obj:CopyProperties(obj) + DoneObject(obj) + SetReplacedObjectDefaultFlags(new_obj) + Msg("EditorCallback", "EditorCallbackPlace", {new_obj}, "replace") + XEditorUndo:EndOp({new_obj}) + return new_obj + end + return obj +end + +function editor.ReplaceObjects(objs, class) + if g_Classes[class] and #objs > 0 then + SuspendPassEditsForEditOp() + PauseInfiniteLoopDetection("ReplaceObjects") + XEditorUndo:BeginOp{ objects = objs, name = string.format("Replaced %d objects", #objs) } + Msg("EditorCallback", "EditorCallbackDelete", objs, "replace") + local ol = {} + for i = 1 , #objs do + local new_obj = PlaceObject(class) + new_obj:CopyProperties(objs[i]) + DoneObject(objs[i]) + SetReplacedObjectDefaultFlags(new_obj) + ol[#ol + 1] = new_obj + end + if ol then + editor.ClearSel() + editor.AddToSel(ol) + end + Msg("EditorCallback", "EditorCallbackPlace", ol, "replace") + XEditorUndo:EndOp(ol) + ResumeInfiniteLoopDetection("ReplaceObjects") + ResumePassEditsForEditOp() + else + print("No such class: " .. class) + end +end + +function OnMsg.EditorCallback(id, objects, ...) + if id == "EditorCallbackClone" then + local old = ... + for i = 1, #old do + local object = objects[i] + if IsValid(object) and object:IsKindOf("EditorCallbackObject") then + object:EditorCallbackClone(old[i]) + end + end + else + local place = id == "EditorCallbackPlace" + local clone = id == "EditorCallbackClone" + local delete = id == "EditorCallbackDelete" + for i = 1, #objects do + local object = objects[i] + if IsValid(object) then + if (place or clone) and object:IsKindOf("AutoAttachObject") and object:GetForcedLODMin() then + object:SetAutoAttachMode(object:GetAutoAttachMode()) + end + if IsKindOf(object, "EditorCallbackObject") then + object[id](object, ...) + end + if place then + if IsKindOf(object, "EditorObject") then + object:EditorEnter() + end + elseif delete then + if IsKindOf(object, "EditorObject") then + object:EditorExit() + end + end + end + end + end +end + +function editor.GetSingleSelectedCollection(objs) + local collections, remaining = editor.ExtractCollections(objs or editor.GetSel()) + local first = collections and next(collections) + return #remaining == 0 and first and not next(collections, first) and Collections[first] +end + +function editor.ExtractCollections(objs) + local collections + local remaining = {} + local locked_idx = editor.GetLockedCollectionIdx() + for _, obj in ipairs(objs or empty_table) do + local coll_idx = 0 + if obj:IsKindOf("Collection") then + coll_idx = obj.Index + else + coll_idx = obj:GetCollectionIndex() + if locked_idx ~= 0 then + local relation = obj:GetCollectionRelation(locked_idx) + if relation == "child" then -- add just this object + coll_idx = 0 + elseif relation == "sub" then -- add the whole collection (use GetCollectionRoot) + coll_idx = Collection.GetRoot(coll_idx) + end + else + if coll_idx ~= 0 then -- try to find root + coll_idx = Collection.GetRoot(coll_idx) + end + end + end + + if coll_idx == 0 then -- add just this object + remaining[#remaining + 1] = obj + else -- add the whole collection + collections = collections or {} + collections[coll_idx] = true + end + end + return collections, remaining +end + +function editor.SelectionPropagate(objs) + local collections, selection = nil, objs or {} + if XEditorSelectSingleObjects == 0 then + collections, selection = editor.ExtractCollections(objs) + end + for coll_idx, _ in sorted_pairs(collections or empty_table) do + table.iappend(selection, MapGet("map", "collection", coll_idx, true)) + end + + if const.SlabSizeX then + if terminal.IsKeyPressed(const.vkControl) then + local visited = {} + for _, obj in ipairs(objs) do + if IsKindOf(obj, "StairSlab") and not visited[obj] then + local gx, gy, gz = obj:GetGridCoords() + table.iappend(selection, EnumConnectedStairSlabs(gx, gy, gz, 0, visited)) + end + end + end + end + + return selection +end + +MapVar("EditorCursorObjs", {}, weak_keys_meta) +PersistableGlobals.EditorCursorObjs = nil + +function editor.GetPlacementPoint(pt) + local eye = camera.GetEye() + local target = pt:SetTerrainZ() + + local objs = IntersectSegmentWithObjects(eye, target, const.efBuilding | const.efVisible) + local pos, dist + if objs then + for _, obj in ipairs(objs) do + if not EditorCursorObjs[obj] and obj:GetGameFlags(const.gofSolidShadow) == 0 then + local hit = obj:IntersectSegment(eye, target) + if hit then + local d = eye:Dist(hit) + if not dist or d < dist then + pos, dist = hit, d + end + end + end + end + end + return pos or pt:SetInvalidZ() +end + +function editor.CycleDetailClass() + local sel = editor:GetSel() + if #sel < 1 then + return + end + + XEditorUndo:BeginOp{ objects = sel, name = "Toggle Detail Class" } + local seldc = {} + for _, obj in ipairs(sel) do + local dc = obj:GetDetailClass() + seldc[dc] = seldc[dc] or 0 + seldc[dc] = seldc[dc] + 1 + end + + local next_dc = "Eye Candy" + if seldc["Eye Candy"] then + next_dc = "Optional" + elseif seldc["Optional"] then + next_dc = "Essential" + end + + for _, obj in ipairs(sel) do + obj:SetDetailClass(next_dc) + end + + XEditorUndo:EndOp(sel) +end + +function editor.ForceEyeCandy() + local sel = editor:GetSel() + if #sel < 1 then + return + end + + XEditorUndo:BeginOp{ objects = sel, name = "Force Eye Candy" } + SuspendPassEditsForEditOp(sel) + for _, obj in ipairs(sel) do + obj:ClearEnumFlags(const.efCollision + const.efApplyToGrids) + obj:SetDetailClass("Eye Candy") + end + + ResumePassEditsForEditOp(sel) + XEditorUndo:EndOp(sel) +end + + +----- Modding editor +-- +-- The Mod Editor starts the map editor in this mode when a user is editing a map. +-- +-- The Mod Item that contains the map (or map patch) is stored in editor.ModItem; +-- for ModItemMapPatch Ctrl-S generates a patch via XEditorCreateMapPatch. +-- This mode also disables some shortcuts, e.g. closing the editor. + +if FirstLoad then + editor.ModdingEditor = false +end + +function editor.IsModdingEditor() + return editor.ModdingEditor +end + +function editor.AskSavingChanges() + if editor.ModdingEditor and editor.ModItem and EditorMapDirty then + if GedAskEverywhere("Warning", "There are unsaved changes on the map.\n\nSave before proceeding?", "Yes", "No") == "ok" then + editor.ModItem:SaveMap() + end + SetEditorMapDirty(false) + end +end + +-- When changing the map via F5, ask the user for saving changes; +-- (in case the map is changed via editor.StartModdingEditor, we call editor.AskSavingChanges before updating editor.ModItem) +function OnMsg.ChangingMap(map, mapdata, handler_fns) + table.insert(handler_fns, editor.AskSavingChanges) +end + +function editor.StartModdingEditor(mod_item, map) + if ChangingMap then return end + + editor.AskSavingChanges() + + editor.ModdingEditor = true + editor.ModItem = mod_item + editor.ModItemMap = map + + ReloadShortcuts() + UpdateModEditorsPropPanels() -- update buttons in Mod Item properties, e.g. "Edit Map" + + if editor.PreviousModItem ~= mod_item or CurrentMap ~= map then + editor.PreviousModItem = mod_item + ChangeMap(map) + end + + if not IsEditorActive() then + EditorActivate() + end +end + +function editor.StopModdingEditor(return_to_mod_map) + if ChangingMap or not editor.ModdingEditor then return end + + CreateRealTimeThread(function() + -- change map first, so the OnMsg.ChangingMap asks for saving changes + if return_to_mod_map and CurrentMap ~= ModEditorMapName then + editor.PreviousModItem = false + ChangeMap(ModEditorMapName) + end + + editor.ModdingEditor = false + editor.ModItem = nil + editor.ModItemMap = nil + + ReloadShortcuts() + UpdateModEditorsPropPanels() -- update buttons in Mod Item properties, e.g. "Edit Map" + + if IsEditorActive() then + EditorDeactivate() + end + end) +end + +function OnMsg.GedClosing(ged_id) + local conn = GedConnections[ged_id] + if conn and conn.app_template == "ModEditor" then + if editor.ModdingEditor and editor.ModItem and conn.bound_objects.root[1] == editor.ModItem.mod then + editor.StopModdingEditor("return to mod map") -- close map editor if the mod editor for its edited map is closed + end + end +end + +-- the user may change the map to another one; if it is a mod-created map, find its mod item +function OnMsg.ChangeMap(map) + if editor.ModdingEditor and editor.ModItemMap ~= map then + editor.ModItemMap = nil + editor.ModItem = nil + for _, mod in ipairs(ModsLoaded) do + mod:ForEachModItem(function(mod_item) + if mod_item:GetMapName() == map then + editor.ModItem = mod_item + return "break" + end + end) + end + UpdateModEditorsPropPanels() -- update buttons in Mod Item properties, e.g. "Edit Map" + end +end diff --git a/CommonLua/EventLog.lua b/CommonLua/EventLog.lua new file mode 100644 index 0000000000000000000000000000000000000000..5fdf993ed5a3aa38a53091e357e5d17a574a2ec1 --- /dev/null +++ b/CommonLua/EventLog.lua @@ -0,0 +1,179 @@ +if FirstLoad then + g_logStorage = false + g_logScreen = true + LogBacklog = {} + LogBacklogIndex = 0 + LogBacklogSize = 10 + LogEventsCount = 0 + LogErrorsCount = 0 + LogSecurityCount = 0 + LocalTSStart = GetPreciseTicks() +end + +local string_format = string.format + +-- returns UTC timestamp string formatted "%d %b %Y %H:%M:%S" +local ts_func = GetPreciseTicks +local ts_valid = ts_func() - 1000 +local ts_last = os.date("!%d %b %Y %H:%M:%S") +function timestamp() + local time = ts_func() - ts_valid + if time > 900 or time < 0 then + ts_valid = ts_func() + ts_last = os.date("!%d %b %Y %H:%M:%S") + end + return ts_last +end + +local localts_time = GetPreciseTicks +local localts_start = LocalTSStart or localts_time() +local localts_valid +local localts_last_timestamp = "" +function local_timestamp() + local time = localts_time() + if time ~= localts_valid then + localts_valid = time + time = time - localts_start + localts_last_timestamp = string_format("%d %02d:%02d:%02d.%03d", time / 24 / 3600000, time / 3600000 % 24, time / 60000 % 60, time / 1000 % 60, time % 1000) + end + return localts_last_timestamp +end + +local log_timestamp = local_timestamp + +local function log(screen_format, backlog, event_type, event_source, event, ...) + local time, screen_text + if g_logScreen then + time = time or log_timestamp() + screen_text = screen_text or print_format(string_format(screen_format, time, event_source or ""), event, ...) + print(screen_text) + end + + if backlog then + time = time or log_timestamp() + screen_text = screen_text or print_format(string_format(screen_format, time, event_source or ""), event, ...) + local i = 1 + LogBacklogIndex % LogBacklogSize + LogBacklogIndex = i + backlog[i] = screen_text + end + + local logstorage = g_logStorage + if logstorage then + time = time or log_timestamp() + event = event or string_format(event_text, ...) + if event_type == "event" then + logstorage:WriteTuple(timestamp(), time, event_source or "", event, ...) + else + logstorage:WriteTuple(timestamp(), time, event_type, event_source or "", event, ...) + end + end +end + +function EventLog(event_text, ...) + if event_text then + LogEventsCount = LogEventsCount + 1 + return log("%s", nil, "event", "", event_text, ...) + end +end + +function EventLogSrc(event_source, event_text, ...) + if event_text then + LogEventsCount = LogEventsCount + 1 + return log("%s %s ->", nil, "event", event_source, event_text, ...) + end +end + +function ErrorLog(event_text, ...) + if event_text then + LogErrorsCount = LogErrorsCount + 1 + return log("[color=magenta]%s error:", LogBacklog, "error", "", event_text, ...) + end +end + +function ErrorLogSrc(event_source, event_text, ...) + if event_text then + LogErrorsCount = LogErrorsCount + 1 + return log("[color=magenta]%s error: %s ->", LogBacklog, "error", event_source, event_text, ...) + end +end + +function SecurityLog(event_text, ...) + if event_text then + LogSecurityCount = LogSecurityCount + 1 + return log("[color=cyan]%s security:", LogBacklog, "security", "", event_text, ...) + end +end + +------------- + +DefineClass.EventLogger = { + __parents = { }, + event_source = "", +} + +function EventLogger:Log(event_text, ...) + if event_text then + LogEventsCount = LogEventsCount + 1 + local src = self.event_source or "" + if src ~= "" then + return log("%s %s ->", nil, "event", src, event_text, ...) + else + return log("%s", nil, "event", "", event_text, ...) + end + end +end + +function EventLogger:ErrorLog(event_text, ...) + if event_text then + LogErrorsCount = LogErrorsCount + 1 + local src = self.event_source or "" + if src ~= "" then + return log("[color=magenta]%s error: %s ->", LogBacklog, "error", src, event_text, ...) + else + return log("[color=magenta]%s error:", LogBacklog, "error", "", event_text, ...) + end + end +end + +function EventLogger:SecurityLog(event_text, ...) + if event_text then + LogSecurityCount = LogSecurityCount + 1 + local src = self.event_source or "" + if src ~= "" then + return log("[color=cyan]%s security: %s ->", LogBacklog, "security", src, event_text, ...) + else + return log("[color=cyan]%s security:", LogBacklog, "security", "", event_text, ...) + end + end +end + +------------- + +function LogPrint(count) + local logsize = LogBacklogSize + local backlog = LogBacklog + count = Min(count or logsize, logsize) + for i = LogBacklogIndex - count + 1, LogBacklogIndex do + local event = backlog[i < 1 and i + logsize or i] + if event then + print(event) + end + end +end + +-- gives the last 'count' entries of the backlog as a string +function LogToString(count) + local logsize = LogBacklogSize + local backlog = LogBacklog + local server_backlog = false + + count = Min(count or logsize, logsize) + for i = LogBacklogIndex - count + 1, LogBacklogIndex do + local event = backlog[i < 1 and i + logsize or i] + if event then + server_backlog = string.format("%s%s\n", server_backlog or "\n", event) + end + end + + return server_backlog +end \ No newline at end of file diff --git a/CommonLua/FXSource.lua b/CommonLua/FXSource.lua new file mode 100644 index 0000000000000000000000000000000000000000..24f5d1d264180a751e3b695cf2a06382e6050caf --- /dev/null +++ b/CommonLua/FXSource.lua @@ -0,0 +1,626 @@ +local Behaviors +local BehaviorsList + +MapVar("BehaviorLabels", {}) +MapVar("BehaviorLabelsUpdate", {}) +MapVar("BehaviorAreaUpdate", sync_set()) + +---- + +local function GatherFXSourceTags() + local tags = {} + Msg("GatherFXSourceTags", tags) + ForEachPreset("FXSourcePreset", function(preset, group, tags) + for tag in pairs(preset.Tags) do + tags[tag] = true + end + end, tags) + return table.keys(tags, true) +end + +---- + +function FXSourceUpdate(self, game_state_changed, forced_match, forced_update) + assert(not DisableSoundFX) + if not IsValid(self) or not forced_update and self.update_disabled then + return + end + local preset = self:GetPreset() or empty_table + local fx_event = preset.Event + if fx_event then + local match = forced_match + if match == nil then + match = MatchGameState(self.game_states) + end + if not match then + fx_event = false + end + end + if fx_event and Behaviors then + for name, set in pairs(preset.Behaviors) do + local behavior = Behaviors[name] + if behavior then + local enabled = behavior:IsFXEnabled(self, preset) + if set and not enabled or not set and enabled then + fx_event = false + break + end + end + end + end + local current_fx = self.current_fx + if fx_event == current_fx and not forced_update then + return + end + if current_fx then + PlayFX(current_fx, "end", self) + end + if fx_event then + PlayFX(fx_event, "start", self) + end + self.current_fx = fx_event or nil + if game_state_changed then + if current_fx and not fx_event and preset.PlayOnce then + self.update_disabled = true + end + self:OnGameStateChanged() + end +end + +---- + +DefineClass.FXSourceBehavior = { + __parents = { "PropertyObject" }, + id = false, + CreateLabel = false, + LabelUpdateMsg = false, + LabelUpdateDelay = 0, + LabelUpdateDelayStep = 50, + IsFXEnabled = return_true, +} + +function FXSourceBehavior:GetEditorView() + return Untranslated(self.id or self.class) +end + +function FXSourceUpdateBehaviorLabels() + local now = GameTime() + local labels_to_update = BehaviorLabelsUpdate + local sources_to_update = BehaviorAreaUpdate + if not next(sources_to_update) then + sources_to_update = false + end + local labels = BehaviorLabels + local next_time = max_int64 + local pass_edits + local FXSourceUpdate = FXSourceUpdate + for _, name in ipairs(labels_to_update) do + local def = Behaviors[name] + local label = def and labels[name] + if label then + local delay = def.LabelUpdateDelay + local time = labels_to_update[name] + if now < time then + if next_time < time then + next_time = time + end + elseif now <= time + delay then + if not pass_edits then + pass_edits = true + SuspendPassEdits("FXSource") + end + if delay == 0 then + for _, source in ipairs(label) do + FXSourceUpdate(source) + if sources_to_update then + sources_to_update:remove(source) + end + end + else + local step = def.LabelUpdateDelayStep + local steps = 1 + delay / step + local BraidRandom = BraidRandom + local seed = xxhash(name, MapLoadRandom) + for i, source in ipairs(label) do + local delta + delta, seed = BraidRandom(seed, steps) + local time_i = time + delta * step + if now == time_i then + FXSourceUpdate(source) + if sources_to_update then + sources_to_update:remove(source) + end + elseif now < time_i and next_time > time_i then + next_time = time_i + end + end + end + end + end + end + if pass_edits then + ResumePassEdits("FXSource") + end + return (next_time > now and next_time < max_int) and (next_time - now) or nil +end + +--ErrorOnMultiCall("FXSourceUpdate") + +MapGameTimeRepeat("FXSourceUpdateBehaviorLabels", nil, function() + local sleep = FXSourceUpdateBehaviorLabels() + WaitWakeup(sleep) +end) + +function FXSourceUpdateBehaviorLabel(id) + if not BehaviorLabels[id] then + return + end + local list = BehaviorLabelsUpdate + if not list[id] then + list[#list + 1] = id + end + list[id] = GameTime() + WakeupPeriodicRepeatThread("FXSourceUpdateBehaviorLabels") +end + +function FXSourceUpdateBehaviorArea() + local sources_to_update = BehaviorAreaUpdate + if not next(sources_to_update) then return end + SuspendPassEdits("FXSource") + local FXSourceUpdate = FXSourceUpdate + for _, source in ipairs(sources_to_update) do + FXSourceUpdate(source) + end + table.clear(sources_to_update, true) + ResumePassEdits("FXSource") +end + +function FXSourceUpdateBehaviorAround(id, pos, radius) + local def = Behaviors[id] + local label = def and BehaviorLabels[id] + if not label then + return + end + local list = BehaviorAreaUpdate + MapForEach(pos, radius, "FXSource", function(source, label, list) + if label[source] then + list:insert(source) + end + end, label, list) + if not next(list) then + return + end + WakeupPeriodicRepeatThread("FXSourceUpdateBehaviorArea") +end + +MapGameTimeRepeat("FXSourceUpdateBehaviorArea", nil, function() + FXSourceUpdateBehaviorArea() + WaitWakeup() +end) + +function OnMsg.ClassesBuilt() + ClassDescendants("FXSourceBehavior", function(class, def) + local id = def.id + if id then + Behaviors = table.create_set(Behaviors, id, def) + assert(not def.LabelUpdateMsg or def.CreateLabel) + if def.CreateLabel and def.LabelUpdateMsg then + OnMsg[def.LabelUpdateMsg] = function() + FXSourceUpdateBehaviorLabel(id) + end + end + end + end) + BehaviorsList = Behaviors and table.keys(Behaviors, true) +end + +local function RegisterBehaviors(source, labels, preset) + if not Behaviors then + return + end + preset = preset or source:GetPreset() + if not preset or not preset.Event then + return + end + for name, set in pairs(preset.Behaviors) do + local behavior = Behaviors[name] + if behavior and behavior.CreateLabel then + labels = labels or BehaviorLabels + local label = labels[name] + if not label then + labels[name] = {source, [source] = true} + elseif not label[source] then + label[#label + 1] = source + label[source] = true + end + end + end +end + +function FXSourceRebuildLabels() + BehaviorLabels = {} + BehaviorLabelsUpdate = BehaviorLabelsUpdate or {} + MapForEach("map", "FXSource", const.efMarker, RegisterBehaviors, BehaviorLabels) +end + +local function UnregisterBehaviors(source, labels) + for name, label in pairs(labels or BehaviorLabels) do + if label[source] then + table.remove_value(label, source) + label[source] = nil + end + end +end + +---- + +DefineClass.FXBehaviorChance = { + __parents = { "FXSourceBehavior" }, + id = "Chance", + CreateLabel = true, + properties = { + { category = "FX: Chance", id = "EnableChance", name = "Chance", editor = "number", default = 100, min = 0, max = 100, scale = "%", slider = true }, + { category = "FX: Chance", id = "ChangeInterval", name = "Change Interval", editor = "number", default = 0, min = 0, scale = function(self) return self.IntervalScale end, help = "Time needed to change the chance result." }, + { category = "FX: Chance", id = "IntervalScale", name = "Interval Scale", editor = "choice", default = false, items = function() return table.keys(const.Scale, true) end }, + { category = "FX: Chance", id = "IsGameTime", name = "Game Time", editor = "bool", default = false, help = "Change interval time type. Game Time is needed for events messing with the game logic." }, + }, +} + +function FXBehaviorChance:IsFXEnabled(source, preset) + local chance = preset and preset.EnableChance or 100 + if chance >= 100 then return true end + local time = (preset.IsGameTime and GameTime() or RealTime()) / Max(1, preset.ChangeInterval or 0) + local seed = xxhash(source.handle, time, MapLoadRandom) + return (seed % 100) < chance +end + +---- + +DefineClass.FXSourcePreset = { + __parents = { "Preset" }, + properties = { + { category = "FX", id = "Event", name = "FX Event", editor = "combo", default = false, items = function(fx) return ActionFXClassCombo(fx) end }, + { category = "FX", id = "GameStates", name = "Game State", editor = "set", default = set(), three_state = true, items = function() return GetGameStateFilter() end }, + { category = "FX", id = "PlayOnce", name = "Play Once", editor = "bool", default = false, help = "Kill the object if the FX is no more matched after changing game state", }, + { category = "FX", id = "EditorPlay", name = "Editor Play", editor = "choice", default = "force play", items = {"no change", "force play", "force stop"}, developer = true }, + { category = "FX", id = "Entity", name = "Editor Entity", editor = "combo", default = false, items = function() return GetAllEntitiesCombo() end }, + { category = "FX", id = "Tags", name = "Tags", editor = "set", default = set(), items = GatherFXSourceTags, help = "Help the game logic find this source if needed", }, + { category = "FX", id = "Behaviors", name = "Behaviors", editor = "set", default = set(), items = function() return BehaviorsList end, three_state = true, }, + { category = "FX", id = "ConditionText", name = "Condition", editor = "text", default = "", read_only = true, no_edit = function(self) return not next(self.Behaviors) end }, + { category = "FX", id = "Actor", name = "FX Actor", editor = "combo", default = false, items = function(fx) return ActorFXClassCombo(fx) end}, + { category = "FX", id = "Scale", name = "Scale", editor = "number", default = false }, + { category = "FX", id = "Color", name = "Color", editor = "color", default = false }, + { category = "FX", id = "FXButtons", editor = "buttons", default = false, buttons = {{ name = "Map Select", func = "ActionSelect" }} }, + }, + GlobalMap = "FXSourcePresets", + EditorMenubarName = "FX Sources", + EditorMenubar = "Editors.Art", + EditorIcon = "CommonAssets/UI/Icons/atoms electron physic.png", +} + +function FXSourcePreset:GetConditionText() + local texts = {} + for name, set in pairs(self.Behaviors) do + if set then + texts[#texts + 1] = name + else + texts[#texts + 1] = "not " .. name + end + end + return table.concat(texts, " and ") +end + +function FXSourcePreset:ActionSelect() + if GetMap() == "" then + return + end + editor.ClearSel() + editor.AddToSel(MapGet("map", "FXSource", const.efMarker, function(obj, id) + return obj.FxPreset == id + end, self.id)) +end + +function FXSourcePreset:OnEditorSetProperty(prop_id) + if GetMap() == "" then + return + end + local prop = self:GetPropertyMetadata(prop_id) + if not prop or prop.category ~= "FX" then + return + end + MapForEach("map", "FXSource", const.efMarker, function(obj, self) + if obj.FxPreset == self.id then + obj:SetPreset(self) + end + end, self) +end + +function FXSourcePreset:GetProperties() + local orig_props = Preset.GetProperties(self) + local props = orig_props + if Behaviors then + for name, set in pairs(self.Behaviors) do + local classdef = Behaviors[name] + local propsi = classdef and classdef.properties + if propsi and #propsi > 0 then + if props == orig_props then + props = table.icopy(props) + end + props = table.iappend(props, propsi) + end + end + end + return props +end + +DefineClass("FXSourceAutoResolve") + +DefineClass.FXSource = { + __parents = { "Object", "FXObject", "EditorEntityObject", "EditorCallbackObject", "EditorTextObject", "FXSourceAutoResolve" }, + flags = { efMarker = true, efWalkable = false, efCollision = false, efApplyToGrids = false }, + editor_text_offset = point(0, 0, -guim), + editor_text_style = "FXSourceText", + editor_entity = "ParticlePlaceholder", + entity = "InvisibleObject", + + properties = { + { category = "FX Source", id = "FxPreset", name = "FX Preset", editor = "preset_id", default = false, preset_class = "FXSourcePreset", buttons = {{ name = "Start", func = "ActionStart" }, { name = "End", func = "ActionEnd" }} }, + { category = "FX Source", id = "Playing", name = "Playing", editor = "bool", default = false, dont_save = true, read_only = true }, + }, + + current_fx = false, + update_disabled = false, + game_states = false, + + prefab_no_fade_clamp = true, +} + +function FXSource:GetPlaying() + return not not self.current_fx +end + +function FXSource:EditorGetText() + return (self.FxPreset or "") ~= "" and self.FxPreset or self.class +end + +function FXSource:GetEditorLabel() + local label = self.class + if (self.FxPreset or "") ~= "" then + label = label .. " (" .. self.FxPreset .. ")" + end + return label +end + +function FXSource:GetError() + if not self.FxPreset then + return "FX source has no FX preset assigned." + end +end + +function FXSource:GameInit() + if ChangingMap then + return -- sound FX are disabled during map changing + end + FXSourceUpdate(self) +end + +FXSourceAutoResolve.EditorExit = FXSourceUpdate + +function FXSourceAutoResolve:EditorEnter() + CreateRealTimeThread(function() + WaitChangeMapDone() + if GetMap() == "" then + return + end + local match + if IsEditorActive() then + local preset = self:GetPreset() or empty_table + local editor_play = preset.EditorPlay + if editor_play == "force play" then + match = true + elseif editor_play == "force stop" then + match = false + end + end + FXSourceUpdate(self, nil, match) + end) +end + +function FXSourceAutoResolve:OnEditorSetProperty(prop_id) + if prop_id == "FxPreset" then + FXSourceUpdate(self, nil, self:GetPlaying(), true) + self:EditorTextUpdate() + end +end + +MapVar("FXSourceStates", false) +MapVar("FXSourceUpdateThread", false) + +function FXSource:SetGameStates(states) + states = states or false + local prev_states = self.game_states + if prev_states == states then + return + end + local counters = FXSourceStates or {} + FXSourceStates = counters + for state in pairs(states) do + counters[state] = (counters[state] or 0) + 1 + end + for state in pairs(prev_states) do + local count = counters[state] or 0 + assert(count > 0) + if count > 1 then + counters[state] = count - 1 + else + counters[state] = nil + end + end + self.game_states = states or nil +end + +function FXSource:OnGameStateChanged() +end + +function FXSource:SetFxPreset(id) + if (id or "") == "" then + self.FxPreset = nil + self:SetPreset() + return + end + self.FxPreset = id + self:SetPreset(FXSourcePresets[id]) +end + +function FXSource:SetPreset(preset) + UnregisterBehaviors(self) + + if not preset then + self:SetGameStates(false) + self:ChangeEntity(FXSource.entity) + self.fx_actor_class = nil + self:SetState("idle") + self:SetScale(100) + self:SetColorModifier(const.clrNoModifier) + FXSourceUpdate(self, nil, false) + return + end + + RegisterBehaviors(self, nil, preset) + self:SetGameStates(preset.GameStates) + if preset.Entity then + self.editor_entity = preset.Entity + if IsEditorActive() then + self:ChangeEntity(preset.Entity) + end + end + if preset.Actor then + self.fx_actor_class = preset.Actor + end + if preset.State then + self:SetState(preset.State) + end + if preset.Scale then + self:SetScale(preset.Scale) + end + if preset.Color then + self:SetColorModifier(preset.Color) + end + if self.current_fx then + FXSourceUpdate(self, nil, true) + end +end + +function FXSource:GetPreset() + return FXSourcePresets[self.FxPreset] +end + +function FXSource:Done() + self:SetGameStates(false) -- unregister from FXSourceStates + FXSourceUpdate(self, nil, false) + UnregisterBehaviors(self) +end + + + +function FXSource:ActionStart() + FXSourceUpdate(self, nil, true, true) + ObjModified(self) +end + +function FXSource:ActionEnd() + FXSourceUpdate(self, nil, false, true) + ObjModified(self) +end + +local function FXSourceUpdateAll(area, ...) + SuspendPassEdits("FXSource") + MapForEach(area, "FXSource", const.efMarker, FXSourceUpdate, ...) + ResumePassEdits("FXSource") +end + +function FXSourceUpdateOnGameStateChange(delay) + if GetMap() == "" then + return + end + delay = delay or GameTime() == 0 and 0 or config.MapSoundUpdateDelay or 1000 + DeleteThread(FXSourceUpdateThread) + FXSourceUpdateThread = CreateGameTimeThread(function(delay) + if delay <= 0 then + FXSourceUpdateAll("map", "game_state_changed") + else + local boxes = GetMapBoxesCover(config.MapSoundBoxesCoverParts or 8, "MapSoundBoxesCover") + local count = #boxes + for i, box in ipairs(boxes) do + FXSourceUpdateAll(box, "game_state_changed") + Sleep((i + 1) * delay / count - i * delay / count) + end + end + FXSourceUpdateThread = false + end, delay) +end + +function OnMsg.ChangeMapDone() + FXSourceUpdateOnGameStateChange() +end + +function OnMsg.GameStateChanged(changed) + if ChangingMap or GetMap() == "" then return end + local GameStateDefs, FXSourceStates = GameStateDefs, FXSourceStates + if not FXSourceStates then return end + for id in sorted_pairs(changed) do + if GameStateDefs[id] and (FXSourceStates[id] or 0) > 0 then -- if a game state is changed, update sound sources + FXSourceUpdateOnGameStateChange() + break + end + end +end + +if Platform.developer then + +local function ReplaceWithSources(objs, fx_src_preset) + if #(objs or "") == 0 then + return 0 + end + XEditorUndo:BeginOp{ objects = objs, name = "ReplaceWithFXSource" } + editor.ClearSel() + local sources = {} + for _, obj in ipairs(objs) do + local pos, axis, angle, scale, coll = obj:GetPos(), obj:GetAxis(), obj:GetAngle(), obj:GetScale(), obj:GetCollectionIndex() + DoneObject(obj) + local src = PlaceObject("FXSource") + src:SetGameFlags(const.gofPermanent) + src:SetAxisAngle(axis, angle) + src:SetScale(scale) + src:SetPos(pos) + src:SetCollectionIndex(coll) + src:SetFxPreset(fx_src_preset) + sources[#sources + 1] = src + end + Msg("EditorCallback", "EditorCallbackPlace", sources) + editor.AddToSel(sources) + XEditorUndo:EndOp(sources) + return #sources +end + +function ReplaceMapSounds(snd_name, fx_src_preset) + local objs = MapGet("map", "SoundSource", function(obj) + for _, entry in ipairs(obj.Sounds) do + if entry.Sound == snd_name then + return true + end + end + end) + local count = ReplaceWithSources(objs, fx_src_preset) + print(count, "sounds replaced and selected") +end + +function ReplaceMapParticles(prtcl_name, fx_src_preset) + local objs = MapGet("map", "ParSystem", function(obj) + return obj:GetParticlesName() == prtcl_name + end) + local count = ReplaceWithSources(objs, fx_src_preset) + print(count, "particles replaced and selected") +end + +end diff --git a/CommonLua/Friends.lua b/CommonLua/Friends.lua new file mode 100644 index 0000000000000000000000000000000000000000..ad9f52f6067b85d778ecf95bef8bd7607314334b --- /dev/null +++ b/CommonLua/Friends.lua @@ -0,0 +1,267 @@ +-- err, alias_type, { [] = }, { [blocked_id] = } +function PlatformGetFriends() + return nil, "name", {}, {} +end + +if Platform.steam then + +function OnMsg.NetPlayerInfo(player, info) + if player.id ~= netUniqueId and info.steam_id64 then + SteamSetPlayedWith(info.steam_id64) + end +end + +function OnMsg.NetGameJoined(game_id, player_id) + for k, v in sorted_pairs(netGamePlayers) do + if k ~= netUniqueId and v.steam_id64 then + SteamSetPlayedWith(v.steam_id64) + end + end +end + +function PlatformGetFriends() + local friends = SteamGetFriends() + if not friends then return "getting friends error" end + local blocked_users = SteamGetBlockedUsers() + if not friends then return "getting blocked users error" end + return nil, "steam", friends, blocked_users +end + +end --Platform.steam + +if Platform.playstation then + +if FirstLoad then + g_LastPlayStationGetFriends = 0 + g_LastFriendList = {} + g_LastBlockList = {} +end + +function GetUsersList(psn_id, limit, url_template, list_name) + local users_list = {} + local count = 0 + for start = 0, 2000, limit do + local url = string.format(url_template, tostring(psn_id), limit, start) + local err, http_code, result = AsyncOpWait(PSNAsyncOpTimeout, nil, "AsyncPlayStationWebApiRequest", "userProfile", url, "", "GET", "", {}) + if err or http_code ~= 200 then + local err, http_error = JSONToLua(result) + return err or http_code, result + end + local err, users = JSONToLua(result) + if err or not users then + return "json error" + end + for _, user_t in ipairs(users[list_name]) do + count = count + 1 + table.insert(users_list, user_t) + end + if count >= users.totalItemCount then break end + end + return nil, users_list +end + +function GetPublicIds(account_ids) + -- PlayStation API has a limit of 100 accounts per request + local batches = {} + local count = 0 + local account_ids_string = "" + for _, acc_id in pairs(account_ids) do + if count == 99 then + count = 0 + table.insert(batches, account_ids_string) + account_ids_string = "" + end + account_ids_string = account_ids_string .. (count == 0 and "" or ",") .. acc_id + count = count + 1 + end + table.insert(batches, account_ids_string) + + local online_ids = {} + local count = 0 + for _, accounts_id_string in ipairs(batches) do + local url = string.format("/v1/users/profiles?accountIds=%s", accounts_id_string) + local err, http_code, result = AsyncOpWait(PSNAsyncOpTimeout, nil, "AsyncPlayStationWebApiRequest", "userProfile", url, "", "GET", "", {}) + if err or http_code ~= 200 then + local err, http_error = JSONToLua(result) + return err or http_code, result + end + local err, users = JSONToLua(result) + if err or not users then + return "json error" + end + for _, user_t in ipairs(users and users.profiles) do + count = count + 1 + table.insert(online_ids, tostring(user_t.onlineId or "")) + end + end + + local assocs = {} + for idx, id in ipairs(account_ids) do + assocs[id] = online_ids[idx] + end + return nil, assocs +end + +function PlatformGetFriends() + local time = os.time() + if time - g_LastPlayStationGetFriends > 60 then -- more than a minute ago + g_LastPlayStationGetFriends = time + -- can use psn_id instead of "me" for friendsList, but not blockList + local user = "me" + -- Get friends + local friends_list = {} + local err, friends_list = GetUsersList(user, 500, "/v1/users/%s/friends?limit=%d&offset=%d", "friends") + if err then return "GET friendList failed: " .. err end + + -- Get blocked users + local block_list = {} + err, block_list = GetUsersList(user, 2000, "/v1/users/%s/blocks?limit=%d&offset=%d", "blocks") + if err then return "GET blockList failed: " .. err end + + -- get online (public) ids from the account ids + local total_ids = {} + for _, id in ipairs(friends_list) do + table.insert(total_ids, id) + end + for _, id in ipairs(block_list) do + table.insert(total_ids, id) + end + local err, public_ids = GetPublicIds(total_ids) + if err then + return "GET Public IDs failed: " .. err + end + + -- recover the association between account id and online id + g_LastFriendList = {} + for _, friend in ipairs(friends_list) do + g_LastFriendList[friend] = public_ids[friend] + end + g_LastBlockList = {} + for _, blocked in ipairs(block_list) do + g_LastBlockList[friend] = public_ids[blocked] + end + end + return nil, "psn", g_LastFriendList, g_LastBlockList +end + +end --Platform.playstation + +if Platform.xbox then + +function PlatformGetFriends() + local err, console_friends_xuid = AsyncXboxGetFriends() + if err then return err end + local err, consoles_friends_gamertags = AsyncXboxGetGamertagsFromXuids(console_friends_xuid) + if err then return err end + local friends = {} + for i, xuid in ipairs(console_friends_xuid) do + friends[HashXUID(tostring(xuid))] = consoles_friends_gamertags[i] + end + + local blocked = {} + local err, console_blocked = AsyncXboxGetAvoidList() + for _, XUID in ipairs(console_blocked or empty_table) do + blocked[HashXUID(tostring(XUID))] = "" -- this will force the backed to lookup the name + end + return nil, "xboxlive", friends, blocked +end + +function OnMsg.XboxAppStateChanged() + if XboxAppState == "full" then + CreateRealTimeThread(UpdatePlatformFriends, {}, {}) + end +end + +end -- Platform.xbox + +if Platform.switch then + +function PlatformGetFriends() + local err, friend_ids, friend_names = Switch.GetFriends() + if err then return err end + local friends = {} + for i = 1, #friend_ids do + friends[string.format("%s:%s", netEnvironment, friend_ids[i])] = friend_names[i] + end + local err, blocked_ids = Switch.GetBlocked() + if err then return err end + local blocked = {} + for i = 1, #blocked_ids do + blocked[string.format("%s:%s", netEnvironment, blocked_ids[i])] = "" + end + + return nil, "nintendo", friends, blocked +end + +end -- Platform.switch + +function UpdatePlatformFriends(friends, friend_names) + if not AccountStorage or not NetIsConnected() then return end + local err, alias_type, platform_friends, platform_blocked = PlatformGetFriends() + if err or not friends then return err end + + local stored_friends = AccountStorage.platform_friends or empty_table + local stored_blocked = AccountStorage.platform_blocked or empty_table + + -- confirm invitations from people with names matching our friends + local platform_friends_by_name = table.invert(platform_friends) + for account_id, status in pairs(friends) do + local name = friend_names[account_id] + if status == "invited" and platform_friends_by_name[name] then + NetFriendRequest(name, platform_friends_by_name[name], alias_type) + end + end + + + local friends_changed + -- send invitation to new friends (compared to the last seen list) + for id, name in pairs(platform_friends) do + if not stored_friends[id] then + friends_changed = true + NetFriendRequest(name, id, alias_type) + end + end + + -- unfriend people who we have unfriended in the platform (compared to last seen list) + for id in pairs(stored_friends) do + if not platform_friends[id] then + friends_changed = true + NetUnfriend(id, alias_type) + end + end + + -- block players (compared to the last seen blocked list) + for id, name in pairs(platform_blocked) do + if not stored_blocked[id] then + friends_changed = true + NetBlock(name, id, alias_type) + end + end + + -- unblock players (compared to the last seen blocked list) + for id in pairs(stored_blocked) do + if not platform_blocked[id] then + friends_changed = true + NetUnblock(id, alias_type) + end + end + + if friends_changed then + AccountStorage.platform_friends = platform_friends + AccountStorage.platform_blocked = platform_blocked + SaveAccountStorage(5000) + end +end + +function OnMsg.FriendsChange(friends, friend_names, event) + if not AccountStorage then return end + if event == "init" then + local time = os.time() + if time - (AccountStorage.friend_reset_time or 0) > 7*24*60*60 then + AccountStorage.platform_friends = {} + AccountStorage.platform_blocked = {} + AccountStorage.friend_reset_time = time + end + CreateRealTimeThread(UpdatePlatformFriends, friends, friend_names) + end +end diff --git a/CommonLua/GameBase.lua b/CommonLua/GameBase.lua new file mode 100644 index 0000000000000000000000000000000000000000..ced10261ea4036c1d35611a43e183b86d0d4187b --- /dev/null +++ b/CommonLua/GameBase.lua @@ -0,0 +1,17 @@ +-- base game functions needed for loading a map, moved from EditorGame.lua in order to detach the editor from the game + +function WaitNextFrame(count) + local persistError = collectgarbage -- we reference a C function so trying to persist WaitNextFrame will result in an error + local frame = GetRenderFrame() + (count or 1) + while GetRenderFrame() - frame < 0 do + WaitMsg("OnRender", 30) + end +end + +function WaitFramesOrSleepAtLeast(frames, ms) + local end_frame = GetRenderFrame() + (frames or 1) + local end_time = now() + ms + while GetRenderFrame() < end_frame or now() < end_time do + Sleep(1) + end +end \ No newline at end of file diff --git a/CommonLua/GameRecording.lua b/CommonLua/GameRecording.lua new file mode 100644 index 0000000000000000000000000000000000000000..15fb0b7cdd8ee111af6a8c0ba4361dfa737de596 --- /dev/null +++ b/CommonLua/GameRecording.lua @@ -0,0 +1,751 @@ +DefineClass("TestValidator") + +IsGameReplayRunning = empty_func + +if Platform.developer then + RecursiveCallMethods.GetTestData = "call" +end + +function IsGameRecordingSupported() + return config.SupportGameRecording and Libs.Network == "sync" +end + +GameRecordVersion = 2 + +function ApplyRecordVersion(record) + local version = not record and GameRecordVersion or record.version or 1 + if version == GameRecordCurrentVersion then + return + end + GameRecordCurrentVersion = version + if version == 1 then + RECORD_GTIME = 1 + RECORD_EVENT = 2 + RECORD_PARAM = 3 + --RECORD_RAND = 4 + --RECORD_HANDLE = 5 + RECORD_RTIME = 6 + RECORD_ETYPE = 7 + --RECORD_HASH = 8 + RECORD_SPEED = 9 + else + RECORD_GTIME = 1 + RECORD_EVENT = 2 + RECORD_PARAM = 3 + RECORD_RTIME = 4 + RECORD_ETYPE = 5 + RECORD_SPEED = 6 + end +end + +if FirstLoad then + GameRecordCurrentVersion = false + ApplyRecordVersion() +end + +config.GameRecordsPath = "AppData/GameRecords" +local records_path = config.GameRecordsPath + +MapVar("GameRecord", false) -- to be available in the saves +MapVar("GameReplay", false) +MapVar("GameReplayThread", false) + +if FirstLoad then + GameRecordScheduled = false + + GameReplayScheduled = false + GameReplayPath = false + GameReplaySaveLoading = false + GameReplayUnresolved = false + GameReplayWaitMap = false + GameReplayToInject = false + GameReplayFastForward = false + + GameRecordSaveRequests = false +end + +function IsGameReplayRunning() + return IsValidThread(GameReplayThread) and GameReplay +end + +if not IsGameRecordingSupported() then + return +end + +local IsGameReplayRunning = IsGameReplayRunning + +function OnMsg.ChangeMap() + GameReplayPath = false + GameRecordScheduled = false + StopGameReplay() +end + +function OnMsg.NewMapLoaded() + GameReplay = false + GameRecord = GameRecordScheduled + GameRecordScheduled = false + if not not GameRecord then + Msg("GameRecordingStarted") + end +end + +function OnMsg.NetGameJoined() + GameRecord = false +end + +function OnMsg.GameTestsBegin(auto_test) + table.change(config, "GameTests_GameRecording", { + EnableGameRecording = false, + }) +end + +function OnMsg.GameTestsEnd(auto_test) + table.restore(config, "GameTests_GameRecording", true) +end + +function SerializeRecordParams(...) + return SerializeEx(const.SerializeCustom, CustomSerialize, ...) +end + +function UnserializeRecordParams(params_str) + return UnserializeEx(const.SerializeCustom, CustomUnserialize, params_str) +end + +function PrepareRecordForSaving(record) + record = record or GameRecord + if record ~= GameRecord then return end + record.game_time = GameTime() +end + +function PlayGameRecord(record, start_idx) + record = record or GameReplayScheduled + if not record then return end + assert(IsGameTimeThread()) + assert(record.start_rand == MapLoadRandom) + start_idx = start_idx or 1 + GameReplay = record + GameReplayScheduled = false + GameReplaySaveLoading = false + GameReplayThread = CurrentThread() + if GameReplayWaitMap then + WaitWakeup() -- ensure the map loading is complete + end + ApplyRecordVersion(record) + local desync_any + local total_time = Max((record[#record] or empty_table)[RECORD_GTIME] or 0, record.game_time or 0) + local start_time = GameTime() + if start_idx > #record or start_time > record[start_idx][RECORD_GTIME] then + GameTestsPrint("Replay injection start mismatch!") + start_idx = 1 + while start_idx <= #record and start_time > record[start_idx][RECORD_GTIME] do + start_idx = start_idx + 1 + end + end + local version = record.version or 1 + GameTestsPrint("Replay start at", Min(start_idx, #record), "/", #record, "events", "|", start_time, "/", total_time, "ms", "|", "Lua rev", record.lua_rev or 0, "/", LuaRevision, "|", "assets rev", record.assets_rev or 0, "/", AssetsRevision) + for i = start_idx,#record do + local event_time = record[i][RECORD_GTIME] + local delay = event_time - now() + local yield + if delay > 0 then + yield = record[i][RECORD_SPEED] == 0 + Sleep(delay) + else + local last_record = record[i - 1] + local prev_real_time = last_record and last_record[RECORD_RTIME] or record.real_time + yield = prev_real_time ~= record[i][RECORD_RTIME] + end + if yield then + -- make sure all game time threads created by the previous event have been started + WaitAllOtherThreads() + end + if GameReplayThread ~= CurrentThread() or GameReplay ~= record then + return + end + print("Replay", i, '/', #record) + CreateGameTimeThread(function(record, i) + local entry = record[i] + local event, params_str = entry[RECORD_EVENT], entry[RECORD_PARAM] + GameReplayUnresolved = false + local success, err = ExecuteSyncEvent(event, UnserializeRecordParams(params_str)) + if not success then + GameTestsError("Replay", i, '/', #record, event, err) + end + if GameReplayUnresolved then + GameTestsPrint("Replay", i, '/', #record, event, "unresolved objects:") + for _, data in ipairs(GameReplayUnresolved) do + local handle, class, pos = table.unpack(data) + GameTestsPrint("\t", class, handle, "at", pos) + end + end + Msg("GameRecordPlayed", i, record) + end, record, i) + end + Sleep((record.game_time or 0) - now()) + Sleep(0) Sleep(0) Sleep(0) Sleep(0) + + GameTestsPrint("Replay finished") + Msg("GameReplayEnd", record) +end + +local function IsSameClass(obj, class) + if not obj then + return -- object handle changed or object not yet spawned + end + if not class or obj.class == class then + return true + end + local classdef = g_Classes[class] + return not classdef or IsKindOf(classdef, obj.class) or IsKindOf(obj, class) -- recorded class renamed +end + +function CustomSerialize(obj) + local handle = obj.handle + if handle and IsValid(obj) and obj:IsValidPos() then + if obj:GetGameFlags(const.gofSyncObject | const.gofPermanent) == 0 then + StoreErrorSource(obj, "Async object in sync event") + end + local record = GameRecord + local handles = record and record.handles + if handles then + local class = obj.class + handles[handle] = class + return { handle, class, obj:GetPos() } + end + end +end + +function CustomUnserialize(tbl) + local handle, class, pos = table.unpack(tbl) + local obj = HandleToObject[handle] + if IsSameClass(obj, class) then + return obj + end + local map_obj = MapGetFirst(pos, 0, class) + if map_obj then + return map_obj + end + GameReplayUnresolved = table.create_add(GameReplayUnresolved, tbl) +end + +function CreateRecordedEvent(event_type) + local origGameEvent = _G[event_type] + _G[event_type] = function(event, ...) + if IsGameReplayRunning() then + if not config.GameReplay_EventsDuringPlaybackExpected then + print("Ignoring", event_type, event, "during replay!") + end + + return + end + local record = GameRecord + if record then + local params, err = SerializeRecordParams(...) + assert(params, err) + CreateGameTimeThread(function(event, event_type, record, params) + -- in a thread to have the correct sync values (as the event will be started in a thread) + local time = GameTime() + local n = #record + while n > 0 and record[n][RECORD_GTIME] > time do + record[n] = nil + n = n - 1 + end + n = n + 1 + record[n] = { + [RECORD_GTIME] = time, + [RECORD_EVENT] = event, + [RECORD_PARAM] = params, + [RECORD_RTIME] = RealTime(), + [RECORD_ETYPE] = event_type, + [RECORD_SPEED] = GetTimeFactor(), + } + if config.GameRecordingAutoSave then + SaveGameRecord() + end + end, event, event_type, record, params) + end + return origGameEvent(event, ...) + end +end + +function CreateRecordedMapLoadRandom() + local origInitMapLoadRandom = InitMapLoadRandom + InitMapLoadRandom = function() + local rand + if GameReplayScheduled then + CreateGameTimeThread(PlayGameRecord) + rand = GameReplayScheduled.start_rand + else + rand = origInitMapLoadRandom() + if mapdata and mapdata.GameLogic and config.EnableGameRecording then + assert(Game) + assert(not IsGameReplayRunning()) + GameReplay = false + GameRecordScheduled = { + start_rand = rand, + map_name = GetMapName(), + map_hash = mapdata.NetHash, + os_time = os.time(), + real_time = RealTime(), + game = CloneObject(Game), + lua_rev = LuaRevision, + assets_rev = AssetsRevision, + handles = {}, + version = GameRecordVersion, + net_update_hash = config.DebugReplayDesync, + } + Msg("GameRecordScheduled") + end + end + return rand + end +end + +function CreateRecordedGenerateHandle() + local origGenerateSyncHandle = GenerateSyncHandle + GenerateSyncHandle = function(self) + local h0, h = NextSyncHandle, origGenerateSyncHandle() + if IsGameReplayRunning() and GameReplay.NextSyncHandle and GameReplay.handles and not IsSameClass(self, GameReplay.handles[h]) then + -- the handle should be associated with another object, not yet spawn because of a previois desync + NextSyncHandle = GameReplay.NextSyncHandle + h = origGenerateSyncHandle() + NextSyncHandle = h0 + end + return h + end +end + +function RegisterGameRecordOverrides() + CreateRecordedEvent("NetSyncEvent") + CreateRecordedMapLoadRandom() + CreateRecordedGenerateHandle() +end + +function OnMsg.ClassesGenerate() + RegisterGameRecordOverrides() + GameTests.PlayGameRecords = TestGameRecords +end + +function TestGameRecords() + local list = {} + for _, test in ipairs(GetHGProjectTests()) do + for _, info in ipairs(test.test_replays) do + local name = string.format("%s.%s", test.id, info:GetRecordName()) + if info.Disabled then + GameTestsPrint("Skipping disabled test replay", name) + else + list[#list + 1] = {name = name, record = info.Record} + end + end + end + GameTestsPrintf("Found %d game records to test.", #(list or "")) + for i, entry in ipairs(list) do + GameTestsPrintf("Testing record %d/%d %s", i, #list, entry.name) + local st = RealTime() + local err, record = LoadGameRecord(entry.record) + if err then + GameTestsPrintf("Failed to load record %s", entry.record, err) + else + err = ReplayGameRecord(record) + if err then + GameTestsError("Replay error:", err) + elseif not WaitReplayEnd() then + GameTestsError("Replay timeout.") + else + GameTestsPrint("Replay finished in:", RealTime() - st, "ms") + end + end + end +end + +local function GenerateRecordPath(record) + local err = AsyncCreatePath(records_path) + if err then + assert(err) + return false + end + record = record or GameRecord + local name = string.format("Record_%s_%s", os.date("%Y_%m_%d_%H_%M_%S", record.os_time), GetMapName()) + if record.continue then + name = name .. "_continue" + end + return string.format("%s/%s.lua", records_path, name) +end + +function SaveGameRecord() + if not GameRecord then return end + local path = GameReplayPath or GenerateRecordPath(GameRecord) + GameRecord.NextSyncHandle = NextSyncHandle + if not GameRecordSaveRequests then + GameRecordSaveRequests = {} + CreateRealTimeThread(function() + while true do + for path, record in pairs(GameRecordSaveRequests) do + GameRecordSaveRequests[path] = nil + WaitSaveGameRecord(path, record) + end + if not next(GameRecordSaveRequests) then + GameRecordSaveRequests = false + break + end + end + end) + end + GameRecordSaveRequests[path] = GameRecord +end + +function WaitSaveGameRecord(path, record) + record = record or GameRecord + if not record then return end + PrepareRecordForSaving(record) + local code = pstr("return ", 64*1024) + TableToLuaCode(record, nil, code) + path = path or GenerateRecordPath(record) + local err = AsyncStringToFile(path, code) + if err then return err end + Msg("GameReplaySaved", path) + return nil, path +end + +function ReplayGameRecord(record) + record = record or not IsGameReplayRunning() and GameRecord or GameReplay + local err + if type(record) == "string" then + local _, _, ext = SplitPath(record) + local path = ext ~= "" and record or string.format("%s/%s.lua", records_path, record) + err, record = LoadGameRecord(path) + end + if not record then + return err or "No record found!" + end + PrepareRecordForSaving(record) + Msg("GameRecordEnd", record) + StopGameReplay() + Msg("GameReplayStart", record) + GameReplayScheduled = record + if record.start_save then + GameReplaySaveLoading = true + GameReplayThread = CreateRealTimeThread(ReplayLoadGameSpecificSave, record) + else + CloseMenuDialogs() + CreateRealTimeThread(function() + LoadingScreenOpen("idLoadingScreen", "ReplayGameRecord") + GameReplayWaitMap = true + local game = CloneObject(record.game) + ChangeMap("") + NewGame(game) + local map_name = record.map_name + local map_hash = record.map_hash + if map_hash and map_hash ~= table.get(MapData, map_name, "NetHash") then + local matched + for map, data in sorted_pairs(MapData) do + if map_hash == data.NetHash then + matched = map + break + end + end + if not matched then + GameTestsPrint("Replay map has been modified!") + elseif matched ~= map_name then + GameTestsPrint("Replay map changed to", matched) + end + map_name = matched or map_name + end + ChangeMap(map_name) + GameReplayWaitMap = false + Wakeup(GameReplayThread) + LoadingScreenClose("idLoadingScreen", "ReplayGameRecord") + end) + end +end + +function ResaveGameRecord(path) + local _, _, ext = SplitPath(path) + local path = ext ~= "" and path or string.format("%s/%s.lua", records_path, path) + local err, record = LoadGameRecord(path) + if not record then + return err or "No record found!" + end + StopGameReplay() + if record.start_save then + assert(false, "Not implemented!") + else + CloseMenuDialogs() + CreateRealTimeThread(function() + table.change(config, "ResaveGameRecord", { + FixedMapLoadRandom = record.start_rand, + StartGameOnPause = true, + }) + ChangeMap(record.map_name) + table.restore(config, "ResaveGameRecord") + GameReplayPath = path + end) + end +end + +function WaitReplayEnd() + while not WaitMsg("GameReplayEnd", 100) do + if not IsChangingMap() and not IsValidThread(GameReplayThread) then + return + end + end + return true +end + +function StopGameReplay() + local thread = not GameReplaySaveLoading and GameReplayThread + if not IsValidThread(thread) then return end + GameReplayScheduled = false + GameReplayThread = false + Msg("GameReplayEnd") + DeleteThread(thread, true) + return true +end + +function LoadGameRecord(path) + local func, err = loadfile(path, nil, _ENV) + if not func then + return err + end + local success, record = procall(func) + if not success then return record end + return nil, record +end + +function OnMsg.LoadGame() + if not GameReplayToInject then + return + end + StopGameReplay() + if not GameRecord then + print("Replay injection failed: No saved record found (maybe a saved game during a replay?)") + return + end + for _, key in ipairs{"start_rand", "map_name", "os_time", "lua_rev", "assets_rev"} do + if GameRecord[key] ~= GameReplayToInject[key] then + print("Replay injection failed: Wrong game!") + return + end + end + print("Replay Injection Success.") + CreateGameTimeThread(PlayGameRecord, GameReplayToInject, #GameRecord + 1) + GameReplayToInject = false +end + +function ToggleGameReplayInjection(record) + record = record or GameRecord or GameReplay + if record and record == GameReplayToInject then + record = false + print("Replay Injection Cancelled") + elseif record then + print("Replay Injection Ready") + else + print("No record found to inject") + end + GameReplayToInject = record +end + +---- + +function ReplayLoadGameSpecificSave(save, callbackOnload) + print("You must implement your game loading function in ReplayLoadGameSpecificSave to use game replays with saves.") + return true +end + +function ReplayToggleFastForward(set) + if not IsGameReplayRunning() then + set = false + elseif set == nil then + set = not GameReplayFastForward + end + if GameReplayFastForward == set then + return + end + GameReplayFastForward = set + TurboSpeed(set, true) +end + +function OnMsg.GameReplayEnd(record) + if GameReplayFastForward then + ReplayToggleFastForward() + end + -- allow to continue the recording + if record then + record.continue = true + GameRecord = record + end +end + +function OnMsg.GameRecordPlayed(i, record) + if record and GameReplayFastForward then + local events_before_end = config.ReplayFastForwardBeforeEnd or 10 + if i >= #record - events_before_end then + print(events_before_end, "events before the end reached, stopping fast forward...") + ReplayToggleFastForward() + SetTimeFactor(0) + end + end +end + +---- + +if config.DebugReplayDesync then + +if FirstLoad then + GameRecordSyncLog = false + GameRecordSyncTest = false + GameRecordSyncIdx = false + HashLogSize = 32 +end + +function OnMsg.AutorunEnd() + pairs = totally_async_pairs +end + +function OnMsg.ReloadLua() + pairs = g_old_pairs +end + +function OnMsg.NetUpdateHashReasons(enable_reasons) + local record = GameRecord or GameRecordScheduled or GameReplay or GameReplayScheduled + enable_reasons.GameRecord = record and record.net_update_hash and true or nil +end + +function OnMsg.LoadGame() + GameRecordSyncLog = false +end + +local function StartSyncLogSaving(replay) + local err = AsyncCreatePath("AppData/ReplaySyncLogs") + if err then + print("Failed to create NetHashLogs folder:", err) + return + end + GameRecordSyncLog = true + GameRecordSyncTest = replay and ((GameRecordSyncTest or 0) + 1) or false + GameRecordSyncIdx = 1 +end + +function OnMsg.GameRecordScheduled() + StartSyncLogSaving() +end + +function OnMsg.GameReplayStart() + StartSyncLogSaving(true) +end + +function OnMsg.GameReplayEnd(record) + NetSaveHashLog("E", "Replay", GameRecordSyncTest) + GameRecordSyncLog = false +end + +function OnMsg.GameRecordEnd(record) + NetSaveHashLog("E", "Record") + GameRecordSyncLog = false +end + +function OnMsg.SyncEvent() + if not GameRecordSyncIdx then + return + end + if GameRecordSyncTest then + NetSaveHashLog(GameRecordSyncIdx, "Replay", GameRecordSyncTest) + else + NetSaveHashLog(GameRecordSyncIdx, "Record") + end + GameRecordSyncIdx = GameRecordSyncIdx + 1 +end + +function NetSaveHashLog(prefix, logtype, suffix) + if not GameRecordSyncLog then + return + end + local str = pstr("") + NetGetHashLog(str) + if #str == 0 then + return + end + local path = string.format("AppData/ReplaySyncLogs/%s%s%s.log", (prefix and tostring(prefix) .. "_" or ""), logtype, suffix and ("_" .. tostring(suffix)) or "") + CreateRealTimeThread(function(path, str) + local err = AsyncStringToFile(path, str) + if err then + printf("Failed to save %s: %s", path, err) + end + end, path, str) +end + +function OnMsg.BugReportStart(print_func) + local replay = IsGameReplayRunning() + if not replay then return end + print_func("\nGame replay running:", replay.lua_rev, replay.assets_rev) +end + +end -- config.DebugReplayDesync + +if Platform.developer then + +function TestValidator:CollectTestData() + local data = {} + if Platform.developer then + self:GetTestData(data) + end + return data +end + +function TestValidator:rfnTestData(orig_data) + local new_data = self:CollectTestData() + local ignore_missing = true -- avoid breaking existing tests when adding or removing test validation entries + if table.equal_values(orig_data, new_data, -1, ignore_missing) then + return + end + GameTestsError("Test data validation failed for", self.class, + "\n--- Orig data: ", ValueToStr(orig_data), + "\n--- New data: ", ValueToStr(new_data)) +end + +function TestValidator:CreateValidation() + local data = self:CollectTestData() + if next(data) == nil then + print("No test data to validate!") + return + end + NetSyncEvent("ObjFunc", self, "rfnTestData", data) + print("Test data collected:", ValueToStr(data)) +end + +function TestValidator:AsyncCheatValidate() + self:CreateValidation() +end + +function ReplayCreateUpdateScript() + local record = GameReplay or GameRecord + if not record then + print("No replay running!") + return + end + + local lua_rev = record.lua_rev or LuaRevision + local assets_rev = record.assets_rev or AssetsRevision + + local src_path = ConvertToOSPath("svnSrc/") + local assets_path = ConvertToOSPath("svnAssets/") + + local scrip = { + "@echo off", + "cd " .. src_path, + "svn cleanup", + "svn up -r " .. lua_rev, + "cd " .. assets_path, + "svn cleanup", + "svn up -r " .. assets_rev, + } + local path = string.format("%sUpdateToRev_%d_%d.bat", src_path, lua_rev, assets_rev) + local err = AsyncStringToFile(path, table.concat(scrip, "\n")) + if err then + print("Failed to create script:", err) + else + print("Script created at:", path) + end +end + +end -- Platform.developer \ No newline at end of file diff --git a/CommonLua/GameState.lua b/CommonLua/GameState.lua new file mode 100644 index 0000000000000000000000000000000000000000..2f14f13fc78772dc7f1d6081f57dbe36a72b3412 --- /dev/null +++ b/CommonLua/GameState.lua @@ -0,0 +1,142 @@ +if FirstLoad then + GameState = {} +end + +if FirstLoad then +GameStateNotifyThread = false +AutoSetGameStates = false +end + +function RebuildAutoSetGameStates() + AutoSetGameStates = ForEachPreset("GameStateDef", function(state_def, group, states) + if #(state_def.AutoSet or "") > 0 then + states[#states + 1] = state_def.id + end + end, {}) +end + +function ChangeGameState(state_descr, state) + local changed + local GameState = GameState + if type(state_descr) == "table" then + for state_id, state in pairs(state_descr) do + if (GameState[state_id] or false) ~= state then + changed = changed or {} + changed[state_id] = state + GameState[state_id] = state or nil + end + end + elseif (state_descr or "") ~= "" then + state = state or false + if (GameState[state_descr] or false) ~= state then + changed = {[state_descr] = state} + GameState[state_descr] = state or nil + end + end + + if changed then + local GameStateDefs = GameStateDefs + -- AutoSet + for _, state_id in ipairs(AutoSetGameStates) do + local state_def = GameStateDefs[state_id] + if state_def then + local state = EvalConditionList(state_def.AutoSet, state_def) or false + if (GameState[state_id] or false) ~= state then + assert(changed[state_id] == nil) -- an AutoSet state overwriting ChangeGameState call + changed[state_id] = state + GameState[state_id] = state or nil + end + end + end + -- GroupExclusive + local excluded + for state_id, state in pairs(GameState) do + local state_def = GameStateDefs[state_id] + if state and state_def and state_def.GroupExclusive then + local group = state_def.group + for other_id, other_state in sorted_pairs(changed) do + if other_state and other_id ~= state_id then + local other_state_def = GameStateDefs[other_id] + if other_state_def and other_state_def.group == group then + assert(not changed[state_id]) -- Adding two states from the same excludive group, undefined result + changed[state_id] = false + excluded = true + break + end + end + end + end + end + -- GameState was not changed above because we were iterating on it + for state_id, state in pairs(excluded and changed) do + if not state then + GameState[state_id] = nil + end + end + + Msg("GameStateChanged", changed) + GameStateNotifyThread = GameStateNotifyThread or CreateRealTimeThread(function() + Msg("GameStateChangedNotify") + GameStateNotifyThread = false + end) + end + return changed +end + +function WaitGameState(states) + while not MatchGameState(states) do + WaitMsg("GameStateChanged") + end +end + +function MatchGameState(states) + local GameState = GameState + for state, active in pairs(states) do + local game_state_active = GameState[state] or false + if active ~= game_state_active then + return + end + end + + return true +end + +function GetMismatchGameStates(states) + local GameState = GameState + local curr_states, mismatches = {}, {} + + for state, active in pairs(GameState) do + if string.match(state, "^[A-Z]") then + curr_states[#curr_states + 1] = state + end + end + + for state, active in pairs(states) do + local game_state_active = GameState[state] or false + if active ~= game_state_active then + table.insert(mismatches, state) + end + end + + local current = string.format("Current states: %s", table.concat(curr_states, ", ")) + local mismatched = (#mismatches > 0) and string.format("Mismatches: %s", table.concat(mismatches, ", ")) or "No mismatching states" + local result = string.format("Result: %s", not (#mismatches > 0)) + + return string.format("%s\n%s\n%s", result, current, mismatched) +end + +function OnMsg.BugReportStart(print_func) + local states = {} + for state, active in pairs(GameState) do + if active then + if type(active) ~= "boolean" then + state = state .. " (" .. tostring(active) .. ")" + end + states[#states + 1] = state + end + end + if #states > 0 then + table.sort(states) + print_func("GameState:", table.concat(states, ", "), "\n") + end +end diff --git a/CommonLua/GameTests.lua b/CommonLua/GameTests.lua new file mode 100644 index 0000000000000000000000000000000000000000..cd8f4217ae4bbbe28974c0fb3ef1c193f2f85829 --- /dev/null +++ b/CommonLua/GameTests.lua @@ -0,0 +1,928 @@ +function CreateTestPrints(output, tag, timestamp) + tag = tag or "" + local err_tag = "GT_ERROR " .. tag + + GameTestsPrint = CreatePrint { tag, output = output } + GameTestsPrintf = CreatePrint { tag, output = output, format = string.format } + GameTestsError = CreatePrint { err_tag, output = output, timestamp = timestamp } + GameTestsErrorf = CreatePrint { err_tag, output = output, timestamp = timestamp, format = string.format } +end + +if FirstLoad then + GameTestsRunning = false + GameTestsPrint = false + GameTestsPrintf = false + GameTestsError = false + GameTestsErrorf = false + GameTestsErrorsFilename = "svnAssets/Logs/GameTestsErrors.log" + GameTestsFlushErrors = empty_func -- if you want to see the prints from asserts inside the prints some sub-section of your test, call this when the sub-section ends + + CreateTestPrints() +end + +function RunGameTests(time_start_up, game_tests_name, ...) + time_start_up = os.time() - (time_start_up or os.time()) + game_tests_name = game_tests_name or "GameTests" + + CreateRealTimeThread( function(...) + AsyncFileDelete(GameTestsErrorsFilename) + local game_tests_errors_file, error_msg = io.open(GameTestsErrorsFilename, "w+") + if not game_tests_errors_file then + print("Failed to open GameTestsErrors.log:", error_msg) + end + + local function GameTestOutput(s) + ConsolePrintNoLog(s) + if game_tests_errors_file then + game_tests_errors_file:write(s, "\n") + end + end + + CreateTestPrints(GameTestOutput) + GameTestsPrintf("Lua rev: %d, Assets rev: %d", LuaRevision, AssetsRevision) + + LoadBinAssets("") -- saving presets that include ColorizationPropSet requires knowledge about the number of colorization channels for entities + + GameTestsRunning = true + Msg("GameTestsBegin", true) + SetAllDevDlcs(true) + + table.change(config, "GameTests", { + Backtrace = false, + SilentVMEStack = true, + }) + UpdateThreadDebugHook() + + local game_tests_table = _G[game_tests_name] + local tests_to_run = {...} + if #tests_to_run == 0 then + tests_to_run = table.keys2(game_tests_table, "sorted") + end + local log_lines_processed = 0 + + local any_failed + local lua_error_prefix = "[LUA ERROR] " + GameTestsFlushErrors = function() + FlushLogFile() + local err, log_file = AsyncFileToString(GetLogFile(), false, false, "lines") + if not err then + for i = log_lines_processed+1, #log_file do + local line = log_file[i] + if line:starts_with(lua_error_prefix) then + GameTestsErrorf("%s", string.sub(line, #lua_error_prefix+1)) + any_failed = true + elseif line:match("%)%: ASSERT.*failed") then + GameTestsErrorf("once", "%s", line) + any_failed = true + elseif line:match(".*%.lua%(%d*%): ") then + GameTestsErrorf("%s", line) + any_failed = true + elseif line:match("COMPILE!.*fx") then + GameTestsPrint("once", line) + end + end + log_lines_processed = #log_file + else + GameTestsPrint("Failed to load log file from game " .. GetLogFile() .. " : " .. err) + end + if game_tests_errors_file then + game_tests_errors_file:flush() + end + end + + GameTestsFlushErrors() + + local all_tests_start_time = GetPreciseTicks() + for _, test in ipairs(tests_to_run) do + if game_tests_table[test] then + CreateTestPrints(GameTestOutput, test, "gametime") + + GameTestsPrint("Start...") + local time = GetPreciseTicks() + Msg("GameTestBegin", test) + + local success = sprocall(game_tests_table[test], time_start_up, game_tests_name) + if not success then + any_failed = true + end + + Msg("GameTestEnd", test) + GameTestsFlushErrors() + GameTestsPrint(string.format("...end. Duration %i ms. Since start %i sec.", GetPreciseTicks() - time , (GetPreciseTicks() - all_tests_start_time) / 1000)) + else + GameTestsError("GameTest not found:", test) + end + end + + if any_failed then + FlushLogFile() + local err, log_file = AsyncFileToString(GetLogFile(), false, false, "lines") + if not err then + CreateTestPrints(GameTestOutput, "GT_LOG") + GameTestsPrint("Complete log file from run follows:") + GameTestsPrint(string.rep("-", 80)) + for _, line in ipairs(log_file) do + GameTestsPrint(line) + end + end + end + + if game_tests_errors_file then + game_tests_errors_file:close() + end + + GameTestsRunning = false + Msg("GameTestsEnd", true) + + table.restore(config, "GameTests", true) + UpdateThreadDebugHook() + + CreateTestPrints() + + quit() + end, ...) +end + +function DbgRunGameTests(game_tests_table, names) + if not IsRealTimeThread() then + return CreateRealTimeThread(DbgRunGameTests, game_tests_table, names) + end + GameTestsRunning = true + Msg("GameTestsBegin") + local old = LocalStorage.DisableDLC + SetAllDevDlcs(true) + game_tests_table = game_tests_table or GameTests + names = names or table.keys(game_tests_table, true) + local times = {} + local st = GetPreciseTicks() + for _, name in ipairs(names) do + local func = game_tests_table[name] + if not func then + printf("No such test", name) + else + CreateTestPrints(print, name) + Msg("GameTestBegin", name) + print("Testing", name) + CloseMenuDialogs() + local time = GetPreciseTicks() + sprocall(func) + time = GetPreciseTicks() - time + Msg("GameTestEnd", name) + printf("Done testing %s in %d ms", name, time) + times[name] = time + end + end + if #names > 1 then + printf("Done testing all in %d ms", GetPreciseTicks() - st) + for _, name in ipairs(names) do + printf("\t%s: %d ms", name, times[name]) + end + print() + end + LocalStorage.DisableDLC = old + SaveLocalStorage() + CreateTestPrints() + GameTestsRunning = false + Msg("GameTestsEnd") +end + +function DbgRunGameTest(name, game_tests_table) + return DbgRunGameTests(game_tests_table, {name}) +end + +GameTests = {} +GameTestsNightly = {} + +-- these are defined per project +g_UIAutoTestButtonsMap = false +g_UIGameChangeMap = ChangeMap +g_UIGetContentTop = function() return GetInGameInterface() end +g_UIGetBuildingsList = false +g_UISpecialToggleButton = {match = false} -- match is function checking button properties to recognize special ones +g_UIBlacklistButton = {match = false} -- match is function checking button properties to recognize black listed +g_UIPrepareTest = false -- funtion to call on UI test start, e.g. cheat for research all + +local function IsSpecialToggleButton(button, id) + if g_UISpecialToggleButton[id] then return true end + local match = g_UISpecialToggleButton.match + return match and match(button) +end + +local function IsBlacklistedButton(button, id) + local id = rawget(button, "Id") + if id and g_UIBlacklistButton[id] then + return true + end + local match = g_UIBlacklistButton.match + return match and match(button) +end + +local function GetContentSnapshot(content) + content = content or g_UIGetContentTop() + + local snapshot, used = {}, {} + for idx, window in ipairs(content) do + if not used[window] then + used[window] = true + snapshot[idx] = GetContentSnapshot(window) + end + end + + return snapshot, used +end + +local function DetectNewWindows(snapshot, used) + local new_snapshot, new_used = GetContentSnapshot() + local windows = setmetatable({}, weak_keys_meta) + for window in pairs(new_used) do + if not used[window] then + table.insert(windows, window) + end + end + + return windows +end + +local function GetButtons(windows, buttons) + buttons = buttons or {} + + for _, control in ipairs(windows) do + if control:IsKindOf("XButton") then + if not IsBlacklistedButton(control) then + table.insert(buttons, control) + end + else + GetButtons(control, buttons) + end + end + + return buttons +end + +local function FilterWindowsWithButtons(windows) + local windows_with_buttons = {} + for _, window in ipairs(windows) do + local buttons = GetButtons(window) + if #buttons > 0 then + table.insert(windows_with_buttons, {window = window, buttons = buttons}) + end + end + + return windows_with_buttons +end + +local function GetSelectObjContainer(obj) + local snapshot, used = GetContentSnapshot() + SelectObj(obj) + WaitMsg("SelectionChange", 1000) + local windows = DetectNewWindows(snapshot, used) + local windows_with_buttons = FilterWindowsWithButtons(windows) + assert(#windows_with_buttons <= 1) + + return #windows_with_buttons == 1 and windows_with_buttons[1] +end + +local function GetButtonPressContainer(button) + local snapshot, used = GetContentSnapshot() + button:Press() + local windows = DetectNewWindows(snapshot, used) + local windows_with_buttons = FilterWindowsWithButtons(windows) + assert(#windows_with_buttons <= 1) + + return #windows_with_buttons == 1 and windows_with_buttons[1] +end + +local function GetButtonId(button, idx) + return button.Id or string.format("idChild_%d", idx) +end + +function FindButton(container, id) + for _, control in ipairs(container) do + if control:IsKindOf("XButton") then + if GetButtonId(control) == id then + return control + end + else + local button = FindButton(control, id) + if button then + return button + end + end + end +end + +local function ExpandGraph(node, buttons) + node.children = node.children or {} + for idx, button in ipairs(buttons) do + local id = GetButtonId(button, idx) + table.insert(node.children, {processed = {}, children = {}, parent = node, id = id, expanded = false}) + end + node.expanded = true +end + +local function MarkNodeProcessed(node) + node.parent.processed[node.id] = true +end + +local function GenNodePath(node, nodes) + for idx, child in ipairs(node.children) do + if not node.processed[child.id] then + table.insert(nodes, child) + if child.expanded then + local old_len = #buttons + GenButtonSequence(child, nodes) + if #nodes > old_len then + return + end + else + return + end + table.remove(nodes) + node.processed[child.id] = true + end + end +end + +local function FindButtonSequence(root) + local nodes = {} + GenNodePath(root, nodes) + if #nodes > 0 then + local node = nodes[#nodes] + local buttons = {} + for i = 1, #nodes - 1 do + buttons[i] = nodes[i].id + end + + return buttons, node + end +end + +function GetSingleBuildingClassList(list) + local buildings, class_taken = {}, {} + for _, bld in ipairs(list) do + if not class_taken[bld.class] then + table.insert(buildings, bld) + class_taken[bld.class] = true + end + end + + return buildings +end + +function GameTests.BuildingButtons() + if not g_UIAutoTestButtonsMap then return end + + local time_started = GetPreciseTicks() + + if GetMapName() ~= g_UIAutoTestButtonsMap then g_UIGameChangeMap(g_UIAutoTestButtonsMap) end + local list, content + while not (list and content) do + list = g_UIGetBuildingsList() + content = g_UIGetContentTop() + Sleep(50) + end + if g_UIPrepareTest then + g_UIPrepareTest() + end + + --print(string.format("Testing UI buttons for %d buildings", #list)) + local clicks = 0 + SelectObj(false) + for bld_idx, bld in ipairs(list) do + local container = IsValid(bld) and GetSelectObjContainer(bld) + if container then + local root = {processed = {}, children = {}, expanded = false} + ExpandGraph(root, container.buttons) + local buttons, node = FindButtonSequence(root) + while container and buttons do + for _, button in ipairs(buttons) do + -- TODO: keep changing container here + button:Press() + clicks = clicks + 1 + end + local button = FindButton(container.window, node.id) + if button and button:GetVisible() and button:GetEnabled() and not IsBlacklistedButton(button) then + --print(string.format("Pressing %s:%s", bld.class, node.id)) + local new_container = GetButtonPressContainer(button) + if new_container then + ExpandGraph(node, new_container.buttons) + end + if IsSpecialToggleButton(button, node.id) then + --print(string.format("Toggling off %s:%s", bld.class, node.id)) + button:Press() -- toggle it + clicks = clicks + 1 + end + end + MarkNodeProcessed(node) + SelectObj(false) + container = GetSelectObjContainer(bld) + -- TODO: detect graph cycles + buttons, node = FindButtonSequence(root) + end + end + SelectObj(false) + end + GameTestsPrintf("Testing %d building for %d UI buttons clicks finished: %ds.", #list, clicks, (GetPreciseTicks() - time_started) / 1000) +end + +function GameTestAddReferenceValue(type, name, value, comment, tolerance_mul, tolerance_div) + if not type then return end + local results_file = "AppData/Benchmarks/GameTestReferenceValues.lua" + local _, str_result = AsyncFileToString(results_file) + local _, referenceValues = LuaCodeToTuple(str_result) + + referenceValues = referenceValues or {} + + local avg_previous, avg_items = 0, 0 + + local maxResults = 5 -- how many results should be stored per test + + referenceValues[type] = referenceValues[type] or {} + local benchmark_results = table.copy(referenceValues[type]) + + benchmark_results[name] = benchmark_results[name] or {} + + for oldInd, oldCamera in pairs(benchmark_results[name]) do + if oldCamera.comment == comment then + avg_previous = avg_previous + oldCamera.value + avg_items = avg_items + 1 + else + table.remove(benchmark_results[name], oldInd) + GameTestsPrintf("Old %s not matching, deleting results for %s data!", name, type) + end + end + table.insert(benchmark_results[name], {comment = comment, value = value}) + referenceValues[type] = benchmark_results + while true do + if #benchmark_results[name] > maxResults then + table.remove(benchmark_results[name], 1) + else + break + end + end + + if avg_items == 0 then + GameTestsPrintf("No previous results to compare to for %s: %s. New results saved.",type, name) + else + avg_previous = avg_previous/avg_items + end + + if avg_previous ~= 0 then + if abs( 100.0 - ( ( (value*1.0)* 100.0) / (avg_previous*1.0) ) ) <= (tolerance_mul*1.0)/(tolerance_div*1.0) * 100.0 then + GameTestsPrintf("Reference value %s: %s is %s, avg of previous is %s", type, name, value, avg_previous) + else + GameTestsErrorf("Reference value %s: %s is %s, avg of previous is %s", type, name, value, avg_previous) + GameTestsPrintf("Camera properties: "..tostring(comment)) + end + end + + AsyncCreatePath("AppData/Benchmarks") + local err = AsyncStringToFile(results_file, ValueToLuaCode(referenceValues)) + if err then + GameTestsError("Failed to create file with reference values", results_file, err) + end +end + +function GameTestsNightly.ReferenceImages() + -- change map and video mode for consistency in tests + if not config.RenderingTestsMap then + GameTestsPrint("config.RenderingTestsMap map not specified, skipping the test.") + return + end + if not MapData[config.RenderingTestsMap] then + GameTestsError(config.RenderingTestsMap, "map not found, could not complete test.") + return + end + ChangeMap(config.RenderingTestsMap) + SetMouseDeltaMode(true) + + ChangeVideoMode(512, 512, 0, false, false) + SetLightmodel(0, LightmodelPresets.ArtPreview, 0) + WaitNextFrame(10) + + local allowedDifference = 80 -- the lower the value, the more different the images are allowed to be + -- max is (inf), if images are identical, (0) means images have absolutely nothing in common + -- usually, when two images are quite simmilar, results vary from 80 to 100+ + + local cameras = Presets.Camera["reference"] + if not cameras or #cameras == 0 then + GameTestsPrint("No recorded 'reference' Cameras, could not complete test.") + return + end + + local ostime = os.time() + local results = {} + for i, cam in ipairs(cameras) do + local logs_gt_src = "svnAssets/Logs/"..cam.id..".png" + local logs_ref_src = "svnAssets/Logs/"..cam.id.."_"..ostime.."_reference.png" + local logs_diff_src = "svnAssets/Logs/"..cam.id.."_"..ostime.."_diffResult.png" + + cam:ApplyProperties() + cam:beginFunc() + camera.Lock() + Sleep(3500) + + AsyncCreatePath("svnAssets/Logs") + local ref_img_path = "svnAssets/Tests/ReferenceImages/" + local name = ref_img_path .. cam.id .. ".png" + local err = AsyncCopyFile(name, logs_gt_src, "raw") + if err then + err = AsyncExec(string.format("svn update %s --set-depth infinity", ConvertToOSPath(ref_img_path)), true, true) + if err then + GameTestsErrorf("Reference images folder '%s' could not be updated. Reason: %s!", ConvertToOSPath(ref_img_path), err) + return + end + err = AsyncExec(string.format("svn update %s --depth infinity", ConvertToOSPath(ref_img_path)), true, true) + if err then + GameTestsErrorf("Reference images folder '%s' could not be updated. Reason: %s!", ConvertToOSPath(ref_img_path), err) + return + end + err = AsyncCopyFile(name, logs_gt_src, "raw") + if err then + GameTestsErrorf("Reference images could not be copied from Tests folder for '%s' --> '%s'. Reason: %s. Try increasing SVN update depth manually!", ConvertToOSPath(name), ConvertToOSPath(logs_gt_src), err) + return + end + end + + AsyncFileDelete(logs_ref_src) + WriteScreenshot(logs_ref_src, 512, 512) + Sleep(300) + + local err, img_err = CompareImages( logs_gt_src, logs_ref_src, logs_diff_src, 4) + if img_err then if img_err < allowedDifference then + GameTestsErrorf("Image taken from "..cam.id.." is too different from reference image!") + end end + cam:endFunc() + WaitNextFrame(1) + table.insert(results, {id = cam.id, img_err = img_err}) + end + + local newHTMLTable = {"", + "", + " Image report for Reference Cameras ", + " ", + "", + "", + "", + " ", + ""}) + + for i,img in ipairs(results) do + local str_for_color = " style=\"background-color:"..(img.img_err < allowedDifference and "#f76e59;\"" or "#92ed78;\"") + local img_diff = string.format('"%s_%s_diffResult.png"',tostring(img.id), tostring(ostime)) + table.iappend(newHTMLTable,{ + "", + "", + ""}) + end + table.insert(newHTMLTable,"") + + AsyncCreatePath("svnAssets/Logs") + local report_name = os.date("%Y-%m-%d_%H-%M-%S", os.time()) + local err = AsyncStringToFile("svnAssets/Logs/reference_images_"..report_name..".html", table.concat(newHTMLTable)) + + GameTestsPrint("RULE(reference_images_" .. report_name .. ")") + + --table.restore(hr, "reference_screenshot") + ChangeVideoMode(1680, 940, 0, false, false) + SetMouseDeltaMode(false) + camera.Unlock() +end + +function GameTestsNightly.RenderingBenchmark() + if not config.RenderingTestsMap then + GameTestsPrint("config.RenderingTestsMap map not specified, skipping the test.") + return + end + if not MapData[config.RenderingTestsMap] then + GameTestsError(config.RenderingTestsMap, "map not found, could not complete test.") + return + end + ChangeMap(config.RenderingTestsMap) + ChangeVideoMode(1920, 1080, 0, false, false) + WaitNextFrame(5) + + local num_shaders = GetNumShaders() + GameTestAddReferenceValue("TotalNumberOfShaders", 0, num_shaders, "", 20, 100) + + local cameras = Presets.Camera["benchmark"] + if not cameras or #cameras == 0 then + GameTestsPrint("No recorded 'benchmark' Cameras, could not complete test.") + return + end + + local results = {} + table.change(hr, "rendering_benchmark", { RenderStatsSmoothing = 30 }) + for i, cam in pairs(cameras) do + cam:ApplyProperties() + Sleep(3000) + + local gpu_time = hr.RenderStatsFrameTimeGPU + local cpu_time = hr.RenderStatsFrameTimeCPU + local result = { + time = os.time(), + id = cam.id, + gpu_time = gpu_time, + cpu_time = cpu_time + } + table.insert (results, result) + end + table.restore(hr, "rendering_benchmark") + + for _, cameraResult in ipairs(results) do + GameTestAddReferenceValue("RenderingBenchmarkCPU", cameraResult.id, cameraResult.cpu_time, "", 50, 1000) + GameTestAddReferenceValue("RenderingBenchmarkGPU", cameraResult.id, cameraResult.gpu_time, "", 50, 1000) + end +end + +function TestNonInferedShaders(time, seed, verbose) + if not config.RenderingTestsMap then + GameTestsPrint("config.RenderingTestsMap not specified, skipping the test.") + return + end + if not MapData[config.RenderingTestsMap] then + GameTestsError(config.RenderingTestsMap, "map not found, could not complete test.") + return + end + ChangeMap(config.RenderingTestsMap) + WaitNextFrame(5) + + time = time or 5 * 60 * 1000 -- 5 min + seed = seed or AsyncRand() + GameTestsPrintf("TestNonInferedShaders: time %d, seed %d", time, seed) + + local options = {} + for option, descr in pairs(OptionsData.Options) do + if descr[1] and descr[1].hr then + options[#options + 1] = descr + end + end + + local real_time_start = RealTime() + local precise_time_start = GetPreciseTicks() + local test = 0 + local rand = BraidRandomCreate(seed) + local orig_hr = {} + while RealTime() - real_time_start < time do + test = test + 1 + GameTestsPrintf("Changing hr. options test #%d", test) + local change_time = RealTime() + local changed + while not changed do + for _, option_set in ipairs(options) do + local entry = table.rand(option_set, rand()) + for hr_key, hr_param in sorted_pairs(entry.hr) do + if hr[hr_key] ~= hr_param then + if verbose then + GameTestsPrintf(" hr['%s'] = %s -- was %s", hr_key, hr_param, hr[hr_key]) + end + orig_hr[hr_key] = orig_hr[hr_key] or hr[hr_key] + hr[hr_key] = hr_param + changed = true + end + end + end + end + WaitNextFrame(3) + if verbose then + GameTestsPrintf("done for %dms.", RealTime() - change_time) + end + end + + GameTestsPrintf("Restoring initial hr...") + for hr_key, hr_param in sorted_pairs(orig_hr) do + if verbose then + GameTestsPrintf(" hr['%s'] = %s", hr_key, hr_param) + end + hr[hr_key] = hr_param + end + + if verbose then + GameTestsPrintf("Changing hr. options for %d mins finished.", time / (60 * 1000)) + end +end + +function GameTestsNightly.NonInferedShaders() + TestNonInferedShaders() +end + +function GameTests.TestDoesMapSavingGenerateFakeDeltas() + if not config.AutoTestSaveMap then return end + ChangeMap(config.AutoTestSaveMap) + if GetMapName() ~= config.AutoTestSaveMap then + GameTestsError("Failed to change map to " .. config.AutoTestSaveMap .. "! ") + return + end + + local p = "svnAssets/Source/Maps/" .. config.AutoTestSaveMap .. "/objects.lua" + + if not IsEditorActive() then + EditorActivate() + end + SaveMap("no backup") + EditorDeactivate() + + local _, str = SVNDiff(p) + local diff = {} + for s in str:gmatch("[^\r\n]+") do + diff[#diff+1] = s + if #diff == 20 then break end + end + if #diff > 0 then + GameTestsError("Resaving " .. config.AutoTestSaveMap .. " produced differences!") + GameTestsPrint(table.concat(diff, "\n")) + end +end + +-- call this at the beginning of each game test which requires to happen on a map, with loaded BinAssets +function GameTests_LoadAnyMap() + if GetMap() ~= "" then return end + if not config.VideoSettingsMap then + GameTestsError("Configure config.GameTestsMap to test presets - some preset validation tests may only run on a map") + return + end + if GetMap() ~= config.VideoSettingsMap then + CloseMenuDialogs() + ChangeMap(config.VideoSettingsMap) + WaitNextFrame() + end +end + +function GameTests.z8_ValidatePresetDataIntegrity() + GameTests_LoadAnyMap() + + local orig_pairs = pairs + pairs = g_old_pairs + ValidatePresetDataIntegrity("validate_all", "game_tests", "verbose") + pairs = orig_pairs +end + +function GameTests.InGameEditors() + if not config.EditorsToTest then return end + + PauseInfiniteLoopDetection("GameTests.InGameEditors") + + local time_started = GetPreciseTicks() + local project = GetAppName() + local function Test(editor_class) + local waiting = CurrentThread() + local worker = CreateRealTimeThread(function() + local ged = OpenPresetEditor(editor_class) + if ged then + local err = ged:Send("rfnApp", "SaveAll", true) + if err then + GameTestsErrorf("%s:%s In-Game editor SaveAll(true) failed: %s", project, editor_class, tostring(err)) + end + err = ged:Send("rfnClose") + else + GameTestsErrorf("%s:%s In-Game editor opening failed", project, editor_class) + end + Wakeup(waiting) + end) + if not WaitWakeup(10000) then + GameTestsErrorf("%s:%s In-Game editor test timeout", project, editor_class) + DeleteThread(worker) + end + end + if not config.EditorsToTestThrottle then + parallel_foreach(config.EditorsToTest, Test, nil, 8) + else + for _, editor_class in ipairs(config.EditorsToTest) do + Test(editor_class) + Sleep(config.EditorsToTestThrottle) + end + end + GameTestsPrintf("%s In-Game editors tests finished: %ds.", project, (GetPreciseTicks() - time_started) / 1000) + + ResumeInfiniteLoopDetection("GameTests.InGameEditors") +end + +function ChangeVideoSettings_ViewPositions() end +function GameTests.ChangeVideoSettings() + if not config.VideoSettingsMap then return end + local presets = {"Low", "Medium", "High", "Ultra"} + if GetMap() ~= config.VideoSettingsMap then -- speed up the test by skipping map change if already on the test map + CloseMenuDialogs() + ChangeMap(config.VideoSettingsMap) + WaitNextFrame() + end + local orig = OptionsCreateAndLoad() + for _, p in ipairs(presets) do + GameTestsPrint("Video preset", p) + ApplyVideoPreset(p) + WaitNextFrame() + ChangeVideoSettings_ViewPositions() + end + if orig then + GameTestsPrint("Returning to the original preset", orig.VideoPreset) + ApplyOptionsObj(orig) + WaitNextFrame() + end +end + +function GameTests.EntityStatesMissingAnimations() + if not g_AllEntities then + GameTests_LoadAnyMap() + end + for entity_name in sorted_pairs(g_AllEntities) do + local entity_spec = GetEntitySpec(entity_name, "expect_missing") + if entity_spec then + local entity_states = GetStates(entity_name) + local state_specs = entity_spec:GetSpecSubitems("StateSpec", not "inherit") + for _, state_name in pairs(entity_states) do + local state_spec = state_specs[state_name] + if state_spec and state_name:sub(1, 1) ~= "_" then + local mesh_spec = entity_spec:GetMeshSpec(state_spec.mesh) + local anim_name = GetEntityAnimName(entity_name, state_spec.name) + if mesh_spec.animated and (not anim_name or anim_name == "") then + GameTestsPrintf("State %s/%s is animated but has no exported animation!", entity_name, state_spec.name) + end + end + end + end + end +end + +function GameTests.EntityBillboards() + GetBillboardEntities(GameTestsErrorf) +end + +function GameTests.ValidateSounds() + GenerateSoundMetadata("svnAssets/tmp/sndmeta-autotest.dat") +end + +function CheckEntitySpots(entity) + local meshes = {} + for k, state in pairs( EnumValidStates(entity) ) do + local mesh = GetStateMeshFile(entity, state) + if mesh and not meshes[mesh] then + meshes[mesh] = state + end + end + for mesh, state in sorted_pairs(meshes) do + local spbeg, spend = GetAllSpots(entity, state) + local pos_map, pos_spots = {}, {} + local pos_list = { GetEntitySpotPos(entity, state, 0, spbeg, spend) } + for idx=spbeg, spend do + local pos = pos_list[idx - spbeg + 1] + local pos_hash = point_pack(pos) + local spot_name = GetSpotName(entity, idx) + local annotation = GetSpotAnnotation(entity, idx) or "" + if annotation ~= "" then + spot_name = spot_name .. " [" .. annotation .. "]" + end + local spot_names = pos_spots[pos_hash] or {} + pos_spots[pos_hash] = spot_names + if pos_map[pos_hash] and spot_names[spot_name] then + table.insert(spot_names[spot_name], idx) + else + pos_map[pos_hash] = pos + spot_names[spot_name] = {idx} + end + end + for pos_hash, spot_names in sorted_pairs(pos_spots) do + local pos = pos_map[pos_hash] + for spot_name, spot_index_list in sorted_pairs(spot_names) do + if #spot_index_list > 1 then + GameTestsErrorf("%d duplicated spots %s.%s (%s) %s: %s", #spot_index_list, entity, spot_name, mesh, tostring(pos), table.concat(spot_index_list, ",")) + end + end + end + end +end + +function GameTests.CheckSpots() + if not g_AllEntities then + GameTests_LoadAnyMap() + end + PauseInfiniteLoopDetection("CheckSpots") + for entity in sorted_pairs(g_AllEntities) do + CheckEntitySpots(entity) + end + ResumeInfiniteLoopDetection("CheckSpots") +end + +function GameTests.z9_ResaveAllPresetsTest() + ResetInteractionRand() + ResaveAllPresetsTest("game_tests") +end diff --git a/CommonLua/Gameplay.lua b/CommonLua/Gameplay.lua new file mode 100644 index 0000000000000000000000000000000000000000..08c374b986a70d01a601b8122441ff7d2a442ebd --- /dev/null +++ b/CommonLua/Gameplay.lua @@ -0,0 +1,273 @@ +if FirstLoad then + Game = false +end +PersistableGlobals.Game = true + +if not Platform.ged then +DefineClass.GameClass = { + __parents = { "CooldownObj", "GameSettings", "LabelContainer" }, +} +end + +function NewGame(game) + DoneGame() + if not IsKindOf(game, "GameClass") then + game = GameClass:new(game) + end + game.save_id = nil + Game = game + InitGameVars() + Msg("NewGame", game) + NetGossip("NewGame", game.id, GetGameSettingsTable(game)) + return game +end + +function DoneGame() + local game = Game + if not game then + return + end + NetGossip("DoneGame", GameTime(), game.id) + DoneGameVars() + Game = false + Msg("DoneGame", game) + game:delete() +end + +function DevReloadMap() + ReloadMap(true) +end + +function RestartGame() + CreateRealTimeThread(function() + LoadingScreenOpen("idLoadingScreen", "RestartGame") + local map = GetOrigMapName() + local game2 = CloneObject(Game) + ChangeMap("") + NewGame(game2) + ChangeMap(map) + LoadingScreenClose("idLoadingScreen", "RestartGame") + end) +end + +function RestartGameFromMenu(host, parent) + CreateRealTimeThread(function(host, parent) + if WaitQuestion(parent or host, T(354536203098, ""), T(1000852, "Are you sure you want to restart the map? Any unsaved progress will be lost."), T(147627288183, "Yes"), T(1139, "No")) == "ok" then + LoadingScreenOpen("idLoadingScreen", "RestartMap") + if host.window_state ~= "destroying" then + host:Close() + end + RestartGame() + LoadingScreenClose("idLoadingScreen", "RestartMap") + end + end, host, parent) +end + +function OnMsg.ChangeMap(map, mapdata) + ChangeGameState("gameplay", false) +end + +function GetDefaultGameParams() +end + +function OnMsg.PreNewMap(map, mapdata) + if map ~= "" and not Game and mapdata.GameLogic and mapdata.MapType ~= "system" then + NewGame(GetDefaultGameParams()) + end +end + +function OnMsg.ChangeMapDone(map) + if map ~= "" and mapdata.GameLogic then + ChangeGameState("gameplay", true) + end +end + +function OnMsg.LoadGame() + assert( GetMap() ~= "" ) + ChangeGameState("gameplay", true) + if not Game then return end + Game.loaded_from_id = Game.save_id + NetGossip("LoadGame", Game.id, Game.loaded_from_id, GetGameSettingsTable(Game)) +end + +function OnMsg.SaveGameStart() + if not Game then return end + Game.save_id = random_encode64(48) + NetGossip("SaveGame", GameTime(), Game.id, Game.save_id) +end + +function GetGameSettingsTable(game) + local settings = {} + assert(IsKindOf(game, "GameSettings")) + for _, prop_meta in ipairs(GameSettings:GetProperties()) do + settings[prop_meta.id] = game:GetProperty(prop_meta.id) + end + return settings +end + +function OnMsg.NewMap() + NetGossip("map", GetMapName(), MapLoadRandom) +end + +function OnMsg.ChangeMap(map) + if map == "" then + NetGossip("map", "") + end +end + +function OnMsg.NetConnect() + if Game then + NetGossip("GameInProgress", GameTime(), Game.id, Game.loaded_from_id, GetMapName(), MapLoadRandom, GetGameSettingsTable(Game)) + end +end + +function OnMsg.BugReportStart(print_func) + if Game then + print_func("\nGameSettings:", TableToLuaCode(GetGameSettingsTable(Game), " "), "\n") + end +end + + +-- GameVars (persistable, reset on new game) + +GameVars = {} +GameVarValues = {} + +function GameVar(name, value, meta) + if type(value) == "table" then + local org_value = value + value = function() + local v = table.copy(org_value, false) + setmetatable(v, getmetatable(org_value) or meta) + return v + end + end + if FirstLoad or rawget(_G, name) == nil then + rawset(_G, name, false) + end + GameVars[#GameVars + 1] = name + GameVarValues[name] = value or false + PersistableGlobals[name] = true +end + +function InitGameVars() + for _, name in ipairs(GameVars) do + local value = GameVarValues[name] + if type(value) == "function" then + value = value() + end + _G[name] = value or false + end +end + +function DoneGameVars() + for _, name in ipairs(GameVars) do + _G[name] = false + end +end + +function OnMsg.PersistPostLoad(data) + -- create missing game vars (unexisting at the time of the save) + for _, name in ipairs(GameVars) do + if data[name] == nil then + local value = GameVarValues[name] + if type(value) == "function" then + value = value() + end + _G[name] = value or false + end + end +end + +function GetCurrentGameVarValues() + local gvars = {} + for _, name in ipairs(GameVars) do + gvars[name] = _G[name] + end + return gvars +end + +function GetPersistableGameVarValues() + local gvars = {} + for _, name in ipairs(GameVars) do + if PersistableGlobals[name] then + gvars[name] = _G[name] + end + end + return gvars +end + +---- + +GameVar("LastPlaytime", 0) +if FirstLoad then + PlaytimeCheckpoint = false +end +function OnMsg.SaveGameStart() + LastPlaytime = GetCurrentPlaytime() + PlaytimeCheckpoint = GetPreciseTicks() +end +function OnMsg.LoadGame() + PlaytimeCheckpoint = GetPreciseTicks() +end +function OnMsg.NewGame() + PlaytimeCheckpoint = GetPreciseTicks() -- also called on LoadGame +end +function OnMsg.DoneGame() + PlaytimeCheckpoint = false +end +function GetCurrentPlaytime() + return PlaytimeCheckpoint and (LastPlaytime + (GetPreciseTicks() - PlaytimeCheckpoint)) or 0 +end +function FormatElapsedTime(time, format) + format = format or "dhms" + local sec = 1000 + local min = 60 * sec + local hour = 60 * min + local day = 24 * hour + + local res = {} + if format:find_lower("d") then + res[#res + 1] = time / day + time = time % day + end + if format:find_lower("h") then + res[#res + 1] = time / hour + time = time % hour + end + if format:find_lower("m") then + res[#res + 1] = time / min + time = time % min + end + if format:find_lower("s") then + res[#res + 1] = time / sec + time = time % sec + end + res[#res + 1] = time + + return table.unpack(res) +end + +if Platform.asserts then + +function OnMsg.NewMapLoaded() + if not Game then return end + local last_game = LocalStorage.last_game + local count = 0 + for _, prop_meta in ipairs(GameSettings:GetProperties()) do + if prop_meta.remember_as_last then + local value = Game[prop_meta.id] + last_game = last_game or {} + if value ~= last_game[prop_meta.id] then + last_game[prop_meta.id] = value + count = count + 1 + end + end + end + if count == 0 then return end + LocalStorage.last_game = last_game + SaveLocalStorageDelayed() +end + +end -- Platform.asserts + diff --git a/CommonLua/Ged.lua b/CommonLua/Ged.lua new file mode 100644 index 0000000000000000000000000000000000000000..15b6c70c043846d9cff87a4f97731f955a9d2844 --- /dev/null +++ b/CommonLua/Ged.lua @@ -0,0 +1,2978 @@ +----- GedFilter + +DefineClass.GedFilter = { + __parents = { "InitDone" }, + properties = {}, + + ged = false, + target_name = false, + supress_filter_reset = false, + FilterName = Untranslated("Filter"), +} + +function GedFilter:ResetTarget(socket) + if self.target_name and socket then + local obj = socket:ResolveObj(self.target_name) + self.supress_filter_reset = true + ObjModified(obj) + self.supress_filter_reset = false + end +end + +function GedFilter:OnEditorSetProperty(prop_id, old_value, ged) + self:ResetTarget(ged) +end + +function GedFilter:TryReset(ged) + if self.supress_filter_reset then return false end + for _, prop in ipairs(self:GetProperties()) do + self:SetProperty(prop.id, self:GetDefaultPropertyValue(prop.id, prop)) + end + GedForceUpdateObject(self) + ObjModified(self) + return true +end + +function GedFilter:FilterObject(object) + return true +end + +function GedFilter:PrepareForFiltering() +end + +function GedFilter:DoneFiltering(displayed_count, filtered --[[ passed for GedListPanel filters only ]]) +end + +if FirstLoad then + GedConnections = setmetatable({}, weak_values_meta) + GedObjects = {} -- global mapping object -> { name1, socket1, name2, socket1, name3, socket2, ... } + GedTablePropsCache = {} -- caches properties of the bound objects; this prevents issues when editing nested_obj/list props that work via Get/Set functions + g_gedListener = false + + GedTreePanelCollapsedNodes = setmetatable({}, weak_keys_meta) +end + +config.GedPort = config.GedPort or 44000 + +function ListenForGed(search_for_port) + StopListenForGed() + g_gedListener = BaseSocket:new{ + socket_type = "GedGameSocket", + } + local port_start = config.GedPort or 44000 + local port_end = port_start + (search_for_port and 100 or 1) + for port = port_start, port_end do + local err = g_gedListener:Listen("*", port) + if not err then + g_gedListener.port = port + return true + elseif err == "address in use" then + print("ListenForGed: Address in use. Trying with another port...") + else + return false + end + end + return false +end + +function StopListenForGed() + if g_gedListener then + g_gedListener:delete() + g_gedListener = false + end +end + +if config.GedLanguageEnglish then + if FirstLoad or ReloadForDlc then + TranslationTableEnglish = false -- for the Mod Editor on PC + end + + function GedTranslate(T, context_obj, check) + local old_table = TranslationTable + TranslationTable = TranslationTableEnglish + local ret = _InternalTranslate(T, context_obj, check) + TranslationTable = old_table + return ret + end +else + GedTranslate = _InternalTranslate +end + +function OpenGed(id, in_game) + if not g_gedListener then + ListenForGed(true) + end + if config.GedLanguageEnglish and not TranslationTableEnglish then + if GetLanguage() == "English" then + TranslationTableEnglish = TranslationTable + else + TranslationTableEnglish = {} + LoadTranslationTablesFolder("EnglishLanguage/CurrentLanguage/", "English", TranslationTableEnglish) + end + end + local port = g_gedListener.port + if not port then + print("Could not start the ged listener") + return + end + id = id or AsyncRand() + if in_game then + assert(GedSocket, "Ged source files not loaded") + local socket = GedSocket:new() -- if GedSocket is missing the Ged sources are not loaded + local err = socket:WaitConnect(10000, "localhost", port) + if err then + socket:delete() + else + socket:Call("rfnGedId", id) + end + else + local exec_path = GetExecDirectory() .. GetExecName() + local path = string.format('"%s" %s -ged=%s -address=127.0.0.1:%d %s', exec_path, GetIgnoreDebugErrors() and "-no_interactive_asserts" or "", tostring(id), port, config.RunUnpacked and "-unpacked" or "") + local start_func + if Platform.linux or Platform.osx then + start_func = function(path) + local exit_code, _, std_error = os.execute(path .. " &") + return exit_code, std_error + end + else + start_func = function(path) + local cmd = string.format('cmd /c start "GED" %s', path) + local err, exit_code, output, err_messsage = AsyncExec(cmd, nil, true) + if err then return false, err end + return exit_code, err_messsage + end + end + local exit_code, std_error = start_func(path) + if exit_code ~= 0 then + print("Could not launch Ged from:", path, "\nExec error:", std_error) + return + end + end + local timeout = 60000 + while timeout do + if GedConnections[id] then + return GedConnections[id] + end + timeout = WaitMsg("GedConnection", timeout) + end +end + +local ged_print = CreatePrint{ + "ged", + format = "printf", + output = DebugPrint, +} + +function OpenGedApp(template, root, context, id, in_game) + assert(root ~= nil) + if not IsRealTimeThread() or not CanYield() then + CreateRealTimeThread(OpenGedApp, template, root, context, id, in_game) + return + end + if in_game == nil then + in_game = (g_Classes[template] or XTemplates[template] and XTemplates[template].save_in ~= "Ged" and XTemplates[template].save_in ~= "GameGed") and true + end + context = context or {} + if context.dark_mode == nil then + context.dark_mode = GetDarkModeSetting() + end + context.color_palette = CurrentColorPalette and CurrentColorPalette:ColorsPlainObj() or false + context.color_picker_scale = rawget(_G, "g_GedApp") and g_GedApp.color_picker_scale or EditorSettings:GetColorPickerScale() + context.ui_scale = rawget(_G, "g_GedApp") and g_GedApp.ui_scale or EditorSettings:GetGedUIScale() + context.max_fps = rawget(_G, "g_GedApp") and g_GedApp.max_fps or hr.MaxFps + context.in_game = in_game + context.game_real_time = RealTime() + context.mantis_project_id = const.MantisProjectID + context.mantis_copy_url_btn = const.MantisCopyUrlButton + context.bug_report_tags = GetBugReportTagsForGed() + local ged = OpenGed(id, in_game) + if not ged then return end + ged:BindObj("root", root) + ged.app_template = template + ged.context = context + ged.in_game = in_game + local err = ged:Call("rfnOpenApp", template, context, id) + if err then + printf("OpenGedApp('%s') error: %s", tostring(template), tostring(err)) + end + Msg("GedOpened", ged.ged_id) + + local preset_class = context and context.PresetClass + ged_print("Opened %s with class %s, id %s", tostring(template), tostring(preset_class), tostring(ged.ged_id)) + return ged +end + +function CloseGedApp(gedsocket, wait) + if GedConnections[gedsocket.ged_id] then + gedsocket:Close() + if wait then + local id + repeat + local _, id = WaitMsg("GedClosing") + until id == gedsocket.ged_id + end + end +end + +function FindGedApp(template, preset_class) + for id, conn in pairs(GedConnections) do + if conn.app_template == template and + (not preset_class or conn.context.PresetClass == preset_class) then + return conn + end + end +end + +function FindAllGedApps(template, preset_class) + local connections = setmetatable({}, weak_values_meta) + for id, conn in pairs(GedConnections) do + if conn.app_template == template and + (not preset_class or conn.context.PresetClass == preset_class) then + table.insert(connections, conn) + end + end + + return connections +end + +function OpenGedAppSingleton(template, root, context, id, in_game) + local app = FindGedApp(template) + if app then + app:Call("rfnApp", "Activate", context) + app:BindObj("root", root) + if app.last_app_state and app.last_app_state.root then + local sel = app.last_app_state.root.selection + if sel and type(sel[1]) == "table" then + app:SetSelection("root", {1}, {1}) -- tree panel + else + app:SetSelection("root", 1, {1}) -- list panel + end + end + app:ResetUndoQueue() + app.last_app_state = false -- the last app state won't make sense for a new root object + return app + end + return OpenGedApp(template, root, context, id, in_game) +end + +function OnMsg.BugReportStart(print_func) + local list = {} + for key, ged in sorted_pairs(GedConnections) do + local preset_class = ged.context and ged.context.PresetClass + list[#list+1] = "\t" .. tostring(ged.app_template) .. " with preset class " .. tostring(preset_class) .. " and id " .. tostring(ged.ged_id) + end + if #list == 0 then + return + end + print_func("Opened GedApps:\n" .. table.concat(list, "\n") .. "\n") +end + + +----- GedGameSocket + +DefineClass.GedGameSocket = { + __parents = { "MessageSocket" }, + msg_size_max = 256*1024*1024, + call_timeout = false, + ged_id = false, + app_template = false, + context = false, + + root_names = false, -- array of the names of root objects; these are always updated when any object is edited + bound_objects = false, -- mapping name -> object + bound_objects_svalue = false, -- mapping name -> cached value (string) + bound_objects_func = false, -- mapping name -> process_function + bound_objects_path = false, -- mapping name -> list of BindObj calls from root to this object + bound_objects_filter = false, -- mapping name -> GedFilter object + prop_bindings = false, + + last_app_state = false, + selected_object = false, + tree_panel_collapsed_nodes = false, + + -- undo/redo support + undo_position = 0, -- idx of the next undo entry that will be executed with Ctrl-Z + undo_queue = false, + redo_thread = false, +} + +function GedGameSocket:Init() + self.root_names = { "root" } + self.bound_objects = {} + self.bound_objects_svalue = {} + self.bound_objects_func = {} + self.bound_objects_path = {} + self.bound_objects_filter = {} + self.prop_bindings = {} + self:ResetUndoQueue() +end + +function GedGameSocket:Done() + Msg("GedClosing", self.ged_id) + ged_print("Closed %s with id %s", tostring(self.app_template), tostring(self.ged_id)) + GedNotify(self.selected_object, "OnEditorSelect", false, self) + Msg("GedOnEditorSelect", self.selected_object, false, self) + for name in pairs(self.bound_objects) do + self:UnbindObj(name, "leave_values") + end + Msg("GedClosed", self) + GedSetUiStatus("ged_multi_select") +end + +function GedGameSocket:Close() + self:Send("rfnGedQuit") +end + +function GedGameSocket:ResetUndoQueue() + self.undo_position = 0 + self.undo_queue = {} +end + +function GedGameSocket:rfnGedId(id) + assert(not GedConnections[id], "Duplicate Ged id " .. tostring(id)) + GedConnections[id] = self + self.ged_id = id + Msg("GedConnection", id) +end + +function GedGameSocket:OnDisconnect(reason) + if GedConnections[self.ged_id] then + self:delete() + GedConnections[self.ged_id] = nil + end +end + +local prop_prefix = "prop:" +function TreeNodeByPath(root, key1, key2, ...) + if key1 == nil or not root then + return root + end + + local key_type = type(key1) + assert(key_type == "number" or key_type == "string") + if key_type == "number" then + local f = root.GedTreeChildren + root = f and f(root) or root + end + + local prop_name = type(key1) == "string" and key1:starts_with(prop_prefix) and key1:sub(#prop_prefix + 1) + if prop_name then + if GedTablePropsCache[root] and GedTablePropsCache[root][prop_name] ~= nil then + root = GedTablePropsCache[root][prop_name] + else + root = root:GetProperty(prop_name) + end + if key2 == nil then + return root, prop_name + end + else + root = rawget(root, key1) + end + return TreeNodeByPath(root, key2, ...) +end + +function GedGameSocket:ResolveObj(name, ...) + if name then + local idx = string.find(name, "|") + if idx then + name = string.sub(name, 1, idx - 1) + end + end + return TreeNodeByPath(self.bound_objects[name or false], ...) +end + +function GedGameSocket:ResolveName(obj) + if not obj then return end + for name, bobj in pairs(self.bound_objects) do + if obj == bobj then + return name + end + end +end + +function GedGameSocket:FindFilter(name) + local filter = self.bound_objects_filter[name] + if filter then return filter end + + local obj_name, view = name:match("(.+)|(.+)") + if obj_name and view then + return self.bound_objects_filter[obj_name] + end + + for filter_name, filter in pairs(self.bound_objects_filter) do + local obj_name, view = filter_name:match("(.+)|(.+)") + if obj_name and view and obj_name == name then + return filter + end + end +end + +function GedGameSocket:ResetFilter(obj_name) + local filter = self:FindFilter(obj_name) + if filter and filter:TryReset(self) then + filter:ResetTarget(self) + end +end + +function GedGameSocket:BindObj(name, obj, func, dont_send) + if not obj then return end + if not func and rawequal(obj, self.bound_objects[name]) then return end + self:UnbindObj(name) + + func = func or function(obj, filter) return tostring(obj) end + local sockets = GedObjects[obj] or {} + GedObjects[obj] = sockets + sockets[#sockets + 1] = name + sockets[#sockets + 1] = self + self.bound_objects[name] = obj + self.bound_objects_func[name] = func + if not dont_send then + local values = func(obj, self:FindFilter(name)) + local vpstr = ValueToLuaCode(values, nil, pstr("", 1024)) + if vpstr ~= self.bound_objects_svalue[name] then + self.bound_objects_svalue[name] = vpstr + self:Send("rfnObjValue", name, vpstr:str(), true) + end + end + Msg("GedBindObj", obj) +end + +function GedGameSocket:UnbindObj(name, leave_values) + local obj = self.bound_objects[name] + if obj then + local sockets = GedObjects[obj] + if sockets then + for i = 1, #sockets - 1, 2 do + if sockets[i] == name and sockets[i + 1] == self then + table.remove(sockets, i) + table.remove(sockets, i) + end + end + if #sockets == 0 then + GedObjects[obj] = nil + end + end + self.prop_bindings[obj] = nil + GedTablePropsCache[obj] = nil + end + if leave_values then return end + self.bound_objects[name] = nil + self.bound_objects_svalue[name] = nil + self.bound_objects_func[name] = nil + self.bound_objects_path[name] = nil +end + +function GedGameSocket:UnbindObjs(name_prefix, leave_values) + for name in pairs(self.bound_objects) do + if string.starts_with(name, name_prefix) then + self:UnbindObj(name, leave_values) + end + end +end + +function GedGameSocket:GetParentOfKind(name, type_name) + -- find object's bind name if we are searching by object + if type(name) == "table" then + for objname, obj in pairs(self.bound_objects) do + if name == obj then + name = objname + break + end + end + if type(name) == "table" then return end + end + + local bind_path = self.bound_objects_path[name:match("(.+)|.+") or name] + if not bind_path then return end + + -- return the last parent that matches + local indexes_flattened = {} + for i = 2, #bind_path do + local subpath = bind_path[i] + if type(subpath) == "table" then + for u = 1, #subpath do + -- a table here represents multiple selection handled by GedMultiSelectAdapter; we can't find bind parents below that level + if type(subpath[u]) == "table" then + goto completed + end + table.insert(indexes_flattened, subpath[u]) + end + else + table.insert(indexes_flattened, subpath) + end + end +::completed:: + + local obj, last_matching = self.bound_objects[bind_path[1]], nil + for _, key in ipairs(indexes_flattened) do + obj = TreeNodeByPath(obj, key) + if IsKindOf(obj, type_name) then + last_matching = obj + end + end + return last_matching +end + +function GedGameSocket:GetParentsList(name) + local all_parents = {} + local bind_path = self.bound_objects_path[name:match("(.+)|.+") or name] + if bind_path then + local obj = self.bound_objects[bind_path[1]] + table.insert(all_parents, obj) + for i = 2, #bind_path - 1 do + table.insert(all_parents, self.bound_objects[bind_path[i].name]) + end + end + return all_parents +end + +function GedGameSocket:OnParentsModified(name) + local all_parents = self:GetParentsList(name) + -- call in reverse order, so a preset would be marked as dirty before the preset tree is refreshed + for i = #all_parents, 1, -1 do + ObjModified(all_parents[i]) + end + assert(next(SuspendObjModifiedReasons)) -- assume that SuspendObjModified is called and will prevent multiple updates of the same object + for _, name in ipairs(self.root_names) do + ObjModified(self.bound_objects[name]) + end +end + +function GedGameSocket:GatherAffectedGameObjects(obj) + local ret = {} + local objs_and_parents = self:GetParentsList(self:ResolveName(obj)) + table.insert(objs_and_parents, obj) + for _, obj in ipairs(objs_and_parents) do + if IsValid(obj) then + table.insert(ret, obj) + elseif IsKindOf(obj, "GedMultiSelectAdapter") then + table.iappend(ret, obj.__objects) + end + end + ret = table.validate(table.get_unique(ret)) + return #ret > 0 and ret +end + +function GedGameSocket:RestoreAppState(undo_entry) + -- 1. Set pending selection in all panels (will be set when panel data arrives) + local app_state = undo_entry and undo_entry.app_state or self.last_app_state + local focused_panel = app_state.focused_panel + for context, state in pairs(app_state) do + local sel = state.selection + if sel then + self:SetSelection(context, sel[1], sel[2], not "notify", "restoring_state", focused_panel == context) + end + end + + -- 2. Rebind each panel to its former object + if undo_entry then + self.bound_objects_path = table.copy(undo_entry.bound_objects_path, "deep") + self.bound_objects_func = table.copy(undo_entry.bound_objects_func) + self:SetLastAppState(app_state) + end + self:RebindAll() +end + +function GedGameSocket:RebindAll() + -- iterate a copy of the keys as the bound_objects changes while iterating + for idx, name in ipairs(table.keys(self.bound_objects)) do + if not name:find("|", 1, true) then + local obj_path = self.bound_objects_path[name] + local obj = self.bound_objects[obj_path and obj_path[1] or name] + if obj then + if obj_path then + for i = 2, #obj_path do + local path = obj_path[i] + local last_entry = #path > 0 and path[#path] + if type(last_entry) == "table" then + obj = TreeNodeByPath(obj, unpack_params(path, 1, #path - 1)) + obj = GedMultiSelectAdapter:new{ __objects = table.map(last_entry, function(idx) return TreeNodeByPath(obj, idx) end) } + else + obj = TreeNodeByPath(obj, unpack_params(path)) + end + end + end + if obj then + local func = self.bound_objects_func[name] + self:UnbindObj(name) + self:UnbindObjs(name .. "|") -- unbind all views, Ged will rebind them + self:BindObj(name, obj, func) + self.bound_objects_path[name] = obj_path -- restore bind path as UnbindObj removes it + elseif not self.bound_objects_filter[name] then + self:UnbindObj(name) + end + end + end + end +end + +function GedGameSocket:rfnBindFilterObj(name, filter_name, filter_class_or_instance) + local filter = filter_class_or_instance + if type(filter) == "string" then + filter = _G[filter]:new() + elseif not filter then + filter = self:ResolveObj(filter_name) + end + assert(IsKindOf(filter, "GedFilter")) + + filter.ged = self + filter.target_name = name + self:BindObj(filter_name, filter) + + self.bound_objects_filter[name] = filter +end + +function GedGameSocket:rfnBindObj(name, obj_address, func_name, ...) + if func_name and not (type(func_name) == "string" and string.starts_with(func_name, "Ged")) then + assert(not "func_name should start with 'Ged'") + return + end + local parent_name, path + if type(obj_address) == "table" then + parent_name, path = obj_address[1], obj_address + table.remove(path, 1) + else + parent_name, path = obj_address, empty_table + end + + local params = pack_params(...) + local obj, prop_id = self:ResolveObj(parent_name, unpack_params(path)) + self:BindObj(name, obj, func_name and function(obj, filter) return _G[func_name](obj, filter, unpack_params(params)) end) + + if next(path) then + local bind_path = self.bound_objects_path[parent_name] + bind_path = bind_path and table.copy(bind_path, "deep") or { parent_name } + path.name = name + table.insert(bind_path, path) + self.bound_objects_path[name] = bind_path + end + if obj and prop_id and not name:find("|", 1, true) then + assert(#path == 1) + self.prop_bindings[obj] = { parent = self.bound_objects[parent_name], prop_id = prop_id } + end +end + +function GedGameSocket:SetLastAppState(app_state) + self.last_app_state = app_state + for key, value in pairs(app_state) do + if type(value) == "table" and value.selection then + self:UpdateObjectsFromPanelSelection(key) + end + end +end + +function GedGameSocket:GetSelectedObjectsParent(panel, selection) + local parent = self:ResolveObj(panel) + if not parent then return end + local path = selection[1] + if type(path) == "table" then + local children_fn = function(obj) return obj.GedTreeChildren and obj.GedTreeChildren(obj) or obj end + for i = 1, #path - 1 do + parent = children_fn(parent)[path[i]] + if not parent then return end + end + parent = children_fn(parent) + end + return parent +end + +function GedGameSocket:UpdateObjectsFromPanelSelection(panel) + local state = self.last_app_state[panel] + local selection = state.selection + if type(selection[2]) == "table" and next(selection[2]) then + local objects = {} + local parent = self:GetSelectedObjectsParent(panel, selection) + if not parent then return end + for i, idx in ipairs(selection[2]) do + objects[i] = parent[idx] + end + state.selected_objects = objects + else + state.selected_objects = nil + end +end + +function GedGameSocket:UpdatePanelSelectionFromObjects(panel) + if not self.last_app_state then return end + + panel = panel:match("(.+)|.+") + local state = self.last_app_state[panel] + local selection = state.selection + local objects = state.selected_objects + if selection and type(objects) == "table" then + local objects_idxs = {} + local parent = self:GetSelectedObjectsParent(panel, selection) + if not parent then return end + for _, obj in ipairs(objects) do + objects_idxs[#objects_idxs + 1] = table.find(parent, obj) or nil + end + if #objects_idxs ~= #objects then + -- we failed finding the objects in their former parent, perform a fully recursive search + local root = self:ResolveObj(panel) + local path, selected_idxs = RecursiveFindTreeItemPaths(root, objects) + if path then + self:SetSelection(panel, path, selected_idxs, not "notify") + return + end + end + if not table.iequal(state.selection[2], objects_idxs) then + if type(selection[1]) == "table" then + selection[1][#selection[1]] = objects_idxs[1] + end + selection[2] = objects_idxs + self:SetSelection(panel, selection[1], selection[2], not "notify") + end + end +end + +function GedGameSocket:rfnStoreAppState(app_state) + self:SetLastAppState(app_state) +end + +function GedGameSocket:rfnSelectAndBindObj(name, obj_address, func_name, ...) + local panel_context = obj_address and obj_address[1] + local sel = self.selected_object + self:rfnBindObj(name, obj_address, func_name, ...) + local obj = self:ResolveObj(name) + if obj ~= sel then + if sel then + GedNotify(sel, "OnEditorSelect", false, self) + Msg("GedOnEditorSelect", sel, false, self, panel_context) + end + if obj then + GedNotify(obj, "OnEditorSelect", true, self) + Msg("GedOnEditorSelect", obj, true, self, panel_context) + end + self.selected_object = obj + end + if self.last_app_state and self.last_app_state[name] then + self.last_app_state[name].selection = nil + end +end + +function GedGameSocket:rfnSelectAndBindMultiObj(name, obj_address, obj_children_list, func_name, ...) + PauseInfiniteLoopDetection("BindMultiObj") + if #obj_children_list > 80 then + GedSetUiStatus("ged_multi_select", "Please wait...") -- cleared in GedGetValues + end + + GedNotify(self.selected_object, "OnEditorSelect", false, self) + self:rfnBindMultiObj(name, obj_address, obj_children_list, func_name, ...) + local obj = self:ResolveObj(name) + Msg("GedOnEditorMultiSelect", obj, false, self) + GedNotify(obj, "OnEditorSelect", true, self) + Msg("GedOnEditorMultiSelect", obj, true, self) + self.selected_object = obj + + ResumeInfiniteLoopDetection("BindMultiObj") + + if self.last_app_state and self.last_app_state[name] then + self.last_app_state[name].selection = nil + end +end + +function GedGameSocket:rfnBindMultiObj(name, obj_address, obj_children_list, func_name, ...) + local parent_name, path + if type(obj_address) == "table" then + parent_name, path = obj_address[1], obj_address + table.remove(path, 1) + else + parent_name, path = obj_address, {} + end + local obj = self:ResolveObj(parent_name, unpack_params(path)) + if not obj then + return + end + + table.sort(obj_children_list) + local obj_list = table.map(obj_children_list, function(el) return TreeNodeByPath(obj, el) end) + if #obj_list == 0 then + return + end + obj = GedMultiSelectAdapter:new{ __objects = obj_list } + + local params = pack_params(...) + self:BindObj(name, obj, func_name and function(obj) return _G[func_name](obj, unpack_params(params)) end) + + local bind_path = self.bound_objects_path[parent_name] + bind_path = bind_path and table.copy(bind_path, "deep") or { parent_name } + table.insert(path, obj_children_list) + path.name = name + table.insert(bind_path, path) + self.bound_objects_path[name] = bind_path +end + +function GedGameSocket:rfnUnbindObj(name, to_prefix) + self:UnbindObj(name) + if to_prefix then + self:UnbindObjs(name .. to_prefix) + end +end + +function GedGameSocket:rfnGedActivated(initial) + Msg("GedActivated", self, initial) +end + +function GedGameSocket:NotifyEditorSetProperty(obj, prop_id, old_value, multi) + Msg("GedPropertyEdited", self.ged_id, obj, prop_id, old_value) + GedNotify(obj, "OnEditorSetProperty", prop_id, old_value, self, multi) +end + +function GedGameSocket:Op(app_state, op_name, obj_name, params) + local op_fn = _G[op_name] + if not op_fn then + print("Ged - unrecognized op", op_name) + return "not found" + end + + SuspendObjModified("GedOp") + + local obj = self:ResolveObj(obj_name) + local game_objects = IsEditorActive() and obj and self:GatherAffectedGameObjects(obj) + if game_objects then + local name = "Edit objects" + if op_name == "GedSetProperty" then + local prop_id = params[1] + local prop_meta = obj:GetPropertyMetadata(prop_id) + name = string.format("Edit %s", prop_meta.name or prop_id) + end + XEditorUndo:BeginOp{ name = name, objects = game_objects, collapse_with_previous = (op_name == "GedSetProperty") } + end + + local op_params = table.copy(params, "deep") -- keep a copy for the undo queue + local ok, new_selection, undo_fn, slider_drag_id = sprocall(op_fn, self, obj, unpack_params(params)) + if ok then + if type(new_selection) == "string" then -- error + local error_msg = new_selection + self:Send("rfnApp", "GedOpError", error_msg) + ResumeObjModified("GedOp") + return new_selection + elseif new_selection then + self:ResetFilter(obj_name) + end + + if undo_fn then + assert(type(undo_fn) == "function") + while #self.undo_queue ~= self.undo_position do + table.remove(self.undo_queue) + end + local current = self.undo_queue[self.undo_position] + if not (slider_drag_id and current and current.slider_drag_id == slider_drag_id) then + self.undo_position = self.undo_position + 1 + self.undo_queue[self.undo_position] = { + app_state = app_state, + op_fn = op_fn, + obj_name = obj_name, + op_params = op_params, + bound_objects_path = table.copy(self.bound_objects_path, "deep"), + bound_objects_func = table.copy(self.bound_objects_func), + clipboard = table.copy(GedClipboard), + slider_drag_id = slider_drag_id, + undo_fn = undo_fn, + } + end + end + end + + obj = self:ResolveObj(obj_name) -- might change, e.g. new list item in a nested_list that was false + if not slider_drag_id and ObjModifiedIsScheduled(obj) then + self:OnParentsModified(obj_name) -- the change might affect how our object is displayed in the parent object(s) + end + ResumeObjModified("GedOp") + + if game_objects then + XEditorUndo:EndOp(game_objects) + end + if ok and new_selection then + self:SetSelection(obj_name, new_selection) + end +end + +function GedGameSocket:rfnGetLastError() + return GetLastError() +end + +function GedGameSocket:rfnOp(app_state, op_name, obj_name, ...) + local params = table.pack(...) + + -- execute SetProperty immediately; it is sent as the selection is being changed and affects the newly selected object otherwise + if op_name == "GedSetProperty" then + self:Op(app_state, op_name, obj_name, params) + return + end + + CreateRealTimeThread(self.Op, self, app_state, op_name, obj_name, params) +end + +function GedGameSocket:rfnUndo() + if self.undo_position == 0 or IsValidThread(self.redo_thread) then return end + + local entry = self.undo_queue[self.undo_position] + self.undo_position = self.undo_position - 1 + + SuspendObjModified("GedUndo") + procall(entry.undo_fn) + self:RestoreAppState(entry) + self:ResetFilter(entry.obj_name) + + self:OnParentsModified(entry.obj_name) + ResumeObjModified("GedUndo") +end + +function GedGameSocket:rfnRedo() + if self.undo_position == #self.undo_queue then return end + + self.undo_position = self.undo_position + 1 + local entry = self.undo_queue[self.undo_position] + + self.redo_thread = CreateRealTimeThread(function() + SuspendObjModified("GedRedo") + + local clipboard = GedClipboard + GedClipboard = entry.clipboard + self:RestoreAppState(entry) + self:ResetFilter(entry.obj_name) + + local obj = self:ResolveObj(entry.obj_name) + local params = table.copy(entry.op_params, "deep") -- make sure 'entry.op_params' is not modified + local ok, new_selection, undo_fn = sprocall(entry.op_fn, self, obj, unpack_params(params)) + if ok then + assert(type(new_selection) ~= "string") -- no errors are expected with redo + if new_selection then + self:SetSelection(entry.obj_name, new_selection) + end + entry.undo_fn = undo_fn + end + self:OnParentsModified(entry.obj_name) + + GedClipboard = clipboard + ResumeObjModified("GedRedo") + end) +end + +function GedGameSocket:SetSelection(panel_context, selection, multiple_selection, notify, restoring_state, focus) + assert(not selection or type(selection) == "number" or type(selection) == "table") + assert(not string.find(panel_context, "|")) + self:Send("rfnApp", "SetSelection", panel_context, selection, multiple_selection, notify, restoring_state, focus) +end + +function GedGameSocket:SetUiStatus(id, text, delay) + self:Send("rfnApp", "SetUiStatus", id, text, delay) +end + +function GedGameSocket:SetSearchString(search_string, panel) + self:Send("rfnApp", "SetSearchString", panel or "root", search_string) +end + +function GedGameSocket:SelectAll(panel) + local objects, selection = self:ResolveObj(panel), {} + if #objects > 0 then + for i, _ in ipairs(objects) do + table.insert(selection, i) + end + assert(not string.find(panel, "|")) + self:Send("rfnApp", "SetSelection", panel, { 1 }, selection) + end +end + +function GedGameSocket:SelectSiblingsInFocusedPanel(selection, selected) + self:Send("rfnApp", "SelectSiblingsInFocusedPanel", selection, selected) +end + +function GedGameSocket:rfnRunGlobal(func_name, ...) + if not string.starts_with(func_name, "Ged") then + assert(not "func_name should start with 'Ged'") + return + end + local fn = _G[func_name] + if not fn then + print("Ged - function not found", func_name) + return "not found" + end + return fn(self, ...) +end + +function GedGameSocket:rfnInvokeMethod(obj_name, func_name, ...) + local obj = self:ResolveObj(obj_name) + if not obj or IsKindOf(obj, "GedMultiSelectAdapter") then return false end + if PropObjHasMember(obj, func_name) then + if CanYield() then -- :Call() expects the result of the method call + return obj[func_name](obj, self, ...) + else + CreateRealTimeThread(obj[func_name], obj, self, ...) + end + else + print("The object has no method: ", func_name) + end +end + +function GedCustomEditorAction(ged, obj_name, func_name) + local obj = ged:ResolveObj(obj_name) + if not obj then return false end + if PropObjHasMember(obj, func_name) then + CreateRealTimeThread(function() obj[func_name](obj, ged) end) + elseif rawget(_G, func_name) then + CreateRealTimeThread(function() _G[func_name](ged, obj) end) + else + print("Could not find CustomEditorAction's method by name", func_name) + end +end + +function GedGetToggledActionState(ged, func_name) + return _G[func_name](ged) +end + +function GedGameSocket:ShowMessage(title, text) + title = GedTranslate(title or "", nil, false) + text = GedTranslate(text or "", nil, false) + self:Send("rfnApp", "ShowMessage", title, text) +end + +function GedGameSocket:WaitQuestion(title, text, ok_text, cancel_text) + title = GedTranslate(title or "", nil, false) + text = GedTranslate(text or "", nil, false) + ok_text = GedTranslate(ok_text or "", nil, false) + cancel_text = GedTranslate(cancel_text or "", nil, false) + return self:Call("rfnApp", "WaitQuestion", title, text, ok_text, cancel_text) +end + +function GedGameSocket:DeleteQuestion() + return self:Call("rfnApp", "DeleteQuestion") +end + +function GedGameSocket:WaitUserInput(title, default_text, combo_items) + title = GedTranslate(title or "", nil, false) + default_text = GedTranslate(default_text or "", nil, false) + return self:Call("rfnApp", "WaitUserInput", title, default_text, combo_items) +end + +function GedGameSocket:WaitListChoice(items, caption, start_selection, lines) + if not caption or caption == "" then caption = "Please select:" end + if not items or type(items) ~= "table" or #items == 0 then items = {""} end + if not start_selection then start_selection = items[1] end + return self:Call("rfnApp", "WaitListChoice", items, caption, start_selection, lines) +end + + +function GedGameSocket:WaitBrowseDialog(folder, filter, create, multiple) + return self:Call("rfnApp", "WaitBrowseDialog", folder, filter, create, multiple) +end + +function GedGameSocket:SetProgressStatus(text, progress, total_progress) + self:Send("rfnApp", "SetProgressStatus", text, progress, total_progress) +end + +-- We only send the text representation of the items for combo & choice props (assuming uniqueness), +-- as the actual values could be complex objects that can't go through the socket. +local function GedFormatComboItem(item, obj) + if type(item) == "table" and not IsT(item) then + return GedTranslate(item.name or item.text or Untranslated(item.id), obj, false) + else + return IsT(item) and GedTranslate(item, obj) or tostring(item) + end +end + +local function ComboGetItemIdByName(value, items, obj, allow_arbitrary_values, translate) + if not value then return end + for _, item in ipairs(items or empty_table) do + if GedFormatComboItem(item, obj) == value then + if type(item) == "table" then + return item.id or (item.value ~= nil and item.value) + else + return item + end + end + end + if not allow_arbitrary_values then return end + return translate and T{RandomLocId(), value} or value +end + +local function ComboGetItemNameById(id, items, obj, allow_arbitrary_values) + if not items then return end + for _, item in ipairs(items) do + if item == id or type(item) == "table" and not IsT(item) and (item.id or (item.value ~= nil and item.value)) == id then + return GedFormatComboItem(item, obj) + end + end + return IsT(id) and GedTranslate(id, obj) or id +end + +local eval = prop_eval + +function GedGameSocket:rfnGetPropItems(obj_name, prop_id) + local obj = self:ResolveObj(obj_name) + if not obj then return empty_table end + + local meta = obj:GetPropertyMetadata(prop_id) + local items = meta and eval(meta.items, obj, meta, {}) + if not items then return empty_table end + + local ret = {} + for i, item in ipairs(items) do + local text = GedFormatComboItem(item, obj) + ret[#ret + 1] = type(item) == "table" and item or text + end + return ret +end + +function GedGameSocket:rfnGetPresetItems(obj_name, prop_id) + local obj = self:ResolveObj(obj_name) + local meta = GedIsValidObject(obj) and obj:GetPropertyMetadata(prop_id) + if not meta then return empty_table end -- can happen when the selected object in Ged changes and GetPresetItems RPC is sent after that + + local preset_class = eval(meta.preset_class, obj, meta) + if not preset_class or not g_Classes[preset_class] then return empty_table end + + local extra_item = eval(meta.extra_item, obj, meta) or nil + local combo_format = _G[preset_class]:HasMember("ComboFormat") and _G[preset_class].ComboFormat + local enumerator + local preset_group = eval(meta.preset_group, obj, meta) + if preset_group then + enumerator = PresetGroupCombo(preset_class, preset_group, meta.preset_filter, extra_item, combo_format) + elseif _G[preset_class].GlobalMap or IsPresetWithConstantGroup(_G[preset_class]) then + enumerator = PresetsCombo(preset_class, nil, extra_item, meta.preset_filter, combo_format) + else + return { "" } + end + return table.iappend({ "" }, eval(enumerator, obj, meta)) +end + +function GedGameSocket:MapGetGameObjects(obj_name, prop_id) + local obj = self:ResolveObj(obj_name) + if not obj then return empty_table end + + local meta = obj:GetPropertyMetadata(prop_id) + + if not meta.base_class then return empty_table end + + local base_class = eval(meta.base_class, obj, meta) or "Object" + local objects = MapGet("map", base_class) or {} + + return objects +end + +function GetObjectPropEditorFormatFuncDefault(gameobj) + if gameobj and IsValid(gameobj) then + local x, y = gameobj:GetPos():xy() + local label = gameobj:GetProperty("EditorLabel") or gameobj.class + return string.format("%s x:%d y:%d", label, x, y) + else + return "" + end +end + +function GetObjectPropEditorFormatFunc(prop_meta) + local format_func = GetObjectPropEditorFormatFuncDefault + if prop_meta.format_func then + format_func = prop_meta.format_func + end + return format_func +end + +function GedGameSocket:rfnMapGetGameObjects(obj_name, prop_id) + local obj = self:ResolveObj(obj_name) + if not obj then return { {value = false, text = ""} } end + + local meta = obj:GetPropertyMetadata(prop_id) + local objects = self:MapGetGameObjects(obj_name, prop_id) + local format_func = GetObjectPropEditorFormatFunc(meta) + local items = { {value = false, text = ""} } + for key, value in ipairs(objects) do + table.insert(items, { + value = value.handle, + text = format_func(value), + }) + end + return items +end + +function GedGameSocket:rfnTreePanelNodeCollapsed(obj_name, path, collapsed) + local obj = self:ResolveObj(obj_name, unpack_params(path)) + if not obj then return end + GedTreePanelCollapsedNodes[obj] = collapsed or nil + if self.context.PresetClass then + Msg("GedTreeNodeCollapsedChanged") + end +end + +function GedGameSocket:GetMatchingBoundObjects(view_to_function) + local results = {} + for name, object in ipairs(self.bound_objects) do + local name = name:match("^(%w+)$") + if not name then + goto no_match + end + for view, func in pairs(view_to_function) do + local full_name = name .. "|" .. view + if not self.bound_objects_func[full_name] ~= func then + goto no_match + end + end + + table.insert(results, object) + ::no_match:: + end + + return results +end + +function GedForceUpdateObject(obj) + local sockets = GedObjects[obj] + if not sockets then return end + for i = 1, #sockets - 1, 2 do + local name, socket = sockets[i], sockets[i + 1] + if socket.bound_objects[name] == obj then + socket.bound_objects_svalue[name] = nil + end + end +end + +function GedUpdateObjectValue(socket, obj, name) + local func = socket.bound_objects_func[name] + if not func then return end + + local values = func(obj or socket.bound_objects[name], socket:FindFilter(name)) + local vpstr = ValueToLuaCode(values, nil, pstr("", 1024)) + if vpstr ~= socket.bound_objects_svalue[name] then + socket.bound_objects_svalue[name] = vpstr + socket:Send("rfnObjValue", name, vpstr:str(), true) + + if name:ends_with("|list") or name:ends_with("|tree") then + socket:UpdatePanelSelectionFromObjects(name) + end + end +end + +function GedObjectModified(obj, view) + local sockets = GedObjects[obj] + if not sockets then return end + + Msg("GedObjectModified", obj, view) + + sockets = table.copy(sockets) -- rfnBindObj and other calls could be received during socket:Send in GedUpdateObjectValue + for i = 1, #sockets - 1, 2 do + local name, socket = sockets[i], sockets[i + 1] + if socket.bound_objects[name] == obj and (not view or name:ends_with("|" .. view)) then + GedUpdateObjectValue(socket, obj, name) + end + + -- when a nested_obj / nested_list changes, call its property setter + -- this allow nested_obj/nested_list properties implemented via Get/Set to work + local prop_binding = socket.prop_bindings[obj] + if prop_binding then + prop_binding.parent:SetProperty(prop_binding.prop_id, obj) + end + end +end + +function OnMsg.ObjModified(obj) + GedObjectModified(obj) +end + +function GedObjectDeleted(obj) + if GedObjects[obj] then + for id, conn in pairs(GedConnections) do + if conn:ResolveObj("root") == obj then + conn:Send("rfnClose") + end + end + end +end + +-- delayed rebinding of root in all GedApps +-- can now be used with any bind name, not only root +function GedRebindRoot(old_value, new_value, bind_name, func, dont_restore_app_state) + if not old_value then return end + bind_name = bind_name or "root" + CreateRealTimeThread(function() + for id, conn in pairs(GedConnections) do + if conn:ResolveObj(bind_name) == old_value then + conn:BindObj(bind_name, new_value, func) + + if not dont_restore_app_state then + -- Will rebind all panels; everything that was bound "relatively" from the root might be invalidated as well. + -- Keeps the selection and last focused panel the same (as stored in last_app_state). + conn:RestoreAppState() + conn:ResetUndoQueue() + end + end + end + end) +end + +AutoResolveMethods.OnEditorSetProperty = true +RecursiveCallMethods.OnEditorNew = "sprocall" +RecursiveCallMethods.OnAfterEditorNew = "sprocall" +RecursiveCallMethods.OnEditorDelete = "sprocall" +RecursiveCallMethods.OnAfterEditorDelete = "sprocall" +RecursiveCallMethods.OnAfterEditorSwap = "sprocall" +RecursiveCallMethods.OnAfterEditorDragAndDrop = "procall" +AutoResolveMethods.OnEditorSelect = true +AutoResolveMethods.OnEditorDirty = true + +function GedNotify(obj, method, ...) + if not obj then return end + Msg("GedNotify", obj, method, ...) + if PropObjHasMember(obj, method) then + local ok, result = sprocall(obj[method], obj, ...) + return ok and result + end +end + +function GedNotifyRecursive(obj, method, parent, ...) + if not obj then return end + assert(type(parent) == "table") + obj:ForEachSubObject(function(obj, parents, key, ...) + GedNotify(obj, method, parents[#parents] or parent, ...) + end, ...) +end + + +----- Data formatting functions + +function GedIsValidObject(obj) + return IsKindOf(obj, "PropertyObject") and (not IsKindOf(obj, "CObject") or IsValid(obj)) +end + +function GedGlobalPropertyCategories() + return PropertyCategories +end + +function GedPresetPropertyUsageStats(root, filter, preset_class) + local stats, used_in = {}, {} + ForEachPreset(preset_class, function(preset) + for _, prop in ipairs(preset:GetProperties()) do + local id = prop.id + stats[id] = stats[id] or 0 + if not preset:IsPropertyDefault(id, prop) then + stats[id] = stats[id] + 1 + used_in[id] = preset.id + end + end + end) + for id, count in pairs(stats) do + if count == 1 then + stats[id] = used_in[id] + elseif count ~= 0 then + stats[id] = nil + end + end + return stats +end + +local function ConvertSlashes(path) + return string.gsub(path, "\\", "/") +end + +local function OSFolderObject(os_path) + local os_path, err = ConvertToOSPath(os_path) + -- if an error occurred, then this path doesn't exist in the OS filesystem. + -- if we return nil, the result will not be added to the list of paths for this control (see GedGetFolders) + -- thus a button will not be created for it + if not err then + return { os_path = ConvertSlashes(os_path) } + end +end + +local function GameFolderObject(game_path) + local os_path, err = ConvertToOSPath(SlashTerminate(game_path)) + if not err then + return { game_path = game_path, os_path = ConvertSlashes(os_path) } + end +end + +local function ToFolderObject(path, path_type) + path = SlashTerminate(path) + return path_type == "os" and OSFolderObject(path) or GameFolderObject(path) +end + +local function GedGetFolders(obj, prop_meta, mod_def) + local result = {} + local folder = eval(prop_meta.folder, obj, prop_meta) + local os_path = eval(prop_meta.os_path, obj, prop_meta) + if folder then + local default_type = os_path and "os" or "game" + if type(folder) == "string" then + result = { ToFolderObject(folder, default_type) } + elseif type(folder) == "table" then + for i,entry in ipairs(folder) do + if type(entry) == "string" then + table.insert(result, ToFolderObject(entry, default_type)) + elseif type(entry) == "table" then + local path_type = entry.os_path and "os" or entry.game_path and "game" or default_type + table.insert(result, ToFolderObject(entry[1], path_type)) + end + end + end + end + + if not os_path then + -- add built-in paths for image files + if prop_meta.editor == "ui_image" then + local preset = GetParentTableOfKindNoCheck(obj, "Preset") + local common_preset = preset and preset:GetSaveLocationType() == "common" + local builtin_paths = common_preset and { "CommonAssets/UI/" } or { "UI/", "CommonAssets/UI/" } + for i, path in ipairs(builtin_paths) do + if not table.find_value(result, "game_path", path) then + table.insert(result, { + game_path = path, + os_path = ConvertToOSPath(path), + }) + end + end + elseif not next(result) then + table.insert(result, OSFolderObject("./")) + end + + if mod_def then + table.insert(result, { + os_path = mod_def.path, --backwards compatibility + }) + table.insert(result, { + game_path = mod_def.content_path, + os_path = ConvertToOSPath(mod_def.content_path), + }) + end + end + + return result +end + +local function GedPopulateClassUseCounts(class_list, obj) + local parent = IsKindOf(obj, "Preset") and obj or GetParentTableOfKindNoCheck(obj, "Preset") + if not parent then return end + + local counts = {} + local iterations = 1 + ForEachPreset(parent.class, function(preset) + preset:ForEachSubObject(function(obj) + local class = obj.class + if obj.class then + counts[class] = (counts[class] or 0) + 1 + end + iterations = iterations + 1 + if iterations > 9999 then return "break" end + end) + if iterations > 9999 then return "break" end + end) + for _, item in ipairs(class_list) do + item.use_count = counts[item.value] or 0 + item.use_count_in_preset = parent.PresetClass or parent.class + end +end + +function GedGetSubItemClassList(socket, obj, path, prop_script_domain) + local obj = socket:ResolveObj(obj, unpack_params(path)) + if not obj then return end + local items = obj:EditorItemsMenu() + local filtered_items = {} + for _, item in ipairs(items) do + -- Filter script blocks by script domain + if not item.ScriptDomain or item.ScriptDomain == prop_script_domain then + table.insert(filtered_items, { + text = item.EditorName, + value = item.Class, + documentation = GedGetDocumentation(g_Classes[item.Class]), + category = item.EditorSubmenu + }) + end + end + GedPopulateClassUseCounts(filtered_items, obj) + return filtered_items +end + +function GedGetSiblingClassList(socket, obj, path) + table.remove(path) + return GedGetSubItemClassList(socket, obj, path) +end + +ClassNonInheritableMembers.EditorExcludeAsNested = true + +function GedGetNestedClassItems(socket, obj, prop_id) + local obj = socket:ResolveObj(obj) + local prop_meta = obj:GetPropertyMetadata(prop_id) + + local base_class = eval(prop_meta.base_class, obj, prop_meta) or eval(prop_meta.class, obj, prop_meta) + local def = base_class and g_Classes[base_class] + if not def or base_class == "PropertyObject" then + assert(false, "Invalid base_class or class for a nested obj/list property") + return {} + end + + local list = {} + local default_format = T(243864368637, "") + local function AddList(list, name, class, default_format) + list[#list + 1] = { + text = GedTranslate(class:HasMember("ComboFormat") and class.ComboFormat or default_format, class, false), + value = name, + documentation = GedGetDocumentation(class), + category = class:HasMember("EditorNestedObjCategory") and class.EditorNestedObjCategory, + } + end + + if prop_meta.base_class then + local inclusive = eval(prop_meta.inclusive, obj, prop_meta) + if not eval(prop_meta.no_descendants, obj, prop_meta) then + local all_descendants = eval(prop_meta.all_descendants, obj, prop_meta) + local class_filter = prop_meta.class_filter + local descendants_func = all_descendants and ClassDescendantsList or ClassLeafDescendantsList + descendants_func(base_class, function(name, class) + if not (class:HasMember("EditorExcludeAsNested") and class.EditorExcludeAsNested) and + (not class_filter or class_filter(name, class, obj)) and + not class:IsKindOf("Preset") + then + AddList(list, name, class, default_format) + end + end) + end + if inclusive or #list == 0 then + AddList(list, base_class, def, default_format) + end + table.sortby_field(list, "value") + else + AddList(list, base_class, def, default_format) + end + + GedPopulateClassUseCounts(list, obj) + return list +end + +local function GedGetProperty(obj, prop_meta) + if eval(prop_meta.no_edit, obj, prop_meta) or not prop_meta.editor then + return + end + + local prop_id = prop_meta.id + local name = eval(prop_meta.name, obj, prop_meta, "") + if name and not IsT(name) then + name = tostring(name) + end + local help = eval(prop_meta.help, obj, prop_meta, "") + local editor = eval(prop_meta.editor, obj, prop_meta, "") + local lines + + local scale = eval(prop_meta.scale, obj, prop_meta) + local scale_name + if scale and type(scale) == "string" then + scale_name = scale + elseif prop_meta.translate == true then + scale_name = "T" + end + scale = type(scale) == "string" and const.Scale[scale] or scale + scale = scale ~= 1 and scale or nil + + local buttons = eval(prop_meta.buttons, obj, prop_meta) or nil + local buttons_data + if editor == "number" and ((obj:IsKindOf("Preset") and obj.HasParameters) or (not obj:IsKindOf("Preset") and obj:HasMember("param_bindings"))) then + buttons_data = { { name = "Param", func = "PickParam" } } + end + if buttons then + buttons_data = buttons_data or {} + for _, button in ipairs(buttons) do + button.name = button.name or button[1] + button.func = button.func or button[2] or button[1] + assert(not table.find(buttons_data, "name", button.name), "Duplicate property button names!") + if not button.is_hidden or not button.is_hidden(obj, prop_meta) then + table.insert(buttons_data, { + name = button.name, + func = type(button.func) == "string" and button.func or nil, + param = button.param, + icon = button.icon, + icon_scale = button.icon_scale, + toggle = button.toggle, + toggled = button.toggle and button.is_toggled and button.is_toggled(obj), + rollover = button.rollover, + }) + end + end + + if editor == "buttons" and not next(buttons_data) then + return + end + end + + local editor_class = g_Classes[GedPropEditors[editor]] + local items = rawget(prop_meta, "items") and eval(prop_meta.items, obj, prop_meta) or nil + local prop = { + id = prop_id, + category = eval(prop_meta.category, obj, prop_meta), + editor = editor, + script_domain = eval(prop_meta.script_domain, obj, prop_meta), + default = GameToGedValue(obj:GetDefaultPropertyValue(prop_id, prop_meta), prop_meta, obj, items), + sort_order = eval(prop_meta.sort_order, obj, prop_meta) or nil, + name_on_top = eval(prop_meta.name_on_top, obj, prop_meta) or nil, + name = name ~= "" and (IsT(name) and GedTranslate(name, obj) or name) or nil, + help = help ~= "" and (IsT(help) and GedTranslate(help, obj) or help) or nil, + read_only = eval(prop_meta.read_only, obj, prop_meta) or editor == "image" or editor == "grid" or nil, + hide_name = eval(prop_meta.hide_name, obj, prop_meta) or nil, + buttons = buttons_data, + scale = scale, + scale_name = scale_name, + dlc_name = prop_meta.dlc, + min = (editor == "number" or editor == "range" or editor == "point" or editor == "box") and eval(prop_meta.min, obj, prop_meta) or nil, + max = (editor == "number" or editor == "range" or editor == "point" or editor == "box") and eval(prop_meta.max, obj, prop_meta) or nil, + step = (editor == "number" or editor == "range") and eval(prop_meta.step, obj, prop_meta) or nil, + float = (editor == "number" or editor == "range") and eval(prop_meta.float, obj, prop_meta) or nil, + buttons_step = (editor == "number" or editor == "range") and prop_meta.slider and eval(prop_meta.buttons_step, obj, prop_meta) or nil, + slider = (editor == "number" or editor == "range") and eval(prop_meta.slider, obj, prop_meta) or nil, + params = (editor == "func" or editor == "expression") and eval(prop_meta.params, obj, prop_meta) or nil, + translate = editor == "text" and eval(prop_meta.translate, obj, prop_meta) or nil, + lines = (editor == "text" or editor == "func" or editor == "prop_table") and (eval(prop_meta.lines, obj, prop_meta) or lines) or nil, + max_lines = (editor == "text" or editor == "func" or editor == "prop_table") and eval(prop_meta.max_lines, obj, prop_meta) or nil, + max_len = editor == "text" and eval(prop_meta.max_len, obj, prop_meta) or nil, + trim_spaces = editor == "text" and eval(prop_meta.trim_spaces, obj, prop_meta) or nil, + realtime_update = editor == "text" and eval(prop_meta.realtime_update, obj, prop_meta) or nil, + allowed_chars = editor == "text" and eval(prop_meta.allowed_chars, obj, prop_meta) or nil, + size = editor == "flags" and (eval(prop_meta.size, obj, prop_meta) or 32) or nil, + items = (editor == "flags" or editor == "set" or editor == "number_list" or editor == "string_list" or editor == "texture_picker" or editor == "text_picker") + and items or nil, -- N.B: 'items' for combo properties are fetched on demand as an optimization, + item_default = (editor == "number_list" or editor == "string_list" or editor == "preset_id_list") and eval(prop_meta.item_default, obj, prop_meta) or nil, + arbitrary_value = (editor == "string_list" and items) and eval(prop_meta.arbitrary_value, obj, prop_meta) or nil, + max_items = (editor == "number_list" or editor == "string_list" or editor == "preset_id_list" or editor == "T_list") and eval(prop_meta.max_items, obj, prop_meta) or nil, + exponent = editor == "number" and eval(prop_meta.exponent, obj, prop_meta) or nil, + per_item_buttons = (editor == "number_list" or editor == "string_list" or editor == "preset_id_list" or editor == "T_list") and prop_meta.per_item_buttons or nil, + lock_ratio = IsKindOf(editor_class, "GedCoordEditor") and prop_meta.lock_ratio or nil, + } + + if editor == "number_list" or editor == "string_list" or editor == "preset_id_list" then + if eval(prop_meta.weights, obj, prop_meta) then + prop.weights = true + prop.weight_default = eval(prop_meta.weight_default, obj, prop_meta) or nil + prop.weight_key = eval(prop_meta.weight_key, obj, prop_meta) or nil + prop.value_key = eval(prop_meta.value_key, obj, prop_meta) or nil + end + end + + if items then -- all kinds or properties that use a combo box, including *_list properties + prop.items_allow_tags = eval(prop_meta.items_allow_tags, obj, prop_meta) or nil + prop.show_recent_items = eval(prop_meta.show_recent_items, obj, prop_meta) or nil + prop.mru_storage_id = prop.show_recent_items and (prop_meta.mru_storage_id or string.format("%s.%s", obj.class, prop_meta.id)) -- ideally would use the class where the property was defined + end + if editor == "number" or editor == "text" or editor == "prop_table" or editor == "object" or editor == "range" or + editor == "point" or editor == "box" or editor == "rect" or editor == "expression" or editor == "func" + then + prop.auto_select_all = eval(prop_meta.auto_select_all, obj, prop_meta) or nil + prop.no_auto_select = eval(prop_meta.no_auto_select, obj, prop_meta) or nil + end + if editor == "nested_list" or editor == "nested_obj" then + local base_class = eval(prop_meta.base_class, obj, prop_meta) + local class = eval(prop_meta.class, obj, prop_meta) + prop.base_class = base_class or class -- used for clipboard class when copying items + prop.format = eval(prop_meta.format, obj, prop_meta) or nil + prop.auto_expand = eval(prop_meta.auto_expand, obj, prop_meta) or nil + prop.suppress_props = eval(prop_meta.suppress_props, obj, prop_meta) or nil + end + if editor == "property_array" then + prop.base_class = "GedDynamicProps" + prop.auto_expand = true + end + if editor == "texture_picker" or editor == "text_picker" then + prop.max_rows = eval(prop_meta.max_rows, obj, prop_meta) or nil + prop.multiple = eval(prop_meta.multiple, obj, prop_meta) or nil + prop.small_font = eval(prop_meta.small_font, obj, prop_meta) or nil + prop.filter_by_prop = eval(prop_meta.filter_by_prop, obj, prop_meta) or nil + if editor == "texture_picker" then + prop.thumb_size = eval(prop_meta.thumb_size, obj, prop_meta) or nil + prop.thumb_width = eval(prop_meta.thumb_width, obj, prop_meta) or nil + prop.thumb_height = eval(prop_meta.thumb_height, obj, prop_meta) or nil + prop.thumb_zoom = eval(prop_meta.thumb_zoom, obj, prop_meta) or nil + prop.alt_prop = eval(prop_meta.alt_prop, obj, prop_meta) or nil + prop.base_color_map = eval(prop_meta.base_color_map, obj, prop_meta) or nil + else -- text_picker + prop.horizontal = eval(prop_meta.horizontal, obj, prop_meta) or nil + prop.virtual_items = eval(prop_meta.virtual_items, obj, prop_meta) or nil + prop.bookmark_fn = eval(prop_meta.bookmark_fn, obj, prop_meta) or nil + end + end + if editor == "set" then + prop.horizontal = eval(prop_meta.horizontal, obj, prop_meta) or nil + prop.small_font = eval(prop_meta.small_font, obj, prop_meta) or nil + prop.three_state = eval(prop_meta.three_state, obj, prop_meta) or nil + prop.max_items_in_set = eval(prop_meta.max_items_in_set, obj, prop_meta) or nil + prop.arbitrary_value = eval(prop_meta.arbitrary_value, obj, prop_meta) or nil + end + if editor == "preset_id" or editor == "preset_id_list" then + prop.preset_class = eval(prop_meta.preset_class, obj, prop_meta) + prop.preset_group = eval(prop_meta.preset_group, obj, prop_meta) + prop.editor_preview = eval(prop_meta.editor_preview, obj, prop_meta) + if prop.editor_preview == true then + prop.editor_preview = g_Classes[prop.preset_class].EditorPreview + end + prop.editor_preview = prop.editor_preview and TDevModeGetEnglishText(prop.editor_preview, not "deep", "no_assert") or nil + end + if editor == "browse" or editor == "ui_image" or editor == "font" then + local mod_def = TryGetModDefFromObj(obj) + prop.image_preview_size = eval(prop_meta.image_preview_size, obj, prop_meta) or nil + prop.filter = eval(prop_meta.filter, obj, prop_meta) or nil + prop.dont_validate = eval(prop_meta.dont_validate, obj, prop_meta) or nil + prop.os_path = eval(prop_meta.os_path, obj, prop_meta) or nil + prop.folder = GedGetFolders(obj, prop_meta, mod_def) or nil + prop.allow_missing = eval(prop_meta.allow_missing, obj, prop_meta) or nil + prop.force_extension = eval(prop_meta.force_extension, obj, prop_meta) or nil + prop.mod_dst = eval(prop_meta.mod_dst, obj, prop_meta) or nil + end + if editor == "text" then -- nil means 'true' for wordwrap, so we need a separate if statement + prop.wordwrap = eval(prop_meta.wordwrap, obj, prop_meta) + prop.text_style = eval(prop_meta.text_style, obj, prop_meta) + prop.code = eval(prop_meta.code, obj, prop_meta) + prop.trim_spaces = eval(prop_meta.trim_spaces, obj, prop_meta) + end + if editor == "func" then + prop.trim_spaces = false + end + if editor == "image" then + prop.img_back = eval(prop_meta.img_back, obj, prop_meta) or nil + prop.img_size = eval(prop_meta.img_size, obj, prop_meta) or nil + prop.img_width = eval(prop_meta.img_width, obj, prop_meta) or nil + prop.img_height = eval(prop_meta.img_height, obj, prop_meta) or nil + prop.img_box = eval(prop_meta.img_box, obj, prop_meta) or nil + prop.img_draw_alpha_only = eval(prop_meta.img_draw_alpha_only, obj, prop_meta) or nil + prop.img_polyline_color = eval(prop_meta.img_polyline_color, obj, prop_meta) or nil + prop.img_polyline = eval(prop_meta.img_polyline, obj, prop_meta) or nil + local img_polyline_closed = eval(prop_meta.img_polyline_closed, obj, prop_meta) or nil + if img_polyline_closed and prop.img_polyline and prop.img_polyline_color then + prop.img_polyline = table.copy(prop.img_polyline, "deep") + for _, v in ipairs(prop.img_polyline) do + if type(v) == "table" then + v[#v+1] = v[1] + end + end + end + prop.base_color_map = eval(prop_meta.base_color_map, obj, prop_meta) or nil + end + if editor == "grid" then + prop.frame = eval(prop_meta.frame, obj, prop_meta) or nil + prop.color = eval(prop_meta.color, obj, prop_meta) or nil + prop.min = eval(prop_meta.min, obj, prop_meta) or nil + prop.max = eval(prop_meta.max, obj, prop_meta) or nil + prop.invalid_value = eval(prop_meta.invalid_value, obj, prop_meta) or nil + prop.grid_offset = eval(prop_meta.grid_offset, obj, prop_meta) or nil + prop.dont_normalize = eval(prop_meta.dont_normalize, obj, prop_meta) or nil + end + if editor == "color" then + prop.alpha = (prop_meta.alpha == nil) or eval(prop_meta.alpha, obj, prop_meta) or false + end + if editor == "packedcurve" then + prop.display_scale_x = eval(prop_meta.display_scale_x, obj, prop_meta) or nil + prop.max_amplitude = eval(prop_meta.max_amplitude, obj, prop_meta) or nil + prop.min_amplitude = eval(prop_meta.min_amplitude, obj, prop_meta) or nil + prop.color_args = eval(prop_meta.color_args, obj, prop_meta) or nil + end + if editor == "curve4" then + prop.scale_x = eval(prop_meta.scale_x, obj, prop_meta) or nil + prop.max_x = eval(prop_meta.max_x, obj, prop_meta) or nil + prop.min_x = eval(prop_meta.min_x, obj, prop_meta) or nil + prop.color_args = eval(prop_meta.color_args, obj, prop_meta) or nil + prop.no_minmax = eval(prop_meta.no_minmax, obj, prop_meta) or nil + prop.max = eval(prop_meta.max, obj, prop_meta) or nil + prop.min = eval(prop_meta.min, obj, prop_meta) or nil + prop.scale = eval(prop_meta.scale, obj, prop_meta) or nil + prop.control_points = eval(prop_meta.control_points, obj, prop_meta) or nil + prop.fixedx = eval(prop_meta.fixedx, obj, prop_meta) or nil + end + if editor == "script" then + prop.name = string.format("%s(%s)", prop.name or prop.id, eval(prop_meta.params, obj, prop_meta) or "") + if g_EditedScript and g_EditedScript == obj:GetProperty(prop.id) then + prop.name = "\n%s", name, class.Documentation), + } + end + end + table.sortby_field(menu, "EditorName") + return menu +end + +function GedDynamicItemsMenu(obj, filter, class, path) + local parent = (not class or IsKindOf(obj, class)) and obj + for i, key in ipairs(path or empty_table) do + obj = obj and rawget(TreeNodeChildren(obj), key) + if IsKindOf(obj, class) then + parent = obj + end + end + return IsKindOf(parent, "Container") and parent:EditorItemsMenu() +end + +function GedExecMemberFunc(obj, filter, member, ...) + if obj and obj:HasMember(member) then + return obj[member](obj, ...) + end +end + +function GedGetWarning(obj, filter) + if not GedIsValidObject(obj) then return end + if obj:HasMember("param_bindings") then + local sockets = GedObjects[obj] + local parent = #sockets >= 2 and sockets[2]:GetParentOfKind(obj, "Preset") or obj + for property, param in pairs(obj.param_bindings or empty_table) do + if not ResolveValue(parent, param) then + return "Undefined parameter '"..param.."' for property '"..property.."'" + end + end + end + + local diag_msg = GetDiagnosticMessage(obj) + if diag_msg and type(diag_msg) == "table" and type(diag_msg[1]) == "table" then + return diag_msg[1] + end + return diag_msg +end + +function GedGetDocumentation(obj) + if IsKindOfClasses(obj, "ScriptBlock", "FunctionObject") then + local documentation = GetDocumentation(obj) + if (documentation or "") == "" then return end + local docs = { ""} + docs[#docs+1] = "\n" + for _, prop in ipairs(obj:GetProperties()) do + local name = prop.name or prop.id + if type(name) ~= "function" and (name ~= "Negate" or obj.HasNegate) then -- filters out ScriptSimpleStatement's Param1/2/3 properties + if prop.help and prop.help ~= "" then + docs[#docs + 1] = " " + else + docs[#docs + 1] = "" + end + end + end + return table.concat(docs, "\n") + end + return GetDocumentation(obj) +end + +function GedGetDocumentationLink(obj) + return GetDocumentationLink(obj) +end + +-- hide/show functions for inline documentation at the place of the editor = "documentation" property (for Presets and Mod Items) +function GedHideDocumentation(root, obj, prop_id, ged, btn_param, idx) + local hidden = LocalStorage.DocumentationHidden or {} + hidden[obj.class] = true + LocalStorage.DocumentationHidden = hidden + SaveLocalStorageDelayed() + ObjModified(obj) +end + +function GedShowDocumentation(root, obj, prop_id, ged, btn_param, idx) + local hidden = LocalStorage.DocumentationHidden or {} + hidden[obj.class] = nil + LocalStorage.DocumentationHidden = hidden + SaveLocalStorageDelayed() + ObjModified(obj) +end + +function IsDocumentationHidden(obj) + return LocalStorage.DocumentationHidden and LocalStorage.DocumentationHidden[obj.class] +end + +function GedTestFunctionObject(socket, obj_name) + local obj = socket:ResolveObj(obj_name) + local subject = obj:HasMember("RequiredObjClasses") and SelectedObj or nil + obj:TestInGed(subject, socket) +end + +function GedPickerItemDoubleClicked(socket, obj_name, prop_id, item_id) + local obj = socket:ResolveObj(obj_name) + GedNotify(obj, "OnPickerItemDoubleClicked", prop_id, item_id, socket) +end + +function OnMsg.LuaFileChanged() GedSetUiStatus("lua_reload", "Reloading Lua...") end -- unable to make remote calls in OnMsg.ReloadLua +function OnMsg.Autorun() GedSetUiStatus("lua_reload") end +function OnMsg.ChangeMap() GedSetUiStatus("change_map", "Changing map...") end +function OnMsg.ChangeMapDone() GedSetUiStatus("change_map") end +function OnMsg.PreSaveMap() GedSetUiStatus("save_map", "Saving map...") end +function OnMsg.SaveMapDone() GedSetUiStatus("save_map") end +function OnMsg.DataReload() GedSetUiStatus("data_reload", "Reloading presets...") end +function OnMsg.DataReloadDone() GedSetUiStatus("data_reload") end +function OnMsg.ValidatingPresets() GedSetUiStatus("validating_presets", "Validating presets...") end +function OnMsg.ValidatingPresetsDone() GedSetUiStatus("validating_presets") end +function OnMsg.DebuggerBreak() GedSetUiStatus("pause", "Debugger Break") end +function OnMsg.DebuggerContinue() GedSetUiStatus("pause") end + +function GedSetUiStatus(id, text, delay) + for _, socket in pairs(GedConnections or empty_table) do + socket:SetUiStatus(id, text, delay) + end +end + +function OnMsg.ApplicationQuit() + for _, socket in pairs(GedConnections or empty_table) do + socket:Send("rfnGedQuit") + end +end + +----- GedDynamicProps +-- A dummy class to generate the properties of the nested_obj that represents the value of a property_array property + +DefineClass.GedDynamicProps = { + __parents = { "PropertyObject" }, + prop_meta = false, + parent_obj = false, +} + +function GedDynamicProps:Instance(parent, value, prop_meta) + local meta = { prop_meta = prop_meta, parent_obj = parent } + meta.__index = meta + setmetatable(meta, self) + return setmetatable(value, meta) +end + +function GedDynamicProps:__toluacode(indent, pstr, ...) + -- remove default values from the table + for _, prop_meta in ipairs(self:GetProperties()) do + if rawget(self, prop_meta.id) == prop_meta.default then + rawset(self, prop_meta.id, nil) + end + end + return TableToLuaCode(self, indent, pstr) +end + +function GedDynamicProps:GetProperties() + local props = {} + local meta = self.prop_meta + if not meta then + return props + end + local prop_meta = meta.prop_meta + local idx = 1 + + local parent_obj = self.parent_obj + local prop_meta_update = meta.prop_meta_update or empty_func + if IsKindOf(g_Classes[meta.from], "Preset") then + ForEachPreset(meta.from, function(preset) + local prop = table.copy(prop_meta) + prop.id = preset.id + prop.index = idx + prop.preset = preset + if prop_meta_update then + prop_meta_update(parent_obj, prop) + end + props[idx] = prop + idx = idx + 1 + end) + return props + end + + for k, v in sorted_pairs(eval(meta.items, self.parent_obj, meta)) do + local prop = table.copy(prop_meta) + prop.id = + meta.from == "Table keys" and k or + meta.from == "Table values" and v or + meta.from == "Table field values" and type(v) == "table" and v[meta.field] + if type(prop.id) == "string" or type(prop.id) == "number" then + prop.index = idx + prop.value = v + if prop_meta_update then + prop_meta_update(parent_obj, prop) + end + props[idx] = prop + idx = idx + 1 + end + end + return props +end + +function GedDynamicProps:Clone(class, ...) + class = class or self.class + local obj = g_Classes[class]:new(...) + setmetatable(obj, getmetatable(self)) + obj:CopyProperties(self) + return obj +end + + +----- Support for cached incremental recursive search in property values + +if FirstLoad then + ValueSearchCache = false + ValueSearchCacheInProgress = false +end + +local function populate_texts_cache_simple(obj, value, prop_meta) + if type(value) == "table" and not IsT(value) then + for k, v in pairs(value) do + populate_texts_cache_simple(obj, k, prop_meta) + populate_texts_cache_simple(obj, v, prop_meta) + end + return + end + + local str, _ + if type(value) == "string" and value ~= "" then + str = value + elseif type(value) == "number" then + str = tostring(value) -- TODO: Properly format the number values as Ged would display them? + elseif type(value) == "function" then + -- don't search in functions/expressions that are defaults (slow and these matches are of no interest most of the time) + if value ~= (prop_meta.default or obj:HasMember(prop_meta.id) and obj[prop_meta.id]) then + _, _, str = GetFuncSource(value) + str = type(str) == "table" and table.concat(str, "\n") or str + end + elseif IsT(value) then + str = TDevModeGetEnglishText(value, "deep", "no_assert") + end + if str and str ~= "" then + local cache = ValueSearchCache + table.insert(cache.objs, obj) + table.insert(cache.texts, string.lower(str)) + table.insert(cache.props, prop_meta.id) + end +end + +local function populate_texts_cache(obj, parent) + ValueSearchCache.obj_parent[obj] = parent + + for _, subobj in ipairs(obj) do + if type(subobj) == "table" then + populate_texts_cache(subobj, obj) + end + end + + if IsKindOf(obj, "PropertyObject") then + for _, prop_meta in ipairs(obj:GetProperties()) do + local id, editor = prop_meta.id, prop_meta.editor + local value = obj:GetProperty(id) + if editor == "nested_obj" and value then + populate_texts_cache(value, obj) + elseif editor == "nested_list" then + for _, subobj in ipairs(value) do + populate_texts_cache(subobj, obj) + end + else + populate_texts_cache_simple(obj, value, prop_meta) + end + end + end +end + +local function search_in_cache(root, search_text, results) + local cache = ValueSearchCache + local old_text = cache.search_text + local objs, texts, props = cache.objs, cache.texts, cache.props + cache.search_text = search_text + + local match_idxs, i = { n = 0 }, 1 + if not old_text or old_text == search_text or not search_text:starts_with(old_text) then + for idx, text in ipairs(cache.texts) do + if string.find(text, search_text, 1, true) then + match_idxs[i] = idx + match_idxs.n = i + i = i + 1 + end + end + else -- incremental search + match_idxs = cache.matches + local texts = cache.texts + for i = 1, match_idxs.n do + local idx = match_idxs[i] + if idx and not string.find(texts[idx], search_text, 1, true) then + match_idxs[i] = nil + end + end + end + cache.matches = match_idxs + + local hidden = {} + for obj in pairs(cache.obj_parent) do + hidden[tostring(obj)] = true + end + + local objs, parents = cache.objs, cache.obj_parent + for i = 1, match_idxs.n do + local idx = match_idxs[i] + if idx then + local obj, prop = objs[idx], props[idx] + local obj_id = tostring(obj) + local result = results[obj_id] or {} + if type(result) == "string" then -- there is a match both in an object's property, and its children + result = { __match = result } + end + result[#result + 1] = prop + + local parent, obj = parents[obj], obj + local match_path = { tostring(obj) } + while parent do + local parent_id = tostring(parent) + results[parent_id] = results[parent_id] or tostring(obj) + hidden[parent_id] = nil + match_path[#match_path + 1] = parent_id + obj = parent + parent = parents[parent] + end + + hidden[obj_id] = nil + results[obj_id] = result + results[#results + 1] = { prop = prop, path = table.reverse(match_path) } + end + end + results.hidden = hidden + return results +end + +local function repopulate_cache(obj) + PauseInfiniteLoopDetection("rfnPopulateSearchValuesCache") + ValueSearchCache = { + search_text = false, + obj_parent = {}, -- obj -> parent + matches = false, -- indexes in the tables below + + -- the following tables have one entry for each property text that was found recursively in 'root' + objs = {}, + texts = {}, + props = {}, -- prop name where the text was found + } + populate_texts_cache(obj) + ResumeInfiniteLoopDetection("rfnPopulateSearchValuesCache") +end + +function GedGameSocket:rfnPopulateSearchValuesCache(obj_context) + local root = self:ResolveObj(obj_context) + if root then + if ValueSearchCacheInProgress then return end + ValueSearchCacheInProgress = true + CreateRealTimeThread(function(obj) + local success, err = sprocall(repopulate_cache, obj) + if not success then + assert(false, err) + end + ValueSearchCacheInProgress = false + Msg("ValueSearchCacheUpdated") + end, root) + end +end + +function GedGameSocket:rfnSearchValues(obj_context, text) + local root = self:ResolveObj(obj_context) + if root and text and text ~= "" then + local results = {} + PauseInfiniteLoopDetection("rfnSearchValues") + if ValueSearchCacheInProgress then + WaitMsg("ValueSearchCacheUpdated") + elseif text == ValueSearchCache.search_text then + repopulate_cache(root) -- refresh results button pressed + end + search_in_cache(root, text, results) + ResumeInfiniteLoopDetection("rfnSearchValues") + return results + end +end + + +----- Bookmarks + +if FirstLoad then + g_Bookmarks = {} +end + +local function GedSortBookmarks(bookmarks) + table.sort(bookmarks, function(a, b) + local id1 = IsKindOf(a, "Preset") and a.id or a[1].group + local id2 = IsKindOf(b, "Preset") and b.id or b[1].group + return id1 < id2 + end) +end + +-- Rebuild bookmarks from local storage +function RebuildBookmarks() + if LocalStorage.editor.bookmarks then + local bookmarks = {} + local loc_storage_bookmarks = {} + + -- Remove previous bookmarks of deleted template classes + LocalStorage.editor.bookmarks["UnitAnimalTemplate"] = nil + LocalStorage.editor.bookmarks["InventoryItemTemplate"] = nil + + for class, preset_arr in pairs(LocalStorage.editor.bookmarks) do + if not bookmarks[class] then + bookmarks[class] = {} + loc_storage_bookmarks[class] = {} + end + + for idx, preset_path in ipairs(preset_arr) do + -- Find preset or group by class and path = { group, id } + local bookmark = PresetOrGroupByUniquePath(class, preset_path) + if bookmark then + table.insert(bookmarks[class], bookmark) + table.insert(loc_storage_bookmarks[class], preset_path) + end + end + GedSortBookmarks(bookmarks[class]) + + -- Rebind new bookmarks object and update UI + GedRebindRoot(g_Bookmarks[class], bookmarks[class], "bookmarks") + end + + g_Bookmarks = bookmarks + LocalStorage.editor.bookmarks = loc_storage_bookmarks + + SaveLocalStorageDelayed() + else + LocalStorage.editor.bookmarks = {} + end +end + +OnMsg.DataLoaded = RebuildBookmarks -- After Presets have been loaded initially +OnMsg.DataReloadDone = RebuildBookmarks -- After Presets have been reloaded + +function OnMsg.GedPropertyEdited(ged_id, object, prop_id, old_value) + if not IsKindOf(object, "Preset") then return end + if not object.class or not LocalStorage.editor.bookmarks or not LocalStorage.editor.bookmarks[object.class] then return end + if not table.find(g_Bookmarks[object.class], object) then return end + + local old_value_idx + if prop_id == "Group" then + old_value_idx = 1 + elseif prop_id == "Id" then + old_value_idx = 2 + else + return + end + -- Recreate the old path + local old_path = GetPresetOrGroupUniquePath(object) + old_path[old_value_idx] = old_value + + local change_idx + for idx, path in ipairs(LocalStorage.editor.bookmarks[object.class]) do + if path[1] == old_path[1] and path[2] == old_path[2] then + change_idx = idx + break + end + end + + if change_idx then + -- Replace with the new path + LocalStorage.editor.bookmarks[object.class][change_idx] = GetPresetOrGroupUniquePath(object) + + ObjModified(g_Bookmarks[object.class]) + SaveLocalStorageDelayed() + end +end + +function GedGameSocket:rfnBindBookmarks(name, class) + if not g_Bookmarks[class] then + g_Bookmarks[class] = {} + LocalStorage.editor.bookmarks[class] = {} + end + + self:BindObj(name, g_Bookmarks[class]) + table.insert_unique(self.root_names, name) +end + +-- can bookmark a preset or a preset group +function GedToggleBookmark(socket, bind_name, class) + local bookmark = socket:ResolveObj(bind_name) + local preset_root = socket:ResolveObj("root") + if not bookmark or not IsKindOf(bookmark, "Preset") and not table.find(preset_root, bookmark) then + return + end + + if not GedAddBookmark(bookmark, class) then + GedRemoveBookmark(bookmark, class) + end +end + +function GedAddBookmark(obj, class) + local bookmarks = g_Bookmarks[class] + if not table.find(bookmarks, obj) then + if not bookmarks then + bookmarks = {} + g_Bookmarks[class] = bookmarks + LocalStorage.editor.bookmarks[class] = {} + end + + local bookmarks_size = #bookmarks + bookmarks[bookmarks_size + 1] = obj + GedSortBookmarks(bookmarks) + ObjModified(bookmarks) + + LocalStorage.editor.bookmarks[class][bookmarks_size + 1] = GetPresetOrGroupUniquePath(obj) + SaveLocalStorageDelayed() + return true + end + return false +end + +function GedRemoveBookmark(obj, class) + local index = table.find(g_Bookmarks[class], obj) + if index then + local removed_path = GetPresetOrGroupUniquePath(g_Bookmarks[class][index]) + + local stored_bookmarks = LocalStorage.editor.bookmarks[class] + local idx = table.findfirst(stored_bookmarks, function(idx, path, removed_path) + return path[1] == removed_path[1] and path[2] == removed_path[2] + end, removed_path) + if idx then + table.remove(stored_bookmarks, idx) + end + + table.remove(g_Bookmarks[class], index) + ObjModified(g_Bookmarks[class]) + SaveLocalStorageDelayed() + end +end + +function GedBookmarksTree(obj, filter, format) + local format = type(format) == "string" and T{format} or format + local format_fn = function(obj) + return IsKindOf(obj, "Preset") and GedTranslate(format, obj, not "check") or obj and obj[1] and obj[1].group or "[Invalid bookmark]" + end + local children_fn = function(obj) + return not IsKindOf(obj, "Preset") and obj + end + return next(obj) and GedExpandNode(obj, nil, format_fn, children_fn) or "empty tree" +end + +function GedPresetWarningsErrors(obj) + -- find the preset class by the object that's selected (it could be a preset, a preset group, or GedMultiSelectAdapter) + local preset_class + if IsKindOf(obj, "Preset") then + preset_class = obj.class + elseif IsKindOf(obj, "GedMultiSelectAdapter") then + preset_class = obj.__objects[1].class + else -- preset group + preset_class = obj[1] and obj[1].class + end + + local warnings, errors = 0, 0 + if preset_class then + ForEachPreset(preset_class, function(preset) + local msg = DiagnosticMessageCache[preset] + if msg then + if msg[#msg] == "warning" then + warnings = warnings + 1 + else + errors = errors + 1 + end + end + end) + end + return warnings + errors, warnings, errors +end + +function GedModWarningsErrors(obj) + local parent = obj + if not IsKindOf(parent, "ModItem") and not IsKindOf(parent, "ModDef") then parent = GetParentTableOfKind(parent, "ModItem") end + if IsKindOf(parent, "ModItem") then parent = parent.mod end + + local warnings, errors = 0, 0 + local msg = DiagnosticMessageCache[parent] + if msg then + if msg[#msg] == "warning" then + warnings = warnings + 1 + else + errors = errors + 1 + end + end + + assert(IsKindOf(parent, "ModDef")) + parent:ForEachModItem(function(item) + local msg = DiagnosticMessageCache[item] + if msg then + if msg[#msg] == "warning" then + warnings = warnings + 1 + else + errors = errors + 1 + end + end + end) + return warnings + errors, warnings, errors +end + +function GedGenericStatusText(obj, filter, format, warningErrorsFunction) + local status = obj.class and obj:GetProperty("PresetStatusText") + status = status and status ~= "" and string.format("", status) or "" + + local total, warnings, errors = warningErrorsFunction(obj) + if total == 0 then return status end + + local texts = {} + texts[#texts + 1] = errors > 0 and string.format("%d error%s" , errors , errors == 1 and "" or "s") or nil + texts[#texts + 1] = warnings > 0 and string.format("%d warning%s", warnings, warnings == 1 and "" or "s") or nil + return table.concat(texts, ", ") .. "\n" .. status +end + +function GedPresetStatusText(obj, filter, format) + if IsKindOf(obj, "GedMultiSelectAdapter") then + obj = obj.__objects[1] + end + return GedGenericStatusText(obj, filter, format, GedPresetWarningsErrors) +end + +function GedModStatusText(obj, filter, format) + return GedGenericStatusText(obj, filter, format, GedModWarningsErrors) +end + +-- Root panel and bookmarks panel set the selection in each other +function OnMsg.GedOnEditorSelect(obj, selected, socket, panel_context) + if not selected then return end + + if panel_context == "root" then + local bookmark_idx = table.find(g_Bookmarks[obj.PresetClass or obj.class], obj) + if bookmark_idx then + socket:SetSelection("bookmarks", { bookmark_idx }, nil, not "notify") + end + elseif panel_context == "bookmarks" then + local path + if IsKindOf(obj, "Preset") then + socket:SetSelection("root", PresetGetPath(obj), nil, not "notify") + else -- preset group + local presets = socket:ResolveObj("root") + local idx = table.find(presets, obj) + if idx then + socket:SetSelection("root", { idx }, nil, not "notify") + end + end + end +end + + +----- Ask everywhere function (pops a message in-game and in all Ged windows) + +local function wait_any(functions) + local thread = CurrentThread() + local result = false + + for key, value in ipairs(functions) do + CreateRealTimeThread(function() + local worker_result = table.pack(value()) + if not result then + result = worker_result + Wakeup(thread) + end + end) + end + + return WaitWakeup() and table.unpack(result) +end + +function GedAskEverywhere(title, question) + local game_question = StdMessageDialog:new({}, terminal.desktop, { + question = true, title = title, text = question, + ok_text = "Yes", cancel_text = "No", + }) + game_question:Open() + + local questions = { function() return game_question:Wait() end } + for id, ged in pairs(GedConnections) do + if not ged.in_game then + table.insert(questions, function() return ged:WaitQuestion(title, question, "Yes", "No") end) + end + end + local result = wait_any(questions) + + -- close all dialogs + if game_question.window_state ~= "destroying" then + game_question:Close(false) + end + for id, ged in pairs(GedConnections) do + if not ged.in_game then + ged:DeleteQuestion() + end + end + + return result +end diff --git a/CommonLua/Ged/Apps/BillboardEditor.lua b/CommonLua/Ged/Apps/BillboardEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..626fddb3c5712b9887593059251bb235efa2ecf4 --- /dev/null +++ b/CommonLua/Ged/Apps/BillboardEditor.lua @@ -0,0 +1,57 @@ +DefineClass.BillboardEditor = { + __parents = { "GedApp" }, + + Title = "Billboard Editor", + AppId = "BillboardEditor", + InitialWidth = 1600, + InitialHeight = 900, +} + +function BillboardEditor:Init(parent, context) + GedListPanel:new({ + Id = "idBillboards", + Title = "Billboards", + Format = "", + SelectionBind = "SelectedObject", + ItemActionContext = "Billboard", + }, self, "root") + + XAction:new({ + ActionId = "Bake", + ActionMenubar = "main", + ActionName = "Bake", + ActionTranslate = false, + OnAction = function(self, host, button) + host:Send("GedBakeBillboard") + end, + ActionContexts = { "Billboard" } + }, self) + XAction:new({ + ActionId = "Spawn", + ActionMenubar = "main", + ActionName = "Spawn", + ActionTranslate = false, + OnAction = function(self, host, win) + host:Send("GedSpawnBillboard") + end, + ActionContexts = { "Billboard" } + }, self) + XAction:new({ + ActionId = "Debug Billboards", + ActionMenubar = "main", + ActionName = "Debug Billboards", + ActionTranslate = false, + OnAction = function(self, host, win) + host:Send("GedDebugBillboards") + end, + }, self) + XAction:new({ + ActionId = "Bake All", + ActionMenubar = "main", + ActionName = "Bake All", + ActionTranslate = false, + OnAction = function(self, host, win) + host:Send("GedBakeAllBillboards") + end, + }, self) +end \ No newline at end of file diff --git a/CommonLua/Ged/Apps/ScreenshotDiffViewer.lua b/CommonLua/Ged/Apps/ScreenshotDiffViewer.lua new file mode 100644 index 0000000000000000000000000000000000000000..e9d7e640744645426cb28687c94992d57605747f --- /dev/null +++ b/CommonLua/Ged/Apps/ScreenshotDiffViewer.lua @@ -0,0 +1,304 @@ +DefineClass.ScreenshotDiffViewer = { + __parents = { "GedApp" }, + + Translate = true, + Title = "Screenshot Diff Viewer", + AppId = "ScreenshotDiffViewer", + InitialWidth = 1600, + InitialHeight = 900, + + selected_image_paths = false, + view_mode = "cycle", + diff_scale = 2, +} + +function ScreenshotDiffViewer:Init(parent, context) + XAction:new({ + ActionId = "TakeScreenshot", + ActionToolbar = "main", + ActionName = "Take Screenshot", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/new.tga", + OnAction = function(action, host) + host:Op("rfnCreateNewScreenshot", "root") + end, + }, self) + XAction:new({ + ActionId = "OpenFolder", + ActionToolbar = "main", + ActionName = "Open Folder", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/explorer.tga", + OnAction = function(action, host) + AsyncExec("explorer " .. ConvertToOSPath(g_ScreenshotViewerFolder)) + end, + }, self) + XAction:new({ + ActionId = "RefreshList", + ActionToolbar = "main", + ActionToolbarSplit = true, + ActionName = "Refresh List", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/undo.tga", + OnAction = function(action, host) + host:Op("rfnReloadScreenshotItems", "root") + end, + }, self) + XAction:new({ + ActionId = "FitImage", + ActionToolbar = "main", + ActionName = "Fit Image", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/fit.tga", + OnAction = function(action, host) + host:SetImageFitScale(nil, true) + end, + }, self) + XAction:new({ + ActionId = "ShowOriginalSize", + ActionToolbar = "main", + ActionName = "Original Size", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/original.tga", + OnAction = function(action, host) + host:SetImageFitScale() + end, + }, self) + XAction:new({ + ActionId = "MagnifyImage", + ActionToolbar = "main", + ActionToolbarSplit = true, + ActionName = "200%", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/x2.tga", + OnAction = function(action, host) + host:SetImageFitScale(point(2000, 2000)) + end, + }, self) + XAction:new({ + ActionId = "ToggleVStripComp", + ActionToolbar = "main", + ActionToolbarSplit = true, + ActionName = "Toggle Vertical Strips Comparison", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/collection.tga", + ActionToggle = true, + ActionToggled = function (self, host) + return host.view_mode == "vstrips" + end, + OnAction = function(action, host) + if action:ActionToggled(host) then + host:SetViewMode("cycle") + else + host:SetViewMode("vstrips") + end + end, + }, self)--]] + XAction:new({ + ActionId = "ToggleCycleDiff", + ActionToolbar = "main", + ActionToolbarSplit = true, + ActionName = "Toggle Screenshot Difference", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/view.tga", + ActionToggle = true, + ActionToggled = function (self, host) + return host.view_mode == "diff" + end, + OnAction = function(action, host) + if action:ActionToggled(host) then + host:SetViewMode("cycle") + else + host:SetViewMode("diff") + end + end, + }, self) + XAction:new({ + ActionId = "SetDiff2", + ActionToolbar = "main", + ActionName = "2x Difference Scale", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/2x.tga", + ActionToggle = true, + ActionToggled = function (self, host) + return host.diff_scale == 2 + end, + OnAction = function(action, host) + host:SetDiffScale(2) + end, + }, self) + XAction:new({ + ActionId = "SetDiff2", + ActionToolbar = "main", + ActionName = "4x Difference Scale", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/4x.tga", + ActionToggle = true, + ActionToggled = function (self, host) + return host.diff_scale == 4 + end, + OnAction = function(action, host) + host:SetDiffScale(4) + end, + }, self) + XAction:new({ + ActionId = "SetDiff2", + ActionToolbar = "main", + ActionName = "16x Difference Scale", + ActionTranslate = false, + ActionIcon = "CommonAssets/UI/Ged/16x.tga", + ActionToggle = true, + ActionToggled = function (self, host) + return host.diff_scale == 16 + end, + OnAction = function(action, host) + host:SetDiffScale(16) + end, + }, self) + + self.LayoutHSpacing = 0 + GedListPanel:new({ + Id = "idScreenshots", + Title = "Screenshots", + TitleFormatFunc = "GedFormatPresets", + Format = "", + SelectionBind = "SelectedObject,SelectedPreset", + MultipleSelection = true, + SearchHistory = 20, + PersistentSearch = true, + }, self, "root") + XPanelSizer:new({ + }, self) + local win = XWindow:new({ + }, self) + XImage:new({ + Id = "idImageFit", + ImageFit = "smallest", + FoldWhenHidden = true, + }, win) + local container = XWindow:new({ + Id = "idScrollContainer", + FoldWhenHidden = true, + }, win) + container:SetVisible(false) + local area = XScrollArea:new({ + Id = "idScrollArea", + IdNode = false, + HScroll = "idHScroll", + VScroll = "idScroll", + }, container) + XImage:new({ + Id = "idImage", + ImageFit = "none", + }, area) + XSleekScroll:new({ + Id = "idHScroll", + Target = "idScrollArea", + Dock = "bottom", + Margins = box(0, 2, 7, 0), + Horizontal = true, + AutoHide = true, + FoldWhenHidden = true, + }, container) + XSleekScroll:new({ + Id = "idScroll", + Target = "idScrollArea", + Dock = "right", + Margins = box(2, 0, 0, 0), + Horizontal = false, + AutoHide = true, + FoldWhenHidden = true, + }, container) +end + +function ScreenshotDiffViewer:SetViewMode(mode) + self.view_mode = mode + self:UpdateShownImages() +end + +function ScreenshotDiffViewer:SetDiffScale(scale) + self.diff_scale = scale + self:UpdateShownImages() +end + +function ScreenshotDiffViewer:UpdateShownImages() + self:DeleteThread("cycle_thread") + local images = self.selected_image_paths + if #(images or "") > 1 then + if self.view_mode == "cycle" then + self:CreateThread("cycle_thread", function(self) + local idx = 1 + local count = #images + while true do + self:SetImage(images[idx]) + Sleep(500) + idx = idx % count + 1 + end + end, self) + elseif self.view_mode == "diff" then + self:ShowDiffImage() + elseif self.view_mode == "vstrips" then + self:ShowVStripsComparison() + end + elseif #(images or "") > 0 then + self:SetImage(images[1]) + end +end + +function ScreenshotDiffViewer:ShowVStripsComparison() + local images = self.selected_image_paths + local hash = "" + for i, img in ipairs(images) do hash = hash..xxhash(img) end + local result_path = string.format("%s%d%s.png", g_ScreenshotViewerCacheFolder, #images, hash) + if not io.exists(result_path) then + AsyncCreatePath(g_ScreenshotViewerCacheFolder) + CreateRealTimeThread(function() + local err = self.connection:Call("rfnOp", self:GetState(), "rfnCompareScreenshotItemsVstrips", "root", images, result_path) + if not err and io.exists(result_path) then + self:SetImage(result_path) + end + end, self, images, result_path) + return + end + self:SetImage(result_path) +end + +function ScreenshotDiffViewer:ShowDiffImage() + local images = self.selected_image_paths + local diff_path = string.format("%s%d_%d_%d.png", g_ScreenshotViewerCacheFolder, xxhash(images[1]), xxhash(images[2]), self.diff_scale) + if not io.exists(diff_path) then + AsyncCreatePath(g_ScreenshotViewerCacheFolder) + CreateRealTimeThread(function() + local err = self.connection:Call("rfnOp", self:GetState(), "rfnCompareScreenshotItemsDiff", "root", images[1], images[2], diff_path, self.diff_scale) + if not err and io.exists(diff_path) then + self:SetImage(diff_path) + end + end, self, images, diff_path) + return + end + self:SetImage(diff_path) +end + +function ScreenshotDiffViewer:SetImage(image) + self.idImage:SetImage(image) + self.idImageFit:SetImage(image) +end + +function ScreenshotDiffViewer:SetImageFitScale(scale, fit) + scale = scale or point(1000, 1000) + self.idImage:SetScaleModifier(scale) + self.idScrollContainer:SetVisible(not fit) + self.idImageFit:SetVisible(fit) +end + +function ScreenshotDiffViewer:rfnSetSelectedFilePath(file_path, selected) + self.selected_image_paths = self.selected_image_paths or {} + local t = self.selected_image_paths + if selected then + table.insert_unique(t, file_path) + else + table.remove_value(t, file_path) + end + table.sort(t) + self:UpdateShownImages() +end \ No newline at end of file diff --git a/CommonLua/Ged/Apps/__load.lua b/CommonLua/Ged/Apps/__load.lua new file mode 100644 index 0000000000000000000000000000000000000000..dc10740b1e2bd848813bbeffba686e8249d9a74c --- /dev/null +++ b/CommonLua/Ged/Apps/__load.lua @@ -0,0 +1,3 @@ +if Platform.ged then + dofolder_files("CommonLua/Ged/Apps/") +end \ No newline at end of file diff --git a/CommonLua/Ged/GedApp.lua b/CommonLua/Ged/GedApp.lua new file mode 100644 index 0000000000000000000000000000000000000000..dfcec9604724c1182e0ed0756a9f57d436b689f0 --- /dev/null +++ b/CommonLua/Ged/GedApp.lua @@ -0,0 +1,1484 @@ +function OnMsg.SystemActivate() + if rawget(_G, "g_GedApp") and Platform.ged then + g_GedApp.connection:Send("rfnGedActivated", false) + end +end + +GedDisabledOp = "(disabled)" +GedCommonOps = { + { Id = "MoveUp", Name = "Move up", Icon = "CommonAssets/UI/Ged/up.tga", Shortcut = "Alt-Up", }, + { Id = "MoveDown", Name = "Move down", Icon = "CommonAssets/UI/Ged/down.tga", Shortcut = "Alt-Down", }, + { Id = "MoveOut", Name = "Move out", Icon = "CommonAssets/UI/Ged/left.tga", Shortcut = "Alt-Left", }, + { Id = "MoveIn", Name = "Move in", Icon = "CommonAssets/UI/Ged/right.tga", Shortcut = "Alt-Right", }, + { Id = "Delete", Name = "Delete", Icon = "CommonAssets/UI/Ged/delete.tga", Shortcut = "Delete", Split = true, }, + { Id = "Cut", Name = "Cut", Icon = "CommonAssets/UI/Ged/cut.tga", Shortcut = "Ctrl-X", }, + { Id = "Copy", Name = "Copy", Icon = "CommonAssets/UI/Ged/copy.tga", Shortcut = "Ctrl-C", }, + { Id = "Paste", Name = "Paste", Icon = "CommonAssets/UI/Ged/paste.tga", Shortcut = "Ctrl-V", }, + { Id = "Duplicate", Name = "Duplicate", Icon = "CommonAssets/UI/Ged/duplicate.tga", Shortcut = "Ctrl-D", Split = true, }, + { Id = "DiscardEditorChanges", Name = "Discard editor changes", Icon = "CommonAssets/UI/Ged/cleaning_brush.png", Shortcut = "Ctrl-Alt-D", }, + { Id = "Undo", Name = "Undo", Icon = "CommonAssets/UI/Ged/undo.tga", Shortcut = "Ctrl-Z", }, + { Id = "Redo", Name = "Redo", Icon = "CommonAssets/UI/Ged/redo.tga", Shortcut = "Ctrl-Y", Split = true, }, +} + +DefineClass.GedApp = { + __parents = { "XActionsHost", "XDarkModeAwareDialog" }, + properties = { + { category = "GedApp", id = "HasTitle", editor = "bool", default = true, }, + { category = "GedApp", id = "Title", editor = "text", default = "", no_edit = function(obj) return not obj:GetProperty("HasTitle") end }, + { category = "GedApp", id = "AppId", editor = "text", default = "", }, + { category = "GedApp", id = "ToolbarTemplate", editor = "choice", default = "GedToolBar", items = XTemplateCombo("XToolBar"), }, + { category = "GedApp", id = "MenubarTemplate", editor = "choice", default = "GedMenuBar", items = XTemplateCombo("XMenuBar"), }, + { category = "GedApp", id = "CommonActionsInMenubar", editor = "bool", default = true, }, + { category = "GedApp", id = "CommonActionsInToolbar", editor = "bool", default = true, }, + { category = "GedApp", id = "InitialWidth", editor = "number", default = 1600, }, + { category = "GedApp", id = "InitialHeight", editor = "number", default = 900, }, + { category = "GedApp", id = "DiscardChangesAction", editor = "bool", default = true, }, + }, + LayoutMethod = "HPanel", + LayoutHSpacing = 0, + IdNode = true, + Background = RGB(160, 160, 160), + connection = false, + in_game = false, + settings = false, + first_update = true, -- used by GedListPanel/GedTreePanel + all_panels = false, + interactive_panels = false, -- mapping context => panel + actions_toggled = false, -- mapping actionid => toggled (only for Toggled actions) + ui_status = false, + ui_update_time = 0, + ui_questions = false, + ui_status_delay_time = 0, + ui_status_delay_thread = false, + last_focused_panel = false, + last_focused_tree_or_list_panel = false, + blink_thread = false, -- see GedOpError + blink_border_color = RGBA(0, 0, 0, 0), + progress_text = false, + progress_bar = false, + + -- support for next/previous match when a search with "Search in properties and sub-objects" is active + search_value_filter_text = false, + search_value_results = false, + search_value_panel = false, + search_result_idx = 1, + display_search_result = false, +} + +function GedApp:Init(parent, context) + if Platform.ged then rawset(_G, "g_GedApp", self) end + + for k, v in pairs(context) do + rawset(self, k, v) + end + if Platform.ged and self.connection then + self.connection:Send("rfnGedActivated", true) + end + + self.connection.app = self + self.actions_toggled = {} + self.ui_status = {} + self:SetHasTitle(true) + if not self.in_game then + self.HAlign = "stretch" + self.VAlign = "stretch" + if self.ui_scale then + self:SetScaleModifier(point(self.ui_scale * 10, self.ui_scale * 10)) + end + end + hr.MaxFps = Min(60, self.max_fps) + ShowMouseCursor("GedApp") + + XAction:new({ + ActionId = "idSearch", + ActionToolbar = false, + ActionShortcut = "Ctrl-F", + ActionContexts = {}, + ActionMenubar = false, + ActionName = "Search", + ActionTranslate = false, + OnAction = function(action, ged_app, src) + local panel = ged_app.last_focused_panel + if panel and IsKindOf(panel, "GedPanel") then + panel:OpenSearch() + end + end + }, self) + + XAction:new({ + ActionId = "idExpandCollapseNode", + ActionToolbar = false, + ActionShortcut = "Alt-C", + ActionContexts = {}, + ActionMenubar = false, + ActionName = "Expand/collapse selected node's children", + ActionTranslate = false, + OnAction = function(action, ged_app, src) + local panel = ged_app.last_focused_panel + if panel and IsKindOf(panel, "GedTreePanel") then + panel.idContainer:ExpandNodeByPath(panel.idContainer:GetFocusedNodePath() or empty_table) + panel.idContainer:ExpandCollapseChildren(panel.idContainer:GetFocusedNodePath() or empty_table, not "recursive", "user_initiated") + end + end + }, self) + XAction:new({ + ActionId = "idExpandCollapseTree", + ActionToolbar = false, + ActionShortcut = "Shift-C", + ActionContexts = {}, + ActionMenubar = false, + ActionName = "Expand/collapse tree", + ActionTranslate = false, + OnAction = function(action, ged_app, src) + local panel = ged_app.last_focused_panel + if IsKindOf(panel, "GedTreePanel") then + panel.idContainer:ExpandCollapseChildren({}, "recursive", "user_initiated") + elseif IsKindOf(panel, "GedPropPanel") then + panel:ExpandCollapseCategories() + end + end + }, self) + + if self.MenubarTemplate ~= "" then XTemplateSpawn(self.MenubarTemplate, self) end + if self.ToolbarTemplate ~= "" then XTemplateSpawn(self.ToolbarTemplate, self) end + + if not self.in_game then + self.status_ui = StdStatusDialog:new({}, self.desktop, { dark_mode = self.dark_mode }) + self.status_ui:SetVisible(false) + self.status_ui:Open() + end + + self:SetContext("root") +end + +function GedApp:AddCommonActions() + if not self.interactive_panels then return end + + if self.CommonActionsInMenubar then + if not self:ActionById("File") then + XAction:new({ + ActionId = "File", + ActionName = "File", + ActionMenubar = "main", + ActionTranslate = false, + ActionSortKey = "1", + OnActionEffect = "popup", + }, self) + end + if not self:ActionById("Edit") then + XAction:new({ + ActionId = "Edit", + ActionName = "Edit", + ActionMenubar = "main", + ActionTranslate = false, + ActionSortKey = "1", + OnActionEffect = "popup", + }, self) + end + end + + local has_undo = false -- if at least one panel with ActionsClass set + for _, panel in pairs(self.interactive_panels) do + has_undo = has_undo or panel.ActionsClass ~= "None" + end + + local needs_separator = false + for _, data in ipairs(GedCommonOps) do + local id = data.Id + local is_undo = id == "Undo" or id == "Redo" or id == "DiscardEditorChanges" + local contexts = {} + for _, panel in pairs(self.interactive_panels) do + if not is_undo and panel[id] ~= "" and panel[id] ~= GedDisabledOp then + if panel:IsKindOf("GedListPanel") then + table.insert(contexts, panel.ItemActionContext) + elseif panel:IsKindOf("GedTreePanel") then + if panel.EnableForRootLevelItems or id == "Paste" then + table.insert(contexts, panel.RootActionContext) + end + table.insert(contexts, panel.ChildActionContext) + elseif panel:IsKindOf("GedPropPanel") then + table.insert(contexts, panel.PropActionContext) + end + end + end + + if is_undo and has_undo or next(contexts) then + XAction:new({ + ActionId = id, + ActionMenubar = self.CommonActionsInMenubar and "Edit", + ActionToolbar = self.CommonActionsInToolbar and "main", + ActionToolbarSplit = data.Split, + ActionTranslate = false, + ActionName = data.Name, + ActionIcon = data.Icon, + ActionShortcut = data.Shortcut, + ActionSortKey = "1", + ActionContexts = contexts, + ActionState = function(self, host) return host:CommonActionState(self.ActionId) end, + OnAction = function(self, host, source) host:CommonAction(self.ActionId) end, + }, self) + needs_separator = true + end + + if data.Split and needs_separator then + XAction:new({ + ActionMenubar = "Edit", + ActionName = "-----", + ActionTranslate = false, + ActionSortKey = "1", + }, self) + needs_separator = false + end + end +end + +function GedApp:FindPropPanelForPropertyPaste(panel) + -- look for a GedPropPanel where the object from the current panel is displayed (and it supports paste) + -- allow pasting properties in this case even if the current panel has no ops + if panel and panel:HasMember("SelectionBind") then + local panel_bindings = panel.SelectionBind:split(",") + for _, prop_panel in pairs(self.interactive_panels) do + if prop_panel:IsKindOf("GedPropPanel") and prop_panel.Paste ~= "" and prop_panel.Paste ~= GedDisabledOp and + table.find(panel_bindings, prop_panel.context) then + return prop_panel + end + end + end +end + +local reCommaList = "([%w_]+)%s*,%s*" +function GedApp:CommonActionState(id) + if id == "DiscardEditorChanges" then + return (not rawget(self, "PresetClass") or not self.DiscardChangesAction or config.ModdingToolsInUserMode) and "hidden" + elseif id ~= "Undo" and id ~= "Redo" then + local panel = self:GetLastFocusedPanel() + if IsKindOf(panel, "GedTreePanel") and not panel.EnableForRootLevelItems then + local selection = panel:GetSelection() + if selection and #selection == 1 and id ~= "Paste" then + return "disabled" + end + end + + -- Disable most common operations for read-only presets/objects + if panel then + -- Check if selection obj is read-only + local sel_read_only + if panel.SelectionBind then + for bind in string.gmatch(panel.SelectionBind .. ",", reCommaList) do + sel_read_only = sel_read_only or self.connection:Obj(bind .. "|read_only") + if sel_read_only then + break + end + end + else + sel_read_only = self.connection:Obj(panel.context .. "|read_only") + end + + -- "sel_read_only == nil" means the binding object is changing, so actions should be disabled + if sel_read_only == nil then + sel_read_only = true + end + + -- Panel context IS NOT read-only but the selection bind IS read-only (ex. Leftmost preset tree panel) + if not panel.read_only and sel_read_only then + -- Disable all operations when Modding, otherwise leave only Copy and Paste + if (id ~= "Copy" and id ~= "Paste") or config.ModdingToolsInUserMode then + return "disabled" + end + -- Panel context IS read-only but the selection bind IS read-only (ex. Middle preset panel (container items panel)) + elseif panel.read_only and sel_read_only then + -- Disable all operations except Copy + if id ~= "Copy" then + return "disabled" + end + end + end + + if not panel or not IsKindOf(panel, "GedPanel") or panel[id] == "" or panel[id] == GedDisabledOp then + if id ~= "Paste" or not self:FindPropPanelForPropertyPaste(panel) then + return "disabled" + end + end + end +end + +function GedApp:CommonAction(id) + if id == "Undo" then + self:Undo() + elseif id == "Redo" then + self:Redo() + elseif id == "DiscardEditorChanges" then + self:DiscardEditorChanges() + else + local panel = self:GetLastFocusedPanel() + local op = panel[id] + if panel:IsKindOf("GedPropPanel") then + if id == "Copy" then + self:Op(op, panel.context, panel:GetSelectedProperties(), panel.context) + elseif id == "Paste" then + self:Op(op, panel.context, panel:GetSelectedProperties(), panel.context) + else + assert(false, "Unknown common action " .. id .. "; prop panels only have Copy & Paste common actions") + end + elseif id == "MoveUp" or id == "MoveDown" or id == "MoveIn" or id == "MoveOut" or id == "Delete" then + self:Op(op, panel.context, panel:GetMultiSelection()) + elseif id == "Cut" or id == "Copy" or id == "Paste" or id == "Duplicate" then + if id == "Paste" and (op == "" or op == GedDisabledOp) then + panel = self:FindPropPanelForPropertyPaste(panel) + self:Op(panel[id], panel.context) + else + self:Op(op, panel.context, panel:GetMultiSelection(), panel.ItemClass(self)) + end + else + assert(false, "Unknown common action " .. id) + end + end +end + +function GedApp:SetHasTitle(has_title) + self.HasTitle = has_title + + if self.in_game and self.HasTitle then + if not self:HasMember("idTitleContainer") then + XMoveControl:new({ + Id = "idTitleContainer", + Dock = "top", + }, self) + XLabel:new({ + Id = "idTitle", + Dock = "left", + Margins = box(4, 2, 4, 2), + TextStyle = "GedTitle", + }, self.idTitleContainer) + XTextButton:new({ + Dock = "right", + OnPress = function(n) + self:Exit() + end, + Text = "X", + LayoutHSpacing = 0, + Padding = box(1, 1, 1, 1), + Background = RGBA(0, 0, 0, 0), + RolloverBackground = RGB(204, 232, 255), + PressedBackground = RGB(121, 189, 241), + VAlign = "center", + TextStyle = "GedTitle", + }, self.idTitleContainer) + end + elseif self:HasMember("idTitleContainer") then + self.idTitleContainer:Done() + end +end + +function GedApp:UpdateUiStatus(force) + if self.in_game then return end + + if not force and now() - self.ui_update_time < 250 then + CreateRealTimeThread(function() + Sleep(now() - self.ui_update_time) + self:UpdateUiStatus(true) + end) + return + end + self.ui_update_time = now() + + if #self.ui_status == 0 then + self.status_ui:SetVisible(false) + return + end + + local texts = {} + for _, status in ipairs(self.ui_status) do + texts[#texts + 1] = status.text + end + self.status_ui.idText:SetText(table.concat(texts, "\n")) + self.status_ui:SetVisible(true) +end + +function GedApp:Open(...) + self:CreateProgressStatusText() + + XActionsHost.Open(self, ...) + self:AddCommonActions() + if self.AppId ~= "" then + self:ApplySavedSettings() + end + self:SetDarkMode(GetDarkModeSetting()) + self:OnContextUpdate(self.context, nil) +end + +function GedApp:CreateProgressStatusText() + if not self.interactive_panels then return end + + -- find first interactive panel, add the progress status text on its bottom + for _, panel in ipairs(self.all_panels) do + if self.interactive_panels[panel.context or false] then + local parent = XWindow:new({ + Dock = "bottom", + FoldWhenHidden = true, + Margins = box(2, 1, 2, 1), + }, panel) + self.progress_bar = XWindow:new({ + DrawContent = function(self, clip_box) + local bbox = self.content_box + local sizex = MulDivRound(bbox:sizex(), self.progress, self.total_progress) + UIL.DrawSolidRect(sizebox(bbox:min(), bbox:size():SetX(sizex)), RGBA(128, 128, 128, 128)) + end, + }, parent) + self.progress_text = XText:new({ + Background = RGBA(0, 0, 0, 0), + TextStyle = "GedDefault", + TextHAlign = "center", + }, parent) + parent:SetVisible(false) + break + end + end +end + +function GedApp:SetProgressStatus(text, progress, total_progress) + if not self.progress_text then return end + if not text then + self.progress_text.parent:SetVisible(false) + return + end + rawset(self.progress_bar, "progress", progress) + rawset(self.progress_bar, "total_progress", total_progress) + self.progress_bar:Invalidate() + self.progress_text:SetText(text) + self.progress_text.parent:SetVisible(true) +end + +function GedApp:GedDefaultBox() + local ret = sizebox(20, 20, self.InitialWidth, self.InitialHeight) + return ret + (self.in_game and GetDevUIViewport().box:min() or point(30, 30)) +end + +function GedApp:ApplySavedSettings() + self.settings = io.exists(self:SettingsPath()) and LoadLuaTableFromDisk(self:SettingsPath()) or {} + self:SetWindowBox(self.settings.box or self:GedDefaultBox()) + if self.settings.resizable_panel_sizes then + self:SetSizeOfResizablePanels() + end + + local search_values = self.settings.search_in_props + if search_values then + for context, panel in pairs(self.interactive_panels) do + local setting_value = search_values[context] + if panel.SearchValuesAvailable and setting_value ~= nil and panel.search_values ~= setting_value then + panel:ToggleSearchValues("no_settings_update") + end + end + end + + local collapsed_categories = self.settings.collapsed_categories or empty_table + for context, panel in pairs(self.interactive_panels) do + panel.collapsed_categories = collapsed_categories[context] or {} + end +end + +function GedApp:SetWindowBox(box) + if self.in_game then + local viewport = GetDevUIViewport().box:grow(-20) + if viewport:Intersect2D(box) ~= const.irInside then + box = self:GedDefaultBox() + end + self:SetDock("ignore") + self:SetBox(box:minx(), box:miny(), box:sizex(), box:sizey()) + else + terminal.OverrideOSWindowPos(box:min()) + ChangeVideoMode(box:sizex(), box:sizey(), 0, true, false) + end +end + +function GedApp:SetSizeOfResizablePanels() + if not self.settings.resizable_panel_sizes then return end + for id, data in pairs(self.settings.resizable_panel_sizes) do + local panel = self:ResolveId(id) + if panel then + panel:SetMaxWidth(data.MaxWidth) + panel:SetMaxHeight(data.MaxHeight) + end + end +end + +function GedApp:Exit() + self.connection:delete() +end + +function GedApp:Close(...) + self:SaveSettings() + XActionsHost.Close(self, ...) +end + +function GedApp:SettingsPath() + local subcategory = "" + if rawget(self, "PresetClass") then subcategory = "-" .. self.PresetClass end + local filename = string.format("AppData/Ged/%s%s%s.settings", self.in_game and "ig_" or "", self.AppId, subcategory) + return filename +end + +function GedApp:SaveSettings() + if not self.settings then return end + self.settings.box = self:GetWindowBox() + self:SavePanelSize() + + local filename = self:SettingsPath() + local path = SplitPath(filename) + AsyncCreatePath(path) + return SaveLuaTableToDisk(self.settings, filename) +end + +function GedApp:SavePanelSize() + if not self.settings["resizable_panel_sizes"] then + self.settings["resizable_panel_sizes"] = {} + end + + for _, child in ipairs(self) do + if not child:IsKindOf("XPanelSizer") and not child.Dock then + self.settings.resizable_panel_sizes[child.Id] = { + MaxHeight = child.MaxHeight, + MaxWidth = child.MaxWidth + } + end + end +end + +function GedApp:Activate(context) + for k, v in pairs(context) do + rawset(self, k, v) + end + if rawget(terminal, "BringToTop") then + return terminal.BringToTop() + end + return false +end + +function GedApp:GetWindowBox() + if self.in_game then + return self.box + end + return sizebox(terminal.GetOSWindowPos(), self.box:size()) +end + +function GedApp:OnContextUpdate(context, view) + if not view then + if self.HasTitle then + local title = self.Title + title = _InternalTranslate(IsT(title) and title or T{title}, self, false) + if self.in_game then + self.idTitle:SetText(title) + else + terminal.SetOSWindowTitle(title) + end + end + + -- fetch global data, as needed + if self.WarningsUpdateRoot then + self.connection:BindObj("root|warnings_cache", "root", "GedGetCachedDiagnosticMessages") + end + if #GetChildrenOfKind(self, "GedPropPanel") > 0 then + self.connection:BindObj("root|categories", "root", "GedGlobalPropertyCategories") + end + if self.PresetClass then + self.connection:BindObj("root|prop_stats", "root", "GedPresetPropertyUsageStats", self.PresetClass) + end + if self.PresetClass or self.AppId == "ModEditor" then + self.connection:BindObj("root|dirty_objects", "root", "GedGetDirtyObjects") + end + end + if view == "prop_stats" then + for _, panel in ipairs(self.all_panels) do + if panel.context and IsKindOf(panel, "GedPropPanel") then + panel:UpdatePropertyNames(panel.ShowInternalNames) + end + end + end + self:CheckUpdateItemTexts(view) +end + +function GedApp:CheckUpdateItemTexts(view) + if view == "warnings_cache" or view == "dirty_objects" then + for _, panel in ipairs(self.all_panels) do + if panel.context then -- cached detached panels as property editors get recreated by GedPropPanel:RebuildControls + panel:UpdateItemTexts() + end + end + end +end + +function GedApp:SetTitle(title) + self.Title = title + self:OnContextUpdate(self.context, nil) +end + +function GedApp:AddPanel(context, panel) + self.all_panels = self.all_panels or {} + self.all_panels[#self.all_panels + 1] = panel + + if panel.Interactive and not panel.Embedded then + self.interactive_panels = self.interactive_panels or {} + if not self.interactive_panels[context] then + local focus_column = 1 + for _, panel in pairs(self.interactive_panels) do + focus_column = Max(focus_column, panel.focus_column + 1000) + end + panel.focus_column = focus_column + self.interactive_panels[context] = panel + end + end +end + +function GedApp:RemovePanel(panel) + table.remove_value(self.all_panels, panel) + for id, obj in pairs(self.interactive_panels or empty_table) do + if obj == panel then + self.interactive_panels[id] = nil + return + end + end +end + +function GedApp:SetSelection(panel_context, selection, multiple_selection, notify, restoring_state, focus) + local panel = self.interactive_panels[panel_context] + if not panel then return end + if selection and (notify or restoring_state) then + panel:CancelSearch("dont_select") + end + panel:SetSelection(selection, multiple_selection, notify, restoring_state) + if not restoring_state then + panel:SetPanelFocused() + end + if focus then + self.last_focused_panel = panel -- will suppress GedPanelBase:OnSetFocus() which binds the object, selected in the panel + self.last_focused_tree_or_list_panel = panel + self:ActionsUpdated() + panel.idContainer:SetFocus() + end +end + +function GedApp:SetSearchString(panel_context, search_string) + local panel = self.interactive_panels[panel_context] + if not panel then return end + + if not search_string then + panel:CancelSearch() + return + end + if panel.idSearchEdit then + panel.idSearchEdit:SetText(search_string) + panel:UpdateFilter() + end +end + +function GedApp:SelectSiblingsInFocusedPanel(selection, selected) + local panel = self.last_focused_panel + if panel then + local first_selected, all_selected = panel:GetSelection() + for _, idx in ipairs(selection) do + if selected then + table.insert_unique(all_selected, idx) + else + table.remove_value(all_selected, idx) + end + end + panel:SetSelection(first_selected, all_selected, not "notify") + end +end + +function GedApp:SetPropSelection(context, prop_list) -- list with id's to select + if not context or not self.interactive_panels[context] then return end + self.interactive_panels[context]:SetSelection(prop_list) +end + +function GedApp:SetLastFocusedPanel(panel) + if self.last_focused_panel ~= panel then + self.last_focused_panel = panel + if IsKindOfClasses(panel, "GedTreePanel", "GedListPanel") then + self.last_focused_tree_or_list_panel = panel + end + self:ActionsUpdated() + return true + end +end + +function GedApp:GetLastFocusedPanel() + return self.last_focused_panel +end + +function GedApp:GetState() + local state = {} + if self.interactive_panels and self.window_state ~= "destroying" then + for context, panel in pairs(self.interactive_panels) do + state[context] = panel:GetState() + end + end + state.focused_panel = self.last_focused_tree_or_list_panel and self.last_focused_tree_or_list_panel.context + return state +end + +function GedApp:OnMouseButtonDown(pt, button) + if button == "L" then + if self.last_focused_panel then + self.last_focused_panel:SetPanelFocused() + end + return "break" + end +end + +function GedApp:OnMouseWheelForward() + return "break" +end + +function GedApp:OnMouseWheelBack() + return "break" +end + +function GedApp:SetActionToggled(action_id, toggled) + self.actions_toggled[action_id] = toggled + self:ActionsUpdated() +end + +function GedApp:Op(op_name, obj, ...) + self.connection:Send("rfnOp", self:GetState(), op_name, obj, ...) +end + +function GedApp:OnSaving() + local focus = self.desktop.keyboard_focus + if focus then + local prop_editor = GetParentOfKind(focus, "GedPropEditor") + if prop_editor and not prop_editor.prop_meta.read_only then + prop_editor:SendValueToGame() + end + end +end + +function GedApp:Send(rfunc_name, ...) -- for calls that don't modify objects + self.connection:Send("rfnRunGlobal", rfunc_name, ...) +end + +function GedApp:Call(rfunc_name, ...) -- for calls that return a value + return self.connection:Call("rfnRunGlobal", rfunc_name, ...) +end + +function GedApp:InvokeMethod(obj_name, func_name, ...) + self.connection:Send("rfnInvokeMethod", obj_name, func_name, ...) +end + +function GedApp:InvokeMethodReturn(obj_name, func_name, ...) -- for calls that return a value + return self.connection:Call("rfnInvokeMethod", obj_name, func_name, ...) +end + +function GedApp:Undo() + self.connection:Send("rfnUndo") +end + +function GedApp:Redo() + self.connection:Send("rfnRedo") +end + +function GedApp:StoreAppState() + self.connection:Send("rfnStoreAppState", self:GetState()) +end + +function GedApp:SelectAndBindObj(name, obj_address, func_name, ...) + self.connection:Send("rfnSelectAndBindObj", name, obj_address, func_name, ...) +end + +function GedApp:SelectAndBindMultiObj(name, obj_address, all_indexes, func_name, ...) + self.connection:Send("rfnSelectAndBindMultiObj", name, obj_address, all_indexes, func_name, ...) +end + +function GedApp:DiscardEditorChanges() + self:Send("GedDiscardEditorChanges") +end + +function GedApp:GetGameError() + local error_text, error_time = self.connection:Call("rfnGetLastError") + return error_text, error_time and (error_time - self.game_real_time) +end + +function GedApp:ShowMessage(title, text) + StdMessageDialog:new({}, self.desktop, { title = title, text = text, dark_mode = self.dark_mode }):Open() +end + +function GedApp:WaitQuestion(title, text, ok_text, cancel_text) + local dialog = StdMessageDialog:new({}, self.desktop, { + title = title or "", + text = text or "", + ok_text = ok_text ~= "" and ok_text, + cancel_text = cancel_text ~= "" and cancel_text, + translate = false, + question = true, + dark_mode = self.dark_mode, + }) + dialog:Open() + + self.ui_questions = self.ui_questions or {} + table.insert(self.ui_questions, dialog) + + local result, win = dialog:Wait() + if self.ui_questions then + table.remove_value(self.ui_questions, dialog) + end + return result +end + +function GedApp:DeleteQuestion() -- cancel is already taken as the default "answer" + local question = self.ui_questions and self.ui_questions[1] + if question then + question:Close("delete") -- the waiting thread should remove it form the list + end +end + +function GedApp:WaitUserInput(title, default, items) + local dialog = StdInputDialog:new({}, self.desktop, { title = title, default = default, items = items, dark_mode = self.dark_mode }) + dialog:Open() + local result, win = dialog:Wait() + return result +end + +function GedApp:WaitListChoice(items, caption, start_selection, lines) + local dialog = StdInputDialog:new({}, terminal.desktop, { title = caption, default = start_selection, items = items, lines = lines } ) + dialog:Open() + local result, win = dialog:Wait() + return result +end + +function GedApp:SetUiStatus(id, text, delay) + local idx = table.find(self.ui_status, "id", id) or (#self.ui_status + 1) + if not text then + table.remove(self.ui_status, idx) + else + self.ui_status[idx] = { id = id, text = text } + end + self:UpdateUiStatus() + if delay then + self.ui_status_delay_time = RealTime() + delay + if not self.ui_status_delay_thread then + self.ui_status_delay_thread = CreateRealTimeThread(function() + while self.ui_status_delay_time - RealTime() > 0 do + Sleep(self.ui_status_delay_time - RealTime()) + end + self:SetUiStatus(id) + self.ui_status_delay_thread = nil + end) + end + end +end + +function GedApp:WaitBrowseDialog(folder, filter, create, multiple) + return OpenBrowseDialog(folder, filter or "", not not create, not not multiple) +end + +function GedApp:GedOpError(error_message) + if not self.blink_thread then + self.blink_thread = CreateRealTimeThread(function() + for i = 1, 3 do + self.blink_border_color = RGB(220, 0, 0) + self:Invalidate() + Sleep(50) + self.blink_border_color = RGBA(0, 0, 0, 0) + self:Invalidate() + Sleep(50) + end + self.blink_border_color = nil + self.blink_thread = nil + self:Invalidate() + end) + end + if type(error_message) == "string" and error_message ~= "error" and error_message ~= "" then + self:ShowMessage("Error", error_message) + end +end + +function GedApp:DrawChildren(clip_box) + XActionsHost.DrawChildren(self, clip_box) + if self.blink_border_color ~= RGBA(0, 0, 0, 0) then + local box = (self:GetLastFocusedPanel() or self).box + UIL.DrawBorderRect(box, 2, 2, self.blink_border_color, RGBA(0, 0, 0, 0)) + end +end + +function GedApp:GetDisplayedSearchResultData() + return self.display_search_result and self.search_value_results and self.search_value_results[self.search_result_idx] +end + +function GedApp:TryHighlightSearchMatchInChildPanels(parent_panel) + if not parent_panel.SelectionBind then return end + for bind in string.gmatch(parent_panel.SelectionBind .. ",", reCommaList) do + local bind_dot = bind .. "." + for _, panel in ipairs(self.all_panels) do + local context = panel.context + if panel.window_state ~= "destroying" and context and (context == bind or context:starts_with(bind_dot)) then + panel:TryHighlightSearchMatch() + end + end + end +end + +function GedApp:FocusProperty(panel_id, prop_id) + local panel = self.interactive_panels[panel_id] + local prop_editor = panel and panel:LocateEditorById(prop_id) + if prop_editor then + local focus = prop_editor:GetRelativeFocus(point(0, 0), "next") or prop_editor + focus:SetFocus() + end +end + +function GedApp:OpenContextMenu(action_context, anchor_pt) + if not action_context or not anchor_pt or action_context == "" then return end + local menu = XPopupMenu:new({ + ActionContextEntries = action_context, + Anchor = anchor_pt, + AnchorType = "mouse", + MaxItems = 12, + GetActionsHost = function() return self end, + popup_parent = self, + RebuildActions = function(menu, host) + XPopupMenu.RebuildActions(menu, host) + for _, entry in ipairs(menu.idContainer) do + if entry.action.OnActionEffect == "popup" then + XLabel:new({ + Dock = "right", + ZOrder = -1, + Margins = box(5, 0, 0, 0), + }, entry):SetText(">") + end + end + end, + }, terminal.desktop) + menu:Open() + return menu +end + +----- Dark Mode support + +function GetDarkModeSetting() + local setting = rawget(_G, "g_GedApp") and g_GedApp.dark_mode + if setting == "Follow system" then + return GetSystemDarkModeSetting() + else + return setting and setting ~= "Light" + end +end + +local menubar = RGB(64, 64, 64) +local l_menubar = RGB(255, 255, 255) + +local menu_selection = RGB(100, 100, 100) +local l_menu_selection = RGB(204, 232, 255) + +local toolbar = RGB(64, 64, 64) +local l_toolbar = RGB(255, 255, 255) + +local panel = RGB(42, 41, 41) +local panel_title = RGB(64, 64, 64) +local panel_background_tab = RGB(96, 96, 96) +local panel_rollovered_tab = RGB(110, 110, 110) +local panel_child = RGBA(0, 0, 0, 0) +local panel_focused_border = RGB(100, 100, 100) + +local l_panel = RGB(255, 255, 255) +local l_panel_title = RGB(220, 220, 220) +local l_panel_background_tab = RGB(196, 196, 196) +local l_panel_rollovered_tab = RGB(240, 240, 240) +local l_panel_child = RGBA(0, 0, 0, 0) +local l_panel_focused_border = RGB(0, 0, 0) + +local l_prop_button_focused = RGB(24, 123, 197) +local l_prop_button_rollover = RGB(24, 123, 197) +local l_prop_button_pressed = RGB(38, 146, 227) +local l_prop_button_disabled = RGB(128, 128, 128) +local l_prop_button_background = RGB(38, 146, 227) + +local prop_button_focused = RGB(193, 193, 193) +local prop_button_rollover = RGB(100, 100, 100) +local prop_button_pressed = RGB(105, 105, 105) +local prop_button_disabled = RGB(93, 93, 93) +local prop_button_background = RGB(105, 105, 105) + +local scroll = RGB(131, 131, 131) +local scroll_pressed = RGB(211, 211, 211) +local scroll_rollover = RGB(170, 170, 170) +local scroll_background = RGB(64, 64, 64) +local button_divider = RGB(100, 100, 100) + +local l_scroll = RGB(169, 169, 169) +local l_scroll_pressed = RGB(100, 100, 100) +local l_scroll_rollover = RGB(128, 128, 128) +local l_scroll_background = RGB(240, 240, 240) +local l_button_divider = RGB(169, 169, 169) + +local edit_box = RGB(54, 54, 54) +local edit_box_border = RGB(130, 130, 130) +local edit_box_focused = RGB(42, 41, 41) + +local l_edit_box = RGB(240, 240, 240) +local l_edit_box_border = RGB(128, 128, 128) +local l_edit_box_focused = RGB(255, 255, 255) + +local propitem_selection = RGB(20, 109, 171) +local subobject_selection = RGB(40, 50, 70) +local panel_item_selection = RGB(70, 70, 70) + +local l_propitem_selection = RGB(121, 189, 241) +local l_subobject_selection = RGB(204, 232, 255) +local l_panel_item_selection = RGB(204, 232, 255) + +local button_border = RGB(130, 130, 130) +local button_pressed_background = RGB(191, 191, 191) +local button_toggled_background = RGB(150, 150, 150) +local button_rollover = RGB(70, 70, 70) + +local l_button_border = RGB(240, 240, 240) +local l_button_pressed_background = RGB(121, 189, 241) +local l_button_toggled_background = RGB(35, 97, 171) +local l_button_rollover = RGB(204, 232, 255) + +local checkbox_color = RGB(128, 128, 128) +local checkbox_disabled_color = RGBA(128, 128, 128, 128) + +function GedApp:UpdateChildrenDarkMode(win) + if win.window_state ~= "destroying" then + self:UpdateControlDarkMode(win) + if not IsKindOfClasses(win, "XMenuBar", "XToolBar", "GedPanelBase", "GedPropSet") then + for _, child in ipairs(win or self) do + self:UpdateChildrenDarkMode(child) + end + end + end +end + +local function SetUpTextStyle(control, dark_mode) + local new_style = GetTextStyleInMode(rawget(control, "TextStyle"), dark_mode) + if new_style then + control:SetTextStyle(new_style) + elseif control.TextStyle:starts_with("GedDefault") then + control:SetTextStyle(dark_mode and "GedDefaultDark" or "GedDefault") + elseif control.TextStyle:starts_with("GedSmall") then + control:SetTextStyle(dark_mode and "GedSmallDark" or "GedSmall") + end +end + +local function SetUpDarkModeButton(button, dark_mode) + SetUpTextStyle(button, dark_mode) + button:SetBackground(dark_mode and prop_button_background or l_prop_button_background) + button:SetFocusedBackground(dark_mode and prop_button_focused or l_prop_button_focused) + button:SetRolloverBackground(dark_mode and prop_button_rollover or l_prop_button_rollover) + button:SetPressedBackground(dark_mode and prop_button_pressed or l_prop_button_pressed) + button:SetDisabledBackground(dark_mode and prop_button_disabled or l_prop_button_disabled) +end + +local function SetUpDarkModeSetItem(button, dark_mode) + SetUpTextStyle(button, dark_mode) + button:SetBackground(RGBA(0, 0, 0, 0)) + if dark_mode and not button:GetEnabled() then + button:SetToggledBackground(dark_mode and prop_button_disabled or l_propitem_selection) + button:SetDisabledBackground(dark_mode and RGBA(0, 0, 0, 0) or l_prop_button_disabled) + else + button:SetFocusedBackground(dark_mode and prop_button_focused or l_prop_button_focused) + button:SetPressedBackground(dark_mode and propitem_selection or l_propitem_selection) + button:SetToggledBackground(dark_mode and propitem_selection or l_propitem_selection) + button:SetDisabledBackground(dark_mode and prop_button_disabled or l_prop_button_disabled) + end +end + +local function SetUpIconButton(button, dark_mode) + button.idIcon:SetImageColor(dark_mode and RGB(210, 210, 210) or button.parent.Id == "idNumberEditor" and RGB(0, 0, 0) or nil) + button:SetBackground(RGBA(0, 0, 0, 1)) -- 1 is used as alpha because we don't touch control with color == 0 + if IsKindOf(button, "XCheckButton") then + button:SetBorderColor(RGBA(0, 0, 0, 0)) + button:SetIconColor(dark_mode and checkbox_color or RGB(0, 0, 0)) + button:SetDisabledIconColor(checkbox_disabled_color) + else + button:SetRolloverBorderColor(dark_mode and button_border or l_button_border) + button:SetRolloverBackground(dark_mode and button_rollover or l_button_rollover) + button:SetPressedBackground(dark_mode and button_pressed_background or l_button_pressed_background) + + if IsKindOf(button, "XToggleButton") and button:GetToggledBackground() == button:GetDefaultPropertyValue("ToggledBackground") then + button:SetToggledBackground(dark_mode and button_toggled_background or l_button_toggled_background) + end + + -- Set border color if it hasn't been manually set + local border_color = button:GetBorderColor() + local other_mode_border_color = not dark_mode and button_border or l_button_border + if border_color == button:GetDefaultPropertyValue("BorderColor") or border_color == other_mode_border_color then + button:SetBorderColor(dark_mode and button_border or l_button_border) + end + end +end + +function GedApp:UpdateControlDarkMode(control) + local dark_mode = self.dark_mode + local new_style = GetTextStyleInMode(rawget(control, "TextStyle"), dark_mode) + + if IsKindOf(control, "XRolloverWindow") and IsKindOf(control[1], "XText") then + control[1].invert_colors = true + end + if control.Id == "idPopupBackground" then + control:SetBackground(dark_mode and RGB(54, 54, 54) or RGB(240, 240, 240)) + end + if IsKindOf(control, "GedApp") then + control:SetBackground(dark_mode and button_divider or nil) + end + if IsKindOf(control, "GedPropEditor") then + control.SelectionBackground = dark_mode and panel_item_selection or l_panel_item_selection + end + if IsKindOf(control, "XList") then + control:SetBorderColor(dark_mode and edit_box_border or l_edit_box_border) + control:SetFocusedBorderColor(dark_mode and edit_box_border or l_edit_box_border) + control:SetBackground(dark_mode and panel or l_panel) + control:SetFocusedBackground(dark_mode and panel or l_panel) + end + if IsKindOf(control, "XListItem") then + local prop_editor = GetParentOfKind(control, "GedPropEditor") + if prop_editor and not IsKindOfClasses(prop_editor, "GedPropPrimitiveList", "GedPropEmbeddedObject") then + control:SetSelectionBackground(dark_mode and propitem_selection or l_propitem_selection) + else + control:SetSelectionBackground(dark_mode and panel_item_selection or l_panel_item_selection) + end + control:SetFocusedBorderColor(dark_mode and panel_focused_border or l_panel_focused_border) + end + + if IsKindOf(control, "XMenuBar") then + control:SetBackground(dark_mode and menubar or l_menubar) + for _, menu_item in ipairs(control) do + SetUpTextStyle(menu_item.idLabel, dark_mode) + menu_item:SetRolloverBackground(dark_mode and menu_selection or l_menu_selection) + menu_item:SetPressedBackground(dark_mode and menu_selection or l_menu_selection) + end + return + end + if IsKindOf(control, "ShortcutEditor") then + control:SetBackground(dark_mode and menubar or l_menubar) + control:SetFocusedBackground(dark_mode and menubar or l_menubar) + for _, win in ipairs(control.idContainer.idModifiers) do + if IsKindOf(win, "XToggleButton") then + SetUpDarkModeSetItem(win, dark_mode) + end + end + return + end + if IsKindOf(control, "XPopup") then + control:SetBackground(dark_mode and menubar or l_menubar) + control:SetFocusedBackground(dark_mode and menubar or l_menubar) + for _, entry in ipairs(control.idContainer) do + if entry:IsKindOf("XButton") then + entry:SetRolloverBackground(dark_mode and menu_selection or l_menu_selection) + end + end + return + end + if IsKindOf(control, "XToolBar") then + control:SetBackground(dark_mode and toolbar or l_toolbar) + for _, toolbar_item in ipairs(control) do + -- divider line betweeen action buttons + if not IsKindOf(toolbar_item, "XTextButton") and not IsKindOf(toolbar_item, "XToggleButton") then + toolbar_item:SetBackground(dark_mode and button_divider or l_button_divider) + else + SetUpIconButton(toolbar_item, dark_mode) + end + end + return + end + if IsKindOf(control, "GedPropSet") then + self:UpdateChildrenDarkMode(control.idLabelHost) + control.idContainer:SetBorderColor(dark_mode and edit_box_border or l_edit_box_border) + for _, win in ipairs(control.idContainer) do + if IsKindOf(win, "XToggleButton") then + SetUpDarkModeSetItem(win, dark_mode) + else + self:UpdateChildrenDarkMode(win) + end + end + return + end + if IsKindOf(control, "GedPropScript") then + control.idEditHost:SetBorderColor(dark_mode and edit_box_border or l_edit_box_border) + return + end + if IsKindOf(control, "XCurveEditor") then + control:SetCurveColor(dark_mode and l_scroll or nil) + control:SetControlPointColor(dark_mode and l_scroll or nil) + control:SetControlPointHoverColor(dark_mode and button_divider or nil) + control:SetControlPointCaptureColor(dark_mode and scroll_background or nil) + control:SetGridColor(dark_mode and scroll_background or nil) + return + end + if IsKindOf(control, "GedPanelBase") then + if control.Id == "idStatusBar" then + control:SetBackground(dark_mode and panel_title or l_panel_title) + else + control:SetBackground(dark_mode and panel or l_panel) + end + if control:ResolveId("idTitleContainer") then + control.idTitleContainer:SetBackground(dark_mode and panel_title or l_panel_title) + control.idTitleContainer:ResolveId("idTitle"):SetTextStyle(new_style or GetTextStyleInMode(control.Embedded and "GedDefault" or "GedTitleSmall", dark_mode)) + end + if control:ResolveId("idSearchResultsText") then + control:ResolveId("idSearchResultsText"):SetTextStyle(GetTextStyleInMode("GedDefault", dark_mode)) + end + for _, child_control in ipairs(control) do + if child_control.Id == "idContainer" then + if child_control:HasMember("TextStyle") then + SetUpTextStyle(child_control, dark_mode) + end + if child_control:HasMember("FocusedBackground") then + child_control:SetFocusedBackground(dark_mode and panel_child or l_panel_child) + end + if child_control:HasMember("SelectionBackground") then + child_control:SetSelectionBackground(dark_mode and panel_item_selection or l_panel_item_selection) + child_control:SetFocusedBorderColor(dark_mode and panel_focused_border or l_panel_focused_border) + end + child_control:SetBackground(dark_mode and panel_child or l_panel_child) + + if IsKindOfClasses(control, "GedPropPanel", "GedTreePanel") then + local noProps = control.idContainer:ResolveId("idNoPropsToShow") + if noProps then + SetUpTextStyle(noProps, dark_mode) + else + control:SetFocusedBackground(dark_mode and panel or l_panel) + local container = control.idContainer + for _, prop_win in ipairs(container) do + for _, prop_child in ipairs(prop_win) do + if IsKindOf(prop_child, "XTextButton") then + SetUpDarkModeButton(prop_child, dark_mode) + else + self:UpdateChildrenDarkMode(prop_child) + end + end + end + for _, control in ipairs(control.idTitleContainer) do + if IsKindOf(control, "XTextButton") then + SetUpIconButton(control, dark_mode) + end + end + end + elseif IsKindOf(control, "GedBreadcrumbPanel") then + local container = control.idContainer + for _, win in ipairs(container) do + if IsKindOf(win, "XButton") then + win:SetRolloverBackground(dark_mode and RGB(72, 72, 72) or l_button_rollover) + SetUpTextStyle(win[1], dark_mode) + end + end + elseif IsKindOf(control, "GedTextPanel") then + control.idContainer:SetTextStyle(new_style or GetTextStyleInMode("GedTextPanel", dark_mode)) + else + self:UpdateChildrenDarkMode(child_control) + end + elseif child_control.Id == "idTitleContainer" then + local search = control:ResolveId("idSearchContainer") + search:SetBackground(RGBA(0, 0, 0, 0)) + search:SetBorderColor(dark_mode and panel_focused_border or l_panel_focused_border) + + local edit = control:ResolveId("idSearchEdit") + self:UpdateEditControlDarkMode(edit, dark_mode) + edit:SetTextStyle(new_style or GetTextStyleInMode("GedDefault", dark_mode)) + edit:SetBackground(dark_mode and edit_box_focused or l_edit_box_focused) + edit:SetHintColor(dark_mode and RGBA(210, 210, 210, 128) or nil) + + SetUpIconButton(control:ResolveId("idToggleSearch"), dark_mode) + SetUpIconButton(control:ResolveId("idCancelSearch"), dark_mode) + SetUpDarkModeButton(control:ResolveId("idSearchHistory"), dark_mode) + + for _, tab_button in ipairs(control.idTabContainer) do + tab_button:SetToggledBackground(dark_mode and panel or l_panel) + tab_button:SetToggledBorderColor(dark_mode and panel or l_panel) + tab_button:SetBackground(dark_mode and panel_background_tab or l_panel_background_tab) + tab_button:SetPressedBackground(dark_mode and panel_rollovered_tab or l_panel_rollovered_tab) + tab_button:SetRolloverBackground(dark_mode and panel_rollovered_tab or l_panel_rollovered_tab) + tab_button:SetBorderColor(dark_mode and panel_title or l_panel_title) + tab_button:SetRolloverBorderColor(dark_mode and panel_title or l_panel_title) + tab_button:SetTextStyle(dark_mode and "GedButton" or "GedDefault") + end + elseif IsKindOf(child_control, "XSleekScroll") then + child_control.idThumb:SetBackground(dark_mode and scroll or l_scroll) + child_control:SetBackground(dark_mode and scroll_background or l_scroll_background) + elseif child_control.Id ~= "idViewErrors" and child_control.Id ~= "idPauseResume" then + self:UpdateChildrenDarkMode(child_control) + end + end + return + end + + if control.Id == "idPanelDockedButtons" then + control:SetBackground(dark_mode and panel_title or l_panel_title) + return + end + + if IsKindOf(control, "XRangeScroll") then + control:SetThumbBackground(dark_mode and scroll or l_scroll) + control:SetThumbRolloverBackground(dark_mode and scroll_rollover or l_scroll_rollover) + control:SetThumbPressedBackground(dark_mode and scroll_pressed or l_scroll_pressed) + control.idThumbLeft:SetBackground(dark_mode and scroll or l_scroll) + control.idThumbRight:SetBackground(dark_mode and scroll or l_scroll) + control:SetScrollColor(dark_mode and scroll or l_scroll) + control:SetBackground(dark_mode and scroll_background or l_scroll_background) + end + + if IsKindOf(control, "XTextButton") then + SetUpIconButton(control, dark_mode) + end + if IsKindOf(control, "XFontControl") then + if control.Id == "idWarningText" then + return + end + SetUpTextStyle(control, dark_mode) + end + if IsKindOf(control, "XCombo") or IsKindOf(control, "XCheckButtonCombo") then + control:SetBackground(dark_mode and edit_box or l_edit_box) + control:SetFocusedBackground(dark_mode and edit_box or l_edit_box) + control:SetBorderColor(dark_mode and edit_box_border or l_edit_box_border) + control:SetFocusedBorderColor(dark_mode and edit_box_border or l_edit_box_border) + if IsKindOf(control, "XCombo") and (control:GetListItemTemplate() == "XComboListItemDark" or control:GetListItemTemplate() == "XComboListItemLight") then + control:SetListItemTemplate(dark_mode and "XComboListItemDark" or "XComboListItemLight") + control.PopupBackground = dark_mode and panel or l_panel + end + end + if IsKindOf(control, "XComboButton") then + SetUpDarkModeButton(control, dark_mode) + end + if IsKindOf(control, "XScrollArea") and not IsKindOf(control, "XMultiLineEdit") and not IsKindOf(control, "XList") then + control:SetBackground(dark_mode and panel or l_panel) + end + if IsKindOf(control, "XMultiLineEdit") then + if GetParentOfKind(control, "GedMultiLinePanel") then + control:SetTextStyle(new_style or GetTextStyleInMode("GedMultiLine", dark_mode)) + else + control:SetTextStyle(new_style or GetTextStyleInMode("GedDefault", dark_mode)) + if control.parent.Id == "idEditHost" then + control.parent:SetBorderColor(dark_mode and edit_box_border or l_edit_box_border) + end + end + self:UpdateEditControlDarkMode(control, dark_mode) + end + if IsKindOf(control, "XEdit") then + control:SetTextStyle(new_style or GetTextStyleInMode("GedDefault", dark_mode)) + self:UpdateEditControlDarkMode(control, dark_mode) + end + if IsKindOf(control, "XTextEditor") then + control:SetHintColor(dark_mode and RGBA(210, 210, 210, 128) or nil) + end + if IsKindOf(control, "XSleekScroll") then + control.idThumb:SetBackground(dark_mode and scroll or l_scroll) + control:SetBackground(dark_mode and scroll_background or l_scroll_background) + end + if IsKindOf(control, "XToggleButton") and control.Id == "idInputListener" then + control:SetPressedBackground(dark_mode and propitem_selection or l_propitem_selection) + control:SetToggledBackground(dark_mode and propitem_selection or l_propitem_selection) + end +end + +function OnMsg.GedPropertyUpdated(property) + if IsKindOf(property, "GedPropSet") then + GetParentOfKind(property, "GedApp"):UpdateChildrenDarkMode(property) + end +end + + +----- GedBindView + +DefineClass.GedBindView = { + __parents = { "XContextWindow" }, + Dock = "ignore", + visible = false, + properties = { + { category = "General", id = "BindView", editor = "text", default = "", }, + { category = "General", id = "BindRoot", editor = "text", default = "", }, + { category = "General", id = "BindField", editor = "text", default = "", }, + { category = "General", id = "BindFunc", editor = "text", default = "", }, + { category = "General", id = "ControlId", editor = "text", default = "", }, + { category = "General", id = "GetBindParams", editor = "expression", params = "self, control", }, + { category = "General", id = "OnViewChanged", editor = "func", params = "self, value, control", }, + }, + MinWidth = 0, + MinHeight = 0, +} + +function GedBindView:Open(...) + XContextWindow.Open(self, ...) + self.app = GetParentOfKind(self.parent, "GedApp") +end + +function GedBindView:Done() + local connection = self.app and self.app.connection + if connection then + connection:UnbindObj(self.context .. "|" .. self.BindView) + end +end + +function GedBindView:GetBindParams(control) +end + +function GedBindView:OnContextUpdate(context, view) + self.app = self.app or GetParentOfKind(self.parent, "GedApp") + local connection = self.app and self.app.connection + if not connection then return end + if not view and self.BindView ~= "" then -- obj changed + local path = self.BindRoot == "" and context or self.BindRoot + if self.BindField ~= "" then + path = {path, self.BindField} + end + connection:BindObj(context .. "|" .. self.BindView, path, self.BindFunc ~= "" and self.BindFunc, self:GetBindParams(self:ResolveId(self.ControlId))) + end + if view == self.BindView or (self.BindView == "") then -- view changed + local name = self.BindView ~= "" and context .. "|" .. self.BindView or context + local value = connection.bound_objects[name] + self:OnViewChanged(value, self:ResolveId(self.ControlId)) + end +end + +function GedBindView:OnViewChanged(value, control) +end + +function RebuildSubItemsActions(panel, actions_def, default_submenu, toolbar, menubar) + local host = GetActionsHost(panel) + local actions = host:GetActions() + for i = #(actions or ""), 1, -1 do + local action = actions[i] + if action.ActionId:starts_with("NewItemEntry_") or action.ActionId:starts_with("NewSubitemMenu_") then + host:RemoveAction(action) + end + end + + if type(actions_def) == "table" and #actions_def > 0 then + local submenus = {} + for _, def in ipairs(actions_def) do + local submenu = def.EditorSubmenu or default_submenu + if submenu ~= "" then + submenus[submenu] = true + XAction:new({ + ActionId = "NewItemEntry_" .. def.Class, + ActionMenubar = "NewSubitemMenu_" .. submenu, + ActionToolbar = def.EditorIcon and toolbar, + ActionIcon = def.EditorIcon, + ActionName = def.EditorName or def.Class, + ActionTranslate = false, + ActionShortcut = def.EditorShortcut, + OnActionParam = def.Class, + OnAction = function(self, host, source) + if panel:IsKindOf("GedTreePanel") then + host:Op("GedOpTreeNewItemInContainer", panel.context, panel:GetSelection(), self.OnActionParam) + else + host:Op("GedOpListNewItem", panel.context, panel:GetSelection(), self.OnActionParam) + end + end, + }, host) + end + end + + for submenu in sorted_pairs(submenus) do + XAction:new({ + ActionId = "NewSubitemMenu_" .. submenu, + ActionMenubar = menubar, + ActionName = submenu, + ActionTranslate = false, + ActionSortKey = "2", + OnActionEffect = "popup", + ActionContexts = { "ContentPanelAction", "ContentRootPanelAction", "ContentChildPanelAction" }, + }, host) + end + end +end \ No newline at end of file diff --git a/CommonLua/Ged/GedCreateSubItemsPopup.lua b/CommonLua/Ged/GedCreateSubItemsPopup.lua new file mode 100644 index 0000000000000000000000000000000000000000..1043f6b4dc21b6c23cc91d0961d02d0ec5da42b7 --- /dev/null +++ b/CommonLua/Ged/GedCreateSubItemsPopup.lua @@ -0,0 +1,294 @@ +local function XPopupWindowWithSearch(anchor, title, create_children_func) + local popup = OpenDialog("GedNestedElementsList", terminal.desktop) + popup.OnShortcut = function(self, shortcut) + if shortcut == "Escape" then + popup:Close() + return "break" + elseif shortcut == "Down" then + popup.idLeftList.idWin.idList:SetFocus() + end + end + + local list = popup.idLeftList.idWin.idList + popup.idTitle:SetText(title) + local edit = popup.idLeftList.idSearch + local container = popup.idRightList + + edit.OnTextChanged = function(edit) + create_children_func(popup, list, container, edit:GetText()) + end + + edit.OnShortcut = function(edit, shortcut, source, ...) + if shortcut == "Escape" then + return + elseif shortcut == "Enter" and edit:GetText() then + local list = edit.parent.idWin.idList + if list[1] and list[1]:IsKindOf("XTextButton") then + list[1]:OnPress() + return + end + end + return XEdit.OnShortcut(edit, shortcut, source, ...) + end + + list.OnShortcut = function(list, shortcut, source, ...) + local relation = XShortcutToRelation[shortcut] + if shortcut == "Down" or shortcut == "Up" or relation == "down" or relation == "up" then + local focus = list.desktop.keyboard_focus + local order = focus and focus:GetFocusOrder() + if shortcut == "Down" or relation == "down" then + focus = list:GetRelativeFocus(order or point(0, 0), "next") + else + focus = list:GetRelativeFocus(order or point(1000000000, 1000000000), "prev") + end + if focus then + list:ScrollIntoView(focus) + focus:SetFocus() + end + return "break" + end + end + + create_children_func(popup, list, container, "", "create") + popup:SetModal(true) + edit:SetFocus() +end + +local function XPopupListWithSearch(anchor, create_children_func) + local popup = XPopup:new({}, terminal.desktop) + popup.OnKillFocus = function(self) + if self.window_state == "open" then + popup:Close() + end + end + popup.OnShortcut = function(self, shortcut) + if shortcut == "Escape" then + popup:Close() + return "break" + elseif shortcut == "Down" then + popup.idPopupList:SetFocus() + end + end + local list = XPopupList:new({ + Id = "idPopupList", + AutoFocus = false, + Dock = "bottom", + MaxItems = 10, + BorderWidth = 0, + min_width = false, + OnShortcut = function(list, shortcut, source, ...) -- let the popup handle escapes + if shortcut == "Escape" then + return + end + return XPopupList.OnShortcut(list, shortcut, source, ...) + end, + Measure = function(self, max_width, max_height) -- do not fold the search popup after items have been filtered + local _, height + if not self.min_width then + self.min_width, height = XPopupList.Measure(self, max_width, max_height) + else + _, height = XPopupList.Measure(self, max_width, max_height) + end + return self.min_width, height + end, + OnKillFocus = function(self, new_focus) end, + }, popup) + + local edit = XEdit:new({ + Dock = "top", + Id = "idSearch", + Margins = box(2, 2, 2, 2), + OnTextChanged = function(edit) + create_children_func(popup, list.idContainer, edit:GetText()) + end, + OnShortcut = function(list, shortcut, source, ...) + if shortcut == "Escape" then + return + end + return XEdit.OnShortcut(list, shortcut, source, ...) + end, + }, popup) + + popup:SetAnchor(anchor.box) + popup:SetAnchorType("drop-right") + popup:SetScaleModifier(anchor.scale) + popup:SetOutsideScale(point(1000, 1000)) + popup:Open() + + create_children_func(popup, list.idContainer, "") + if #list.idContainer > 5 then + edit:SetFocus() + else + edit:delete() + popup:SetFocus() + end +end + +function FillUsageSegments(list, segments) + local sum = 0 + local sorted = {} + for _, item in ipairs(list) do + if item.use_count then + sum = sum + item.use_count + table.insert(sorted, item) + end + end + + table.sortby_field(sorted, "use_count") + + local tally, target, segment = 0, 0, 0 + for _, item in ipairs(sorted) do + tally = tally + item.use_count + if tally > target then + segment = segment + 1 + target = MulDivRound(sum, segment, segments) + end + item.usage_segment = segment + end + return sum +end + +function GedOpenCreateItemPopup(panel, title, items, button, create_callback) + if items and #items == 1 then + create_callback(items[1].value) + return + end + + local defined = 0 + for i = 1, #items do + local cat = items[i].category + if cat and cat ~= "" and cat ~= "General" then + defined = defined + 1 + end + end + if defined >= 3 then + XPopupWindowWithSearch(button, title, function(popup, list, container, search_text, create) + local least_dim = GetDarkModeSetting() and 255 or 32 + local most_dim = GetDarkModeSetting() and 128 or 140 + local modifiable_zone = most_dim - least_dim + + local suffixes = { + "", + "•", + "••", + "•••", + } + local uses_total = FillUsageSegments(items, 3) + + local create_button = function(idx, item, container, popup, right) + local entry = XTextButton:new({ UseXTextControl = true }, container) + entry:SetFocusOrder(point(1, idx)) + entry:SetLayoutMethod("Box") + entry.idLabel:SetHAlign("stretch") + if right and search_text == "" and uses_total ~= 0 then + local gamma = most_dim - MulDivRound(modifiable_zone, item.usage_segment, 3) + entry:SetText(string.format("%s%s", gamma, gamma, gamma, item.text, suffixes[item.usage_segment + 1])) + elseif right and item.usage_segment then + entry:SetText(item.text .. suffixes[item.usage_segment + 1]) + else + entry:SetText(item.text) + end + entry.OnPress = function(entry) + button:SetFocus() + create_callback(item.value) + button:SetFocus(false) + if popup.window_state ~= "destroying" then + popup:Close() + end + end + + if item.documentation or item.use_count then + local texts = {} + table.insert(texts, item.documentation or string.format("", item.value)) + if item.use_count_in_preset then + table.insert(texts, item.use_count and string.format(" " + end + if prop_meta.scale_name then + suffix = " ("..prop_meta.scale_name..")" + end + if self.highlight_search_match then + prop_name = GedPanelBase.MatchMark .. prop_name + end + + local rollover + local editor = prop_meta.editor + if self.panel.ShowUnusedPropertyWarnings and not PlaceholderProperties[editor] then + local prop_stats = self.panel:Obj("root|prop_stats") + if prop_stats and prop_stats[prop_meta.id] then + local used_in = prop_stats[prop_meta.id] + if used_in == 0 then + prefix = " " .. prefix + rollover = " to disable snapping)", + }, + UsesCodeRenderables = true, + + start_pos = false, + guides = false, + prg_applied = false, + old_guides_hash = false, +} + +function XCreateGuidesTool:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Vertical" then + self:SetPrg("") + end +end + +function XCreateGuidesTool:Done() + if self.start_pos then -- tool destroyed while dragging + ResumePassEdits("XCreateGuidesTool") + self:CreateGuides(0) + end +end + +function XCreateGuidesTool:CreateGuides(count) + self.guides = self.guides or {} + + local guides = self.guides + if count == #guides then return end + + for i = 1, Max(count, #guides) do + if i <= count and not guides[i] then + guides[i] = EditorLineGuide:new() + guides[i]:SetOpacity(self:GetPrg() == "" and 100 or 0) + elseif i > count and guides[i] then + DoneObject(guides[i]) + guides[i] = nil + end + end +end + +function XCreateGuidesTool:UpdateGuides(pt_min, pt_max) + local x1, y1 = pt_min:xy() + local x2, y2 = pt_max:xy() + local z = Max( + terrain.GetHeight(point(x1, y1)), + terrain.GetHeight(point(x1, y2)), + terrain.GetHeight(point(x2, y1)), + terrain.GetHeight(point(x2, y2)) + ) + if x1 == x2 then + local count = y1 == y2 and 0 or 1 + self:CreateGuides(count) + if count == 0 then return end + + local pos, lookat = GetCamera() + local dot = Dot(SetLen((lookat - pos):SetZ(0), 4096), axis_x) + self.guides[1]:Set(point(x1, y1, z), point(x1, y2, z), dot > 0 and -axis_x or axis_x) + elseif y1 == y2 then + self:CreateGuides(1) + + local pos, lookat = GetCamera() + local dot = Dot(SetLen((lookat - pos):SetZ(0), 4096), axis_y) + self.guides[1]:Set(point(x1, y1, z), point(x2, y1, z), dot > 0 and -axis_y or axis_y) + else + self:CreateGuides(4) + self.guides[1]:Set(point(x1, y1, z), point(x1, y2, z), -axis_x) + self.guides[2]:Set(point(x2, y1, z), point(x2, y2, z), axis_x) + self.guides[3]:Set(point(x1, y1, z), point(x2, y1, z), -axis_y) + self.guides[4]:Set(point(x1, y2, z), point(x2, y2, z), axis_y) + end +end + +function XCreateGuidesTool:GetGuidesHash() + local hash = 42 + for _, guide in ipairs(self.guides or empty_table) do + hash = xxhash(hash, guide:GetPos1(), guide:GetPos2(), guide:GetNormal()) + end + return hash +end + +function XCreateGuidesTool:ApplyPrg() + local hash = self:GetGuidesHash() + if self:GetPrg() ~= "" and hash ~= self.old_guides_hash and self.guides and #self.guides ~= 0 then + if self.prg_applied then + XEditorUndo:UndoRedo("undo") + end + + -- create copies of the guides and apple the Prg to them (some Prgs change the guides) + local guides = {} + for _, guide in ipairs(self.guides) do + local g = EditorLineGuide:new() + g:Set(guide:GetPos1(), guide:GetPos2(), guide:GetNormal()) + guides[#guides + 1] = g + end + GenExtras(self:GetPrg(), guides) + for _, guide in ipairs(guides) do + DoneObject(guide) + end + + self.old_guides_hash = hash + self.prg_applied = true + end +end + +function XCreateGuidesTool:OnMouseButtonDown(pt, button) + if button == "L" then + self.start_pos = GetTerrainCursor() + self.snapping = self:GetSnapping() and not terminal.IsKeyPressed(const.vkControl) + if self.snapping then + self.start_pos = snap_to_voxel_grid(self.start_pos) + end + self.desktop:SetMouseCapture(self) + SuspendPassEdits("XCreateGuidesTool") + return "break" + end + return XEditorTool.OnMouseButtonDown(self, pt, button) +end + +local function MinMaxPtXY(f, p1, p2) + return point(f(p1:x(), p2:x()), f(p1:y(), p2:y())) +end + +function XCreateGuidesTool:OnMousePos(pt, button) + local start_pos = self.start_pos + if start_pos then + if self:GetVertical() then + local eye, lookat = GetCamera() + local cursor = ScreenToGame(pt) + + -- vertical plane parallel to screen + local pt1, pt2, pt3 = start_pos, start_pos + axis_z, start_pos + SetLen(Cross(lookat - eye, axis_z), 4096) + local intersection = IntersectRayPlane(eye, cursor, pt1, pt2, pt3) + intersection = ProjectPointOnLine(pt1, pt2, intersection) + intersection = self.snapping and snap_to_voxel_grid(intersection) or intersection + if start_pos ~= intersection then + local angle = CalcSignedAngleBetween2D(axis_x, eye - lookat) + local axis = Rotate(axis_x, CardinalDirection(angle)) + if start_pos:Dist(intersection) > guim / 2 then + self:CreateGuides(1) + self.guides[1]:Set(start_pos, intersection, axis) + end + end + self:ApplyPrg() + return "break" + end + + local pt_new = GetTerrainCursor() + if self.snapping then + pt_new = snap_to_voxel_grid(pt_new) + else + if abs(pt_new:x() - start_pos:x()) < guim / 2 then pt_new = pt_new:SetX(start_pos:x()) end + if abs(pt_new:y() - start_pos:y()) < guim / 2 then pt_new = pt_new:SetY(start_pos:y()) end + end + local pt_min = MinMaxPtXY(Min, pt_new, start_pos) + local pt_max = MinMaxPtXY(Max, pt_new, start_pos) + self:UpdateGuides(pt_min, pt_max) + self:ApplyPrg() + return "break" + end + return XEditorTool.OnMousePos(self, pt, button) +end + +function XCreateGuidesTool:OnMouseButtonUp(pt, button) + local start_pos = self.start_pos + if start_pos then + if self.prg_applied then + self:CreateGuides(0) + elseif self.guides and #self.guides > 1 then + XEditorUndo:BeginOp{ name = "Created guides" } + local collection = Collection.Create() + for _, obj in ipairs(self.guides) do + obj:SetCollection(collection) + end + editor.ChangeSelWithUndoRedo(self.guides) + XEditorUndo:EndOp(table.iappend(self.guides, { collection })) + end + + self.desktop:SetMouseCapture() + self.start_pos = nil + self.prg_applied = nil + self.guides = nil + ResumePassEdits("XCreateGuidesTool") + return "break" + end + return XEditorTool.OnMouseButtonUp(self, pt, button) +end diff --git a/CommonLua/Libs/Volumes/Editor/XCreateRoomTool.lua b/CommonLua/Libs/Volumes/Editor/XCreateRoomTool.lua new file mode 100644 index 0000000000000000000000000000000000000000..92bdf26e50632d44274184f59871f95ac4e87acb --- /dev/null +++ b/CommonLua/Libs/Volumes/Editor/XCreateRoomTool.lua @@ -0,0 +1,140 @@ +if not const.SlabSizeX then return end + +DefineClass.XCreateRoomTool = { + __parents = { "XEditorTool" }, + properties = { + { id = "RoofOnly", name = "Roof only", editor = "bool", default = false, persisted_setting = true, }, + }, + + ToolTitle = "Create Room", + Description = { + "(drag to place a room)\n" .. + "( to force placing a roof only)", + }, + UsesCodeRenderables = true, + + room = false, + room_terrain_z = 0, + start_pos = false, + vxs = 0, + vys = 0, +} + +function XCreateRoomTool:Done() + if self.room then -- tool destroyed while dragging + DoneObject(self.room) + end +end + +function XCreateRoomTool_PlaceRoom(props) + return PlaceObject("Room", props) +end + +function XCreateRoomTool:OnMouseButtonDown(pt, button) + if button == "L" then + local startPos = GetTerrainCursor() + local is_roof = self:GetRoofOnly() or terminal.IsKeyPressed(const.vkControl) + local props = { floor = 1, position = SnapVolumePos(startPos), size = point(1, 1, is_roof and 0 or defaultVolumeVoxelHeight), + auto_add_in_editor = false, wireframe_visible = true, being_placed = true } + local room = XCreateRoomTool_PlaceRoom(props) + local gz = room:LockToCurrentTerrainZ() + room:InternalAlignObj("test") + if room:CheckCollision() then + room.wireframeColor = RGB(255, 0, 0) + else + room.wireframeColor = RGB(0, 255, 0) + end + room:GenerateGeometry() + + self.room = room + self.start_pos = startPos + self.vxs, self.vys = WorldToVoxel(startPos) + self.room_terrain_z = gz + self.desktop:SetMouseCapture(self) + return "break" + end + return XEditorTool.OnMouseButtonDown(self, pt, button) +end + +local function MinMaxPtXY(f, p1, p2) + return point(f(p1:x(), p2:x()), f(p1:y(), p2:y())) +end + +function XCreateRoomTool:OnMousePos(pt, button) + local room = self.room + if room then + local pNew = GetTerrainCursor() + local pMin = MinMaxPtXY(Min, pNew, self.start_pos) + local pMax = MinMaxPtXY(Max, pNew, self.start_pos) + local change = false + local moved = false + pMin = pMin:SetZ(terrain.GetHeight(pMin)) + pMax = pMax:SetZ(terrain.GetHeight(pMax)) + pMin = SnapVolumePos(pMin) + + local vxMin, vyMin = WorldToVoxel(pMin) + local vxMax, vyMax = WorldToVoxel(pMax) + local pos = room.position + + if pos:x() ~= pMin:x() or pos:y() ~= pMin:y() then + moved = pMin - (pos or pMin) + moved = moved:SetZ(0) + rawset(room, "position", pMin:SetZ(self.room_terrain_z)) + change = true + end + + local xSize = Max((pMax:x() - pMin:x()) / const.SlabSizeX, 1) + local ySize = Max((pMax:y() - pMin:y()) / const.SlabSizeY, 1) + + if vxMin ~= self.vxs or vxMax ~= self.vxs then + xSize = xSize + 1 + end + if vyMin ~= self.vys or vyMax ~= self.vys then + ySize = ySize + 1 + end + + local newSize = point(xSize, ySize, room.size:z()) + local oldSize = room.size + if room.size ~= newSize then + rawset(room, "size", newSize) + change = true + end + + if change then + room:InternalAlignObj("test") + if room:CheckCollision() then + room.wireframeColor = RGB(255, 0, 0) + else + room.wireframeColor = RGB(0, 255, 0) + end + room:GenerateGeometry() + end + return "break" + end + return XEditorTool.OnMousePos(self, pt, button) +end + +function XCreateRoomTool:OnMouseButtonUp(pt, button) + local room = self.room + if room then + XEditorUndo:BeginOp{ name = "Created room" } + + room.wireframeColor = nil + room.wireframe_visible = false + room:OnSetwireframe_visible() + room.being_placed = false + room:AddInEditor() + if room.wall_mat ~= const.SlabNoMaterial or room.floor_mat ~= const.SlabNoMaterial then + room:CreateAllSlabs() + end + room:FinishAlign() + SetSelectedVolume(room) + BuildBuildingsData() + + XEditorUndo:EndOp{ room } + self.desktop:SetMouseCapture() + self.room = nil + return "break" + end + return XEditorTool.OnMouseButtonUp(self, pt, button) +end diff --git a/CommonLua/Libs/Volumes/Editor/XStickToWallsFloorsHelper.lua b/CommonLua/Libs/Volumes/Editor/XStickToWallsFloorsHelper.lua new file mode 100644 index 0000000000000000000000000000000000000000..10d73b84a54c03acec89708f8f4530c38e78a3d3 --- /dev/null +++ b/CommonLua/Libs/Volumes/Editor/XStickToWallsFloorsHelper.lua @@ -0,0 +1,137 @@ +DefineClass.XStickToObjsBase = { + __parents = { "XObjectPlacementHelper" }, + + InXPlaceObjectTool = true, + InXSelectObjectsTool = true, + AllowRotationAfterPlacement = true, + HasSnapSetting = true, + + pivot_obj_id = false, + intersection_class = "WallSlab", + set_angle = true, +} + +function XStickToObjsBase:StartMoveObjects(mouse_pos, objects) + local cur_pos = GetTerrainCursor() + local bestIp = false + local bestId = -1 + local eye = camera.GetEye() + local cursor = ScreenToGame(mouse_pos) + local sp = eye + local ep = (cursor - eye) * 1000 + cursor + self.init_move_positions = {} + self.init_orientations = {} + for i, o in ipairs(objects) do + local hisPos = o:GetPos() + if not hisPos:IsValid() then + o:SetPos(cur_pos) + hisPos = cur_pos + end + self.init_move_positions[i] = hisPos + self.init_orientations[i] = { o:GetOrientation() } + + local ip1, ip2 = ClipSegmentWithBox3D(sp, ep, o) + if ip1 and (bestId == -1 or IsCloser(sp, ip1, bestIp)) then + bestIp = ip1 + bestId = i + end + end + + if bestId == -1 then + bestId = 1 --that sux, no obj crossed the ray from camera + end + + self.pivot_obj_id = bestId + self.init_drag_position = objects[bestId]:GetPos():SetTerrainZ() +end + +function XStickToObjsBase:MoveObjects(mouse_pos, objects) + local vMove = (GetTerrainCursor() - self.init_drag_position):SetZ(0) + for i, obj in ipairs(objects) do + local offset = self.init_move_positions[i] - self.init_move_positions[self.pivot_obj_id] + if not offset:z() then + offset = offset:SetZ(0) + end + local eye = camera.GetEye() + local cursor = ScreenToGame(mouse_pos) + local sp = eye + offset + local ep = (cursor - eye) * 1000 + cursor + offset + local objs = IntersectObjectsOnSegment(sp, ep, 0, self.intersection_class, function(o) + if not o.isVisible then + return rawget(o, "wall_obj") == obj + end + return true + end) + --[[DbgClear() + print(eye) + DbgAddVector(eye, cursor - eye, RGB(255, 0, 0)) + DbgAddVector(sp, ep - sp) + if objs and objs[1] then + DbgAddBox(objs[1]:GetObjectBBox()) + end]] + + if objs and #objs > 0 then + local closest = objs[1] + local p1, p2 = ClipSegmentWithBox3D(sp, ep, closest) + local ip = closest:GetPos() + if p1 then + ip = p1 == sp and p2 or p1 + end + local angle = closest:GetAngle() + local x, y, z + if XEditorSettings:GetSnapEnabled() then + x, y, z, angle = WallWorldToVoxel(ip:x(), ip:y(), ip:z(), angle) + x, y, z = WallVoxelToWorld(x, y, z, angle) + else + x, y, z = ip:xyz() + end + if IsKindOf(obj, "AlignedObj") then + obj:AlignObj(point(x, y, z), self.set_angle and angle or nil) + else + if self.set_angle then + obj:SetPosAngle(x, y, z, angle) + else + obj:SetPos(x, y, z) + end + end + + else + local pos = (self.init_move_positions[i] + vMove):SetTerrainZ() + if IsKindOf(obj, "AlignedObj") then + obj:AlignObj(pos) + else + obj:SetPos(pos) + end + end + + end + Msg("EditorCallback", "EditorCallbackMove", objects) +end + +DefineClass.XStickToWallsHelper = { + __parents = { "XStickToObjsBase" }, + + Title = "Stick to walls (Y)", + Description = false, + ActionSortKey = "6", + ActionIcon = "CommonAssets/UI/Editor/Tools/StickToWall.tga", + ActionShortcut = "Y", + UndoOpName = "Stuck %d object(s) to wall", + + intersection_class = "WallSlab", + set_angle = true, +} + +DefineClass.XStickToFloorsHelper = { + __parents = { "XStickToObjsBase" }, + + Title = "Stick to floors (U)", + Description = false, + ActionSortKey = "7", + ActionIcon = "CommonAssets/UI/Editor/Tools/StickToFloor.tga", + ActionShortcut = "U", + UndoOpName = "Stuck %d object(s) to floor", + + intersection_class = "FloorSlab", + set_angle = false, +} diff --git a/CommonLua/Libs/Volumes/Editor/__load.lua b/CommonLua/Libs/Volumes/Editor/__load.lua new file mode 100644 index 0000000000000000000000000000000000000000..716a9d19e9bcac83253fd0c85e074f3bcd55e50d --- /dev/null +++ b/CommonLua/Libs/Volumes/Editor/__load.lua @@ -0,0 +1,7 @@ +if Platform.editor then + for _, file in ipairs(io.listfiles("CommonLua/Libs/Volumes/Editor")) do + if not file:ends_with("__load.lua") then + dofile(file) + end + end +end diff --git a/CommonLua/Libs/Volumes/Editor/editor.lua b/CommonLua/Libs/Volumes/Editor/editor.lua new file mode 100644 index 0000000000000000000000000000000000000000..6dc2a364c106236cf0149284756bbe17e8767766 --- /dev/null +++ b/CommonLua/Libs/Volumes/Editor/editor.lua @@ -0,0 +1,149 @@ +local function FindHighestRoof(obj) + local pos = obj:GetVisualPos() + local highest_room, highest_z, highest_pt_dir = false, 0, 0 + MapForEach(pos, roomQueryRadius, "Room", function(room, pos) + if not IsPointInVolume2D(room, pos) then return end + + local z, dir = room:GetRoofZAndDir(pos) + if not IsKindOf(obj, "Decal") then + local thickness = room:GetRoofThickness() + z = z + thickness + end + + if z < highest_z then return end + + highest_z = z + highest_room = room + highest_pt_dir = dir + end, pos) + + return highest_room, highest_z, highest_pt_dir +end + +function editor.ToggleDontHideWithRoom() + local sel = editor:GetSel() + if #sel < 1 then + return + end + + XEditorUndo:BeginOp{ objects = sel, "Set hide with room" } + local set = 0 + local cleared = 0 + for i = 1, #sel do + local obj = sel[i] + if obj:GetGameFlags(const.gofDontHideWithRoom) == 0 then + obj:SetDontHideWithRoom(true) + set = set + 1 + else + obj:SetDontHideWithRoom(false) + cleared = cleared + 1 + end + end + + print("Set flag to " .. tostring(set) .. " objects.") + print("Cleared flag from " .. tostring(cleared) .. " objects.") + XEditorUndo:EndOp(sel) +end + +function editor.ClearRoofFlags() + local sel = editor:GetSel() + if #sel < 1 then + return + end + + XEditorUndo:BeginOp{ objects = sel, name = "Set roof OFF" } + + for i = 1, #sel do + local obj = sel[i] + + obj:ClearHierarchyGameFlags(const.gofOnRoof) + end + print("CLEARED objects' roof flags") + XEditorUndo:EndOp(sel) +end + +function editor.SnapToRoof() + local sel = editor:GetSel() + if #sel < 1 then + return + end + + XEditorUndo:BeginOp{ objects = sel, name = "Set roof ON" } + local counter = 0 + + for i = 1, #sel do + local obj = sel[i] + + if obj:GetGameFlags(const.gofOnRoof) == 0 then + counter = counter + 1 + obj:SetHierarchyGameFlags(const.gofOnRoof) + end + end + + if counter > 0 then + print(tostring(counter) .. " objects MARKED as OnRoof objects out of " .. tostring(#sel)) + XEditorUndo:EndOp(sel) + return + end + + SuspendPassEditsForEditOp() + local new_position = { } + local new_up = { } + + for i = 1, #sel do + local obj = sel[i] + + local highest_roof = FindHighestRoof(obj) + if highest_roof then + local target_pos, target_up = highest_roof:SnapObject(obj) + new_position[obj] = target_pos + new_up[obj] = target_up + counter = counter + 1 + end + end + print(tostring(counter) .. " objects SNAPPED to roof out of " .. tostring(#sel)) + --editor callbacks + local objects = {} + local cfEditorCallback = const.cfEditorCallback + for i = 1, #sel do + if sel[i]:GetClassFlags(cfEditorCallback) ~= 0 then + objects[#objects + 1] = sel[i] + end + end + if #objects > 0 then + Msg("EditorCallback", "EditorCallbackMove", objects) + end + + XEditorUndo:EndOp(sel) + ResumePassEditsForEditOp() + + for i = 1, #sel do + local obj = sel[i] + + local pos = obj:GetPos() + local wrong_pos = new_position[obj] and new_position[obj] ~= pos + + local wrong_angle + local up = RotateAxis(point(0, 0, 4096), obj:GetAxis(), obj:GetAngle()) + if new_up[obj] then + local axis, angle = GetAxisAngle(new_up[obj], up) + wrong_angle = (angle > 0) + end + + local obj_name = IsKindOf(obj, "CObject") and obj:GetEntity() or obj.class + local aligned_obj = IsKindOf(obj, "AlignedObj") + + if wrong_pos then + local err = "not snapped to the roof Z" + if aligned_obj then + err = err .. " (AlignedObj)" + end + print(obj_name, err) + end + + if wrong_angle then + local err = "not aligned with the roof slope" + print(obj_name, err) + end + end +end diff --git a/CommonLua/Libs/Volumes/LuaExportedDocs/Volume.lua b/CommonLua/Libs/Volumes/LuaExportedDocs/Volume.lua new file mode 100644 index 0000000000000000000000000000000000000000..9986c12cf0a2f292831b545233f44ea4ad144872 --- /dev/null +++ b/CommonLua/Libs/Volumes/LuaExportedDocs/Volume.lua @@ -0,0 +1,28 @@ +-- File created to fill the Hearald's auto-complete list. + +function EncodeVoxelPos(x, y, z) +end + +function DecodeVoxelPos(pos) +end + +function SnapToVoxel(x, y, z) +end + +function ForEachVoxelInBox2D(box, func, ...) +end + +function EnumVolumes(class, area, sync, smallest, filter, ...) +end + +function GetClosestVolume(pos, max_dist, volumes, efAnd) +end + +function GetDistToVolumeSqr(pos, volume) +end + +function IsPointInVolume(volume, pos) +end + +function ForEachVolumeVoxel(volume, callback, ...) +end \ No newline at end of file diff --git a/CommonLua/Libs/Volumes/LuaExportedDocs/__load.lua b/CommonLua/Libs/Volumes/LuaExportedDocs/__load.lua new file mode 100644 index 0000000000000000000000000000000000000000..1b7522974de5a021c779a62cf8db31cacb65c391 --- /dev/null +++ b/CommonLua/Libs/Volumes/LuaExportedDocs/__load.lua @@ -0,0 +1 @@ +-- supress automatic loading when mounted in the game \ No newline at end of file diff --git a/CommonLua/Libs/Volumes/RoomRoof.lua b/CommonLua/Libs/Volumes/RoomRoof.lua new file mode 100644 index 0000000000000000000000000000000000000000..849cc37ae96028bf4fe34869b800433942ed23c7 --- /dev/null +++ b/CommonLua/Libs/Volumes/RoomRoof.lua @@ -0,0 +1,2593 @@ +if FirstLoad then + RoofVisualsEnabled = true + GableRoofDirections = { "North-South", "East-West" } +end + +local voxelSizeX = const.SlabSizeX or 0 +local voxelSizeY = const.SlabSizeY or 0 +local voxelSizeZ = const.SlabSizeZ or 0 +local halfVoxelSizeX = voxelSizeX / 2 +local halfVoxelSizeY = voxelSizeY / 2 +local halfVoxelSizeZ = voxelSizeZ / 2 +local InvalidZ = const.InvalidZ +local noneWallMat = const.SlabNoMaterial + +CardinalDirectionNames = { "East", "South", "West", "North" } +local cardinal_direction_names = CardinalDirectionNames +local cardinal_directions = { + 0 * 60, 90 * 60, 180 * 60, 270 * 60, + East = 0 * 60, + South = 90 * 60, + West = 180 * 60, + North = 270 * 60, +} +local cardinal_steps = {point(voxelSizeX, 0, 0), point(0, voxelSizeY, 0), point(-voxelSizeX, 0, 0), point(0, -voxelSizeY, 0)} +local cardinal_offsets = {{voxelSizeX, 0, 0}, {0, voxelSizeY, 90 * 60}, {-voxelSizeX, 0, 180 * 60}, {0, -voxelSizeY, 270 * 60}} + +function GetCardinalOffsets() + return cardinal_offsets +end + +DefineClass.SkewAlign = { + __parents = { "CObject" }, + flags = { cfSkewAlign = true, }, +} + +DefineClass.RoomRoof = { + __parents = { "PropertyObject" }, + properties = { + { category = "Roof", id = "roof_type", name = "Roof Type", editor = "preset_id", default = "", preset_class = "RoofTypes" }, + { category = "Roof", id = "roof_mat", name = "Roof Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "RoofSlabMaterials", default = "" }, + { category = "Roof", id = "roof_parapet", name = "Roof Parapet", editor = "bool", default = false }, + { category = "Roof", id = "roof_direction", name = "Roof Direction", editor = "dropdownlist", + items = function(obj) return obj:IsAnyOfRoofTypes("Gable") and GableRoofDirections or cardinal_direction_names end, default = "North", + no_edit = function(obj) return not obj:IsAnyOfRoofTypes("Shed", "Gable") end }, + { category = "Roof", id = "roof_inclination", name = "Roof Inclination", editor = "number", scale = "deg", default = 20 * 60, min = 0*60, max = 45*60, slider = true, + no_edit = function(obj) return not obj:IsAnyOfRoofTypes("Shed", "Gable") end }, + { category = "Roof", id = "roof_additional_height", name = "Additional Height", editor = "number", scale = "m", default = 0, min = 0, max = const.SlabSizeZ, slider = true }, + { category = "Roof", name = "Has Ceiling", id = "build_ceiling", editor = "bool", default = true, }, + { category = "Roof", name = "Ceiling Material", id = "ceiling_mat", editor = "preset_id", preset_class = "SlabPreset", preset_group = "FloorSlabMaterials", extra_item = noneWallMat, default = noneWallMat, }, + { category = "Roof", id = "roof_colors", name = "Roof Color Modifier", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, + + { category = "Not Room Specific", id = "roofVisualsEnabled", name = "Toggle Roof Visuals", default = true, editor = "bool", dont_save = true }, + + { id = "roof_objs", no_edit = true, editor = "objects", default = false, }, + { category = "Debug", id = "is_roof_visible", editor = "bool", default = true, dont_save = true, read_only = true }, + + { id = "RoofInclinationIP", name = T(770322265919, "Roof Inclination"), editor = "number", default = 0, no_edit = true, dont_save = true, min = 0*60, max = 45*60 }, + + { category = "Roof", id = "keep_roof_passable", name = "Keep Roof Passable", editor = "bool", default = false, }, + { category = "Roof", id = "roof_buttons", name = "Buttons", editor = "buttons", default = false, dont_save = true, read_only = true, + buttons = { + {name = "Make Roof Passable", func = "MakeRoofPassable"}, + }, + }, + + { id = "vfx_roof_surface_controllers", editor = "objects", default = false, dont_save = true, no_edit = true}, + { id = "vfx_eaves_controllers", editor = "objects", default = false, dont_save = true, no_edit = true}, + }, + + roof_box = false, + + prop_map = false, + box_at_last_roof_edit = false, + rr_recursing = false, +} + +function RoomRoof:GetRoofInclinationIP() + return self.roof_inclination +end + +function RebuildChimneysInBox2D() + --print("stub") +end + +function RoomRoof:SetRoofInclinationIP(roof_inclination) + if self.roof_inclination ~= roof_inclination then + NetSyncEvent("ObjFunc", self, "rfnSetRoofInclination", roof_inclination) + end +end + +function RoomRoof:rfnSetRoofInclination(roof_inclination) + if self.roof_inclination ~= roof_inclination then + self.roof_inclination = roof_inclination + self:RecreateRoof() + ObjModified(self) + end +end + +function RoomRoof:GetRoofSize() + return self.size +end + +function RoomRoof:GetRoofType() + return self.roof_type +end + +function RoomRoof:GetRoofEntitySet() + local roof_mat = self.roof_mat or "" + if roof_mat == "" then return end + local preset = Presets.SlabPreset.RoofSlabMaterials[roof_mat] + return preset and preset.EntitySet or "" +end + +function RoomRoof:GetRoofInclination() + if self:GetRoofType() == "Flat" then + return 0 + end + + return self.roof_inclination +end + +function RoomRoof:HasRoofSet() + return self.roof_mat ~= "" and self.roof_type ~= "" +end + +function RoomRoof:HasRoomAbove() + local bbox = self.box:grow(const.SlabSizeX, const.SlabSizeY, const.SlabSizeZ) + local volumes = EnumVolumes(bbox, function(volume) + if volume.floor == self.floor + 1 then + return true + end + end) + + return volumes and #volumes > 0 +end + +function RoomRoof:HasRoof(scan_rooms_above) + if self:HasRoofSet() then + if self:IsRoofOnly() then + return true + end + local mrz = select(3, self:GetPosXYZ()) + self.size:z() * voxelSizeZ + local biggestRoom = self:GetBiggestEncompassingRoom(function(o, mrz) + local rz = select(3, o:GetPosXYZ()) + o.size:z() * voxelSizeZ + if rz < mrz then + return false + elseif not o:HasRoof() then + return false + end + return true + end, mrz) + if biggestRoom ~= self then + return scan_rooms_above and self:HasRoomAbove() + end + + local adjacent_rooms = self.adjacent_rooms + local sizex, sizey = self.box:sizexyz() + local boxes + local totalFace = 0 + local myFace = sizex * sizey + + for _, room in ipairs(adjacent_rooms) do + if not room.being_placed then + local data = adjacent_rooms[room] + local ib = data[1] + if ib:sizez() == 0 and table.find(data[2], "Roof") then --he is above us + local szx, szy = ib:sizexyz() + local hisFace = szx * szy + boxes = boxes or {} + for i = 1, #boxes do + local dx, dy = IntersectSize(ib, boxes[i]) + if dx > 0 and dy > 0 then + hisFace = hisFace - dx * dy + end + end + table.insert(boxes, ib) + totalFace = totalFace + hisFace + end + end + end + if myFace - totalFace <= 0 then + return scan_rooms_above and self:HasRoomAbove() --completely covered by upper nbrs + end + + return true + end + + return scan_rooms_above and self:HasRoomAbove() +end + +function RoomRoof:IsAnyOfRoofTypes(...) + local roof_type = self:GetRoofType() + for i=1,select("#", ...) do + if roof_type == select(i, ...) then + return true + end + end +end + +function RoomRoof:GetRoofThickness() + local entity_set = self:GetRoofEntitySet() + if entity_set == "" then return 0 end + + local entity_name = string.format("Roof_%s_Plane_01", entity_set) + if not IsValidEntity(entity_name) then return 0 end + + local bbox = GetEntityBoundingBox(entity_name) + return bbox:sizez() +end + +function RoomRoof:SetRoofVisibility(visible) + local objs = self.roof_objs + if not objs or #objs <= 0 then return end + if self.is_roof_visible == visible then return end + + self.is_roof_visible = visible + local hide = not visible + + for i=1,#objs do + local o = objs[i] + if IsValid(o) then + o:SetShadowOnly(hide) + end + end + local wire_supporters = {} + assert(self.roof_box, string.format("Room %s with .roof_objs (count:%d), but no roof_box!", self.name, #objs)) + if g_Classes.WireSupporter and self.roof_box then --this does not exist in bacon, apparently the old code never found any objs on roofs in bacon so it never asserted. + local query_box = self.roof_box:grow(1) + local min_z = query_box:minz() + + MapForEach(query_box, "WireSupporter", function(o, min_z, wire_supporters) + local x, y, z = o:GetVisualPosXYZ() + local is_on_roof = o:GetGameFlags(const.gofOnRoof) ~= 0 + if z >= min_z and is_on_roof then + table.insert(wire_supporters, o) + end + end, min_z, wire_supporters) + end + + ForEachConnectedWire(wire_supporters, function(wire) + wire:SetVisible(visible) + end) + + for side, t in sorted_pairs(self.spawned_windows or empty_table) do + for i = 1, #(t or "") do + local w = t[i] + if w.hide_with_wall and IsKindOf(w.main_wall, "RoofWallSlab") then + w:SetShadowOnly(hide) + end + end + end + + if hide then + (rawget(_G, "CollectionsToHideHideCollections") or empty_func)(self, "Roof") + else + (rawget(_G, "CollectionsToHideShowCollections") or empty_func)(self, "Roof") + end + + self:SetVfxControllersVisibility(visible) +end + +function RoomRoof:DeleteRoofObjs() + for _, roof in ipairs(self.roof_objs) do + if IsValid(roof) then + DoneObject(roof) + end + end + self.roof_objs = nil +end + +function RoomRoof:PostProcessPlaneSlab(slab, is_flat) + if is_flat then + slab:ClearEnumFlags(const.efInclinedSlab) + slab:SetEnumFlags(const.efApplyToGrids) + elseif self:GetRoofInclination() <= const.MaxPassableTerrainSlope then + slab:SetEnumFlags(const.efApplyToGrids | const.efInclinedSlab) + else + slab:ClearEnumFlags(const.efApplyToGrids | const.efInclinedSlab) + end +end + +local function IsRoofTile(o) --roof tile or part of walls + return not IsKindOfClasses(o, "RoofWallSlab", "RoofCornerWallSlab", "CeilingSlab", "DestroyableWallDecoration") +end + +local slabPoint = point(voxelSizeX, voxelSizeY, voxelSizeZ) +local function GetElHashId(o, pivots) + pivots = pivots or o.room:GetPivots() + + if not IsRoofTile(o) then + local s = o.side + local dp = (o:GetPos() - pivots[s]) + local x, y, z = abs(dp:x()) / voxelSizeX, abs(dp:y()) / voxelSizeY, abs(dp:z()) / voxelSizeZ + --print(pivots[s], o:GetPos(), dp, s, o.class, x, y, z, o.room:GetRoofZAndDir(o.room.box:min())) + return xxhash(s, x, y, z, IsKindOf(o, "RoomCorner") or false) + else + local dp = (o:GetPos() - pivots[false]) + --print(pivots[false], o:GetPos(), dp, false, o.class, 0, 0, 0, o.room:GetRoofZAndDir(o.room.box:min())) + return xxhash(dp:x(), dp:y(), o:GetAngle(), o.class, rawget(o, "dir") or false) + end +end + +function RoomRoof:GetWallBeginPos(side, box) + local b = box or self.box + if side == "North" then + return point(b:minx(), b:miny(), b:maxz()) + elseif side == "South" then + return point(b:maxx(), b:maxy(), b:maxz()) + elseif side == "West" then + return point(b:minx(), b:maxy(), b:maxz()) + else --east + return point(b:maxx(), b:miny(), b:maxz()) + end +end + +function RoomRoof:GetPivots(box) + box = box or self.box + local pivots = {} + for _, side in ipairs(cardinal_direction_names) do + pivots[side] = self:GetWallBeginPos(side, box) + end + + pivots[false] = pivots.North --pivot for roof tiles + return pivots +end + +function RoomRoof:CleanupPropMap() + self.prop_map = false +end + +function RoomRoof:ApplyPropsFromPropObj(o, prop_map, pivots) + prop_map = prop_map or self.prop_map + pivots = pivots or self:GetPivots() + if prop_map then + local id = GetElHashId(o, pivots) + local po = prop_map[id] + if po then + o:CopyProperties(po, po:GetProperties()) + --else + --print("not found!", id) + end + end +end + +function RoomRoof:PopulatePropMap() + self:CleanupPropMap() + self.prop_map = {} + + local pivots = self:GetPivots(self.box_at_last_roof_edit or self.box) + + for i = 1, #(self.roof_objs or "") do + local o = self.roof_objs[i] + if IsValid(o) and not IsKindOfClasses(o, "CeilingSlab") then + local id = GetElHashId(o, pivots) + --assert(self.prop_map[id] == nil) --in rooms of size 1 tile we cannot guarantee unique ids for all elements + local propO = SlabPropHolder:new() + propO:CopyProperties(o) + self.prop_map[id] = propO + end + end + self.prop_map["minz"] = self.box_at_last_roof_edit and self.box_at_last_roof_edit:maxz() or self.box:maxz() --"minz" is used as roof box minz, which is box:maxz + self.box_at_last_roof_edit = self.box +end + +local visibilityStateForNewRoofPieces = true +function RoomRoof:RecreateRoof(force) + SuspendPassEdits("RoomRoof") + self:PopulatePropMap() + self:DeleteRoofObjs() + + if self:HasRoofSet() and (force or self:HasRoof()) then + visibilityStateForNewRoofPieces = self.is_roof_visible and RoofVisualsEnabled and (not IsEditorActive() or LocalStorage.FilteredCategories["Roofs"]) + local roof_type = self:GetRoofType() + local method_name = string.format("Create%sRoof", roof_type) + local method = self[method_name] + self.roof_objs, self.roof_box = method(self) + if self.build_ceiling then + self:CreateCeiling(self.roof_objs) + end + self:SnapObjects() + end + + visibilityStateForNewRoofPieces = true + + self:UpdateRoofSlabVisibility() + if not self.is_roof_visible then + self.is_roof_visible = true --force setter + self:SetRoofVisibility(false) + end + + if not RoofVisualsEnabled then + self:SetroofVisualsEnabledForRoom(false) + end + + RebuildChimneysInBox2D(self.box) + + if self.keep_roof_passable and not self.rr_recursing then + --sub optimal, dobule recreate, should be fine for f3 only. + self.rr_recursing = true + self:MakeRoofPassable() + self.rr_recursing = false + end + + ResumePassEdits("RoomRoof") +end + +local up = point(0, 0, 4096) +local ptx = point(4096, 0, 0) +local pty = point(0, 4096, 0) +function RoomRoof:SnapObject(obj) + obj:SetGameFlags(const.gofOnRoof) + local skew = obj:GetClassFlags(const.cfSkewAlign) ~= 0 + + --snap to roof pos + local pos = obj:GetVisualPos() + local roof_z, roof_dir = self:GetRoofZAndDir(pos) + if not IsKindOf(obj, "Decal") then + local thickness = self:GetRoofThickness() + roof_z = roof_z + thickness + end + local target_pos = pos:SetZ(roof_z) + obj:SetPos(target_pos) + + --roof forward vector + local roof_fwd_y, roof_fwd_x = sincos(roof_dir) + local roof_fwd_z = sin(self:GetRoofInclination()) + local roof_forward = SetLen(point(roof_fwd_x, roof_fwd_y, roof_fwd_z), 4096) + + --object target up vector + local target_up + if skew then + target_up = up + else + local roof_right = SetLen(point(-roof_fwd_y, roof_fwd_x, 0), 4096) + local roof_up = Cross(roof_forward, roof_right) / 4096 + target_up = roof_up + end + + --rotate object (align with target up) + local obj_axis = obj:GetAxis() + local obj_angle = obj:GetAngle() + local obj_up = RotateAxis(point(0, 0, 4096), obj_axis, obj_angle) + + if obj_up ~= target_up then + local axis, angle = GetAxisAngle(obj_up, target_up) + local axis, angle = ComposeRotation(obj_axis, obj_angle, axis, angle) + obj:SetAxisAngle(axis, angle) + end + + --skew object + if skew then + --determine roof 2D forward vector + local roof_forward_2d = SetLen(roof_forward:SetZ(0), 4096) + + --determine object-local forward/right vectors + local obj_axis = obj:GetAxis() + local obj_angle = obj:GetAngle() + local obj_x = RotateAxis(ptx, obj_axis, obj_angle) + local obj_y = RotateAxis(pty, obj_axis, obj_angle) + + --calculate skew + local dot_x = Dot(roof_forward_2d, obj_x) / 4096 + local dot_y = Dot(roof_forward_2d, obj_y) / 4096 + + local skew_x = MulDivRound(roof_fwd_z, dot_x*guim, 4096*4096) + local skew_y = MulDivRound(roof_fwd_z, dot_y*guim, 4096*4096) + obj:SetSkew(skew_x, skew_y) + else + obj:SetSkew(0, 0) + end + + return target_pos, target_up +end + +function RoomRoof:GetSkewAtPos(pos) + local ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs = self:GetRoofCoordSystem(pos) + local skew_x, skew_y = MulDivRound(-fz, guim, abs(fx + rx)), 0 + + return skew_x, skew_y +end + +function RoomRoof:SnapObjects() + if not self:HasRoof() then return end + + local above_rooms = MapGet(self:GetPos(), roomQueryRadius, "Room", function(room, my_maxz) + return room.box and room.box:maxz() > my_maxz + end, self.box:maxz()) + local min_z = self.prop_map and self.prop_map["minz"] or self.roof_box:minz() + + MapForEach( + self.roof_box, --area + "CObject", --class + false, --enum flags (all) + false, --enum flags (any) + false, --game flags (all) + const.gofOnRoof, --game flags (any) + false, --class flags (all) + false, --class flags (any) + function(obj, above_rooms, min_z) --action + local x, y, z = obj:GetVisualPosXYZ() + if z < min_z then return end + for _, room in ipairs(above_rooms) do + if IsPointInVolume2D(room, obj) then + return + end + end + self:SnapObject(obj) + end, + above_rooms, min_z) +end + +function RoomRoof:RecalcRoof() + if not self.roof_objs then return end + + local roof_type = self:GetRoofType() + local method_name = string.format("Recalc%sRoof", roof_type) + local method = self[method_name] + method(self) +end + +function RoomRoof:GetRoofCoordSystem(pt) + if not self:HasRoof() then return end + + local roof_type = self:GetRoofType() + local method_name = string.format("Get%sRoofCoordSystem", roof_type) + local method = self[method_name] + return method(self, pt) +end + +function RoomRoof:GetRoofClippingPlane(pt) + local ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs = self:GetRoofCoordSystem(pt) + local p1, p2, p3 = point(ox, oy, oz), + point(ox + rx*rs, oy + ry*rs, oz + rz*rs), + point(ox + fx*fs, oy + fy*fs, oz + fz*fs) + return PlaneFromPoints(p1, p2, p3) +end + +local eastPt, southPt, westPt, northPt = point(4096,0,0), point(0,4096,0), point(-4096,0,0), point(0,-4096,0) +function RoomRoof:GetRoofZAndDir(pt) + if not self:HasRoof() then + return InvalidPos():z(), 0 + end + + local ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs = self:GetRoofCoordSystem(pt) + local z = CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, pt:xy()) + + local dir = cardinal_directions[self.roof_direction] + if not dir then + --non-standard roof direction + --estimate using dot products + local fwd = point(fx, fy, fz) + local dots = { Dot(fwd, eastPt), Dot(fwd, southPt), Dot(fwd, westPt), Dot(fwd, northPt) } + local max = Max(table.unpack(dots)) + local idx = table.find(dots, max) + dir = cardinal_directions[idx] + end + + return z, dir +end + +---- roof creation + +function RoomRoof:UpdateRoofSlabVisibility() + if self.roof_box and self:GetGameFlags(const.gofPermanent) ~= 0 then + --DbgAddBox(self.roof_box) + ComputeSlabVisibilityInBox(self.roof_box) + end +end + +-- +--+ +-- | | +function RoomRoof:GetFlatRoofParams() + local px, py, pz = WorldToVoxel(self.position) + local sx, sy, sz = self.size:xyz() + local voxel_box = box(px, py, pz, px + sx, py + sy, pz + sz) + + return + px, py, pz, + sx, sy, sz, + voxel_box +end + +function RoomRoof:GetFlatRoofCoordSystem(pt) + local + px, py, pz, + sx, sy, sz, + voxel_box = self:GetFlatRoofParams() + return SolveRoofCoordSystem(voxel_box, self.roof_additional_height, self.roof_direction, self:GetRoofInclination()) +end + +function RoomRoof:CreateFlatRoof() + local + px, py, pz, + sx, sy, sz, + voxel_box = self:GetFlatRoofParams() + return self:CreateRoof("Flat", voxel_box, self.roof_direction, self.roof_parapet) +end + +function RoomRoof:RecalcFlatRoof() + local + px, py, pz, + sx, sy, sz, + voxel_box = self:GetFlatRoofParams() + self.roof_box = self:RecalcRoof_Generic("Flat", voxel_box, self.roof_direction, self.roof_parapet) +end + +-- /| +-- / | +function RoomRoof:GetShedRoofParams() + local px, py, pz = WorldToVoxel(self.position) + local sx, sy, sz = self.size:xyz() + local voxel_box = box(px, py, pz, px + sx, py + sy, pz + sz) + + return + px, py, pz, + sx, sy, sz, + voxel_box +end + +function RoomRoof:GetShedRoofCoordSystem(pt) + local + px, py, pz, + sx, sy, sz, + voxel_box = self:GetShedRoofParams() + return SolveRoofCoordSystem(voxel_box, self.roof_additional_height, self.roof_direction, self:GetRoofInclination()) +end + +function RoomRoof:CreateShedRoof() + local + px, py, pz, + sx, sy, sz, + voxel_box = self:GetShedRoofParams() + return self:CreateRoof("Shed", voxel_box, self.roof_direction, self.roof_parapet) +end + +function RoomRoof:RecalcShedRoof() + local + px, py, pz, + sx, sy, sz, + voxel_box = self:GetShedRoofParams() + self.roof_box = self:RecalcRoof_Generic("Shed", voxel_box, self.roof_direction, self.roof_parapet) +end + +-- /\ +-- / \ +function RoomRoof:GetGableRoofBoxes() + local px, py, pz = WorldToVoxel(self.position) + local sx, sy, sz = self.size:xyz() + + local dir1, dir2 + local box1, box2 + local odd + + if self.roof_direction == "East-West" or self.roof_direction == "East" or self.roof_direction == "West" then + dir1 = "South" + dir2 = "North" + box1 = box(px, py, pz, px + sx, py + sy/2, pz + sz) + box2 = box(px, py + sy/2, pz, px + sx, py + sy, pz + sz) + odd = (sy % 2) == 1 + if odd then + box1 = box1:grow(0, 0, 0, 1) + end + else -- North-South + dir1 = "East" + dir2 = "West" + box1 = box(px, py, pz, px + sx/2, py + sy, pz + sz) + box2 = box(px + sx/2, py, pz, px + sx, py + sy, pz + sz) + odd = (sx % 2) == 1 + if odd then + box1 = box1:grow(0, 0, 1, 0) + end + end + + return + dir1, dir2, + box1, box2, + odd +end + +function RoomRoof:GetGableRoofCoordSystem(pt) + local dir1, dir2, box1, box2, odd = self:GetGableRoofBoxes() + local bx, by, bz = box1:size():xyz() + local sx, sy, sz = self.size:xyz() + local dx, dy = 0, 0 + --when gable roof is odd, box1 and box2 are 1/2 voxel larger in the direction of inclination + --in other words, the topmost voxel is shared by both boxes and there are two clipped tiles on top of each other + --it is technically correct, however in that voxel we would always snap as if we are in box1, whereas we should + --snap as if in box2 when in the second half of said voxel + if bx < sx and bx * 2 > sx then + --even x, the box should be half a voxel smaller on the x side + dx = voxelSizeX / 2 + end + if by < sy and by * 2 > sy then + --even y + dy = voxelSizeY / 2 + end + + local minx, miny, minz, maxx, maxy, maxz = BoxVoxelToWorld(box1) + minx, miny, minz, maxx, maxy, maxz = minx - 1, miny - 1, minz - 1, maxx + 1 - dx, maxy + 1 - dy, maxz + 1 + local world_box1 = box(minx, miny, minz, maxx, maxy, maxz) + local in_box1 = pt:InBox2D(world_box1) + local box = in_box1 and box1 or box2 + local dir = in_box1 and dir1 or dir2 + + return SolveRoofCoordSystem(box, self.roof_additional_height, dir, self:GetRoofInclination()) +end + +function RoomRoof:CreateGableRoof() + local dir1, dir2, box1, box2, odd = self:GetGableRoofBoxes() + + local objs1, box1 = self:CreateRoof("Gable", box1, dir1, self.roof_parapet, odd and "odd_gable_short") + local objs2, box2 = self:CreateRoof("Gable", box2, dir2, self.roof_parapet, odd and "odd_gable_long") + + local px, py, pz = WorldToVoxel(self.position) + local sx, sy, sz = self.size:xyz() + local full_box = box(px, py, pz, px + sx, py + sy, pz + sz) --in voxels + local objs3 = self:CreateRoof_GableCaps(full_box, dir1, self.roof_parapet) + + local objs = { } + table.iappend(objs, objs1 or empty_table) + table.iappend(objs, objs2 or empty_table) + table.iappend(objs, objs3 or empty_table) + + local box = AddRects(box1, box2) + return objs, box +end + +function RoomRoof:RecalcGableRoof() + local dir1, dir2, box1, box2, odd = self:GetGableRoofBoxes() + + local roof_box1 = self:RecalcRoof_Generic("Gable", box1, dir1, self.roof_parapet, odd and "odd_gable_short") + local roof_box2 = self:RecalcRoof_Generic("Gable", box2, dir2, self.roof_parapet, odd and "odd_gable_long") + self.roof_box = AddRects(roof_box1, roof_box2) +end + +---- generic roof creation + +function BoxVoxelToWorld(voxel_box) + local minx, miny, minz, maxx, maxy, maxz = voxel_box:xyzxyz() + + minx, miny, minz = VoxelToWorld(minx, miny, minz) + minx, miny, minz = minx - halfVoxelSizeX, miny - halfVoxelSizeY, minz + + maxx, maxy, maxz = VoxelToWorld(maxx, maxy, maxz) + maxx, maxy, maxz = maxx - halfVoxelSizeX, maxy - halfVoxelSizeY, maxz + + return minx, miny, minz, maxx, maxy, maxz +end + +--calculates vector for roof local coordinate system +function SolveRoofCoordSystem(voxel_box, z_offset, direction, inclination) + local ox, oy, oz --origin x, y, z + local fx, fy, fz, fs --forward x, y, z, size + local rx, ry, rz, rs --right x, y, z, size + + --center (in 2D world space) + local minx, miny, minz, maxx, maxy, maxz = BoxVoxelToWorld(voxel_box) + local cx, cy, cz = + (minx + maxx) / 2, + (miny + maxy) / 2, + maxz + z_offset + + --DbgAddBox(box(minx,miny,minz,maxx,maxy,maxz)) + + local angle = cardinal_directions[direction] --roof slope direction + local sin, cos = sincos(angle) + sin, cos = sin / 4096, cos / 4096 + + --rotate from world space to local space + local function rotate_vector(x, y, z) + return x * cos - y * sin, x * sin + y * cos, z + end + + --forward/right sizes + inclination + local inclination_size + if direction == "North" or direction == "South" then + rs, fs = voxel_box:sizexyz() + inclination_size = const.SlabSizeX + else + fs, rs = voxel_box:sizexyz() + inclination_size = const.SlabSizeY + end + + --forward inclination per 1 voxel + local incl_sin, incl_cos = sincos(inclination) + local incl_tan = MulDivRound(incl_sin, 4096, incl_cos) + local voxel_incline = MulDivRound(inclination_size, incl_tan, 4096) + + --forward/right vectors + local fx, fy, fz = rotate_vector(const.SlabSizeX, 0, voxel_incline) + local rx, ry, rz = rotate_vector(0, const.SlabSizeY, 0) + + --origin + ox, oy, oz = + cx - MulDivRound(fs, fx, 2) - MulDivRound(rs, rx, 2), + cy - MulDivRound(fs, fy, 2) - MulDivRound(rs, ry, 2), + cz + + --DbgAddVector(point(ox, oy, oz), point(0, 0, const.SlabSizeZ), const.clrGreen) + --DbgAddVector(point(ox, oy, oz), point(fx, fy, fz), const.clrBlue) + --DbgAddVector(point(ox, oy, oz), point(rx, ry, rz), const.clrRed) + + return + ox, oy, oz, --origin x, y, z + fx, fy, fz, fs, --forward x, y, z, size + rx, ry, rz, rs --right x, y, z, size +end + +local function RoofCoordSystemBBox(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, additional_height) + local minx, miny, minz = + ox, + oy, + oz - additional_height + + local maxx, maxy, maxz = + ox + fs*fx + rs*rx + 1, + oy + fs*fy + rs*ry + 1, + oz + fs*fz + rs*rz + 1 + + minx, maxx = Min(minx, maxx), Max(minx, maxx) + miny, maxy = Min(miny, maxy), Max(miny, maxy) + minz, maxz = Min(minz, maxz), Max(minz, maxz) + + return box( + minx, miny, minz, + maxx, maxy, maxz) +end + +--calculates position (x, y, z) of roof corner +local function SolveRoofCornerPosition(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, side) + if side == "Front" then + return + ox + fx*fs, + oy + fy*fs, + oz + fz*fs + elseif side == "Right" then + return + ox + fx*fs + rx*rs, + oy + fy*fs + ry*rs, + oz + fz*fs + rz*rs + elseif side == "Back" then + return + ox + rx*rs, + oy + ry*rs, + oz + rz*rs + elseif side == "Left" then + return ox, oy, oz + else + assert(false, "Invalid side of roof corner") + return ox, oy, oz + end +end + +--calculates vectors for roof edge coordinate system +local function SolveRoofEdgeCoordSystem(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, side) + local front_or_back = (side == "Front" or side == "Back") + + local dx, dy, dz --delta + if front_or_back then + dx, dy, dz = rx, ry, rz + else + dx, dy, dz = fx, fy, fz + end + + local ds = front_or_back and rs or fs --delta size + + --base x, y, z + local bx, by, bz + if side == "Left" then + bx, by, bz = ox, oy, oz + elseif side == "Front" then + bx, by, bz = ox + fx*fs, oy + fy*fs, oz + fz*fs + elseif side == "Right" then + bx, by, bz = ox + rx*rs, oy + ry*rs, oz + rz*rs + elseif side == "Back" then + bx, by, bz = ox, oy, oz + end + + return + bx, by, bz, --base x, y, z + dx, dy, dz, ds --delta x, y, z, size +end + +local function GetGableClipPlane(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs) + local p1, p2 = + point(ox + (fs-1)*fx + fx/2, + oy + (fs-1)*fy + fy/2, + oz + (fs-1)*fz + fz/2), + point(ox + (fs-1)*fx + rs*rx + fx/2, + oy + (fs-1)*fy + rs*ry + fy/2, + oz + (fs-1)*fz + rs*rz + fz/2) + local p3 = p2:AddZ(const.SlabSizeZ) + --DbgAddPoly({p1, p2, p3, p1:AddZ(const.SlabSizeZ)}) + return PlaneFromPoints(p1, p3, p2) +end + +function CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, px,py) + local ppx = MulDivRound(px - ox, guim, fx + rx) + local ppy = MulDivRound(py - oy, guim, fy + ry) + + if abs(fx) > abs(fy) then + return (ppx*fz + ppy*rz) / guim + oz + else + return (ppx*rz + ppy*fz) / guim + oz + end +end + +local function SideNames(direction) + local first_dir_i = table.find(cardinal_direction_names, direction) + local sides = { } + for i=1,4 do + local dir_i = (((first_dir_i - 1) + (i - 1)) % 4) + 1 + sides[i] = cardinal_direction_names[dir_i] + end + local side_front, side_right, side_back, side_left = table.unpack(sides) + return side_front, side_right, side_back, side_left +end + +local side_to_i = { + ["Front"] = 0, + ["Right"] = 1, + ["Back"] = 2, + ["Left"] = 3, +} + +function RoomRoof:CreateSlab(slab_class, params) + local slab_classdef = g_Classes[slab_class] + return slab_classdef:new(params) +end + +function RoomRoof:CreateRoofComponents_RoofPlane(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, special) + --determine roof skew + local skew_x, skew_y = MulDivRound(-fz, guim, abs(fx + rx)), 0 + + --determine angle + local angle = cardinal_directions[direction] + 180*60 + + --handle odd length gable roof top slabs + local clip_plane + local odd_gable = (special == "odd_gable_long") or (special == "odd_gable_short") + if odd_gable then + clip_plane = GetGableClipPlane(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs) + end + local is_flat = self:GetRoofType() == "Flat" + + for fi=1,fs do + local gable_clip = odd_gable and (fi == fs) + + for ri=1,rs do + local x = ox + (fi-1)*fx + (ri-1)*rx + (fx+rx)/2 + local y = oy + (fi-1)*fy + (ri-1)*ry + (fy+ry)/2 + local z = oz + (fi-1)*fz + (ri-1)*rz + (fz+rz)/2 + --DbgAddVector(point(x, y, z), point(0, 0, const.SlabSizeZ), const.clrBlue) + + local slab = self:CreateSlab("RoofPlaneSlab", { + floor = self.floor, + room = self, + dir = direction, + material = self.roof_mat, + }) + + self:SetupNewObj(slab, x, y, z, angle, self.roof_colors, nil, objs, gable_clip and clip_plane or nil, nil, skew_x, skew_y) + self:PostProcessPlaneSlab(slab, is_flat) + end + end +end + +RoomRoof.ShouldPlaceRoofEdge = return_true +function RoomRoof:CreateRoofComponents_RoofEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, side, special) + if not self:ShouldPlaceRoofEdge(direction, side) then return end + + local front_or_back = (side == "Front" or side == "Back") + + --determine entity + local edge_type = (side == "Front" and "Ridge") or (side == "Back" and "Eave") or "Rake" + + --local coord system + local bx, by, bz, + dx, dy, dz, ds = SolveRoofEdgeCoordSystem(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, side) + --adjust for voxel edges + bx, by, bz = bx + dx/2, by + dy/2, bz + dz/2 + + --determine how the edge will be skewed + local skew_x, skew_y + if side == "Front" then + skew_x, skew_y = MulDivRound(fz, guim, abs(fx + rx)), 0 + elseif side == "Back" then + skew_x, skew_y = MulDivRound(-fz, guim, abs(fx + rx)), 0 + else + skew_x, skew_y = 0, MulDivRound(fz, guim, abs(fy + ry)) + end + + --rakes (left and right side) are mirrored only on the right side + local mirror = (side == "Right") + + --determine object angle + local direction_i = table.find(cardinal_direction_names, direction) + local direction_i = (((direction_i-1) + side_to_i[side]) % 4) + 1 + local angle = cardinal_directions[direction_i] + + --handle odd length gable roof top slabs + local clip_plane + local odd_gable = (special == "odd_gable_long") or (special == "odd_gable_short") + if odd_gable then + clip_plane = GetGableClipPlane(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs) + end + + for di=1,ds do + local x = bx + (di-1)*dx + local y = by + (di-1)*dy + local z = bz + (di-1)*dz + --DbgAddVector(point(x, y, z), point(0, 0, const.SlabSizeZ), const.clrMagenta) + + local slab = self:CreateSlab("RoofEdgeSlab", { + floor = self.floor, + room = self, + dir = direction, + roof_comp = edge_type, + material = self.roof_mat, + }) + + self:SetupNewObj(slab, x, y, z, angle, self.roof_colors, nil, objs, odd_gable and di == ds and clip_plane or nil, mirror, skew_x, skew_y) + end +end + +RoomRoof.ShouldCreateRoofCorner = return_true +function RoomRoof:CreateRoofComponents_RoofCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, side) + if not self:ShouldCreateRoofCorner(direction, side) then return end + + local mirror, angle + local x, y, z + + --determine angle + angle = cardinal_directions[direction] + if side == "Left" or side == "Back" then + angle = cardinal_directions[direction] + 180*60 + end + + --determine if component should be mirrored + local mirror = (side == "Front" or side == "Back") + + --determine position + local x, y, z = SolveRoofCornerPosition(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, side) + + --determine entity + local corner_type = (side == "Front" or side == "Right") and "RakeRidge" or "RakeEave" + + --determine skew + local skew = (side == "Front" or side == "Right") and fz or -fz + local skew_x, skew_y = MulDivRound(skew, guim, abs(fx + rx)), 0 + + --DbgAddVector(point(x, y, z), point(0, 0, const.SlabSizeZ), const.clrOrange) + + local slab = self:CreateSlab("RoofCorner", { + floor = self.floor, + room = self, + dir = direction, + roof_comp = corner_type, + material = self.roof_mat, + }) + + self:SetupNewObj(slab, x, y, z, angle, self.roof_colors, nil, objs, nil, mirror, skew_x, skew_y) +end + +RoomRoof.ShouldCreateRoofWallEdge = return_true +function RoomRoof:CreateRoofComponents_WallEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, side, clip_plane, special) + if not self:ShouldCreateRoofWallEdge(direction, side) then return end + + --local coord system + local bx, by, bz, + dx, dy, dz, ds = SolveRoofEdgeCoordSystem(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, side) + --adjust for walls & voxel edges + dz = 0 + bx, by = bx + dx/2, by + dy/2 + bz = oz - self.roof_additional_height + + --determine object angle + local front_or_back = (side == "Front" or side == "Back") + local dir_i = table.find(cardinal_direction_names, direction) + local side_i = side_to_i[side] + local wall_side = cardinal_direction_names[(((dir_i-1) + side_i) % 4) + 1] + local angle = cardinal_directions[wall_side] + local oc = self.outer_colors + local ic = self.inner_colors + local dbg_outwards_vec = (side == "Left" or side == "Front") and point(dy, -dx, dz) or point(-dy, dx, dz) + dbg_outwards_vec = SetLen(dbg_outwards_vec, guim) + + --handle middle column of gable roofs with odd length + local odd_gable_long_half = (special == "odd_gable_long") + local odd_gable_short_half = (special == "odd_gable_short") + local odd_gable = odd_gable_long_half or odd_gable_short_half + local max_height = max_int + if odd_gable and not front_or_back then + local size = ds + if odd_gable_short_half then size = size + 1 end + local x, y = (bx + (size-1)*dx), (by + (size-1)*dy) + + local roof_z1, roof_z2 = + CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, x + dx/2, y + dy/2), + CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, x - dx/2, y - dy/2) + + local min_roof_z = Min(roof_z1, roof_z2) - bz + max_height = min_roof_z / const.SlabSizeZ + end + + local is_flat = self:GetRoofType() == "Flat" + + for di=1,ds do + local x = bx + (di-1)*dx + local y = by + (di-1)*dy + + --determine wall column height + --note: when creating parapet this fn is called without clipping plane + local roof_z + if clip_plane then + local roof_z1, roof_z2 = + CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, x + dx/2, y + dy/2), + CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, x - dx/2, y - dy/2) + --DbgAddVector(point(x + dx/2, y + dy/2, roof_z1), dbg_outwards_vec, const.clrRed) + --DbgAddVector(point(x - dx/2, y - dy/2, roof_z2), dbg_outwards_vec, const.clrRed) + roof_z = Max(roof_z1, roof_z2) - bz + else + roof_z = Max(fz*fs, const.SlabSizeZ) + self.roof_additional_height + end + + local height = DivCeil(roof_z, const.SlabSizeZ) + height = Min(height, max_height) + local mat = self:GetWallMatHelperSide(wall_side) + + for j=1,height do + local z = bz + (di-1)*dz + (j-1)*const.SlabSizeZ + --DbgAddVector(point(x, y, z), dbg_outwards_vec, const.clrYellow) + + local variant = self.inner_wall_mat ~= noneWallMat and "OutdoorIndoor" or "Outdoor" + local wall = self:CreateSlab("RoofWallSlab", { --left + floor = self.floor, + material = mat, + room = self, + side = wall_side, + variant = variant, + indoor_material_1 = self.inner_wall_mat, + }) + + self:SetupNewObj(wall, x, y, z, angle, oc, ic, objs, clip_plane) + end + + if not clip_plane and self.roof_parapet and is_flat then + --only for flat parapet roofs + local extra_dec_e = "WallDec_material_FenceTop_Body_01" + extra_dec_e = extra_dec_e:gsub("material", mat) + if IsValidEntity(extra_dec_e) then + --ent exists + local z = bz + (di-1)*dz + (height-1)*const.SlabSizeZ + local o = PlaceObject(extra_dec_e, {side = wall_side}) + o:SetGameFlags(const.gofPermanent) + self:SetupNewObj(o, x, y, z, angle, oc or o:GetDefaultColorizationSet(), ic, objs, clip_plane) + end + end + end + + --now place the half of middle column of gable roofs with odd length + + if odd_gable and not front_or_back then + local size = ds + if odd_gable_long_half then size = size - 1 end + + local di_from = (fz > 0) and ((max_height*const.SlabSizeZ - self.roof_additional_height)/fz) or 0 + for di=di_from,size do + local x = bx + (di-1)*dx + dx/2 + local y = by + (di-1)*dy + dy/2 + + --determine wall column height + --note: when creating parapet this fn is called without clipping plane + local roof_z + if clip_plane then + local roof_z1, roof_z2 = + CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, x + dx/2, y + dy/2), + CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, x - dx/2, y - dy/2) + --DbgAddVector(point(x + dx/2, y + dy/2, roof_z1), dbg_outwards_vec, const.clrRed) + --DbgAddVector(point(x - dx/2, y - dy/2, roof_z2), dbg_outwards_vec, const.clrRed) + roof_z = Max(roof_z1, roof_z2) - bz + else + roof_z = Max(fz*fs, const.SlabSizeZ) + self.roof_additional_height + end + + local height = DivCeil(roof_z, const.SlabSizeZ) + + for i=max_height+1,height do + local z = bz + (size-1)*dz + (i-1)*const.SlabSizeZ + --DbgAddVector(point(x, y, z), dbg_outwards_vec, const.clrYellow) + + local mat = self:GetWallMatHelperSide(wall_side) + local variant = self.inner_wall_mat ~= noneWallMat and "OutdoorIndoor" or "Outdoor" + local wall = self:CreateSlab("GableRoofWallSlab", { --left + floor = self.floor, + material = mat, + room = self, + side = wall_side, + variant = variant, + indoor_material_1 = self.inner_wall_mat, + }) + + self:SetupNewObj(wall, x, y, z, angle, oc, ic, objs, clip_plane) + end + end + end +end + +function RoomRoof:SetupNewObj(obj, x, y, z, a, colors, inner_colors, container, clip_plane, mirror, skew_x, skew_y) + obj:SetPosAngle(x, y, z, a) + obj:AlignObj() + if colors then + obj:Setcolors(colors) + end + if inner_colors then + obj:Setinterior_attach_colors(inner_colors) + end + if clip_plane then + obj:SetClipPlane(clip_plane) + end + obj:UpdateEntity() + obj:UpdateVariantEntities() + + obj:SetMirrored(mirror or false) + if skew_x and skew_y then + obj:SetSkew(skew_x, skew_y) + end + self:ApplyPropsFromPropObj(obj) + if container then + table.insert(container, obj) + end + if not visibilityStateForNewRoofPieces then + obj:SetHierarchyGameFlags(const.gofSolidShadow) + obj:SetOpacity(0) + end +end + +function RoomRoof:CreateRoofComponents_WallCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, side, clip_plane, has_plug) + --determine angle + local angle = cardinal_directions[direction] + if side == "Left" or side == "Back" then + angle = cardinal_directions[direction] + 180*60 + end + + --determine position + local bx, by = SolveRoofCornerPosition(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, side) + local bz = oz - self.roof_additional_height + + --determine skew + local skew = (side == "Front" or side == "Right") and fz or -fz + local skew_x, skew_y = MulDivRound(skew, guim, abs(fx + rx)), 0 + + --determine corner column height + --note: when creating parapet this fn is called without clipping plane + local roof_z + if clip_plane then + roof_z = CalcRoofZAt(ox,oy,oz, fx,fy,fz, rx,ry,rz, bx, by) - bz + else + roof_z = Max(fz*fs, const.SlabSizeZ)+ self.roof_additional_height + end + local height = DivCeil(roof_z, const.SlabSizeZ) + + local dir_i = table.find(cardinal_direction_names, direction) + local side_i = side_to_i[side] + local corner_side = cardinal_direction_names[(((dir_i-1) + side_i) % 4) + 1] + local mat = self:GetWallMatHelperSide(corner_side) + + for i=1,height do + local z = bz + (i-1)*const.SlabSizeZ + + --DbgAddVector(point(bx, by, z), point(0, 0, const.SlabSizeZ), const.clrYellow) + + local corner = self:CreateSlab("RoofCornerWallSlab", { + room = self, + material = mat, + side = corner_side, + isPlug = false, + floor = self.floor, + }) + + self:SetupNewObj(corner, bx, by, z, angle, self.outer_colors, nil, objs, clip_plane) + end + + if has_plug then + local z = bz + (height-1)*const.SlabSizeZ + + local corner = self:CreateSlab("RoofCornerWallSlab", { + room = self, + material = mat, + side = corner_side, + isPlug = true, + floor = self.floor, + }) + + self:SetupNewObj(corner, bx, by, z, angle, self.outer_colors, nil, objs, clip_plane) + end + + local is_flat = self:GetRoofType() == "Flat" + if not clip_plane and self.roof_parapet and is_flat then + local extra_dec_e = "WallDec_material_FenceTop_Corner_01" + extra_dec_e = extra_dec_e:gsub("material", mat) + if IsValidEntity(extra_dec_e) then + --ent exists + local z = bz + (height-1)*const.SlabSizeZ + local o = PlaceObject(extra_dec_e, {side = corner_side}) + o:SetGameFlags(const.gofPermanent) + local a = cardinal_directions[corner_side] - 90 * 60 --corners probably self align, since angle is bogus + self:SetupNewObj(o, bx, by, z, a, self.outer_colors or o:GetDefaultColorizationSet(), nil, objs, clip_plane) + end + end +end + +local gable_cap_30_degree = -100 +local gable_cap_max_adjustment = 120 +function RoomRoof:AdjustGableCapZ(z) + --adjust the caps depending on roof inclination angle + local inclination = self:GetRoofInclination() + inclination = 30*60 - Clamp(inclination, 0, 30*60) + return (z + gable_cap_30_degree) + MulDivRound(inclination, gable_cap_max_adjustment, 30*60) +end + +function RoomRoof:CreateRoof_GableCaps(voxel_box, direction, parapet) + local objs = { } + + --local coordinate system (origin, forward, right) + local ox, oy, oz, + fx, fy, fz, fs, + rx, ry, rz, rs = SolveRoofCoordSystem(voxel_box, self.roof_additional_height, direction, self:GetRoofInclination()) + + --base x, y, z (where the caps begin) + local bx, by, bz = + ox + MulDivRound(fx, fs, 2), + oy + MulDivRound(fy, fs, 2), + oz + (fz*fs) / 2 + + bz = self:AdjustGableCapZ(bz) + + --determine angle for componnets + local angle = cardinal_directions[direction] + 180*60 + local angle_rake, angle_gable = angle + 90*60, angle + + --determine classes for the two types of components + local class_rake, class_gable + if (fs % 2) == 0 then + class_rake, class_gable = "GableCapRoofCorner", "GableCapRoofEdgeSlab" + else + class_rake, class_gable = "GableCapRoofEdgeSlab", "GableCapRoofPlaneSlab" + end + + --create rake components + if not parapet then + --left rake + local slab = self:CreateSlab(class_rake, { + floor = self.floor, + room = self, + material = self.roof_mat, + roof_comp = "RakeGable", + }) + self:SetupNewObj(slab, bx, by, bz, angle_rake, self.roof_colors, nil, objs) + --right rake + local slab = self:CreateSlab(class_rake, { + floor = self.floor, + room = self, + material = self.roof_mat, + roof_comp = "RakeGable", + }) + self:SetupNewObj(slab, bx + rx*rs, by + ry*rs, bz + rz*rs, angle_rake + 180*60, self.roof_colors, nil, objs, nil, true) + end + + --adjust the base x, y, z for the gable components + bx, by, bz = bx + rx/2, by + ry/2, bz + rz/2 + + --create gable components + for i=1,rs do + local x, y, z = + bx + (i-1)*rx, + by + (i-1)*ry, + bz + (i-1)*rz + + --DbgAddVector(point(x, y, z), point(0, 0, const.SlabSizeZ), const.clrCyan) + + local slab = self:CreateSlab(class_gable, { + floor = self.floor, + room = self, + material = self.roof_mat, + roof_comp = "Gable", + }) + self:SetupNewObj(slab, x, y, z, angle_gable, self.roof_colors, nil, objs) + end + + return objs +end + +--[[@@@ +Create a roof plane. Can be called multiple times to create a more complex roof. +@function objects RoomRoof@CreateRoof(box voxel_box, string direction, bool parapet, string special) +@param string roof_type - Type of roof ("Flat", "Shed", "Gable"). +@param box voxel_box - 2D box, in voxels, which the roof should cover. +@param string direction - Roof direction ("East", "South", "West", "North"). +@param bool parapet - If the roof should have a parapet. +@param string special - Special behaviour for some circumstances ("odd_gable_long", "odd_gable_short"). +@result array objects - Created objects. +]] +function RoomRoof:CreateRoof(roof_type, voxel_box, direction, parapet, special) + local objs = { } + + local odd_gable = (special == "odd_gable_long" or special == "odd_gable_short") + local odd_gable_long_half = (special == "odd_gable_long") + + --local coordinate system (origin, forward, right) + local inclination = self:GetRoofInclination() + local ox, oy, oz, + fx, fy, fz, fs, + rx, ry, rz, rs = SolveRoofCoordSystem(voxel_box, self.roof_additional_height, direction, inclination) + + --geometric plane + local p1, p2, p3 = + point(ox, oy, oz), + point(ox + rx*rs, oy + ry*rs, oz + rz*rs), + point(ox + fx*fs, oy + fy*fs, oz + fz*fs) + local clip_plane = PlaneFromPoints(p1, p2, p3) + + --DbgAddVector(p1, p2 - p1, const.clrWhite) + --DbgAddVector(p1, p3 - p1, const.clrWhite) + --DbgAddVector(p3, p2 - p1, const.clrWhite) + --DbgAddVector(p2, p3 - p1, const.clrWhite) + + --create flat inner roof planes + self:CreateRoofComponents_RoofPlane(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, special) + + --create roof edges + if (roof_type ~= "Flat" or not parapet) and roof_type ~= "Gable" then + self:CreateRoofComponents_RoofEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Front", special) + end + if not parapet then + self:CreateRoofComponents_RoofEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Right", special) + end + if (roof_type ~= "Flat" or not parapet) then + self:CreateRoofComponents_RoofEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Back", special) + end + if not parapet then + self:CreateRoofComponents_RoofEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Left", special) + end + + --create roof corners + if not parapet then + if roof_type ~= "Gable" then + self:CreateRoofComponents_RoofCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Front") + self:CreateRoofComponents_RoofCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Right") + end + + self:CreateRoofComponents_RoofCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Back") + self:CreateRoofComponents_RoofCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Left") + end + + --prepare clipping planes for walls (fpn=front-(clipping)plane-normal;fpd=front-(clipping)plane-distance;...) + local fp, rp, bp, lp = clip_plane, clip_plane, clip_plane, clip_plane + if parapet then + --when creating a parapets we don't provide clipping planes + if roof_type == "Flat" then + --parapets on all sides + fp, rp, bp, lp = false, false, false, false + else + --parapets on left and right sides only + rp, lp = false, false + end + end + + local wfs = fs --side walls forward size (left and right walls) + if odd_gable and not odd_gable_long_half then + wfs = wfs - 1 + end + + --create walls + if roof_type ~= "Gable" then + self:CreateRoofComponents_WallEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Front", fp, special) + end + self:CreateRoofComponents_WallEdge(objs, ox,oy,oz, fx,fy,fz,wfs, rx,ry,rz,rs, direction, "Right", rp, special) + self:CreateRoofComponents_WallEdge(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Back", bp, special) + self:CreateRoofComponents_WallEdge(objs, ox,oy,oz, fx,fy,fz,wfs, rx,ry,rz,rs, direction, "Left", lp, special) + + --preapre clipping planes for wall corners (fcpn=front-corner-(clipping)plane-normal;...) + --where there are two adjacent parapets - there is a corner without a clipping plane + local fcp, rcp, bcp, lcp = clip_plane, clip_plane, clip_plane, clip_plane + if not lp and not fp then fcp = false end + if not fp and not rp then rcp = false end + if not rp and not bp then bcp = false end + if not bp and not lp then lcp = false end + + --prepare wall corner plugs/caps + --if the corners aren't clipped, they should have a plug at the top + local f_plug, r_plug, b_plug, l_plug = not fcp, not rcp, not bcp, not lcp + + --create wall corners + if roof_type ~= "Gable" then + self:CreateRoofComponents_WallCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Front", fcp, f_plug) + self:CreateRoofComponents_WallCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Right", rcp, r_plug) + end + self:CreateRoofComponents_WallCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Back", bcp, b_plug) + self:CreateRoofComponents_WallCorner(objs, ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, direction, "Left", lcp, l_plug) + + local roof_box = RoofCoordSystemBBox(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, self.roof_additional_height) + return objs, roof_box +end + +function RoomRoof:RecalcRoof_Generic(roof_type, voxel_box, direction, parapet, special) + local objs = self.roof_objs + if not objs or not next(objs) then return end + + --recalc common properties + for i=1,#objs do + local obj = objs[i] + if IsValid(obj) then + obj.room = self + obj.floor = self.floor + if IsRoofTile(obj) then + SetSlabColorHelper(obj, obj.colors or self.roof_colors) + else + SetSlabColorHelper(obj, obj.colors or self.outer_colors) + end + end + end + + --prepare for gable roofs + local odd_gable = (special == "odd_gable_long" or special == "odd_gable_short") + local odd_gable_long_half = (special == "odd_gable_long") + + --local coordinate system (origin, forward, right) + local inclination = self:GetRoofInclination() + local ox, oy, oz, + fx, fy, fz, fs, + rx, ry, rz, rs = SolveRoofCoordSystem(voxel_box, self.roof_additional_height, direction, inclination) + + --geometric plane + local clip_plane = PlaneFromPoints( + ox, oy, oz, + ox + rx*rs, oy + ry*rs, oz + rz*rs, + ox + fx*fs, oy + fy*fs, oz + fz*fs) + + --find side cardinal directions and their angles + local side_front, side_right, side_back, side_left = SideNames(direction) + local angle_front = cardinal_directions[side_front] + local angle_right = cardinal_directions[side_right] + local angle_back = cardinal_directions[side_back] + local angle_left = cardinal_directions[side_left] + + --filter objects depending on their role in the roof + local roof_box = box(BoxVoxelToWorld(voxel_box)):grow(1) + local walls_box = roof_box + + --gable roofs of odd length use a different box for their walls + if odd_gable then + local dminx, dminy, dmaxx, dmaxy = 0, 0, 0, 0 + if direction == "West" then dminx = 1 end + if direction == "North" then dminy = 1 end + if direction == "East" then dmaxx = 1 end + if direction == "South" then dmaxy = 1 end + + if odd_gable_long_half then + walls_box = walls_box:grow( + MulDivRound(dminx, const.SlabSizeX, 2), + MulDivRound(dminy, const.SlabSizeY, 2), + MulDivRound(dmaxx, const.SlabSizeX, 2), + MulDivRound(dmaxy, const.SlabSizeY, 2)) + else + local walls_voxel_box = voxel_box:grow(-dminx, -dminy, -dmaxx, -dmaxy) + walls_box = box(BoxVoxelToWorld(walls_voxel_box)):grow(1) + end + end + + --DbgAddBox(roof_box, const.clrWhite) + --DbgAddBox(walls_box:grow(10,10,10), const.clrRed) + + --filter objects into categories and recover some properties + local wall_objs, wall_corner_objs, roof_objs = {}, {}, {} + --also assign entities + local class_gable, class_rake + if odd_gable then + class_rake, class_gable = "RoofEdgeSlab", "RoofPlaneSlab" + else + class_rake, class_gable = "RoofCorner", "RoofEdgeSlab" + end + + local is_flat = self:GetRoofType() == "Flat" + local center = point( + ox + fx*fs/2 + rx*rs/2, + oy + fy*fs/2 + ry*rs/2, + oz + fz*fs/2 + rz*rs/2) + local right = point(rx, ry, rz) + local north_x, north_y, south_x, south_y = self.box:xyxy() + local east_x, east_y = south_x, north_y + local epsilon = voxelSizeX / 10 + + for i, obj in ipairs(objs) do + if obj then + if IsKindOf(obj, "RoofCornerWallSlab") and walls_box:Point2DInside(obj) then + table.insert(wall_corner_objs, obj) + + if IsCloser2D(obj, north_x, north_y, epsilon) then + obj.side = "North" + elseif IsCloser2D(obj, south_x, south_y, epsilon) then + obj.side = "South" + elseif IsCloser2D(obj, east_x, east_y, epsilon) then + obj.side = "East" + else + obj.side = "West" + end + elseif IsKindOf(obj, "RoofWallSlab") and walls_box:Point2DInside(obj) then + table.insert(wall_objs, obj) + obj.side = slabAngleToDir[obj:GetAngle()] + elseif IsKindOf(obj, "RoofSlab") and roof_box:Point2DInside(obj) and (not obj.dir or obj.dir == direction) then + local angle = obj:GetAngle() + if (not IsKindOf(obj, "RoofPlaneSlab") or angle == angle_back) and obj.dir then + table.insert(roof_objs, obj) + end + + obj.side = direction + + if not obj.dir then + if IsKindOf(obj, class_gable) then + obj.roof_comp = "Gable" + elseif IsKindOf(obj, class_rake) then + obj.roof_comp = "RakeGable" + end + elseif IsKindOf(obj, "RoofEdgeSlab") then + if angle == angle_front then + obj.roof_comp = "Ridge" + elseif angle == angle_back then + obj.roof_comp = "Eave" + else + obj.roof_comp = "Rake" + if angle == angle_right then + obj:SetMirrored(true) + end + end + elseif IsKindOf(obj, "RoofCorner") then + obj.roof_comp = (angle == angle_front or angle == angle_right) and "RakeRidge" or "RakeEave" + if (angle == angle_front) == (Dot(right, obj:GetPos() - center) < 0) then + obj:SetMirrored(true) + end + elseif IsKindOf(obj, "RoofPlaneSlab") then + obj.roof_comp = "Plane" + self:PostProcessPlaneSlab(obj, is_flat) + end + + end + + obj:DelayedUpdateEntity() + end + end + + --roofs without inclination don't have skewed slabs + if inclination > 0 then + + local sx = MulDivRound(fz, guim, const.SlabSizeX) + local sy = MulDivRound(fz, guim, const.SlabSizeY) + for i,slab in ipairs(roof_objs) do + local skew_x, skew_y + local angle = slab:GetAngle() + if angle == angle_front then + skew_x, skew_y = sx, 0 + elseif angle == angle_right or angle == angle_left then + skew_x, skew_y = 0, sy + elseif angle == angle_back then + skew_x, skew_y = -sx, 0 + end + + if skew_x and skew_y then + slab:SetSkew(skew_x, skew_y) + end + end + + end + + --clip top parts of a gable roof + if odd_gable then + + local gable_clip_plane = GetGableClipPlane(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs) + for i,slab in ipairs(roof_objs) do + slab:SetClipPlane(gable_clip_plane) + end + + end + + --prepare clipping planes for walls (fpn=front-(clipping)plane-normal;fpd=front-(clipping)plane-distance;...) + local fp, rp, bp, lp = clip_plane, clip_plane, clip_plane, clip_plane + if parapet then + --when creating a parapets we don't provide clipping planes + if roof_type == "Flat" then + --parapets on all sides + fp, rp, bp, lp = false, false, false, false + else + --parapets on left and right sides only + rp, lp = false, false + end + end + + --clip walls + for i,wall in ipairs(wall_objs) do + local clip_plane + local angle = wall:GetAngle() + if angle == angle_front then clip_plane = fp end + if angle == angle_right then clip_plane = rp end + if angle == angle_back then clip_plane = bp end + if angle == angle_left then clip_plane = lp end + + if clip_plane then + wall:SetClipPlane(clip_plane) + end + end + + --preapre clipping planes for wall corners (fcpn=front-corner-(clipping)plane-normal;...) + --where there are two adjacent parapets - there is a corner without a clipping plane + local fcp, rcp, bcp, lcp = clip_plane, clip_plane, clip_plane, clip_plane + if not lp and not fp then fcp = false end + if not fp and not rp then rcp = false end + if not rp and not bp then bcp = false end + if not bp and not lp then lcp = false end + + --clip wall corners + for i,corner in ipairs(wall_corner_objs) do + local clip_plane + local angle = corner:GetAngle() + if angle == angle_front then clip_plane = fcp end + if angle == angle_right then clip_plane = rcp end + if angle == angle_back then clip_plane = bcp end + if angle == angle_left then clip_plane = lcp end + + if clip_plane then + corner:SetClipPlane(clip_plane) + end + end + + return RoofCoordSystemBBox(ox,oy,oz, fx,fy,fz,fs, rx,ry,rz,rs, self.roof_additional_height) +end + +function RoomRoof:Setkeep_roof_passable(val) + self.keep_roof_passable = val + if val then + self:MakeRoofPassable() + end +end + +--moves roof so it becomes passable +function RoomRoof:MakeRoofPassable() + local nrah, rah = self:GetPassableRoofAdditionalHeight() + if nrah then + self:OnSetroof_additional_height(nrah, rah) + end +end + +function RoomRoof:GetPassableRoofAdditionalHeight() + if not self:HasRoof() then + return + end + if self:GetRoofType() ~= "Flat" then + --only flat roof may be passable + return + end + + local objs = self.roof_objs + local rs + for i = 1, #(objs or "") do + local o = objs[i] + if IsKindOf(o, "RoofPlaneSlab") and o:HasSpot("Slab") then + rs = o + break + end + end + + if not rs then + return + end + + local rah = self.roof_additional_height + local si = rs:GetSpotBeginIndex("Slab") + local p = rs:GetPos() + local sp = rs:GetSpotPos(si) + local offs = sp - p + + local minHeight = sp:z() - rah + local vx, vy, vz = SnapToVoxel(sp:xyz()) + if vz < minHeight then + vz = vz + voxelSizeZ + end + + local np = point(vx, vy, vz) - offs + local d = np:z() - p:z() + local nrah = rah + d + assert(nrah >= 0 and nrah <= voxelSizeZ) + return nrah, rah +end + +---- roof getters and setters + +function RoomRoof:GetroofVisualsEnabled() + return RoofVisualsEnabled +end + +function RoomRoof:SetroofVisualsEnabledForRoom(v) + local roof_objs = self.roof_objs + if roof_objs then + for j=1,#roof_objs do + local roof_obj = roof_objs[j] + if IsValid(roof_obj) then + local opacity + if v then + roof_obj:ClearHierarchyGameFlags(const.gofSolidShadow) + opacity = 100 + else + roof_obj:SetHierarchyGameFlags(const.gofSolidShadow) + opacity = 0 + end + if not IsKindOf(roof_obj, "Decal") then + roof_obj:SetOpacity(opacity) + end + end + end + MapForEach(self.roof_box, "CObject", function(o) + if o:GetGameFlags(const.gofOnRoof)~=0 then + local opacity + if v then + o:ClearHierarchyGameFlags(const.gofSolidShadow) + opacity = 100 + else + o:SetHierarchyGameFlags(const.gofSolidShadow) + opacity = 0 + end + if not IsKindOf(o, "Decal") then + o:SetOpacity(opacity) + end + end + end) + end +end + +function RoomRoof:SetroofVisualsEnabled(v) + RoofVisualsEnabled = v + + MapForEach("map", "Room", function(roof) + roof:SetroofVisualsEnabledForRoom(v) + end) +end + +function RoomRoof:OnSetroof_type(new_type, old_type) + if old_type == new_type then return end + self.roof_type = new_type + if new_type == "Gable" and not table.find(GableRoofDirections, self.roof_direction) then + self.roof_direction = GableRoofDirections[1] + elseif table.find(GableRoofDirections, self.roof_direction) then + self.roof_direction = cardinal_direction_names[1] + end + self:RecreateRoof() +end + +function RoomRoof:OnSetroof_mat(new_mat, old_mat) + if old_mat == new_mat then return end + self.roof_mat = new_mat + self:UnlockRoof() + self:RecreateRoof() +end + +function RoomRoof:OnSetroof_direction(new_dir, old_dir) + if old_dir == new_dir then return end + self.roof_direction = new_dir + self:RecreateRoof() +end + +function RoomRoof:OnSetroof_inclination(new_incl, old_incl) + if old_incl == new_incl then return end + self.roof_inclination = new_incl + self:RecreateRoof() +end + +function RoomRoof:OnSetroof_parapet(new_parapet, old_parapet) + if old_parapet == new_parapet then return end + self.roof_parapet = new_parapet + self:RecreateRoof() +end + +function RoomRoof:OnSetroof_additional_height(new_height, old_height) + if old_height == new_height then return end + self.roof_additional_height = new_height + self:RecreateRoof() +end + +function RoomRoof:OnSetbuild_ceiling(val) + self.build_ceiling = val + self:RecreateRoof() +end + +function RoomRoof:OnSetceiling_mat(mat, oldmat) + if oldmat == mat then return end + if not self.build_ceiling then return end + Notify(self, "SetCeilingMatToCeilingSlabs") +end + +function RoomRoof:SetCeilingMatToCeilingSlabs() + if not self.build_ceiling then return end + + local objs = self.roof_objs + local mat = self.ceiling_mat + local bb = box() + for i = #(objs or ""), 1, -1 do + local o = objs[i] + if not IsKindOf(o, "CeilingSlab") then + break + end + o.material = mat + o:UpdateEntity() + bb = Extend(bb, o:GetPos()) + end + if bb:IsValid() and not bb:IsEmpty() then + ComputeSlabVisibilityInBox(bb) + end +end + +function RoomRoof:CreateCeiling(objs) + local mat = self.ceiling_mat + local sx, sy = self.position:x(), self.position:y() + local sizeX, sizeY, sizeZ = self.size:xyz() + sx = sx + halfVoxelSizeX + sy = sy + halfVoxelSizeY + local gz = self:CalcZ() + sizeZ * voxelSizeZ + + SuspendPassEdits("Room:CreateCeiling") + for xOffset = 0, sizeX - 1 do + for yOffset = 0, sizeY - 1 do + local x = sx + xOffset * voxelSizeX + local y = sy + yOffset * voxelSizeY + + local ceil = self:CreateSlab("CeilingSlab", { + floor = self.floor, + material = mat, + side = false, + room = self + }) + self:SetupNewObj(ceil, x, y, gz, 0, nil, nil, objs) + end + end + ResumePassEdits("Room:CreateCeiling") +end + +function RoomRoof:OnSetroof_colors(val, oldVal) + for i = 1, #(self.roof_objs or "") do + local o = self.roof_objs[i] + if o and IsRoofTile(o) then + o:Setcolors(val) + end + end +end + +---- roof slabs + +--[[@@@ +@class RoofSlab +All slabs that build up the roof (exist in room.roof_objs) derive from this class. +]] +DefineClass.RoofSlab = { + __parents = { "Slab", "Mirrorable" }, + + properties = { + { category = "Slabs", id = "material", name = "Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "RoofSlabMaterials", extra_item = noneWallMat, default = "none", }, + { category = "Slabs", id = "forceInvulnerableBecauseOfGameRules", name = "Invulnerable", editor = "bool", default = false, help = "In context of destruction."}, + { id = "dir", name = "Direction", editor = "choice", default = false, items = cardinal_direction_names }, + { id = "SkewX" }, + { id = "SkewY" }, + { id = "Mirrored", editor = "bool", default = false, dont_save = true }, + }, + + roof_comp = false, + colors_room_member = "roof_colors", + entity_base_name = "Roof", + room_container_name = "roof_objs", + invulnerable = false, +} + +function RoofSlab:GetBaseEntityName() + local material_list = Presets.SlabPreset[self.MaterialListClass] or Presets.SlabPreset.RoofSlabMaterials + local svd = material_list[self.material] + + return string.format("%s_%s_%s", self.entity_base_name, svd.EntitySet, self.roof_comp) +end + +local roofCompToSubvariantArr = { + Plane = "subvariants", + Eave = "eave_subvariants", + Rake = "rake_subvariants", + Ridge = "ridge_subvariants", + Gable = "gable_subvariants", + RakeGable = "rake_gable_subvariants", + RakeRidge = "rake_ridge_subvariants", + RakeEave = "rake_eave_subvariants", + GableCrest = "crest_subvariants", + RakeGableCrestTop = "crest_top_subvariants", + RakeGableCrestBot = "crest_bot_subvariants", + GableSlope = "slope_subvariants", + RakeGableSlopeTop = "slope_top_subvariants", + RakeGableSlopeBot = "slope_bot_subvariants", +} + +function RoofSlab:ComposeEntityName() + local material_list = Presets.SlabPreset[self.MaterialListClass] or Presets.SlabPreset.RoofSlabMaterials + local svd = material_list[self.material] + local sm = roofCompToSubvariantArr[self.roof_comp] + local subvariants = svd and svd[sm] + if subvariants and #subvariants > 0 then + if self.subvariant ~= -1 then --user selected subvar + local digit = ((self.subvariant - 1) % #subvariants) + 1 --assumes "01, 02, etc. suffixes + local digitStr = digit < 10 and "0" .. tostring(digit) or tostring(digit) + return string.format("Roof_%s_%s_%s", svd.EntitySet, self.roof_comp, digitStr) + else + local subvariant, i = table.weighted_rand(subvariants, "chance", self:GetSeed()) + while subvariant do + local name = string.format("Roof_%s_%s_%s", svd.EntitySet, self.roof_comp, subvariant.suffix) + if IsValidEntity(name) then + return name + end + i = i - 1 + subvariant = subvariants[i] + end + end + end + return string.format("Roof_%s_%s_01", self.material or noneWallMat, self.roof_comp) +end + +function RoofSlab:MirroringFromRoom() +end + +function RoofSlab:EditorCallbackClone(source) + Slab.EditorCallbackClone(self, source) + if source.room then + source.room:RecreateRoof() + end +end + +DefineClass.RoofPlaneSlab = { + __parents = { "RoofSlab", "HFloorAlignedObj" }, + flags = { efPathSlab = true }, + properties = { + { category = "Slabs", id = "material", name = "Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "RoofSlabMaterials", extra_item = noneWallMat, default = "none", }, + }, + MaterialListClass = "RoofSlabMaterials", + roof_comp = "Plane", +} + +DefineClass.RoofEdgeSlab = { + __parents = { "RoofSlab", "HWallAlignedObj" }, + + MaterialListClass = "RoofSlabMaterials", + roof_comp = "Rake", +} + +DefineClass.RoofCorner = { + __parents = { "RoofSlab", "HCornerAlignedObj" }, + + MaterialListClass = "RoofSlabMaterials", + roof_comp = "RakeRidge", +} + +--[[@@@ +@class BaseRoofWallSlab +All walls and wall corners that build up the roof derive from this class. +They will behave exactly like their normal wall counterparts, but are identifiable as part of the roof by this class. +]] +DefineClass.BaseRoofWallSlab = { + __parents = { "CObject" }, + properties = { + { category = "Slabs", id = "forceInvulnerableBecauseOfGameRules", name = "Invulnerable", editor = "bool", default = false, help = "In context of destruction."}, + }, + room_container_name = "roof_objs", + invulnerable = false, +} + +DefineClass.RoofWallSlab = { + __parents = { "BaseRoofWallSlab", "WallSlab" }, + room_container_name = "roof_objs", + forceInvulnerableBecauseOfGameRules = false, + invulnerable = false, +} + +--[[@@@ +@class GableRoofWallSlab +This is a workaround class for gable roofs of odd length require walls that are aligned on voxel corners, instead of their edges. +This type of walls is needed, because we cannot clip a single object with multiple planes. +]] +DefineClass.GableRoofWallSlab = { + __parents = { "RoofWallSlab", "CornerAlignedObj" }, +} + +function GableRoofWallSlab:AlignObj(pos, angle) + CornerAlignedObj.AlignObj(self, pos, angle) +end + +DefineClass("GableCapRoofPlaneSlab", "RoofPlaneSlab") +DefineClass("GableCapRoofEdgeSlab", "RoofEdgeSlab") +DefineClass("GableCapRoofCorner", "RoofCorner") + +DefineClass.RoofCornerWallSlab = { + __parents = { "BaseRoofWallSlab", "RoomCorner" }, + room_container_name = "roof_objs", + forceInvulnerableBecauseOfGameRules = false, + invulnerable = false, +} + +---- horizontally aligned objects (can move freely on the Z axis) + +DefineClass.HWallAlignedObj = { + __parents = { "WallAlignedObj" }, +} + +function HWallAlignedObj:AlignObjAttached() + local p = self:GetParent() + assert(p) + local ap = self:GetPos() + self:GetAttachOffset() + local x, y, z, angle = WallWorldToVoxel(ap:x(), ap:y(), ap:z(), self:GetAngle()) + x, y, z = WallVoxelToWorld(x, y, z, angle) + local my_x, my_y, my_z = self:GetPosXYZ() + my_z = my_z or InvalidZ + px, py, pz = p:GetPosXYZ() + self:SetAttachOffset(x - px, y - py, my_z - pz) + self:SetAngle(angle) --havn't tested with parents with angle ~= 0, might not work +end + +function HWallAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z = pos:xyz() + x, y, z, angle = WallWorldToVoxel(x, y, z or terrain.GetHeight(x, y), angle or self:GetAngle()) + else + x, y, z, angle = WallWorldToVoxel(self) + end + local my_x, my_y, my_z = self:GetPosXYZ() + my_z = my_z or InvalidZ + x, y, z = WallVoxelToWorld(x, y, z, angle) + self:SetPosAngle(x, y, my_z, angle) +end + +DefineClass.HFloorAlignedObj = { + __parents = { "FloorAlignedObj" }, +} + +function HFloorAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = WorldToVoxel(pos, angle or self:GetAngle()) + else + x, y, z, angle = WorldToVoxel(self) + end + local my_x, my_y, my_z = self:GetPosXYZ() + my_z = my_z or InvalidZ + x, y, z = VoxelToWorld(x, y, z) + self:SetPosAngle(x, y, my_z, angle) +end + +DefineClass.HCornerAlignedObj = { + __parents = { "CornerAlignedObj" }, +} + +function HCornerAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = CornerWorldToVoxel(pos, angle or self:GetAngle()) + else + x, y, z, angle = CornerWorldToVoxel(self) + end + local my_x, my_y, my_z = self:GetPosXYZ() + my_z = my_z or InvalidZ + x, y, z = CornerVoxelToWorld(x, y, z, angle) + self:SetPosAngle(x, y, my_z, angle) +end + +--------------------------------------------------- +--vfx controllers +--------------------------------------------------- +local function IsOutsideVolumes(obj) + local inside = false + local mr = obj.room + local mp = obj:GetPos() + MapForEach(obj:GetObjectBBox():grow(voxelSizeX * 30, voxelSizeY * 30, 0), "Room", function(v, mp, mr) + if v ~= mr then + if not v:IsRoofOnly() and v.box:PointInsideInclusive(mp) and mp:z() < v.box:maxz() then --horizontally inclusive only! + inside = "volume" + --DbgAddBox(v.box) + return "break" + end + if v.roof_box and v.roof_box:PointInsideInclusive(mp) then + inside = "roof" + --DbgAddBox(v.roof_box) + return "break" + end + end + end, mp, mr) + + return not inside, inside +end + +function ShouldHaveRoofEavesSegment(roof_edge) + if not roof_edge.isVisible or roof_edge.is_destroyed then return false end + local room = roof_edge.room + if roof_edge.roof_comp == "Eave" or (room and room.roof_type == "Flat") then + if IsOutsideVolumes(roof_edge) then + return true + end + end + + return false +end + +function RoomRoof:OnRoofEdgeTilesDestroyed() + self:UpdateRoofVfxControllers() +end + +function RoomRoof:OnRoofPlaneTilesDestroyed() + --TODO: something should happen +end + +function RoomRoof:SetVfxControllersVisibility(val) + for i = 1, #(self.vfx_roof_surface_controllers or "") do + self.vfx_roof_surface_controllers[i]:SetVisibility(val) + end + for i = 1, #(self.vfx_eaves_controllers or "") do + self.vfx_eaves_controllers[i]:SetVisibility(val) + end +end + +function RoomRoof:UpdateRoofVfxControllers() + local b = self.roof_box + if not self:HasRoof() or not b then + DoneObjects(self.vfx_roof_surface_controllers) + DoneObjects(self.vfx_eaves_controllers) + self.vfx_roof_surface_controllers = false + self.vfx_eaves_controllers = false + return + end + + --surfaces + local controllers = self.vfx_roof_surface_controllers + + if self.roof_type == "Gable" then + controllers = controllers or {} + + local vc1 = IsValid(controllers[1]) and controllers[1] or PlaceObject("RoofSurface") + local vc2 = IsValid(controllers[2]) and controllers[2] or PlaceObject("RoofSurface") + local v11, v12, v13, v21, v22, v23 + v11 = b:min() + if self.roof_direction == "East-West" then + v12 = v11 + point(b:sizex(), 0, 0) + v13 = v11 + point(0, b:sizey() / 2, 0) + v21 = v13 + v22 = v21 + point(b:sizex(), 0, 0) + v23 = v21 + point(0, b:sizey() / 2, 0) + else + v12 = v11 + point(b:sizex() / 2, 0, 0) + v13 = v11 + point(0, b:sizey(), 0) + v21 = v12 + v22 = v21 + point(b:sizex() / 2, 0, 0) + v23 = v21 + point(0, b:sizey(), 0) + end + + vc1:InitFromParent(self) + vc2:InitFromParent(self) + vc1:SetVertexes(v11, v12, v13) + local _, angle = self:GetRoofZAndDir(v11) + vc1.angle = angle + 180 * 60 + vc2:SetVertexes(v21, v22, v23) + vc2.angle = angle + + controllers[1] = vc1 + controllers[2] = vc2 + elseif self.roof_type == "Shed" or self.roof_type == "Flat" then + controllers = controllers or {} + + local vc = IsValid(controllers[1]) and controllers[1] or PlaceObject("RoofSurface") + local v1 = b:min() + local v2 = v1 + point(b:sizex(), 0, 0) + local v3 = v1 + point(0, b:sizey(), 0) + + vc:InitFromParent(self) + vc:SetVertexes(v1, v2, v3) + local _, angle = self:GetRoofZAndDir(v1) + vc.angle = angle + 180 * 60 + + controllers[1] = vc + if IsValid(controllers[2]) then + DoneObject(controllers[2]) + end + controllers[2] = nil + else + for i = #(controllers or ""), 1, -1 do + DoneObject(controllers[i]) + end + controllers = false + end + + self.vfx_roof_surface_controllers = controllers + + local map = {} + MapForEach(b:grow(10, 10, 10), "RoofEdgeSlab", function(o, self, map) + if o.room == self then + if ShouldHaveRoofEavesSegment(o) then + local s = slabAngleToDir[o:GetAngle()] + map[s] = map[s] or {} + local x, y, z = WorldToVoxel(o) + local sigcoord + if s == "East" or s == "West" then + sigcoord = y + else + sigcoord = x + end + + map[s][sigcoord] = o + map[s].min = not map[s].min and sigcoord or Min(map[s].min, sigcoord) + map[s].max = not map[s].max and sigcoord or Max(map[s].max, sigcoord) + end + end + end, self, map) + + + local function DetermineVertex(o, side, last) + local curb = o:GetObjectBBox() + local dir = (o:GetRelativePoint(axis_x) - o:GetPos()) + local sx, sy, sz = curb:sizexyz() + + if side == "East" or side == "West" then + local p = curb:Center() + MulDivRound(dir, sx / 2, 4096) + local x, y, z = p:xyz() + return point(x, y + halfVoxelSizeY * (not last and -1 or 1), z) + else + local p = curb:Center() + MulDivRound(dir, sy / 2, 4096) + local x, y, z = p:xyz() + return point(x + halfVoxelSizeX * (not last and -1 or 1), y, z) + end + end + + local controllers = self.vfx_eaves_controllers + local cidx = 1 + for side, t in pairs(map) do + local last + local v1, v2 + + for i = t.min, t.max + 1 do + local cur = t[i] + if cur then + if not last then + v1 = DetermineVertex(cur, side, false) + end + else + if last then + v2 = DetermineVertex(last, side, true) + + controllers = controllers or {} + local vc = IsValid(controllers[cidx]) and controllers[cidx] or PlaceObject("RoofEavesSegment") + controllers[cidx] = vc + cidx = cidx + 1 + + vc:InitFromParent(self) + + if side == "South" or side == "West" then + vc.vertex1 = v2 + vc.vertex2 = v1 + else + vc.vertex1 = v1 + vc.vertex2 = v2 + end + + vc.angle = last:GetAngle() + + --[[DbgAddVector(v1) + DbgAddVector(v2) + DbgAddVector(v1, v2 - v1)]] + if vc.playing then + vc:Stop() + vc:Play() + end + + v1 = nil + v2 = nil + end + end + + last = cur + end + end + + for i = #(controllers or ""), cidx, -1 do + if IsValid(controllers[i]) then + DoneObject(controllers[i]) + end + controllers[i] = nil + end + + if #(controllers or "") <= 0 then + controllers = false + end + + self.vfx_eaves_controllers = controllers +end + +DefineClass.RoofFXController = { + __parents = { "Object" }, + entity = "InvisibleObject", + + properties = { + {id = "material", editor = "text", default = false }, + {id = "parent_obj", editor = "object", default = false }, + {id = "disabled", editor = "bool", default = false }, + }, + + particles = false, + playing = false, +} + +function RoofFXController:Done() + self:Stop() +end + +function RoofFXController:SetVisibility(val) + if self.playing then + for i = 1, #(self.particles or "") do + if val then + self.particles[i]:SetEnumFlags(const.efVisible) + else + self.particles[i]:ClearEnumFlags(const.efVisible) + end + end + end +end + +function RoofFXController:SetDisabled(val) + if val then + self:Stop() + end + self.disabled = val +end + +function RoofFXController:InitFromParent(parent_obj) + self:SetPos(parent_obj:GetPos()) + self:SetAngle(parent_obj:GetAngle()) + self.parent_obj = parent_obj + self.material = parent_obj.roof_mat +end + +function RoofFXController:Play() + if self.disabled then return end + if self.playing then return end + PlayFX("ClearSky", "end", self, self.material) + PlayFX("RainHeavy", "start", self, self.material) + self.playing = true +end + +function RoofFXController:Stop() + if not self.playing then return end + PlayFX("RainHeavy", "end", self, self.material) + PlayFX("ClearSky", "start", self, self.material) + + DoneObjects(self.particles) + self.particles = false + self.playing = false +end + +DefineClass.RoofEavesSegment = { + __parents = { "RoofFXController" }, + properties = { + {id = "vertex1", editor = "point", default = false }, + {id = "vertex2", editor = "point", default = false }, + {id = "angle", editor = "number", default = false }, + }, +} + +function RoofEavesSegment:Dbg() + local v1 = self.vertex1 + local v2 = self.vertex2 + + DbgAddVector(v1) + DbgAddVector(v2) + DbgAddVector(v1, v2 - v1) +end + +function RoofEavesSegment:Play() + if self.disabled then return end + if self.playing then return end + RoofFXController.Play(self) + + local d = self.vertex1:Dist2D(self.vertex2) + local angle = CalcSignedAngleBetween2D(point(4096, 0, 0), self.vertex2 - self.vertex1) + if angle < 0 then + angle = 360 * 60 + angle + end + + local par = PlaceParticles("Rain_Pouring_Dyn") + par:SetPos(self.vertex1) + par:SetAngle(angle) + par:SetParam("width", d) + + self.particles = self.particles or {} + table.insert(self.particles, par) +end + +DefineClass.RoofSurface = { + __parents = { "RoofFXController" }, + properties = { + {id = "vertex1", editor = "point", default = false }, + {id = "vertex2", editor = "point", default = false }, + {id = "vertex3", editor = "point", default = false }, + {id = "angle", editor = "number", default = false }, + }, +} + +function RoofSurface:GetOffset() + if self.material == "Tin" then + return 74 --boxmaxz - originz + elseif self.material == "Tiles" then + return 129 + elseif self.material == "Concrete" then + return 190 + end + return 0 +end + +function RoofSurface:SetVertexes(v1, v2, v3) + local parent_obj = self.parent_obj + assert(parent_obj) + + --different material pieces have different heights + local hoff = self:GetOffset() + --GetRoofZAndDir doesn't always work on the extreme edges.. + local minx = Min(v1:x(), v2:x(), v3:x()) + 1 + local maxx = Max(v1:x(), v2:x(), v3:x()) - 1 + local miny = Min(v1:y(), v2:y(), v3:y()) + 1 + local maxy = Max(v1:y(), v2:y(), v3:y()) - 1 + + v1 = point(minx, maxy, 0) + v2 = point(maxx, maxy, 0) + v3 = point(minx, miny, 0) + + v1 = v1:SetZ(parent_obj:GetRoofZAndDir(v1) + hoff) + v2 = v2:SetZ(parent_obj:GetRoofZAndDir(v2) + hoff) + v3 = v3:SetZ(parent_obj:GetRoofZAndDir(v3) + hoff) + + self.vertex1 = v1 + self.vertex2 = v2 + self.vertex3 = v3 + + --[[DbgAddVector(self.vertex1) + DbgAddVector(self.vertex2) + DbgAddVector(self.vertex3)]] +end + + +function RoofSurface:Play() + RoofFXController.Play(self) + + local v1 = self.vertex1 + local v2 = self.vertex2 + local v3 = self.vertex3 + + local xmax = v2:Dist(v1) + local ymax = v3:Dist(v1) + assert(ymax < 1000000 and ymax > 0) + local angle = CalcSignedAngleBetween2D(point(4096, 0, 0), v2 - v1) + if angle < 0 then + angle = 360 * 60 + angle + end + + local par = PlaceParticles("Splashes_Raindrop_Dyn") + par:SetPos(v1) + par:SetAngle(angle) + par:SetParam("area", MulDivRound(xmax, ymax, 1000)) + par:SetParam("width", xmax) + par:SetParam("height", ymax) + self.parent_obj:SnapObject(par) + + self.particles = self.particles or {} + table.insert(self.particles, par) +end + +function CreateVfxControllersForAllRoomsOnMap() + MapForEach("map", "RoofFXController", DoneObject) --in case of old version ones existing + MapForEach("map", "Room", RoomRoof.UpdateRoofVfxControllers) +end + +function PlayRoofFX() + MapForEach("map", "RoofFXController", function(o) + o:Play() + end) +end + +function StopRoofFX() + MapForEach("map", "RoofFXController", function(o) + o:Stop() + end) +end \ No newline at end of file diff --git a/CommonLua/Libs/Volumes/Slab.lua b/CommonLua/Libs/Volumes/Slab.lua new file mode 100644 index 0000000000000000000000000000000000000000..a3b64da12549493f28488e0dd2c004fcb1049883 --- /dev/null +++ b/CommonLua/Libs/Volumes/Slab.lua @@ -0,0 +1,4050 @@ +-- the map is considered in bottom-right quadrant, which means that (0, 0) is north, west +local default_color = RGB(100, 100, 100) +local voxelSizeX = const.SlabSizeX or 0 +local voxelSizeY = const.SlabSizeY or 0 +local voxelSizeZ = const.SlabSizeZ or 0 +local halfVoxelSizeX = voxelSizeX / 2 +local halfVoxelSizeY = voxelSizeY / 2 +local halfVoxelSizeZ = voxelSizeZ / 2 +local no_mat = const.SlabNoMaterial +local noneWallMat = no_mat +local gofPermanent = const.gofPermanent +local efVisible = const.efVisible + +const.SuppressMultipleRoofEdges = true + +DefineClass.Restrictor = { + __parents = { "Object" }, + + restriction_box = false, +} + +local iz = const.InvalidZ +function Restrictor:Restrict() + local b = self.restriction_box + if not b then return end + local x, y, z = self:GetPosXYZ() + x, y, z = self:RestrictXYZ(x, y, z) + self:SetPos(x, y, z) +end + +function Restrictor:RestrictXYZ(x, y, z) + local b = self.restriction_box + if not b then return x, y, z end + local minx, miny, minz, maxx, maxy, maxz = b:xyzxyz() + x = Clamp(x, minx, maxx) + y = Clamp(y, miny, maxy) + if z ~= iz and minz ~= iz and maxz ~= iz then + z = Clamp(z, minz, maxz) + end + return x, y, z +end + +DefineClass.WallAlignedObj = { + __parents = { "AlignedObj" }, +} + +function WallAlignedObj:AlignObjAttached() + local p = self:GetParent() + assert(p) + local ap = self:GetPos() + self:GetAttachOffset() + local x, y, z, angle = WallWorldToVoxel(ap, self:GetAngle()) + x, y, z = WallVoxelToWorld(x, y, z, angle) + px, py, pz = p:GetPosXYZ() + self:SetAttachOffset(x - px, y - py, z - pz) + self:SetAngle(angle) --havn't tested with parents with angle ~= 0, might not work +end + +function WallAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = WallWorldToVoxel(pos, angle or self:GetAngle()) + else + x, y, z, angle = WallWorldToVoxel(self) + end + x, y, z = WallVoxelToWorld(x, y, z, angle) + self:SetPosAngle(x, y, z, angle) +end + +DefineClass.FloorAlignedObj = { + __parents = { "AlignedObj" }, + GetGridCoords = rawget(_G, "WorldToVoxel"), +} + +function FloorAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = WorldToVoxel(pos, angle or self:GetAngle()) + else + x, y, z, angle = WorldToVoxel(self) + end + x, y, z = VoxelToWorld(x, y, z) + self:SetPosAngle(x, y, z, angle) +end + +DefineClass.CornerAlignedObj = { + __parents = { "AlignedObj" }, +} + +function CornerAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = CornerWorldToVoxel(pos, angle or self:GetAngle()) + else + x, y, z, angle = CornerWorldToVoxel(self) + end + x, y, z = CornerVoxelToWorld(x, y, z, angle) + self:SetPosAngle(x, y, z, angle) +end + +DefineClass.GroundAlignedObj = { + __parents = { "AlignedObj" }, + GetGridCoords = rawget(_G, "WorldToVoxel"), +} + +function GroundAlignedObj:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = WorldToVoxel(pos, angle or self:GetAngle()) + if not pos:IsValidZ() then + z = iz + end + else + x, y, z, angle = WorldToVoxel(self) + if not self:IsValidZ() then + z = iz + end + end + x, y, z = VoxelToWorld(x, y, z) + self:SetPosAngle(x, y, z or iz, angle) +end + +local function FloorsComboItems() + local items = {} + for i = -5, 10 do + items[#items + 1] = tostring(i) + end + return items +end + +function SlabMaterialComboItems() + return PresetGroupCombo("SlabPreset", Slab.MaterialListClass, Slab.MaterialListFilter) +end + +---- + +-- CObject slab variant (no Lua object) +DefineClass.CSlab = { + __parents = { "EntityChangeKeepsFlags", "AlignedObj" }, + flags = { efBuilding = true }, + entity_base_name = "Slab", + material = false, -- will store only temporally the value as the CObject Lua tables are subject to GC + MaterialListClass = "SlabMaterials", + MaterialListFilter = false, + isVisible = true, + always_visible = false, + + ApplyMaterialProps = empty_func, + class_suppression_strenght = 0, + variable_entity = true, +} + +function CSlab:GetBaseEntityName() + return string.format("%s_%s", self.entity_base_name, self.material) +end + +function CSlab:GetSeed(max, const) + assert(self:IsValidPos()) + return BraidRandom(EncodeVoxelPos(self) + (const or 0), max) +end + +function CSlab:ComposeEntityName() + local base_entity = self:GetBaseEntityName() + local material_preset = self:GetMaterialPreset() + local subvariants = material_preset and material_preset.subvariants or empty_table + if #subvariants > 0 then + local seed = self:GetSeed() + local remaining = subvariants + while true do + local subvariant, idx = table.weighted_rand(remaining, "chance", seed) + if not subvariant then + break + end + local entity = subvariant.suffix ~= "" and (base_entity .. "_" .. subvariant.suffix) or base_entity + if IsValidEntity(entity) then + return entity + end + remaining = remaining == subvariants and table.copy(subvariants) or remaining + table.remove(remaining, idx) + end + end + return IsValidEntity(base_entity) and base_entity or (base_entity .. "_01") +end + +function CSlab:UpdateEntity() + local name = self:ComposeEntityName() + if IsValidEntity(name) then + self:ChangeEntity(name) + self:ApplyMaterialProps() + elseif self.material ~= no_mat then + self:ReportMissingSlabEntity(name) + end +end + +if Platform.developer and config.NoPassEditsOnSlabEntityChange then + function CSlab:ChangeEntity(entity, ...) + DbgSetErrorOnPassEdit(self, "%s: Entity %s --> %s", self.class, self:GetEntity(), entity) + EntityChangeKeepsFlags.ChangeEntity(self, entity, ...) + DbgClearErrorOnPassEdit(self) + end +end + +function CSlab:GetArtMaterialPreset() + return CObject.GetMaterialPreset(self) +end + +function CSlab:GetMaterialPreset() + local material_list = Presets.SlabPreset[self.MaterialListClass] + return material_list and material_list[self.material] +end + +function CSlab:SetSuppressor(suppressor, initiator, reason) + reason = reason or "suppressed" + if suppressor then + self:TurnInvisible(reason) + else + self:TurnVisible(reason) + end + return true +end + +function CSlab:ShouldUpdateEntity(agent) + return true +end + +--presumably cslabs don't need reasons +function CSlab:TurnInvisible(reason) + self:ClearHierarchyEnumFlags(const.efVisible) +end + +function CSlab:TurnVisible(reason) + self:SetHierarchyEnumFlags(const.efVisible) +end + +function CSlab:GetMaterialType() + --this gets the combat or obj material, not to be confused with slab material.. + local preset = self:GetMaterialPreset() + return preset and preset.obj_material or self.material +end + +---- +local function ListAddObj(list, obj) + if not list[obj] then + list[obj] = true + list[#list + 1] = obj + end +end + +local function ListForEach(list, func, ...) + for _, obj in ipairs(list or empty_table) do + if IsValid(obj) then + procall(obj[func], obj) + end + end +end + +local DelayedUpdateEntSlabs = {} +local DelayedUpdateVariantEntsSlabs = {} +local DelayedAlignObj = {} +function SlabUpdate() + SuspendPassEdits("SlabUpdate", false) + + ListForEach(DelayedUpdateEntSlabs, "UpdateEntity") + table.clear(DelayedUpdateEntSlabs) + + ListForEach(DelayedUpdateVariantEntsSlabs, "UpdateVariantEntities") + table.clear(DelayedUpdateVariantEntsSlabs) + + ListForEach(DelayedAlignObj, "AlignObj") + table.clear(DelayedAlignObj) + + ResumePassEdits("SlabUpdate") +end + +local first = false +function OnMsg.NewMapLoaded() + first = true +end + +function DelayedSlabUpdate() + --assert(mapdata.GameLogic, "Thread will never resume on map with no GameLogic.") + Wakeup(PeriodicRepeatThreads["DelayedSlabUpdate"]) +end + + +---- +--this class is used when copying/preserving props of slabs and room objs +DefineClass.SlabPropHolder = { + __parents = { "PropertyObject" }, + --props we want stored : + properties = { + { id = "colors", name = "Colors", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, + { id = "interior_attach_colors", name = "Interior Attach Color", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, help = "Color of the interior attach for ExIn materials.", no_edit = function(self) + return self.variant == "Outdoor" + end,}, + { id = "exterior_attach_colors", name = "Exterior Attach Color", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, help = "Color of the exterior attach for InIn materials.", no_edit = function(self) + return self.variant == "Outdoor" or self.variant == "OutdoorIndoor" + end}, + { name = "Subvariant", id = "subvariant", editor = "number", default = -1} + }, +} +---- + +DefineClass("SlabAutoResolve") + +DefineClass.Slab = { + __parents = { "CSlab", "Object", "DestroyableSlab", "HideOnFloorChange", "ComponentExtraTransform", "EditorSubVariantObject", "Mirrorable", "SlabAutoResolve" }, + flags = { gofPermanent = true, cofComponentColorizationMaterial = true, gofDetailClass0 = false, gofDetailClass1 = true }, + + properties = { + category = "Slabs", + { id = "buttons", name = "Buttons", editor = "buttons", default = false, dont_save = true, read_only = true, sort_order = -1, + buttons = { + {name = "Select Parent Room", func = "SelectParentRoom"}, + }, + }, + { id = "material", name = "Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "SlabMaterials", extra_item = noneWallMat, default = "Planks", }, + { id = "variant", name = "Variant", editor = "dropdownlist", items = PresetGroupCombo("SlabPreset", "SlabVariants"), default = "Outdoor", }, + { id = "forceVariant", name = "Force Variant", editor = "dropdownlist", items = PresetGroupCombo("SlabPreset", "SlabVariants"), default = "", help = "Variants are picked automatically and settings to the variant prop are overriden by internal slab workings, use this prop to force this slab to this variant at all times."}, + { id = "indoor_material_1", name = "Indoor Material 1", editor = "dropdownlist", items = PresetGroupCombo("SlabPreset", "SlabIndoorMaterials", false, no_mat), default = no_mat, no_edit = function(self) + return self.variant == "Outdoor" + end,}, + { id = "indoor_material_2", name = "Indoor Material 2", editor = "dropdownlist", items = PresetGroupCombo("SlabPreset", "SlabIndoorMaterials", false, no_mat), default = no_mat, no_edit = function(self) + return self.variant == "Outdoor" or self.variant == "OutdoorIndoor" + end,}, + { id = "colors", name = "Colors", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, + + { id = "colors1", name = "Colors 1", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, help = "Color of mat1 attach.", no_edit = function(self) + return self.variant == "Outdoor" + end, dont_save = true, no_edit = true}, --TODO: remove, save compat + { id = "interior_attach_colors", name = "Interior Attach Color", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, help = "Color of the interior attach for ExIn materials.", no_edit = function(self) + return self.variant == "Outdoor" + end,}, + { id = "colors2", name = "Colors 2", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, help = "Color of mat2 attach", no_edit = function(self) + return self.variant == "Outdoor" or self.variant == "OutdoorIndoor" + end, dont_save = true, no_edit = true}, --TODO: remove, save compat + { id = "exterior_attach_colors", name = "Exterior Attach Color", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, help = "Color of the exterior attach for InIn materials.", no_edit = function(self) + return self.variant == "Outdoor" or self.variant == "OutdoorIndoor" + end,}, + { id = "Walkable" }, + { id = "ApplyToGrids" }, + { id = "Collision" }, + { id = "always_visible", name = "Always Visible", editor = "bool", help = "Ignores room slab logic for making slabs invisible. Only implemented for walls, other types upon request.", default = false }, + { id = "ColorModifier", dont_save = true, read_only = true }, + { id = "forceInvulnerableBecauseOfGameRules", name = "Invulnerable", editor = "bool", default = true, help = "In context of destruction."}, + }, + entity_base_name = "Slab", + GetGridCoords = rawget(_G, "WorldToVoxel"), + --in parent room + + variant_objects = false, + + isVisible = true, + invisible_reasons = false, + + room = false, + side = false, + floor = 1, + + collision_allowed_mask = 0, + colors_room_member = "outer_colors", + room_container_name = false, + invulnerable = true, + + subvariants_table_id = "subvariants", + bad_entity = false, + exterior_attach_colors_from_nbr = false, +} + +function Slab:IsInvulnerable() + --not checking IsObjInvulnerableDueToLDMark(self) because related setter is hidden for slabs; + return self.invulnerable or TemporarilyInvulnerableObjs[self] +end + +function Slab:GetColorsRoomMember() + return self.colors_room_member +end + +function SetupObjInvulnerabilityColorMarkingOnValueChanged(o) + --stub +end + +function Slab:SetforceInvulnerableBecauseOfGameRules(val) + self.forceInvulnerableBecauseOfGameRules = val + self.invulnerable = val + SetupObjInvulnerabilityColorMarkingOnValueChanged(self) +end + +function Slab:GetEntitySubvariant() + local e = self:GetEntity() + local strs = string.split(e, "_") + return tonumber(strs[#strs]) +end + +function Slab:SelectParentRoom() + if IsValid(self.room) then + editor.ClearSel() + editor.AddToSel({self.room}) + else + print("This slab has no room.") + end +end + +function Slab:Init() + -- all Slab operations must be captured by editor undo + assert(EditorCursorObjs[self] or XEditorUndo:AssertOpCapture()) + if IsValid(self.room) then + self:SetWarped(self.room:GetWarped()) + end +end + +if Platform.developer then +function Slab:Done() + -- all Slab operations must be captured by editor undo + assert(EditorCursorObjs[self] or XEditorUndo:AssertOpCapture()) +end +end + +function Slab:GameInit() + self:UpdateSimMaterialId() +end + +function Slab:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "material" or prop_id == "variant" then + self.subvariant = -1 + end + if prop_id == "forceVariant" then + self.variant = self.forceVariant + self:DelayedUpdateEntity() + self:DelayedUpdateVariantEntities() + end +end + +function Slab:SetWarped(val) + --all attached objs as well, like wallpapers and such + if val then + self:SetHierarchyGameFlags(const.gofWarped) + else + self:ClearHierarchyGameFlags(const.gofWarped) + end +end + +function Slab:UpdateSimMaterialId() +end + +function Slab:Setmaterial(val) + self.material = val + self:UpdateSimMaterialId() +end + +function Slab:Setvariant(val) + self.variant = val +end + +function SlabAutoResolve:SelectionPropagate() + return self.room +end + +function SlabAutoResolve:CompleteElementConstruction() + self:UpdateSimMaterialId() +end + +--suppress saving of this prop +function Slab:Setroom(val) + self.room = val + self:DelayedUpdateEntity() +end + +function Slab:GetDefaultColor() + local member = self:GetColorsRoomMember() + return member and table.get(self.room, member) or false +end + +function Slab:Setcolors(val) + local def_color = self:GetDefaultColor() + if not val or val == empty_table or val == ColorizationPropSet then + val = def_color + end + self.colors = (val ~= def_color) and val:Clone() or nil + self:SetColorization(val) + SetSlabColorHelper(self, val) +end + +function Slab:Setinterior_attach_colors(val) + local def_color = self.room and self.room.inner_colors or false + if not val or val == empty_table or val == ColorizationPropSet then + val = def_color + end + self.interior_attach_colors = (val ~= def_color) and val:Clone() or nil + if self.variant_objects and self.variant_objects[1] then + SetSlabColorHelper(self.variant_objects[1], val) + end +end + +function Slab:GetExteriorAttachColor() + return self.exterior_attach_colors or self.exterior_attach_colors_from_nbr or self.colors or self:GetDefaultColor() +end + +function Slab:Setexterior_attach_colors_from_nbr(val) + if val == empty_table then + val = false + end + if val and (not self.exterior_attach_colors_from_nbr or not rawequal(self.exterior_attach_colors_from_nbr, val)) then + val = val:Clone() + end + + self.exterior_attach_colors_from_nbr = val + if self.variant_objects and self.variant_objects[2] then + SetSlabColorHelper(self.variant_objects[2], self:GetExteriorAttachColor()) + end +end + +function Slab:Setexterior_attach_colors(val) + if val == empty_table then + val = false + end + if val and (not self.exterior_attach_colors or not rawequal(self.exterior_attach_colors, val)) then + val = val:Clone() + end + + self.exterior_attach_colors = val + if self.variant_objects and self.variant_objects[2] then + SetSlabColorHelper(self.variant_objects[2], self:GetExteriorAttachColor()) + end +end + +function Slab:CanMirror() + return true +end + +local invisible_mask = const.cmDynInvisible & ~const.cmVisibility + +function Slab:TurnInvisible(reason) + assert(not self.always_visible) + self.invisible_reasons = table.create_set(self.invisible_reasons, reason, true) + + if self.isVisible or self:GetEnumFlags(efVisible) ~= 0 then + self.isVisible = false + local mask = collision.GetAllowedMask(self) + self.collision_allowed_mask = mask ~= 0 and mask or nil + self:ClearHierarchyEnumFlags(efVisible) + collision.SetAllowedMask(self, invisible_mask) + end +end + +function Slab:TurnVisible(reason) + local invisible_reasons = self.invisible_reasons + if reason and invisible_reasons then + invisible_reasons[reason] = nil + end + if not next(invisible_reasons) and not self.isVisible then + self.isVisible = nil + assert(self.isVisible) + self:SetHierarchyEnumFlags(efVisible) + collision.SetAllowedMask(self, self.collision_allowed_mask) + self.collision_allowed_mask = nil + self.invisible_reasons = nil + end +end + +local sx, sy, sz = const.SlabSizeX or guim, const.SlabSizeY or guim, const.SlabSizeZ or guim + +local slabgroupop_lastObjs = false +local slabgroupop_lastRealTime = false + +local function slab_group_op_done(objs) + local rt = RealTime() + if objs == slabgroupop_lastObjs and slabgroupop_lastRealTime and slabgroupop_lastRealTime == rt then + return true + end + slabgroupop_lastObjs = objs + slabgroupop_lastRealTime = rt + return false +end + +local slab_sides = { "N", "W", "S", "E" } +local slab_coord_limit = shift(1, 20) + +local function slab_hash(x, y, z, side) + local s = table.find(slab_sides, side) or 0 + assert(x < slab_coord_limit and y < slab_coord_limit and z < slab_coord_limit) + return x + shift(y, 20) + shift(z, 40) + shift(s, 60) +end + +function Slab:SetHeatMaterialIndex(matIndex) + self:SetCustomData(9, matIndex) +end + +function Slab:GetHeatMaterialIndex() + return self:GetCustomData(9) +end + +function Slab:RemoveDuplicates() + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + MapDelete(self, 0, self.class, nil, nil, gameFlags, function(o, self, is_permanent) + return obj ~= self and (is_permanent or o:GetGameFlags(gofPermanent) == 0) and self:IsSameLocation(obj) + end, self, is_permanent) +end + +function Slab:EditorCallbackPlace(reason) + if reason == "paste" or reason == "clone" or reason == "undo" then return end + local x, y, z = self:GetPosXYZ() + local surfz = terrain.GetHeight(x, y) + z = z or (surfz + voxelSizeZ - 1) + while z < surfz do + z = z + sz + end + self:AlignObj(point(x, y, z)) + self:UpdateEntity() +end + +function Slab:IsSameLocation(obj) + local x1, y1, z1 = self:GetPosXYZ() + local x2, y2, z2 = obj:GetPosXYZ() + + return x1 == x2 and y1 == y2 and z1 == z2 +end + +function Slab:GetWorldBBox() + return GetSlabWorldBBox(self:GetPos(), 1, 1, self:GetAngle()) +end + +function Slab:GetSeed(max, const) + assert(self:IsValidPos()) + return BraidRandom(EncodeVoxelPos(self) + (IsValid(self.room) and self.room.seed or 0) + (const or 0), max) +end + +local cached_totals = {} +local function ClearCachedTotals() + cached_totals = {} +end + +OnMsg.DoneMap = ClearCachedTotals +OnMsg.DataReload = ClearCachedTotals +OnMsg.PresetSave = ClearCachedTotals + +function GetMaterialSubvariants(svd, subvariants_id) + if not svd then return false, 0 end + local subvariants = not subvariants_id and svd.subvariants or subvariants_id and svd:HasMember(subvariants_id) and svd[subvariants_id] + local key = xxhash(svd.class, svd.id, subvariants_id or "") + local total = cached_totals[key] + if not total then + total = 0 + for i = 1, #(subvariants or empty_table) do + total = total + subvariants[i].chance + end + + cached_totals[key] = total + end + + return subvariants, total +end + +function Slab:Setsubvariant(val) + EditorSubVariantObject.Setsubvariant(self, val) + self:DelayedUpdateEntity() +end + +function Slab:ResetSubvariant() + EditorSubVariantObject.ResetSubvariant(self, val) + self:UpdateEntity() +end + +variantToVariantName = { + OutdoorIndoor = "ExIn", + IndoorIndoor = "InIn", + Outdoor = "ExEx", +} + +function Slab:GetBaseEntityName() + return string.format("%sExt_%s_Wall_%s", self.entity_base_name, self.material, variantToVariantName[self.variant]) +end + +function GetRandomSubvariantEntity(random, subvariants, get_ent_func, ...) + local t = 0 + for i = 1, #subvariants do + t = t + subvariants[i].chance + if i == #subvariants or t > random then + local ret = get_ent_func(subvariants[i].suffix, ...) + while i > 1 and not IsValidEntity(ret) do + --fallback to first valid ent in the set + i = i - 1 + ret = (subvariants[i].chance > 0 or i == 1) and get_ent_func(subvariants[i].suffix, ...) or false + end + + return ret, subvariants[i].suffix + end + end +end + +function Slab:GetSubvariantDigitStr(subvariants) + local digit = self.subvariant + if digit == -1 then + return "01" + end + if subvariants then + digit = ((digit - 1) % #subvariants) + 1 --assumes "01, 02, etc. suffixes + end + return digit < 10 and "0" .. tostring(digit) or tostring(digit) +end + +function Slab:ComposeEntityName() + local material_list = Presets.SlabPreset[self.MaterialListClass] + local svd = material_list and material_list[self.material] + local svdId = self.subvariants_table_id + + local baseEntity = self:GetBaseEntityName() + if svd and svd[svdId] and #svd[svdId] > 0 then + local subvariants, total = GetMaterialSubvariants(svd, svdId) + + if self.subvariant ~= -1 then --user selected subvar + local digitStr = self:GetSubvariantDigitStr() + local ret = string.format("%s_%s", baseEntity, digitStr) + if not IsValidEntity(ret) then + print("Reverting slab [" .. self.handle .. "] subvariant [" .. self.subvariant .. "] because no entity [" .. ret .. "] found. The slab has a subvariant set by the user (level-designer) which produces an invalid entity, this subvariant will be reverted back to a random subvariant. Re-saving the map will save the removed set subvariant and this message will no longer appear.") + ret = false + self.subvariant = -1 + else + return ret + end + end + + return GetRandomSubvariantEntity(self:GetSeed(total), subvariants, function(suffix, baseEntity) + return string.format("%s_%s", baseEntity, suffix) + end, baseEntity) + else + local digitStr = self:GetSubvariantDigitStr() + local ent = string.format("%s_%s", baseEntity, digitStr) + return IsValidEntity(ent) and ent or baseEntity + end +end + +function Slab:ComposeIndoorMaterialEntityName(mat) + if self.destroyed_neighbours ~= 0 and self.destroyed_entity_side ~= 0 or + self.is_destroyed and self.diagonal_ent_mask ~= 0 then + return self:ComposeBrokenIndoorMaterialEntityName(mat) + end + + local svd = (Presets.SlabPreset.SlabIndoorMaterials or empty_table)[mat] + if svd and svd.subvariants and #svd.subvariants > 0 then + local subvariants, total = GetMaterialSubvariants(svd) + return GetRandomSubvariantEntity(self:GetSeed(total), subvariants, function(suffix, mat) + return string.format("WallInt_%s_Wall_%s", mat, suffix) + end, mat) + else + return string.format("WallInt_%s_Wall_01", mat) + end +end + +function SetSlabColorHelper(obj, colors) + if obj:GetMaxColorizationMaterials() > 0 then + obj:SetColorModifier(RGB(100, 100, 100)) + if not colors then + colors = obj:GetDefaultColorizationSet() + end + obj:SetColorization(colors, "ignore_his_max") + else + local color1 = (colors or ColorizationPropSet):GetEditableColor1() + local r,g,b = GetRGB(color1) + obj:SetColorModifier(RGB(r / 2, g / 2, b / 2)) + end +end + +function Slab:ShouldUseRoomMirroring() + return true +end + +function Slab:MirroringFromRoom() + --called on update entity, deals with mirroring coming from parent room + if not IsValid(self.room) then + return + end + if not self:ShouldUseRoomMirroring() then + return + end + local mirror = self:CanMirror() and self:GetSeed(100, 115249) < 50 + self:SetMirrored(mirror) + if mirror then + -- interior objects can't be mirrored, so unmirror them... by mirroring them again + for _, interior in ipairs(self.variant_objects or empty_table) do + interior:SetMirrored(true) + end + end +end + +function Slab:DelayedUpdateEntity() + ListAddObj(DelayedUpdateEntSlabs, self) + DelayedSlabUpdate() +end + +function Slab:DelayedUpdateVariantEntities() + ListAddObj(DelayedUpdateVariantEntsSlabs, self) + DelayedSlabUpdate() +end + +function Slab:DelayedAlignObj() + ListAddObj(DelayedAlignObj, self) + DelayedSlabUpdate() +end + +function Slab:ForEachDestroyedAttach(f, ...) + for k, v in pairs(rawget(self, "destroyed_attaches") or empty_table) do + if IsValid(v) then + f(v, ...) + elseif type(v) == "table" then + for i = 1, #v do + local vi = v[i] + if IsValid(vi) then + f(vi, ...) + end + end + end + end +end + +function Slab:RefreshColors() + local clrs = self.colors or self:GetDefaultColor() + SetSlabColorHelper(self, clrs) + self:ForEachDestroyedAttach(function(v, clrs) + SetSlabColorHelper(v, clrs) + end, clrs) +end + +function Slab:DestroyAttaches(...) + Object.DestroyAttaches(self, ...) + self.variant_objects = nil +end + +function Slab:UpdateDestroyedState() + return false +end + +function Slab:GetSubvariantFromEntity(e) + e = e or self:GetEntity() + local strs = string.split(e, "_") + return tonumber(strs[#strs]) or 1 +end + +function Slab:LockSubvariantToCurrentEntSubvariant() + local e = self:GetEntity() + if IsValidEntity(e) and e ~= "InvisibleObject" then + self.subvariant = self:GetSubvariantFromEntity(e) + end +end + +function Slab:LockRandomSubvariantToCurrentEntSubvariant() + if self.subvariant ~= -1 then return end + self:LockSubvariantToCurrentEntSubvariant() +end + +function Slab:UnlockSubvariant() + self.subvariant = -1 +end + +function Slab:SetVisible(value) + Object.SetVisible(self, value) + if value then + self:TurnVisible("SetVisible") + else + self:TurnInvisible("SetVisible") + end +end + +function Slab:ResetVisibilityFlags() + if self.isVisible then + self:SetHierarchyEnumFlags(const.efVisible) + else + self:ClearHierarchyEnumFlags(const.efVisible) + end +end + +function Slab:UpdateEntity() + self.bad_entity = nil + if self.destroyed_neighbours ~= 0 or self.is_destroyed then + if self:UpdateDestroyedState() then + return + end + elseif self.destroyed_entity_side ~= 0 then + --nbr got repaired + self.destroyed_entity_side = 0 + self.destroyed_entity = false + self:RestorePreDestructionSubvariant() + end + + local name = self:ComposeEntityName() + + if name == self:GetEntity() then + self:UpdateSimMaterialId() + self:MirroringFromRoom() + elseif IsValidEntity(name) then + self:UpdateSimMaterialId() + self:ChangeEntity(name, "idle") + self:MirroringFromRoom() + self:RefreshColors() + self:ApplyMaterialProps() + + --change ent resets flags, set them back + self:ResetVisibilityFlags() + + if Platform.developer and IsEditorActive() and selo() == self then + ObjModified(self) --fixes 0159218 + end + elseif self.material ~= no_mat then + self:ReportMissingSlabEntity(name) + end +end + +DefineClass.SlabInteriorObject = { + __parents = { "Object", "ComponentAttach" }, + flags = { efCollision = false, efApplyToGrids = false, cofComponentColorizationMaterial = true } +} + +function Slab:UpdateVariantEntities() +end + +function Slab:SetProperty(id, value) + EditorCallbackObject.SetProperty(self, id, value) + if id == "material" then + self:DelayedUpdateEntity() + elseif id == "entity" or id == "variant" or id == "indoor_material_1" or id == "indoor_material_2" then + self:DelayedUpdateEntity() + self:DelayedUpdateVariantEntities() + end +end + +function Slab:GetContainerInRoom() + local room = self.room + if IsValid(room) then + local container = self.room_container_name + container = container and room[container] + return container and container[self.side] or container + end +end + +function Slab:RemoveFromRoomContainer() + local t = self:GetContainerInRoom() + if t then + local idx = table.find(t, self) + if idx then + t[idx] = false + end + end +end + +function Slab:GetObjIdentifier() + if not self.room or not IsValid(self.room) or not self.room_container_name then + return CObject.GetObjIdentifier(self) + end + local idx = table.find(self:GetContainerInRoom(), self) + assert(idx) + return xxhash(CObject.GetObjIdentifier(self.room), self.room_container_name, self.side, idx) +end + +function Slab:EditorCallbackDelete(reason) + self:RemoveFromRoomContainer() + + -- delete hidden (supressed) slabs from other rooms that are on the same position + if EditorCursorObjs[self] or not self.isVisible or reason == "undo" then + return + end + MapForEach(self, 0, self.class, function(o, self) + if o ~= self and not o.isVisible then + o:RemoveFromRoomContainer() + DoneObject(o) + end + end, self) +end + +function Slab:GetEditorParentObject() + return self.room +end + +DefineClass.FloorSlab = { + __parents = { "Slab", "FloorAlignedObj", "DestroyableFloorSlab" }, + flags = { efPathSlab = true }, + properties = { + category = "Slabs", + { id = "material", name = "Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "FloorSlabMaterials", extra_item = noneWallMat, default = "Planks", }, + { id = "variant", name = "Variant", editor = "dropdownlist", items = PresetGroupCombo("SlabPreset", "SlabVariants"), default = "", no_edit = true }, + }, + entity = "Floor_Planks", + entity_base_name = "Floor", + MaterialListClass = "FloorSlabMaterials", + colors_room_member = "floor_colors", + room_container_name = "spawned_floors", +} + +FloorSlab.MirroringFromRoom = empty_func +function FloorSlab:CanMirror() + return false +end + +function FloorSlab:GetBaseEntityName() + return string.format("%s_%s", self.entity_base_name, self.material) +end + +DefineClass.CeilingSlab = { + __parents = { "Slab", "FloorAlignedObj", "DestroyableFloorSlab" }, + flags = { efWalkable = false, efCollision = false, efApplyToGrids = false, efPathSlab = false }, + properties = { + category = "Slabs", + { id = "material", name = "Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "FloorSlabMaterials", extra_item = noneWallMat, default = "Planks", }, + { id = "forceInvulnerableBecauseOfGameRules", name = "Invulnerable", editor = "bool", default = false, help = "In context of destruction."}, + }, + entity = "Floor_Planks", + entity_base_name = "Floor", + MaterialListClass = "FloorSlabMaterials", + room_container_name = "roof_objs", + class_suppression_strenght = -10, +} + +function CeilingSlab:GetBaseEntityName() + return string.format("%s_%s", self.entity_base_name, self.material) +end + +DefineClass.BaseWallSlab = { + __parents = { "CSlab" }, +} + +DefineClass.WallSlab = { + __parents = { "Slab", "BaseWallSlab", "WallAlignedObj", "ComponentAttach"}, + + entity_base_name = "Wall", + + wall_obj = false, -- SlabWallObject currently covering this wall + GetGridCoords = rawget(_G, "WallWorldToVoxel"), + room_container_name = "spawned_walls", + class_suppression_strenght = 100, +} + +function WallSlab:UpdateVariantEntities() + if self.variant == "Outdoor" or (self.is_destroyed and self.diagonal_ent_mask == 0) then + DoneObjects(self.variant_objects) + self.variant_objects = nil + elseif self.variant == "OutdoorIndoor" then + if not self.variant_objects then + local o1 = PlaceObject("SlabInteriorObject") + self:Attach(o1) + o1:SetAttachAngle(180 * 60) + self.variant_objects = { o1 } + else + DoneObject(self.variant_objects[2]) + self.variant_objects[2] = nil + end + + if IsValid(self.variant_objects[1]) then + local e = self:ComposeIndoorMaterialEntityName(self.indoor_material_1) + if self.variant_objects[1]:GetEntity() ~= e then + self.variant_objects[1]:ChangeEntity(e) + end + self:Setinterior_attach_colors(self.interior_attach_colors or self.room and self.room.inner_colors) + end + elseif self.variant == "IndoorIndoor" then + if not self.variant_objects then + local o1 = PlaceObject("SlabInteriorObject") + self:Attach(o1) + o1:SetAttachAngle(180 * 60) + self.variant_objects = { o1 } + end + if not IsValid(self.variant_objects[2]) then + local o1 = PlaceObject("SlabInteriorObject") + self:Attach(o1) + self.variant_objects[2] = o1 + end + + if IsValid(self.variant_objects[1]) then + local e = self:ComposeIndoorMaterialEntityName(self.indoor_material_1) + if self.variant_objects[1]:GetEntity() ~= e then + self.variant_objects[1]:ChangeEntity(e) + end + self:Setinterior_attach_colors(self.interior_attach_colors or self.room and self.room.inner_colors) + end + + if IsValid(self.variant_objects[2]) then + local e = self:ComposeIndoorMaterialEntityName(self.indoor_material_2) + if self.variant_objects[2]:GetEntity() ~= e then + self.variant_objects[2]:ChangeEntity(e) + end + SetSlabColorHelper(self.variant_objects[2], self:GetExteriorAttachColor()) + end + end + + self:SetWarped(IsValid(self.room) and self.room:GetWarped() or self:GetWarped()) --propagate warped state to variant_objs +end + +function WallSlab:RefreshColors() + local clrs = self.colors or self:GetDefaultColor() + local iclrs = self.interior_attach_colors or self.room and self.room.inner_colors + SetSlabColorHelper(self, clrs) + self:ForEachDestroyedAttach(function(v, clrs, self) + SetSlabColorHelper(v, clrs) + --manage attaches of attaches colors + local atts = v:GetAttaches() + for i = 1, #(atts or "") do + local a = atts[i] + if not rawget(a, "editor_ignore") then + local angle = a:GetAttachAngle() + if angle == 0 then + SetSlabColorHelper(a, self:GetExteriorAttachColor()) + else + SetSlabColorHelper(a, iclrs) + end + end + end + end, clrs, self) + + if self.variant_objects then + if self.variant_objects[1] then + SetSlabColorHelper(self.variant_objects[1], self.interior_attach_colors or self.room and self.room.inner_colors) + end + if self.variant_objects[2] then + SetSlabColorHelper(self.variant_objects[2], self:GetExteriorAttachColor()) + end + end +end + +function WallSlab:EditorCallbackPlace(reason) + Slab.EditorCallbackPlace(self, reason) + if not self.room then + self.always_visible = true -- manually placed slabs can't be suppressed + end +end + +WallSlab.EditorCallbackPlaceCursor = WallSlab.EditorCallbackPlace + +function WallSlab:Done() + self.wall_obj = nil --clear refs if any +end + +function WallSlab:SetWallObj(obj) + self.wall_obj = obj + if self.always_visible then + return + end + return self:SetSuppressor(obj, nil, "wall_obj") +end + +function WallSlab:SetWallObjShadowOnly(shadow_only, clear_contour) + local wall_obj = self.wall_obj + if wall_obj then + if (const.cmtVisible and (not CMT_IsObjVisible(wall_obj))) ~= shadow_only then + wall_obj:SetShadowOnly(shadow_only) + end + if wall_obj.main_wall == self then + wall_obj:SetManagedSlabsShadowOnly(shadow_only, clear_contour) + end + end +end + +function WallSlab:EditorCallbackDelete(reason) + Slab.EditorCallbackDelete(self, reason) + if IsValid(self.wall_obj) and self.wall_obj.main_wall == self then + DoneObject(self.wall_obj) + end +end + +function WallSlab:GetSide(angle) + angle = angle or self:GetAngle() + if angle == 0 then + return "E" + elseif angle == 90*60 then + return "S" + elseif angle == 180*60 then + return "W" + else + return "N" + end +end + +function WallSlab:IsSameLocation(obj) + local x1, y1, z1 = self:GetPosXYZ() + local x2, y2, z2 = obj:GetPosXYZ() + local side1 = self:GetSide() + local side2 = obj:GetSide() + + return x1 == x2 and y1 == y2 and z1 == z2 and side1 == side2 +end + +function WallSlab:ExtendWalls(objs) + local visited, topmost = {}, {} + + -- filter out objects on the same grid (x, y), keeping the topmost only + for _, obj in ipairs(objs) do + local gx, gy, gz = obj:GetGridCoords() + local side = obj:GetSide() + local loc = slab_hash(gx, gy, 0, side) -- ignore z so the whole column gets the same hash + + if not visited[loc] then + visited[loc] = true + -- find the topmost wall in this location + local idx = #topmost + 1 + topmost[idx] = obj + while true do + local wall = GetWallSlab(gx, gy, gz + 1, side) + if IsValid(wall) then + gz = gz + 1 + topmost[idx] = wall + else + break + end + end + end + end + + -- make an initial pass to make sure there's a space for the extension and the objects being extended are not being pushed up by subsequent ones + for _, obj in ipairs(topmost) do + local gx, gy, gz = obj:GetGridCoords() + SlabsPushUp(gx, gy, gz + 1) + end + + -- create the walls + for _, obj in ipairs(topmost) do + local x, y, z = obj:GetPosXYZ() + local wall = WallSlab:new() + wall:SetPosAngle(x, y, z + sz, obj:GetAngle()) + wall:EditorCallbackClone(obj) -- copy relevant properties + wall:AlignObj() + wall:UpdateEntity() + end +end + +DefineClass.StairSlab = { + __parents = { "Slab", "FloorAlignedObj" }, + flags = { efPathSlab = true }, + properties = { + { category = "Slabs", id = "material", name = "Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "StairsSlabMaterials", extra_item = noneWallMat, default = "WoodScaff", }, + { category = "Slab Tools", id = "autobuild_stair_up", editor = "buttons", buttons = {{name = "Extend Up", func = "UIExtendUp"}}, default = false, dont_save = true}, + { category = "Slab Tools", id = "autobuild_stair_down", editor = "buttons", buttons = {{name = "Extend Down", func = "UIExtendDown" }}, default = false, dont_save = true}, + { name = "Subvariant", id = "subvariant", editor = "number", default = 1, + buttons = { + { name = "Next", func = "CycleEntityBtn" }, + }, + }, + + { id = "variant" }, + { id = "always_visible" }, + { id = "forceInvulnerableBecauseOfGameRules", name = "Invulnerable", editor = "bool", default = true, help = "In context of destruction.", dont_save = true, no_edit = true}, + }, + entity = "Stairs_WoodScaff_01", --should be some sort of valid ent or place object new excludes it + entity_base_name = "Stairs", + + MaterialListClass = "StairsSlabMaterials", + hide_floor_slabs_above_in_range = 2, --how far above the slab origin will floor slabs be hidden +} + +function StairSlab:IsInvulnerable() + return true +end +--[[ +function StairSlab:GameInit() + self:PFConnect() +end + +function StairSlab:Done() + self:PFDisconnect() +end +]] + +function StairSlab:GetBaseEntityName() + return string.format("%s_%s", self.entity_base_name, self.material) +end + +function StairSlab:GetStepZ() + return GetStairsStepZ(self) +end + +function StairSlab:GetExitOffset() + local dx, dy + local angle = self:GetAngle() + if angle == 0 then + dx, dy = 0, 1 + elseif angle == 90*60 then + dx, dy = -1, 0 + elseif angle == 180*60 then + dx, dy = 0, -1 + else + assert(angle == 270*60) + dx, dy = 1, 0 + end + return dx, dy, 1 +end + +function StairSlab:TraceConnectedStairs(zdir) + assert(zdir == 1 or zdir == -1) + + local first, last + local dx, dy = self:GetExitOffset() + local angle = self:GetAngle() + + dx, dy = dx * zdir, dy * zdir -- reverse the direction if walking down + + local all = {} + local gx, gy, gz = self:GetGridCoords() + -- check for any stairs below heading toward this voxel + -- repeat until the first(lowest one) is found + local step = 1 + while true do + local obj = GetStairSlab(gx + step * dx, gy + step * dy, gz + step * zdir) + if IsValid(obj) and obj:GetAngle() == angle and obj.floor == self.floor then + first = first or obj + last = obj + all[step] = obj + step = step + 1 + else + break + end + end + return first, last, all +end +--[[ +function StairSlab:PFConnect() + local first, last, start_stair, end_stair + + -- trace down + first, last = self:TraceConnectedStairs(-1) + start_stair = last or self + if first then + pf.RemoveTunnel(first) + end + + -- trace up + first, last = self:TraceConnectedStairs(1) + end_stair = last or self + if first then + pf.RemoveTunnel(first) + end + + --DbgClearVectors() + self:TunnelStairs(start_stair, end_stair) +end + +function StairSlab:TunnelStairs(start_stair, end_stair) + -- add a tunnel starting at Stairbottom spot of the first stair and ending on Stairtop spot of the last one + local start_point = start_stair:GetSpotPos(start_stair:GetSpotBeginIndex("Stairbottom")) + local exit_point = end_stair:GetSpotPos(end_stair:GetSpotBeginIndex("Stairtop")) + local h = terrain.GetHeight(start_point) + if h >= start_point:z() then + start_point:SetInvalidZ() + end + local weight = 1 + pf.AddTunnel(start_stair, start_point, exit_point, weight, -1, const.fTunnelActiveOnImpassable) + + --DbgAddVector(start_point, exit_point - start_point, const.clrGreen) +end + +function StairSlab:PFDisconnect() + DbgClearVectors() + -- trace down + local first, last = self:TraceConnectedStairs(-1) + if last then + pf.RemoveTunnel(last) + self:TunnelStairs(last, first) + else + -- no stairs below, tunnel starts from self + pf.RemoveTunnel(self) + end + + -- trace up + local first, last = self:TraceConnectedStairs(1) + if first and last then + -- already disconnected, no need to further remove tunnels + self:TunnelStairs(first, last) + end +end +]] + +function StairSlab:UIExtendUp(parent, prop_id, ged) + if slab_group_op_done(parent or {self}) then + return + end + + -- trace up from 'self' + local first, last = self:TraceConnectedStairs(1) + + -- check the spot for the next stair for a floor + local obj = last or self + local x, y, z = obj:GetGridCoords() + local dx, dy, dz = obj:GetExitOffset() + local floor = GetFloorSlab(x + dx, y + dy, z + dz) + + if IsValid(floor) then + print("Can't extend the stairs upward, floor tile is in the way") + return + end + + x, y, z = obj:GetPosXYZ() + + local stair = StairSlab:new({material = self.material, subvariant = self.subvariant}) + stair:SetPosAngle(x + dx * sx, y + dy * sy, z + dz * sz, obj:GetAngle()) + stair:EditorCallbackClone(self) + stair:UpdateEntity() + self:AlignObj() + self:UpdateEntity() +end + +function StairSlab:UIExtendDown(parent, prop_id, ged) + if slab_group_op_done(parent or {self}) then + return + end + + -- trace down from 'self' + local first, last = self:TraceConnectedStairs(-1) + + -- check the spot for the next stair for a floor + local obj = last or self + local x, y, z = obj:GetGridCoords() + local dx, dy, dz = obj:GetExitOffset() + local floor = GetFloorSlab(x, y, z) + + if IsValid(floor) then + print("Can't extend the stairs upward, floor tile is in the way") + return + end + + x, y, z = obj:GetPosXYZ() + + local stair = StairSlab:new({material = self.material, subvariant = self.subvariant}) + stair:SetPosAngle(x - dx * sx, y - dy * sy, z - dz * sz, obj:GetAngle()) + stair:EditorCallbackClone(self) + stair:UpdateEntity() + self:AlignObj() + self:UpdateEntity() +end + +function StairSlab:AlignObj(pos, angle) + FloorAlignedObj.AlignObj(self, pos, angle) + if self:GetGameFlags(const.gofPermanent) == 0 then + return + end + local lp = rawget(self, "last_pos") + local p = self:GetPos() + if lp ~= p then + rawset(self, "last_pos", p) + local b = self:GetObjectBBox() + if lp then + local s = b:size() / 2 + b = Extend(b, lp + s) + b = Extend(b, lp - s) + end + + ComputeSlabVisibilityInBox(b) + end +end + +DefineClass.SlabWallObject = { + __parents = { "Slab", "WallAlignedObj" }, + properties = { + category = "Slabs", + { id = "variant", name = "Variant", editor = "dropdownlist", items = PresetGroupCombo("SlabPreset", "SlabVariants"), default = "", no_edit = true }, + { id = "width", name = "Width", editor = "number", min = 0, max = 3, default = 1, }, + { id = "height", name = "Height", editor = "number", min = 1, max = 4, default = 3, }, + { id = "subvariant", name = "Subvariant", editor = "number", default = 1, + buttons = { + { name = "Next", func = "CycleEntityBtn" }, + },}, + { id = "hide_with_wall", name = "Hide With Wall", editor = "bool", default = false }, + { id = "owned_slabs", editor = "objects", default = false, no_edit = true }, + { id = "forceInvulnerableBecauseOfGameRules", name = "Invulnerable", editor = "bool", default = false, help = "In context of destruction."}, + + { id = "colors" }, + { id = "interior_attach_colors" }, + { id = "exterior_attach_colors" }, + { id = "indoor_material_1" }, + { id = "indoor_material_2" }, + }, + + entity = "Window_Colonial_Single_01", --ent that exists but is not a slab or common ent so it appear in new obj palette correctly + material = "Planks", + affected_walls = false, + main_wall = false, + last_snap_pos = false, + room = false, + owned_objs = false, + invulnerable = false, + colors_room_member = false, +} + + +SlabWallObject.GetSide = WallSlab.GetSide +SlabWallObject.GetGridCoords = WallSlab.GetGridCoords +SlabWallObject.IsSameLocation = WallSlab.IsSameLocation + +function WallSlab:GetAttachColors() + local iclrs = self.interior_attach_colors or self.room and self.room.inner_colors + local clrs = self:GetExteriorAttachColor() + return iclrs, clrs +end + +function SlabWallObject:GetMaterialType() + --this gets the combat or obj material, not to be confused with slab material.. + return self.material_type +end + +function SlabWallObject:RefreshColors() + if not self.is_destroyed then return end + + local clrs, iclrs + local aw = self.affected_walls + for i = 1, #(aw or "") do + local w = aw[i] + if w.invisible_reasons and not w.invisible_reasons["suppressed"] then + iclrs, clrs = w:GetAttachColors() + + if w:GetAngle() ~= self:GetAngle() then + local tmp = iclrs + iclrs = clrs + clrs = tmp + end + + break + end + end + + if not clrs then + clrs = self.colors or self:GetDefaultColor() + end + if not iclrs then + iclrs = self.room and self.room.inner_colors + end + + self:ForEachDestroyedAttach(function(v, self, clrs, iclrs) + local c, ic = clrs, iclrs + SetSlabColorHelper(v, rawget(v, "use_self_colors") and self or c or self) + if c or ic then + local atts = v:GetAttaches() + for i = 1, #(atts or "") do + local a = atts[i] + if not rawget(a, "editor_ignore") then + local angle = a:GetAttachAngle() + if angle == 0 then + if c then + SetSlabColorHelper(a, c) + end + else + if ic then + SetSlabColorHelper(a, ic) + end + end + end + end + end + end, self, clrs, iclrs) +end + +function SlabWallObject:SetStateSavedOnMap(val) + self:SetState(val) +end + +function SlabWallObject:GetStateSavedOnMap() + return self:GetStateText() +end + +local function InsertInParentContainersHelper(self, room, side) + assert(side) + if self:IsDoor() then + room.spawned_doors = room.spawned_doors or {} + room.spawned_doors[side] = room.spawned_doors[side] or {} + table.insert(room.spawned_doors[side], self) + else + room.spawned_windows = room.spawned_windows or {} + room.spawned_windows[side] = room.spawned_windows[side] or {} + table.insert(room.spawned_windows[side], self) + end +end + +local function RemoveFromParentContainerHelper(self, room, side) + if self:IsDoor() then + local spawned_doors = room.spawned_doors + if spawned_doors and spawned_doors[side] then + table.remove_entry(spawned_doors[side], self) + if #spawned_doors[side] <= 0 then + spawned_doors[side] = nil + end + end + else + local spawned_windows = room.spawned_windows + if spawned_windows and spawned_windows[side] then + table.remove_entry(spawned_windows[side], self) + if #spawned_windows[side] == 0 then + spawned_windows[side] = nil + end + end + end +end + +function SlabWallObject:FixNoRoom() + if self.room then return end + if not self.main_wall then return end + + local room = self.main_wall.room + local side = self.main_wall.side + self.room = room + self.side = side + if room and side then + InsertInParentContainersHelper(self, room, side) + end +end + +function SlabWallObject:Setside(newSide) + if self.side == newSide then return end + if self.room then + RemoveFromParentContainerHelper(self, self.room, self.side) + if newSide then + InsertInParentContainersHelper(self, self.room, newSide) + end + end + + self.side = newSide +end + +function SlabWallObject:ChangeRoom(newRoom) + if self.room == newRoom then return end + self.restriction_box = false + if self.room then + RemoveFromParentContainerHelper(self, self.room, self.side) + end + if newRoom and not EditorCursorObjs[self] then + XEditorUndo:StartTracking({newRoom}, not "created", "omit_children") -- let XEditorUndo store the "old" data for the room + end + self.room = newRoom + if newRoom then + InsertInParentContainersHelper(self, newRoom, self.side) + end +end + +function SlabWallObject:GetWorldBBox() + return GetSlabWorldBBox(self:GetPos(), self.width, self.height, self:GetAngle()) +end + +function GetSlabWorldBBox(pos, width, height, angle) + local x, y, z = pos:xyz() + local b + local minAdd = width > 1 and voxelSizeX or 0 + local maxAdd = width == 3 and voxelSizeX or 0 + local tenCm = guim / 10 + if angle == 0 then --e + return box(x - tenCm, y - halfVoxelSizeX - minAdd, z, x + tenCm, y + halfVoxelSizeX + maxAdd, z + height * voxelSizeZ) + elseif angle == 180 * 60 then --w + return box(x - tenCm, y - halfVoxelSizeX - maxAdd, z, x + tenCm, y + halfVoxelSizeX + minAdd, z + height * voxelSizeZ) + elseif angle == 90 * 60 then --s + return box(x - halfVoxelSizeX - maxAdd, y - tenCm, z, x + halfVoxelSizeX + minAdd, y + tenCm, z + height * voxelSizeZ) + else --n + return box(x - halfVoxelSizeX - minAdd, y - tenCm, z, x + halfVoxelSizeX + maxAdd, y + tenCm, z + height * voxelSizeZ) + end +end + +function IntersectWallObjs(obj, newPos, width, height, angle) + local ret = false + local b = GetSlabWorldBBox(newPos or obj:GetPos(), width or obj.width, height or obj.height, angle or obj:GetAngle()) + angle = angle or obj:GetAngle() + MapForEach(b:grow(voxelSizeX * 2, voxelSizeX * 2, voxelSizeZ * 3), "SlabWallObject", nil, nil, gofPermanent, function(o, obj, angle) + if o ~= obj then + local a = o:GetAngle() + if a == angle or abs(a - angle) == 180 * 60 then --ignore perpendicular objs + local hisBB = o:GetObjectBBox() + local ib = IntersectRects(b, hisBB) + if ib:IsValid() and (Max(ib:sizex(), ib:sizey()) >= halfVoxelSizeX / 2 and ib:sizez() >= halfVoxelSizeZ / 2) then + --DbgAddBox(b) + --DbgAddBox(hisBB) + ret = true + return "break" + end + end + end + end, obj, angle) + + return ret +end + +SlabWallObject.MirroringFromRoom = empty_func +function SlabWallObject:CanMirror() + return false +end + +function SlabWallObject:EditorCallbackMove() + self:AlignObj() +end + +function SlabWallObject:AlignObj(pos, angle) + local x, y, z + if pos then + x, y, z, angle = WallWorldToVoxel(pos:x(), pos:y(), pos:z() or iz, angle or self:GetAngle()) + else + x, y, z, angle = WallWorldToVoxel(self) + end + x, y, z = WallVoxelToWorld(x, y, z, angle) + local oldPos = self:GetPos() + local newPos = point(x, y, z) + if not newPos:z() then + newPos = newPos:SetZ(snapZCeil(terrain.GetHeight(newPos:xy()))) + end + + if pos then + --this tests for collision with other wallobjs + if oldPos:IsValid() and oldPos ~= newPos then + if IntersectWallObjs(self, newPos, self.width, self.height, angle) then + --print("WallObject could not move due to collision with other wall obj!", self.handle, newPos, oldPos) + newPos = oldPos + end + end + end + + self:SetPosAngle(newPos:x(), newPos:y(), newPos:z() or const.InvalidZ, angle) + self:PostEntityUpdate() -- updates self.main_wall + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + + if is_permanent and self.main_wall then + self:Setside(self.main_wall.side) + self:ChangeRoom(self.main_wall.room) + else + self:Setside(false) + self:ChangeRoom(false) + end +end + +-- depending on height/width +local SlabWallObject_BaseNames = { "WindowVent", "Window", "Door", "TallDoor" } +local SlabWallObject_BaseNames_Window = { "WindowVent", "Window", "WindowBig", "TallWindow" } +local SlabWallObject_WidthNames = { "Single", "Double", "Triple", "Quadruple", [0] = "Small" } + +function SlabWallObjectName(material, height, width, variant, isDoor) + local base = isDoor ~= nil and not isDoor and SlabWallObject_BaseNames_Window[height] or SlabWallObject_BaseNames[height] or "" + + if variant then + local v = variant <= 0 and 1 or variant + local str = variant < 10 and "%s_%s_%s_0%s" or "%s_%s_%s_%s" + return string.format(str, base, material, + SlabWallObject_WidthNames[width] or "", tostring(v)) + else + return string.format("%s_%s_%s", base, + material, SlabWallObject_WidthNames[width] or "") + end +end + +function SlabWallObject:EditorCallbackPlaceCursor() + -- init properties from entity + if IsValidEntity(self.class) then + local e = self.class + local strs = string.split(e, "_") + local base = strs[1] + local idxW = table.find(SlabWallObject_BaseNames_Window, base) + local idxD = table.find(SlabWallObject_BaseNames, base) + local isDoor = false + if idxW then + --window + self.height = idxW + if self:IsDoor() then + assert(false, "Please fix " .. self.class .. ". It is named as a window but its parent class a door!") + end + else + --door + self.height = idxD + if not self:IsDoor() then + --zulu door SlabWallDoor + --bacon door SlabDoor + --setmetatable(self, g_Classes.SlabDoor or g_Classes.SlabWallDoor) --this wont work, cuz they inherit more then one thing. + assert(false, "Please fix " .. self.class .. ". It is named as a door but its parent class is not a door!") + end + end + + self.material = strs[2] + local w = table.find(SlabWallObject_WidthNames, strs[3]) or SlabWallObject_WidthNames[0] == strs[3] and 0 + assert(w) + self.width = w + self.subvariant = tonumber(strs[4]) + self:UpdateEntity() + assert(self:GetEntity() == e, string.format("Failed to guess props from ent for slab wall obj, ent %s, picked ent %s", e, self:GetEntity())) + end +end + +function SlabWallObject:EditorCallbackPlace(reason) + Slab.EditorCallbackPlace(self, reason) + if reason ~= "undo" then + self:EditorCallbackPlaceCursor() + self:FixNoRoom() + end +end + +function SlabWallObject:HasEntityForSubvariant(var) + local ret = SlabWallObjectName(self.material, self.height, self.width, var, self:IsDoor()) + return IsValidEntity(ret) +end + +function SlabWallObject:HasEntityForHeight(height) + local ret + if self.subvariant then + ret = SlabWallObjectName(self.material, height, self.width, self.subvariant, self:IsDoor()) + if IsValidEntity(ret) then + return true + end + end + + return IsValidEntity(SlabWallObjectName(self.material, height, self.width, nil, self:IsDoor())), ret +end + +function SlabWallObject:HasEntityForWidth(width) + local ret + if self.subvariant then + ret = SlabWallObjectName(self.material, self.height, width, self.subvariant, self:IsDoor()) + if IsValidEntity(ret) then + return true + end + end + + return IsValidEntity(SlabWallObjectName(self.material, self.height, width, nil, self:IsDoor())), ret +end + +function SlabWallObject:ComposeEntityName() + if self.subvariant then + local ret = SlabWallObjectName(self.material, self.height, self.width, self.subvariant, self:IsDoor()) + if IsValidEntity(ret) then + return ret + else + self:ReportMissingSlabEntity(ret) + end + end + + return SlabWallObjectName(self.material, self.height, self.width, nil, self:IsDoor()) +end + +function SlabWallObject:EditorCallbackDelete(reason) + --Slab.EditorCallbackDelete(self, reason) --this will do nothing + if IsValid(self.room) then + self.room:OnWallObjDeletedOutsideOfGedRoomEditor(self) --this will remove from parent containers + end +end + +function SlabWallObject:Done() + self:RestoreAffectedSlabs() + DoneObjects(self.owned_slabs) + self.owned_slabs = false + if self.owned_objs then + DoneObjects(self.owned_objs) + end + self.owned_objs = false + + if not self.room or not self.side then return end + local isDoor = self:IsDoor() + local c + if isDoor then + c = self.room.spawned_doors and self.room.spawned_doors[self.side] + else + c = self.room.spawned_windows and self.room.spawned_windows[self.side] + end + if not c then return end + table.remove_entry(c, self) +end + +function SlabWallObject:ForEachAffectedWall(callback, ...) + for _, wall in ipairs(self.affected_walls or empty_table) do + if IsValid(wall) and wall.wall_obj == self then + local func = type(callback) == "function" and callback or wall[callback] + func(wall, ...) + end + end +end + +function SlabWallObject:RestoreAffectedSlabs() + SuspendPassEdits("SlabWallObject:RestoreAffectedSlabs") + for _, wall in ipairs(self.affected_walls or empty_table) do + if IsValid(wall) and wall.wall_obj == self then + wall:SetWallObj() + end + end + + self.affected_walls = nil + self.main_wall = nil + ResumePassEdits("SlabWallObject:RestoreAffectedSlabs") +end + +function SlabWallObject:SetProperty(id, value) + Slab.SetProperty(self, id, value) + if IsChangingMap() then return end + if id == "width" or id == "height" then + self:DelayedUpdateEntity() + end +end + +function SlabWallObject:PostLoad() + self:DelayedUpdateEntity() +end + +function SlabWallObject:UpdateEntity() + if self.is_destroyed then + if self:UpdateDestroyedState() then + return + end + end + + self:DestroyAttaches() + Slab.UpdateEntity(self) + AutoAttachObjects(self) + self:PostEntityUpdate() + self:RefreshEntityState() + self:RefreshClass() +end + +function SlabWallObject:CycleEntity(delta) + EditorSubVariantObject.CycleEntity(self, delta) + self:RefreshEntityState() + self:RefreshClass() + self:PostEntityUpdate() +end + +function SlabWallObject:RefreshClass() + --SlabWallObject is a cls that generates ent from given props + --SlabWallWindow and SlabWallWindowBroken are functional classes, where ents inherit them + local e = self:GetEntity() + if IsValidEntity(e) then + local cls = g_Classes[e] + if cls and IsKindOf(cls, "SlabWallObject") then + setmetatable(self, cls) + end + end +end + +function DbgChangeClassOfAllWindows() + CreateRealTimeThread(function() + ForEachMap(ListMaps(), function() + MapForEach("map", "SlabWallObject", function(obj) + obj:RefreshClass() + end) + SaveMap("no backup") + end) + end) +end + +function SlabWallObject:RefreshEntityState() + --cb for lockpickable +end + +function SlabWallObject:PostEntityUpdate() + self:UpdateAffectedWalls() + self:UpdateManagedSlabs() + self:UpdateManagedObj() +end + +function SlabWallObject:IsWindow() + return not self:IsDoor() +end + +function SlabWallObject:IsDoor() + return IsKindOfClasses(self, "SlabWallDoorDecor", "SlabWallDoor") or false +end + +function SlabWallObject:ForEachSlabPos(func, ...) + local width = self.width + local height = self.height + if width <= 0 or height <= 0 then return end + + local x, y, z = self:GetPosXYZ() + if not z then + z = terrain.GetHeight(x, y) + end + local side = self:GetSide() + + for w = 1, width do + for h = 1, self.height do + local tx, ty, tz, wf + tz = z + (h - 1) * const.SlabSizeZ + wf = (w - width/2 - 1) + if side == "E" then -- x = const + tx = x + ty = y + wf * const.SlabSizeY + elseif side == "W" then -- x = const + tx = x + ty = y - wf * const.SlabSizeY + elseif side == "N" then -- y = const + tx = x + wf * const.SlabSizeX + ty = y + else -- y = const + tx = x - wf * const.SlabSizeX + ty = y + end + + func(tx, ty, tz, ...) + end + end +end + +function SlabWallObject:UpdateAffectedWalls() + SuspendPassEdits("SlabWallObject:UpdateAffectedWalls") + + local old_aw = self.affected_walls or empty_table + local new_aw = {} + self.affected_walls = new_aw + self.main_wall = nil + + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + local x, y, z = self:GetPosXYZ() + if not z then + z = terrain.GetHeight(x, y) + end + local side = self:GetSide() + local width = Max(self.width, 1) + for w = 1, width do + for h = 1, self.height do + local tx, ty, tz, wf + tz = z + (h - 1) * const.SlabSizeZ + wf = (w - width/2 - 1) + if side == "E" then -- x = const + tx = x + ty = y + wf * const.SlabSizeY + elseif side == "W" then -- x = const + tx = x + ty = y - wf * const.SlabSizeY + elseif side == "N" then -- y = const + tx = x + wf * const.SlabSizeX + ty = y + else -- y = const + tx = x - wf * const.SlabSizeX + ty = y + end + + local is_main_pos = tx == x and ty == y and tz == z + local main_slab_candidates = is_main_pos and {} + MapForEach(tx, ty, tz, 0, "WallSlab", nil, nil, gameFlags, function(slab, self, is_main_pos, is_permanent) + local wall_obj = slab.wall_obj + if IsValid(wall_obj) and wall_obj ~= self and not new_aw[slab] then + return + end + if self.owned_slabs and table.find(self.owned_slabs, slab) then + return + end + if wall_obj ~= self and (is_permanent or slab:GetGameFlags(gofPermanent) == 0) then + -- non permanent wall objs should not affect permanent walls + slab:SetWallObj(self) + end + new_aw[slab] = true + table.insert(new_aw, slab) + if is_main_pos then + table.insert(main_slab_candidates, slab) + end + end, self, is_main_pos, is_permanent) + + if is_main_pos and #main_slab_candidates > 0 then + --first non roof slab, if any + self:PickMainWall(main_slab_candidates) + end + end + end + + if not self.main_wall and #new_aw > 0 then + --this makes it so that main_wall wont be in the same pos as the window/door, idk what that will break.. + --this is a fix for a fringe case where the slab at the door/window pos is deleted + self:PickMainWall(new_aw) + end + + for _, slab in ipairs(old_aw) do + if IsValid(slab) and slab.wall_obj == self and not new_aw[slab] then + slab:SetWallObj() + end + end + + ResumePassEdits("SlabWallObject:UpdateAffectedWalls") + + return old_aw +end + +function SlabWallObject:PickMainWall(t) + local iHaveRoom = not not self.room + local roofCandidate = false + local nonRoofCandidateDiffRoom = false + local nonRoofRotatedCandidate = false + self.main_wall = false + + for i = 1, #t do + local s = t[i] + local slabHasRoom = not not s.room + local anyRoomMissing = (not slabHasRoom or not iHaveRoom) + local slaba = s:GetAngle() + local selfa = self:GetAngle() + local angleIsTheSame = slaba == selfa + local angleIsReveresed = abs(slaba - selfa) == 180*60 + + if (slabHasRoom and s.room == self.room or + not iHaveRoom and + (angleIsTheSame or angleIsReveresed)) and + (anyRoomMissing or s.room.being_placed == self.room.being_placed) then + if not IsKindOf(s, "RoofWallSlab") then + if angleIsReveresed then + nonRoofRotatedCandidate = s + else + self.main_wall = s + return + end + elseif not roofCandidate then + roofCandidate = s + end + elseif not anyRoomMissing and s.room ~= self.room and angleIsTheSame then + nonRoofCandidateDiffRoom = s + end + end + + self.main_wall = self.main_wall or nonRoofCandidateDiffRoom or nonRoofRotatedCandidate or roofCandidate +end + +function SlabWallObject:DestroyAttaches() + if self.is_destroyed and string.find(GetStack(2), "SetAutoAttachMode") then + --todo: hack + return + end + Slab.DestroyAttaches(self, function(o, doNotDelete) + if IsKindOf(self, "EditorTextObject") and o == self.editor_text_obj then return end + return not doNotDelete or not IsKindOf(o, doNotDelete.class) + end, g_Classes.ConstructionSite) +end + +function SlabWallObject:SetManagedSlabsShadowOnly(val, clear_contour) + for i = 1, #(self.owned_slabs or "") do + local slab = self.owned_slabs[i] + if slab then --can be saved as false if deleted by user + slab:SetShadowOnly(val) + if clear_contour then + slab:ClearHierarchyGameFlags(const.gofContourInner) + end + end + end +end + +function OnMsg.EditorCallback(id, objs) + if id == "EditorCallbackDelete" then + for i = 1, #objs do + local o = objs[i] + if IsKindOf(o, "WallSlab") and o.always_visible and o:GetClipPlane() ~= 0 then + local x, y, z = o:GetPosXYZ() + local swo = MapGetFirst(x, y, z, 0, "SlabWallObject") + if swo and swo.owned_slabs then + local idx = table.find(swo.owned_slabs, o) + if idx then + swo.owned_slabs[idx] = false + end + end + end + end + end +end + +function SlabWallObject:UpdateManagedSlabs() + if self.width == 0 then + --small window + local main = self.main_wall + + --clipboard cpy paste fix, owned_slabs contains empty tables + for i = 1, #(self.owned_slabs or "") do + local s = self.owned_slabs[i] + if not IsValid(s) then + self.owned_slabs[i] = nil + end + end + + if not main or (main:GetAngle() ~= self:GetAngle() or self.room and self.room ~= main.room) then + self:UpdateAffectedWalls() + main = self.main_wall + if not main then + DoneObjects(self.owned_slabs) + self.owned_slabs = false + return + end + end + + if self.owned_slabs and self.owned_slabs[1] == false then + --happens after copy + DoneObjects(self.owned_slabs) + self.owned_slabs = false + end + + if not self.owned_slabs then + self.owned_slabs = {} + local s = WallSlab:new({always_visible = true, forceInvulnerableBecauseOfGameRules = false}) + table.insert(self.owned_slabs, s) + s = WallSlab:new({always_visible = true, forceInvulnerableBecauseOfGameRules = false}) + table.insert(self.owned_slabs, s) + end + + local bb = self:GetObjectBBox() + local isVerticalAligned = self:GetAngle() % (180 * 60) == 0 + local mx, my, mz = main:GetPosXYZ() + local ma = main:GetAngle() + local destroyed = self.is_destroyed + + for i = 1, 2 do + local s = self.owned_slabs[i] + if IsValid(s) then --this is false when deleted manually and saved + s:SetPosAngle(mx, my, mz, ma) + s.material = main.material + s.variant = main.variant + s.indoor_material_1 = main.indoor_material_1 + s.indoor_material_2 = main.indoor_material_2 + s.subvariant = main.subvariant + s:UpdateEntity() + s:UpdateVariantEntities() + local room = main.room + s:SetColorModifier(main:GetColorModifier()) + s:Setcolors(main.colors or room and room.outer_colors) + s:Setinterior_attach_colors(main.interior_attach_colors or room and room.inner_colors) + s:Setexterior_attach_colors(main.exterior_attach_colors) + s:Setexterior_attach_colors_from_nbr(main.exterior_attach_colors_from_nbr) + s:SetWarped(main:GetWarped()) + collision.SetAllowedMask(s, 0) + --save fixup, default state of slabs is invulnerable, so all small window helper slabs are now all saved as such.. + s.forceInvulnerableBecauseOfGameRules = false --TODO:remove + s.invulnerable = false --TODO:remove + + if destroyed ~= s.is_destroyed then + if destroyed then + s:Destroy() + else + s:Repair() + end + end + + local p1, p2, p3 + + if i == 2 then + p3 = bb:min() + p1 = p3 + point(0, 0, bb:sizez()) + if isVerticalAligned then + p2 = p1 + point(bb:sizex(), 0, 0) + else + p2 = p1 + point(0, bb:sizey(), 0) + end + else + p1 = bb:max() + p3 = p1 - point(0, 0, bb:sizez()) + if isVerticalAligned then + p2 = p1 - point(bb:sizex(), 0, 0) + else + p2 = p1 - point(0, bb:sizey(), 0) + end + end + + if isVerticalAligned then + --east/west needs inversion + p1, p3 = p3, p1 + end + + --DbgAddVector(p1, p2 - p1) + --DbgAddVector(p2, p3 - p2) + --DbgAddVector(p3, p1 - p3) + + s:SetClipPlane(PlaneFromPoints( p1, p2, p3 )) + else + self.owned_slabs[i] = false + end + end + else + if self.owned_slabs then + DoneObjects(self.owned_slabs) + end + self.owned_slabs = false + end +end + +local function TryGetARepresentativeWall(self, wall) + local mw + if not mw then + --mw = MapGetFirst(self, 0, "WallSlab", nil, const.efVisible) + local aw = self.affected_walls + for i = 1, #(aw or "") do + local w = aw[i] + if w.invisible_reasons and not w.invisible_reasons["suppressed"] then + mw = w + break + end + end + + end + return mw or self.main_wall +end + +local function TryFigureOutInteriorMaterialOnTheExteriorSide(self) + local m, c, _ + local mw = TryGetARepresentativeWall(self) + + if mw then + if self:GetAngle() == mw:GetAngle() then + m = mw.indoor_material_2 + _, c = mw:GetAttachColors() + else + m = mw.indoor_material_1 + c = mw:GetAttachColors() + end + else + m = self.material + end + + + return m ~= noneWallMat and m or false, c or self.colors or self:GetDefaultColor() +end + +local function TryFigureOutInteriorMaterial(self) + local m, c, _ + local mw = TryGetARepresentativeWall(self) + + if mw then + if self:GetAngle() == mw:GetAngle() then + m = mw.indoor_material_1 + c = mw:GetAttachColors() + else + m = mw.indoor_material_2 + _, c = mw:GetAttachColors() + end + elseif self.room then + m = self.room.inner_wall_mat + c = self.room.inner_colors + end + + + return m ~= noneWallMat and m or false, c +end + +local function GetAttchEntName(e, material) + if material then + local e = string.format("%s_Int_%s", e, material) + if IsValidEntity(e) then + return e + end + end + return string.format("%s_Int", e) +end + +function SlabWallObject:UpdateManagedObj() + --some windows are composed from more than one entity for kicks + --it musn't be attached or the colors don't work. + if not self.is_destroyed then + local function setupObj(ea, color, idx, si) + local t = self.owned_objs + if not t then + t = {} + for i = 1, idx - 1 do + t[i] = false + end + self.owned_objs = t + end + + if not IsValid(t[idx]) then + t[idx] = PlaceObject("Object") + end + + local o = t[idx] + if o:GetEntity() ~= ea then + o:ChangeEntity(ea) + end + o:SetPos(self:GetSpotPos(si)) + o:SetAngle(self:GetSpotAngle2D(si)) + SetSlabColorHelper(o, color) + end + + local function manageObj(spotName, idx, mat_func) + local added = false + if self:HasSpot(spotName) then + local si = self:GetSpotBeginIndex(spotName) + local e = self:GetEntity() + local material, color = mat_func(self) + local ea = GetAttchEntName(e, material) + + if IsValidEntity(ea) then + setupObj(ea, color, idx, si) + added = true + else + print("SlabWallObject has a " .. spotName .. " spot defined but no ent found to place there [" .. ea .. "]") + end + end + if not added then + local t = self.owned_objs + if t and IsValid(t[idx]) then + DoneObject(t[idx]) + t[idx] = false + end + end + end + + manageObj("Interior1", 1, TryFigureOutInteriorMaterial) + manageObj("Interior2", 2, TryFigureOutInteriorMaterialOnTheExteriorSide) + + local t = self.owned_objs + if t and not IsValid(t[1]) and not IsValid(t[2]) then + self.owned_objs = false + end + else + if self.owned_objs then + DoneObjects(self.owned_objs) + end + self.owned_objs = false + end +end + +function SlabWallObject:SetShadowOnly(val, ...) + Slab.SetShadowOnly(self, val, ...) + for _, o in ipairs(self.owned_objs or empty_table) do + o:SetShadowOnly(val, ...) + end +end + +function SlabWallObject:GetPlaceClass() + return self +end + +function SlabWallObject:GetError() + local lst = MapGet(self, 0, "SlabWallObject") + if #lst > 1 then + return "Stacked doors/windows!" + end + + self:ForEachSlabPos(function(x, y, z) + local slb = MapGetFirst(x, y, z, 0, "WallSlab") + if slb then + if slb.wall_obj ~= self then + return "Stacked doors/windows!" + end + end + end) +end + +DefineClass.SlabWallDoorDecor = { --SlabWallDoor carries logic in zulu, SlabWallDoorDecor == SlabWallObject but is considered a door by IsDoor + __parents = { "SlabWallObject" }, + fx_actor_class = "Door", +} + +DefineClass("SlabWallDoor", "SlabWallDoorDecor") --door with decals +DefineClass("SlabWallWindow", "SlabWallObject") --window with decals +DefineClass("SlabWallWindowBroken", "SlabWallObject") + +function GetFloorAlignedObj(gx, gy, gz, class) + local x, y, z = VoxelToWorld(gx, gy, gz) + return MapGetFirst(x, y, z, 0, class, nil, efVisible) +end + +function GetWallAlignedObj(gx, gy, gz, dir, class) + local x, y, z = WallVoxelToWorld(gx, gy, gz, dir) + return MapGetFirst(x, y, z, 0, class, nil, efVisible) +end + +function GetWallAlignedObjs(gx, gy, gz, dir, class) + local x, y, z = WallVoxelToWorld(gx, gy, gz, dir) + return MapGet(x, y, z, 0, class, nil, nil, gofPermanent) or empty_table +end + +function GetFloorSlab(gx, gy, gz) + return GetFloorAlignedObj(gx, gy, gz, "FloorSlab") +end + +function GetWallSlab(gx, gy, gz, side) + return GetWallAlignedObj(gx, gy, gz, side, "WallSlab") +end + +function GetWallSlabs(gx, gy, gz, side) + return GetWallAlignedObjs(gx, gy, gz, side, "WallSlab") +end + +function GetStairSlab(gx, gy, gz) + return GetFloorAlignedObj(gx, gy, gz, "StairSlab") +end + +function EnumConnectedFloorSlabs(x, y, z, visited) + local queue, objs = {}, {} + visited = visited or {} + + local function push(x, y, z) + local hash = slab_hash(x, y, z) + if visited[hash] then return end + visited[hash] = true + + local slab = GetFloorSlab(x, y, z) + if not slab or visited[slab] then return end + visited[slab] = true + + table.insert_unique(objs, slab) + queue[#queue + 1] = { x = x, y = y, z = z } + end + + push(x, y, z) + while #queue > 0 do + local loc = table.remove(queue) + push(loc.x + 1, loc.y, loc.z) + push(loc.x - 1, loc.y, loc.z) + push(loc.x, loc.y + 1, loc.z) + push(loc.x, loc.y - 1, loc.z) + end + return objs +end + +function EnumConnectedWallSlabs(x, y, z, side, floor, enum_adjacent_sides, zdir, visited) + local queue, objs = {}, {} + visited = visited or {} + zdir = zdir or 0 + + local function push(x, y, z, side) + local hash = slab_hash(x, y, z, side) + if visited[hash] then return end + visited[hash] = true + + local slab = GetWallSlab(x, y, z, side) + if not slab or (floor and slab.floor ~= floor) or visited[slab] then return end + visited[slab] = true + + table.insert_unique(objs, slab) + queue[#queue + 1] = { x = x, y = y, z = z, side = side } + end + + push(x, y, z, side) + while #queue > 0 do + local loc = table.remove(queue) + if zdir >= 0 then + push(loc.x, loc.y, loc.z + 1, loc.side) + end + if zdir <= 0 then + push(loc.x, loc.y, loc.z - 1, loc.side) + end + if loc.side == "E" or loc.side == "W" then -- x = const + push(loc.x, loc.y + 1, loc.z, loc.side) + push(loc.x, loc.y - 1, loc.z, loc.side) + if enum_adjacent_sides then + push(loc.x, loc.y, loc.z, "S") + push(loc.x, loc.y, loc.z, "N") + + -- handle non-convex shapes + push(loc.x + 1, loc.y + 1, loc.z, "N") + push(loc.x - 1, loc.y + 1, loc.z, "N") + push(loc.x + 1, loc.y - 1, loc.z, "S") + push(loc.x - 1, loc.y - 1, loc.z, "S") + end + else -- y = const + push(loc.x + 1, loc.y, loc.z, loc.side) + push(loc.x - 1, loc.y, loc.z, loc.side) + if enum_adjacent_sides then + push(loc.x, loc.y, loc.z, "E") + push(loc.x, loc.y, loc.z, "W") + + -- handle non-convex shapes + push(loc.x + 1, loc.y + 1, loc.z, "W") + push(loc.x + 1, loc.y - 1, loc.z, "W") + push(loc.x - 1, loc.y + 1, loc.z, "E") + push(loc.x - 1, loc.y - 1, loc.z, "E") + end + end + end + return objs +end + +function EnumConnectedStairSlabs(x, y, z, zdir, visited) + local stair = GetStairSlab(x, y, z) + local objs = {} + + visited = visited or {} + zdir = zdir or 0 + + if stair then + objs[1] = stair + visited[stair] = true + local first, last, all + if zdir >= 0 then + first, last, all = stair:TraceConnectedStairs(1) + table.iappend(objs, all) + for _, obj in ipairs(all) do + visited[obj] = true + end + end + if zdir <= 0 then + first, last, all = stair:TraceConnectedStairs(-1) + table.iappend(objs, all) + for _, obj in ipairs(all) do + visited[obj] = true + end + end + end + return objs +end + +function FindConnectedWallSlab(obj) + if IsKindOf(obj, "WallSlab") then + return obj + elseif IsKindOf(obj, "SlabWallObject") then + return obj.main_wall + elseif IsKindOf(obj, "FloorSlab") then + local x, y, z = obj:GetGridCoords() + local tiles = EnumConnectedFloorSlabs(x, y, z) + for _, tile in ipairs(tiles or empty_table) do + x, y, z = tile:GetGridCoords() + for _, side in ipairs(slab_sides) do + local slab = GetWallSlab(x, y, z, side) + if IsValid(slab) then + return slab + end + end + end + end +end + +function FindConnectedFloorSlab(obj) + if IsKindOf(obj, "FloorSlab") then + return obj + end + if IsKindOf(obj, "SlabWallObject") then + obj = obj.main_wall + end + if IsKindOf(obj, "WallSlab") then + local x, y, z = obj:GetGridCoords() + local walls = EnumConnectedWallSlabs(x, y, z, obj:GetSide(), obj.floor) + for _, wall in ipairs(walls) do + x, y, z = wall:GetGridCoords() + local slab = GetFloorSlab(x, y, z) + if IsValid(slab) and slab.floor == obj.floor then + return slab + end + end + end +end + +function SlabsPushUp(gx, gy, gz, visited) + visited = visited or {} + local walls, floors = {}, {} + local objs + + -- start by enumerating all connected slabs in the given grid coords + floors = EnumConnectedFloorSlabs(gx, gy, gz, visited) + for _, side in ipairs(slab_sides) do + objs = EnumConnectedWallSlabs(gx, gy, gz, side, false, "enum adjacent", 1, visited) + if #objs > 0 then + table.iappend(walls, objs) + end + end + + -- enumerate the whole connected structure above + local iwall, ifloor = 1, 1 + while iwall <= #walls or ifloor <= #floors do + if iwall <= #walls then + local x, y, z = walls[iwall]:GetGridCoords() + objs = EnumConnectedWallSlabs(x, y, z, walls[iwall]:GetSide(), false, "enum adjacent", 1, visited) + if #objs > 0 then + table.iappend(walls, objs) + end + objs = EnumConnectedFloorSlabs(x, y, z, visited) + if #objs > 0 then + table.iappend(floors, objs) + end + iwall = iwall + 1 + end + if ifloor <= #floors then + -- no need to check for other floors, they would be enumerated already + local x, y, z = floors[ifloor]:GetGridCoords() + for _, side in ipairs(slab_sides) do + objs = EnumConnectedWallSlabs(x, y, z, side, false, "enum adjacent", 1, visited) + if #objs > 0 then + table.iappend(walls, objs) + end + end + ifloor = ifloor + 1 + end + end + + -- push up all enumerated objects by sz + for _, obj in ipairs(floors) do + local x, y, z = obj:GetPosXYZ() + local gx, gy, gz = obj:GetGridCoords() + obj:SetPos(x, y, z + sz) + + local stairs = EnumConnectedStairSlabs(gx, gy, gz) + for i, stair in ipairs(stairs) do + x, y, z = stair:GetPosXYZ() + stair:SetPos(x, y, z + sz) + end + end + for _, obj in ipairs(walls) do + local x, y, z = obj:GetPosXYZ() + obj:SetPos(x, y, z + sz) + if IsValid(obj.wall_obj) and obj.wall_obj.main_wall == obj then + obj.wall_obj:SetPos(x, y, z + sz) + end + end +end +------------------------------------------------------------------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------------------------------------------------------- +function ComposeCornerPlugName(mat, crossingType, variant) + variant = variant or "01" + local ret = string.format("WallExt_%s_Cap%s_%s", mat, crossingType, variant) + return ret +end + +function ComposeCornerBeamName(mat, interiorExterior, variant, svd) + variant = variant or "01" + interiorExterior = interiorExterior or "Ext" + local ret = string.format("Wall%s_%s_Corner_%s", interiorExterior, mat, variant) + return ret +end + +DefineClass.RoomCorner = { + __parents = { "Slab", "CornerAlignedObj", "ComponentExtraTransform", "HideOnFloorChange" }, + properties = { + { id = "ColorModifier", dont_save = true, }, + { id = "isPlug", editor = "bool", default = false }, + }, + + room_container_name = "spawned_corners", +} + +function RoomCorner:SetProperty(id, value) + EditorCallbackObject.SetProperty(self, id, value) +end + +function RoomCorner:Setentity(val) + if not IsValidEntity(val) then + --print(self.handle) + self:ReportMissingSlabEntity(val) + return + end + + self.entity = val + self:ChangeEntity(val) + self:ResetVisibilityFlags() +end + +function RoomCorner:GetAttachColors() + return false, false +end + +function RoomCorner:UpdateEntity() + self.bad_entity = nil + if self.is_destroyed then --TODO: do corner destroyed nbrs matter? + if self:UpdateDestroyedState() then + return + end + end + + local pos = self:GetPos() + local newEnt = "InvisibleObject" + local angle = 0 + local dir = self.side + local room = self.room + if not room then return end + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + local mat = self.material + local faceThis + if mat ~= noneWallMat then + local amIRoof = not not IsKindOf(self, "RoofCornerWallSlab")-- and "RoofWallSlab" or "WallSlab" + local alwaysVisibleSlabsPresent = false + local walls = MapGet(pos, voxelSizeX, "WallSlab", nil, nil, gameFlags, function(o, self, amIRoof, is_permanent) + if not is_permanent and o:GetGameFlags(gofPermanent) ~= 0 then return end + if not self:ShouldUpdateEntity(o) then return end + local clsTest = amIRoof == not not IsKindOf(o, "RoofWallSlab") + if not clsTest then return end + --gofshadow should not be considered here, it would cause ent change because of temp invisible things, I can't recall what it fixes either + local visible = (o:GetEnumFlags(const.efVisible) ~= 0 or IsValid(o.wall_obj)) + if not visible then return end + + local x, y, z = o:GetPosXYZ() + if z ~= pos:z() then return end + + alwaysVisibleSlabsPresent = alwaysVisibleSlabsPresent or o.always_visible + + return true + end, self, amIRoof, is_permanent) or empty_table + + --filter out walls on the same positions + if (amIRoof or alwaysVisibleSlabsPresent) and #walls > 1 then + + local pos_top = pos:AddZ(voxelSizeZ) + pos_top = pos_top:SetZ(Min(room and room:GetRoofZAndDir(pos_top) or pos_top:z(), pos_top:z())) + + for i = #walls, 1, -1 do + local wall_i = walls[i] + local pos_i = wall_i:GetPos() + local height_i = wall_i.room and wall_i.room:GetRoofZAndDir(pos_i) or 0 + + for j = 1, #walls do + local wall_j = walls[j] + if wall_j ~= wall_i then + local pos_j = wall_j:GetPos() + if pos_i == pos_j then + if wall_i.room == wall_j.room or wall_i.room ~= room then + table.remove(walls, i) + end + break + elseif amIRoof and wall_i.room ~= wall_j.room then + local go = true + local other_room = wall_i.room ~= room and wall_i.room or wall_j.room + if other_room ~= room then + if IsValid(other_room) and (other_room:GetRoofZAndDir(pos_top) or 0) > pos_top:z() then + go = false --don't drop lower roof slabs if we stick out + end + end + + if go then + local height_j = wall_j.room and wall_j.room:GetRoofZAndDir(pos_j) or 0 + if height_i < height_j then + table.remove(walls, i) + break + end + end + end + end + end + end + end + + local ext_material_list = Presets.SlabPreset.SlabMaterials or empty_table + local int_material_list = Presets.SlabPreset.SlabIndoorMaterials or empty_table + local esvd = ext_material_list[mat] + local is_inner_none = room.inner_wall_mat == noneWallMat + local inner_mat_to_use = not is_inner_none and room.inner_wall_mat or mat + local isvd = int_material_list[inner_mat_to_use] + local variantStr = false + if self.subvariant ~= -1 then + local digit = self.subvariant + variantStr = digit < 10 and "0" .. tostring(digit) or tostring(digit) + end + + if #walls > 1 then + if #walls == 2 then + local p1 = walls[1]:GetPos() - pos + local p2 = walls[2]:GetPos() - pos + if p1:x() ~= p2:x() and p1:y() ~= p2:y() then --else they are on the same plane and corner should be invisible + local x = p1:x() ~= 0 and p1:x() or p2:x() + local y = p1:y() ~= 0 and p1:y() or p2:y() + if x < 0 and y < 0 then + angle = 0 + elseif x < 0 and y > 0 then + angle = 270 * 60 + elseif x > 0 and y > 0 then + angle = 180 * 60 + elseif x > 0 and y < 0 then + angle = 90 * 60 + end + local d = slabCornerAngleToDir[angle] + + if self.isPlug then + if self.material == "Concrete" and dir ~= d then + newEnt = ComposeCornerPlugName(mat, "D") + end + if newEnt == "InvisibleObject" or not IsValidEntity(newEnt) then + newEnt = ComposeCornerPlugName(mat, "L") + end + else + if d ~= dir or + walls[1].variant == "IndoorIndoor" or + walls[2].variant == "IndoorIndoor" then + --What this does is that ExEx sets will use Ext corners for places where Int corners should be used. + --This may not be exactly correct, the pedantically correct way would be for ExEx materials to use ExEx corners and have an ExExInt and an ExExExt variants. + --Leave as is until someone needs it. + local interior_exterior = not is_inner_none and "Int" or "Ext" + + if not variantStr and isvd then + local subvariants, total = GetMaterialSubvariants(isvd, "corner_subvariants") + if subvariants and #subvariants > 0 then + local random = self:GetSeed(total) + + newEnt = GetRandomSubvariantEntity(random, subvariants, function(suffix, mat, interior_exterior) + return ComposeCornerBeamName(mat, interior_exterior, suffix) + end, inner_mat_to_use, interior_exterior) or ComposeCornerBeamName(inner_mat_to_use, interior_exterior) + end + end + + if newEnt == "InvisibleObject" then + newEnt = ComposeCornerBeamName(inner_mat_to_use, interior_exterior, variantStr) + end + else + if not variantStr and esvd then + local subvariants, total = GetMaterialSubvariants(esvd, "corner_subvariants") + if subvariants and #subvariants > 0 then + local random = self:GetSeed(total) + + newEnt = GetRandomSubvariantEntity(random, subvariants, function(suffix, mat) + return ComposeCornerBeamName(mat, "Ext", suffix) + end, mat) or ComposeCornerBeamName(mat, "Ext") + end + end + + if newEnt == "InvisibleObject" then + newEnt = ComposeCornerBeamName(mat, "Ext", variantStr) + end + end + end + end + elseif #walls == 3 then + if self.isPlug then + newEnt = ComposeCornerPlugName(mat, "T") + local a1 = walls[1]:GetAngle() + local a2 = walls[2]:GetAngle() + local a3 = walls[3]:GetAngle() + local delim = 180 * 60 + local orthoEl + + if a1 % delim == a2 % delim then + orthoEl = walls[3] + elseif a1 % delim == a3 % delim then + orthoEl = walls[2] + else + orthoEl = walls[1] + end + + local toMe = pos - orthoEl:GetPos() + faceThis = pos + toMe + end + elseif #walls == 4 then + if self.isPlug then + newEnt = ComposeCornerPlugName(mat, "X") + end + end + end + end + + if not IsValidEntity(newEnt) then + if self.subvariant == 1 or self.subvariant == -1 then + --presumably if var 1 is missing nothing can be done + self:ReportMissingSlabEntity(newEnt) + newEnt = "InvisibleObject" + else + print("Reverting corner [" .. self.handle .. "] subvariant [" .. self.subvariant .. "] because no entity [" .. newEnt .. "] found.") + self.subvariant = -1 + self:UpdateEntity() + return + end + end + + if newEnt ~= self.entity or IsChangingMap() then + self:Setentity(newEnt) + self:ApplyMaterialProps() + end + + if faceThis then + self:Face(faceThis) + else + self:SetAngle(angle) + end + + self:SetColorFromRoom() +end + +function RoomCorner:SetColorFromRoom() + local room = self.room + if not room then return end + local rm = self:GetColorsRoomMember() + self:Setcolors(self.colors or room[rm]) +end + +function RoomCorner:GetColorsRoomMember() + local room = self.room + if not room then + return self.colors_room_member + end + if slabCornerAngleToDir[self:GetAngle()] == self.side or room.inner_wall_mat == noneWallMat then + return "outer_colors" + else + return "inner_colors" + end +end +------------------------------------------------------------------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------------------------------------------------------- +------------------------------------------------------------------------------------------------------------------------------------------------- +g_BoxesToCompute = false +local BoxIntersect = box().Intersect +local irInside = const.irInside +local function DoesBoxEncompassBox(b1, b2) + return BoxIntersect(b1, b2) == irInside +end + +MapGameTimeRepeat("ComputeSlabVisibility", nil, function() + if not g_BoxesToCompute then + WaitWakeup() + end + ComputeSlabVisibility() +end) +--the order in which MapGameTimeRepeat get registered determines when they'll execute, +--we want DelayedSlabUpdate to be after ComputeSlabVisibility if they are concurent +MapGameTimeRepeat("DelayedSlabUpdate", -1, function(sleep) + SlabUpdate() + if first then + Msg("SlabsDoneLoading") + first = false + end + WaitWakeup() +end) + +function ComputeSlabVisibilityOfObjects(objs) + local bbox = empty_box + for _, obj in ipairs(objs) do + bbox = AddRects(bbox, obj:GetObjectBBox()) + end + ComputeSlabVisibilityInBox(bbox) +end + +function ComputeSlabVisibilityInBox(box) + if not box or box:IsEmpty2D() then + return + end + g_BoxesToCompute = g_BoxesToCompute or {} + local boxes = g_BoxesToCompute + for i = 1, #boxes do + local bi = boxes[i] + if bi == box then + --same + return + end + if DoesBoxEncompassBox(bi, box) then + --fully encompassed + return + elseif DoesBoxEncompassBox(box, bi) then + boxes[i] = box + return + end + end + --DbgAddBox(box) + NetUpdateHash("ComputeSlabVisibilityInBox", box) + table.insert(boxes, box) + DelayedComputeSlabVisibility() +end + +function DelayedComputeSlabVisibility() + Wakeup(PeriodicRepeatThreads["ComputeSlabVisibility"]) +end + +local function TestMaterials(myMat, hisMat, reverseNoneForMe, reverseNoneForHim) + --first ret true -- someone will get suppressed + --second ret true - otherSlab will get suppressed, false - slab will get suppressed + if hisMat == noneWallMat then + return true, reverseNoneForHim and true or false + elseif myMat == noneWallMat and hisMat ~= noneWallMat then + return true, not reverseNoneForMe and true or false + end + + return false +end + +function Slab:IsOffset() + return false +end + +function RoofWallSlab:IsOffset() + local x1, y1, z1 = WallVoxelToWorld(WallWorldToVoxel(self)) + local x2, y2, z2 = self:GetPosXYZ() + return x1 ~= x2 or y1 ~= y2 +end + +-- 1 if otherSlab should be suppressed +-- -1 if self should be suppressed +-- 0 if noone is suppress +function WallSlab:ShouldSuppressSlab(otherSlab, material_preset) + if self:IsSuppressionDisabled(otherSlab) then + return 0 + end + + local importance_test = self:SuppressByImportance(otherSlab) + if importance_test ~= 0 then + return importance_test + end + + local amIRoof = IsKindOf(self, "RoofWallSlab") + local isHeRoof = IsKindOf(otherSlab, "RoofWallSlab") + if isHeRoof and not amIRoof then + return 1 + elseif not isHeRoof and amIRoof then + return -1 + end + + local mr = self.room + local reverseNoneForMe = IsValid(mr) and (amIRoof and mr.none_roof_wall_mat_does_not_affect_nbrs or not amIRoof and mr.none_wall_mat_does_not_affect_nbrs) or false + local hr = otherSlab.room + local reverseNoneForHim = IsValid(hr) and (isHeRoof and hr.none_roof_wall_mat_does_not_affect_nbrs or not isHeRoof and hr.none_wall_mat_does_not_affect_nbrs) or false + local r1, r2 = TestMaterials(self.material, otherSlab.material, reverseNoneForMe, reverseNoneForHim) + + if r1 then + return r2 and 1 or -1 + end + + if isHeRoof and amIRoof then + return 0 + end + + local material_test = self:SuppressByMaterial(otherSlab, material_preset) + if material_test ~= 0 then + return material_test + end + + if IsValid(self.room) and IsValid(otherSlab.room) then + return self.room.handle - otherSlab.room.handle + end + if not IsValid(self.room) and IsValid(otherSlab.room) then + return -1 + end + if IsValid(self.room) and not IsValid(otherSlab.room) then + return 1 + end + + return self.handle - otherSlab.handle +end + +-- 1 if otherSlab should be suppressed +-- -1 if self should be suppressed +-- 0 if noone is suppress +function FloorSlab:ShouldSuppressSlab(otherSlab, material_preset) + if self:IsSuppressionDisabled(otherSlab) then + return 0 + end + + local importance_test = self:SuppressByImportance(otherSlab) + if importance_test ~= 0 then + return importance_test + end + + local reverseNoneForMe = IsValid(self.room) and self.room.none_floor_mat_does_not_affect_nbrs or false + local reverseNoneForHim = IsValid(otherSlab.room) and otherSlab.room.none_floor_mat_does_not_affect_nbrs or false + local r1, r2 = TestMaterials(self.material, otherSlab.material, reverseNoneForMe, reverseNoneForHim) + + if r1 then + return r2 and 1 or -1 + end + + local material_test = self:SuppressByMaterial(otherSlab, material_preset) + if material_test ~= 0 then + return material_test + end + + if IsValid(self.room) and IsValid(otherSlab.room) then + local amIRoof = self.room:IsRoofOnly() + local isHeRoof = otherSlab.room:IsRoofOnly() + if isHeRoof and not amIRoof then + return 1 + elseif amIRoof and not isHeRoof then + return -1 + end + + return self.room.handle - otherSlab.room.handle + end + if not IsValid(self.room) and IsValid(otherSlab.room) then + return -1 + end + if IsValid(self.room) and not IsValid(otherSlab.room) then + return 1 + end + return self.handle - otherSlab.handle +end + +CeilingSlab.ShouldSuppressSlab = FloorSlab.ShouldSuppressSlab + +cornerToWallSides = { + East = { "East", "North" }, + South = { "East", "South" }, + West = { "West", "South" }, + North = { "West", "North" }, +} + +-- 1 if otherSlab should be suppressed +-- -1 if self should be suppressed +-- 0 if noone is suppress +function RoomCorner:ShouldSuppressSlab(otherSlab, material_preset) + if self:IsSuppressionDisabled(otherSlab) then + return 0 + end + + local importance_test = self:SuppressByImportance(otherSlab) + if importance_test ~= 0 then + return importance_test + end + + local amIRoof = IsKindOf(self, "RoofCornerWallSlab") + local isHeRoof = IsKindOf(otherSlab, "RoofCornerWallSlab") + if isHeRoof and not amIRoof then + return 1 + elseif not isHeRoof and amIRoof then + return -1 + elseif isHeRoof and amIRoof then + return 0 + end + + local r1, r2 = TestMaterials(self.material, otherSlab.material) + r2 = not r2 --reverse behavior, none corners should be below other corners + if r1 then return r2 and 1 or -1 end + + local material_test = self:SuppressByMaterial(otherSlab, material_preset) + if material_test ~= 0 then + return material_test + end + + if IsValid(self.room) and IsValid(otherSlab.room) then + --if one of the walls of an adjacent bld is disabled, corners of existing walls have precedence + local myC, hisC = 0, 0 + local myAdj = cornerToWallSides[self.side] + local hisAdj = cornerToWallSides[otherSlab.side] + for i = 1, 2 do + myC = myC + ((self.room:GetWallMatHelperSide(myAdj[i]) == noneWallMat) and 1 or 0) + hisC = hisC + ((otherSlab.room:GetWallMatHelperSide(hisAdj[i]) == noneWallMat) and 1 or 0) + end + if myC ~= hisC then + return hisC - myC + end + + return self.room.handle - otherSlab.room.handle + end + return self.handle - otherSlab.handle +end + +function CSlab:ShouldSuppressSlab(otherSlab) + return 0 +end + +function CSlab:SuppressByMaterial(slab, material_preset) + local mp_self = material_preset or self:GetMaterialPreset() + local mp_slab = slab:GetMaterialPreset() + return (mp_self and mp_self.strength or 0) - (mp_slab and mp_slab.strength or 0) +end + +function CSlab:SuppressByImportance(slab) + return self.class_suppression_strenght - slab.class_suppression_strenght +end + +function CSlab:IsSuppressionDisabled(slab) + return self == slab or self.always_visible or slab.always_visible +end + +function GetTopmostWallSlab(slab) + return MapGetFirst(slab, 0, "WallSlab", function(o) + return o.isVisible + end) +end + +CSlab.visibility_pass = 1 +function CSlab:ComputeVisibility(passed) + self:SetSuppressor(false) +end + +function CSlab:ComputeVisibilityAround() + ComputeSlabVisibilityInBox(self:GetObjectBBox()) +end + +local function PassSlab(slab, passed) + --for easier debug, all passing goes trhough here + passed[slab] = true +end + +local topMySide +local topOpSide +function OnMsg.DoneMap() + topMySide = nil + topOpSide = nil +end + +local function PassWallSlabs(slab, self, passed, mpreset) + if slab == self then + return + end + if slab:GetAngle() == self:GetAngle() then + local r = topMySide:ShouldSuppressSlab(slab, mpreset) + if r > 0 then + --slab is suppressed + PassSlab(slab, passed) + slab:SetSuppressor(topMySide, self) + elseif r < 0 then + if topMySide:SetSuppressor(slab, self) then + topMySide = slab + end + end + else + local r = topOpSide and topOpSide:ShouldSuppressSlab(slab, mpreset) or 0 + if r > 0 then + --slab is suppressed + PassSlab(slab, passed) + slab:SetSuppressor(topOpSide, self) + elseif r < 0 then + if topOpSide:SetSuppressor(slab, self) then + topOpSide = slab + end + end + topOpSide = topOpSide or slab + end +end + +function WallSlab:ComputeVisibility(passed) + --walls + passed = passed or {} + local mpreset = self:GetMaterialPreset() + topMySide = self + topOpSide = false + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + + MapForEach(self, 0, "WallSlab", nil, nil, gameFlags, PassWallSlabs, self, passed, mpreset) + + local top = topMySide + local variant = false + --take inner mats from tops, todo: maybe they should be calculated b4 hand from all slabs in spot? + local m1 = noneWallMat + local m2 = noneWallMat + local c2 + + --dont do innerinner variants for roof<->notroof combo + if top and topOpSide then + local isTopRoof = IsKindOf(top, "RoofWallSlab") + local isOpTopRoof = IsKindOf(topOpSide, "RoofWallSlab") + + if isTopRoof ~= isOpTopRoof then + if isTopRoof and not isOpTopRoof then + PassSlab(top, passed) + if top:SetSuppressor(topOpSide, self) then + top = topOpSide + topOpSide = false + end + elseif not isTopRoof and isOpTopRoof then + PassSlab(topOpSide, passed) + if topOpSide:SetSuppressor(top, self) then + topOpSide = false + end + end + end + end + + local opSideCompResult + if topOpSide then + variant = "IndoorIndoor" + opSideCompResult = top:ShouldSuppressSlab(topOpSide, mpreset) + if opSideCompResult > 0 then + topOpSide:SetSuppressor(top, self) + m1 = top.room and top.room.inner_wall_mat or top.indoor_material_1 or noneWallMat + m2 = topOpSide.room and topOpSide.room.inner_wall_mat or topOpSide.indoor_material_1 or noneWallMat + c2 = topOpSide.room and topOpSide.room.inner_colors + elseif opSideCompResult < 0 then + if top:SetSuppressor(topOpSide, self) then + top = topOpSide + end + m1 = top.room and top.room.inner_wall_mat or top.indoor_material_1 or noneWallMat + m2 = topMySide.room and topMySide.room.inner_wall_mat or topMySide.indoor_material_1 or noneWallMat + c2 = topMySide.room and topMySide.room.inner_colors + if top.wall_obj then + passed[top] = nil --topOpSide is visible, he needs to pass in case his covered up by a slabwallobj + end + elseif opSideCompResult == 0 then + passed[topOpSide] = nil --both are visible, this guy needs to pass to fix up his variant + m1 = top.room and top.room.inner_wall_mat or top.indoor_material_1 or noneWallMat --stacked roof walls, ignore his mat, use ours though. + end + + if m2 == noneWallMat and m1 == noneWallMat then + variant = "Outdoor" + elseif m2 == noneWallMat then + variant = "OutdoorIndoor" + elseif m1 == noneWallMat then + --this will hide opposing inner mat + variant = "Outdoor" + end + else + local indoorMat = top.room and top.room.inner_wall_mat or top.indoor_material_1 + if indoorMat == noneWallMat then + variant = "Outdoor" + else + variant = "OutdoorIndoor" + m1 = indoorMat + end + end + + if top.material == noneWallMat and not top.always_visible then + top:SetSuppressor(self) + else + if top.exterior_attach_colors == c2 then + --save fixup, TODO: remove + top:Setexterior_attach_colors(false) + end + + if top:ShouldUpdateEntity(self) + and (top.variant ~= variant or top.indoor_material_2 ~= m2 or top.exterior_attach_colors_from_nbr ~= c2) then + + local newVar = variant + if not IsValid(top.room) then + --slabs placed by hand have their variant preserved, unless forced + newVar = top.variant + m2 = top.indoor_material_2 + end + if top.forceVariant ~= "" then + newVar = top.forceVariant + end + + local defaults = getmetatable(top) + top.variant = newVar ~= defaults.variant and newVar or nil + top.indoor_material_2 = m2 ~= defaults.indoor_material_2 and m2 or nil + top:Setexterior_attach_colors_from_nbr(c2) + top:UpdateEntity() + top:UpdateVariantEntities() + end + + top:SetSuppressor(false, self) + end + + --supress roof walls if they are in the box of above rooms (2D box) + if top.room and not top.room:IsRoofOnly() and IsKindOf(top, "RoofWallSlab") then + local pos = top:GetPos() + local adjacent_rooms = top.room.adjacent_rooms or empty_table + for _, adj_room in ipairs(adjacent_rooms) do + local data = adjacent_rooms[adj_room] + if not adj_room.being_placed and + not adj_room:IsRoofOnly() and adj_room.box ~= data[1] then --ignore rooms inside our room + + local adj_box = adj_room.box:grow(1, 1, 0) + local in_box_3d = pos:InBox(adj_box) + + if not in_box_3d and top.room.floor < adj_room.floor then + local rb = adj_room.roof_box + if rb then + rb = rb:grow(1, 1, 0) + in_box_3d = pos:InBox(rb) + --[[ + --restore if needed + if in_box_3d then + --this part shows offset pieces that are partially in the roof box of a lower room + local b = top:GetObjectBBox() + local ib = IntersectRects(b, rb) + if ib:sizex() ~= b:sizex() and ib:sizey() ~= b:sizey() then + in_box_3d = false --at least one side should be fully in, else we are partially affected and will leave a whole + end + end + ]] + end + end + + if in_box_3d then + top:SetSuppressor(self) + if topOpSide and opSideCompResult == 0 then --topOpSide is still visible + topOpSide:SetSuppressor(self) + end + break + end + end + end + end + + topMySide = nil + topOpSide = nil +end + +local floor_top +function OnMsg.DoneMap() + floor_top = nil +end + +local function FloorPassFloorAndCeilingSlabs(slab, self, passed, mpreset) + if slab == self then + return + end + local comp = floor_top:ShouldSuppressSlab(slab, mpreset) + if comp == 0 then + return + end + PassSlab(slab, passed) + if comp > 0 then + slab:SetSuppressor(floor_top, self) + else + if floor_top:SetSuppressor(slab, self) then + floor_top = slab + end + end +end + +local function FloorPassRoofPlaneAndEdgeSlabs(slab, floor_top, topZ, passed, self) + local dz = topZ - select(3, slab:GetPosXYZ()) + if 0 < dz and dz <= voxelSizeZ then + PassSlab(slab, passed) + slab:SetSuppressor(floor_top, self) + end +end + +function FloorSlab:ComputeVisibility(passed) + passed = passed or {} + floor_top = self + local mpreset = self:GetMaterialPreset() + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + + MapForEach(self, 0, "FloorSlab", "CeilingSlab", nil, nil, gameFlags, + FloorPassFloorAndCeilingSlabs, self, passed, mpreset) + + if floor_top.material == noneWallMat then + floor_top:SetSuppressor(floor_top, self) + else + floor_top:SetSuppressor(false, self) + + --hide roofs 1 vox below 155029 + MapForEach(floor_top, halfVoxelSizeX, "RoofPlaneSlab", "RoofEdgeSlab", nil, nil, gameFlags, + FloorPassRoofPlaneAndEdgeSlabs, floor_top, select(3, floor_top:GetPosXYZ()), passed, self) + end + + floor_top = nil +end + +CeilingSlab.ComputeVisibility = FloorSlab.ComputeVisibility + +function SlabWallObject:ComputeVisibility(passed) + self:UpdateAffectedWalls() +end + +local roof_suppressed +function OnMsg.DoneMap() + roof_suppressed = nil +end + +local function RoofPassRoofEdgeAndCornerSlabs(slab, self, passed, z) + if slab == self or slab.room == self.room + or (slab:GetAngle() == self:GetAngle() and slab:GetMirrored() == self:GetMirrored()) + or self:IsSuppressionDisabled(slab) + then + return + end + local _, _, slab_z = slab:GetPosXYZ() + if z < slab_z then + roof_suppressed = true + return + end + PassSlab(slab, passed) + if slab:SetSuppressor(self) and z == slab_z then + roof_suppressed = true + end +end + +function RoofSlab:ComputeVisibility(passed) + passed = passed or {} + roof_suppressed = nil + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + + if self.room and not self.room:IsRoofOnly() then + --determine a list of rooms, inside whose volumes our self lies + local passed = {} + local function CheckSuppressed(adj_room, pos, adjacent_rooms, slab_room, passed) + local data = adjacent_rooms[adj_room] + if not passed[adj_room] then + passed[adj_room] = true + --ignore rooms inside our room + if slab_room ~= adj_room and + not adj_room:IsRoofOnly() and + not adj_room.being_placed and + (not data or adj_room.box ~= data[1]) + then + local adj_box = adj_room.box:grow(1,1,1) + local in_box = pos:InBox(adj_box) + + if not in_box and (slab_room.floor or 0) < (adj_room.floor or 0) then + local rb = adj_room.roof_box + if rb then + rb = rb:grow(1, 1, 0) + in_box = pos:InBox(rb) + end + end + + if in_box then + roof_suppressed = true + return "break" + end + end + end + end + + local pos = self:GetPos() + local adjacent_rooms = self.room.adjacent_rooms or empty_table + local sbox = self:GetObjectBBox() + EnumVolumes(sbox:grow(1, 1, 1), CheckSuppressed, pos, adjacent_rooms, self.room, passed) --this catches some rooms that are not considered adjacent (roof adjacency) + if not roof_suppressed then + --this catches other rooms the previous enum doesn't, normal adjacency + for _, adj_room in ipairs(adjacent_rooms) do + if CheckSuppressed(adj_room, pos, adjacent_rooms, self.room, passed) == "break" then + break + end + end + end + end + + --idk, but this seems to be the best looking option atm, of course that could change + if not roof_suppressed and const.SuppressMultipleRoofEdges and IsKindOfClasses(self, "RoofEdgeSlab", "RoofCorner") then + --if more than one in one pos, suppress all + local x, y, z = self:GetPosXYZ() + MapForEach(x, y, guic, "RoofEdgeSlab", "RoofCorner", nil, nil, gameFlags, + RoofPassRoofEdgeAndCornerSlabs, self, passed, z) + end + + if roof_suppressed then + self:SetSuppressor(self) + else + self:SetSuppressor(false) + end + roof_suppressed = nil +end + +local dev = Platform.developer +RoomCorner.visibility_pass = 2 +function RoomCorner:ComputeVisibility(passed) + local _, _, z = self:GetPosXYZ() + local topSlab = self + local mpreset = self:GetMaterialPreset() + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + + MapForEach(self, halfVoxelSizeX, "RoomCorner", nil, nil, gameFlags, function(slab, self, z, mpreset) + if slab == self then + return + end + local _, _, z1 = slab:GetPosXYZ() + if z1 ~= z or self.isPlug ~= slab.isPlug then + return --plugs suppress plugs only, corners suppress corners only + end + if slab:ShouldUpdateEntity(self) then + slab:UpdateEntity() --update all entities all the time, otherwise we get map save diffs when they get updated on load map + end + local comp = topSlab:ShouldSuppressSlab(slab, mpreset) + if comp > 0 then + slab:SetSuppressor(topSlab, self) + elseif comp < 0 then + if topSlab:SetSuppressor(slab, self) then + topSlab = slab + end + end + end, self, z, mpreset) + + if topSlab:ShouldUpdateEntity(self) then + topSlab:UpdateEntity() + end + + if topSlab.entity ~= "InvisibleObject" and IsValidEntity(topSlab.entity) or + dev and topSlab.entity == "InvisibleObject" and topSlab.is_destroyed then --so they can be seen and repaired by nbrs + topSlab:SetSuppressor(false, self) + else + topSlab:SetSuppressor(topSlab, self) + end +end + +StairSlab.visibility_pass = 3 +function StairSlab:ComputeVisibility(passed) + if self:GetEnumFlags(const.efVisible) == 0 then + return + end + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + local x, y, z = self:GetPosXYZ() + if z then + local max = self.hide_floor_slabs_above_in_range + for i = 1, max do + z = z + voxelSizeZ + MapForEach(x, y, z, 0, "FloorSlab", nil, nil, gameFlags, function(slab, self) + slab:SetSuppressor(self) + end, self) + end + else + print(string.format("Stairs with handle[%d] have an invalid Z!", stairs_slab.handle)) + end +end + +local function _ComputeSlabVisibility(boxes) + local passed = {} + local max_pass = 1 + local passes + for i = 1, #boxes do + local _box = boxes[i]:grow(1, 1, 1) -- include the wall elements + --query optimizes some children away, so they are added explicitly + MapForEach(_box, "CSlab", function(slab) + if passed[slab] then + return + end + local pass = slab.visibility_pass + if pass == 1 then + PassSlab(slab, passed) + slab:ComputeVisibility(passed) + else + max_pass = Max(max_pass, pass) + passes = passes or {} + passes[pass] = table.create_add(passes[pass], slab) + end + end) + end + + for i=2,max_pass do + for _, slab in ipairs(passes[i] or empty_table) do + if not passed[slab] then + PassSlab(slab, passed) + slab:ComputeVisibility(passed) + end + end + end +end + +function ComputeSlabVisibility() + local boxes = g_BoxesToCompute + g_BoxesToCompute = false + if not boxes then return end --nothing to compute + + SuspendPassEdits("ComputeSlabVisibility") + + procall(_ComputeSlabVisibility, boxes) + + ResumePassEdits("ComputeSlabVisibility") + Msg("SlabVisibilityComputeDone") +end + +function DeleteOrphanCorners() + MapForEach("map", "RoomCorner", nil, nil, gofPermanent, function(o) + if not o.room then + DoneObject(o) + end + end) +end + +function DeleteOrphanWalls() + local c = 0 + MapForEach("map", "WallSlab", nil, nil, gofPermanent, function(o) + if not o.room then + DoneObject(o) + c = c + 1 + end + end) + print("deleted: ", c) +end + +function RecreateSelectedSlabFloorWall_RestoreSel(t) + editor.ClearSel() + for i = 1, #t do + local entry = t[i] + local o = MapGet(entry[2], 0, entry[1], nil, nil, gofPermanent, function(o, room) + return o.room == room + end, entry[3]) + + if o then + editor.AddToSel(o) + end + end +end + +function RecreateSelectedSlabFloorWall() + local ol = editor.GetSel() + local restoreSel = {} + local walls = {} + local floors = {} + for i = 1, #ol do + local o = ol[i] + local room = o.room + if IsValid(room) then + if IsKindOf(o, "WallSlab") then + table.insert(restoreSel, {o.class, o:GetPos(), room}) + table.insert_unique(walls, room) + elseif IsKindOf(o, "FloorSlab") then + table.insert(restoreSel, {o.class, o:GetPos(), room}) + table.insert_unique(floors, room) + end + end + end + + for i = 1, #walls do + walls[i]:RecreateWalls() + end + + for i = 1, #floors do + floors[i]:RecreateFloor() + end + + if #restoreSel > 0 then + DelayedCall(0, RecreateSelectedSlabFloorWall_RestoreSel, restoreSel) + end +end + +local defaultColorMod = RGBA(100, 100, 100, 255) +function Slab:ApplyColorModFromSource(source) + local cm = source:GetColorModifier() + if cm ~= defaultColorMod then + self:SetColorModifier(cm) + end +end +function Slab:EditorCallbackClone(source) + self.room = false + self.subvariant = source.subvariant + self:Setcolors(source.colors) + self:Setinterior_attach_colors(source.interior_attach_colors) + self:Setexterior_attach_colors(source.exterior_attach_colors) + self:Setexterior_attach_colors_from_nbr(source.exterior_attach_colors_from_nbr) + self:ApplyColorModFromSource(source) +end + +function WallSlab:EditorCallbackClone(source) + Slab.EditorCallbackClone(self, source) + --TODO: rem this once colors auto copy + self:Setcolors(source.colors or source.room and source.room.outer_colors) + self:Setinterior_attach_colors(source.interior_attach_colors or source.room and source.room.inner_colors) + self:ApplyColorModFromSource(source) +end + +function FloorSlab:EditorCallbackClone(source) + Slab.EditorCallbackClone(self, source) + self:Setcolors(source.colors or source.room and source.room.floor_colors) + self:ApplyColorModFromSource(source) +end + +function SlabWallObject:EditorCallbackClone(source) + if self.owned_slabs == source.owned_slabs then + --props copy will sometimes provoke the creation of new slabs and sometimes it wont.. + self.owned_slabs = false + end + self.room = false + self.subvariant = source.subvariant + --aparently windows n doors use the default colorization + self:SetColorization(source) +end + +function RoofSlab:EditorCallbackClone(source) + Slab.EditorCallbackClone(self, source) + self:Setcolors(source.colors or source.room and source.room.roof_colors) + self:ApplyColorModFromSource(source) + local x, y = source:GetSkew() + self:SetSkew(x, y) +end + +function OnMsg.GedPropertyEdited(ged_id, obj, prop_id, old_value) + if obj.class == "ColorizationPropSet" then + -- escalate OnEditorSetProperty for the colorization nested object properties to the parent Room/Slab + local ged = GedConnections[ged_id] + local parents = ged:GatherAffectedGameObjects(obj) + for _, parent in ipairs(parents) do + if parent:IsKindOfClasses("Room", "Slab") then + local prop_id = "" + for _, value in ipairs(parent:GetProperties()) do + if parent:GetProperty(value.id) == obj then + prop_id = value.id + break + end + end + + if IsKindOf(parent, "Slab") then + ged:NotifyEditorSetProperty(parent, prop_id, obj) + parent:SetProperty(prop_id, obj) + elseif IsKindOf(parent, "Room") then + parent:OnEditorSetProperty(prop_id, old_value, ged) + end + end + end + end +end + +local similarSlabPropsToMatch = { + "entity_base_name", + "material", + "variant", + "indoor_material_1", + "indoor_material_2", +} + +function EditorSelectSimilarSlabs(matchSubvariant) + local sel = editor.GetSel() + local o = #(sel or "") > 0 and sel[1] + if not o then + print("No obj selected.") + return + end + if not IsKindOf(o, "Slab") then + print("Obj not a Slab.") + return + end + + local newSel = {o} + MapForEach("map", "Slab", function(s, o, newSel, similarSlabPropsToMatch) + if o ~= s then + local similar = true + for i = 1, #similarSlabPropsToMatch do + local p = similarSlabPropsToMatch[i] + if s[p] ~= o[p] then + similar = false + break + end + end + if similar then + if not matchSubvariant or s:GetEntity() == o:GetEntity() then + table.insert(newSel, s) + end + end + end + end, o, newSel, similarSlabPropsToMatch) + + editor.ClearSel() + editor.AddToSel(newSel) +end + +function DbgRestoreDefaultsFor_forceInvulnerableBecauseOfGameRules() + MapForEach("map", "Slab", function(o) + if not o.room then + --cls default + o.forceInvulnerableBecauseOfGameRules = g_Classes[o.class].forceInvulnerableBecauseOfGameRules + else + + if IsKindOf(o, "FloorSlab") and o.floor == 1 then + o.forceInvulnerableBecauseOfGameRules = true + else + --vulnerable + o.forceInvulnerableBecauseOfGameRules = false + end + end + end) +end + +slab_missing_entity_white_list = { +} + +function CSlab:ReportMissingSlabEntity(ent) + if not slab_missing_entity_white_list[ent] then + print(string.format("[WARNING] Missing slab entity %s, reporting slab handle [%d], class [%s], material [%s], variant [%s], map [%s]", (ent or tostring(ent)), self.handle, self.class, self.material, self.variant, GetMapName())) + slab_missing_entity_white_list[ent] = true + end + self.bad_entity = true +end + +function GetBadEntitySlabsOnMap() + return MapGet("map", "Slab", function(o) return o.bad_entity end) +end + +function IsSlabPassable(o) + if o:GetSkewX() == 0 and o:GetSkewY() == 0 then + local sp = o:GetSpotPos(o:GetSpotBeginIndex("Slab")) + if SnapToVoxel(sp) == sp then + return true + end + end + return false +end + +function ValidateSlabs() + local slabs = MapGet("map", "Slab", nil, nil, const.gofPermanent) + + local killedSlabs = 0 + local resetDestroyed = 0 + local passedNbrs = {} + local slabsWithDestroyedNbrs = {} + for _, slab in ipairs(slabs) do + local e = slab:GetEntity() + local isInvisibleObj = (e == "InvisibleObject") --these slabs suppress themselves, so isVisible is not a guarantee that its suppressed by another + local isInvisible = not slab.isVisible and not isInvisibleObj or slab.material == noneWallMat + if isInvisible and not slab.room then + --invisible manually placed slab - kill it + DoneObject(slab) + killedSlabs = killedSlabs + 1 + elseif isInvisible and (slab.is_destroyed or slab.destroyed_neighbours ~= 0) then + slab:ResetDestroyedState() + resetDestroyed = resetDestroyed + 1 + end + + if not isInvisible then + --fix neighbour data + if slab.is_destroyed then + local function proc(nbr, i) + passedNbrs[nbr] = true + local f = GetNeigbhourSideFlagTowardMe(1 << (i - 1), nbr, slab) + if (nbr.destroyed_neighbours & f) == 0 then + nbr.destroyed_neighbours = nbr.destroyed_neighbours | f + end + end + local nbrs = {slab:GetNeighbours()} + nbrs[1], nbrs[2], nbrs[3], nbrs[4] = nbrs[3], nbrs[4], nbrs[1], nbrs[2] --so masks fit, l,r,t,b -> t,b,l,r + for i = 1, 4 do + local nbrs2 = nbrs[i] + if IsValid(nbrs2) then + proc(nbrs2, i) + else + for j, nbr in ipairs(nbrs2) do + proc(nbr, i) + end + end + end + elseif slab.destroyed_neighbours ~= 0 then + slabsWithDestroyedNbrs[slab] = true + end + end + + if IsKindOf(slab, "Lockpickable") then + --save fixup + if slab:IsBlockedDueToRoom() and slab.lockpickState ~= "blocked" then + slab:SetlockpickState("blocked") + end + end + end + + for slab, _ in pairs(slabsWithDestroyedNbrs) do + if not passedNbrs[slab] then + for i = 0, 3 do + local dn = slab.destroyed_neighbours + local f = 1 << i + if (dn & f) ~= 0 then + local nbr = slab:GetNeighbour(f) + if nbr and not nbr.is_destroyed then + slab.destroyed_neighbours = slab.destroyed_neighbours & ~f + end + end + end + end + end + + if killedSlabs > 0 then + print("Killed invisible roomless slabs ", killedSlabs) + end + if resetDestroyed > 0 then + print("Repaired invisible destroyed slabs ", resetDestroyed) + end + + EnumVolumes(function(v) + if v.ceiling_mat ~= noneWallMat and not v.build_ceiling then + v.ceiling_mat = nil --the set material is irrelevant since there is no ceiling, revert to default + end + end) +end + +function OnMsg.ValidateMap() + if IsEditorActive() and not mapdata.IsRandomMap and mapdata.GameLogic then + ValidateSlabs() + end +end + +function testInvulnerableSlabs(lst) + lst = lst or MapGet("map", "RoomCorner", const.efVisible, function(o) return o.forceInvulnerableBecauseOfGameRules end) + DbgClear() + local c = 0 + for i = 1, #(lst or "") do + DbgAddVector(lst[i]) + c = c + 1 + end + print(c, #(lst or "")) +end + +------------------------------------ +--maps are broken again, map fixing stuff +------------------------------------ +local invulnerableMaterials = { + ["Concrete"] = true +} + +function FixInvulnerabilityStateOfOwnedSlabsOnMap() + local function makeInvul(o, val) + o.invulnerable = val + o.forceInvulnerableBecauseOfGameRules = val + end + EnumVolumes(function(r) + local invul = r.outside_border + local firstFloor = not r:IsRoofOnly() and r.floor == 1 + r:ForEachSpawnedObj(function(o, invul) + if firstFloor and IsKindOf(o, "FloorSlab") or invul or invulnerableMaterials[o.material] and IsKindOf(o, "WallSlab") then + makeInvul(o, true) + else + makeInvul(o, false) + end + end, invul) + end) +end + +--deleting slabs from editor deletes invisible slabs underneath +if Platform.developer then + function DeleteSelectedSlabsWithoutPropagation() + for _, obj in ipairs(editor.GetSel() or empty_table) do + rawset(obj, "dont_propagate_deletion", true) + end + editor.DelSelWithUndoRedo() + end +end \ No newline at end of file diff --git a/CommonLua/Libs/Volumes/Volume.lua b/CommonLua/Libs/Volumes/Volume.lua new file mode 100644 index 0000000000000000000000000000000000000000..428268322bbe439ae7dd59d0aae345a5c5024a41 --- /dev/null +++ b/CommonLua/Libs/Volumes/Volume.lua @@ -0,0 +1,4334 @@ +NSEW_pairs = sorted_pairs -- todo: make an iterator that explicitly visits them in a predefined order +-- EnumVolumes([class, ] { box, | obj, | point, | x, y | x, y, z, }) - returns an array of the volumes interesecting the box/point +-- EnumVolumes(..., "smallest") - returns the smallest volume (by surface) interesecting the box/point +-- EnumVolumes(..., filter, ...) - returns an array with all volumes interesecting the box/point for which filter(volume, ...) returned true +local gofPermanent = const.gofPermanent + +if FirstLoad then + GedRoomEditor = false + GedRoomEditorObjList = false + g_RoomCornerTaskList = {} + SelectedVolume = false + VolumeCollisonEnabled = false + HideFloorsAboveThisOne = false + --moves wall slabs on load to their expected pos + --it seems that lvl designers commonly misplace wall slabs which causes weird glitches, this should fix that + RepositionWallSlabsOnLoad = Platform.developer or false +end + +function VolumeStructuresList() + local list = {""} + EnumVolumes(function (volume, list, find) + if not find(list, volume.structure) then + list[#list + 1] = volume.structure + end + end, list, table.find) + table.sort(list) + return list +end + +-- the map is considered in bottom-right quadrant, which means that (0, 0) is north, west +local noneWallMat = const.SlabNoMaterial +local defaultWallMat = "default" + +DefineClass.Volume = { + __parents = { "RoomRoof", "StripObjectProperties", "AlignedObj", "ComponentAttach", "EditorVisibleObject" }, + flags = { gofPermanent = true, cofComponentVolume = true, efVisible = true }, + + properties = { + { category = "Not Room Specific", id = "volumeCollisionEnabled", name = "Toggle Global Volume Collision", default = true, editor = "bool", dont_save = true }, + { category = "Not Room Specific", id = "buttons3", name = "Buttons", editor = "buttons", default = false, dont_save = true, read_only = true, + buttons = { + {name = "Recreate All Walls", func = "RecreateAllWallsOnMap"}, + {name = "Recreate All Roofs", func = "RecreateAllRoofsOnMap"}, + {name = "Recreate All Floors", func = "RecreateAllFloorsOnMap"}, + }, + }, + { category = "General", id = "buttons2", name = "Buttons", editor = "buttons", default = false, dont_save = true, read_only = true, + buttons = { + {name = "Recreate Walls", func = "RecreateWalls"}, + {name = "Recreate Floor", func = "RecreateFloor"}, + {name = "Recreate Roof", func = "RecreateRoofBtn"}, + {name = "Re Randomize", func = "ReRandomize"}, + {name = "Copy Above", func = "CopyAbove"}, + {name = "Copy Below", func = "CopyBelow"}, + }, + }, + { category = "General", id = "buttons2row2", name = "Buttons", editor = "buttons", default = false, dont_save = true, read_only = true, + buttons = { + {name = "Lock Subvariants", func = "LockAllSlabsToCurrentSubvariants"}, + {name = "Unlock Subvariants", func = "UnlockAllSlabs"}, + {name = "Make Slabs Vulnerable", func = "MakeOwnedSlabsVulnerable"}, + {name = "Make Slabs Invulnerable", func = "MakeOwnedSlabsInvulnerable"}, + }, + }, + + { category = "General", id = "box", name = "Box", editor = "box", default = false, no_edit = true }, + { category = "General", id = "locked_slabs_count", name = "Locked Slabs Count", editor = "text", default = "", read_only = true, dont_save = true}, + { category = "General", id = "wireframe_visible", name = "Wireframe Visible", editor = "bool", default = false,}, + { category = "General", id = "wall_text_markers_visible", name = "Wall Text ID Visible", editor = "bool", default = false, dont_save = true}, + { category = "General", id = "dont_use_interior_lighting", name = "No Interior Lighting", editor = "bool", default = false, }, + { category = "General", id = "seed", name = "Random Seed", editor = "number", default = false}, + { category = "General", id = "floor", name = "Floor", editor = "number", default = 1, min = -9, max = 99 }, + --bottom left corner of room in world coords + { category = "General", id = "position", name = "Position", editor = "point", default = false, no_edit = true, }, + { category = "General", id = "size", name = "Size", editor = "point", default = point30, no_edit = true, }, + + { category = "General", id = "override_terrain_z", editor = "number", default = false, no_edit = true }, + { category = "General", id = "structure", name = "Structure", editor = "combo", default = "", items = VolumeStructuresList }, + + { category = "Roof", id = "room_is_roof", name = "Room is Roof", editor = "bool", default = false, help = "Mark room as roof, roofs are hidden entirely (all walls, floors, etc.) when their floor is touched. Rooms that have zero height are considered roofs by default."}, + }, + + wireframeColor = RGB(100, 100, 100), + lines = false, + + adjacent_rooms = false, --{ ? } + + text_markers = false, + + last_wall_recreate_seed = false, + building = false, --{ [floor] = {room, room, room} } + + being_placed = false, + enable_collision = true, --with other rooms + + EditorView = Untranslated(""), + + light_vol_obj = false, + entity = "InvisibleObject", + editor_force_excluded = true, -- exclude from Map editor palette (causes crash) +} + +local voxelSizeX = const.SlabSizeX or 0 +local voxelSizeY = const.SlabSizeY or 0 +local voxelSizeZ = const.SlabSizeZ or 0 +local halfVoxelSizeX = voxelSizeX / 2 +local halfVoxelSizeY = voxelSizeY / 2 +local halfVoxelSizeZ = voxelSizeZ / 2 +local InvalidZ = const.InvalidZ +maxRoomVoxelSizeX = const.MaxRoomVoxelSizeX or 40 +maxRoomVoxelSizeY = const.MaxRoomVoxelSizeY or 40 +maxRoomVoxelSizeZ = const.MaxRoomVoxelSizeZ or 40 +roomQueryRadius = Max((maxRoomVoxelSizeX + 1) * voxelSizeX, (maxRoomVoxelSizeY + 1) * voxelSizeY, (maxRoomVoxelSizeZ + 1) * voxelSizeZ) +defaultVolumeVoxelHeight = 4 + +local halfVoxelPtZeroZ = point(halfVoxelSizeX, halfVoxelSizeY, 0) +local halfVoxelPt = point(halfVoxelSizeX, halfVoxelSizeY, halfVoxelSizeZ) +function SnapVolumePos(pos) + return SnapToVoxel(pos) - halfVoxelPtZeroZ +end + +function snapZRound(z) + z = (z + halfVoxelSizeZ) / voxelSizeZ + return z * voxelSizeZ +end + +function snapZCeil(z) + z = DivCeil(z, voxelSizeZ) + return z * voxelSizeZ +end + +function snapZ(z) + z = z / voxelSizeZ + return z * voxelSizeZ +end + +function Volume:Getlocked_slabs_count() + return "N/A" +end + +function Volume:GetvolumeCollisionEnabled() + return VolumeCollisonEnabled +end + +function Volume:Setroom_is_roof(val) + if self.room_is_roof == val then return end + self.room_is_roof = val + ComputeSlabVisibilityInBox(self.box) +end + +function Volume:IsRoofOnly() + return self.room_is_roof or self.size:z() == 0 +end + +function Volume:SetvolumeCollisionEnabled(v) + VolumeCollisonEnabled = v +end + +function Volume:Init() + --todo: if platform.somethingorother, + self.text_markers = { + North = PlaceObject("Text"), + South = PlaceObject("Text"), + West = PlaceObject("Text"), + East = PlaceObject("Text"), + } + + for k, v in pairs(self.text_markers) do + v.hide_in_editor = false + v:SetText(k) + v:SetColor(RGB(255, 0, 0)) + v:SetGameFlags(const.gofDetailClass1) + v:ClearGameFlags(const.gofDetailClass0) + end + + self:SetPosMarkersVisible(self.wall_text_markers_visible) + self:CopyBoxToCCD() + self:InitEntity() +end + +function Volume:InitEntity() + if IsEditorActive() then + self:ChangeEntity("RoomHelper") + end +end + +function Volume:EditorEnter() + --dont mess with visibility flags + self:ChangeEntity("RoomHelper") +end + +function Volume:EditorExit() + --dont mess with visibility flags + self:ChangeEntity("InvisibleObject") +end + +function Volume:Setdont_use_interior_lighting(val) + self.dont_use_interior_lighting = val + self:UpdateInteriorLighting() +end + +function Volume:CopyBoxToCCD() + local box = self.box + if not box then return end + SetVolumeBox(self, box) + self:UpdateInteriorLighting() +end + +function Volume:UpdateInteriorLighting() + local box = self.box + if not box then + return + end + + if self.dont_use_interior_lighting then + DoneObject(self.light_vol_obj) + self.light_vol_obj = nil + return + end + + local lo = self.light_vol_obj + if not IsValid(lo) then + lo = PlaceObject("ComponentLight") + lo:SetLightType(const.eLightTypeClusterVolume) + self.light_vol_obj = lo + end + + if not self.dont_use_interior_lighting and self.floor_mat == noneWallMat and self.floor == 1 then + lo:SetClusterVolumeBox(box:minx(), box:miny(), box:minz() - 100, box:maxx(), box:maxy(), box:maxz()) --this is here so vfx can properly catch ground below rooms + else + lo:SetClusterVolumeBox(box:minx(), box:miny(), box:minz(), box:maxx(), box:maxy(), box:maxz()) + end + lo:SetVolumeId(self.handle) + lo:SetPos(self:GetPos()) +end + +function Volume:CalcZ() + local posZ = self.position:z() + + if self.being_placed then + local z = self.override_terrain_z or terrain.GetHeight(self.position) + posZ = snapZ(z + voxelSizeZ / 2) + self.position = self.position:SetZ(posZ) + end + + if posZ == nil then + --save compat + local z = self.override_terrain_z or terrain.GetHeight(self.position) + z = snapZ(z + voxelSizeZ / 2) + posZ = (rawget(self, "z_offset") or 0) * voxelSizeZ + z + (self.floor - 1) * self.size:z() * voxelSizeZ + self.position = self.position:SetZ(posZ) + end + + return posZ +end + +function Volume:LockToCurrentTerrainZ() + self.override_terrain_z = terrain.GetHeight(self.position) + return self.override_terrain_z +end + +function Volume:CalcSnappedZ() + local z = self:CalcZ() + z = z / voxelSizeZ + z = z * voxelSizeZ + return z +end + +function FloorFromZ(z, roomHeight, ground_level) + return (z - ground_level) / (roomHeight * voxelSizeZ) + 1 +end + +function ZFromFloor(f, roomHeight, ground_level) --essentialy calcz but makes assumptions about floor size + return ground_level + (f - 1) * (roomHeight * voxelSizeZ) +end + +function Volume:Move(pos) + --bottom left corner of the voxel at pos will be the new positon + self.position = SnapVolumePos(pos) + self:AlignObj() +end + +function Volume:ChangeFloor(newFloor) + if self.floor == newFloor then + return + end + + self.floor = newFloor + self:AlignObj() +end + +function Volume:SetSize(newSize) + if self.size == newSize then + return + end + + self.size = newSize + self:AlignObj() +end + +function Volume:AlignObj(pos, angle) + if pos then + local v = pos - self:GetPos() + self.position = SnapVolumePos(self.position + v) + end + self:InternalAlignObj() +end + +function Volume:InternalAlignObj(test) + local w, h, d = self.size:x() * voxelSizeX, self.size:y() * voxelSizeY, self.size:z() * voxelSizeZ + local cx, cy = w / 2, h / 2 + local z = self:CalcZ() + local pos = point(self.position:x() + cx, self.position:y() + cy, z) + local p = self.position + local newBox = box(p:x(), p:y(), z, p:x() + w, p:y() + h, z + d) + if not test and self:GetPos() == pos and self.box == newBox then return p end --nothing to align + self:SetPos(pos) + self:SetAngle(0) + self.box = newBox + if not test then + self:FinishAlign() + end + return p +end + +function GetOppositeSide(side) + if side == "North" then return "South" + elseif side == "South" then return "North" + elseif side == "West" then return "East" + elseif side == "East" then return "West" + end +end + +local function GetOppositeCorner(c) + if c == "NW" then return "SE" + elseif c == "NE" then return "SW" + elseif c == "SW" then return "NE" + elseif c == "SE" then return "NW" + end +end + +local function SetAdjacentRoom(adjacent_rooms, room, data) + if not adjacent_rooms then + return + end + if data then + if not adjacent_rooms[room] then + adjacent_rooms[#adjacent_rooms + 1] = room + end + adjacent_rooms[room] = data + return data + end + data = adjacent_rooms[room] + if data then + adjacent_rooms[room] = nil + table.remove_value(adjacent_rooms, room) + return data + end +end + +function Volume:ClearAdjacencyData() + local adjacent_rooms = self.adjacent_rooms + self.adjacent_rooms = nil + + for _, room in ipairs(adjacent_rooms or empty_table) do + local hisData = SetAdjacentRoom(room.adjacent_rooms, self, false) + if hisData then + local hisAW = hisData[2] + for i = 1, #(hisAW or empty_table) do + room:OnAdjacencyChanged(hisAW[i]) + end + end + end +end + + +local AdjacencyEvents = {} +function Volume:RebuildAdjacencyData() + -- must be called inside an undo op, otherwise delayed updating may cause changes not captured by undo + assert(XEditorUndo:AssertOpCapture()) + + local adjacent_rooms = self.adjacent_rooms + local new_adjacent_rooms = {} + local mb = self.box + local events = {} + --DbgAddBox(mb) + local is_permanent = self:GetGameFlags(gofPermanent) ~= 0 + local gameFlags = is_permanent and gofPermanent or nil + MapForEach(self, roomQueryRadius, self.class, nil, nil, gameFlags, function(o, mb, is_permanent) + if o == self or not is_permanent and o:GetGameFlags(gofPermanent) ~= 0 then + return + end + + --TODO: use +roof box for sides, -roof box for ceiling/floor + local hb = o.box + --DbgAddBox(hb, RGB(0, 255, 0)) + local ib = IntersectRects(hb, mb) + --DbgAddBox(ib, RGB(255, 0, 0)) + if not ib:IsValid() then + return + end + local myData = adjacent_rooms and adjacent_rooms[o] + local oldIb = myData and myData[1] + + local myNewData = {} + local hisData = o.adjacent_rooms and o.adjacent_rooms[self] + local hisNewData = {} + + --restore previously affected walls + local myaw = myData and myData[2] + local hisaw = hisData and hisData[2] + for i = 1, #(myaw or empty_table) do + table.insert(events, {self, myaw[i]}) + end + for i = 1, #(hisaw or empty_table) do + table.insert(events, {o, hisaw[i]}) + end + + hisNewData[1] = ib + myNewData[1] = ib + hisNewData[2] = {} + myNewData[2] = {} + + if ib:sizez() > 0 then + if ib:minx() == ib:maxx() and ib:miny() == ib:maxy() then + --corner adj, rebuild corner + local p = ib:min() + if p:x() == mb:minx() then --west + if p:y() == mb:miny() then --north + table.insert(events, {self, "NW"}) + table.insert(hisNewData[2], "SE") + table.insert(myNewData[2], "NW") + else + table.insert(events, {self, "SW"}) + table.insert(hisNewData[2], "NE") + table.insert(myNewData[2], "SW") + end + else --east + if p:y() == mb:miny() then + table.insert(events, {self, "NE"}) + table.insert(hisNewData[2], "SW") + table.insert(myNewData[2], "NE") + else + table.insert(events, {self, "SE"}) + table.insert(hisNewData[2], "NW") + table.insert(myNewData[2], "SE") + end + end + elseif ib:minx() == ib:maxx() + and ib:miny() ~= ib:maxy() then + --east/west adjacency + if mb:maxx() == ib:maxx() then + --my east, his west + table.insert(events, {self, "East"}) + table.insert(events, {o, "West"}) + table.insert(hisNewData[2], "West") + table.insert(myNewData[2], "East") + else + --my west, his east + table.insert(events, {self, "West"}) + table.insert(events, {o, "East"}) + table.insert(hisNewData[2], "East") + table.insert(myNewData[2], "West") + end + elseif ib:minx() ~= ib:maxx() + and ib:miny() == ib:maxy() then + --nort/south adjacency + if mb:maxy() == ib:maxy() then + --my north, his south + table.insert(events, {self, "South"}) + table.insert(events, {o, "North"}) + table.insert(hisNewData[2], "North") + table.insert(myNewData[2], "South") + else + --my south, his north + table.insert(events, {self, "North"}) + table.insert(events, {o, "South"}) + table.insert(hisNewData[2], "South") + table.insert(myNewData[2], "North") + end + else + --rooms intersect + if (ib:maxx() == mb:maxx() or ib:minx() == mb:maxx()) + and mb:maxx() == hb:maxx() then + --east + table.insert(events, {self, "East"}) + table.insert(myNewData[2], "East") + table.insert(hisNewData[2], "East") + end + + if (ib:minx() == mb:minx() or ib:maxx() == mb:minx()) + and mb:minx() == hb:minx() then + --west + table.insert(events, {self, "West"}) + table.insert(myNewData[2], "West") + table.insert(hisNewData[2], "West") + end + + if (ib:maxy() == mb:maxy() or ib:miny() == mb:maxy()) + and mb:maxy() == hb:maxy() then + --south + table.insert(events, {self, "South"}) + table.insert(myNewData[2], "South") + table.insert(hisNewData[2], "South") + end + + if ib:maxy() == mb:miny() or ib:miny() == mb:miny() + and mb:miny() == hb:miny() then + --north + table.insert(events, {self, "North"}) + table.insert(myNewData[2], "North") + table.insert(hisNewData[2], "North") + end + end + end + + + if ib:sizex() > 0 and ib:sizey() > 0 then + if (mb:minz() >= ib:minz() and mb:minz() <= ib:maxz()) or + (hb:maxz() >= ib:minz() and hb:maxz() <= ib:maxz()) then + --floor + table.insert(events, {self, "Floor"}) + table.insert(myNewData[2], "Floor") + table.insert(hisNewData[2], "Roof") + end + + if (mb:maxz() <= ib:maxz() and mb:maxz() >= ib:minz()) or + (hb:minz() <= ib:maxz() and hb:minz() >= ib:minz()) then + --roof + table.insert(events, {self, "Roof"}) + table.insert(myNewData[2], "Roof") + table.insert(hisNewData[2], "Floor") + end + end + + SetAdjacentRoom(o.adjacent_rooms, self, #hisNewData[2] > 0 and hisNewData) + SetAdjacentRoom(new_adjacent_rooms, o, #myNewData[2] > 0 and myNewData) + end, mb, is_permanent) + + for _, room in ipairs(adjacent_rooms or empty_table) do + if not new_adjacent_rooms[room] then + --adjacency removed + local data = adjacent_rooms[room] + local myaw = data[2] + local hisData = SetAdjacentRoom(room.adjacent_rooms, self, false) + local hisaw = hisData and hisData[2] + for i = 1, #(myaw or empty_table) do + table.insert(events, {self, myaw[i]}) + end + for i = 1, #(hisaw or empty_table) do + table.insert(events, {room, hisaw[i]}) + end + end + end + + self.adjacent_rooms = new_adjacent_rooms + + if IsChangingMap() or XEditorUndo.undoredo_in_progress then + return + end + + if #(events or empty_table) > 0 then + table.insert(AdjacencyEvents, events) + Wakeup(PeriodicRepeatThreads["AdjacencyEvents"]) + end +end + +function ProcessVolumeAdjacencyEvents() + local passed = {} + for i = 1, #AdjacencyEvents do + local events = AdjacencyEvents[i] + for i = 1, #(events or empty_table) do + local ev = events[i] + local o = ev[1] + local s = ev[2] + if IsValid(o) and (not passed[o] or (passed[o] and not passed[o][s])) then + passed[o] = passed[o] or {} + passed[o][s] = true + --print(o.name, s) + o:OnAdjacencyChanged(s) + end + end + end + table.clear(AdjacencyEvents) +end + +-- make sure all changes to rooms are completed before we finish capturing undo data +OnMsg.EditorObjectOperationEnding = ProcessVolumeAdjacencyEvents + +MapGameTimeRepeat("AdjacencyEvents", -1, function(sleep) + PauseInfiniteLoopDetection("AdjacencyEvents") + ProcessVolumeAdjacencyEvents() + ResumeInfiniteLoopDetection("AdjacencyEvents") + WaitWakeup() +end) + +local dirToWallMatMember = { + North = "north_wall_mat", + South = "south_wall_mat", + West = "west_wall_mat", + East = "east_wall_mat", + Floor = "floor_mat", +} + +local sideToFuncName = { + NW = "RecreateNWCornerBeam", + NE = "RecreateNECornerBeam", + SW = "RecreateSWCornerBeam", + SE = "RecreateSECornerBeam", +} + +function Volume:CheckWallSizes() + if not Platform.developer then return end + local t = self.spawned_walls + assert(#t.West == #t.East) + assert(#t.North == #t.South) +end + +function Volume:OnAdjacencyChanged(side) + if #side == 2 then + self[sideToFuncName[side]](self) + elseif side == "Floor" then + self:CreateFloor(self.floor_mat) + elseif side == "Roof" then + if not self.being_placed then + self:UpdateRoofSlabVisibility() + end + else + self:CreateWalls(side, self[dirToWallMatMember[side]]) + self:CheckWallSizes() + end + + self:DelayedRecalcRoof() +end + +if FirstLoad then + SelectedRooms = false + RoomSelectionMode = false +end + +function SetRoomSelectionMode(bVal) + RoomSelectionMode = bVal + print(string.format("RoomSelectionMode is %s", RoomSelectionMode and "ON" or "OFF")) +end + +function ToggleRoomSelectionMode() + SetRoomSelectionMode(not RoomSelectionMode) +end + +if FirstLoad then + roomsToDeselect = false +end + +local function selectRoomHelper(r, t) + t = t or SelectedRooms + t = t or {} + r:SetPosMarkersVisible(true) + table.insert(t, r) + if roomsToDeselect then + table.remove_entry(roomsToDeselect, r) + end +end + +local function deselectRoomHelper(r) + if IsValid(r) then + r:SetPosMarkersVisible(false) + r:ClearSelectedWall() + end +end + +local function deselectRooms() + for i = 1, #(roomsToDeselect or "") do + deselectRoomHelper(roomsToDeselect[i]) + end + roomsToDeselect = false +end + +function OnMsg.EditorSelectionChanged(objects) + --room selection + if RoomSelectionMode then + --if 1 slab is selected? + local o = #objects == 1 and objects[1] + if o and IsKindOf(o, "Slab") and IsValid(o.room) then + editor.ClearSel() + editor.AddToSel({o.room}) + return --don't do further analysis this pass + end + end + --selected rooms + local newSelectedRooms = {} + + for i = 1, #objects do + local o = objects[i] + if IsKindOf(o, "Slab") then + local r = o.room + if IsValid(r) then + selectRoomHelper(r, newSelectedRooms) + end + elseif IsKindOf(o, "Room") then + selectRoomHelper(o, newSelectedRooms) + end + end + + for i = 1, #(SelectedRooms or "") do + local r = SelectedRooms[i] + if not table.find(newSelectedRooms, r) then + --deselect + roomsToDeselect = roomsToDeselect or {} + table.insert(roomsToDeselect, r) + DelayedCall(0, deselectRooms) + end + end + + SelectedRooms = #newSelectedRooms > 0 and newSelectedRooms or false +end + +function Volume:TogglePosMarkersVisible() + local el = self.text_markers.North + self:SetPosMarkersVisible(el:GetEnumFlags(const.efVisible) == 0) +end + +function Volume:SetPosMarkersVisible(val) + for k, v in pairs(self.text_markers) do + if not val then + v:ClearEnumFlags(const.efVisible) + else + v:SetEnumFlags(const.efVisible) + end + end +end + +function Volume:PositionWallTextMarkers() + local t = self.text_markers + local gz = self:CalcZ() + self.size:z() * voxelSizeZ / 2 + local p = self.position + point(self.size:x() * voxelSizeX / 2, 0) + p = p:SetZ(gz) + t.North:SetPos(p) + p = self.position + point(self.size:x() * voxelSizeX / 2, self.size:y() * voxelSizeY) + p = p:SetZ(gz) + t.South:SetPos(p) + p = self.position + point(0, self.size:y() * voxelSizeY / 2) + p = p:SetZ(gz) + t.West:SetPos(p) + p = self.position + point(self.size:x() * voxelSizeX, self.size:y() * voxelSizeY / 2) + p = p:SetZ(gz) + t.East:SetPos(p) +end + +function Volume:FinishAlign() + if not self.seed then + self.seed = EncodeVoxelPos(self) + end + + self:CopyBoxToCCD() + self:RebuildAdjacencyData() + if self.wireframe_visible then + self:GenerateGeometry() + else + self:DoneLines() + end + self:PositionWallTextMarkers() + self.box_at_last_roof_edit = self.box + + if not IsChangingMap() then + self:RefreshFloorCombatStatus() + end + + Msg("RoomAligned", self) +end + +function Volume:RefreshFloorCombatStatus() +end + +function Volume:Setfloor(v) + self.floor = v + self:RefreshFloorCombatStatus() +end + +function Volume:VolumeDestructor() + self:DoneLines() + DoneObject(self.light_vol_obj) + DoneObjects(self.light_vol_objs) + self.light_vol_obj = nil + self.light_vol_objs = nil + for k, v in pairs(self.text_markers) do + DoneObject(v) + end + self["VolumeDestructor"] = empty_func +end + +function Volume:Done() + self:VolumeDestructor() +end + +function Volume.ToggleVolumeCollision(_, self) + VolumeCollisonEnabled = not VolumeCollisonEnabled +end + +function Volume:CheckCollision(cls, box) + if not VolumeCollisonEnabled then return false end + if not self.enable_collision then return false end + cls = cls or self.class + local ret = false + box = box or self.box + MapForEach(self:GetPos(), roomQueryRadius, cls, function(o) + if o ~= self and o.enable_collision then + if box:Intersect(o.box) ~= 0 then + ret = true + return "break" + end + end + end, box) + + return ret +end + +local dontCopyTheeseProps = { + name = true, + floor = true, + adjacent_rooms = true, + box = true, + position = true, + roof_objs = true, + spawned_doors = true, + spawned_windows = true, + spawned_decals = true, + spawned_walls = true, + spawned_corners = true, + spawned_floors = true, + text_markers = true, +} + +function Volume:RecreateAllWallsOnMap() + MapForEach("map", "Volume", Volume.RecreateWalls) +end + +function Volume:RecreateAllRoofsOnMap() + local all_volumes = MapGet("map", "Volume") + table.sortby_field(all_volumes, "floor") + for i,volume in ipairs(all_volumes) do + volume:RecreateRoof() + end +end + +function Volume:RecreateAllFloorsOnMap() + MapForEach("map", "Volume", Volume.RecreateFloor) +end + +function Volume:RecreateWalls() + SuspendPassEdits("Volume:RecreateWalls") + self:DeleteAllWallObjs() + self:DeleteAllCornerObjs() + self:CreateAllWalls() + self:CreateAllCorners() + self:OnSetouter_colors(self.outer_colors) + self:OnSetinner_colors(self.inner_colors) + ResumePassEdits("Volume:RecreateWalls") +end + +function Volume:RecreateFloor() + SuspendPassEdits("Volume:RecreateFloor") + self:DeleteAllFloors() + self:CreateFloor() + self:OnSetfloor_colors(self.floor_colors) + ResumePassEdits("Volume:RecreateFloor") +end + +function Volume:RecreateRoofBtn() + self:RecreateRoof() +end + +function Volume:ReRandomize() + self.last_wall_recreate_seed = self.seed + self.seed = BraidRandom(self.seed) + self:CreateAllWalls() + self.last_wall_recreate_seed = self.seed + ObjModified(self) +end + +function Volume:CopyAbove() + XEditorUndo:BeginOp() + local nv = self:Copy(1) + SetSelectedVolume(nv) + XEditorUndo:EndOp{ nv } +end + +function Volume:CopyBelow() + XEditorUndo:BeginOp() + local nv = self:Copy(-1) + SetSelectedVolume(nv) + XEditorUndo:EndOp{ nv } +end + +function Volume:CollisionCheckNextFloor(floorOffset) + if not VolumeCollisonEnabled then return false end + if not self.enable_collision then return false end + local b = self.box + local offset = point(0, 0, voxelSizeZ * self.size:z() * floorOffset) + b = Offset(b, offset) + local collision = false + MapForEach(self:GetPos(), roomQueryRadius, self.class, function(o) + if o ~= self and o.enable_collision then + if b:Intersect(o.box) ~= 0 then + collision = true + return "break" + end + end + end) + return collision +end + +function Volume:Copy(floorOffset, inputObj, skipCollisionTest) + local offset = point(0, 0, voxelSizeZ * self.size:z() * floorOffset) + local collision = false + if not skipCollisionTest then + collision = self:CollisionCheckNextFloor(floorOffset) + end + + if skipCollisionTest or not collision then + inputObj = inputObj or {} + inputObj.floor = inputObj.floor or self.floor + floorOffset + inputObj.position = inputObj.position or self.position + offset + inputObj.size = inputObj.size or self.size + inputObj.name = inputObj.name or self.name .. " Copy" + local doNotCopyTheseEither = table.copy(inputObj) + + local cpy = PlaceObject(self.class, inputObj) + local prps = self:GetProperties() + for i = 1, #prps do + local prop = prps[i] + if not dontCopyTheeseProps[prop.id] and not doNotCopyTheseEither[prop.id] then + cpy:SetProperty(prop.id, self:GetProperty(prop.id)) + end + end + cpy:OnCopied(self, offset) + DelayedCall(500, BuildBuildingsData) + return cpy + end +end + +function Volume:OnCopied(from) + self:AlignObj() +end + +function Volume:ToggleGeometryVisible() + if self.lines == false then + self:GenerateGeometry() + return + end + if self.lines and self.lines[1] then + local visible = self.lines[1]:GetEnumFlags(const.efVisible) == 0 + for i = 1, #(self.lines or empty_table) do + if visible then + self.lines[i]:SetEnumFlags(const.efVisible) + else + self.lines[i]:ClearEnumFlags(const.efVisible) + end + end + end +end + +function Volume:DoneLines() + DoneObjects(self.lines) + self.lines = false +end + +function Volume:GetWallBox(side, roomBox) + local ret = false + local b = roomBox or self.box + if side == "North" then + ret = box(b:minx(), b:miny(), b:minz(), b:maxx(), b:miny() + 1, b:maxz()) + elseif side == "South" then + ret = box(b:minx(), b:maxy() - 1, b:minz(), b:maxx(), b:maxy(), b:maxz()) + elseif side == "East" then + ret = box(b:maxx() - 1, b:miny(), b:minz(), b:maxx(), b:maxy(), b:maxz()) + elseif side == "West" then + ret = box(b:minx(), b:miny(), b:minz(), b:minx() + 1, b:maxy(), b:maxz()) + end + + return ret +end + +local function SetLineMesh(line, line_pstr) + if not line_pstr or line_pstr:size() == 0 then return end + line:SetMesh(line_pstr) + return line +end + +local offsetFromVoxelEdge = 20 +function Volume:GenerateGeometry() + self:DoneLines() + + local lines = {} + local xPoints = {} + local xPointsRoof = {} + local yPoints = {} + local yPointsRoof = {} + + local zOrigin = self:CalcSnappedZ() + local p = self.position + local x, y = p:xyz() + local sx = abs(self.size:x()) + local sy = abs(self.size:y()) + local sz = abs(self.size:z()) + + for inX = 0, sx - 1 do + for inY = 0, sy - 1 do + xPoints[inY] = xPoints[inY] or pstr("") + yPoints[inX] = yPoints[inX] or pstr("") + + xPointsRoof[inY] = xPointsRoof[inY] or pstr("") + yPointsRoof[inX] = yPointsRoof[inX] or pstr("") + + local xx, yy, zz, ox, oy, oz + + xx = x + inX * voxelSizeX + halfVoxelSizeX + if inX == 0 then + ox = xx - halfVoxelSizeX + offsetFromVoxelEdge + elseif inX == sx - 1 then + ox = xx + halfVoxelSizeX - offsetFromVoxelEdge + else + ox = xx + end + + yy = y + inY * voxelSizeY + halfVoxelSizeY + if inY == 0 then + oy = yy - halfVoxelSizeY + offsetFromVoxelEdge + elseif inY == sy - 1 then + oy = yy + halfVoxelSizeY - offsetFromVoxelEdge + else + oy = yy + end + + zz = zOrigin + offsetFromVoxelEdge + oz = zz + sz * voxelSizeZ - offsetFromVoxelEdge*2 + + if inX == 0 then + --wall + xPoints[inY]:AppendVertex(ox, yy, oz, self.wireframeColor) + end + xPoints[inY]:AppendVertex(ox, yy, zz, self.wireframeColor) + if sx == 1 then + xPointsRoof[inY]:AppendVertex(ox, yy, oz, self.wireframeColor) + ox = xx + halfVoxelSizeX - offsetFromVoxelEdge + xPoints[inY]:AppendVertex(ox, yy, zz, self.wireframeColor) + end + if inX == sx - 1 then + --wall + xPoints[inY]:AppendVertex(ox, yy, oz, self.wireframeColor) + end + + if inY == 0 then + --wall + yPoints[inX]:AppendVertex(xx, oy, oz, self.wireframeColor) + end + yPoints[inX]:AppendVertex(xx, oy, zz, self.wireframeColor) + if sy == 1 then + yPointsRoof[inX]:AppendVertex(xx, oy, oz, self.wireframeColor) + oy = yy + halfVoxelSizeY - offsetFromVoxelEdge + yPoints[inX]:AppendVertex(xx, oy, zz, self.wireframeColor) + end + if inY == sy - 1 then + --wall + yPoints[inX]:AppendVertex(xx, oy, oz, self.wireframeColor) + end + + xPointsRoof[inY]:AppendVertex(ox, yy, oz, self.wireframeColor) + yPointsRoof[inX]:AppendVertex(xx, oy, oz, self.wireframeColor) + end + end + + local visible = self.wireframe_visible + local function SetVisibilityHelper(line) + if not visible then + line:ClearEnumFlags(const.efVisible) + end + end + + for inX = 0, sx - 1 do + local line = PlaceObject("Polyline") + SetVisibilityHelper(line) + line:SetPos(p) + SetLineMesh(line, yPoints[inX]) + table.insert(lines, line) + self:Attach(line) + line = PlaceObject("Polyline") + SetVisibilityHelper(line) + line:SetPos(p) + SetLineMesh(line, yPointsRoof[inX]) + table.insert(lines, line) + self:Attach(line) + end + + for inY = 0, sy - 1 do + local line = PlaceObject("Polyline") + SetVisibilityHelper(line) + line:SetPos(p) + SetLineMesh(line, xPoints[inY]) + table.insert(lines, line) + self:Attach(line) + line = PlaceObject("Polyline") + SetVisibilityHelper(line) + line:SetPos(p) + SetLineMesh(line, xPointsRoof[inY]) + table.insert(lines, line) + self:Attach(line) + end + + self.lines = lines +end + +function Volume:GetBiggestEncompassingRoom(func, ...) + --this presumes no wall crossing + local biggestRoom = self + if self.box then + local sizex, sizey = self.box:sizexyz() + local biggestRoomSize = sizex + sizey + EnumVolumes(self.box, function(o, ...) + local szx, szy = o.box:sizexyz() + local size = szx + szy + if size > biggestRoomSize then + if not func or func(o, ...) then + biggestRoom = o + biggestRoomSize = size + end + end + end, ...) + end + return biggestRoom +end + +local function MakeSlabInvulnerable(o, val) + o.forceInvulnerableBecauseOfGameRules = val + o.invulnerable = val + SetupObjInvulnerabilityColorMarkingOnValueChanged(o) +end + +function Volume:MakeOwnedSlabsInvulnerable() + self:ForEachSpawnedObj(function(o) + MakeSlabInvulnerable(o, true) + if IsKindOf(o, "SlabWallObject") then + local os = o.owned_slabs + if os then + for _, oo in ipairs(os) do + MakeSlabInvulnerable(oo, true) + end + end + end + end) +end + +function Volume:MakeOwnedSlabsVulnerable() + local floorsInvul = self.floor == 1 + self:ForEachSpawnedObj(function(o) + if not floorsInvul or not IsKindOf(o, "FloorSlab") then + MakeSlabInvulnerable(o, false) + if IsKindOf(o, "SlabWallObject") then + local os = o.owned_slabs + if os then + for _, oo in ipairs(os) do + MakeSlabInvulnerable(oo, false) + end + end + end + end + end) +end + +function Volume:Destroy() + --so shift + d in f3 doesn't kill these +end + +function ShowVolumes(bShow, volume_class, max_floor, fn) + MapClearEnumFlags(const.efVisible, "map", "Volume") + if not bShow or not volume_class then return end + MapSetEnumFlags(const.efVisible, "map", volume_class, function(volume, max_floor, fn) + if volume.floor <= max_floor then + fn(volume) + return true + end + end, max_floor or max_int, fn or empty_func) +end + +function SelectVolume(pt) -- in screen coordinates, terminal.GetMousePos() + -- enumerate all visible volumes on the map and select the one under the mouse point pt + local start = ScreenToGame(pt) + local pos = cameraRTS.GetPos() + local dir = start - pos + dir = dir * 1000 + local dir2 = start + dir + local camFloor = cameraTac.GetFloor() + 1 + + --DbgAddCircle(start, 100) + --DbgAddVector(start, pos - start) + --DbgAddVector(start, dir*1000, RGB(0, 255, 0)) + + return MapFindMin("map", "Volume", nil, nil, nil, nil, nil, nil, function(volume, dir2, camFloor) + if HideFloorsAboveThisOne then + if volume.floor > HideFloorsAboveThisOne then + return false + end + end + --return distance to intersection between the camera ray and volume box + local p1, p2 = ClipSegmentWithBox3D(start, dir2, volume.box) + if p1 then + --DbgAddCircle(p1, 100) + --DbgAddCircle(p2, 100, RGB(0, 0, 255)) + return p1:Dist2(start) + end + + return false + end, start, dir2, camFloor) or false, start, dir2 +end + +local lastSelectedVolume = false +local function SetSelectedVolumeAndFireEvents(vol) + if vol ~= SelectedVolume then + local oldVolume = SelectedVolume + SelectedVolume = vol + if oldVolume then + lastSelectedVolume = oldVolume + if IsValid(oldVolume) then + oldVolume.wall_text_markers_visible = false + oldVolume:SetPosMarkersVisible(false) + end + Msg("VolumeDeselected", oldVolume) + end + if SelectedVolume then + if SelectedVolume ~= lastSelectedVolume then --only deselect wall if another vol is selected + SelectedVolume.selected_wall = false + ObjModified(SelectedVolume) + end + + SelectedVolume.wall_text_markers_visible = true + SelectedVolume:SetPosMarkersVisible(true) + editor.ClearSel() + end + Msg("VolumeSelected", SelectedVolume) + end +end + +function SetSelectedVolume(vol) + SetSelectedVolumeAndFireEvents(vol) + if GedRoomEditor then + GedRoomEditorObjList = GedRoomEditor:ResolveObj("root") + CreateRealTimeThread(function() + GedRoomEditor:SetSelection("root", table.find(GedRoomEditorObjList, SelectedVolume)) + end) + end +end + +local doorId = "Door" +local windowId = "Window" +local doorTemplate = "%s_%s" +local Doors_WidthNames = { "Single", "Double" } +local Windows_WidthNames = { "Single", "Double", "Triple" } + +function DoorsDropdown() + return function() + local ret = { {name = "", id = ""} } + + for j = 1, #Doors_WidthNames do + local name = string.format(doorTemplate, doorId, Doors_WidthNames[j]) + local data = {mat = false, width = j, height = 3} + table.insert(ret, {name = name, id = data}) + end + + return ret + end +end + +function WindowsDropdown() + return function() + local ret = { {name = "", id = ""} } + + for j = 1, #Windows_WidthNames do + local name = string.format(doorTemplate, windowId, Windows_WidthNames[j]) + local data = {mat = false, width = j, height = 2} + table.insert(ret, {name = name, id = data}) + end + + return ret + end +end + +function GetDecalPresetData() + return Presets.RoomDecalData.Default +end + +function DecalsDropdown() + return function() + local ret = { {name = "", id = ""} } + local presetData = GetDecalPresetData() + for _, entry in ipairs(presetData) do + local data = { entity = entry.id, } + table.insert(ret, {name = entry.id, id = data}) + end + return ret + end +end + +local function GetAllWindowEntitiesForMaterial(obj) + local material = type(obj) == "string" and obj or obj.linked_obj and obj.linked_obj.material or obj.material + local ret = { false } + for w = 0, 3 do + for h = 1, 3 do + for v = 1, 10 do + local e = SlabWallObjectName(material, h, w, v, false) + if IsValidEntity(e) then + ret[#ret + 1] = { name = e, value = {entity = e, height = h, width = w, subvariant = v, material = material} } + end + end + end + end + + return ret +end + +local function GetAllDoorEntitiesForMaterial(obj) + local material = type(obj) == "string" and obj or obj.linked_obj and obj.linked_obj.material or obj.material + local ret = { false } + for w = 1, 3 do + for h = 3, 4 do + for v = 1, 10 do + local e = SlabWallObjectName(material, h, w, v, true) + if IsValidEntity(e) then + ret[#ret + 1] = { name = e, value = {entity = e, height = h, width = w, subvariant = v, material = material} } + end + + if v == 1 then + e = SlabWallObjectName(material, h, w, nil, true) + if IsValidEntity(e) then + ret[#ret + 1] = { name = e, value = {entity = e, height = h, width = w, subvariant = v, material = material} } + end + end + end + end + end + + return ret +end + +local function SelectedWallNoEdit(self) + return self.selected_wall == false +end + +slabDirToAngle = { + North = 270 * 60, + South = 90 * 60, + West = 180 * 60, + East = 0, +} + +slabAngleToDir = { + [270 * 60] = "North", + [90 * 60] = "South", + [180 * 60] = "West", + [0] = "East", +} + +slabCornerAngleToDir = { + [270 * 60] = "East", + [90 * 60] = "West", + [180 * 60] = "North", + [0] = "South", +} + +function _RoomVisibilityCategoryNoEdit() + return RoomVisibilityCategoryNoEdit() +end + +function RoomVisibilityCategoryNoEdit() + return true +end + +local VisibilityStateItems = { + "Closed", + "Hidden", + "Open" +} + +function SlabMaterialComboItemsWithNone() + return PresetGroupCombo("SlabPreset", "SlabMaterials", nil, noneWallMat) +end + +function SlabMaterialComboItemsOnly() + return function() + local f1 = SlabMaterialComboItemsWithNone() + local ret = f1() + table.remove(ret, 1) + return ret + end +end + +function SlabMaterialComboItemsWithDefault() + return function() + local f1 = SlabMaterialComboItemsWithNone() + local ret = f1() + table.insert(ret, 2, defaultWallMat) + return ret + end +end + +DefineClass.Room = { + __parents = { "Volume", "EditorSubVariantObject" }, + flags = { gofWarped = true }, + + properties = { + { category = "General", name = "Doors And Windows Are Blocked", id = "doors_windows_blocked", editor = "bool", default = false, }, + { category = "General", id = "name", name = "Name", editor = "text", default = false, help = "Default 'Room ', renameable." }, + + { category = "General", id = "size_z", name = "Height (z)", editor = "number", default = defaultVolumeVoxelHeight, min = 0, max = maxRoomVoxelSizeZ, dont_save = true}, + { category = "General", id = "size_x", name = "Width (x)", editor = "number", default = 1, min = 1, max = maxRoomVoxelSizeX, dont_save = true}, + { category = "General", id = "size_y", name = "Depth (y)", editor = "number", default = 1, min = 1, max = maxRoomVoxelSizeY, dont_save = true}, + { category = "General", id = "move_x", name = "Move EW (x)", editor = "number", default = 0, dont_save = true}, + { category = "General", id = "move_y", name = "Move NS (y)", editor = "number", default = 0, dont_save = true}, + { category = "General", id = "move_z", name = "Move UD (z)", editor = "number", default = 0, dont_save = true}, + --materials + { category = "Materials", id = "wall_mat", name = "Wall Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "SlabMaterials", extra_item = noneWallMat, default = "Planks", + buttons = { + {name = "Reset", func = "ResetWallMaterials"}, + }, + }, + { category = "Materials", id = "outer_colors", name = "Outer Color Modifier", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, + { category = "Materials", id = "inner_wall_mat", name = "Inner Wall Material", editor = "preset_id", preset_class = "SlabIndoorMaterials", extra_item = noneWallMat, default = "Planks", }, + + { category = "Materials", id = "inner_colors", name = "Inner Color Modifier", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, + { category = "Materials", id = "north_wall_mat", name = "North Wall Material", editor = "dropdownlist", items = SlabMaterialComboItemsWithDefault, default = defaultWallMat, + buttons = { + {name = "Select", func = "ViewNorthWallFromOutside"}, + } + }, + { category = "Materials", id = "south_wall_mat", name = "South Wall Material", editor = "dropdownlist", items = SlabMaterialComboItemsWithDefault, default = defaultWallMat, + buttons = { + {name = "Select", func = "ViewSouthWallFromOutside"}, + } + }, + { category = "Materials", id = "east_wall_mat", name = "East Wall Material", editor = "dropdownlist", items = SlabMaterialComboItemsWithDefault, default = defaultWallMat, + buttons = { + {name = "Select", func = "ViewEastWallFromOutside"}, + } + }, + { category = "Materials", id = "west_wall_mat", name = "West Wall Material", editor = "dropdownlist", items = SlabMaterialComboItemsWithDefault, default = defaultWallMat, + buttons = { + {name = "Select", func = "ViewWestWallFromOutside"}, + } + }, + { category = "Materials", id = "floor_mat", name = "Floor Material", editor = "preset_id", preset_class = "SlabPreset", preset_group = "FloorSlabMaterials", extra_item = noneWallMat, default = "Planks", }, + { category = "Materials", id = "floor_colors", name = "Floor Color Modifier", editor = "nested_obj", base_class = "ColorizationPropSet", inclusive = true, default = false, }, + { category = "Materials", id = "Warped", name = "Warped", editor = "bool", default = true }, + + { category = "Materials", id = "selected_wall_buttons", name = "selected wall buttons", editor = "buttons", default = false, dont_save = true, read_only = true, + no_edit = SelectedWallNoEdit, + buttons = { + {name = "Clear Wall Selection", func = "ClearSelectedWall"}, + {name = "Delete Doors", func = "UIDeleteDoors"}, + {name = "Delete Windows", func = "UIDeleteWindows"}, + }, + }, + + { category = "Materials", id = "place_decal", name = "Place Decal", editor = "choice", items = DecalsDropdown, default = "", no_edit = SelectedWallNoEdit,}, + + { category = "General", id = "spawned_doors", editor = "objects", no_edit = true,}, + { category = "General", id = "spawned_windows", editor = "objects", no_edit = true,}, + { category = "General", id = "spawned_decals", editor = "objects", no_edit = true,}, --todo: kill + + { category = "General", id = "spawned_floors", editor = "objects", no_edit = true}, + { category = "General", id = "spawned_walls", editor = "objects", no_edit = true,}, + { category = "General", id = "spawned_corners", editor = "objects", no_edit = true,}, + + { category = "Not Room Specific", id = "hide_floors_editor", editor = "number", default = 100, name = "Hide Floors Above", dont_save = true}, + + --bacon specific? + { category = "Visibility", name = "Visibility State", id = "visibility_state", editor = "choice", items = VisibilityStateItems, dont_save = true, no_edit = _RoomVisibilityCategoryNoEdit }, + { category = "Visibility", name = "Focused", id = "is_focused", editor = "bool", default = false, dont_save = true, no_edit = _RoomVisibilityCategoryNoEdit }, + + { category = "Ignore None Material", name = "Wall", id = "none_wall_mat_does_not_affect_nbrs", editor = "bool", default = false, help = "By default, setting a wall material to none will hide overlapping walls, tick this for it to stop happening. Affects all walls of a room." }, + { category = "Ignore None Material", name = "Roof Wall", id = "none_roof_wall_mat_does_not_affect_nbrs", editor = "bool", default = false, help = "Same as walls (see above), but for walls that are part of the roof - roof walls." }, + { category = "Ignore None Material", name = "Floor", id = "none_floor_mat_does_not_affect_nbrs", editor = "bool", default = false, help = "By default, setting a floor material to none will hide overlapping floors, tick this for it to stop happening. Affects all floors of a room." }, + }, + + auto_add_in_editor = true, -- for Room editor + + spawned_walls = false, -- {["North"] = {}, etc.} + spawned_corners = false, + spawned_floors = false, + spawned_doors = false, + spawned_windows = false, + spawned_decals = false, + + selected_wall = false, -- false, "North", "South", etc. + + next_visibility_state = false, + visibility_state = false, -- when defined purely as a prop, something strips it. + open_state_collapsed_walls = false, + outside_border = false, + nametag = false, +} + +local function moveHelper(self, key, old_v, x, y, z, ignore_collision) + if IsChangingMap() then return end + self:InternalAlignObj(true) -- this moves box + if not ignore_collision and self:CheckCollision() then + self[key] = old_v + self:InternalAlignObj(true) + print("Could not move room due to collision with other room!") + return false + else + self:MoveAllSpawnedObjs(x, y, z) + Volume.FinishAlign(self) + Msg("RoomMoved", self, x, y, z) -- x, y, z - move delta in voxels + return true + end +end + +function sign(v) + return v ~= 0 and abs(v) / v or 0 +end + +function moveHelperHelper(r, delta) + local old = r.position + delta = delta + halfVoxelPt + r.position = SnapVolumePos(old + delta) + return moveHelper(r, "position", old, delta:x() / voxelSizeX, delta:y() / voxelSizeY, delta:z() / voxelSizeZ) +end + +function Room:EditorExit() + if IsValid(self.nametag) then + self.nametag:ClearEnumFlags(const.efVisible) + end +end + +function Room:EditorEnter() + if IsValid(self.nametag) and self:GetEnumFlags(const.efVisible) ~= 0 then + self.nametag:SetEnumFlags(const.efVisible) + end +end + +function Room:Setname(n) + self.name = n + if not IsValid(self.nametag) then + self.nametag = PlaceObject("TextEditor") + self:Attach(self.nametag) + self.nametag:SetAttachOffset((axis_z * 3 * voxelSizeZ) / 4096) + + if not IsEditorActive() or self:GetEnumFlags(const.efVisible) == 0 then + self.nametag:ClearEnumFlags(const.efVisible) + end + end + self.nametag:SetText(self.name) +end + +local movedRooms = false +function Room_RecalcRoofsOfMovedRooms() + if not movedRooms then return end + for i = 1, #movedRooms do + local room = movedRooms[i] + if IsValid(room) then + room:RecalcRoof() + room:UpdateRoofVfxControllers() + end + end + movedRooms = false +end + +function Room:DelayedRecalcRoof() + movedRooms = table.create_add_unique(movedRooms, self) + if LocalStorage.FilteredCategories["Roofs"] and not XEditorUndo.undoredo_in_progress then + DelayedCall(200, Room_RecalcRoofsOfMovedRooms) + end +end + +-- make sure all changes to roofs are completed before we finish capturing undo data +OnMsg.EditorObjectOperationEnding = Room_RecalcRoofsOfMovedRooms + +function Room:AlignObj(pos, angle) + if pos then + assert(IsEditorActive()) + local offset = pos - self:GetPos() + local box = self.box + local didMove = false + if abs(offset:x()) / voxelSizeX > 0 or abs(offset:y()) / voxelSizeY > 0 or abs(offset:z()) / voxelSizeZ > 0 then + didMove = moveHelperHelper(self, offset) + end + if didMove then + ObjModified(self) + assert(self:GetGameFlags(const.gofPermanent) ~= 0) + box = AddRects(box, self.box) + ComputeSlabVisibilityInBox(box) + DelayedCall(500, BuildBuildingsData) + self:DelayedRecalcRoof() + end + else + self:InternalAlignObj() + end +end + +function Room:GetEditorLabel() + return self.name or self.class +end + +function InsertMaterialProperties(name, count) + assert(count >= 1 and count <= 4) + for i = 1, count do + table.insert(Room.properties, { + id = name .. "color" .. count, + editor = "color", + alpha = false, + }) + table.insert(Room.properties, { + id = name .. "metallic" .. count, + editor = "number", + }) + end +end + + +local room_NSWE_lists = { + "spawned_walls", + "spawned_corners", + "spawned_doors", + "spawned_windows", + "spawned_decals", +} + +local room_NSWE_lists_no_DoorsWindows = { + "spawned_walls", + "spawned_corners", +} + +local room_regular_lists = { + "spawned_floors", + "roof_objs", +} + +local room_regular_list_sides = { + "Floor", + false -- see RoomRoof:GetPivots +} + +function ForEachInTable(t, f, ...) + for i = 1, #(t or "") do + local o = t[i] + if IsValid(o) then + f(o, ...) + end + end +end + +function Room:UnlockAllSlabs() + self:UnlockFloor() + self:UnlockAllWalls() + self:UnlockRoof() +end + +function Room:UnlockFloor() + ForEachInTable(self.spawned_floors, Slab.UnlockSubvariant) +end + +function Room:UnlockAllWalls() + for side, t in pairs(self.spawned_walls or empty_table) do + ForEachInTable(t, Slab.UnlockSubvariant) + end + for side, t in pairs(self.spawned_corners or empty_table) do + ForEachInTable(t, Slab.UnlockSubvariant) + end + ForEachInTable(self.roof_objs, function(o) + if not IsKindOf(o, "RoofSlab") then + o:UnlockSubvariant() + end + end) +end + +function Room:UnlockRoof() + ForEachInTable(self.roof_objs, function(o) + if IsKindOf(o, "RoofSlab") then + o:UnlockSubvariant() + end + end) +end + +sideToCornerSides = { + East = { "East", "South" }, + South = { "West", "South" }, + West = { "West", "North" }, + North = { "East", "North" }, +} + +function Room:UnlockWallSide(side) --both walls and corners + roof walls n corners in one + ForEachInTable(self.spawned_walls and self.spawned_walls[side], Slab.UnlockSubvariant) + local css = sideToCornerSides[side] + for _, cs in ipairs(css) do + ForEachInTable(self.spawned_corners and self.spawned_corners[cs], Slab.UnlockSubvariant) + end + + ForEachInTable(self.roof_objs, function(o, side) + if o.side == side and not IsKindOf(o, "RoofSlab") then + o:UnlockSubvariant() + end + end ,side) +end + +function Room:ForEachSpawnedObjNoDoorsWindows(func, ...) + return self:_ForEachSpawnedObj(room_NSWE_lists_no_DoorsWindows, room_regular_lists, func, ...) +end + +function Room:ForEachSpawnedObj(func, ...) + return self:_ForEachSpawnedObj(room_NSWE_lists, room_regular_lists, func, ...) +end + +function Room:_ForEachSpawnedObj(NSWE_lists, regular_lists, func, ...) + for i = 1, #NSWE_lists do + for side, objs in NSEW_pairs(self[NSWE_lists[i]] or empty_table) do + for j = 1, #objs do + if IsValid(objs[j]) then func(objs[j], ...) end + end + end + end + + for i = 1, #regular_lists do + local lst = self[regular_lists[i]] or "" + for j = 1, #lst do + if IsValid(lst[j]) then func(lst[j], ...) end + end + end +end + +function Room:GetEditorRelatedObjects() + local ret = {} + for i = 1, #room_NSWE_lists do + for side, objs in NSEW_pairs(self[room_NSWE_lists[i]] or empty_table) do + for _, obj in ipairs(objs) do + if obj then + ret[#ret + 1] = obj + if obj:HasMember("owned_objs") and obj.owned_objs then + table.iappend(ret, obj.owned_objs) + end + if obj:HasMember("owned_slabs") and obj.owned_slabs then + table.iappend(ret, obj.owned_slabs) + end + end + end + end + end + for i = 1, #room_regular_lists do + table.iappend(ret, self[room_regular_lists[i]] or empty_table) + end + Msg("GatherRoomRelatedObjects", self, ret) + return ret +end + +function Room:SetWarped(warped, force) + CObject.SetWarped(self, warped) + if force or not IsChangingMap() then + self:ForEachSpawnedObj(function(obj) + obj:SetWarped(warped) + end) + end +end + +local function copyWallObjs(t, offset, room) + local ret = {} + for side, objs in NSEW_pairs(t or empty_table) do + ret[side] = {} + for i = 1, #objs do + local o = objs[i] + local no = PlaceObject(o.class) + no.floor = room.floor + no.width = o.width + no.height = o.height + no.material = o.material + no:SetPos(o:GetPos() + offset) + no:SetAngle(o:GetAngle()) + table.insert(ret[side], no) + no:UpdateEntity() + end + end + + return ret +end + +local function copyDecals(t, offset, room) + local ret = {} + for side, objs in NSEW_pairs(t or empty_table) do + ret[side] = {} + for i = 1, #objs do + local o = objs[i] + local no = PlaceObject(o.class) + no.floor = room.floor + no:SetPos(o:GetPos() + offset) + no:SetAngle(o:GetAngle()) + no.restriction_box = Offset(o.restriction_box, offset) + table.insert(ret[side], no) + end + end + + return ret +end + +function Room:OnSetdoors_windows_blocked() + self:ForEachSpawnedWallObj(function(o, val) + o:SetlockpickState(val and "blocked" or "closed") + end, self.doors_windows_blocked) +end + +function Room:OnSetnone_roof_wall_mat_does_not_affect_nbrs() + self:ComputeRoomVisibility() +end + +function Room:OnSetnone_wall_mat_does_not_affect_nbrs() + self:ComputeRoomVisibility() +end + +function Room:OnCopied(from, offset) + Volume.OnCopied(self, from, offset) + self:CreateAllSlabs() + + self.spawned_doors = copyWallObjs(from.spawned_doors, offset, self) + self.spawned_windows = copyWallObjs(from.spawned_windows, offset, self) + self.spawned_decals = copyDecals(from.spawned_decals, offset, self) +end + +function Room:OnAfterEditorNew(parent, ged, is_paste) + --undo deletion from ged + self.adjacent_rooms = nil + self:AlignObj() + self:CreateAllSlabs() +end + +function Room:OnEditorSetProperty(prop_id, old_value, ged) + if not IsValid(self) then return end --undo on deleted obj + local f = rawget(Room, string.format("OnSet%s", prop_id)) + if f then + f(self, self[prop_id], old_value) + DelayedCall(500, BuildBuildingsData) + end +end + +function Room:OnSethide_floors_editor() + assert(IsEditorActive()) + HideFloorsAboveThisOne = rawget(self, "hide_floors_editor") + HideFloorsAbove(HideFloorsAboveThisOne) +end + +function Room:Gethide_floors_editor() + return HideFloorsAboveThisOne +end + +function Room:OnSetwireframe_visible() + self:ToggleGeometryVisible() +end + +function Room:OnSetwall_text_markers_visible() + self:TogglePosMarkersVisible() +end + +function Room:OnSetinner_wall_mat(val, oldVal) + if val == "" then + val = noneWallMat + self.inner_wall_mat = val + end + if (val == noneWallMat or oldVal == noneWallMat) and val ~= oldVal then + self:UnlockAllWalls() + end + self:SetInnerMaterialToSlabs("North") + self:SetInnerMaterialToSlabs("South") + self:SetInnerMaterialToSlabs("West") + self:SetInnerMaterialToSlabs("East") + self:SetInnerMaterialToRoofObjs() +end + +function Room:OnSetinner_colors(val, oldVal) + self:SetInnerMaterialToSlabs("North") + self:SetInnerMaterialToSlabs("South") + self:SetInnerMaterialToSlabs("West") + self:SetInnerMaterialToSlabs("East") + self:SetInnerMaterialToRoofObjs() +end + +function Room:OnSetouter_colors(val, oldVal) + local function iterateNSEWTableAndSetColor(t) + if not t then return end + for side, list in NSEW_pairs(t) do + for i = 1, #list do + local o = list[i] + if IsValid(o) then --wall piece might be deleted by lvl designer + o:Setcolors(val) + end + end + end + end + iterateNSEWTableAndSetColor(self.spawned_walls) + + for side, list in NSEW_pairs(self.spawned_corners) do + for i = 1, #list do + local o = list[i] + if IsValid(o) then + o:SetColorFromRoom() + end + end + end + + for side, list in NSEW_pairs(self.spawned_windows or empty_table) do + for i = 1, #list do + list[i]:UpdateManagedSlabs() + list[i]:RefreshColors() + end + end + + for side, list in NSEW_pairs(self.spawned_doors or empty_table) do + for i = 1, #list do + list[i]:RefreshColors() + end + end + + if self.roof_objs then + for i=1,#self.roof_objs do + local o = self.roof_objs[i] + if IsValid(o) and not IsKindOf(o, "RoofSlab") then + o:Setcolors(val) + end + end + end +end + +function Room:OnSetfloor_colors(val, oldVal) + for i = 1, #(self.spawned_floors or "") do + local o = self.spawned_floors[i] + if IsValid(o) then + o:Setcolors(val) + end + end +end + +function Room:OnSetfloor_mat(val) + self:UnlockFloor() + self:CreateFloor() +end + +function Room:ResetWallMaterials() + local wm = defaultWallMat + local change = self.north_wall_mat ~= wm + self.north_wall_mat = wm + change = change or self.south_wall_mat ~= wm + self.south_wall_mat = wm + change = change or self.west_wall_mat ~= wm + self.west_wall_mat = wm + change = change or self.east_wall_mat ~= wm + self.east_wall_mat = wm + --todo: wall added/removed msgs + if change then + ObjModified(self) + self:UnlockAllWalls() + self:CreateAllWalls() + self:RecreateRoof() + end +end + +local function FireWallChangedEventsHelper(self, side, val, oldVal) + local wasWall = oldVal ~= noneWallMat and (oldVal ~= defaultWallMat or self.wall_mat ~= noneWallMat) + local isWall = val ~= noneWallMat and (val ~= defaultWallMat or self.wall_mat ~= noneWallMat) + if not wasWall and isWall then + Msg("RoomAddedWall", self, side) + elseif wasWall and not isWall then + Msg("RoomRemovedWall", self, side) + end +end + +function Room:OnSetnorth_wall_mat(val, oldVal) + self:UnlockWallSide("North") + self:CreateWalls("North", val) + self:RecreateNECornerBeam() + self:RecreateNWCornerBeam() + self:RecreateRoof() + FireWallChangedEventsHelper(self, "North", val, oldVal) + self:CheckWallSizes() +end + +function Room:OnSetsouth_wall_mat(val, oldVal) + self:UnlockWallSide("South") + self:CreateWalls("South", val) + self:RecreateSECornerBeam() + self:RecreateSWCornerBeam() + self:RecreateRoof() + FireWallChangedEventsHelper(self, "South", val, oldVal) + self:CheckWallSizes() +end + +function Room:OnSetwest_wall_mat(val, oldVal) + self:UnlockWallSide("West") + self:CreateWalls("West", val) + self:RecreateSWCornerBeam() + self:RecreateNWCornerBeam() + self:RecreateRoof() + FireWallChangedEventsHelper(self, "West", val, oldVal) + self:CheckWallSizes() +end + +function Room:OnSeteast_wall_mat(val, oldVal) + self:UnlockWallSide("East") + self:CreateWalls("East", val) + self:RecreateSECornerBeam() + self:RecreateNECornerBeam() + self:RecreateRoof() + FireWallChangedEventsHelper(self, "East", val, oldVal) + self:CheckWallSizes() +end + +function Room:SetWallMaterial(val) + local ov = self.wall_mat + self.wall_mat = val + if IsChangingMap() then return end + self:OnSetwall_mat(val, ov) +end + +function Room:OnSetwall_mat(val, oldVal) + if val == "" then + val = noneWallMat + self.wall_mat = val + end + self:UnlockAllWalls() + self:CreateAllWalls() + self:RecreateRoof() + + local wasWall = oldVal ~= noneWallMat + local isWall = val ~= noneWallMat + local ev = false + if wasWall and not isWall then + ev = "RoomRemovedWall" + elseif not wasWall and isWall then + ev = "RoomAddedWall" + end + + if ev then + if self.south_wall_mat == defaultWallMat then + Msg(ev, self, "South") + end + if self.north_wall_mat == defaultWallMat then + Msg(ev, self, "North") + end + if self.west_wall_mat == defaultWallMat then + Msg(ev, self, "West") + end + if self.east_wall_mat == defaultWallMat then + Msg(ev, self, "East") + end + end +end + +function SizeSetterHelper(self, old_v) + if IsChangingMap() then return end + local oldBox = self.box + self:InternalAlignObj(true) + if self:CheckCollision() then + self.size = old_v + self:InternalAlignObj(true) + return false + else + self:Resize(old_v, self.size, oldBox) + Volume.FinishAlign(self) + Msg("RoomResized", self, old_v) --old_v == old self.size + return true + end +end + +function Room:OnSetsize_x(val) + local old_v = self.size + self.size = point(val, self.size:y(), self.size:z()) + SizeSetterHelper(self, old_v) +end + +function Room:OnSetsize_y(val) + local old_v = self.size + self.size = point(self.size:x(), val, self.size:z()) + SizeSetterHelper(self, old_v) +end + +function Room:OnSetsize_z(val) + local old_v = self.size + self.size = point(self.size:x(), self.size:y(), val) + SizeSetterHelper(self, old_v) + if val == 0 then + self:DeleteAllWallObjs() + self:DeleteAllFloors() + self:DeleteAllCornerObjs() + end +end + +function Room:Getsize_x() + return self.size:x() +end + +function Room:Getsize_y() + return self.size:y() +end + +function Room:Getsize_z() + return self.size:z() +end + +function Room:Getmove_x() + local x = WorldToVoxel(self.position) + return x +end + +function Room:Getmove_y() + local _, y = WorldToVoxel(self.position) + return y +end + +function Room:Getmove_z() + local x, y, z = WorldToVoxel(self.position) + return z +end + +function Room:OnSetz_offset(val, old_v) + moveHelper(self, "z_offset", old_v, 0, 0, val - old_v) +end + +function Room:OnSetmove_x(val) + local old_v = self.position + local x, y, z = WorldToVoxel(self.position) + self.position = SnapVolumePos(VoxelToWorld(val, y, z, true)) + moveHelper(self, "position", old_v, val - x, 0, 0) +end + +function Room:OnSetmove_y(val) + local old_v = self.position + local x, y, z = WorldToVoxel(self.position) + self.position = SnapVolumePos(VoxelToWorld(x, val, z, true)) + moveHelper(self, "position", old_v, 0, val - y, 0) +end + +function Room:OnSetmove_z(val) + local old_v = self.position + local x, y, z = WorldToVoxel(self.position) + self.position = SnapVolumePos(VoxelToWorld(x, y, val, true)) + moveHelper(self, "position", old_v, 0, 0, val - z) +end + +--left to right sort on selected wall +local dirToComparitor = { + South = function(o1, o2) + local x1, _, _ = o1:GetPosXYZ() + local x2, _, _ = o2:GetPosXYZ() + return x1 < x2 + end, + + North = function(o1, o2) + local x1, _, _ = o1:GetPosXYZ() + local x2, _, _ = o2:GetPosXYZ() + return x2 < x1 + end, + + West = function(o1, o2) + local _, y1, _ = o1:GetPosXYZ() + local _, y2, _ = o2:GetPosXYZ() + return y1 < y2 + end, + + East = function(o1, o2) + local _, y1, _ = o1:GetPosXYZ() + local _, y2, _ = o2:GetPosXYZ() + return y2 < y1 + end, +} + +function Room:SortWallObjs(objs, dir) + table.sort(objs, dirToComparitor[dir]) +end + +--for doors/windows +function Room:CalculateRestrictionBox(dir, wallPos, wallSize, height, width) + local xofs, nxofs = 0, 0 + local yofs, nyofs = 0, 0 + width = Max(width, 1) + + if dir == "North" or dir == "South" then + xofs = (wallSize / 2 - width * voxelSizeX / 2) + nxofs = xofs + if width % 2 == 0 then + local m = (dir == "South" and -1 or 1) + xofs = xofs + m * voxelSizeX / 2 + nxofs = nxofs - m * voxelSizeX / 2 + end + else + yofs = (wallSize / 2 - width * voxelSizeY / 2) + nyofs = yofs + if width % 2 == 0 then + local m = (dir == "West" and -1 or 1) + yofs = yofs + m * voxelSizeX / 2 + nyofs = nyofs - m * voxelSizeX / 2 + end + end + + local maxZ = wallPos:z() + (self.size:z() * voxelSizeZ - height * voxelSizeZ) + return box(wallPos:x() - nxofs, wallPos:y() - nyofs, wallPos:z(), wallPos:x() + xofs, wallPos:y() + yofs, maxZ) +end + +function Room:FindSlabObjPos(dir, width, height) + local sizeX, sizeY = self.size:x(), self.size:y() + if dir == "North" or dir == "South" then + if width > sizeX then + print("Obj is too big") + return false + end + else + if width > sizeY then + print("Obj is too big") + return false + end + end + + local z = self:CalcZ() + (3 - height) * voxelSizeZ + local angle = 0 + local sx, sy = self.position:x(), self.position:y() + local offsx = 0 + local offsy = 0 + local max = 0 + + if dir == "North" then + angle = 270 * 60 + offsx = voxelSizeX + sx = sx + halfVoxelSizeX + max = sizeX + elseif dir == "East" then + angle = 0 + offsy = voxelSizeY + sx = sx + sizeX * voxelSizeX + sy = sy + halfVoxelSizeY + max = sizeY + elseif dir == "South" then + angle = 90 * 60 + offsx = voxelSizeX + sy = sy + sizeY * voxelSizeY + sx = sx + halfVoxelSizeX + max = sizeX + elseif dir == "West" then + angle = 180 * 60 + offsy = voxelSizeY + sy = sy + halfVoxelSizeY + max = sizeY + end + + local iStart = width == 3 and 1 or 0 + + for i = iStart, max - 1 do + local x = sx + offsx * i + local y = sy + offsy * i + + local newPos = point(x, y, z) + local canPlace = not IntersectWallObjs(nil, newPos, width, height, angle) + + if canPlace then + return newPos + end + end + + return false +end + +function Room:NewSlabWallObj(obj, class) + class = class or SlabWallObject + return class:new(obj) +end + +function Room:ForEachSpawnedWindow(func, ...) + for _, t in sorted_pairs(self.spawned_windows or empty_table) do + for i = #t, 1, -1 do + func(t[i], ...) + end + end +end + +function Room:ForEachSpawnedDoor(func, ...) + for _, t in sorted_pairs(self.spawned_doors or empty_table) do + for i = #t, 1, -1 do --functor may del + func(t[i], ...) + end + end +end + +function Room:ForEachSpawnedWallObj(func, ...) --doors and windows + self:ForEachSpawnedDoor(func, ...) + self:ForEachSpawnedWindow(func, ...) +end + +function Room:PlaceWallObj(val, side, class) + local dir = side or self.selected_wall + assert(dir) + if not dir then return end + --check for collision, pick pos + local freePos = self:FindSlabObjPos(dir, val.width, val.height) + + if not freePos then + print("No free pos found!") + return + end + local wallPos, wallSize, center = self:GetWallPos(dir) + local obj = self:NewSlabWallObj({ + entity = false, room = self, material = val.mat or "Planks", + building_class = val.building_class or nil, building_template = val.building_template or nil, + side = dir + }, + class.class) + local a = slabDirToAngle[dir] + local zPosOffset = (3 - val.height) * voxelSizeZ + local vx, vy, vz, va = WallWorldToVoxel(freePos:x(), freePos:y(), wallPos:z() + zPosOffset, a) + local pos = point(WallVoxelToWorld(vx, vy, vz, va)) + obj.room = self + obj.floor = self.floor + obj.subvariant = 1 + obj:SetPos(pos) + obj:SetAngle(a) + obj:SetProperty("width", val.width) + obj:SetProperty("height", val.height) + obj:AlignObj() + obj:UpdateEntity() + + local container, nestedList + if val.is_door or obj:IsDoor() then --door + self.spawned_doors = self.spawned_doors or {} + self.spawned_doors[dir] = self.spawned_doors[dir] or {} + container = self.spawned_doors + else --window + self.spawned_windows = self.spawned_windows or {} + self.spawned_windows[dir] = self.spawned_windows[dir] or {} + container = self.spawned_windows + end + + if container then + table.insert(container[dir], obj) + end + + if Platform.editor and IsEditorActive() then + editor.ClearSel() + editor.AddToSel({obj}) + end + + return obj +end + + +function Room:CalculateDecalRestrictionBox(dir, wallPos, wallSize) + local xofs, nxofs = 0, 0 + local yofs, nyofs = 0, 0 + if dir == "North" or dir == "South" then + xofs = wallSize / 2 + nxofs = xofs + + wallPos = wallPos:SetY(wallPos:y() + 100 * (dir == "North" and -1 or 1)) + else + yofs = wallSize / 2 + nyofs = yofs + + wallPos = wallPos:SetX(wallPos:x() + 100 * (dir == "West" and -1 or 1)) + end + local maxZ = wallPos:z() + (self.size:z() * voxelSizeZ) + 1 + return box(wallPos:x() - nxofs, wallPos:y() - nyofs, wallPos:z(), wallPos:x() + xofs, wallPos:y() + yofs, maxZ) +end + +DefineClass.RoomDecal = { + __parents = { "AlignedObj", "Decal", "Shapeshifter", "Restrictor", "HideOnFloorChange" }, + properties = { + { category = "General", id = "entity", editor = "text", default = false, no_edit = true }, + }, + flags = { cfAlignObj = true, cfDecal = true, efCollision = false, gofPermanent = true, }, +} + +function RoomDecal:AlignObj(pos, angle, axis) + pos = pos or self:GetPos() + local x, y, z = self:RestrictXYZ(pos:xyz()) + + self:SetPos(x, y, z) + self:SetAxisAngle(axis or self:GetAxis(), angle or self:GetAngle()) +end + +function RoomDecal:ChangeEntity(val) + Shapeshifter.ChangeEntity(self, val) + self.entity = val +end + +function RoomDecal:GameInit() + if IsChangingMap() and self.entity then + Shapeshifter.ChangeEntity(self, self.entity) + end +end + +function RoomDecal:Done() + local safe = rawget(self, "safe_deletion") + if not safe then + --decals dont have ref to the room they belong to, so the check is all weird + local box = self.restriction_box + if box then + local passed = {} + MapForEach(box:grow(100, 100, 0), "WallSlab", function(s) + local side = s.side + local room = s.room + if room then + local id = xxhash(room.handle, side) + if not passed[id] then + passed[id] = true + local t = room.spawned_decals[side] + local t_idx = table.find(t, self) + if t_idx then + table.remove(t, t_idx) + ObjModified(room) + return "break" + end + end + end + end) + else + local b = self:GetObjectBBox() + local success = false + b = b:grow(guim, guim, guim) + EnumVolumes(b, function(r) + local t = r.spawned_decals + for side, tt in pairs(t or empty_table) do + local t_idx = table.find(tt, self) + if t_idx then + table.remove(tt, t_idx) + ObjModified(r) + success = true + return "break" + end + end + end) + + if not success then + assert(false, "RoomDecal not safely deleted, ref in room remains!") + end + end + end +end + +function Room:Setplace_decal(val) + local dir = self.selected_wall + if not dir then return end + + local wallPos, wallSize, center = self:GetWallPos(dir) + local a = slabDirToAngle[dir] + + local obj = RoomDecal:new() + obj.floor = self.floor + obj:ChangeEntity(val.entity) + obj:SetAngle(a) + local xOffs = 0 + local yOffs = 0 + if dir == "East" then + obj:SetAxis(axis_y) + obj:SetAngle(90 * 60) + xOffs = 100 + elseif dir == "West" then + obj:SetAxis(axis_y) + obj:SetAngle(-90 * 60) + xOffs = -100 + elseif dir == "North" then + obj:SetAxis(axis_x) + obj:SetAngle(90 * 60) + yOffs = -100 + elseif dir == "South" then + obj:SetAxis(axis_x) + obj:SetAngle(-90 * 60) + yOffs = 100 + end + obj:SetPos(wallPos + point(xOffs, yOffs, voxelSizeZ * self.size:z() / 2)) + obj.restriction_box = self:CalculateDecalRestrictionBox(dir, wallPos, wallSize) + --DbgAddBox(obj.restriction_box, RGB(255, 0, 0)) + + self.spawned_decals = self.spawned_decals or {} + self.spawned_decals[dir] = self.spawned_decals[dir] or {} + table.insert(self.spawned_decals[dir], obj) + + editor.ClearSel() + editor.AddToSel({obj}) + + self.place_decal = "temp" + ObjModified(self) + self.place_decal = "" + ObjModified(self) +end + +function Room:DeleteWallObjHelper(d) + d:RestoreAffectedSlabs() + DoneObject(d) + ObjModified(self) +end + +function Room:DeleteWallObjs(container, dir) + if not dir then + self:DeleteWallObjs("North", container) + self:DeleteWallObjs("South", container) + self:DeleteWallObjs("East", container) + self:DeleteWallObjs("West", container) + self:DeleteAllFloors() + self:DeleteAllCornerObjs() + else + local t = container and container[dir] + for i = #(t or empty_table), 1, -1 do + if IsValid(t[i]) then --can be killed from editor + self:DeleteWallObjHelper(t[i]) + end + t[i] = nil + end + ObjModified(self) + end +end + +function Room:RebuildAllSlabs() + self:DeleteAllSlabs() + self:CreateAllSlabs() + self:RecreateRoof("force") +end + +function Room:DoneObjectsInNWESTable(t) + for k, v in NSEW_pairs(t or empty_table) do + --windows and doors are so clever that they remove themselves from these lists when deleted, which causes DoneObjects to sometimes fail + while #v > 0 do + local idx = #v + DoneObject(v[idx]) + v[idx] = nil + end + end +end + +function Room:DeleteAllSlabs() + SuspendPassEdits("Room:DeleteAllSpawnedObjs") + self:DeleteAllWallObjs() + self:DeleteAllCornerObjs() + self:DeleteAllFloors() + self:DeleteRoofObjs() + ResumePassEdits("Room:DeleteAllSpawnedObjs") +end + +function Room:DeleteAllSpawnedObjs() + SuspendPassEdits("Room:DeleteAllSpawnedObjs") + self:DeleteAllWallObjs() + self:DeleteAllCornerObjs() + self:DeleteAllFloors() + self:DeleteRoofObjs() + self:DoneObjectsInNWESTable(self.spawned_doors) + self:DoneObjectsInNWESTable(self.spawned_windows) + self:DoneObjectsInNWESTable(self.spawned_decals) + ResumePassEdits("Room:DeleteAllSpawnedObjs") +end + +function Room:DeleteAllFloors() + SuspendPassEdits("Room:DeleteAllFloors") + DoneObjects(self.spawned_floors, "clear") + ResumePassEdits("Room:DeleteAllFloors") + Msg("RoomDestroyedFloor", self) +end + +function Room:DeleteAllCornerObjs() + for k, v in NSEW_pairs(self.spawned_corners or empty_table) do + DoneObjects(v, "clear") + end +end + +function Room:DeleteAllWallObjs() + SuspendPassEdits("Room:DeleteAllWallObjs") + for k, v in NSEW_pairs(self.spawned_walls or empty_table) do + DoneObjects(v, "clear") + end + ResumePassEdits("Room:DeleteAllWallObjs") +end + +function Room:HasWall(mat) + return mat ~= noneWallMat and (mat ~= defaultWallMat or self.wall_mat ~= noneWallMat) +end + +function Room:HasWallOnSide(side) + return self:GetWallMatHelperSide(side) ~= noneWallMat +end + +function Room:HasAllWalls() + for _, side in ipairs(CardinalDirectionNames) do + if not self:HasWallOnSide(side) then + return false + end + end + + return true +end + +function Room:RecreateNWCornerBeam() + local mat = self.north_wall_mat + if mat == noneWallMat then + mat = self.west_wall_mat + end + self:CreateCornerBeam("North", mat) --nw +end + +function Room:RecreateSWCornerBeam() + local mat = self.west_wall_mat + if mat == noneWallMat then + mat = self.south_wall_mat + end + self:CreateCornerBeam("West", mat) --sw +end + +function Room:RecreateNECornerBeam() + local mat = self.east_wall_mat + if mat == noneWallMat then + mat = self.north_wall_mat + end + self:CreateCornerBeam("East", mat) --ne +end + +function Room:RecreateSECornerBeam() + local mat = self.south_wall_mat + if mat == noneWallMat then + mat = self.east_wall_mat + end + self:CreateCornerBeam("South", mat) --se +end + +function Room:CreateAllWalls() + SuspendPassEdits("Room:CreateAllWalls") + self:CreateWalls("North", self.north_wall_mat) + self:CreateWalls("South", self.south_wall_mat) + self:CreateWalls("West", self.west_wall_mat) + self:CreateWalls("East", self.east_wall_mat) + self:CheckWallSizes() + ResumePassEdits("Room:CreateAllWalls") +end + +function Room:CreateAllSlabs() + SuspendPassEdits("Room:CreateAllSlabs") + self:CreateAllWalls() + self:CreateFloor() + self:CreateAllCorners() + if not self.being_placed then + self:RecreateRoof() + end + self:SetWarped(self:GetWarped(), true) + ResumePassEdits("Room:CreateAllSlabs") +end + +function Room:RefreshFloorCombatStatus() + local floorsAreCO = g_Classes.CombatObject and IsKindOf(FloorSlab, "CombatObject") + if not floorsAreCO then return end + + local flr = self.floor + local val = not self:IsRoofOnly() and flr == 1 + for i = 1, #(self.spawned_floors or "") do + local f = self.spawned_floors[i] + if IsValid(f) then + f.impenetrable = val + f.invulnerable = val + f.forceInvulnerableBecauseOfGameRules = val + end + end +end + +function Room:CreateFloor(mat, startI, startJ) + mat = mat or self.floor_mat + self.spawned_floors = self.spawned_floors or {} + local objs = self.spawned_floors + + local gz = self:CalcZ() + local sx, sy = self.position:x(), self.position:y() + local sizeX, sizeY, sizeZ = self.size:xyz() + + if sizeZ <= 0 then + self:DeleteAllFloors() + print("Removed floor because it is a zero height room. ") + return + end + + sx = sx + halfVoxelSizeX + sy = sy + halfVoxelSizeY + startI = startI or 0 + startJ = startJ or 0 + + if self:GetGameFlags(const.gofPermanent) ~= 0 then + local floorBBox = box(sx, sy, gz, sx + voxelSizeX * (sizeX - 1), sy + voxelSizeY * (sizeY - 1), gz + 1) + ComputeSlabVisibilityInBox(floorBBox) + end + + SuspendPassEdits("Room:CreateFloor") + local insertElements = startJ ~= 0 and #objs < sizeX * sizeY + local floorsAreCO = g_Classes.CombatObject and IsKindOf(FloorSlab, "CombatObject") + local floorsAreInvulnerable = floorsAreCO and not self:IsRoofOnly() and self.floor == 1 + for xOffset = startI, sizeX - 1 do + for yOffset = xOffset == startI and startJ or 0, sizeY - 1 do + local x = sx + xOffset * voxelSizeX + local y = sy + yOffset * voxelSizeY + local idx = xOffset * sizeY + yOffset + 1 + + if insertElements then + if #objs < idx then + objs[idx] = false + else + table.insert(objs, idx, false) + end + + insertElements = insertElements and #objs < sizeX * sizeY + end + + local floor = objs[idx] + if not IsValid(floor) then + floor = FloorSlab:new{floor = self.floor, material = mat, side = "Floor", room = self} + floor:SetPos(x, y, gz) + floor:AlignObj() + floor:UpdateEntity() + floor:Setcolors(self.floor_colors) + objs[idx] = floor + else + floor:SetPos(x, y, gz) + if floor.material ~= mat then + floor.material = mat + floor:UpdateEntity() + else + floor:UpdateSimMaterialId() + end + + end + + floor.floor = self.floor + if floorsAreCO then --zulu specific + floor.impenetrable = floorsAreInvulnerable + floor.invulnerable = floorsAreInvulnerable + floor.forceInvulnerableBecauseOfGameRules = floorsAreInvulnerable + end + end + end + ResumePassEdits("Room:CreateFloor") + Msg("RoomCreatedFloor", self, mat) +end + +function Room:CreateCornerBeam(dir, mat) --corner is next clockwise corner from dir + self.spawned_corners = self.spawned_corners or {North = {}, South = {}, West = {}, East = {}} + local objs = self.spawned_corners[dir] + + if mat == defaultWallMat then + mat = self.wall_mat + end + + local gz = self:CalcSnappedZ() + local sx, sy = self.position:x(), self.position:y() + local sizeX, sizeY = self.size:x(), self.size:y() + if dir == "South" or dir == "West" then + sy = sy + sizeY * voxelSizeY + end + + if dir == "South" or dir == "East" then + sx = sx + sizeX * voxelSizeX + end + + local count = self.size:z() + 1 + if count < #objs then + for i = #objs, count + 1, -1 do + DoneObject(objs[i]) + objs[i] = nil + end + end + + local isPermanent = self:GetGameFlags(const.gofPermanent) ~= 0 + local sz = self.size:z() + + if sz > 0 then + for j = 0, sz do + local z = gz + voxelSizeZ * Min(j, self.size:z() - 1) + local pt = point(sx, sy, z) + + local obj = objs[j + 1] + if not IsValid(obj) then + obj = PlaceObject("RoomCorner", {room = self, side = dir, floor = self.floor, material = mat}) + objs[j + 1] = obj + end + obj.isPlug = j == self.size:z() + obj:SetPos(pt) + obj.material = mat + obj.invulnerable = false + obj.forceInvulnerableBecauseOfGameRules = false + if not isPermanent then + obj:UpdateEntity() --corners rely on ComputeSlabVisibilityInBox to update their ents + end + end + end + + if isPermanent then + local box = box(sx, sy, gz, sx, sy, gz + voxelSizeZ * (self.size:z() - 1)) + ComputeSlabVisibilityInBox(box) + end +end + +function Room:GetWallMatHelperSide(side) + local m = dirToWallMatMember[side] + return m and self:GetWallMatHelper(self[m]) or nil +end + +function Room:GetWallMatHelper(mat) + return mat == defaultWallMat and self.wall_mat or mat +end + +function Room:RecalcAllRestrictionBoxes(dir, containers) + local wallPos, wallSize, center = self:GetWallPos(dir) + for j = 1, #(containers or empty_table) do + local container = containers[j] + local t = container and container[dir] + + --save fixup, idk how, sometimes decal lists have false entries + for i = #(t or ""), 1, -1 do + if type(t[i]) == "boolean" then + table.remove(t, i) + print("once", "Found badly saved decals/windows/doors!") + end + end + + if t and IsKindOf(t[1], "RoomDecal") then + for i = 1, #(t or empty_table) do + local o = t[i] + if o then + o.restriction_box = self:CalculateDecalRestrictionBox(dir, wallPos, wallSize) + o:AlignObj() + end + end + end + end +end + +function Room:Resize(oldSize, newSize, oldBox) + if oldSize == newSize then + return + end + + SuspendPassEdits("Room:Resize") + local delta = newSize - oldSize + + local offsetY = delta:y() * voxelSizeY + local offsetX = delta:x() * voxelSizeX + local offsetZ = delta:z() * voxelSizeZ + local sx, sy = self.position:x(), self.position:y() + sx = sx + halfVoxelSizeX + sy = sy + halfVoxelSizeY + local sizeX, sizeY = newSize:x(), newSize:y() + + local function moveObjs(objs) + if not objs then return end + for i = 1, #objs do + local o = objs[i] + if IsValid(o) then + local x, y, z = o:GetPosXYZ() + o:SetPos(x + offsetX, y + offsetY, z + offsetZ) + end + end + end + + local function moveObjX(o) + local x, y, z = o:GetPosXYZ() + o:SetPos(x + offsetX, y, z) + if IsKindOf(o, "SlabWallObject") then + o:UpdateManagedObj() + end + end + + local function moveObjsX(objs) + if not objs then return end + for i = 1, #objs do + local o = objs[i] + if IsValid(o) then + moveObjX(o) + end + end + end + + local function moveObjY(o) + local x, y, z = o:GetPosXYZ() + o:SetPos(x, y + offsetY, z) + if IsKindOf(o, "SlabWallObject") then + o:UpdateManagedObj() + end + end + + local function moveObjsY(objs) + if not objs then return end + for i = 1, #objs do + local o = objs[i] + if IsValid(o) then + moveObjY(o) + end + end + end + + local function moveObjsZ(objs) + if not objs then return end + for i = 1, #objs do + local o = objs[i] + if IsValid(o) then + local x, y, z = o:GetPosXYZ() + o:SetPos(x, y, z + offsetZ) + end + end + end + + if delta:y() ~= 0 then + --south wall moves + moveObjsY(self.spawned_walls and self.spawned_walls.South) + moveObjsY(self.spawned_doors and self.spawned_doors.South) + moveObjsY(self.spawned_windows and self.spawned_windows.South) + if self.spawned_corners then + moveObjsY(self.spawned_corners.South) + moveObjsY(self.spawned_corners.West) + end + local containers = {self.spawned_doors, self.spawned_windows, self.spawned_decals} + self:RecalcAllRestrictionBoxes("East", containers) + self:RecalcAllRestrictionBoxes("West", containers) + self:RecalcAllRestrictionBoxes("South", containers) + end + if delta:x() ~= 0 then + --east wall moves + moveObjsX(self.spawned_walls and self.spawned_walls.East) + moveObjsX(self.spawned_doors and self.spawned_doors.East) + moveObjsX(self.spawned_windows and self.spawned_windows.East) + if self.spawned_corners then + moveObjsX(self.spawned_corners.South) + moveObjsX(self.spawned_corners.East) + end + local containers = {self.spawned_doors, self.spawned_windows, self.spawned_decals} + self:RecalcAllRestrictionBoxes("North", containers) + self:RecalcAllRestrictionBoxes("East", containers) + self:RecalcAllRestrictionBoxes("South", containers) + end + + if delta:z() ~= 0 then + if delta:z() > 0 then + local move = delta:x() ~= 0 or delta:y() ~= 0 --needs to reorder slabs so let it go through the entire wall + self:CreateWalls("South", self.south_wall_mat, nil, not move and oldSize:z(), nil, nil, move) + self:CreateWalls("North", self.north_wall_mat, nil, not move and oldSize:z(), nil, nil, move) + self:CreateWalls("East", self.east_wall_mat, nil, not move and oldSize:z(), nil, nil, move) + self:CreateWalls("West", self.west_wall_mat, nil, not move and oldSize:z(), nil, nil, move) + else + self:DestroyWalls("East", nil, oldSize, nil, newSize:z()) + self:DestroyWalls("West", nil, oldSize, nil, newSize:z()) + self:DestroyWalls("North", nil, oldSize, nil, newSize:z()) + self:DestroyWalls("South", nil, oldSize, nil, newSize:z()) + end + end + + if delta:y() ~= 0 then + if delta:y() < 0 then + local count = abs(delta:y()) + self:DestroyWalls("East", count, oldSize:SetZ(newSize:z())) + self:DestroyWalls("West", count, oldSize:SetZ(newSize:z())) + + + local floors = self.spawned_floors + for i = oldSize:x() - 1, 0, -1 do + for j = oldSize:y() - 1, newSize:y(), -1 do + local idx = (i * oldSize:y()) + j + 1 + local o = floors[idx] + if o then + DoneObject(o) + table.remove(floors, idx) + end + end + end + else + if self.spawned_walls then + local ew = self.spawned_walls.East + local ww = self.spawned_walls.West + if ew then + self:CreateWalls("East", self.east_wall_mat, newSize:y() - delta:y()) + end + if ww then + self:CreateWalls("West", self.west_wall_mat, newSize:y() - delta:y()) + end + end + + self:CreateFloor(self.floor_mat, 0, oldSize:y()) + end + end + + if delta:x() ~= 0 then + if delta:x() < 0 then + local count = abs(delta:x()) + self:DestroyWalls("South", count, oldSize:SetZ(newSize:z())) + self:DestroyWalls("North", count, oldSize:SetZ(newSize:z())) + + local floors = self.spawned_floors + local nc = newSize:x() * newSize:y() + local lc = #floors - nc + for i = 1, lc do + local idx = #floors + local f = floors[idx] + DoneObject(f) + floors[idx] = nil + end + else + if self.spawned_walls then + local sw = self.spawned_walls.South + local nw = self.spawned_walls.North + if sw then + self:CreateWalls("South", self.south_wall_mat, newSize:x() - delta:x()) + end + if nw then + self:CreateWalls("North", self.north_wall_mat, newSize:x() - delta:x()) + end + end + + self:CreateFloor(self.floor_mat, oldSize:x()) + end + end + + if oldSize:z() > 0 and newSize:z() <= 0 then + self:DeleteAllFloors() + self:DestroyCorners() + elseif oldSize:z() <= 0 and newSize:z() > 0 then + self:CreateFloor(self.floor_mat) + end + + self:RecreateNECornerBeam() + self:RecreateSECornerBeam() + self:RecreateNWCornerBeam() + self:RecreateSWCornerBeam() + + if not self.being_placed then + self:RecreateRoof() + end + + ResumePassEdits("Room:Resize") + self:CheckWallSizes() +end + +function Room:DestroyCorners() + for side, t in NSEW_pairs(self.spawned_corners or empty_table) do + DoneObjects(t) + self.spawned_corners[side] = {} + end +end + +function Room:MoveAllSpawnedObjs(dvx, dvy, dvz) + local offsetX = dvx * voxelSizeX + local offsetY = dvy * voxelSizeY + local offsetZ = dvz * voxelSizeZ + + if offsetX == 0 and offsetY == 0 and offsetZ == 0 then + return + end + + SuspendPassEdits("Room:MoveAllSpawnedObjs") + local function move(o) + if not IsValid(o) then return end + local x, y, z = o:GetPosXYZ() + o:SetPos(x + offsetX, y + offsetY, z + offsetZ) + --align should not be required, omitted for performance + end + + local function move_window(o) + if not IsValid(o) then return end + local x, y, z = o:GetPosXYZ() + o:SetPos(x + offsetX, y + offsetY, z + offsetZ) + o:AlignObj() --specifically so small windows realign managed slabs, t.t + end + + local function iterateNSWETable(t, m) + m = m or move + for _, st in NSEW_pairs(t or empty_table) do + for i = 1, #st do + local o = st[i] + m(o) + end + end + end + + for i = 1, #(self.spawned_floors or empty_table) do + move(self.spawned_floors[i]) + end + + iterateNSWETable(self.spawned_walls) + iterateNSWETable(self.spawned_corners) + for i = 1, #(self.roof_objs or empty_table) do + move(self.roof_objs[i]) + end + -- move doors & windows after roof_objs, otherwise doors & windows won't find their "main_wall" + iterateNSWETable(self.spawned_doors) + iterateNSWETable(self.spawned_windows, move_window) + iterateNSWETable(self.spawned_decals) + + local containers = {self.spawned_doors, self.spawned_windows, self.spawned_decals} + self:RecalcAllRestrictionBoxes("East", containers) + self:RecalcAllRestrictionBoxes("West", containers) + self:RecalcAllRestrictionBoxes("South", containers) + self:RecalcAllRestrictionBoxes("North", containers) + + ResumePassEdits("Room:MoveAllSpawnedObjs") +end + +function Room:DestroyWalls(dir, count, size, startJ, endJ) + local objs = self.spawned_walls and self.spawned_walls[dir] + local wnd = self.spawned_windows and self.spawned_windows[dir] + local doors = self.spawned_doors and self.spawned_doors[dir] + local len = 0 + local offsX = 0 + local flatOffsX = 0 + local offsY = 0 + local flatOffsY = 0 + local sx, sy = self.position:x(), self.position:y() + local mat + sx = sx + halfVoxelSizeX + sy = sy + halfVoxelSizeY + size = size or self.size + + if dir == "North" then + len = size:x() + mat = self:GetWallMatHelper(self.north_wall_mat) + offsX = voxelSizeX + flatOffsY = -voxelSizeY / 2 + elseif dir == "East" then + len = size:y() + mat = self:GetWallMatHelper(self.east_wall_mat) + flatOffsX = voxelSizeX / 2 + offsY = voxelSizeY + sx = sx + (size:x() - 1) * voxelSizeX + elseif dir == "South" then + len = size:x() + mat = self:GetWallMatHelper(self.south_wall_mat) + offsX = voxelSizeX + flatOffsY = voxelSizeY / 2 + sy = sy + (size:y() - 1) * voxelSizeY + elseif dir == "West" then + len = size:y() + mat = self:GetWallMatHelper(self.west_wall_mat) + flatOffsX = -voxelSizeX / 2 + offsY = voxelSizeY + end + startJ = startJ or size:z() + endJ = endJ or 0 + count = count or len + local gz = self:CalcZ() + + SuspendPassEdits("Room:DestroyWalls") + + self:SortWallObjs(doors or empty_table, dir) + self:SortWallObjs(wnd or empty_table, dir) + + for i = len - 1, len - count, -1 do + for j = startJ - 1, endJ, -1 do + if endJ == 0 then + --clear wind/door + local px = sx + i * offsX + flatOffsX + local py = sy + i * offsY + flatOffsY + local pz = gz + j * voxelSizeZ + local p = point(px, py, pz) + end + + if objs and #objs > 0 then + local idx = i * size:z() + j + 1 + local o = objs[idx] + DoneObject(o) + if #objs >= idx then + table.remove(objs, idx) + end + end + end + end + + local containers = {self.spawned_decals} + self:RecalcAllRestrictionBoxes(dir, containers) + self:TouchWallsAndWindows(dir) + ResumePassEdits("Room:DestroyWalls") + Msg("RoomDestroyedWall", self, dir) +end + +function Room:GetWallSlabPos(dir, idx) + local x, y, z = self.position:xyz() + local sizeX, sizeY, sizeZ = self.size:xyz() + + assert(voxelSizeX == voxelSizeY) + local offs = ((idx - 1) / sizeZ) * voxelSizeX + halfVoxelSizeX + z = z + ((idx - 1) % sizeZ) * voxelSizeZ + + if dir == "North" then + x = x + offs + elseif dir == "South" then + x = x + offs + y = y + sizeY * voxelSizeY + elseif dir == "West" then + y = y + offs + else --dir == "East" + y = y + offs + x = x + sizeX * voxelSizeX + end + + return x, y, z +end + +function Room:TestAllWallPositions() + self:TestWallPositions("North") + self:TestWallPositions("South") + self:TestWallPositions("West") + self:TestWallPositions("East") +end + +function Room:TestWallPositions(dir) + self.spawned_walls = self.spawned_walls or {North = {}, South = {}, East = {}, West = {}} + dir = dir or "North" + local objs = self.spawned_walls[dir] + + local gz = self:CalcZ() + local angle = 0 + local sx, sy = self.position:x(), self.position:y() + --local size = oldSize or self.size + local size = self.size + local sizeX, sizeY, sizeZ = size:x(), size:y(), size:z() + local offsx = 0 + local offsy = 0 + + local endI = (dir == "North" or dir == "South") and sizeX or sizeY + local endJ = sizeZ + local startI = 0 + local startJ = 0 + + if dir == "North" then + angle = 270 * 60 + offsx = voxelSizeX + sx = sx + halfVoxelSizeX + elseif dir == "East" then + angle = 0 + offsy = voxelSizeY + sx = sx + sizeX * voxelSizeX + sy = sy + halfVoxelSizeY + elseif dir == "South" then + angle = 90 * 60 + offsx = voxelSizeX + sy = sy + sizeY * voxelSizeY + sx = sx + halfVoxelSizeX + elseif dir == "West" then + angle = 180 * 60 + offsy = voxelSizeY + sy = sy + halfVoxelSizeY + end + + local insertElements = startJ ~= 0 and #objs < ((dir == "North" or dir == "South") and sizeX or sizeY) * sizeZ + + for i = startI, endI - 1 do + for j = startJ, endJ - 1 do + local px = sx + i * offsx + local py = sy + i * offsy + local z = gz + j * voxelSizeZ + local idx = i * sizeZ + j + 1 + + local s = objs[idx] + if not s or s:GetPos() ~= point(px, py, z) then + print(dir, idx) + end + end + end +end + +function Room:CreateWalls(dir, mat, startI, startJ, endI, endJ, move) + self.spawned_walls = self.spawned_walls or {North = {}, South = {}, East = {}, West = {}} + mat = mat or "Planks" + dir = dir or "North" + local objs = self.spawned_walls[dir] + + if mat == defaultWallMat then + mat = self.wall_mat + end + + local oppositeDir = nil + local gz = self:CalcZ() + local angle = 0 + local sx, sy = self.position:x(), self.position:y() + local size = self.size + local sizeX, sizeY, sizeZ = size:x(), size:y(), size:z() + local offsx = 0 + local offsy = 0 + + endI = endI or (dir == "North" or dir == "South") and sizeX or sizeY + endJ = endJ or sizeZ + startI = startI or 0 + startJ = startJ or 0 + + if dir == "North" then + angle = 270 * 60 + offsx = voxelSizeX + sx = sx + halfVoxelSizeX + oppositeDir = "South" + elseif dir == "East" then + angle = 0 + offsy = voxelSizeY + sx = sx + sizeX * voxelSizeX + sy = sy + halfVoxelSizeY + oppositeDir = "West" + elseif dir == "South" then + angle = 90 * 60 + offsx = voxelSizeX + sy = sy + sizeY * voxelSizeY + sx = sx + halfVoxelSizeX + oppositeDir = "North" + elseif dir == "West" then + angle = 180 * 60 + offsy = voxelSizeY + sy = sy + halfVoxelSizeY + oppositeDir = "East" + end + + if self:GetGameFlags(const.gofPermanent) ~= 0 then + local wallBBox = self:GetWallBox(dir) + ComputeSlabVisibilityInBox(wallBBox) + end + + SuspendPassEdits("Room:CreateWalls") + local insertElements = startJ ~= 0 and #objs < ((dir == "North" or dir == "South") and sizeX or sizeY) * sizeZ + local forceUpdate = self.last_wall_recreate_seed ~= self.seed + local isLoadingMap = IsChangingMap() + local affectedRooms = {} + + for i = startI, endI - 1 do + for j = startJ, endJ - 1 do + local px = sx + i * offsx + local py = sy + i * offsy + local z = gz + j * voxelSizeZ + local idx = i * sizeZ + j + 1 + local m = mat + + if insertElements then + if idx > #objs then + objs[idx] = false --Happens when resizing in (x or y) ~= 0 and z ~= 0 in the same time. Fill it in, second pass will fill in the missing ones without breaking integrity, probably.. + else + table.insert(objs, idx, false) + end + end + + local wall = objs[idx] + if not IsValid(wall) then + wall = WallSlab:new{floor = self.floor, material = m, room = self, side = dir, + variant = self.inner_wall_mat ~= noneWallMat and "OutdoorIndoor" or "Outdoor", indoor_material_1 = self.inner_wall_mat} + wall:SetAngle(angle) + wall:SetPos(px, py, z) + wall:AlignObj() + wall:UpdateEntity() + wall:UpdateVariantEntities() + + wall:Setcolors(self.outer_colors) + wall:Setinterior_attach_colors(self.inner_colors) + wall.invulnerable = false + wall.forceInvulnerableBecauseOfGameRules = false + objs[idx] = wall + else + if move then + --sometimes if we change both z and x or y sizing at the same time we end up with the same number of slabs per wall but they need to be re-arranged. + wall:SetAngle(angle) + local op = wall:GetPos() + wall:SetPos(px, py, z) + if op ~= wall:GetPos() and wall.wall_obj then + local o = wall.wall_obj + o:RestoreAffectedSlabs() + end + wall:AlignObj() + end + wall:UpdateSimMaterialId() + end + + if not isLoadingMap then + if forceUpdate or wall.material ~= m or wall.indoor_material_1 ~= self.inner_wall_mat then + wall.material = m + wall.indoor_material_1 = self.inner_wall_mat + wall:UpdateEntity() + wall:UpdateVariantEntities() + end + end + end + end + + affectedRooms[self] = nil + for room, isMySide in pairs(affectedRooms) do + if IsValid(room) then + local d = isMySide and dir or oppositeDir + room:TouchWallsAndWindows(d) + room:TouchCorners(d) + end + end + + self:TouchWallsAndWindows(dir) + self:TouchCorners(dir) + ResumePassEdits("Room:CreateWalls") + Msg("RoomCreatedWall", self, dir, mat) +end + +local postComputeBatch = false +function Room:SetInnerMaterialToRoofObjs() + local objs = self.roof_objs + if not objs or #objs <= 0 then return end + + local passedSWO = {} + local col = self.inner_colors + for i = 1, #objs do + local o = objs[i] + if IsValid(o) and IsKindOf(o, "WallSlab") then + if o.indoor_material_1 ~= self.inner_wall_mat then + o.indoor_material_1 = self.inner_wall_mat + o:UpdateVariantEntities() + end + o:Setinterior_attach_colors(col) + + local swo = o.wall_obj + if swo and not passedSWO[swo] then + passedSWO[swo] = true + end + end + end + + if next(passedSWO) then + --in some cases slabs need to recalibrate in computeslabvisibility, we need to pass after that + postComputeBatch = postComputeBatch or {} + postComputeBatch[#postComputeBatch + 1] = passedSWO + end + + ComputeSlabVisibilityInBox(self.roof_box) +end + +function Room:SetInnerMaterialToSlabs(dir) + local objs = self.spawned_walls and self.spawned_walls[dir] + local gz = self:CalcZ() + local sizeX, sizeY = self.size:x(), self.size:y() + local endI = (dir == "North" or dir == "South") and sizeX or sizeY + local passedSWO = {} + local wallBBox = box() + local col = self.inner_colors + if objs then + for i = 0, endI - 1 do + for j = 0, self.size:z() - 1 do + local idx = i * self.size:z() + j + 1 + local o = objs[idx] + if IsValid(o) then + wallBBox = Extend(wallBBox, o:GetPos()) + if o.indoor_material_1 ~= self.inner_wall_mat then + o.indoor_material_1 = self.inner_wall_mat + o:UpdateVariantEntities() + end + o:Setinterior_attach_colors(col) + + local swo = o.wall_obj + if swo and not passedSWO[swo] then + passedSWO[swo] = true + end + end + end + end + end + + objs = self.spawned_corners[dir] + for i = 1, #(objs or "") do + if IsValid(objs[i]) then --can be gone + objs[i]:SetColorFromRoom() + end + end + + if next(passedSWO) then + --in some cases slabs need to recalibrate in computeslabvisibility, we need to pass after that + postComputeBatch = postComputeBatch or {} + postComputeBatch[#postComputeBatch + 1] = passedSWO + end + + if self:GetGameFlags(const.gofPermanent) ~= 0 then + ComputeSlabVisibilityInBox(wallBBox) + end + self:CreateAllCorners() +end + +function OnMsg.SlabVisibilityComputeDone() + if not postComputeBatch then return end + + local allPassed = {} + for i, batch in ipairs(postComputeBatch) do + for swo, _ in pairs(batch) do + if not allPassed[swo] then + allPassed[swo] = true + swo:UpdateManagedSlabs() + swo:UpdateManagedObj() + swo:RefreshColors() + end + end + end + postComputeBatch = false +end + +function TouchWallsAndWindowsHelper(objs) + if not objs then return end + table.validate(objs) + for i = #(objs or empty_table), 1, -1 do + local o = objs[i] + o:AlignObj() + if o.room == false then + DoneObject(o) + else + o:UpdateSimMaterialId() + end + end +end + +function Room:TouchWallsAndWindows(side) + TouchWallsAndWindowsHelper(self.spawned_doors and self.spawned_doors[side]) + TouchWallsAndWindowsHelper(self.spawned_windows and self.spawned_windows[side]) +end + +function Room:TouchCorners(side) + if side == "North" then + self:RecreateNECornerBeam() + self:RecreateNWCornerBeam() + elseif side == "South" then + self:RecreateSECornerBeam() + self:RecreateSWCornerBeam() + elseif side == "East" then + self:RecreateSECornerBeam() + self:RecreateNECornerBeam() + elseif side == "West" then + self:RecreateSWCornerBeam() + self:RecreateNWCornerBeam() + end +end + +function GedOpViewRoom(socket, obj) + if IsValid(obj) then + Room.CenterCameraOnMe(nil, obj) + else + print("No room selected.") + end +end + +function GedOpNewVolume(socket, obj) + print("Use f3 -> map -> new room or ctrl+shift+n instead. This method is no longer supported.") +end + +function Room:SelectWall(side) + self:ClearBoldedMarker() + self.selected_wall = side + local m = self.text_markers[side] + m:SetTextStyle("EditorTextBold") + m:SetColor(RGB(0, 255, 0)) + ObjModified(self) +end + +function Room:ViewNorthWallFromOutside() + self:SelectWall("North") + self:ViewWall("North") + ObjModified(self) +end + +function Room:ViewSouthWallFromOutside() + self:SelectWall("South") + self:ViewWall("South") + ObjModified(self) +end + +function Room:ViewWestWallFromOutside() + self:SelectWall("West") + self:ViewWall("West") + ObjModified(self) +end + +function Room:ViewEastWallFromOutside() + self:SelectWall("East") + self:ViewWall("East") + ObjModified(self) +end + +function Room:ClearBoldedMarker() + if self.selected_wall then + local m = self.text_markers[self.selected_wall] + m:SetTextStyle("EditorText") + m:SetColor(RGB(255, 0, 0)) + end +end + +function Room:ClearSelectedWall() + self:ClearBoldedMarker() + self.selected_wall = false + ObjModified(self) +end + +function GetSelectedRoom() + --find a selected room.. + return SelectedRooms and SelectedRooms[1] or SelectedVolume +end + +function SelectedRoomClearSelectedWall() + local r = GetSelectedRoom() + if IsValid(r) then + r:ClearSelectedWall() + print("Cleared selected wall") + else + print("No selected room found!") + end +end + +function SelectedRoomSelectWall(side) + local r = GetSelectedRoom() + if IsValid(r) then + r:SelectWall(side) + print(string.format("Selected wall %s of room %s", side, r.name)) + else + print("No selected room found!") + end +end + +function SelectedRoomResetWallMaterials() + local r = GetSelectedRoom() + if IsValid(r) then + if r.selected_wall then + local side = r.selected_wall + local matMember = string.format("%s_wall_mat", string.lower(side)) + local curMat = r[matMember] + if curMat ~= defaultWallMat then + r[matMember] = defaultWallMat + local matPostSetter = string.format("OnSet%s", matMember) + r[matPostSetter](r, defaultWallMat, curMat) + end + else + r:ResetWallMaterials() + print(string.format("Reset wall materials.")) + end + else + print("No selected room found!") + end +end + +function Room:CycleWallMaterial(delta, side) + local mats + local matMember = "wall_mat" + if side then + mats = SlabMaterialComboItemsWithDefault()() + matMember = string.format("%s_wall_mat", string.lower(side)) + else + mats = SlabMaterialComboItemsWithNone()() + end + local matPostSetter = string.format("OnSet%s", matMember) + local curMat = self[matMember] + local idx = table.find(mats, curMat) or 1 + local newIdx = idx + delta + if newIdx > #mats then + newIdx = 1 + elseif newIdx <= 0 then + newIdx = #mats + end + local newMat = mats[newIdx] + self[matMember] = newMat + self[matPostSetter](self, newMat, curMat) + print(string.format("Changed wall material of room %s side %s new material %s", self.name, side or "all", newMat)) +end + +function Room:CycleEntity(delta) + local sw = self.selected_wall + if not sw then + self:CycleWallMaterial(delta) + return + end + self:CycleWallMaterial(delta, sw) +end + +function Room:UIDeleteDoors() + self:DeleteWallObjs(self.spawned_doors, self.selected_wall) +end + +function Room:UIDeleteWindows() + self:DeleteWallObjs(self.spawned_windows, self.selected_wall) +end + +local decalIdPrefix = "decal_lst_" +function Room:UIDeleteDecal(gedRoot, prop_id) + local sh = string.gsub(prop_id, decalIdPrefix, "") + local h = tonumber(sh) + + local t = self.spawned_decals[self.selected_wall] + local idx = table.find(t, "handle", h) + if idx then + local d = t[idx] + table.remove(t, idx) + rawset(d, "safe_deletion", true) + DoneObject(d) + ObjModified(self) + end +end + +function Room:UISelectDecal(gedRoot, prop_id) + local sh = string.gsub(prop_id, decalIdPrefix, "") + local h = tonumber(sh) + + local t = self.spawned_decals[self.selected_wall] + local idx = table.find(t, "handle", h) + if idx then + local d = t[idx] + if d then + editor.ClearSel() + editor.AddToSel({d}) + end + end +end + +function Room:GetWallPos(dir, zOffset) + local wallSize, wallPos + local wsx = self.size:x() * voxelSizeX + local wsy = self.size:y() * voxelSizeY + local pos = self:GetPos() + if zOffset then + pos = pos:SetZ(pos:z() + zOffset) + end + + if dir == "North" then + wallPos = point(pos:x(), pos:y() - wsy / 2, pos:z()) + wallSize = wsx + elseif dir == "South" then + wallPos = point(pos:x(), pos:y() + wsy / 2, pos:z()) + wallSize = wsx + elseif dir == "West" then + wallPos = point(pos:x() - wsx / 2, pos:y(), pos:z()) + wallSize = wsy + elseif dir == "East" then + wallPos = point(pos:x() + wsx / 2, pos:y(), pos:z()) + wallSize = wsy + end + + return wallPos, wallSize, pos +end + +function Room:ViewWall(dir, inside) + dir = dir or "North" + local wallPos, wallSize, pos = self:GetWallPos(dir, self.size:z() * voxelSizeZ / 2) + + --fit wall to screen + local fovX = camera.GetFovX() + local a = (180 * 60 - fovX) / 2 + local wallWidth = wallSize + local s = MulDivRound(wallWidth, sin(a), sin(fovX)) + local x = wallWidth / 2 + local dist = sqrt(s * s - x * x) + + local fovY = camera.GetFovY() + a = (180 * 60 - fovY) / 2 + local wallHeight = self.size:z() * voxelSizeZ + s = MulDivRound(wallHeight, sin(a), sin(fovY)) + x = wallHeight / 2 + dist = Max(sqrt(s * s - x * x), dist) + + local offset + if inside then + offset = pos - wallPos + else + offset = wallPos - pos + end + + dist = dist + 3 * guim --some x margin so wall edge is not stuck to the screen edge + offset = SetLen(offset, dist) + offset = offset:SetZ(offset:z() + self.size:z() * voxelSizeZ * 3) --move eye up a little bit + + local cPos, cLookAt, cType = GetCamera() + local cam = _G[string.format("camera%s", cType)] + cam.SetCamera(wallPos + offset, wallPos, 1000, "Cubic out") + + if rawget(terminal, "BringToTop") then + return terminal.BringToTop() + end +end + +function Room.CenterCameraOnMe(_, self) + local cPos, cLookAt, cType = GetCamera() + local cOffs = cPos - cLookAt + local mPos = self:GetPos() + local cam = _G[string.format("camera%s", cType)] + if cType == "Max" then + local len = 20*guim * ((Max(self.size:x(), self.size:y()) / 10) + 1) + cOffs = SetLen(cOffs, len) + end + cam.SetCamera(mPos + cOffs, mPos, 1000, "Cubic out") + + if rawget(terminal, "BringToTop") then + return terminal.BringToTop() + end +end + +local defaultDecalProp = { category = "Materials", id = decalIdPrefix, name = "Decal ", editor = "text", default = "", read_only = true, + buttons = { + {name = "Delete", func = "UIDeleteDecal"}, + {name = "Select", func = "UISelectDecal"}, + },} + +local function AddDecalPropsFromContainerHelper(self, props, container, idx, defaultProp) + for i = 1, #container do + local np = table.copy(defaultProp) + local obj = container[i] + np.id = string.format("%s%s", np.id, obj.handle) + np.name = string.format("%s%s", np.name, obj:GetEntity()) + + table.insert(props, idx + i, np) + end +end + +function Room:GetProperties() + local decals = self.spawned_decals and self.spawned_decals[self.selected_wall] + + if #(decals or empty_table) > 0 then + local p = table.copy(self.properties) + + if decals then + local idx = table.find(p, "id", "place_decal") + AddDecalPropsFromContainerHelper(self, p, decals, idx, defaultDecalProp) + end + + return p + else + return self.properties + end +end + +function Room:GenerateName() + return string.format("Room %d%s", self.handle, self:IsRoofOnly() and " - Roof only" or "") +end + +function Room:Init() + self.name = self.name or self:GenerateName() + + if self.auto_add_in_editor then + self:AddInEditor() + end +end + +function ComputeVisibilityOfNearbyShelters() + --stub +end + +function Room:ClearRoomAdjacencyData() + self:ClearAdjacencyData() +end + +function Room:RoomDestructor() + Msg("RoomDone", self) + local wasPermanent = self:GetGameFlags(const.gofPermanent) ~= 0 + self:ClearGameFlags(const.gofPermanent) --this is to dodge this assert -> assert(false, "Passability rebuild provoked from destructor! Obj class: " .. (obj.class or "N/A")) + self:DeleteAllSpawnedObjs() + self:ClearRoomAdjacencyData() + + if GedRoomEditor then + table.remove_entry(GedRoomEditorObjList, self) + ObjModified(GedRoomEditorObjList) + if SelectedVolume == self then + SetSelectedVolumeAndFireEvents(false) + --todo: this does not work to clear the props pane + GedRoomEditor:UnbindObjs("SelectedObject") + ObjModified(GedRoomEditorObjList) + end + end + + if wasPermanent then + ComputeSlabVisibilityInBox(self.box) --since we are no longer gofPermanent, call directly + ComputeVisibilityOfNearbyShelters(self.box) + end + self["RoomDestructor"] = empty_func +end + +function Room:ComputeRoomVisibility() + if self:GetGameFlags(const.gofPermanent) ~= 0 then + ComputeSlabVisibilityInBox(self.box) + end +end + +function Room:OnEditorDelete() + self:VolumeDestructor() + self:RoomDestructor() +end + +function Room:Done() + self:RoomDestructor() + self.spawned_walls = nil + self.spawned_corners = nil + self.spawned_floors = nil + self.spawned_doors = nil + self.spawned_windows = nil + self.spawned_decals = nil +end + +function Room:AddInEditor() + if GedRoomEditor then + table.insert_unique(GedRoomEditorObjList, self) + ObjModified(GedRoomEditorObjList) + end +end + +function WallObjToNestedListEntry(d, cls) + cls = cls or "DoorNestedListEntry" + local entry = PlaceObject(cls) + entry.linked_obj = d + entry.width = d.width + entry.material = d.material + entry.subvariant = d.subvariant or 1 + return entry +end + +function Room:TestCorners() + for k, v in NSEW_pairs(self.spawned_corners or empty_table) do + for i = 1, #v do + if not IsValid(v[i]) then + print(k, v[i]) + end + end + end +end + +function Room:AssignPropValuesToMySlabs() + -- assign prop values that are convenient to have per slab but are not saved per slab + local reposition = RepositionWallSlabsOnLoad + for i = 1, #room_NSWE_lists do + for side, objs in NSEW_pairs(self[room_NSWE_lists[i]] or empty_table) do + local isDecals = "spawned_decals" == room_NSWE_lists[i] + local isCorners = "spawned_corners" == room_NSWE_lists[i] + local isWalls = "spawned_walls" == room_NSWE_lists[i] + local isFloors = "spawned_floors" == room_NSWE_lists[i] + local isWindows = "spawned_windows" == room_NSWE_lists[i] + local isDoors = "spawned_doors" == room_NSWE_lists[i] + for j = 1, #objs do + local obj = objs[j] + if obj then + obj.room = self + obj.side = side + obj.floor = self.floor + + if not isDecals then -- could be a decal from spawned_decals + obj.invulnerable = obj.forceInvulnerableBecauseOfGameRules + end + + if isCorners and j == #objs then + obj.isPlug = true -- last one is always a plug + end + if isWalls or isCorners then + obj:DelayedUpdateEntity() -- call this after room is set for the correct seed + end + if isWalls then + obj:DelayedUpdateVariantEntities() -- default colors are not available 'till .room gets assigned, this will reapply them to attaches + end + + if reposition and isWalls then + obj:SetPos(self:GetWallSlabPos(side, j)) + end + end + end + end + end + + local floorsAreCO = g_Classes.CombatObject and IsKindOf(FloorSlab, "CombatObject") + for i = 1, #room_regular_lists do + local isFloors = room_regular_lists[i] == "spawned_floors" + local t = self[room_regular_lists[i]] or empty_table + local side = room_regular_list_sides[i] + for j = #t, 1, -1 do + local o = t[j] + if o then + o.room = self + o.side = side + o.floor = self.floor + o.invulnerable = o.forceInvulnerableBecauseOfGameRules + + if isFloors then + o:DelayedUpdateEntity() -- these now have random ents as well, so rerandomize after seed is setup + end + end + end + end +end + +function Room:CreateAllCorners() + self:RecreateNECornerBeam() + self:RecreateNWCornerBeam() + self:RecreateSWCornerBeam() + self:RecreateSECornerBeam() +end + +-- used manually when resaving maps from old schema to new shcema +function RecreateAllCornersAndColors() + MapForEach("map", "RoomCorner", DoneObject) + MapForEach("map", "Room", function(room) + room:RecreateWalls() + room:RecreateFloor() + room:RecreateRoof() + room:OnSetouter_colors(room.outer_colors) + room:OnSetinner_colors(room.inner_colors) + end) +end + +function RefreshAllRoomColors() + MapForEach("map", "Room", function(room) + room:OnSetouter_colors(room.outer_colors) + room:OnSetinner_colors(room.inner_colors) + end) +end + +function Room:SaveFixups() + local hasCeiling = type(self.roof_objs) == "table" and IsKindOf(self.roof_objs[#self.roof_objs], "CeilingSlab") or false + if not self.build_ceiling and hasCeiling then + -- tweaked this default value, so now there are blds with disabled ceiling who have ceilings + -- because we avoid touching roofs during load they remain + while IsKindOf(self.roof_objs[#self.roof_objs], "CeilingSlab") do + local o = self.roof_objs[#self.roof_objs] + self.roof_objs[#self.roof_objs] = nil + DoneObject(o) + end + end +end + +function Room:Getlocked_slabs_count() + local total, locked = 0, 0 + + local function iterateAndCount(t) + for i = 1, #(t or "") do + local slab = t[i] + if IsValid(slab) and slab.isVisible then + total = total + 1 + locked = locked + (slab.subvariant ~= -1 and 1 or 0) + end + end + end + + local function iterateAndCountNSEW(objs) + for side, t in NSEW_pairs(objs or empty_table) do + iterateAndCount(t) + end + end + + iterateAndCountNSEW(self.spawned_walls) + local ws = string.format("%d/%d walls", locked, total) + locked, total = 0, 0 + iterateAndCountNSEW(self.spawned_corners) + local cs = string.format("%d/%d corners", locked, total) + locked, total = 0, 0 + iterateAndCount(self.spawned_floors) + local fs = string.format("%d/%d floors", locked, total) + locked, total = 0, 0 + iterateAndCount(self.roof_objs) + local rs = string.format("%d/%d roof objs", locked, total) + + return string.format("%s; %s; %s; %s;", ws, cs, fs, rs) +end + +function Room:LockAllSlabsToCurrentSubvariants() + -- goes through all slabs and switches -1 subvariant val to their current subvariant. + -- this will lock those variants in case of random generator changes + + local function iterateAndSet(t) + for i = 1, #(t or "") do + local slab = t[i] + if IsValid(slab) and slab.isVisible then + slab:LockSubvariantToCurrentEntSubvariant() + end + end + end + + local function iterateAndSetNSEW(objs) + for side, t in NSEW_pairs(objs or empty_table) do + iterateAndSet(t) + end + end + + iterateAndSetNSEW(self.spawned_walls) + iterateAndSetNSEW(self.spawned_corners) + iterateAndSet(self.spawned_floors) + iterateAndSet(self.roof_objs) + ObjModified(self) +end + +local function extractCpyId(str) + local r = string.gmatch(str, "copy%d+")() + return r and tonumber(string.gmatch(r, "%d+")()) or 0 +end + +function Room:GenerateNameWithCpyTag() + local n = self.name + local pid = extractCpyId(n) + local topId = pid + EnumVolumes(function(v, n, find, sub) + local hn = v.name + local mn = n + if #hn < #mn then + mn = string.sub(mn, 1, #hn) + elseif #hn > #mn then + hn = string.sub(hn, 1, #mn) + end + + if hn == mn then + local hpid = extractCpyId(v.name) + if hpid > topId then + topId = hpid + end + end + end, string.gsub(n, " copy%d+", ""), string.find, string.sub) + + local tag = string.format("copy%d", tonumber(topId) + 1) + if pid == 0 then + return string.format("%s %s", self.name, tag) + else + return string.gsub(self.name, "copy%d+", tag) + end +end + +function Room:PostLoad(reason) + if reason == "paste" then + self:Setname(self:GenerateNameWithCpyTag()) + end + + SuspendPassEdits("Room:PostLoad") + + self:SaveFixups() + self:InternalAlignObj() + self:SetWarped(self:GetWarped(), true) + self:AssignPropValuesToMySlabs() -- sets slab properties that are not saved - .room, .side, etc. + + self:RecalcRoof() + self:ComputeRoomVisibility() + + ResumePassEdits("Room:PostLoad") +end + +function Room:OnWallObjDeletedOutsideOfGedRoomEditor(obj) + local dir = slabAngleToDir[obj:GetAngle()] + local t = self[obj:IsDoor() and string.format("placed_doors_nl_%s", string.lower(dir)) or string.format("placed_windows_nl_%s", string.lower(dir))] + local container = obj:IsDoor() and self.spawned_doors or self.spawned_windows + if container then + for i = 1, #(t or empty_table) do + if t[i].linked_obj == obj then + DoneObject(t[i]) + table.remove(t, i) + table.remove_entry(container[dir], obj) + return + end + end + elseif Platform.developer then + local cs = obj:IsDoor() and "spawned_doors" or "spawned_windows" + local dirFound + local r = MapGetFirst("map", "Room", function(o, cs, obj, et) + for side, t in sorted_pairs(o[cs] or et) do + if table.find(t or et, obj) then + dirFound = side + return true + end + end + end, cs, obj, empty_table) + + if r then + print(string.format("Wall obj was found in room %s, side %s container", r.name, dirFound)) + else + print("Wall obj was not found in any room container") + end + + assert(false, string.format("Room %s had no contaier initialized for wall obj with entity %s, deduced side %s, member side %s", self.name, obj.entity, dir, obj.side)) + end +end + +local dirs = { "North", "East", "South", "West" } +function rotate_direction(direction, angle) + local idx = table.find(dirs, direction) + if not idx then return direction end + idx = idx + angle / (90 * 60) + if idx > 4 then idx = idx - 4 end + return dirs[idx] +end + +function Room:EditorRotate(center, axis, angle, last_angle) + angle = angle - last_angle + if axis:z() < 0 then angle = -angle end + angle = (angle + 360 * 60 + 45 * 60 ) / (90 * 60) * (90 * 60) + while angle >= 360 * 60 do angle = angle - 360 * 60 end + if axis:z() == 0 or angle == 0 then return end + + -- rotate room properties + local a = center + Rotate(self.box:min() - center, angle) + local b = center + Rotate(self.box:max() - center, angle) + self.box = boxdiag(a, b) + self.position = self.box:min() + local x, y, z = self.box:size():xyz() + self.size = point(x / voxelSizeX, y / voxelSizeY, z / voxelSizeZ) + + -- rotate roof properties + if self:GetRoofType() == "Gable" then + if angle == 90 * 60 or angle == 270 * 60 then + self.roof_direction = self.roof_direction == GableRoofDirections[1] and GableRoofDirections[2] or GableRoofDirections[1] + end + elseif self:GetRoofType() == "Shed" then + self.roof_direction = rotate_direction(self.roof_direction, angle) + end + + -- rotate slabs + self:ForEachSpawnedObj(function(obj, center, angle) + local new_angle = 0 + if not IsKindOf(obj, "FloorAlignedObj") then + new_angle = obj:GetAngle() + angle + end + obj:SetPosAngle(center + Rotate(obj:GetPos() - center, angle), new_angle) + obj.side = rotate_direction(obj.side, angle) + if obj:IsKindOf("SlabWallObject") then obj:UpdateManagedObj() end + end, center, angle) + + -- assign slabs to the proper lists after rotation + local d = table.copy(dirs) + while angle >= 90 * 60 do + d[1], d[2], d[3], d[4] = d[2], d[3], d[4], d[1] + angle = angle - 90 * 60 + end + for i = 1, #room_NSWE_lists do + local lists = self[room_NSWE_lists[i]] + if lists then + lists[d[1]], lists[d[2]], lists[d[3]], lists[d[4]] = lists.North, lists.East, lists.South, lists.West + end + end + self:InternalAlignObj() + self:RecreateRoof() +end + +function OnMsg.GedClosing(ged_id) + if GedRoomEditor and GedRoomEditor.ged_id == ged_id then + GedRoomEditor = false + GedRoomEditorObjList = false + end +end + +function OnMsg.GedOnEditorSelect(obj, selected, editor) + if editor == GedRoomEditor then + SetSelectedVolumeAndFireEvents(selected and obj or false) + end +end + +function OpenGedRoomEditor() + CreateRealTimeThread(function() + if not IsValid(GedRoomEditor) then + GedRoomEditorObjList = MapGet("map", "Room") or {} + table.sortby_field(GedRoomEditorObjList, "name") + table.sortby_field(GedRoomEditorObjList, "structure") + GedRoomEditor = OpenGedApp("GedRoomEditor", GedRoomEditorObjList) or false + end + end) +end + +function OnMsg.ChangeMap() + if GedRoomEditor then + GedRoomEditor:Send("rfnClose") + GedRoomEditor = false + end +end +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- +DefineClass.SlabPreset = { + __parents = { "Preset", }, + properties = { + { id = "Group", no_edit = false, }, + }, + HasSortKey = false, + PresetClass = "SlabPreset", + NoInstances = true, + EditorMenubarName = "Slab Presets", + EditorMenubar = "Editors.Art", +} + +DefineClass.SlabMaterialSubvariant = { + __parents = {"PropertyObject"}, + properties = { + { id = "suffix", name = "Suffix", editor = "text", default = "01" }, + { id = "chance", name = "Chance", editor = "number", default = 100 }, + }, +} + +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- +-------------------------------------------------------------------------- +function TouchAllRoomCorners() + MapForEach("map", "Room", function(o) + o:TouchCorners("North") + o:TouchCorners("South") + o:TouchCorners("West") + o:TouchCorners("East") + end) +end + +DefineClass.HideOnFloorChange = { + __parents = { "Object" }, + properties = { + { id = "floor", name = "Floor", editor = "number", min = -10, max = 100, default = 1, dont_save = function (obj) return obj.room end }, + }, + room = false, + invisible_reasons = false, +} + +function HideOnFloorChange:Getfloor() + local room = self.room + return room and room.floor or self.floor +end + +HideSlab = false -- used only if defined + +function HideFloorsAbove(floor, fnHide) + SuspendPassEdits("HideFloorsAbove") + HideFloorsAboveC(floor, fnHide or HideSlab or nil) + Msg("FloorsHiddenAbove", floor, fnHide) + ResumePassEdits("HideFloorsAbove") +end + +function CountRoomSlabs() + local t = 0 + MapForEach("map", "Room", function(o) + t = t + (o.size:x() + o.size:y()) * 2 * o.size:z() + end) + + return t +end + +function CountMirroredSlabs() + local t, tm = 0, 0 + + MapForEach("map", "WallSlab", function(o) + if o:CanMirror() and o:GetEnumFlags(const.efVisible) ~= 0 then + if o:GetGameFlags(const.gofMirrored) ~= 0 then + tm = tm + 1 + else + t = t + 1 + end + end + end) + + return t, tm +end + +function BuildBuildingsData() +end + +function DbgWindowDoorOwnership() + MapForEach("map", "SlabWallObject", function(o) + if o.room then + DbgAddVector(o:GetPos(), o.room:GetPos() - o:GetPos()) + else + DbgAddVector(o:GetPos()) + end + end) +end diff --git a/CommonLua/Libs/Volumes/XTemplates/GedRoomEditor.lua b/CommonLua/Libs/Volumes/XTemplates/GedRoomEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..1e43f4c1739b69b55ec6e6a876540d0bff9a3803 --- /dev/null +++ b/CommonLua/Libs/Volumes/XTemplates/GedRoomEditor.lua @@ -0,0 +1,62 @@ +-- ========== GENERATED BY XTemplate Editor (Alt-F3) DO NOT EDIT MANUALLY! ========== + +PlaceObj('XTemplate', { + group = "GedApps", + id = "GedRoomEditor", + save_in = "Libs/Volumes", + PlaceObj('XTemplateWindow', { + '__class', "GedApp", + 'Title', "Room Editor", + 'AppId', "GedRoomEditor", + 'CommonActionsInMenubar', false, + 'CommonActionsInToolbar', false, + }, { + PlaceObj('XTemplateAction', { + 'ActionId', "New", + 'ActionName', T(285113360304, --[[XTemplate GedRoomEditor ActionName]] "New"), + 'ActionIcon', "CommonAssets/UI/Ged/new.tga", + 'ActionToolbar', "main", + 'OnAction', function (self, host, source, ...) + host:Op("GedOpNewVolume", "SelectedObject") + end, + 'ActionContexts', { + "RoomPanelGeneral", + }, + }), + PlaceObj('XTemplateAction', { + 'ActionId', "ViewObject", + 'ActionSortKey', "2", + 'ActionName', T(683738805190, --[[XTemplate GedRoomEditor ActionName]] "View Object"), + 'ActionIcon', "CommonAssets/UI/Ged/view.tga", + 'ActionToolbar', "main", + 'ActionToolbarSplit', true, + 'OnAction', function (self, host, source, ...) + host:Op("GedOpViewRoom", "SelectedObject") + end, + 'ActionContexts', { + "RoomPanelOther", + }, + }), + PlaceObj('XTemplateWindow', { + '__context', function (parent, context) return "root" end, + '__class', "GedListPanel", + 'Id', "idRooms", + 'Margins', box(0, 0, 2, 0), + 'Dock', "left", + 'Title', "Rooms", + 'ActionsClass', "Object", + 'Delete', "GedOpListDeleteItem", + 'Format', "", + 'SelectionBind', "SelectedObject", + }), + PlaceObj('XTemplateWindow', { + '__context', function (parent, context) return "SelectedObject" end, + '__class', "GedPropPanel", + 'Title', "Properties", + 'ActionsClass', "PropertyObject", + 'Copy', "GedOpPropertyCopy", + 'Paste', "GedOpPropertyPaste", + }), + }), +}) + diff --git a/CommonLua/Libs/Volumes/XTemplates/XEditorRoomTools.lua b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomTools.lua new file mode 100644 index 0000000000000000000000000000000000000000..3868a2cec82ed3d96338bc9c33fdebe5465cb9fb --- /dev/null +++ b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomTools.lua @@ -0,0 +1,149 @@ +-- ========== GENERATED BY XTemplate Editor (Alt-F3) DO NOT EDIT MANUALLY! ========== + +PlaceObj('XTemplate', { + __is_kind_of = "XDarkModeAwareDialog", + group = "Editor", + id = "XEditorRoomTools", + save_in = "Libs/Volumes", + PlaceObj('XTemplateWindow', { + '__class', "XDarkModeAwareDialog", + 'Dock', "right", + 'FoldWhenHidden', true, + 'Background', RGBA(64, 64, 66, 255), + 'HandleMouse', true, + 'FocusOnOpen', "", + }, { + PlaceObj('XTemplateWindow', { + '__class', "XScrollArea", + 'Id', "idScrollArea", + 'IdNode', false, + 'LayoutMethod', "VList", + 'VScroll', "idScroll", + }, { + PlaceObj('XTemplateWindow', { + '__class', "XToolBar", + 'MaxWidth', 213, + 'LayoutMethod', "VList", + 'Background', RGBA(64, 64, 66, 255), + 'Toolbar', "EditorRoomWallSelection", + 'Show', "text", + 'ButtonTemplate', "XEditorRoomToolsButton", + 'ToggleButtonTemplate', "XEditorRoomToolsCheckbox", + 'ToolbarSectionTemplate', "XEditorToolbarSection", + }, { + PlaceObj('XTemplateFunc', { + 'name', "GetActionsHost(self)", + 'func', function (self) + return XShortcutsTarget + end, + }), + }), + PlaceObj('XTemplateWindow', { + '__class', "XToolBar", + 'Id', "idToolbar", + 'LayoutMethod', "VList", + 'Background', RGBA(64, 64, 66, 255), + 'Toolbar', "EditorRoomTools", + 'Show', "text", + 'ButtonTemplate', "XEditorRoomToolsButton", + 'ToggleButtonTemplate', "XEditorRoomToolsCheckbox", + 'ToolbarSectionTemplate', "XEditorRoomToolsSection", + }, { + PlaceObj('XTemplateFunc', { + 'name', "GetActionsHost(self)", + 'func', function (self) + return XShortcutsTarget + end, + }), + }), + PlaceObj('XTemplateWindow', { + '__class', "XSleekScroll", + 'Id', "idScroll", + 'Dock', "right", + 'MinWidth', 5, + 'Target', "idScrollArea", + 'AutoHide', true, + }), + }), + PlaceObj('XTemplateWindow', { + '__class', "XText", + 'Dock', "bottom", + 'Text', "Hold Ctrl for preview\nRight-click to edit programs", + 'TextHAlign', "center", + }), + PlaceObj('XTemplateFunc', { + 'comment', "highlight related actions, right-click disabled actions", + 'name', "Open", + 'func', function (self, ...) + self:CreateThread("Rollover", function() + local old_related = {} + local old_hovered_with_ctrl, just_executed + local selection_after_execute + while true do + Sleep(100) + + local buttons = GetChildrenOfKind(self, "XTextButton") + for _, button in ipairs(buttons) do + button.Press = function(self, alt, force, gamepad) + if alt then + self:OnAltPress(gamepad) -- activate alt click even if button is disabled + elseif not alt and self.enabled and old_hovered_with_ctrl == self.action then + just_executed = self.action + old_hovered_with_ctrl = nil + editor.ClearSel() + editor.AddToSel(selection_after_execute) + else + XButton.Press(self, alt, force, gamepad) + end + end + end + + for _, win in ipairs(old_related) do + win:SetTextStyle(GetDarkModeSetting() and "GedDefaultDarkMode" or "GedDefault") + end + + local new_hovered_with_ctrl + local win = self:GetMouseTarget(terminal.GetMousePos()) + if win and rawget(win, "action") and rawget(win.action, "GetRelatedActions") then + -- highlight related actions + local related = win.action:GetRelatedActions(XShortcutsTarget) or empty_table + related = table.map(related, function(action) return table.find_value(buttons, "action", action) end) + for _, win in ipairs(related) do win:SetTextStyle("GedHighlight") end + old_related = related + + -- from action for speculative execution for a "preview" while holding Ctrl + if terminal.IsKeyPressed(const.vkControl) and win.enabled and win.action ~= just_executed then + new_hovered_with_ctrl = win.action + end + end + + -- update "preview" + if new_hovered_with_ctrl ~= old_hovered_with_ctrl then + if old_hovered_with_ctrl then + local sel = editor.GetSel() + if selection_after_execute then + editor.ClearSel() + editor.AddToSel(selection_after_execute) + selection_after_execute = nil + end + XEditorUndo:UndoRedo("undo") + editor.ClearSel() + editor.AddToSel(sel) + end + if new_hovered_with_ctrl then + local sel = editor.GetSel() + GenExtras(new_hovered_with_ctrl.ActionId) + selection_after_execute = editor.GetSel() + editor.ClearSel() + editor.AddToSel(sel) + end + old_hovered_with_ctrl = new_hovered_with_ctrl + end + end + end) + XDarkModeAwareDialog.Open(self, ...) + end, + }), + }), +}) + diff --git a/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsButton.lua b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsButton.lua new file mode 100644 index 0000000000000000000000000000000000000000..164fead8c4e22aaff0efe03842028e03b1263bbd --- /dev/null +++ b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsButton.lua @@ -0,0 +1,14 @@ +-- ========== GENERATED BY XTemplate Editor (Alt-F3) DO NOT EDIT MANUALLY! ========== + +PlaceObj('XTemplate', { + __is_kind_of = "XTextButton", + group = "Editor", + id = "XEditorRoomToolsButton", + save_in = "Libs/Volumes", + PlaceObj('XTemplateWindow', { + '__class', "XTextButton", + 'LayoutMethod', "Box", + 'UseXTextControl', true, + }), +}) + diff --git a/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsCheckbox.lua b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsCheckbox.lua new file mode 100644 index 0000000000000000000000000000000000000000..99e8aaf6e0b7bc0db1ae685d54427b8e3e65423c --- /dev/null +++ b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsCheckbox.lua @@ -0,0 +1,13 @@ +-- ========== GENERATED BY XTemplate Editor (Alt-F3) DO NOT EDIT MANUALLY! ========== + +PlaceObj('XTemplate', { + __is_kind_of = "XCheckButton", + group = "Editor", + id = "XEditorRoomToolsCheckbox", + save_in = "Libs/Volumes", + PlaceObj('XTemplateWindow', { + '__class', "XCheckButton", + 'Margins', box(4, 2, 4, 2), + }), +}) + diff --git a/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsSection.lua b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsSection.lua new file mode 100644 index 0000000000000000000000000000000000000000..2c27cb021c3da39d80e9111962254456f64f5654 --- /dev/null +++ b/CommonLua/Libs/Volumes/XTemplates/XEditorRoomToolsSection.lua @@ -0,0 +1,64 @@ +-- ========== GENERATED BY XTemplate Editor (Alt-F3) DO NOT EDIT MANUALLY! ========== + +PlaceObj('XTemplate', { + __is_kind_of = "XWindow", + group = "Editor", + id = "XEditorRoomToolsSection", + save_in = "Libs/Volumes", + PlaceObj('XTemplateWindow', { + 'IdNode', true, + 'Margins', box(0, 0, 0, 2), + 'LayoutMethod', "VList", + 'UniformColumnWidth', true, + 'FoldWhenHidden', true, + 'HandleMouse', true, + }, { + PlaceObj('XTemplateWindow', { + 'Id', "idSection", + 'BorderWidth', 1, + 'Padding', box(2, 1, 2, 1), + 'Background', RGBA(42, 41, 41, 232), + }, { + PlaceObj('XTemplateWindow', { + '__class', "XLabel", + 'Id', "idSectionName", + 'HAlign', "center", + 'VAlign', "center", + 'Text', "Name", + }), + }), + PlaceObj('XTemplateWindow', { + 'comment', "divider", + 'BorderWidth', 1, + 'MinHeight', 1, + 'MaxHeight', 1, + }), + PlaceObj('XTemplateWindow', { + 'Padding', box(0, 3, 0, 3), + 'VAlign', "top", + }, { + PlaceObj('XTemplateWindow', { + 'Id', "idActionContainer", + 'Dock', "box", + 'GridStretchY', false, + 'LayoutMethod', "VList", + 'LayoutHSpacing', 1, + 'UniformColumnWidth', true, + 'DrawOnTop', true, + }), + }), + PlaceObj('XTemplateFunc', { + 'name', "GetContainer(self)", + 'func', function (self) + return self.idActionContainer + end, + }), + PlaceObj('XTemplateFunc', { + 'name', "SetName(self, name)", + 'func', function (self, name) + self.idSectionName:SetText(name) + end, + }), + }), +}) + diff --git a/CommonLua/Libs/Volumes/XTemplates/__load.lua b/CommonLua/Libs/Volumes/XTemplates/__load.lua new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/CommonLua/Libs/Volumes/__GameConst.lua b/CommonLua/Libs/Volumes/__GameConst.lua new file mode 100644 index 0000000000000000000000000000000000000000..0d894983fd4860d697529f5ea626eebc0c408758 --- /dev/null +++ b/CommonLua/Libs/Volumes/__GameConst.lua @@ -0,0 +1,14 @@ +-- Slab +const.SlabGroundOffset = -50 -- // ground got dropped by this much to avoid Z fight & passability issues +const.SlabNoMaterial = "none" +const.SlabSize = point(const.SlabSizeX, const.SlabSizeY, const.SlabSizeZ) +const.SlabBox = box(-(const.SlabSizeX/2), -(const.SlabSizeY/2), 0, const.SlabSizeX/2, const.SlabSizeY/2, const.SlabSizeZ) -- inclusive, as used by the construction locks +const.SlabOffset = point(const.SlabOffsetX, const.SlabOffsetY, 0) +const.SlabMaterialProps = { "NoShadow", "HasLOS", "Deposition", "Warped" } + +-- Debris +const.DebrisFadeAwayTime = 30000 +const.DebrisDisappearTime = 5000 +const.DebrisExplodeDeviationAngle = 60 * 60 +const.DebrisExplodeSlabDelay = 200 +const.DebrisExplodeRadius = 2500 diff --git a/CommonLua/Libs/__Dev.lua b/CommonLua/Libs/__Dev.lua new file mode 100644 index 0000000000000000000000000000000000000000..78bcf680778581b379419e5d5f23a81001e8bc5e --- /dev/null +++ b/CommonLua/Libs/__Dev.lua @@ -0,0 +1 @@ +function NetStatusSetText() end diff --git a/CommonLua/LuaExportedDocs/Game/GameObject.lua b/CommonLua/LuaExportedDocs/Game/GameObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..0bfd48abee0368818bf5287963febd13037b58f9 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/GameObject.lua @@ -0,0 +1,1487 @@ +--- Entity and Game Object functions. + +--- Returns if the given param is a valid object, non yet destroyed by calling DoneObject. +-- @cstyle bool IsValid(object obj). +-- @param obj object. +-- @return type bool. + +function IsValid(obj) +end + +--- Returns index of a random spot of the speciifed type for the current or default state of the object (-1 if the object does not exists). +-- @cstyle int GetRandomSpot(string entity, int state, int typeID). +-- @param entity string. +-- @param state int. +-- @param typeID int. +-- @return int. + +function GetRandomSpot(entity, state, typeID) +end + +--- Returns the position of the spot in the given entity, relative to the entity center. +-- @cstyle point GetEntitySpotPos(string entity, int idx). +-- @param entity string entity name. +-- @param idx int the index of the spot. +-- @return point. + +function GetEntitySpotPos(entity, idx) +end + +--- Returns the angle(in minutes) of the spot in the given entity, relative to the entity center. +-- @cstyle int GetSpotAngle(string entity/object, int idx). +-- @param entity string entity name or object. +-- @param idx int the index of the spot. +-- @return int. + +function GetEntitySpotAngle(entity, idx) +end + +--- Returns the scale of the spot in the given entity +-- @cstyle int GetSpotScale(string entity, int idx) or object::GetSpotScale(, int idx). +-- @param entity string entity name or object. +-- @param idx int the index of the spot. +-- @return int. + +function GetEntitySpotScale(entity, idx) +end + +--- Returns the typeid of the given spot index. +-- @cstyle int GetSpotsType(string entity|object, int idx). +-- @param entity string. +-- @param idx int. +-- @return int returns the spot typeid. + +function GetSpotsType(pchEnt, idx) +end + +--- Returns true if the entity has this state. +-- @cstyle bool HasState(string entity, int state). +-- @param entity string; Entity name to be checked. +-- @param state int; State of the entity to be checked. +-- @return bool. + +function HasState(entity, state) +end + +-- Returns the step vector of the animation in state stateID of the given entity. +-- @cstyle point GetEntityStepVector(object self, [int stateID]). +-- @param entity string; name of the entity. +-- @param state int. +-- @return point. + +function GetEntityStepVector(entity, state) +end + +--- Places and inits object with some basic properties. + +function PlaceAndInit(class, pos, angle, scale, axis) +end + +--- Places and inits object with some basic properties, not using points to prevent unnecessary allocations. + +function PlaceAndInit2(class, posx, posy, posz, angle, scale, axisx, axisy, axisz, state, groupID) +end + +--- Groups specified objects together. NOTE: This calls Ungroup(list) first. +-- @cstyle void Group(objlist list). +-- @param list objlist. +-- @return void. + +function Group(list) +end + +--- For all objects in list ungroups the WHOLE group the object is member of. +-- @cstyle void Ungroup(objlist list). +-- @param list objlist. +-- @return void. + +function Ungroup(list) +end + +--- Returns the topmost parent (the object itself, if not attached). +-- @cstyle object GetTopmostParent(object obj, string classname = false). +-- @param obj object. +-- @return type object. +function GetTopmostParent(obj, classname) +end + +--- Creates a clone of an object copying all his properties to the clone +-- @cstyle object object:Clone(object self [, string classname]). +-- @param classname string; optional new class +-- @return object; Returns the clone created. +function object:Clone(classname) +end + +--- Changes the class of an object. The new class should be compatible with the previous one (i.e. having the same class flags) +-- @cstyle object object:ChangeClass(object self, string classname). +-- @param classname string; the new class +function object:ChangeClass(classname) +end + +--- Returns whether the object has an entity +-- @cstyle bool object::HasEntity(object self). +-- @return bool. +function object:HasEntity() +end + +--- Returns the destlock of the specified object if any. +-- @cstyle object object::GetDestlock(object self). +-- @return object. + +function object:GetDestlock() +end + +--- If the given unit is moving, return his destination, otherwise returns his current position. +-- @cstyle point object::GetDestination(object self). +-- @return point. + +function object:GetDestination() +end + +--- For moving objects returns the vector difference between the starting and ending position of self object, but with length set to the distance traveled per second. +-- @cstyle point object::GetVelocityVector(object self, int delta = 0, extrapolate = false). +-- @return point. +function object:GetVelocityVector(delta, extrapolate) +end + +--- For moving objects returns the length of the velocity vector. +-- @cstyle point object::GetVelocity(object self). +-- @return point. +function object:GetVelocity() +end + +--- Returns the step length of the animation in state stateID. +-- @cstyle int object::GetStepLength(object this, int stateID). +-- @param stateID int; state for which to return the step length; if omitted the current state is used. +-- @return int. + +function object:GetStepLength(stateID) +end + +--- Returns the step vector of the animation in state stateID with the specified direction. +-- @cstyle int object::GetStepVector(object this, [int stateID], [int direction], [int phase], [int duration], [int step_mod]). +-- @param stateID int; state for which to return the step length; if omitted uses the current state. +-- @param direction int; angle in minutes at which the step vector is oriented;if omitted uses the current direction . +-- @param phase int; animation phase +-- @param duration int; duration in animation phases +-- @param step_mod int; percentage step modifier +-- @return int. +function object:GetStepVector(stateID, direction, phase, duration, step_mod) +end + +--- Detaches the object from the map without destroying it (example - units entering buildings). +-- @cstyle void object::DetachFromMap(object this). +-- @return void. + +function object:DetachFromMap() +end + +--- Sets the entity of object this to newEntity. +-- @cstyle void object::ChangeEntity(object this, string newEntity). +-- @param newEntity string; name of the new entity to set. +-- @return void. + +function object:ChangeEntity(newEntity) +end + +--- Sets the color modifier for an object. +-- A per-component modifier to the final lit color of the object; RGB(100, 100, 100) means no modification. +-- @cstyle void object::SetColorModifier(object this, int colorModifier). +-- @param colorModifier int; the Color modifier. +-- @return void. + +function object:SetColorModifier(colorModifier) +end + +--- Returns the color modifier for an object. +-- A per-component modifier to the final lit color of the object; RGB(100, 100, 100) means no modification. +-- @cstyle int object::GetColorModifier(object this). +-- @return int. + +function object:GetColorModifier() +end + +--- Set new animation speed modifier, as promiles of original animation duration. +-- Animation duration and action moment times are affected by that modifier!. +-- @cstyle void object::SetAnimSpeedModifier(object this, int modifier). +-- @param modifier integer; speed modifier. +-- @return void. + +function object:SetAnimSpeedModifier(modifier) +end + +--- Return the current animation speed modifier as a promile. +-- Affects both animation duration and action moment!. +-- @cstyle int object::GetAnimSpeedModifier(object this). +-- @return int. + +function object:GetAnimSpeedModifier() +end + +--- Returns the last frame mark for the object. Frame marks advance for each frame drawn(main, shadows, reflection, etc.) +-- @cstyle int object::GetLastFrame(object this). +-- @return type. + +function object:GetFrameMark() +end + +--- Runs a pathfinder from the objects' current position to dst, follows the path and moves the object where it would be along the path at the specified time. +-- NOTE: It shouldn't be called on object executing a command. +-- @cstyle int object::GotoFastForward(object self, point dst, int time). +-- @param dst point; Destination of the object. +-- @param time int; Time for the object to go toward the destination. +-- @return int; Retruns the status of the operation, i.e. InProgress, Failed and so on. + +function object:GotoFastForward(dst, time) +end + +--- Attaches game object to another game object at spot of the parent game object. +-- @cstyle void object::Attach(object this, object child, int spot). +-- @param child object object to attach. +-- @param spot int spot at which to attach child object. +-- @return void. + +function object:Attach(child, spot) +end + +--- Detaches game object from its parent (its position remains where it is). +-- @cstyle void object::Detach(object this). +-- @return void. + +function object:Detach() +end + +--- Returns the number of current attached objects to our object. +-- @cstyle int object::GetNumAttaches(object this). +-- @return int. + +function object:GetNumAttaches() +end + +--- Get object(s) attached to a given object. +-- @cstyle object object::GetAttach(object this, int idx). +-- @cstyle object object::GetAttach(object this, string class, function filter). +-- @param idx int index of the attached object to get. +-- @param class string class of attached objects to get. Returns all matching attaches as tuple. +-- @param filter function to test if an attach is a match. +-- @return object. + +function object:GetAttach(idx) +end + +--- Get the spot index at which our object is attached to its parent. +-- @cstyle int object::GetAttachSpot(object this). +-- @return int. + +function object:GetAttachSpot() +end + +--- Get the parent object (if any). +-- @cstyle object object::GetParent(object this). +-- @return object or nil; nil means no parent. + +function object:GetParent() +end + +--- Returns the index of the first spot from the given type for the given or default state of the object (-1 if the object does not exist). +-- @cstyle int object::GetSpotBeginIndex(object this, int state, int typeID) or GetSpotBeginIndex(entity, int state, int typeID). +-- @param state int; This parameter is optional. +-- @param typeID int. +-- @return int. + +function object:GetSpotBeginIndex(state, typeID) +end + +--- Returns the index of the last spot from the given type for the given or default state of the object (-1 if the object does not exist). +-- @cstyle int object::GetSpotEndIndex(object this, int state, int typeID) or GetSpotEndIndex(entity, int state, int typeID). +-- @param state int; This parameter is optional. +-- @param typeID int. +-- @return int. + +function object:GetSpotEndIndex(state, typeID) +end + +--- Returns the index of the first and the last spot from the given type for the given or default state of the object (-1 if the object does not exist). +-- @cstyle int object::GetSpotRange(object this, int state, int typeID) or GetSpotRange(entity, int state, int typeID). +-- @param state int; This parameter is optional. +-- @param typeID int. +-- @return int. + +function object:GetSpotRange(state, typeID) +end + +--- Returns the index of the nearest to pt spot of the specified type for the current or default state of the object (-1 if the object does not exists). +-- @cstyle int object::GetNearestSpot(object this, int state, int typeID, point pt). +-- @param state int Optional parameter. +-- @param typeID int. +-- @param pt point. +-- @return int. + +function object:GetNearestSpot(state, typeID, pt) +end + +--- Returns index of a random spot of the speciifed type for the current or default state of the object (-1 if the object does not exists). +-- @cstyle int object::GetRandomSpot(object this, int state, int typeID). +-- @param state int; Optional paramater. +-- @param typeID int. +-- @return int. + +function object:GetRandomSpot(state, typeID) +end + +--- Returns the position of a random spot of the speciifed type for the current or default state of the object; if the spot doesn't exist returns nil. +-- @cstyle point object::GetRandomSpotPos(object this, int state, int typeID). +-- @param state int; Optional paramater. +-- @param typeID int. +-- @return point or nil. + +function object:GetRandomSpotPos(state, typeID) +end + +--- Returns whether the object has a specific spot type. +-- @cstyle bool object::HasSpot(object this, int state, int typeID) or HasSpot(string entity, int stateID, int typeID). +-- @param state int; Optional parameter when the first one is a game object. +-- @param typeID int. +-- @return bool. + +function object:HasSpot(state, typeID) +end + +--- Returns the position of the spot with the specified spotID. +-- @cstyle point object::GetSpotPos(object this, int spotID). +-- @param spotID int. +-- @return point or nil. + +function object:GetSpotPos(spotID) +end + +--- If the self object has a render object returns the VISUAL position of the spot with the specified spotID; otherwise it acts as GetSpotPos +-- @cstyle point object::GetSpotVisualPos(object self, int spotID). +-- @param spotID int. +-- @return point or nil. + +function object:GetSpotVisualPos(spotID) +end + +--- Returns the rotation (angle + axis) of the spot with the specified spotID (0 if the object or the spot does not exist). +-- @cstyle int, point object::GetSpotAxisAngle(object this, int spotID). +-- @param spotID int. +-- @return point, int. + +function object:GetSpotAxisAngle(spotID) +end + +--- Returns the spot annotation - a string with no predefined meaning, that can carry extra information related to certain spots. +-- @cstyle string object::GetSpotAnnotation(object this, int spotID). +-- @param spotID int. +-- @return string. + +function object:GetSpotAnnotation(spotID) +end + +--- Returns the height of the object (-1 if the object does not exists). +-- @cstyle int object::GetHeight(object this). +-- @return int. + +function object:GetHeight() +end + +--- Returns the radius of the bounding sphere of the current state of the object. +-- @cstyle int object::GetRadius(object this). +-- @return int. + +function object:GetRadius() +end + +--- Returns the bounding sphere of the current state of the object. +-- @cstyle point, int object::GetBSphere(object this). +-- @return point, int; center and radius of the object. + +function object:GetBSphere() +end + +--- Returns the bounding box of the current state of the object with mirroring applied, but without applying object's position, scale and orientation. +-- @cstyle box object::GetEntityBBox(object this). +-- @return box; The bounding box of the object's current state. + +function object:GetEntityBBox() +end + +--- Returns the name of the spot at index spotID. +-- @cstyle string object::GetSpotName(object this, int spotID) or string GetSpotName(string entity, int spotID).. +-- @param entity string or object instance. +-- @param spotID int. +-- @return string. + +function object:GetSpotName(spotID) +end + +--- Gets all GameObject flags of the object, ORed together ANDed the mask specified. +-- @cstyle int object::GetGameFlags(object this, int mask = ~0). +-- @param mask int; Default mask = ~0. +-- @return int. + +function object:GetGameFlags(object, mask) +end + +--- Clears to 0 the specified GameObject game flags of the object. +-- @cstyle void object::ClearGameFlags(object this, int flags). +-- @param flags int; Specifies the flags to be cleared. +-- @return void. + +function object:ClearGameFlags(flags) +end + +--- Clears to 0 the specified GameObject game flags of the object and its attaches. +-- @cstyle void object::ClearHierarchyGameFlags(object this, int flags). +-- @param flags int; Specifies the flags to be cleared. +-- @return void. + +function object:ClearHierarchyGameFlags(flags) +end + +--- Sets to 1 the specified GameObject game flags of the object. +-- @cstyle void object::SetGameFlags(object this, int flags). +-- @param flags int; specifies the flags to be set. +-- @return void. + +function object:SetGameFlags(flags) +end + +--- Sets to 1 the specified GameObject game flags of the object and its attaches. +-- @cstyle void object::SetHierarchyFlags(object this, int flags). +-- @param flags int; specifies the flags to be set. +-- @return void. + +function object:SetHierarchyGameFlags(flags) +end + +--- Gets all MapObject flags of the object, ORed together ANDed the mask specified. +-- @cstyle int object::GetEnumFlags(object this, int mask = ~0). +-- @param mask int; Default mask = ~0. +-- @return int. + +function object:GetEnumFlags(mask) +end + +--- Clears to 0 the specified MapObject flags of the object. +-- @cstyle void object::ClearEnumFlags(object this, int flags). +-- @param flags int; Specifies the flags to be cleared. +-- @return void. + +function object:ClearEnumFlags(flags) +end + +--- Clears to 0 the specified MapObject flags of the object and its attaches. +-- @cstyle void object::ClearHierarchyEnumFlags(object this, int flags). +-- @param flags int; Specifies the flags to be cleared. +-- @return void. + +function object:ClearHierarchyEnumFlags(flags) +end + +--- Sets to 1 the specified MapObject flags of the object. +-- @cstyle void object::SetEnumFlags(object this, int flags). +-- @param flags int; specifies the flags to be set. +-- @return void. + +function object:SetEnumFlags(flags) +end + +--- Gets all class flags of the object, ORed together ANDed the mask specified. +-- @cstyle int object::GetClassFlags(object this, int mask = ~0). +-- @param mask int; Default mask = ~0. +-- @return int. + +function object:GetClassFlags(mask) +end + +--- Sets to 1 the specified MapObject flags of the object its attaches. +-- @cstyle void object::SetHierarchyEnumFlags(object this, int flags). +-- @param flags int; specifies the flags to be set. +-- @return void. + +function object:SetHierarchyEnumFlags(flags) +end + +--- Returns the interpolated position of the object, including a valid Z taken from the terrain. +-- @cstyle point object::GetVisualPos(object this, int time_offset, bool bExtrapolate). +-- @return point; the position of the object. + +function object:GetVisualPos(time_offset, bExtrapolate) +end + +function object:GetVisualPosXYZ(time_offset, bExtrapolate) +end + +--- Returns the interpolated position of the object, including a valid Z taken from the terrain multiplied by factor. +-- @cstyle point object::GetVisualPosPrecise(object this, int factor). +-- @return point; the position of the object. + +function object:GetVisualPosPrecise() +end + +--- Returns the position of the object as point if it exists. Else returns point with invalid coordinates (-MAX_INT). +-- @cstyle point object::GetPos(object self). +-- @return point; The posiotion of the object. + +function object:GetPos() +end + +function object:HasFov(map_pos, fov_arc_angle, pos_offset_z, use_velocity_vector) +end + +function object:GetRollPitchYaw() +end +function object:SetRollPitchYaw(roll, pitch, yaw) +end + +--- Check if the object has a position on the map. +-- @cstyle bool object::IsValidPos(). +-- @return bool; + +function object:IsValidPos() +end + +--- Converts a world position to local space. +-- @cstyle point object::GetLocalPoint(point world_pos). +-- @return point; the position in local space. + +function object:GetLocalPoint(world_pos) end +function object:GetLocalPoint(x, y, z) end +function object:GetLocalPointXYZ(world_pos) end +function object:GetLocalPointXYZ(x, y, z) end + +--- Converts a local position to world space. +-- @cstyle point object::GetRelativePoint(point local_pos). +-- @return point; the position in world space. + +function object:GetRelativePoint(local_pos) end +function object:GetRelativePoint(x, y, z) end +function object:GetRelativePointXYZ(local_pos) end +function object:GetRelativePointXYZ(x, y, z) end + +--- Returns the angle(in minutes) of the spot in the given object and its axis. +-- @cstyle int, point object:GetSpotAngle(int idx). +-- @param idx int the index of the spot. +-- @return int, point. + +function object:GetSpotAngle(idx) +end + +--- Returns the scale of the spot in the. +-- @cstyle int object:GetSpotScale(int idx) or object::GetSpotScale(, int idx). +-- @param idx int the index of the spot. +-- @return int. + +function object:GetSpotScale(idx) +end + +--- Returns the interpolated position of the object, without the Z value +-- @cstyle point object::GetVisualPos2D(object this). +-- @return point; the position of the object. + +function object:GetVisualPos2D() +end + +--- Returns the sound position and distance relative to the closest listener +-- @cstyle point, int object::GetSoundPosAndDist(object this). +-- @return point; the sound position. +-- @return int; the sound dist. + +function object:GetSoundPosAndDist() +end + +--- Set gravity acceleration for this object +-- @cstyle void object::SetGravity(int accel). +-- @param accel int. +-- @return void. + +function object:SetGravity(accel) +end + +--- Compute free fall time +-- @cstyle int object:GetGravityFallTime(int fall_height, int start_speed_z, int accel). +-- @param fall_height int +-- @param start_speed_z int; optional +-- @param accel int; optional +-- @return int. + +function object:GetGravityFallTime(fall_height, start_speed_z, accel) +end + +--- Compute such a travel time that the object would reach the specified z level +-- @cstyle int object::GetGravityHeightTime(point target, int height, int accel). +-- @param target point +-- @param height int; height to reach above the start/end positions +-- @param accel int; optional. +-- @return void. + +function object:GetGravityHeightTime(target, height, accel) +end + +--- Compute travel time for a given starting angle +-- @cstyle int object::GetGravityAngleTime(Point target, int angle, int accel). +-- @param target point +-- @param angle int; angle in minutes +-- @param accel int; optional. +-- @return void. + +function object:GetGravityAngleTime(target, angle, accel) +end + +--- Set linear acceleration for this object +-- @cstyle void object::SetAcceleration(int accel). +-- @param accel int. +-- @return void. +function object:SetAcceleration(accel) +end + +--- Compute the acceleration and time needed to reach the target destination with the desired final velocity +-- @cstyle int object::GetAccelerationAndTime(Point destination, int final_speed, int starting_speed = object.GetVelocity()). +-- @param destination point +-- @param final_speed int; final speed when reaching the destination +-- @param starting_speed int; optional. The current speed when starting the interpolation. +-- @return int, int. + +function object:GetAccelerationAndTime(destination, final_speed, starting_speed) +end + +--- Compute the acceleration and starting speed needed to reach the target destination with the desired final velocity within given time period. +-- @cstyle int object::GetAccelerationAndStartSpeed(Point destination, int final_speed, int time). +-- @param destination point +-- @param final_speed int; final speed when reaching the destination +-- @param time int; movement time. +-- @return int, int. + +function object:GetAccelerationAndStartSpeed(destination, final_speed, time) +end + +--- Compute the acceleration and final speed needed to reach the target destination with the desired starting velocity within given time period. +-- @cstyle int object::GetAccelerationAndStartSpeed(Point destination, int final_speed, int time). +-- @param destination point +-- @param starting_speed int; starting speed +-- @param time int; movement time. +-- @return int, int. + +function object:GetAccelerationAndFinalSpeed(destination, starting_speed, time) +end + +function object:GetFinalSpeedAndTime(destination, acceleration, starting_speed) +end + +function object:GetFinalPosAndTime(final_speed, acceleration) +end + +--- Activates arc movement +-- @cstyle void object::SetCurvature(bool set) +function object:SetCurvature(set) +end + +--- Checks if arc movement is active +-- @cstyle bool object::GetCurvature() +function object:GetCurvature() +end + +--- Computes arc movement time +-- @cstyle int object::GetCurvatureTime(point pos, int angle [, point axis, int speed]) +-- @param pos point; target point. +-- @param angle int; target angle. +-- @param axis point; optional axis. If not provided, the object's current axis is used. +-- @param speed int; optional speed. If not provided, the object's current speed is used. +-- @return int. +function object:GetCurvatureTime(pos, angle, axis, speed) +end + +--- Changes postion of the object to pos smoothly for the specified time. +-- @cstyle void object::SetPos(object this, point pos, int time). +-- @param pos point; the new position of the object; must be in the map rectangle. +-- @param time int. +-- @return void. + +function object:SetPos(pos, time) +end + +--- Changes postion of the object to the current position of the given spot of the target object smoothly for the specified time. +-- @cstyle point object::SetLocationToObjSpot(object this, object target_obj, int spotidx, int time = 0). +-- @param target_obj object; specifies the target object. +-- @param spotidx int; specifies the spot of the target object. +-- @param time int; if time is nil the default value is 0 i.e set the new position right away. +-- @return void. + +function object:SetLocationToObjSpot(this, target_obj, spotidx, time) +end + +--- Changes postion of the object to the current position of a random spot of given type of the target object smoothly for the specified time. +-- @cstyle point object::SetLocationToRandomObjSpot(object this, object target_obj, int spot_type, int time = 0). +-- @param target_obj object specifies the target object. +-- @param spot_type int specifies the spot type. +-- @param time if time is nil the default value is 0, or set the new position right away. +-- @return void. + +function object:SetLocationToRandomObjSpot(this, target_obj, spot_type, time) +end + +--- Changes postion of the object to the current position of a random spot of given type in specified state of the target object smoothly for the specified time. +-- @cstyle point object::SetLocationToRandomObjSpot(object this, object target_obj, int spotidx, int time = 0). +-- @param target_obj object specifies the target object. +-- @param state int specifies the state. +-- @param spot_type int specifies the spot type. +-- @param time if time is nil the default value is 0, or set the new position right away. +-- @return void. + +function object:SetLocationToRandomObjStateSpot(this, target_obj, state, spot_type, time) +end + +--- Returns the angle of the object at which it is rotated around its rotation axis. +-- @cstyle int object::GetAngle(object this). +-- @return int in minutes. + +function object:GetAngle() +end + +--- Smoothly turns the object to the given angle around the rotation axis for the specified time. +-- @cstyle void object::SetAngle(object this, int angle, int time). +-- @param angle int; the angle in minutes. +-- @param time int; the time in ms for which the angle should be changed. +-- @return void. + +function object:SetAngle(angle, time) +end + +--- Returns the interpolated angle of the object in arcseconds. +-- @cstyle int object::GetVisualAngle(object this). +-- @return int; in minutes. + +function object:GetVisualAngle() +end + +--- Smoothly turns the object this to face point pt for the specified time. +-- @cstyle void object::Face(object this, point target, int time). +-- @cstyle void object::Face(object this, object target, int time). +-- @cstyle void object::Face(object this, object target, int time, spot, point offset). +-- @param target point/object; The point or object to face. +-- @param spot/offset; offset is relative to spot coordinate systemn +-- @param time int; The time in 1/1000 sec for which the full turn should be performed. +-- @return void. + +function object:Face(pt, time) +end + +--- Returns the rotation axis of the object. +-- @cstyle point object::GetAxis(object this). +-- @return point; The axis vector of the given object. + +function object:GetAxis() +end + +--- Returns the intepolated rotation axis of the object. +-- @cstyle point object::GetVisualAxis(object this). +-- @return point; The exact axis vector of the given object. + +function object:GetVisualAxis() +end + + +function object:GetVisualAxisXYZ() +end + +--- Smoothly changes the object rotation axis over the specified time. +-- @cstyle void object::SetAxis(object this, point axis, int time). +-- @param axis point; that is the axis vector. +-- @param time int; the time in 1/1000 sec for which the angle should be changed. +-- @return void. + +function object:SetAxis(axis, time) +end + +--- Smoothly turns the object to the given axis and angle for the specified time. This method ensures proper interpolation avoiding discontinuities. +-- @cstyle void object::SetAxisAngle(object this, point axis, int angle, int time). +-- @param axis point; that is the axis vector. +-- @param angle int; the angle in minutes. +-- @param time int; the rotation time in ms. +-- @return void. + +function object:SetAxisAngle(axis, angle, time) +end + +function object:SetPosAxisAngle(pos, axis, angle, time) +end + +--- Inverts the object's rotation axis. +-- @cstyle void object::InvertAxis(). +-- @return void. + +function object:InvertAxis() +end + +--- Sets the direction (a 3D vector) the object's top is facing with an optional angle of rotation around the direction axis. If the 'time' property is specified, the object will reach the orientation specified in 'time' ms. +-- @cstyle void object::SetOrientation(point direction, int angle = 0, int time = 0). +-- @param direction point. +-- @param angle int; in minutes. +-- @param time int; in ms. + +function object:SetOrientation(dir, angle, time) +end + +--- Returns the direction the object's top is facing and the angle it's rotated around this axis. +-- @cstyle point, int object::GetOrientation(). +-- @param time point, int. + +function object:GetOrientation() +end + +--- Rotates the object around the given axis and angle taking into account current object's orientation. +-- @cstyle void object::Rotate(object this, point axis, int angle, int time). +-- @param axis point; that is the axis vector. +-- @param angle int; the angle in minutes. +-- @param time int; the time in 1/1000 sec for which the angle should be changed. +-- @return point. + +function object:Rotate(this, axis, angle, time) +end + +--- Returns the object's visual face direction. +-- @cstyle int object::GetFaceDir(object self, int len). +-- @param len int; The desired length of the result vector. +-- @return point. + +function object:GetFaceDir(len) +end + +--- Returns angle to add to the current orientation angle of self object, so that the self object would face the other object. +-- @cstyle int object::AngleToObject(object self, object other). +-- @param other object. +-- @return int; angle from -180*60 to 180*60 (minutes). + +function object:AngleToObject(other) +end + +--- Returns angle to add to the current orientation angle of self object, so that the self object would face the point specified. +-- @cstyle int object::AngleToObject(object self, point pt). +-- @param pt point. +-- @return int; angle from -180*60 to 180*60 (minutes). + +function object:AngleToPoint(point) +end + +--- Returns the vector difference between the positions of self and other objects. +-- @cstyle int object::VectorTo2D(object self, object other). +-- @param other object. +-- @return int. + +function object:VectorTo2D(other) +end + +--- Returns the distance from self to the given object. +-- @cstyle int object::GetDist2D(object self, object other). +-- @param other object. +-- @return int. +function object:GetDist2D(other) +end + +--- Returns a predicted position of the object after a time interval elapses. +-- The function uses the interpolation data from the last call to the object's 'SetPos' to return +-- a predicted position if no other call to 'SetPos' is made in the meantime. Have in mind that +-- this prediction has some problems with attached object if the extrapolate flag is true. +-- @cstyle point object::PredictPos(object self, int time, bool extrapolate). +-- @param time int - how many milliseconds ahead to predict; negative values will return former object positions. +-- @param extrapolate bool - whether to extrapolate beyond the time of the last 'SetPos'. +-- @return point. + +function object:PredictPos(time, extrapolate) +end + +--- Returns the visual(EXACT) distance from self to the given object. +-- @cstyle int object::GetVisualDist2D(object self, object other). +-- @param other object. +-- @return int. + +function object:GetVisualDist2D(other) +end + +--- Returns the distance from self to the given object. +-- @cstyle int object::GetDist(object self, object other). +-- @param other object. +-- @return int. + +function object:GetDist(other) +end + +--- Returns the visual(EXACT) distance from self to the given object. +-- @cstyle int object::GetVisualDist(object self, object other). +-- @param other object. +-- @return int. +function object:GetVisualDist(other) +end + + +--- Returns integer identifying the player whose is this object. Returns -1 if the object does not exist. +-- @cstyle int object::GetPlayer(object this). +-- @return int. + +function object:GetPlayer() +end + +--- Changes the player whose is this object to specified player. +-- @cstyle void object::SetPlayer(object this, int player). +-- @param player int; player number. +-- @return void. + +function object:SetPlayer(player) +end + +--- Set state nState to the object. +-- @cstyle int object::SetState(object this, int nState, int nFlags, int tCrossfade, int nSpeed, bool bChangeOnly). +-- @param nState int. +-- @param nFlags int; optional anim flags +-- @param tCrossfade int; optional custom crossfade time +-- @param nSpeed int; optional custom anim speed +-- @param bChangeOnly bool; optional skip the anim set if already the same +-- @return int; duration of the animation. + +function object:SetState(nState, nFlags, tCrossfade, nSpeed, bChangeOnly) +end + +--- Set state nState to the object and sleep the calling thread for a number of animation cycles or time. +-- @cstyle int object::PlayState(object this, int nState, int count). +-- @param nState int. +-- @param count int; if positive it's the number of animation loops to sleep; otherwise it specifies the amount of time to sleep. + +function object:PlayState(nState, count) +end + +--- Returns the current state of the object (-1 on invalid object). +-- @cstyle int object::GetState(object this). +-- @return int. + +function object:GetState() +end + +--- Freezes the object in a single frame of the specified animation; the frame is speficied by the time from animation start. +-- @cstyle void object::SetStaticFrame(object this, int nState, int nTime). +-- @param nState int. +-- @param nTime int. +-- @return void. + +function object:SetStaticFrame(nState, time) +end + +--- Returns true if the object's entity has this state. +-- @cstyle bool object::HasState(object this, int state). +-- @param state state of the obejct's entity to be checked. +-- @return bool. + +function object:HasState(state) +end + + +--- Returns the scale of the object (-1 if the object does not exist). +-- @cstyle int object::GetScale(object this). +-- @return int. + +function object:GetScale() +end + +--- Sets the specified scale to the object if it exists. +-- @cstyle void object::SetScale(object this, int scale). +-- @param scale int. +-- @return void. + +function object:SetScale(scale) +end + +--- Return the final scaling of an object, that take into account parent object scale and spot's scale to which the object is attached. +-- @cstyle int object::GetWorldScale(object this). +-- @return int. + +function object:GetWorldScale() +end + +--- Returns the duration of the animation assigned with the specified state (returns -1 if the object does not exist);apply speed modifiers when used with object parameter. +-- @cstyle int object::GetAnimDuration(object this, int state) or GetAnimDuration(entity, int state). +-- @param state int; If parameter is ommited the funtion return the animation duration of the current state. +-- @return int. + +function object:GetAnimDuration(state) +end + +--- Returns how much time has passed since current animation started (time from last call to SetState). +-- @cstyle int object::TimeFromAnimStart(object this). +-- @return int. + +function object:TimeFromAnimStart() +end + +--- Returns remaining time to the end of currently played animation of the object. Modified by animation speed modifier!. +-- @cstyle int object::TimeToAnimEnd(object this). +-- @return int. + +function object:TimeToAnimEnd() +end + +--- Returns remaining time to the end of currently interpolated position change. +-- @cstyle int object::TimeToPosInterpolationEnd(object this). +-- @return int. + +function object:TimeToPosInterpolationEnd() +end + +--- Returns remaining time to the end of currently interpolated angle change. +-- @cstyle int object::TimeToAngleInterpolationEnd(object this). +-- @return int. + +function object:TimeToAngleInterpolationEnd() +end + +--- Returns remaining time to the end of currently interpolated angle change. +-- @cstyle int object::TimeToAxisInterpolationEnd(object this). +-- @return int. + +function object:TimeToAxisInterpolationEnd() +end + +--- Returns the max remaining time to the end of currently interpolated pos, angle and axis changes. +-- @cstyle int object::TimeToInterpolationEnd(object this). +-- @return int. + +function object:TimeToInterpolationEnd() +end + +--- Stops the pos, angle and axis interpolations. +-- @cstyle void object::StopInterpolation(object this). +-- @return void. + +function object:StopInterpolation() +end + +--- Returns whether an object is in a group. +-- @cstyle bool object::IsGrouped(object this). +-- @return bool. +-- @see Group. +-- @see Ungroup. + +function object:IsGrouped() +end + +--- Changes object's sound. +-- @cstyle void object::SetSound(string sound[, string type], int volume = -1, int fade_time = 0, bool looping). +-- @param sound string; either a sound file or a sound bank. +-- @param type string; sound type. used if sound is a file name. if sound is a sound bank it has to be omitted. +-- @param volume int; specifying the volume of the sound between 0 and 1000 (default is used the sound bank volume). +-- @param fade_time int; the cross-fade time if changing the sound state. +-- @param looping bool; specifies if the sound should be looping. (Use the sound bank flag by default) +-- @param loud_distance int; specifies if the distance whithin which the sound is played at max volume +-- @return bool; true if the operation is successful; + +function object:SetSound(sound, __type, volume, fade_time, looping, loud_distance) +end + +--- Changes object's sound state to silent. Breaks the current object sound, if playing. +-- @cstyle void object::StopSound(object this, int fade_time = 0). +-- @return void. + +function object:StopSound(fade_time) +end + +--- Changes object's sound volume. +-- @cstyle void object::SetSoundVolume(int volume, int time = 0). +-- @param volume int; volume between 0 and 1000. +-- @param time int; interpolation time, 0 by default. +-- @return void. + +function object:SetSoundVolume(volume, time) +end + +--- Returns the color modifier of an object. +-- @cstyle int object::GetColorModifier(). +-- @return int; As argb. + +function object:GetColorModifier() +end + +--- Sets the color modifier of an object. +-- @cstyle void object::SetColorModifier(int argb). +-- @param argb int as argb. +-- @return void. + +function object:SetColorModifier(argb) +end + +--- Returns the opacity the object is rendered with. +-- @cstyle int object::GetOpacity(object this). +-- @return int; Returns the opacitiy of the object - 0 for invisible to 100 for visible intransparent. +function object:GetOpacity() +end + +--- Set object's rendering opacity. +-- @cstyle void object::SetOpacity(object this, int val, int time, bool recursive). +-- @param val int; 0 for invisible to 100 for visible intransparent. +-- @return void. + +function object:SetOpacity(val, time, recursive) +end + +--- Specifies a new texture for the objetct's model; DEBUG PURPOSES ONLY!. +-- @cstyle void object::SetDebugTexture(string texture_file). +-- @param texture_file string; the path to the new texture. +-- @return void. +function object:SetDebugTexture(texture_file) +end + +--- Destroys the render object of the self object. +-- @cstyle void object::DestroyRenderObj(object self). +-- @return void. + +function object:DestroyRenderObj() +end + +--- Return the number of triangles in the given object's model; use these only for diagnostic, because they precache object geometry. +-- @cstyle int object::GetNumTris(object self). +-- @return int. +function object:GetNumTris() +end + +--- Return the number of vertices in the given object's model; use these only for diagnostic, because they precache object geometry. +-- @cstyle int object::GetNumVertices(object self). +-- @return int. +function object:GetNumVertices() +end + +--- Returns the name of the self particle object. +-- @cstyle string object::GetParticlesName(object self). +-- @return string. +function object:GetParticlesName() +end + +--- Gets the current self illumination modulation. +-- @cstyle int object::GetSIModulation(). +-- @return int; 0 for no self illumination; 100 for max self illumination. + +function GetSIModulation() +end + +--- Sets the current self illumination modulation. +-- @cstyle void object::SetSIModulation(int modulation). +-- @param modulation int; 0 for no self illumination; 100 for max self illumination. +-- @return void. + +function SetSIModulation(modulation) +end + +--- Finds the nearest object from the given object list to the given point. +-- @cstyle object FindNearestObject(objlist ol, point pt[, function filter]). +-- @param ol; the object list to search in. +-- @param pt; the point or object to which distance is measured; if the point is with invalid Z the measured distances are 2D, otherwise they are 3D. +-- @param filter; optional object filter. +-- @return object or false (if the object list is empty). + +function FindNearestObject(objlist, pt, filter) +end + +-- Returns a table with all valid states for current object. +-- @cstyle table EnumValidStates() or EnumValidStates(entity) +function EnumValidStates() +end + +function object:GetAnim(channel) +end + +--- Returns a table with all anim info. +-- @cstyle table object::GetAnimDebug( int channel ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return table; The channel animation info. +function object:GetAnimDebug(channel) +end + +--- Set new animation to an object's animation channel. +-- @cstyle void object::SetAnim( int channel, int anim, int flags = 0, int crossfade = -1, int speed = 1000, int weight = 100, int phase = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param anim int; the animation (state) to set. +-- @param flags int; the animation flags, see geObject.h for details. +-- @param crossfade int; the animation crossfade time, see geObject.h for details. +-- @param speed int; the animation speed, normal speed is 1000, can be ignored via the flags. +-- @param weight int; the animation weight, relative to the other animation weights. weight = 0 will hide the animation. +-- @param phase int; the animation phase. +-- @return void. + +function object:SetAnim(channel,anim,flags,crossfade,speed,weight,phase) +end + +--- Returns the animation flags of object's animation channel. +-- @cstyle int object::GetAnimFlags( int channel = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8 +-- @return int; The channel animation flags. + +function object:GetAnimFlags(channel) +end + +--- Return the current animation speed for an object's channel +-- @cstyle int object::GetAnimSpeed( int channel = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return int; Current animation speed in promiles. + +function object:GetAnimSpeed(channel) +end + +--- Set the new object's animation channel speed. +-- @cstyle void object::SetAnimSpeed( int channel, int speed, int time = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param speed int; the new speed to set in promiles, speed >= 0. +-- @param time int; the time we want the animation to reach the given speed, relative to the current time, so time=0 means right now. +-- @return void. + +function object:SetAnimSpeed(channel,speed,time) +end + +--- Return the animation weight of object's animation channel. +-- @cstyle int object::GetAnimWeight( int channel = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return int; Current animation weight. + +function object:GetAnimWeight(channel) +end + +--- Set the new object's animation channel weight. +-- @cstyle void object::SetAnimWeight( int channel, int weight, int time = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param weight int; the new weight to set, weight >= 0. +-- @param time int; the time we want the animation to reach the given weight, relative to the current time, so time=0 means right now. +-- @return void. + +function object:SetAnimWeight(channel,weight,time,easing) +end + +--- Return the animation start time of object's animation channel. +-- @cstyle int object::GetAnimStartTime( int channel ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return int; Animation start time. + +function object:GetAnimStartTime(channel) +end + +--- Set the object's animation channel animation start time. +-- @cstyle void object::SetAnimStartTime( int channel, int time ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param time int; the new animation start time, absolute. +-- @return void. + +function object:SetAnimStartTime(channel,time) +end + +--- Returns the object's animation channel current animation phase (offset from the beginning, 0 <= phase <= duration), which can be zero only for non-looping animations. +-- @cstyle int object::GetAnimPhase( int channel = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return int; animation phase. + +function object:GetAnimPhase(channel) +end + +--- Set the object's animation channel new phase. +-- @cstyle void object::SetAnimPhase( int channel, int phase ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return void. + +function object:SetAnimPhase(channel,phase) +end + +--- Clears the animation channel of an object. Works only for 2 <= channel <= 8. +-- @cstyle void object::ClearAnim( int channel ). +-- @param channel int; the index of the channel, 2 <= channel <= 8. +-- @return void. + +function object:ClearAnim(channel) +end + +--- Returns if an animation is used in any animation channel of an object. +-- @cstyle bool object::HasAnim( int anim ). +-- @param anim int; the animation (state) to check for. +-- @return bool; animation is used. + +function object:HasAnim(anim) +end + +--- Returns if an animation is used in any animation channel of an object and in what channel exactly. +-- @cstyle int object::FindAnimChannel( int anim ). +-- @param anim int; the animation (state) to check for. +-- @return int; the first animation channel who uses the animation, 0 if not present. + +function object:FindAnimChannel(anim) +end + +--- Returns if the base state (channel=1) is static (not animated). +-- @cstyle bool object::IsStaticAnim() or IsStaticAnim(entity). +-- @return bool; true if the state is not-animated, false otherwise. + +function object:IsStaticAnim() +end + +--- Returns if an object's animation channel animation is looping or not. +-- @cstyle bool object::IsAnimLooping( int channel = 0 ). +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @return bool; true if the animation is looping, false otherwise. + +function object:IsAnimLooping(channel) +end + +--- Returns the index of the animation component of the current animation of channel with the specified label +-- @cstyle int object::GetAnimComponentIndexFromLabel(int channel) +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param label string; the label of the desired animation component, specified in the AnimComponentDef +-- @ return int; a positive index if the animation component exists, 0 otherwise + +function object:GetAnimComponentIndexFromLabel(channel, label) +end + +--- Sets the runtime parameters for the animation component running on channel +-- @cstyle void object::SetAnimComponentTarget(int channel, int animComponentIndex, params...) +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param animComponentIndex int; the index of the animation component, 1 <= index <= 3 +-- @param params ...; number and type of parameters depends on the animation component type. can be gameObject and spotName, position or other special values +-- @ return void; + +function object:SetAnimComponentTarget(channel, animComponentIndex, params) +end + +--- Removes the targets of an animation component(s) running on a channel +-- @cstyle int object::RemoveAnimComponentTarget(int channel) +-- @cstyle int object::RemoveAnimComponentTarget(int channel, int animComponentIndex) +-- @param channel int; the index of the channel, 1 <= channel <= 8. +-- @param animComponentIndex int; the index of the animation component, 1 <= index <= 3. if missing, removes the targets of all components +-- @ return void; + +function object:RemoveAnimComponentTarget(channel, animComponentIndex) +end + +--- Return whether an error occured while loading this state; precaches the state if it isn't loaded (slow!). +-- @cstyle bool IsErrorState( entity, anim ) or bool object::IsErrorState( entity, anim ). +-- @param entity string; the entity name. +-- @param anim int; the animation (state). +-- @return bool; is this an "error" state (rotating cube). + +function IsErrorState(entity, anim) +end + +--- Return if the animation (state) is looping or not. +-- @cstyle bool IsEntityAnimLooping( entity, anim ). +-- @param entity string; the entity name. +-- @param anim int; the animation (state). +-- @return bool; Is animation looped or not. + +function IsEntityAnimLooping(entity, anim) +end + +--- Returns the number of valid states for current object. +--@cstyle int obj:GetNumStates() or int GetNumStates(entity). +function GetNumStates() +end + +--- Returns the state of the Mirrored flag for current object. +-- @cstyle bool GetMirrored() +-- @return bool, true if Mirrored is set +function GetMirrored() +end + +--- Tests if a vertical ray through given point is intersecting the current object or not. +-- @cstyle bool IsPointOverObject() +-- @return bool; true if there is intersection point. +function IsPointOverObject() +end + +--- Sets an indexed userdata value for current object. +-- @cstyle void SetCustomData(int index, value) +-- @param index int; the index of the userdata value +-- @param value uint32 or pstr; the value to set +function SetCustomData(index, value) +end + +--- Gets an indexed userdata value from current object. +-- @cstyle uint32 GetCustomData(int index) +-- @param index int; the index of the userdata value +-- @return int; the specified userdata value +function GetCustomData(index) +end + +--- Gets the bbox formed by the requested surfaces of the coresponding object. +-- @cstyle box, int GameObject::GetSurfacesBBox(int request_surfaces = -1, int fallback_surfaces = 0) +-- @param request_surfaces int; the requested surfaces (e.g. EntitySurfaces.Selection + EntitySurfaces.Build). By default (-1) all surfaces are requested. +-- @param fallback_surfaces int; fallback case if the requested surfaces are missing. By default (0) no falllback will be matched. +-- @return box; the resulting bounding box +-- @return int; the matched surface flags +function object:GetSurfacesBBox(request_surfaces, fallback_surfaces) +end + + +--- Return a list with attached objects. +-- @cstyle GameObject* GameObject::GetAttaches(string *classes = null) +-- @param classes table; List of attach classes. This parameter is optional. +function object:GetAttaches(classes) +end + +--- Destroy attached objects and return their count. +-- @cstyle int GameObject::DestroyAttaches(string *classes = null, function filter = null) +-- @param classes table; List of class names or single class name. This parameter is optional. +-- @param exec function; Callback function. First parameter if class is omitted. This parameter is optional. Accepts variable number of parameters. +function object:DestroyAttaches(classes, filter, ...) +end + +--- Count attached objects. +-- @cstyle int GameObject::CountAttaches(string *classes = null, function filter = null) +-- @param classes table; List of class names or single class name. This parameter is optional. +-- @param exec function; optional callback function. First parameter if class is omitted. This parameter is optional. Accepts variable number of parameters. +function object:CountAttaches(classes, filter, ...) +end + +--- Call a lua callback function for each attach and return the number of callbacks. +-- @cstyle int GameObject::ForEachAttach(string *classes, function exec) +-- @param classes table; List of class names or single class name. This parameter is optional. +-- @param exec function; Callback function. First parameter if class is omitted. The loop is terminated if true equivalent value is returned. Accepts variable number of parameters. +function object:ForEachAttach(classes, exec, ...) +end + +--- Check if the object has a valid Z coordinate. +-- @cstyle bool GameObject::IsValidZ() +function object:IsValidZ() +end + +--- Check if the object has the same position. +-- @cstyle bool object::IsEqualPos(point). +-- @return bool; +function object:IsEqualPos(pos) +end + +--- Check if the object has the same 2D position. +-- @cstyle bool object::IsEqualPos2D(point). +-- @return bool; +function object:IsEqualPos2D(pos) +end + +--- Check if the object has the same visual position. +-- @cstyle bool object::IsEqualVisualPos(point). +-- @return bool; +function object:IsEqualVisualPos(pos) +end + +--- Check if the object has the same visual 2D position. +-- @cstyle bool object::IsEqualVisualPos2D(point). +-- @return bool; +function object:IsEqualVisualPos2D(pos) +end + +--- Computes an average point from N points or objects. +-- @cstyle point AveragePoint(point pt1, object pt2, ...) +-- @cstyle point AveragePoint(table pts [, int count]) +-- @return point; +function AveragePoint(pt1, pt2, ...) +end + +--- Computes an average 2D point from N points or objects. +-- @cstyle point AveragePoint2D(point pt1, object pt2, ...) +-- @cstyle point AveragePoint2D(table pts [, int count]) +-- @return point; +function AveragePoint2D(pt1, pt2, ...) +end + +-- @cstyle int object::GetPfClass(). +function object:GetPfClass() +end diff --git a/CommonLua/LuaExportedDocs/Game/LuaExports.lua b/CommonLua/LuaExportedDocs/Game/LuaExports.lua new file mode 100644 index 0000000000000000000000000000000000000000..883a127974d3bdf933e31047a4802423872ef856 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/LuaExports.lua @@ -0,0 +1,241 @@ +--- Misc functions - entities, particles, debug, engine settings. + +--- Return the object under the mouse cursor. +-- @cstyle void tco(). +-- @return object. + +function tco() +end + +--- Removes all game objects from the current map. +-- @cstyle void ClearObjects(). +-- @return void. + +function ClearObjects() +end + +--- Returns whether an entity with the specified name exists and can be loaded. +-- @cstyle bool IsValidEntity(string entity). +-- @param entity string. +-- @return boolean. + +function IsValidEntity(entity) +end + +--- Returns a table containing all states of the specified entity. +-- @cstyle string GetStates(string entity|object). +-- @param entity string or object. +-- @return table(string). + +function GetStates(entity) +end + +--- Returns the name of the state associated with the specified integer. +-- @cstyle string GetStateName(int state). +-- @param state int. +-- @return string. + +function GetStateName(state) +end + +--- Returns the formatted (esSomething) name of the state associated with the specified integer. +-- @cstyle string GetStateNameFormat(int state). +-- @param state int. +-- @return string. + +function GetStateNameFormat(state) +end + +--- Returns the index of the state with the given name. +-- @cstyle int GetStateIdx(string state). +-- @param state string. +-- @return int. + +function GetStateIdx(state) +end + +--- Reloads particle system descriptions; changes parameters of currently running particle systems. +-- @cstyle void ParticlesReload(). +-- @return void. + +function ParticlesReload() +end + +--- Places a particle system of type filename in point pt. +-- @cstyle void ParticlePlace(string filename, point pt). +-- @param filename string. +-- @param pt point. +-- @return void. + +function ParticlePlace(filename, pt) +end + +--- Performs subpixel shifting of the rendered image, in the x and y directions. One whole pixel is 1000. +-- @cstyle void SetCameraOffset(int x, int y). +-- @param x int. +-- @param y int. +-- @return void. + +function SetCameraOffset(x, y) +end + +--- Returns the point on the terrain where the mouse cursor points currently. Only works when RTS camera is active. +-- @cstyle point GetTerrainCursor(). +-- @return point. + +function GetTerrainCursor() +end + +--- Returns the closest selectable object to the specified screen position or to the current position of the terrain cursor. +-- Only works when RTS camera is active. +-- @cstyle object GetTerrainCursorObjSel(point screen_pos = nil). +-- @return object. + +function GetTerrainCursorObjSel(screen_pos) +end + +--- Returns the closest object to the specified screen position or to the current position of the terrain cursor. +-- The objects which are tested are from the specified list. +-- Only works when RTS camera is active. +-- @cstyle object GetTerrainCursorObjSel(point screen_pos, objlist objects_to_test, bool test_walkables). +-- @return object. + +function GetCursorObjSel(screen_pos, objects_to_test, test_walkables) +end + +--- Returns the closest object to the specified screen position or to the current position of the terrain cursor. +-- Only works when RTS camera is active. +-- @cstyle object GetTerrainCursorObj(point screen_pos = nil). +-- @return object. + +function GetTerrainCursorObj(screen_pos) +end + +--- Returns the map file path. +-- @cstyle string GetMapPath(). +-- @return string. + +function GetMapPath() +end + +--- Returns a table with all existing entities. +-- @cstyle table GetAllEntities(). +-- @return table; Integer indexed table with all entities. + +function GetAllEntities() +end + +--- Returns the the maximum (bounding) surface box of all surface rects in all entities. +-- @cstyle box GetEntityMaxSurfacesBox(). +-- @return box. + +function GetEntityMaxSurfacesBox() +end + +--- Returns the the maximum (bounding) surface radius of all surface rects in all entities. +-- @cstyle box GetEntityMaxSurfacesRadius(). +-- @return int. + +function GetEntityMaxSurfacesRadius() +end + +--- Returns the the maximum radius of all objects on the map (cached). +-- @cstyle box GetMapMaxObjRadius(). +-- @return int. + +function GetMapMaxObjRadius() +end + +--- Return the entity animation speed modifier as a percent. +-- Affects both animation duration and action moment!. +-- @cstyle int GetStateSpeedModifier(string entity, int state). +-- @param entity string. +-- @param state int. +-- @return int. + +function GetStateSpeedModifier(entity, state) +end + +--- Set new animation speed modifier, as percents of original animation duration. +-- Animation duration and action moment times are affected by that modifier!. +-- @cstyle void SetStateSpeedModifier(entity, state, int modifier). +-- @param modifier int; new speed modifier. +-- @return void. + +function SetStateSpeedModifier(modifier) +end + +--- Changes the specified postprocess parameter smoothly over the given time. +-- @cstyle void SetPostProcessingParam(int param, int value, int time = 0). +-- @param param integer; the parameter to change (currently valid are indexes 0-3). +-- @param value integer; the new value. +-- @param time integer; if omitted defaults to 0. +-- @return void. + +function SetPostProcessingParam(param, value, time) +end + +--- Returns the current value of the specifcied post-processing parameter. +-- @cstyle int GetPostProcessingParam(int param). +-- @param param integer; the parameter index (currently valid are indexes 0-3). +function GetPostProcessingParam(param) +end + +--- Sets the value of given post-processing predicate. +-- @cstyle void SetPostProcPredicate(string name, int value) +-- @param name string; the name of the predicate to set. +-- @param value int; the value to set (0 - disabled, 1 - enabled) +-- @return void +function SetPostProcPredicate(name, value) +end + +--- Return a suitable random spot in circle area where an object from the given class can be placed. +-- The spot will be passable and on the terrain, and it will be far enough from all objects in ol. +-- @cstyle point GetSummonPt(objlist ol, point ptCenter, int nAreaRadius, string pchClass, int nRadius, int nTries). +-- @param ol objlist; list with obstacle objects to consider. +-- @param ptCenter point; the center of the area. +-- @param nAreaRadius integer; the radius of the area. +-- @param pchClass string; the class of the object to place. +-- @param nRadius integer; the radius of the object to place. +-- @param nTries integer; number of random spot to try before the function gives up. +-- @return point; Can be nil if no spot was found. +function GetSummonPt(ol, ptCenter, nAreaRadius, pchClass, nRadius, nTries) +end + +--- Returns the application id, as used to create folders under Application Data and registry entries +-- @cstyle string GetAppName() +-- @return appname string; the application name +function GetAppName() +end + +--- Returns all state moments for specific entity/state. +-- It is supposed to be used only when quering moments embeded in the entity XML itself, otherwise AnimMoments is the easier way to access that data. +-- @cstyle vector GetStateMoments(entity/object, state). +-- @param entity; entity in the game or game object. +-- @param state; state in that entity. +-- @return table; a vector containng all the moments for that entity/state in the form {type = string, time = int}. +function GetStateMoments(entity, state) +end + +--- Returns a convex polygon containing the provided array of points. +-- @cstyle vector ConvexHull2D(point* points, int border = 0). +-- @param points; array with points or game objects. +-- @param border; the border with which to offset to obtained convex polygon. +-- @return table; a vector containng the points of the convex polygon +function ConvexHull2D(points, border) +end + +--- Gets the bbox formed by the requested surfaces. +-- @cstyle box, int GetEntitySurfacesBBox(string entity, int request_surfaces = -1, int fallback_surfaces = 0, int state_idx = 0) +-- @param request_surfaces int; the requested surfaces (e.g. EntitySurfaces.Selection + EntitySurfaces.Build). By default (-1) all surfaces are requested. +-- @param fallback_surfaces int; fallback case if the requested surfaces are missing. By default (0) no falllback will be matched. +-- @param state_idx int; the entity state, 0 by default (Idle). +-- @return box; the resulting bounding box +-- @return int; the matched surface flags +function GetEntitySurfacesBBox(entity, request_surfaces, fallback_surfaces, state_idx) +end + +--- Creates a new empty table and pushes it onto the stack. Parameter narr is a hint for how many elements the table will have as a sequence; parameter nrec is a hint for how many other elements the table will have. +function createtable(narr, nrec) +end + diff --git a/CommonLua/LuaExportedDocs/Game/MapQueries.lua b/CommonLua/LuaExportedDocs/Game/MapQueries.lua new file mode 100644 index 0000000000000000000000000000000000000000..5ccf7ccbc95bb1c868283ca032cb77be7bcfb40a --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/MapQueries.lua @@ -0,0 +1,93 @@ +--- Map objects query functions ---- + +-- see Docs/LuaMapEnumeration.md.html for a detailed description of the query parameters + +--- Returns all objects in the map that match the criteria specified in the query list. +-- @cstyle objlist MapGet(list query). +-- @param query list containing all criteria that an object has to meet to be returned by the function; see above. +-- @return objlist; all objects that match the query in the map. Returns nothing if no objects matched. +function MapGet(list) +end + +--- Returns first object in the map that match the criteria specified in the query list. +-- @cstyle objlist MapGet(list query). +-- @param query list containing all criteria that an object has to meet to be returned by the function; see above. +-- @return obj; one object that match the query in the map. +function MapGetFirst(list) +end + +--- Returns the count of all objects in the map that match the criteria specified in the query list. +-- @cstyle int MapCount(query). +-- @param query - list containing all criteria that an object has to meet to be returned by the function; see above. +-- @return objcount; +function MapCount(list) +end + +---Calls specified function on every object on the map that match the criteria specified in the query list. +-- @cstyle int MapForEach(query). +-- @param query - list containing all criteria that an object has to meet to be processed by the specified function. +-- @return objcount; count of objects which has been filtered out for the function. +function MapForEach(list) +end + +---From all of the objects that match the criteria specified in the query list finds the one with least evaluation (returned by the specified function parameter). +-- @cstyle object MapFindMin(query). +-- @param query - list containing all criteria that an object has to meet to be processed by the specified function. +-- @return obj, obj_eval; return object match and number of evaluations. +function MapFindMin(list) +end + +---From all of the objects that match the criteria specified in the query list finds the closest to the object specified as a first param in the list. +-- @cstyle objlist MapFindNearest(obj, query). +-- @param obj - reference object for the search; query - list containing all criteria that an object has to meet to be processed by the specified function. +-- @return obj, obj_eval; nearest object, number of evaluations +-- note: function specified takes at least two params by default: filtered object and reference object specified in first arguement +function MapFindNearest(obj, list) +end + +---From all of the objects that match the criteria specified in the query list finds the one that takes shortest path to reach from the specified object/point. +-- @cstyle object MapFindShortestPath(obj, query). +-- @param obj - reference object for the search; query - list containing all criteria that an object has to meet to be processed by the specified function. +-- @return obj; return object match or, if no match, all objects matched criteria. ? +function MapFindShortestPath(obj, list) +end + +--- Returns all objects in the filter_list that match the criteria specified in the query list. +-- The syntax sugar member of objlist objlist:MapFilter(list) can also be used. +-- @cstyle objlist MapFilter(objlist list, query). +-- @param query table describing all criteria that an object has to meet to be returned by the function; see above. +-- @return objlist; all objects that match the query in the list. +function MapFilter(obj_list, list) +end + +---Deletes all of the objects that match the criteria specified in the query list. +-- @cstyle int MapDelete(query). +-- @param query - list containing all criteria that an object has to meet to be processed by the specified function. +-- @return objcount; count of objects which has been filtered out for deletion. +function MapDelete(...) +end + +---Sets/Clears specified flag for all of the objects that match the criteria specified in the query list. +-- @cstyle int Map{Set/Clear}{Enum/Game/Hierarchy}Flags(action_data, query). +-- @param action_data - enum flag to set/clear; query - list containing all criteria that an object has to meet to be processed by the specified function. +-- @return objcount; count of objects which has been filtered out. +function MapSetEnumFlags(action_data, list) +end + +function MapClearEnumFlags(action_data, list) +end + +function MapSetGameFlags(action_data, list) +end + +function MapClearGameFlags(action_data, list) +end + +function MapSetHierarchyEnumFlags(action_data, list) +end + +function MapClearHierarchyEnumFlags(action_data, list) +end + +function MapSetCollectionIndex(action_data, list) +end diff --git a/CommonLua/LuaExportedDocs/Game/MultiUser.lua b/CommonLua/LuaExportedDocs/Game/MultiUser.lua new file mode 100644 index 0000000000000000000000000000000000000000..96f8eb1e659620686a11bccb50e763634a4905ea --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/MultiUser.lua @@ -0,0 +1,48 @@ +--- MultiUser support functions. + +--- Returns the number of users. +-- @cstyle int MultiUser.GetCount(). +-- @return int; Returns the number of users. + +function MultiUser.GetCount() +end + +--- Sets the active user. +-- @cstyle bool MultiUser.SetActive(index). +-- @param index int; index of the user to be set as active. +-- @return void. + +function MultiUser.SetActive(index) +end + +--- Returns info for the active user. +-- @cstyle void MultiUser.GetActive(). +-- @return table; example: { uid = "7075076655715131254", name = 'John', country = "India", comment = "very powerful", folder = "AppData/Users/1" }. + +function MultiUser.GetActive() +end + +--- Returns info for specific user. +-- @cstyle void MultiUser.GetUser(int index). +-- @param index int; index of the user to get. +-- @return table; example: { uid = "7075076655715131254", name = 'John', country = "India", comment = "very powerful", folder = "AppData/Users/1" }. + +function MultiUser.GetUser(index) +end + +--- Registers a new user to system and sets it as active. +-- @cstyle void MultiUser.AddNewUser(). +-- @return void. +-- @see MultiUser.GetActive. +-- @see MultiUser.SetActive. + +function MultiUser.AddNewUser() +end + +--- Deletes the specified user. +-- @cstyle void MultiUser.AddNewUser(). +-- @param index int; index of the user to delete. +-- @return void. + +function MultiUser.DeleteUser() +end diff --git a/CommonLua/LuaExportedDocs/Game/PathFinder.lua b/CommonLua/LuaExportedDocs/Game/PathFinder.lua new file mode 100644 index 0000000000000000000000000000000000000000..80a91526b2658aaa4df7441641ef1f2c7337a7fd --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/PathFinder.lua @@ -0,0 +1,16 @@ +--- Path Finder related functions. + +function pf.GetPosPath(src, dst, pfclass, range, min_range, path_owner, restrict_radius, restrict_center, path_flags) +end + +function pf.HasPosPath(src, dst, pfclass, range, min_range, path_owner, restrict_radius, restrict_center, path_flags) +end + +function pf.PosPathLen(src, dst, pfclass, range, min_range, path_owner, restrict_radius, restrict_center, path_flags) +end + +function pf.GetLinearDist(src, dst) +end + +function pf.GetPathLen(obj, end_idx, max_length, skip_tunnels) +end diff --git a/CommonLua/LuaExportedDocs/Game/Terrain.lua b/CommonLua/LuaExportedDocs/Game/Terrain.lua new file mode 100644 index 0000000000000000000000000000000000000000..e110cbddca0b6a0a88931c60c4deb8a6b166e1ed --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/Terrain.lua @@ -0,0 +1,270 @@ +--- Terrain functions. +-- Most functions dealing with terrain have something to do with the editor, which is written in Lua. +-- The functions you will generally use from here are the ones for getting terrain height, terrain surface height. +-- Also see the 'terrain.IsPointInBounds' function. +-- Each project can have different game units. The guim constant contains the number of game units in one meter. + +--- Returns true if the point is in the terrain bounds. +-- @cstyle bool terrain.IsPointInBounds(point pos). +-- @param pos point; the point to be checked. +-- @return bool; true if the point is in terrain bounds, false otherwise. + +function terrain.IsPointInBounds(pos, border) +end + +--- Clamp a position with the map bounding box. +-- @cstyle int terrain.ClampPoint(point pos, int border = 0). +-- @param pos point; point to clamp. +-- @param border int; map border width (optional). +-- @return pos; the clamped position. + +function terrain.ClampPoint(pos, border) +end + +function terrain.ClampBox(box, border) +end + +function terrain.ClampVector(ptFrom, ptTo) +end + +function terrain.IsMapBox(box) +end + +--- Returns true if the point is passable. +-- @cstyle bool terrain.IsPassable(point pos). +-- @param pos point; Map position to be checked for passability. +-- @return bool; true if the point is passable, false otherwise. + +function terrain.IsPassable(pos) +end + +--- Check passability in a radius around a point +-- @cstyle bool terrain.CirclePassable(point center, int radius, int pfclass). +-- @cstyle bool terrain.CirclePassable(object obj, int radius). +function terrain.CirclePassable(center, radius, pfclass) end +function terrain.CirclePassable(x, y, z, radius, pfclass) end +function terrain.CirclePassable(obj, radius) end + + +--- Check if a certain number of tiles are passable, starting from a given position +function terrain.AreaPassable(pos, area, pfclass, avoid_tunnels) end +function terrain.AreaPassable(x, y, z, area, pfclass, avoid_tunnels) end +function terrain.AreaPassable(obj, area, avoid_tunnels) end + +--- Search a position with enough connected passable tiles, starting from a given position +function terrain.FindAreaPassable(pos, area, radius, pfclass, avoid_tunnels, destlock_radius, filter, ...) end +function terrain.FindAreaPassable(pos, obj, area, radius, avoid_tunnels, can_destlock, filter, ...) end +function terrain.FindAreaPassable(x, y, z, area, radius, pfclass, avoid_tunnels, destlock_radius, filter, ...) end +function terrain.FindAreaPassable(x, y, z, obj, area, radius, avoid_tunnels, can_destlock, filter, ...) end +function terrain.FindAreaPassable(obj, area, radius, avoid_tunnels, can_destlock, filter, ...) end + +--- Returns whether the terrain at the given point is vertical. +-- @cstyle bool terrain.IsVerticalTerrain(point pt). +-- @param pt point; map position to be checked. +-- @return bool; true if the terrain point is vertical, false otherwise. + +function terrain.IsVerticalTerrain(pt) +end + +--- Returns the terrain type at the given map position. +-- @cstyle int terrain.GetTerrainType(point pt). +-- @param pt point. +-- @return int. + +function terrain.GetTerrainType() +end + +--- Sets the terrain type at the given map position. +-- @cstyle void terrain.SetTerrainType(point pt, int nType). +-- @param pt point. +-- @param type int. +-- @return void. + +function terrain.SetTerrainType() +end + +--- Returns the surface height (max from terrain height & water for now) at the specified position. +-- @cstyle int terrain.GetSurfaceHeight(point pos). +-- @param pos point; point for which to get the height. +-- @return int; the surface height. + +function terrain.GetSurfaceHeight(pos) +end + +--- Returns the height of the terrain in the specified position. +-- @cstyle int terrain.GetHeight(point pos). +-- @param pos point; point for which to get the height. +-- @return int; Return the height at the given point. + +function terrain.GetHeight(pos) +end + +function terrain.GetMinMaxHeight(box) +end + +function terrain.FindPassable(pos, pfclass, radius, destlock_radius) +end +function terrain.FindPassable(x, y, z, pfclass, radius, destlock_radius) +end + +function terrain.FindPassableZ(pos, pfclass, max_below, max_above) +end +function terrain.FindPassableZ(x, y, z, pfclass, max_below, max_above) +end + +function terrain.FindReachable(start, mode, ...) +end + +function terrain.FindPassableTile(pos, flags, ...) +end +function terrain.FindPassableTile(x, y, z, flags, ...) +end + +--- Returns the normal to the terrain surface, with all components multiplied by 100. +-- @cstyle point terrain.GetSurfaceNormal(point pos). +-- @param pos point; Map position for which to get the surface normal. +-- @return point; The surface normal vector. + +function terrain.GetSurfaceNormal(pos) +end + +--- Returns the normal to the terrain, with all components multiplied by 100. +-- @cstyle point terrain.GetTerrainNormal(point pos). +-- @param pos point; Map position for which to get the terrain normal. +-- @return point; The terrain normal vector. + +function terrain.GetTerrainNormal(pos) +end + +--- Returns the size of the map (terrain) rectangle as two integers - sizex and sizey. +-- @cstyle int, int terrain.GetMapSize(). +-- @return int, int; Returns the width, height. + +function terrain.GetMapSize() +end + +--- Returns the size of the grtass map recrangle as two integers - sizex and sizey. +-- @cstyle int, int terrain.GetGrassMapSize(). +-- @return int, int; Returns the width, height. +function terrain.GetGrassMapSize() +end + +--- Returns the map width/sizex. +-- @cstyle int terrain.GetMapWidth(). +-- @return int. + +function terrain.GetMapWidth() +end + +--- Returns the map height/sizey. +-- @cstyle int terrain.GetMapHeight(). +-- @return int. + +function terrain.GetMapHeight() +end + +--- Get the average height of the area determined by the circle(pos, radius). If no parameters are specified, works over the entire map. +-- @cstyle int terrain.GetAreaHeight(point pos, int radius). +-- @param pos point; center of the area. +-- @param radius int; radius of the area. +-- @return int; average height of the area. + +function terrain.GetAreaHeight(pos, radius) +end + +--- Sets the height of circle(center, innerRadius) to the specified and smoothly transforms the terrain between inner and outer circles (the terrain outside the outer circle preserves its height). Returns the changed box, empty box if nothing was changed. +-- @cstyle void terrain.SetHeightCircle(point center, int innerRadius, int outerRadius, int height). +-- @param center point; the circle center. +-- @param innerRadius int; the inner radius of the circle. +-- @param outerRadius int; the outer radius of the circle. +-- @param height int; the height to be set in the circle. +-- @return box. + +function terrain.SetHeightCircle(center, innerRadius, outerRadius, height) +end + +--- Smooths the terrain inside circle(center, radius) setting its height to the average height of the area. +-- @cstyle void terrain.SmoothHeightCircle(point center, int radius). +-- @param center int; the circle center. +-- @param radius int; radius of the circle. +-- @return void. + +function terrain.SmoothHeightCircle(center, radius) +end + +--- Calculates the height of the circular terrain(center, radius) and sets it to its average + heightdiff; Interpolates the terrain between inner and outer circles. +-- @cstyle void terrain.ChangeHeightCircle(point center, int innerRadius, int outerRadius, int heightdiff). +-- @param center point; out value: false. +-- @param innerRadius int; the inner radius of the circle. +-- @param outerRadius int; the outer radius of the circle. +-- @param heightdiff int; the height difference according to the average. +-- @return void. + +function terrain.ChangeHeightCircle(center, innerRadius, outerRadius, heightdiff) +end + +--- Sets the terrain texture inside the specified circle to type. +-- @cstyle void terrain.SetTypeCircle(point pos, int radius, int type). +-- @param pos point; center of the circle. +-- @param radius int; the circle radius. +-- @param type int; type of the texture to set. +-- @return void. + +function terrain.SetTypeCircle(pos, radius, type) +end + +--- Replaces the terrain texture inside the specified circle of type_old with to type_new. +-- @cstyle void terrain.SetTypeCircle(point pos, int radius, int type_old, int type_new). +-- @param pos point; center of the circle. +-- @param radius int; the circle radius. +-- @param type int; type of the texture to set. +-- @return void. + +function terrain.ReplaceTypeCircle(pos, radius, type_old, type_new) +end + +--- Returns the intersection of a segment with the terrain. +-- @cstyle point terrain.IntersectSegment(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return point. + +function terrain.IntersectSegment(pt1, pt2) +end + +--- Returns the intersection of a ray with the terrain. +-- @cstyle point terrain.IntersectRay(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return point. + +function terrain.IntersectRay(pt1, pt2) +end + +--- Scale the height of the terrain by a rational factor +-- @cstyle terrain.ScaleHeight(int mul, int div). +-- @param mul int; the numerator of the rational factor. +-- @param div int; the denominator of the rational factor. + +function terrain.ScaleHeight(mul, div) +end + +--- Remaps all the terrain indicies in the terrain data. +-- @cstyle void terrain.RemapType(map remap). +-- @param remap; a map specifying remapping from terrain index to terrain index. +-- @return void. + +function terrain.RemapType(remap) +end + +--- Returns the current map heightfield as a grid. If the map is non-square and/or non-pow2, it is extended with 0.0f. +-- @cstyle grid GetHeightGrid(). +-- @return heightfield grid; as grid. +function terrain.GetHeightGrid() +end + +--- Returns the current map terrain type as a grid. If the map is non-square and/or non-pow2, it is extended with 0.0f. +-- @cstyle grid GetTerrainGrid(). +-- @return terrain type grid; as grid. +function terrain.GetTypeGrid() +end diff --git a/CommonLua/LuaExportedDocs/Game/XInput.lua b/CommonLua/LuaExportedDocs/Game/XInput.lua new file mode 100644 index 0000000000000000000000000000000000000000..438e1828eec9ee018821243de559487c585bbe70 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/XInput.lua @@ -0,0 +1,36 @@ +--- X-Box controller functions. + +--- Returns the number of max supported controllers. +-- @cstyle int XInput.MaxControllers(). +-- @return int; Returns the number of the supported controllers. + +function XInput.MaxControllers() +end + +--- Checks if the controller is connected. +-- @cstyle bool XInput.IsControllerConnected(controllerId). +-- @param controllerId int; ID of the controller to be checked. +-- @return bool; true if the controlledId is connected, false otherwise. + +function XInput.IsControllerConnected(controllerId) +end + +--- Set the rumble motors to work at the given speed. +-- @cstyle void XInput.SetRumble(int controllerId, int leftSpeed, int rightSpeed). +-- @param controllerId int; ID of the controller for which to set the rumble motors. +-- @param leftSpeed int; the speed for the left motor ranging from 0 to 65535, i.e. 0% - 100%. +-- @param rightSpeed int; the speed for the right motor ranging from 0 to 65535, i.e. 0% - 100%. +-- @return void. + +function XInput.SetRumble(controllerId, leftSpeed, rightSpeed) +end + +--- Gets the state of controller as last and current state. Last state represents the accumulated events since the last update: : for buttons, they are 1 if the button was pressed during the interval; for triggers, the max value is held, and for thumbs, the point with the largest distance from the origin. Its packetId holds the id of the packet when it was last called. +-- The table fields that are populated are packetId, Left, Right, Up, Down, A, B, X, Y, LeftThumbClick, RightThumbClick, LeftTrigger, RightTrigger, LeftThumb, RightThumb, TouchPadClick (PS4 only). +-- Values for the buttons not currently pressed will be set to nil, not false. +-- @cstyle table, table XInput.GetState(controllerId). +-- @param controllerId int; ID of the controller for which to get the state. +-- @return table, table; two tables - last and current with the format described above. + +function XInput.__GetState(controllerId) +end diff --git a/CommonLua/LuaExportedDocs/Game/camera.lua b/CommonLua/LuaExportedDocs/Game/camera.lua new file mode 100644 index 0000000000000000000000000000000000000000..b8002d3152518942f3b2dffefd01034a0bb2e53f --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/camera.lua @@ -0,0 +1,407 @@ +--- Camera control functions. +-- The engine supports different camera controllers, each possibly having a different Lua interface. +-- There are several general functions that work for all cameras - these are Lock, Unlock, IsLocked and GetPos. +-- The different cameras are activated by calling the 'Activate' functions of the camera namespace, e.g. 'CameraRTS.Activate()'. +-- The only other function present in each specific camera is 'IsActive'. +-- The camera specific for each game should be activated in 'gameautorun.lua'. + +--- Returns the camera position. +-- @cstyle point camera.GetPos(). +-- @return point. + +function camera.GetPos() +end + +--- Returns the camera position. +-- @cstyle point camera.GetEye(). +-- @return point. + +function camera.GetEye() +end + +--- Returns the camera yaw in minutes. +-- @cstyle int camera.GetYaw(). +-- @return int. + +function camera.GetYaw() +end + +--- Returns the camera pitch in minutes. +-- @cstyle int camera.GetPitch(). +-- @return int. + +function camera.GetPitch() +end + +--- Returns the direction of the camera is looking. +-- @cstyle point camera.GetDirection(). +-- @return point. + +function camera.GetDirection() +end + +--- Disallows camera movement. +-- @cstyle void camera.Lock(). +-- @return void. + +function camera.Lock(view) +end + +--- Allows camera movement. +-- @cstyle void camera.Unlock(). +-- @return void. + +function camera.Unlock(view) +end + +--- Returns if the camera can move. +-- @cstyle bool camera.IsLocked(). +-- @return bool. + +function camera.IsLocked(view) +end + +--- Returns the terrain area observed by the camera (a trapeze). +-- @cstyle pt, pt, pt, pt camera.GetViewArea(). +-- @return pt, pt, pt, pt. The four corners of the camera trapeze: left_top, right_top, right_bottom, left_bottom + +function camera.GetViewArea() +end + +--- Activates the fly the camera. +-- @cstyle void cameraFly.Activate(view). +-- @return void. + +function cameraFly.Activate(view) +end + +--- Returns if the camera is active. +-- @cstyle bool camera.IsActive(). +-- @return bool. + +function cameraFly.IsActive() +end + +--- Set camera position and 'look at' point instantly. +-- @cstyle void cameraFly.SetCamera(point pos, point look_at). +-- @param pos point; new position of the camera. +-- @param look_at point; new look-at point of the camera. +-- @return void. +function cameraFly.SetCamera(pos, look_at) +end + +--- Disables the change of s_CameraFly_LeftStickMovesFlat/s_CameraFly_RightStickYMovesUpDown when LT-B/RT-B is pressed. +-- @cstyle void cameraFly.DisableStickMovesChange(). +-- @return void. +function cameraFly.DisableStickMovesChange() +end + +--- Enables the change of s_CameraFly_LeftStickMovesFlat/s_CameraFly_RightStickYMovesUpDown when LT-B/RT-B is pressed. +-- @cstyle void cameraFly.EnableStickMovesChange(). +-- @return void. +function cameraFly.EnableStickMovesChange() +end + +--- Activates the 3rd person camera. +-- @cstyle void camera3p.Activate(view). +-- @return void. + +function camera3p.Activate(view) +end + +--- Returns if the 3rd person camera is active. +-- @cstyle bool camera3p.IsActive(). +-- @return bool. + +function camera3p.IsActive() +end + +--- Changes the camera 'look at' position smoothly for the specified time. +-- @cstyle void camera3p.SetLookAt(point lookat_pos, int time). +-- @param lookat_pos point. +-- @param time int. +-- @return void. + +function camera3p.SetLookAt(lookat_pos, time) +end + +--- Changes the camera 'look at' position smoothly for the specified time; the position is in x10 resolution. +-- @cstyle void camera3p.SetLookAtPrecise(point lookat_pos, int time). +-- @param lookat_pos point. +-- @param time int. +-- @return void. + +function camera3p.SetLookAtPrecise(lookat_pos, time) +end + +--- Returns the camera 'look at' position. +-- @cstyle point camera3p.GetLookAt(). +-- @return point. + +function camera3p.GetLookAt() +end + +--- Changes the position of the camera smoothly for the specified time. +-- @cstyle void camera3p.SetEye(point eye_pos, int time). +-- @param eye_pos point. +-- @param time int. +-- @return void. + +function camera3p.SetEye(eye_pos, time) +end + +--- Changes the position of the camera smoothly for the specified time; the position is in x10 resolution. +-- @cstyle void camera3p.SetEyePrecise(point eye_pos, int time). +-- @param eye_pos point. +-- @param time int. +-- @return void. + +function camera3p.SetEyePrecise(eye_pos, time) +end + +--- Returns the camera position. +-- @cstyle point camera3p.GetEye(). +-- @return point. + +function camera3p.GetEye() +end + +--- Changes the roll of the camera smoothly for the specified time. +-- @cstyle void camera3p.SetRoll(int roll, int time). +-- @param roll int angle in minutes. +-- @param time int. +-- @return void. + +function camera3p.SetRoll(roll, time) +end + +--- Returns the camera roll in minutes. +-- @cstyle int camera3p.GetRoll(). +-- @return int. + +function camera3p.GetRoll() +end + +--- Returns the camera yaw in minutes. +-- @cstyle int camera3p.GetYaw(). +-- @return int. + +function camera3p.GetYaw() +end + + +--- Returns the camera pitch in minutes. +-- @cstyle int camera3p.GetPitch(). +-- @return int. + +function camera3p.GetPitch() +end + +--- Changes the offset of the camera 'look at' position smoothly for the specified REAL time;. +-- the offset is added to the position set by SetLookAt to calculate the final camera 'look at' position. +-- @cstyle void camera3p.SetLookAtOffset(point lookat_pos_offset, int time). +-- @usage The offsets members are intended for use in camera effects which are independent on the main camera logic(see camera shake). +-- @param lookat_pos_offset point. +-- @param time int. +-- @return void. + +function camera3p.SetLookAtOffset(lookat_pos_offset, time) +end + +--- Returns the offset of the camera 'look at' position. +-- @cstyle point camera3p.GetEye(). +-- @return point. + +function camera3p.GetLookAtOffset() +end + +--- Changes the offset of the camera position smoothly for the specified time;. +-- the offset is added to the position set by SetEye to calculate the final camera eye position. +-- @cstyle void camera3p.SetLookAtOffset(point eye_offset, int time). +-- @usage The offsets members are intended for use in camera effects which are independent on the main camera logic(see camera shake). +-- @param eye_offset point. +-- @param time int. +-- @return void. + +function camera3p.SetEyeOffset(eye_offset, time) +end + +--- Returns the offset of the camera position. +-- @cstyle point camera3p.GetEye(). +-- @return point. + +function camera3p.GetEyeOffset() +end + +--- Changes the offset of the camera roll smoothly for the specified REAL time;. +-- the offset is added to the value set by SetRoll to calculate the final camera roll angle. +-- @cstyle void camera3p.SetRollOffset(int roll_offset, int time). +-- @usage The offsets members are intended for use in camera effects which are independent on the main camera logic(see camera shake). +-- @param roll_offset int angle in minutes. +-- @param time int. +-- @return void. + +function camera3p.SetRollOffset(roll_offset, time) +end + +--- Returns the offset of the camera roll in minutes. +-- @cstyle int camera3p.GetEye(). +-- @return int. +function camera3p.GetRollOffset() +end + +--- Activates the RTS camera. +-- @cstyle void cameraRTS.Activate(view). +-- @return void. + +function cameraRTS.Activate(view) +end + +--- Returns whether the RTS camera is active. +-- @cstyle bool cameraRTS.IsActive(). +-- @return bool. + +function cameraRTS.IsActive() +end + +--- Sets camera properties from a given table; the table may contain the following fields. +-- MinHeight, MaxHeight - sets the min and max height of the camera. +-- HeightInertia - the larger the number, the faster the camera height comes to rest when changed. +-- MoveSpeedNormal, MoveSpeedFast - normal and fast camera movement speed; fast is used when Ctrl is pressed. +-- RotateSpeed - the camera rotation speed. +-- LookatDist - 2D distance from the camera position to the 'look at' point. +-- CameraYawRestore - 0 to toogle yaw restore off, 1 to toggle it on. +-- UpDownSpeed - the speed the camera moves vertically. +-- @cstyle void cameraRTS.SetProperties(view, table prop). +-- @return void. + +function cameraRTS.SetProperties(view, prop) +end + +--- Set the YawRestore flag of the camera. +-- @cstyle void cameraRTS.SetYawRestore(bool bRestore). +-- @param bRestore bool true to enable or false to disable. +-- @return void. + +function cameraRTS.SetYawRestore(bRestore) +end + +--- Return current YawRestore flag of the camera. +-- @cstyle bool cameraRTS.GetYawRestore(). +-- @return bool. + +function cameraRTS.GetYawRestore() +end + +--- Set camera position and orientation instantly or gradually over time; gradual transition requires the camera to be locked beforehand. +-- @cstyle void SetCamera(point pos, point lookat, int time). +-- @param pos point new position of the camera. +-- @param lookat point new look-at point of the camera. +-- @param time int (optional) time for adjusting the camera to the new position and look-at point. +-- @param easingType string the type of easing to use (see list in const.Easing: Linear, SinIn, SinOut, SinInOut, CubicIn, CubicOut, CubicInOut, QuinticIn, QuinticOut, QuinticInOut, etc.) +-- @return void. + +function cameraRTS.SetCamera(pos, lookat, time, easingType) +end + +--- Set PRECISELY camera position and orientation instantly or gradually over time; gradual transition requires the camera to be locked beforehand. Precisely means that position and orientation are multiplied by 1000 for some interpolation reasons(like in the camera editor rendering). The parameters are divided by 1000 right before setting them in the engine. +-- @cstyle void SetCameraPrecise(point pos, point lookat, int time). +-- @param pos; new precise(*1000) position of the camera. +-- @param lookat; new precise(*1000) look-at point of the camera. +-- @param time int (optional) time for adjusting the camera to the new position and look-at point. +-- @return void. + +function cameraRTS.SetCameraPrecise(pos, lookat, time) +end + +--- Returns camera position and 'look at' point. +-- @cstyle point,point cameraRTS.GetPosLookAt(). +-- @return point,point. + +function cameraRTS.GetPosLookAt() +end + +--- Returns camera position. +-- @cstyle point cameraRTS.GetPos(). +-- @return point. + +function cameraRTS.GetPos() +end + +--- Returns camera 'look at' point. +-- @cstyle point GetLookAt(). +-- @return point. + +function cameraRTS.GetLookAt() +end + +--- Sets mouse invertion for camera rotation; independent for x and y. +-- @cstyle void InvertMouse(bool inv_x, bool inv_y). +-- @param inv_x bool. +-- @param inv_y bool. +-- @return void. + +function cameraRTS.InvertMouse(inv_x, inv_y) +end + +--- Returns the camera minimal and maximal pitch above the ground. +-- @cstyle int, int GetPitchInterval(). +-- @return int, int; minimal and maximal pitch. + +function cameraRTS.GetPitchInterval() +end + +--- Returns the camera current height above the 'look at' position. +-- @cstyle int GetHeight(). +-- @return int. +function cameraRTS.GetHeight() +end + +--- Returns the camera current look at ditance. +-- @cstyle int GetLookatDist(). +-- @return int. +function cameraRTS.GetLookatDist() +end + +--- Returns the camera yaw in degrees (the angle around the vertical axis). +-- @cstyle int GetYaw(). +-- @return int. + +function cameraRTS.GetYaw() +end + +--- Returns the current zoom +-- @cstyle int GetZoom(). +-- @return int; curent zoom value +function cameraRTS.GetZoom() +end + +--- Changes the current zoom +-- @cstyle void SetZoom(int zoom, int time). +-- @param zoom int. +-- @param time int. +-- @return void. +function cameraRTS.SetZoom() +end + +--- Activates the 3D Studio MAX camera. +-- @cstyle void cameraMax.Activate(view). +-- @return void. + +function cameraMax.Activate(view) +end + +--- Returns if the 3D Studio MAX camera is active. +-- @cstyle bool cameraMax.IsActive(). +-- @return bool. + +function cameraMax.IsActive() +end + +--- Set camera position and 'look at' point instantly. +-- @cstyle void cameraMax.SetCamera(point pos, point look_at). +-- @param pos point; new position of the camera. +-- @param look_at point; new look-at point of the camera. +-- @return void. +function cameraMax.SetCamera(pos, look_at) +end diff --git a/CommonLua/LuaExportedDocs/Game/editor.lua b/CommonLua/LuaExportedDocs/Game/editor.lua new file mode 100644 index 0000000000000000000000000000000000000000..6df717bd98829be29203b2155eaec4aba566b912 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/editor.lua @@ -0,0 +1,79 @@ +--- Editor specific functions. +-- These are functions used in keybindings and you will generally not use them in your code. However, 'editor.GetSel' could be useful for typing debug statements in the console. + +--- Returns a list of the currently selected objects in the editor. +-- @cstyle objlist editor.GetSel(). +-- @return objlist. + +function editor.GetSel() +end + +-- @cstyle bool editor.IsSelected(CObject object). +-- @return bool. + +function editor.IsSelected(object) +end + +--- Clears the editor selection. +-- @cstyle void editor.ClearSel(). +-- @return void. + +function editor.ClearSel() +end + +--- Adds all objects contained in ol to the current selection. +-- @cstyle void editor.AddToSel(objlist ol). +-- @param ol objlist; the object list to add. +-- @return void. + +function editor.AddToSel(ol) +end + +--- Set the objects contained in the current selection. +-- @cstyle void editor.SetSel(objlist ol). +-- @param ol objlist; the object list to remain selected. +-- @return void. + +function editor.SetSel(ol) +end + +--- Changes the selection to the new one with support for undo/redo. +-- @cstyle void editor.ChangeSelWithUndoRedo(objlist sel). +-- @param sel; the new selection to be set. +-- @return void. + +function editor.ChangeSelWithUndoRedo(sel) +end + +--- Deletes the objects in the current editor selection leaving a trace in the undo/redo queue. +-- @cstyle void editor.DelSelWithUndoRedo). +-- @return void. + +function editor.DelSelWithUndoRedo() +end + +--- Clears the current editor selection leaving a trace in the undo/redo queue. +-- @cstyle void editor.ClearSelWithUndoRedo(). +-- @return void. + +function editor.ClearSelWithUndoRedo() +end + +-- Marks the start of an editor undo operation +-- @cstyle bool XEditorUndo:BeginOp(table params) +-- @param params; optional - table with flags and/or list of objects to be modified +--- params entries: +---- height = true - enables undo of the height map +---- terrain_type = true - enables undo of the terrain types +---- passability = true - enables undo of the passability +---- objects = objects - the objects at the start of the editor operation. +-- @return void. +function XEditorUndo:BeginOp(params) +end + +-- Marks the end of an editor undo operation +-- @cstyle bool XEditorUndo:EndOp(int id, table objects) +-- @param objects; optional - the objects at the end of the editor operation. +-- @return void. +function XEditorUndo:EndOp(objects) +end diff --git a/CommonLua/LuaExportedDocs/Game/realm.lua b/CommonLua/LuaExportedDocs/Game/realm.lua new file mode 100644 index 0000000000000000000000000000000000000000..c44612eb716c72e330caa45b03c175672907e310 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Game/realm.lua @@ -0,0 +1,77 @@ +--- Pathfinding-related and map folder functions. +-- This file contains many pathfinder-related utility functions, e.g. functions for finding free positions near a specified map location. +-- Another batch of functions deals with checking and finding destination locks. +-- By definition, a position (with a radius) can be destination-locked if it's passable and there are no static units or other destination locks overlapping it. +-- Destination locks (special dummy objects with radius) are used by the pathfinder to guarantee that the destinations units are heading to will not be occupied by another unit before they reach these destinations. + +--- Suspends passability updates (to reduce overhead when placing many objects in a row). Then call 'ResumePassEdits' to rebuild the passability grid. +-- @cstyle void SuspendPassEdits(). +-- @return void. + +function SuspendPassEdits() +end + +--- Resumes passability updates and rebuild the passability for the map. +-- @cstyle void ResumePassEdits(). +-- @return void. + +function ResumePassEdits() +end + +--- Returns whether passability updates are suspended. +-- @cstyle bool IsPassEditSuspended(). +-- @return bool; true if the passability updates are suspended, false otherwise. + +function IsPassEditSuspended() +end + +--- Returns whether the specified point can be destlocked. +-- @cstyle bool CanDestlock(point pt, int radius). +-- @param pt point; the point to be checked. +-- @param radius int; radius for the destlock. +-- @return bool; true if the point can be destlocked, false otherwise. + +function CanDestlock(pt, radius) +end + +--- Finds a path from and places PathNode objects at the step specified. +-- @cstyle void AddPathTrace(object this, point src, point dst, int step). +-- @param src point; the source point. +-- @param dst point; the destination point. +-- @param step int; step interval at which to place a PathNode object along the path. +-- @return void. + +function AddPathTrace(src, dst, step) +end + +--- ATTENTION!!! This function works only in 2D, and returns only points in the same Z. +--- Finds a passable point nearby the specified point or nil if one can't be found. +-- @cstyle point GetPassablePointNearby(point/object/x,y pt, int pfClass, int nMaxDist, int nMinDist, func filter). +-- @param pt/object point; center to look around for passable point. +-- @param pfClass int; optional. pathfind class +-- @param nMaxDist int; optional. max radius to look up. +-- @param nMinDist int; optional. min radius to look up. +-- @param filter func; optional. function to filter the passable points. +-- @return point; a passable point arount pt or nil if no such point exists. + +function GetPassablePointNearby(pt, pfClass, nMaxDist, nMinDist, filter) +end + +--- ATTENTION!!! This function works only in 2D, and returns only points in the same Z. +--- Finds a destlockable point nearby the specified point or nil if one can't be found. +-- @cstyle point GetDestlockablePointNearby(point pt, int radius, bool checkPassability). +-- @param pt point; center to look around for destlockable point or object. +-- @param radius int; circle radius around the center to look for destlockable point. +-- @param checkPassability bool; indicates if the destlockable point should be passable (default false). +-- @param pfclass int; pathfinder class to use (optional, default object pathfinder class or 0) +-- @return point; a destlockable point in radius around pt or nil is no such point exists. + +function GetDestlockablePointNearby(pt, radius, checkPassability, pfclass) +end + +--- Return the bounding box of the map. +-- @cstyle box GetMapBox(). +-- @return box. + +function GetMapBox() +end diff --git a/CommonLua/LuaExportedDocs/Global/AsyncOp.lua b/CommonLua/LuaExportedDocs/Global/AsyncOp.lua new file mode 100644 index 0000000000000000000000000000000000000000..dcbb66f448af520275473958f443836ace1cc42e --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/AsyncOp.lua @@ -0,0 +1,160 @@ +-- err, file = AsyncFileOpen(string filename, string mode = "r", bool create_path = false) +function AsyncFileOpen(filename, mode, create_path) +end + +-- err = AsyncFileClose(object file) +function AsyncFileClose(file) +end + +-- err = AsyncFileWrite(object file, string data, int offset = -2, bool flush = false) +-- data can be a string or a table of strings +-- offset -1 means write at end of file +-- offset -2 means use file pointer +function AsyncFileWrite(file, data, offset, flush) +end + +-- err, data = AsyncFileRead(object file, int count = -1, int offset = -2, string mode = "string") +-- offset -2 means use file pointer +-- mode can be "string", "lines" - data is a table with lines, or "hash" which returs a hash string 1/1000 of the read part +function AsyncFileRead(file, count, offset, mode) +end + +-- err = AsyncFileFlush(object file) +function AsyncFileFlush(file) +end + +-- err = AsyncStringToFile(string filename, string data, offset = -2, timestamp = 0, compression = "none") +-- data can be a string or a table of strings +-- offset = -1 means append the file +-- offset = -2 means overwrite the entire file +-- sets the modification time of the file to timestamp +-- compression can be "none", "zlib", "lz4", "lz4hc", "zstd"; it is applied only when overwriting the entire file (offset = -2) +function AsyncStringToFile(filename, data, offset, timestamp, compression) +end + +-- err, data = AsyncFileToString(string filename, int count = -1, int offset = 0, string mode = "", bool raw = false) +-- mode can be "string", "lines" - data is a table with lines, "hash" which returns a hash string 1/1000 of the read part, "pstr" or "compress" +-- raw = true means do not decompress +function AsyncFileToString(filename, count, offset, mode, raw) +end + +-- err, idx = AsyncStringSearch(string str_data, string str_to_find, bool case_insensitive = false, bool match_whole_word = false) +function AsyncStringSearch(str_data, str_to_find, case_insensitive, match_whole_word) +end + +-- err = AsyncCopyFile(string src, string dst, string mode = nil) +-- mode can be nil, "zlib" or "raw" +function AsyncCopyFile(src, dst, mode) +end + +-- err = AsyncMountPack(string mount_path, string pack, string options = "final", string label, int mem = 0) +-- options is a string which can contain any of the following: +-- - in_mem - load the packfile in memory (equivalent to mem = -1) +-- - create - create and mount an empty packfile (includes write) +-- - write - mount the packfile writable +-- - compress - create a compressed packfile (useful only in combination with create) +-- - final - stops searching lower priority paths for paths matching the mount path +function AsyncMountPack(mount_path, pack, options, label, mem) +end + +-- err = AsyncUnmount(path) +function AsyncUnmount(path) +end + +-- err, exitcode, stdout, stderr = AsyncExec(string cmd, string working_dir = "", bool hidden = false, bool capture_output = false, string priority = "normal", int timeout = 0) +function AsyncExec(cmd, working_dir, hidden, capture_output, priority, timeout) +end + +-- err, result = AsyncWebRequest(params) +-- params entries: +--- string url +--- string method = "GET" +--- table vars = {} +--- table files = {} +--- table headers = {} +--- string body = "" +--- int max_response_size = 1024*1024 +--- bool pstr_response = false +-- returns err, response +function AsyncWebRequest(params) +end + +-- err, files = AsyncListFiles(string path = "", string mask = "*", string mode = "") +-- mode can include: +-- "recursive" for recursive enumeration +-- "folders" to return folders only instead of files +-- "attributes" to have the attributes of each file in files.attributes +-- "size" to have the size of each file in files.size +-- "modified" to have a UNIX style modification timestamp of each file in files.modified +-- "relative" to return file paths relative to the search path +function AsyncListFiles(path, mask, mode) +end + +-- err = AsyncCreatePath(string path) +function AsyncCreatePath(path) +end + +-- err = AsyncFileDelete(string path) +function AsyncFileDelete(path) +end + +-- err = AsyncPack(packfile, folder, index_table, params_table) +function AsyncPack(packfile, folder, index_table, params_table) + +end + +-- err, files = AsyncUnpack(string packfile, string dest = ".") +function AsyncUnpack(packfile, dest) +end + +-- err, info = AsyncUnpack(string path, string rev_type = "", string query_key = "") +function AsyncGetSourceInfo(path, rev_type, query_key) +end + +-- err = AsyncPlayStationSaveFromMemory(savename, displayname) +function AsyncPlayStationSaveFromMemory(savename, displayname) +end + +-- err = AsyncPlayStationLoadToMemory(savename) +function AsyncPlayStationLoadToMemory(savename) +end + +-- err = AsyncPlayStationSaveDataDelete(mountpoint) +function AsyncPlayStationSaveDataDelete(mountpoint) +end + +--err, list = AsyncPlayStationSaveDataList() +function AsyncPlayStationSaveDataList() +end + +--err, list = AsyncPlayStationSaveDataTotalSize() +function AsyncPlayStationSaveDataTotalSize() +end + +--err, list = AsyncPlayStationGetUnlockedTrophies() +function AsyncPlayStationGetUnlockedTrophies() +end + +--err, platinum_unlocked = AsyncPlayStationUnlockTrophy(id) +function AsyncPlayStationUnlockTrophy(id) +end + +--err, auth_code = AsyncPSNGetAppTicket() +function AsyncPSNGetAppTicket() +end + +--err, auth_code, auth_issuer_id = AsyncPlayStationGetAuthCode() +function AsyncPlayStationGetAuthCode() +end + +--err = AsyncPlayStationShowBrowserDialog() +function AsyncPlayStationShowBrowserDialog() +end + +--err = AsyncPlayStationShowFreeSpaceDialog() +function AsyncPlayStationShowFreeSpaceDialog() +end + +--err, platinum_unlocked = AsyncGetFileAttribute(string filename, string attribute) +function AsyncGetFileAttribute(filename, attribute) +end diff --git a/CommonLua/LuaExportedDocs/Global/LuaExports.lua b/CommonLua/LuaExportedDocs/Global/LuaExports.lua new file mode 100644 index 0000000000000000000000000000000000000000..9462c5dcb3d167fddf0959ed922221cd5c87efd4 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/LuaExports.lua @@ -0,0 +1,742 @@ +--- Miscelanous functions : cursor, movies, maps and etc. + +--- Crashes the +-- @cstyle void Crash(). +-- @return void. + +function Crash() +end + + +--- Return the number of rendered frames so far. +-- @cstyle int GetFrameNo(). +-- @return int. + +function GetFrameNo() +end + +--- Reset the memory stats allocator's statistics tables. +-- @cstyle void ResetMemStats(). +-- @return void. + +function ResetMemStats() +end + +--- Reset profile system and if non-empty file name is given dump there the current data. +-- @cstyle void ResetProfile(string file_name). +-- @param file_name string. +-- @return void. + +function ResetProfile(file_name) +end + +--- Sets UI mouse cursor to the one supplied; if filename is empty the application cursor is used. +-- If the UI and the application cursor are both set the UI cursor is used. +-- @cstyle void SetUIMouseCursor(string filename). +-- @param filename string. +-- @return void. + +function SetUIMouseCursor(filename) +end + +--- Sets application mouse cursor to the one supplied. +-- If the UI and the application cursor are both set the UI cursor is used. +-- @cstyle void SetAppMouseCursor(string filename). +-- @param filename string. +-- @return void. + +function SetAppMouseCursor(filename) +end + +--- Hides the mouse cursor. +-- @cstyle void HideMouseCursor(). +-- @return void. + +function HideMouseCursor() +end + +--- Shows the mouse cursor. +-- @cstyle void ShowMouseCursor(). +-- @return void. + +function ShowMouseCursor() +end + +--- Checks if the mouse cursor is hidden. +-- @cstyle bool IsMouseCursorHidden(). +-- @return true if the mouse cursor is hidden, false otherwise. + +function IsMouseCursorHidden() +end + +--- Returns the current mouse cursor. +-- @cstyle string GetMouseCursor(). +-- @return void. + +function GetMouseCursor() +end + +--- Returns the current map. +-- @cstyle string GetMap(). +-- @return string. + +function GetMap() +end + +--- Opens open-file browse dialog and let the user choose file and returns it. +-- @cstyle string OpenBrowseDialog(string initail_dir, string file_type, bool exists = true, bool multiple = false, initial_file = false). +-- @param initail_dir string; The directory the browse dialog starts browising. +-- @param file_type string; The file type the browse dialog searches for. +-- @param exists boolean; if true the user can choose only existing files, otherwise can enter new name in the editor text box; can be omitted, default value is true. +-- @return string. + +function OpenBrowseDialog(initail_dir, file_type, exists, multiple, initial_file) +end + +--- Returns the executable path. +-- @cstyle string GetExecDirectory(). +-- @return string. + +function GetExecDirectory() +end + +--- Returns the current directory. +-- @cstyle string GetCWD(). +-- @return string. + +function GetCWD() +end + +--- Copies a string into the clipboard, the limit is optional, the default is 1024 (-1 for unlimited number of chars) +-- @cstyle void CopyToClipboard(string clip, int limit). +-- @param clip string. +-- @return void. + +function CopyToClipboard(clip) +end + +--- Returns the string in the clipboard, the limit is optional, the default is 1024 (-1 for unlimited number of chars) +-- @cstyle string GetFromClipboard(int limit). +-- @return string. + +function GetFromClipboard() +end + +--- Returns all surfaces of the given type intersectning the specified box. +-- @cstyle table GetSurfaces(Box box, int type). +-- @param box box. +-- @param type int. +-- @return table; the table is integer indexed and the format is [i] = { v1, v2, v3 }, where v1, v2, v3 are points, vertices of the + +function GetSurfaces(box, type) +end + +--- Opens given address. +-- @cstyle void OpenAddress(string address). +-- @param address string; the address to open. + +function OpenAddress(name) +end + +--- Returns the length in letters of the given utf8 encoded string. +-- @cstyle int len(string utf8). +-- @param utf8 string; a utf8 encoded string. +-- @return int. + +function len(utf8) +end + +--- Returns byte offset after advancing a utf8 string with "letters" characters, starting at position pointer +-- @cstyle string Advance(utf8, pointer, letters). +-- @return int + +function Advance(utf8, pointer, letters) +end + +--- Returns byte offset after retreating a utf8 string with "letters" characters, starting at position pointer +-- @cstyle string Retreat(utf8, pointer, letters). +-- @return int + +function Retreat(utf8, pointer, letters) +end + +--- This function work for the following camera model :. +-- The camera look at point is offset on z axis from the point we wish to observe(observe point). +-- The pitch is the angle between the camera eye and the camera look at. +-- Given pitch, desired distance from eye to observe point and the distance from the look at point to the observe point,. +-- the function returns the 2d (x and y axis only) and z distances between the eye and the look at point. +-- @cstyle int, int GetCameraLH(int pitch, int dist, int dist_to_ground). +-- @param pitch int; angle between the camera look at direction and x, y plane. +-- @param dist int; distance from the eye to. +-- @return int, int; returns the 2d distance and the z distance. + +function GetCameraLH(pitch, dist, dist_to_ground) +end + +--- Intersects segment with cylinder; the base of the cylinder is parallel to the (x, y) plane and the height is parallel to the z axis. +-- @cstyle bool, point, point IntersectSegmentCylinder(point pt1, point pt2, point center, int radius, int height). +-- @param pt1 point; the segment starting point. +-- @param pt2 point; the segment ending point. +-- @param center point; the center of the base. +-- @param radius integer; radius of the base. +-- @param height integer; the height of the cylinder. +-- @return bool, point, point; returns true with the intersection points or false if no intersection exist. + +function IntersectSegmentCylinder(pt1, pt2, center, radius, height) +end + +--- Intersects line with cone; +-- @cstyle bool, point, point IntersectSegmentCylinder(point pt1, point pt2, point vertex, point height, int angle). +-- @param pt1 point; first point that the line passes through. +-- @param pt2 point; second point that the line passes throgh. +-- @param vertex point; vertex of the cone. +-- @param dir point; a point along with vertex defining axis of the cone. +-- #param height integer; height of the cone - if missing cone is infinite. +-- @param angle integer; angle of the cone in minutes. +-- @return point, point; returns 1 or 2 intersection points(they can be "-infinity" or "infinity" if ray inside cone and cone is infinite) or false if no intersection exist. + +function IntersectLineCone(pt1, pt2, vertex, dir, angle, height) +end + +--- Intersects ray with cone; +-- @cstyle bool, point, point IntersectSegmentCylinder(point pt1, point pt2, point vertex, point height, int angle). +-- @param pt1 point; the origin of the ray. +-- @param pt2 point; a point defining the direction of the ray. +-- @param vertex point; vertex of the cone. +-- @param dir point; a point along with vertex defining axis of the cone. +-- #param height integer; height of the cone - if missing cone is infinite. +-- @param angle integer; angle of the cone in minutes. +-- @return point, point; returns 1 or 2 intersection points(they secod one can be "infinity" if ray inside cone and cone is infinite) or false if no intersection exist. + +function IntersectRayCone(pt1, pt2, vertex, dir, angle, height) +end + +--- Intersects segment with cone; +-- @cstyle bool, point, point IntersectSegmentCylinder(point pt1, point pt2, point vertex, point height, int angle). +-- @param pt1 point; the segment starting point. +-- @param pt2 point; the segment ending point. +-- @param vertex point; vertex of the cone. +-- @param dir point; a point along with vertex defining axis of the cone. +-- #param height integer; height of the cone - if missing cone is infinite. +-- @param angle integer; angle of the cone in minutes. +-- @return point, point; returns 1 or 2 intersection points or false if no intersection exist. + +function IntersectSegmentCone(pt1, pt2, vertex, dir, angle, height) +end + +--- Return the distance between segment and point in 2d space. +-- @cstyle int, closestX, closestY, closestZ DistSegmentToPt(point pt1, point pt2, point pt, offset). +-- @param pt1 point; the segment starting point. +-- @param pt2 point; the segment ending point. +-- @param pt point. +-- @return int; distance in game units. + +function DistSegmentToPt2D2(pt1, pt2, pt) +end + +--- Return the intersection between two given lines in 2d space. +-- @cstyle point IntersectLineWithLine2D(point pt1, point pt2, point pt3, point pt4). +-- @param pt1 point; the first line starting point. +-- @param pt2 point; the first line ending point. +-- @param pt3 point; the second line starting point. +-- @param pt4 point; the second line ending point. +-- @return point; the intersection point if one exist or false if no intersection. + +function IntersectLineWithLine2D(pt1, pt2, pt3, pt4) +end + +--- Return the intersection between two given segment in 2d space. +-- @cstyle point IntersectSegmentWithSegment2D(point pt1, point pt2, point pt3, point pt4). +-- @param pt1 point; the first segment starting point. +-- @param pt2 point; the first segment ending point. +-- @param pt3 point; the second segment starting point. +-- @param pt4 point; the second segment ending point. +-- @return point; the intersection point if one exist or false if no intersection. + +function IntersectSegmentWithSegment2D(pt1, pt2, pt3, pt4) +end + +--- Return the intersection between given ray and segment in 2d space. +-- @cstyle point IntersectRayWithSegment2D(point origin, point dir, point pt1, point pt2). +-- @param origin point; the ray origin. +-- @param dir point; the ray direction. +-- @param pt1 point; the segment starting point. +-- @param pt2 point; the segment ending point. +-- @return point; the intersection point if one exist or false if no intersection. + +function IntersectRayWithSegment2D(origin, dir, pt1, pt2) +end + +--- Return the intersection between line with circle in 2d space. +-- @cstyle point, point IntersectLineWithCircle2D(point pt1, point pt2, point center, int radius). +-- @param pt1 point; the first line starting point. +-- @param pt2 point; the first line ending point. +-- @param center point; the center of the circle. +-- @param radius int; the radius of the circle. +-- @return point; the intersection point(s) if one exist or false if no intersection. + +function IntersectLineWithCircle2D(pt1, pt2, center, radius) +end + +--- Return the intersection between line with circle in 2d space. +-- @cstyle point, point IntersectSegmentWithCircle2D(point pt1, point pt2, point center, int radius). +-- @param pt1 point; the first segment starting point. +-- @param pt2 point; the first segment ending point. +-- @param center point; the center of the circle. +-- @param radius int; the radius of the circle. +-- @return point; the intersection point(s) if one exist or false if no intersection. + +function IntersectSegmentWithCircle2D(pt1, pt2, center, radius) +end + +--- Return the first intersected object between two points. +-- @cstyle object, point, point IntersectSegmentWithCircle2D(point pt1, point pt2[[, point offset = point(0, 0, 0)], int enum_flags_all = 0, string class = "", int enum_flags_ignore = 0, int game_flags_ignore = 0, int game_flags_any = 0, int offset_z = 0, int surf_flags = EntitySurfaces.Collision | EntitySurfaces.Walk, bool exact = true]). +-- @param pt1 point; the first segment starting point. +-- @param pt2 point; the first segment ending point. +-- @param enum_flags_all int; the object should have all of these enum flags (optional). +-- @param class string; the object's class (optional). +-- @param int enum_radius; map enum radius (optional, max object's radius by default). +-- @param enum_flags_ignore int; the object should NOT have any of these enum flags (optional). +-- @param game_flags_ignore int; the object should NOT have any of these game flags (optional). +-- @param game_flags_all int; the object should have all of these game flags (optional). +-- @param offset_z int; segment offset usefull as the object's origin is usually at the mesh bottom, which is an edge case (optional). +-- @param surf_flags int; the object should have surfaces with these flags (optional). +-- @param exact bool; specify intersection tests with higher precision (takes more time) +-- @param filter function; optional object filter +-- @return object; the intersected object. +-- @return point; the intersection point. +-- @return point; the intersection normal. + +function IntersectSegmentWithClosestObj(pt1, pt2, class, enum_radius, enum_flags_all, game_flags_all, enum_flags_ignore, game_flags_ignore, surf_flags, exact, offset_z, filter, ...) +end + +--- Checks for intersections between a polygon and a circle in 2d space. +-- @cstyle bool, point* IntersectPolyWithCircle2D(point *poly, point center, int radius). +-- @return bool; is there any intersection. +-- @return point*; table with the intersection points. + +function IntersectPolyWithCircle2D(poly, center, radius) +end + +--- Checks for intersections between two polygons in 2d space. +-- @cstyle bool IntersectPolyWithPoly2D(point *poly1, point *poly2). +-- @return bool; is there any intersection. + +function IntersectPolyWithPoly2D(poly1, poly2) +end + +--- Checks for intersections between a polygon and a spline in 2d space. +-- @cstyle bool IntersectPolyWithSpline2D(point *poly, point *spline, int width, int precision = 0.5). +-- @param poly point*; table with the polygon points. +-- @param spline point; table with the spline points. +-- @param width int; the width of the spline (should be greater of 0). +-- @param precision int; precision for the iterative check (0.5 by default). +-- @return bool; is there any intersection. + +function IntersectPolyWithSpline2D(poly, spline, width, precision) +end + +--- Return a part of the given segment that is inside the given box; at least one point must be inside the box, otherwise it will return the original segment. +-- @cstyle point, point BoundSegmentInBox(point pt1, point pt2, box box). +-- @param pt1 point; the first segment starting point. +-- @param pt2 point; the first segment ending point. +-- @param box box. +-- @return point, point. + +function BoundSegmentInBox(pt1, pt2, box) +end + +--- Writes a screenshot to the file. +-- @cstyle void WriteScreenshot(string file). +-- @param file string; target file. +-- @return void. + +function WriteScreenshot(file) +end + +--- Quits the application. +-- @cstyle void quit(). +-- @return void. +function quit() +end + +--- Checks if quit() function is in process. +-- @cstyle bool IsQuitInProcess(). +-- @return bool. +function IsQuitInProcess() +end + +--- Returns the currently logged user in utf8 string. +-- @cstyle string GetUsername(). +-- @return string the username. +function GetUsername() +end + +--- Returns memory information about a table - memory used, size of array part, size of hash part +-- @cstyle int, int, int gettablesizes(table). +-- @return int, int, int - total memory, entries in array part, entries in hash part. + +function gettablesizes(table) +end + +--- LuaVar functions + +--- Returns the value of an engine exported variable (LuaVar). +-- @cstyle value-type GetEngineVar(string name). +-- @param name; The name of the LuaVar. + +function GetEngineVar(prefix, name) +end + +--- Sets the value of an engine exported variable (LuaVar). +-- @cstyle void SetEngineVar(string name, value-type value). + +function SetEngineVar(prefix, name, value) +end + +--- Returns a table with fields that correspond to engine exported variables (LuaVars) starting with certain prefix. +-- @cstyle table EnumEngineVars(string prefix). +-- @param prefix - a prefix used to match engine exported vars (LuaVars) to table fields - a field matches when == . + +function EnumEngineVars(prefix) +end + +--- Get current time and store it. +-- @cstyle void SetPerformanceTimeMarker(). +function SetPerformanceTimeMarker() +end + +--- Add the time between calling SetPerformanceTimeMarker and this function to specified id. +-- @cstyle void PerformanceTimeAdd(int id1, int id2). +-- @param id1: the id. +-- @param id2: extra id on wich this time differens to be added (optional). +function PerformanceTimeAdd(id1, id2) +end + +--- Get the sum of all times for given id in ms. +-- @cstyle int GetPerformanceTime(int id). +-- @param id1: the id. +function GetPerformanceTime(id) +end + +--- Get min and max times for given id in ms. +-- @cstyle int, int GetPerformanceTime(int id). +-- @param id1: the id. +function GetPerformanceTimesMinMax(id) +end + +--- Set time data to zero for all id-s. +-- @cstyle void ResetPerformanceTimes(). +function ResetPerformanceTimes() +end + +--- Cancels the rendering of an upsampled screenshot and discards any accumulated data. +-- @cstyle void CancelUpsampledScreenshot(). +function CancelUpsampledScreenshot() +end + +--- Draws scaled text, which drops shadow with given properties. +-- @cstyle void StretchTextShadow(string text, box rc, [string/unsigned font], [int color], int shadow_color, int shadow_size, point shadow_dir). +-- @param text: text to draw. +-- @param rc: rectangle in which to draw the text. +-- @param font: font to use for drawing, optional (use last set font if skipped). +-- @param color: color to use for drawing, optional (use las set color if skipped). +-- @param shadow_color: color to use for dropped shadow. +-- @param shadow_size: size of the dropped shadow, in pixels. +-- @param shadow_dir: direction vector of the dropped shadow. +function StretchTextShadow(text, rc, font, color, shadow_color, shadow_size, shadow_dir) +end + +--- Draws scaled outlined text. +-- @cstyle void StretchTextOutline(string text, box rc, [string/unsigned font], [int color], int outline_color, int outline_size). +-- @param text: text to draw. +-- @param rc: rectangle in which to draw the text. +-- @param font: font to use for drawing, optional (use last set font if skipped). +-- @param color: color to use for drawing, optional (use las set color if skipped). +-- @param outline_color: color to use for drawing outline. +-- @param outline_size: size of the outline, in pixels. +function StretchTextOutline(text, rc, font, color, outline_color, outline_size) +end + +--- Returns the fullscreen mode. +-- @cstyle int FullscreenMode(). +-- @return int; the fullscreen mode (0 = windowed; 1 = borderless; 2 = exclusive). + +function FullscreenMode() +end + +--- Creates font face with string description and returns ID for this face (if ID was passed, just returns it). +-- @cstyle unsigned GetFont(char *font_description). +-- @param font_description: string description in following format ", , []". +-- @return ID. +function GetFontID(font_description) +end + +--- Returns font description for font face with given ID. +-- @cstyle char *GetFontDescription(unsigned font_id). +-- @param font_id: ID of the font (returned by GetFontID()). +-- @return string. +function GetFontDescription(font_id) +end + +--- Calculates path distances from given point to multiple destinations +-- @cstyle table GetMultiPathDistances(point origin, table destinations, [int pfClass]) +-- @param origin: the starting point +-- @param destinations: a table, containing the destination points +-- @param pfClass: pathfinder class to use (optional, default 0) +-- @return table; the +function GetMultiPathDistances(origin, destinations, pfClass) +end + +--- Forces the terrain debug (passability) draw texture to be recreated. +-- @cstyle void UpdateTerrainDebugDraw() +function UpdateTerrainDebugDraw() +end + +--- Returns object current path as a list of target points. +-- @cstyle table, bool obj:GetPath() +-- return table: list of target points, bool: path delayed or not +function GetPath() +end + +--- Returns safe sceen area rectangle +-- @cstyle rect GetSafeArea() +function GetSafeArea() +end + +--- Returns a string dump of the map objects' properties. +-- Used for saving maps. +-- @cstyle string __DumpObjPropsForSave() +function __DumpObjPropsForSave() +end + +--- Returns the first argument clamped to the range specified by the 2nd and the 3rd argument. +-- @cstyle int Clamp(int x, int a, int b) +-- @param x; input argument which will be clamped. +-- @param a; lower clamp range. +-- @param b; upper clamp range. +-- @return int; x clamped in the [a,b] range. +function Clamp() +end + +--- Returns render statistics for the last and/or current frame. Since Lua runs concurrently with the renderer, and there is no synchronization for this function, some of the three numbers might come from the current frame and some - from the previous. +-- @cstyle int, int, int GetRenderStatistics() +-- @return int dips, int tris, int vtx; number of drawcalls, Ktriangles, and Kvertices rendered in the frame. +function GetRenderStatistics() +end + +--- Reports memory fragmentation info in the debugger output +-- @cstyle void dbgMemoryAllocationTest() +-- @return void. +function dbgMemoryAllocationTest() +end + +--- Transforms a game point to a screen point of the current game camera. +-- If the point is behind the camera it's flipped first. The point is converted to screen space. The result point may lie outside window boundaries, i.e. negative coordinates and such one greater then Width, Height. +-- @cstyle bool,point GameToCamera(point pt) +-- @param pt; game point to transform. +-- @return bool, point; first return value tells whether the point was in front of the camera. The second return value is the point in screen space. +-- @see GameToCamera. +-- @see ScreenToGame. +function GameToScreen(pt) +end + +--- Transforms a 2D screen point of the current game camera to a game point. +-- The 2D point Z coordinate is considered to be camera's 0. +-- @cstyle bool/point GameToCamera(point pt) +-- @param pt; game point to transform. +-- @param precision; fixed-point multiplier (optional) +-- @return point; the transformed game point. +-- @see GameToCamera. +function ScreenToGame(pt, precision) +end + +--- Transforms a game point to a screen point of the given camera. +-- The function first checks if the point is behind the camera and returns false, otherwise it's converted to screen space. The result point may lie outside window boundaries, i.e. negative coordinates and such one greater then Width, Height. +-- @cstyle bool/point GameToCamera(point pt, point camPos, point camLookAt) +-- @param pt; game point to transform. +-- @param camPos; position of the camera. +-- @param camLookAt; point at which the camera is looking. +-- @return bool/point; if the point is behind the camera NearZ returns false, otherwise a point in screen space is returned. +-- @see GameToScreen. +function GameToCamera(pt, camPos, camLookAt) +end + +--- Returns z and the topmost walkable object at specified point. +-- @cstyle GameObject GetWalkableObject(point pt) +-- @param pt; the query point +-- @return GameObject/nil - the topmost walkable object at this point, and z +function GetWalkableObject(pt) +end + +--- Returns z and the topmost walkable object at specified point. +-- @param pt; the query point +-- @return z; the topmost walkable object at this point, if any, otherwise nil +function GetWalkableZ(pt) +end + +--- Same as pcall, but asserts pop instead of being printed out; the called function cannot Sleep. +-- @cstyle bool procall(f, arg1, ...). +-- @param f; the function to call. +-- @return bool, res1, res2, ... +function procall(f, arg1, ...) +end + +--- Same as pcall, but asserts pop instead of being printed out; the called function can Sleep. +-- @cstyle bool sprocall(f, arg1, ...). +-- @param f; the function to call. +-- @return bool, res1, res2, ... +function sprocall(f, arg1, ...) +end + +--- Print in the C debugger output window. +-- @cstyle void DebugPrint(string text) +-- @param text; the text to be printed. +-- @return void. + +function DebugPrint(text) +end + +--- Returns a table with all the textures used for the given terrain layer. +-- @cstyle table GetTerrainTextureFiles(int layer) +-- @param layer; the numeric layer id. +-- @return table; a table containing all textures related to the layer (diffuse, normal, specular). +function GetTerrainTextureFiles(layer) +end + +--- Sets a SSAO post-processing parameter to specified value +-- @cstyle void SetPostProcSSAOParam(int param, int value) +-- @param param; the id of the parameter to set (0-3) +-- @param value; the value to set +-- @return void +function SetPostProcSSAOParam(param, value) +end + +--- Returns the current value of the specified SSAO post-processing parameter +-- @cstyle int GetPostProcSSAOParam(int param) +-- @param param; the id of the parameter to set (0-3) +-- @return int; the current value of the specified parameter +function GetPostProcSSAOParam(param) +end + +--- Returns the current time represented in a specified precision +-- @cstyle int GetPreciseTicks(int param = 1000) +-- @param precision; the required precision; default value is 1000 (ms) +-- @return int; the current time +function GetPreciseTicks(precision) +end + +--- Returns the current Lua allocations count +-- @cstyle int GetAllocationsCount() +-- @return int; +function GetAllocationsCount() +end + +--- Clears all the debug vectors +-- @cstyle void DbgClearVectors() +function DbgClearVectors() +end + +--- Clears all the debug texts +-- @cstyle void DbgClearTexts() +function DbgClearTexts() +end + +--- Draw a debug vector +-- @cstyle void DbgAddVector(point origin, point vector, int color = RGB(255, 255, 255)) +function DbgAddVector(origin, vector, color) +end + +--- Draw a debug line conecting two points +-- @cstyle void DbgAddSegment(point pt1, point pt2, int color = RGB(255, 255, 255)) +function DbgAddSegment(pt1, pt2, color) +end + +--- Draw a debug spline +-- @cstyle void DbgAddSpline(point spline[4], int color = RGB(255, 255, 255) [, int point_count]) +function DbgAddSpline(spline, color, point_count) +end + +--- Draw a debug polygon +-- @cstyle void DbgAddPoly(point poly[], int color = RGB(255, 255, 255), bool dont_close = false) +function DbgAddPoly(poly, color, dont_close) +end + +--- Draw a debug terrain rectangle +-- @cstyle void DbgAddTerrainRect(box rect, int color = RGB(255, 255, 255)) +function DbgAddTerrainRect(rect, color) +end + +--- Set a default offset when drawing debug vectors +-- @cstyle void DbgSetVectorOffset(int [or point] offset) +-- @param offset; point or only Z coordinate +function DbgSetVectorOffset(offset) +end + +--- Enable/disable dbg vectors ztest +function DbgSetVectorZTest(enable) +end + +--- Draw a debug circle +-- @cstyle void DbgAddCircle(point center, int radius, int color = RGB(255, 255, 255), int point_count = -1) +function DbgAddCircle(center, radius, color, point_count) +end + +--- Draw a debug box +-- @cstyle void DbgAddBox(box box, int color = RGB(255, 255, 255)) +function DbgAddBox(box, color) +end + +--- Draw a solid triangle +-- @cstyle void DbgAddTriangle(point pt1, point pt2, point pt3, int color = RGB(255, 255, 255)) +function DbgAddTriangle(pt1, pt2, pt3, color) +end + +--- Draw a text +-- @cstyle void DbgAddText(string text, point pos, int color = RGB(255, 255, 255), string font_face = const.SystemFont, int back_color = RBGA(0, 0, 0, 0)) +function DbgAddText(text, pos, color, font_face, back_color) +end + +------------------------------------------------------------------------------------------------------------------------------------------- +--- Unregister a certificate. The function will fail if a certificate with the same name isn't registered. +-- @cstyle string CertDelete(string certificate_name). +-- @param certificate_name; name of the certificate. +-- @return string; error message. +function CertDelete(certificate_name) +end + +--- Read certificate data from a file. The function will fail if the file doesn't contain a certificate with the same name. +-- @cstyle string, string CertRead(string certificate_name, string certificate_file). +-- @param certificate_name; name of the certificate. MUST MATCH THE REAL NAME IN THE ENCRYPTED DATA! +-- @param certificate_file; name of the file containing the certificate. +-- @return string, string; error message and encrypted certificate data. +-- @see CertRegister. +function CertRead(certificate_name, certificate_file) +end + +--- Register a certificate from encrypted data. The function will fail if a certificate with the same name is already registered. +-- @cstyle string CertRegister(string certificate_name, string certificate_data). +-- @param certificate_name; name of the certificate. MUST MATCH THE REAL NAME IN THE ENCRYPTED DATA! +-- @param certificate_data; encrypted data containing the certificate obtained via CertRead. +-- @return string; error message. +-- @see CertRead. +function CertRegister(certificate_name, certificate_data) +end +------------------------------------------------------------------------------------------------------------------------------------------- + +--- Encodes a given URL and returns it. Compatible with the RFC 3986 standart +function EncodeURL(url) +end + +--- Decodes a given URL and returns it. Compatible with the RFC 3986 standart +function DecodeURL(url) +end + +--- Splits a file path into dir, name and extension (e.g. SplitPath("C:/dir/file.txt") --> "C:/dir/", "file", ".txt" +function SplitPath(file_path) +end + +---- + +function Lerp(from, to, time, interval) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/LuaSharedLib.lua b/CommonLua/LuaExportedDocs/Global/LuaSharedLib.lua new file mode 100644 index 0000000000000000000000000000000000000000..dd8d611b6118c23c9a5c316143631ee23407edb1 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/LuaSharedLib.lua @@ -0,0 +1,555 @@ +--- Miscellaneous functions : math, console, graphics, translation and etc. + +--- Prints the given text to the console +-- @cstyle void ConsolePrint(string text). +-- @param text string; the text to print. + +function ConsolePrint(text) +end + +--- Shows the given text in the the development environment (does not appear in the console log) +-- @cstyle void OutputDebugString(string text). +-- @param text string; the text to print. + +function OutputDebugString(text) +end + +--- Asynchronous random, mainly for use in async scripts. +-- @cstyle int AsyncRand(). +-- @cstyle int AsyncRand(int max). +-- @cstyle int AsyncRand(int min, int max). +-- @cstyle int AsyncRand(array arr). +-- @return int/value rand; any random, random in the interval[0, max - 1], random in the interval [min, max], OR a random element from arr. + +function AsyncRand(...) +end + +--- Asynchronous random, mainly for use in async scripts. +-- @cstyle int BraidRandom(int seed). +-- @cstyle int BraidRandom(int seed, int max). +-- @cstyle int BraidRandom(int seed, int min, int max). +-- @cstyle int BraidRandom(int seed, array arr). +-- @return int/value rand; any random, random in the interval[0, max - 1], random in the interval [min, max], OR a random element from arr. +-- @return int seed; a new seed. + +function BraidRandom(seed, ...) +end + +--- Returns the result from the xxhash algorithm performed over its arguments +-- @cstyle int xxhash(type arg1, ...). +-- @param arg can be any simple type or a userdata +-- @return int + +function xxhash(arg1, arg2, arg3, ...) +end + +--- Same as xxhash but accepts all parameter types. Tables, functions and threads are converted to memory addresses. Thus results will be different between game sessions. +-- @return int + +function xxhash_session(arg1, arg2, arg3, ...) +end + +--- Returns the absolute value of the given number. +-- @cstyle int abs(int nValue). +-- @param nValue int; the number for which to calculate the absolute value. +-- @return int; the absolute value of nValue. + +function abs(nValue) +end + +--- Returns the square root of the given number. +-- @cstyle int sqrt(int nValue). +-- @param nValue int; the number for which to calculate the square root. +-- @return int; the square root of nValue rounded to nearest integer smaller then the real square root. + +function sqrt(nValue) +end + +-- Translates a value from a linear set to a value in an exponential set with a matching start and end points, and a given exponent for t +-- @cstyle int LinearToExponential(uint value, uint exponent, uint min, uint max). +-- @param value uint; (min <= value <= max) The exponential value to be tranformed. +-- @param exponent uint; (exponent > 0) The exponent of t in the interpolation formula. +-- @param min uint; (min < max) The minimum value of both sets (start). +-- @param max uint; (max > min) The maximum value of both sets (end). +-- @return uint; + +function LinearToExponential(value, exponent, min, max) +end + +-- Reverses the translation done by LinearToExponential(). +-- @cstyle int ExponentialToLinear(uint value, uint exponent, uint min, uint max). +-- @param value uint; (min <= value <= max) The exponential value to be tranformed. +-- @param exponent uint; (exponent > 0) The exponent of t in the interpolation formula. +-- @param min uint; (min < max) The minimum value of both sets (start). +-- @param max uint; (max > min) The maximum value of both sets (end). +-- @return uint; + +function ExponentialToLinear(value, exponent, min, max) +end + +--- Returns angle given normalized from -180*60 to 180*60. +-- @cstyle int AngleNormalize(int angle). +-- @param angle int; angle to normalize in minutes. +-- @return int. + +function AngleNormalize(angle) +end + +--- Returns the arcsine of the value given divided by 4096. +-- @cstyle int sin(int nValue). +-- @param nValue int; the value is between -4096 and 4096, and represents the interval -1..1. +-- @return int; Returns the arcsine in minutes. Safe to use in synched code, does not use floats. + +function asin(nValue) +end + +--- Returns the sine of the given angle. +-- @cstyle int sin(int nAngle). +-- @param nAngle the angle in minutes for which to calculate the sine. +-- @return int; Returns the sine of angle multiplied by 4096. Safe to use in synched code, does not use floats. + +function sin(nAngle) +end + +--- Returns the cosine of the given angle. +-- @cstyle int cos(int nAngle). +-- @param nAngle int; the angle in minutes for which to calculate the cosine. +-- @return int; Returns the cosine of angle multiplied by 4096. Safe to use in synched code, does not use floats. + +function cos(nAngle) +end + +--- Returns the angle corresponding to the given tangent +-- @cstyle int atan(int y, int x). +-- @param y int; can be the y coordinate, the tangent value scaled by 4096 or a point. +-- @param x int; the x coordinate, optional +-- @return int; Returns the angle multiplied by 4096. Safe to use in synched code, does not use floats. + +function atan(mul, div) +end + +--- Rounds a number according to the provided granularity. +-- @cstyle int round(int number, int granularity). +-- @param number int; the number to round. +-- @param number granularity; the granularity to use. +-- @return int; The rounded number. + +function round(number, granularity) +end + +--- Tests if a ray intersects a sphere. +-- @cstyle bool TestRaySphere(point rayOrg, point rayDir, point sphereCenter, int sphereRadius). +-- @param rayOrg point; origin of the ray. +-- @param rayDir point; direction of the ray. +-- @param sphereCenter point; center of the sphere. +-- @param sphereRadius int; radius of the sphere. +-- @return bool; true if the ray intersects the sphere, false otherwise. + +function TestRaySphere(rayOrg, rayDir, sphereCenter, sphereRadius) +end + +--- Checks and returns the result if a ray intersects an axis aligned bounding box. +-- @cstyle bool RayIntersectsSphere(point rayOrg, point rayDir, box b). +-- @param rayOrg point; origin of the ray. +-- @param rayDir point; destination(not direction) of the ray. +-- @param b box. +-- @return point/nil; Returns the intersection point if the ray intersects the box, nil otherwise. + +function RayIntersectsAABB(rayOrg, rayDest, b) +end + +--- Checks and returns the result if a segment intersects an axis aligned bounding box. +-- @cstyle bool, point SegmentIntersectsAABB(point pt1, point pt2, box b). +-- @param pt1 point; first vertex of the segment. +-- @param pt2 point; second vertex of the segment. +-- @param b box. +-- @return bool, point; Returns true, intersection if the ray intersects the box, false otherwise. + +function SegmentIntersectsAABB(pt1, pt2, b) +end + +--- Checks and returns the result if a ray intersects a sphere. +-- @cstyle bool RayIntersectsSphere(point rayOrg, point rayDir, point sphereCenter, int sphereRadius). +-- @param rayOrg point; origin of the ray. +-- @param rayDir point; direction of the ray. +-- @param sphereCenter point; center of the sphere. +-- @param sphereRadius int; radius of the sphere. +-- @return bool, point; Returns true, intersection if the ray intersects the sphere, false otherwise. + +function RayIntersectsSphere(rayOrg, rayDir, sphereCenter, sphereRadius) +end + +--- Checks and returns the result if a segment intersects a sphere. +-- @cstyle bool, point SegmentIntersectsSphere(point pt1, point pt2, point sphereCenter, int sphereRadius). +-- @param pt1 point; first vertex of the segment. +-- @param pt2 point; second vertex of the segment. +-- @param sphereCenter point; center of the sphere. +-- @param sphereRadius int; radius of the sphere. +-- @return bool, point; Returns true, intersection if the ray intersects the sphere, false otherwise. + +function SegmentIntersectsSphere(pt1, pt2, sphereCenter, sphereRadius) +end + +--- Tests if a sphere intersects another sphere. +-- @cstyle bool SphereTestSphere(point ptCenter1, int nRadius1, point ptCenter2, int nRadius2). +-- @param ptCenter1 point; center of the first sphere. +-- @param nRadius1 int; radius of the first sphere. +-- @param ptCenter2 point; center of the second sphere. +-- @param nRadius2 int; radius of the second sphere. +-- @return bool; true if the spheres intersect, false otherwise. + +function SphereTestSphere(ptCenter11, nRadius1, ptCenter2, nRadius2) +end + +--- Tests if a axis aligned bounding box intersects sphere. +-- @cstyle bool SphereTestSphere(box b, point ptCenter1, int nRadius1). +-- @param b box. +-- @param ptCenter1 point; center of the sphere. +-- @param nRadius1 int; radius of the sphere. +-- @return bool; true if the sphere intersect the box, false otherwise. + +function AABBTestSphere(b, ptCenter1, nRadius1) +end + +--- Tests if a axis aligned bounding box intersects another axis aligned bounding box. +-- @cstyle bool AABBTestAABB(box b1, box b2). +-- @param box b1. +-- @param box b2. +-- @return bool; true if the boxes intersect, false otherwise. + +function AABBTestAABB(b, ptCenter1, nRadius1) +end + +--- Performs a Hermite spline interpolation from position p1 with tangent m1 to position p2 and tangent m2. +-- @cstyle point/int HermiteSpline(point/int p1, point/int m1, point/int p2, point/int m2, int t, int scale = 65536). +-- @param p1 point/int; start control point. +-- @param m1 point/int; tangent at the start control point. +-- @param p2 point/int; end control point. +-- @param m2 point/int; tangent at the end control point. +-- @param t int; weighting factor between [0,scale]. +-- @param scale int; factor scale, 65536 by default. +-- @return point/int; the interpolated point between control points according to t. + +function HermiteSpline(p1, m1, p2, m2, t, scale) +end + +--- Performs a Catmull-Rom spline interpolation using the 4 control points. +-- @cstyle point CatmullRomSpline(point p1, point p2, point p3, point p4, int t, int scale = 65536). +-- @param point p1; start control point. +-- @param point p2; second control point. +-- @param point p3; third control point. +-- @param point p4; fourth control point. +-- @param int t; weighting factor between [0,scale]. +-- @param scale int; factor scale, 65536 by default. +-- @return point; the interpolated point between control points according to t. + +function CatmullRomSpline(p1, p2, p3, p4, t, scale) +end + +--- Returns bitwise AND of its arguments. +-- @cstyle int band(int n1, int n2, ...). +-- @param n1 int; +-- @return int; bitwise AND of the arguments. + +function band(n1, n2, ...) +end + +--- Returns bitwise OR of its arguments. +-- @cstyle int bor(int n1, int n2, ...). +-- @param n1 int; +-- @return int; bitwise OR of the arguments. + +function bor(n1, n2, ...) +end + +--- Returns bitwise XOR of its arguments. +-- @cstyle int bxor(int n1, int n2, ...). +-- @param n1 int; +-- @return int; bitwise XOR of the arguments. + +function bxor(n1, n2, ...) +end + +--- Returns bitwise NOT of its argument. +-- @cstyle int bnot(int n). +-- @param n int; +-- @return int; bitwise NOT of the argument. + +function bnot(n) +end + +--- Returns (flags & ~mask) | (value & mask). +-- @cstyle int maskset(int flags, int mask, int value). +-- @param flags int; +-- @param mask int; +-- @param value int; +-- @return int; + +function maskset(flags, mask, value) +end + +--- Logical left or right shift (not arithmetic). For right shift use negative count. +-- @cstyle unsigned int shift(unsigned int value, int count). +-- @param value unsigned int; +-- @param count int; +-- @return unsigned int; Returns (count > 0 ? (value << count) : (value >> -count)). + +function shift(value, shift) +end + +--- Returns whether any bits present in mask are present in flags. (bitwise and) +-- @cstyle bool IsFlagSet(int flags, int mask). +-- @param flags int; +-- @param mask int; the bits(s) to be tested. +-- @return bool; true if any of the bit(s) are set in the flags. +-- @see SetFlag. + +function IsFlagSet(flags, mask) +end + +--- Returns the less of the integers. +-- @cstyle int Min(int i1, int i2). +-- @param i1 int; the first number. +-- @param i2 int; the second number. +-- @return int; the smaller of i1 and i2. + +function Min(i1, i2) +end + +--- Returns the greater of the integers. +-- @cstyle int Max(int i1, int i2). +-- @param i1 int; the first number. +-- @param i2 int; the second number. +-- @return int; the bigger from i1 and i2. + +function Max(i1, i2) +end + +--- Returns the min & max of all provided parameters +function MinMax(i1, i2, ...) +end + +--- Get the red, green and blue components from a RGB color variable. +-- @cstyle int, int, int GetRGB(int argb). +-- @param argb int; a RGB color variable. +-- @return int, int, int; red, green, blue triple of the RGB component. + +function GetRGB(argb) +end + +--- Get the red, green, blue and alpha components from a RGBA color variable. +-- @cstyle int, int, int, int GetRGBA(int argb). +-- @param argb a RGBA color variable. +-- @return int, int, int, int; red, green, blue, aplha four of the RGBA component. + +function GetRGBA(argb) +end + +--- Set the red component of a RGB color variable. +-- @cstyle int SetR(int argb, int r). +-- @param argb int; RGB color variable for which to set the red component. +-- @param r int; value of the red component. +-- @return int; RGB color variable with the new red component. + +function SetR(argb, r) +end + +--- Set the green component of a RGB color variable. +-- @cstyle int SetG(int argb, int g). +-- @param argb int; RGB color variable for which to set the green component. +-- @param g int; value of the green component. +-- @return int; RGB color variable with the new green component. + +function SetG(argb, g) +end + +--- Set the blue component of a RGB color variable. +-- @cstyle int SetB(int argb, int b). +-- @param argb int; RGB color variable for which to set the blue component. +-- @param b int; value of the blue component. +-- @return int; RGB color variable with the new blue component. + +function SetB(argb, b) +end + +--- Set the alpha component of a RGBA color variable. +-- @cstyle int SetA(int argb, int a). +-- @param argb int; RGBA color variable for which to set the alpha component. +-- @param a int; value of the alpha component. +-- @return int; RGBA color variable with the new alpha component. + +function SetA(argb, b) +end + +--- Combines r, g and b color channels into a single number used wherever an int rgb parameter is needed. +-- @cstyle int RGB(int r, int g, int b). +-- @param r int; intensity of the red component. +-- @param g int; intensity of the green component. +-- @param b int; intensity of the blue component. +-- @return int; RGB color variable with the corresponding components set. + +function RGB(r, g, b) +end + +--- Combines r, g, b and a color channels into a single number used wherever an int rgba parameter is needed. +-- @cstyle int RGBA(int r, int g, int b, int a). +-- @param r int; intensity of the red component. +-- @param g int; intensity of the green component. +-- @param b int; intensity of the blue component. +-- @param a int; intensity of the alpha component. +-- @return int; RGBA color variable with the corresponding components set. + +function RGBA(r, g, b, a) +end + +--- Interpolates linearly rgb0 to rgb1 as p goes from 0 to q. +-- @cstyle int InterpolateRGB(int rgb0, int rgb1, int p, int q). +-- @param rgb0 int; the starting RGB color variable. +-- @param rgb1 int; the final RGB color variable. +-- @param p int; the numerator. +-- @param q int; the divisor. +-- @return int; RGB color variable which is rgb0 + (p / q) * (rgb1 - rgb0). + +function InterpolateRGB(rgb0, rgb1, p, q) +end + +--- Compose a random opaque color with the given luminosity and maximum saturation level. +-- @cstyle int RandColor(int hue_seed = AsyncRand(), int lum_seed = AsyncRand()) +-- @param hue_seed int. The random seed for hue (random number by default) +-- @param lum_seed int. The random seed for luminosity (random number by default) +-- @return int; RGB color. + +function RandColor(hue_seed, lum_seed) +end + +--- Get the color distance between two colors. +-- @cstyle int ColorDiff(int col1, int col2) +-- @return int; color dist. + +function ColorDist(col1, col2) +end + +--- Gets the current language used in the +-- @cstyle string GetLanguage(). +-- @return sting; the language currently used by the + +function GetLanguage() +end + +--- Returns the current system tick count. +-- @cstyle int GetClock(). +-- @return int. + +function GetClock() +end + +--- Returns v * m / d calculated with 64 bit integers. Truncates the result similar to plain division. Works on a point or a box as well. +-- @cstyle int/point MulDivTrunc(int/point/box v, int m, int d). +-- @param v int/point/box. +-- @param m int. +-- @param d int. +-- @return int. + +function MulDivTrunc(v, m, d) +end + +--- Returns v * m / d ROUNDED to the nearest integer, calculated with 64 bit integers as the C function MulDiv. Works on a point or a box as well. +-- @cstyle int/point MulDivRound(int/point/box v, int m, int d). +-- @param v int/point/box. +-- @param m int. +-- @param d int. +-- @return int. + +function MulDivRound(v, m, d) +end + +--- Returns m / d ROUNDED to the nearest integer +-- @cstyle int DivRound(int m, int d). +-- @param m int. +-- @param d int. +-- @return int. + +function DivRound(m, d) +end + +-- @cstyle bool IsPowerOf2(int v). +function IsPowerOf2(v) +end + +--- Checks if the file creation date is older than specified days. +-- @cstyle bool FileAgeOlderThanDays(string filename, int days). +-- @param filename; string with the file to be checked. +-- @param days; specifies the period in days to check. +-- @return bool; false if the file was created before given days, true otherwise or if error occured during checking. + +function FileAgeOlderThanDays(filename, days) +end + +--- Check presence of internet connection. +-- @cstyle bool IsThereInternetConnection(). +-- @return bool; true if internet connection exists, false otherwise. + +function IsThereInternetConnection() +end + +--- Ends antialiased and/or motion blurred screenshot +-- @cstyle void EndAAMotionBlurScreenshot(string filename, int samples) +-- @param filename; the target filename to save the screenshot +-- @param samples; the total number of samples added for this screenshot +function EndAAMotionBlurScreenshot(filename, samples) +end + +--- Check the class of an object +-- @cstyle bool IsKindOf(table object, string class). +-- @return bool; +function IsKindOf(object, class) +end + +--- Returns the first object from a given class in a list +-- @cstyle object FindFirstIsKindOf(table objects, string class). +-- @return object; +function FindFirstIsKindOf(objects, class) +end + +function ConvertToOSPath(game_path) +end + +function PlaneFromPoints(pt1, pt2, pt3, local_space) +end + +--- Returns a value computed after following a list of instructions +-- @cstyle any compute(any initial_value, any instruction, ...). +-- @param initial_value; the initial value to start from. Returned if no instruction is given. +-- @param instruction; depending on the type of instruction: +-- type string, number, boolean: return compute(value[instruction], ...) +-- type table: return compute(instruction[value], ...) +-- type function: return instruction(value, ...) +-- any other type: return value +-- @return any; +function compute(value, instruction, ...) +--[[ + if type(instruction) == "string" or type(instruction) == "number" or type(instruction) == "boolean" then + if type(value) == "table" then + return compute(value[instruction], ...) + end + return + elseif type(instruction) == "function" then + return instruction(value, ...) + elseif type(instruction) == "table" then + return compute(instruction[value], ...) + end + return value +]] +end + +--- Pseudo-random permutation generator based on prime number addition. Can generate only a small set of all permutations. +-- Usage: for _, n in permute(size, seed) do ... end +-- The value of n is in the range (1 .. size) in a random permutation (each value will be returned exacly once) order. +-- The value of _ is for internal use. +-- @cstyle void permute(int size, int/string/nil seed). +-- @param size - permutes the integers in the range (1 .. size) +-- @param int seed - initial seed, which defines the permutation order +-- @param string seed - InteractionRand(nil, seed) is used to get the seed +-- @param nil seed - AsyncRand() is used to get the seed +function permute(size, seed) +end diff --git a/CommonLua/LuaExportedDocs/Global/MultiUser.lua b/CommonLua/LuaExportedDocs/Global/MultiUser.lua new file mode 100644 index 0000000000000000000000000000000000000000..9dd1f2d026d9b5d5b4d7dc0263571b7e716ed90a --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/MultiUser.lua @@ -0,0 +1,14 @@ +--- MultiUser functions. + +--- Sets the name and country for a user and additional 3 vector data of strings. +-- @cstyle void MultiUser.SetUser(int index, string name, string country, vector data1, vector data2, vector data3). +-- @param index int; the index of the user. +-- @param name string; the name of the user. +-- @param country string; country of the user. +-- @param data1 vector; additional string vector data to set. +-- @param data2 vector; additional string vector data to set. +-- @param data3 vector; additional string vector data to set. +-- @return void. + +function MultiUser.SetUser(index, name, country, data1, data2, data3) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/Sound.lua b/CommonLua/LuaExportedDocs/Global/Sound.lua new file mode 100644 index 0000000000000000000000000000000000000000..1ba5a7599c041331722bafd91323c054a48b1c3e --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/Sound.lua @@ -0,0 +1,103 @@ +--- Plays the specified sound. If sound is a file name (without extension) then type must be specified. +-- @cstyle int PlaySound(string sound, [string type,] int volume -1, int fade_time = 0, bool looping = false, point/object source, int loud_distance). +-- @param sound string; either a sound file name or a sound bank. +-- @param type string; sound type. used if sound is a file name. if sound is a sound bank it can be omitted. +-- @param volume int; specifying the volume of the sound between 0 and const.MaxVolume (default is used the sound bank volume). +-- @param fade_time int; the cross-fade time if changing the sound state. +-- @param looping bool; specifies if the sound should be looping. (Use the sound bank flag by default). +-- @param source point/object; specifies the sound source (if any) which can be a point or an object. +-- @param loud_distance int; if playing a file name with source specifies the radius where the sound is at maximum volume. +-- @return handle, err. + +function PlaySound(sound, _type, volume, fade_time, looping, point_or_object, loud_distance, time_offset, loop_start, loop_end) +end + +--- Stops a sound. +-- @cstyle void StopSound(int handle) +-- @param handle; handle of the sound. +-- @return void. +function StopSound(handle) +end + +--- Returns true if a sound is valid and playing. +-- @cstyle bool IsSoundPlaying(int handle) +-- @param handle; handle of the sound. +-- @return bool. +function IsSoundPlaying(handle) +end + +--- Returns true if a sound, a sound bank or an object sound is looping. +-- @cstyle bool IsSoundLooping(int handle/object/sound-bank) +-- @param handle; handle of the sound. +-- @return bool. +function IsSoundLooping(handle_obj_bank) +end + +--- Returns the duration of a sound handle, sound sample, sound bank or an object sound. +-- @cstyle int GetSoundDuration(int handle/sample/sound-bank/object) +-- @param handle; handle of the sound. +-- @return int. +function GetSoundDuration(handle_sample_obj) +end + +--- Changes the volume of a sound. +-- @cstyle void SetSoundVolume(int handle, int volume, int time = 0). +-- @param handle; handle of the sound. +-- @param volume int; volume between -1 and 1000. -1 means destroy. +-- @param time int; interpolation time, 0 by default. +-- @return void. +function SetSoundVolume(handle, volume, time) +end + +--- Returns the current volume of a sound handle from 0 to 1000. +-- @cstyle int GetSoundVolume(int handle, int volume, int time = 0). +-- @param handle; handle of the sound. +-- @return int. +function GetSoundVolume(handle) +end + +-- XAudio params: https://msdn.microsoft.com/en-us/library/windows/desktop/microsoft.directx_sdk.xaudio2.xaudio2fx_reverb_parameters(v=vs.85).aspx + +--- Sets the sound reverberation parameters to be interpolated. +-- Supported parameters: +-- DryLevel: Mix level of dry signal in output in mB. Ranges from -10000 to 0. Default is 0. +-- Room: Room effect level at low frequencies in mB. Ranges from -10000 to 0. Default is 0. +-- RoomHF: Room effect high-frequency level re. low frequency level in mB. Ranges from -10000 to 0. Default is 0. +-- RoomRolloffFactor: Like DS3D flRolloffFactor but for room effect. Ranges from 0 to 1000. Default is 1000 +-- DecayTime: Reverberation decay time at low-frequencies in milliseconds. Ranges from 100 to 20000. Default is 1000. +-- DecayHFRatio : High-frequency to low-frequency decay time ratio. Ranges from 10 to 200. Default is 50. +-- ReflectionsLevel : Early reflections level relative to room effect in mB. Ranges from -10000 to 1000. Default is -10000. +-- ReflectionsDelay : Delay time of first reflection in milliseconds. Ranges from 0 to 300. Default is 20. +-- ReverbLevel: Late reverberation level relative to room effect in mB. Ranges from -10000 to 2000. Default is 0. +-- ReverbDelay: Late reverberation delay time relative to first reflection in milliseconds. Ranges from 0 to 100. Default is 40. +-- Diffusion: Reverberation diffusion (echo density) in percent. Ranges from 0 to 100. Default is 100. +-- Density: Reverberation density (modal density) in percent. Ranges from 0 to 100. Default is 100. +-- HFReference: Reference high frequency in Hz. Ranges from 20 to 20000. Default is 5000. +-- RoomLF: Room effect low-frequency level in mB. Ranges from -10000 to 0. Default is 0. +-- LFReference: Reference low-frequency in Hz. Ranges from 20 to 1000. Default is 250. +-- @cstyle void SetReverbParameters(table params, int time) +-- @param params; A string=int table with the above parameters. +-- @param time; A time over which the parameters will be linearly interpolated from their current values to the ones specified, in ms. +function SetReverbParameters(params, time) +end + +--- Retrieves the sound reverberation parameters valid at the moment +-- @cstyle table GetReverbParameters() +-- @return table; a string=int table with the current reverb params - see SetReverbParameters for a list. +function GetReverbParameters() +end + +--- Append PCM samples to a stream, or create a new one if not existing +-- @cstyle string, int AppendStream(int handle, pstr pstr, string sound_type = "", int samples_per_sec = 48000, int bits_per_sample = 16, int channels = 1, int max_silence = 0, int fade_time = 0, int volume = 1000). +-- @param handle int; handle of the sound. +-- @param pstr pstr; PCM samples in pstr format. +-- @param sound_type string; sound type name. +-- @param samples_per_sec int; samples per second (default 48000). +-- @param bits_per_sample int; bits per sample (default 16). +-- @param channels int; channels count (default 1). +-- @param max_silence int; maximum silence time in ms before the sound is auto stoped (default 0, means "keep always alive"). +-- @param fade_time int; time in ms to establish the specified volume (default 0). +-- @param volume int; volume between 0 and 1000 (default 1000, means max volume). +-- @return err string, handle int; error and sound handle +function AppendStream(handle, pstr, sound_type, samples_per_sec, bits_per_sample, channels, max_silence, fade_time, volume) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/Spline.lua b/CommonLua/LuaExportedDocs/Global/Spline.lua new file mode 100644 index 0000000000000000000000000000000000000000..d4afc87c9c2c85da66c1e51a11b8cfe018cf1ee1 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/Spline.lua @@ -0,0 +1,77 @@ +--- Compute a min curvature spline specified by starting and ending point and direction. +-- Returns the control points of the resulting spline +-- @cstyle point[4] BS3_GetMinCurveSplineParams2D(point pos_start, point dir_start, point pos_end, point dir_end). +-- @param pos_start; start point. +-- @param dir_start; start direction. +-- @param pos_end; end point. +-- @param dir_end; end direction. +-- @return pt1,pt2,pt3,pt4 +function BS3_GetMinCurveSpline2D(pos_start, dir_start, pos_end, dir_end) +end + +--- Compute the minimum distance between two splines +-- Returns the distance found and the corresponding points from the two splines +-- @cstyle int, point, point, int BS3_GetSplineToSplineDist2D(point spline1[4], point spline2[4], int precision = guim/10). +-- @param spline1; first spline +-- @param spline2; second spline +-- @param precision; requested precision (min dist to be considered as 0) +-- @return dist, pt1, pt2 +function BS3_GetSplineToSplineDist2D(spline1, spline2, precision) +end + +--- Compute the minimum distance between a spline and a line +-- Returns the distance found and the corresponding points from the spline and the line +-- @cstyle int, point, point, int BS3_GetSplineToLineDist2D(point spline[4], point start_point, point end_point, int precision = guim/10). +-- @param spline; spline +-- @param start_point; line starting point +-- @param end_point; line ending point +-- @param precision; requested precision (min dist to be considered as 0) +-- @return dist, pt1, pt2 +function BS3_GetSplineToLineDist2D(spline, start_point, end_point, precision) +end + +--- Compute the minimum distance between a spline and a point +-- Returns the distance found and the corresponding point and coef from the spline +-- @cstyle int, point, int BS3_GetSplineToPointDist2D(point spline[4], point pos, int precision = guim/10). +-- @param spline; spline +-- @param pos; position +-- @param precision; requested precision (min dist to be considered as 0) +-- @return dist, pos, coef +function BS3_GetSplineToPointDist2D(spline, pos, precision) +end + +--- Compute the minimum distance between a spline and a circle +-- Returns the distance found, the correspondings points and the coef from the spline +-- @cstyle int, point, point, int BS3_GetSplineToCircleDist2D(point spline[4], point center, int radius, int precision = guim/10). +-- @param spline; spline +-- @param center; circle center +-- @param radius; circle radius +-- @param precision; requested precision (min dist to be considered as 0) +-- @return dist, pos1, pos2, coef +function BS3_GetSplineToCircleDist2D(spline, center, radius, precision) +end + +--- Estimate the spline length +-- @cstyle int BS3_GetSplineLength2D(point spline[4], int iterations = 20). +-- @param spline; spline +-- @param iterations; number of iterations +-- @return length +function BS3_GetSplineLength2D(spline, iterations) +end + +--- Estimate if the spline length is shorter than a given length, and if so returns the new shorter length +-- @cstyle bool, int BS3_GetSplineLength2D(point spline[4], int length, int iterations = 20). +-- @param spline; the spline +-- @param length; the length to compare width +-- @param iterations; number of iterations +-- @return shorter_length; int +function BS3_IsSplineShorter2D(spline, length, iterations) +end + +--- Check if the spline could be considered a line. +-- @cstyle bool BS3_IsSplineLinear2D(point spline[4], int max_angle = 1*60). +-- @param spline; spline +-- @param max_angle; max angle in minutes allowed so that the spline is stil considered linear +-- @return bool +function BS3_IsSplineLinear2D(spline, max_angle) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/UIL.lua b/CommonLua/LuaExportedDocs/Global/UIL.lua new file mode 100644 index 0000000000000000000000000000000000000000..874a6004af05a43254d26a4f4ee8a1d85e985234 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/UIL.lua @@ -0,0 +1,52 @@ +--- UIL functions. + +--- Draws a line between the two points using the specified or the default color. +-- @cstyle bool UIL.DrawLine(point pt1, point pt2, int color). +-- @param pt1 point; one end of the line segment. +-- @param pt2 point; the other end of the line segment. +-- @param color int; optional, color to use for drawing; default color will be used if not specified. +-- @return bool; true if the key is pressed; false otherwise. + +function UIL.DrawLine(pt1, pt2, color) +end + +--- Draws the specified texture in the specified screen rectangle with specified color modifier. The +-- @cstyle void UIL.DrawTexture(int id, box rc, int color). +-- @param id; the id of the texture (see ResourceManager.GetResourceID). +-- @param rc; the screen rectangle to draw at. +-- @param color; (optional) color modifier to use for drawing. +-- @return void. +function UIL.DrawTexture(id, rc, color) +end + +--- Push a modifier on the modifiers stack (to be called only during UIL redraw). Any draw +-- primitive is affected by all modifiers that are on the stack at the time it is issued. +-- Parameter table for interpolations can have the following values: +-- - number type - interpolation type (const.intRect, const.intRotate, etc.) +-- - number start, duration - start time and duration (>= 0) +-- - number flags - a combination of flags, see values starting with const.intf (const.intfInverse, const.intfLooping, etc.) +-- - number easing - see values in const.Easing +-- - box originalRect, targetRect - define offset and scale (if type is const.intRect) +-- - point center; number startAngle, endAngle - rotation center and start/end angle in arc-minutes (360*60) (if type is const.intRotate); note that rotations do not stack - only the topmost is applied +-- - number startValue, endValue - start/end alpha, color or desaturation (if type is const.intAlpha, const.intColor or const.intDesaturation) +-- Parameter table for shader modifier can have the following values: +-- - string shader_pass - additional shader pass +-- - number param1,param2,param3,param4 - parmas for shader +-- @cstyle int UIL.PushModifier(table params). +-- @param params; table with modifier parameters. +-- @return int; if successful id of the previous topmost modifier or nil. +function UIL.PushModifier(params) +end + +--- Returns the index of the interpolation on the stack top. +-- @cstyle void UIL.ModifiersGetTop(). +-- @return int; the index of the modifier at the stack top. +function UIL.ModifiersGetTop() +end + +--- Pop all modifiers from the stack until the one with the provided index. The index used should be returned by PushModifier() or ModifiersGetTop(). +-- @cstyle void UIL.InterpolationSetTop(int index). +-- @param index; interpolation index to remain on the stack top. +-- @return void. +function UIL.ModifiersSetTop(index) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/box.lua b/CommonLua/LuaExportedDocs/Global/box.lua new file mode 100644 index 0000000000000000000000000000000000000000..4f0fcdd4da12a4e391ba5cb72fdd813604cae31e --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/box.lua @@ -0,0 +1,294 @@ +--- Box functions. + +--- Returns a 3D box constrained by the specified values; if minz and maxz omitted a 2D box(rect) is returned. +-- @cstyle box box(int minx, int miny, int minz, int maxx, int maxy, int maxz). +-- @param minx integer X coordinate of the lower left corner of the box. +-- @param miny integer Y coordinate of the lower left corner of the box. +-- @param minz integer Z coordinate of the lower left corner of the box; can be omitted together with maxz. +-- @param maxx integer X coordinate of the upper right corner of the box. +-- @param maxy integer Y coordinate of the upper right corner of the box. +-- @param maxz integer Z coordinate of the upper right corner of the box; can be omitted together with minz. +-- @return box. + +function box(minx, miny, minz, maxx, maxy, maxz) +end + +--- Returns a 3D box given the one of the box diagonals; if z1 and z2 omitted then a 2d box is returned; accepts also points as parameters. +-- @cstyle box box(int x1, int y1, int z1, int x2, int y2, int z2). +-- @cstyle box box(int x1, int y1, int x2, int y2). +-- @cstyle box box(point p1, point p2). +-- @return box. + +function boxdiag(x1, y1, z1, x2, y2, z2) +end + +--- Creates a box box described by origin and size vectors (points). +-- @cstyle box sizebox(point min, point size). +-- @cstyle box sizebox(point min, int width, int height). +-- @cstyle box sizebox(int left, int top, point size). +-- @param min point lower left corner of the box. +-- @param size point size vector of the box. +-- @return box. + +function sizebox(...) +end + +--- Rerurns the min point of the box. +-- @cstyle point box::min(box b). +-- @return point. + +function box:min() +end + +--- Returns the max point of the box. +-- @cstyle poin box::max(box b). +-- @return point. + +function box:max() +end + +--- Returns X coordinate of the min point of the box. +-- @cstyle int box::minx(box b). +-- @return int. + +function box:minx() +end + +--- Returns Y coordinate of the min point of the box. +-- @cstyle int box::miny(box b). +-- @return int. + +function box:miny() +end + +--- Returns Z coordinate of the min point of the box. +-- @cstyle int box::minz(box b). +-- @return int or nil(if the box is 2D). + +function box:minz() +end + +--- Returns X, Y, Z coordinate of the min point of the box. +-- @return int, int, int or nil(if the box is 2D). + +function box:minxyz() +end + +--- Returns X coordinate of the max point of the box. +-- @cstyle int box::maxx(box b). +-- @return int. + +function box:maxx() +end + +--- Returns Y coordinate of the max point of the box. +-- @cstyle int box::maxy(box b). +-- @return int. + +function box:maxy() +end + +--- Returns Z coordinate of the max point of the box. +-- @cstyle int box::maxz(box b). +-- @return int or nil(if the box is 2D). + +function box:maxz() +end + +--- Returns X, Y, Z coordinate of the max point of the box. +-- @return int, int, int or nil(if the box is 2D). + +function box:maxxyz() +end + +--- Returns the X size of the box. +-- @cstyle int box::sizex(box b). +-- @return int. + +function box:sizex() +end + +--- Returns the Y size of the box. +-- @cstyle int box::sizey(box b). +-- @return int. + +function box:sizey() +end + +--- Returns the Z size of the box. +-- @cstyle int box::sizez(box b). +-- @return int or nil(if the box is 2D). + +function box:sizez() +end + +--- Returns the X, Y, Z size of the box. +-- @return int, int, int or nil(if the box is 2D). + +function box:sizexyz() +end + +--- Returns the min x, min y, max x and max y of the box. +-- @return int, int, int, int. + +function box:xyxy() +end + +--- Returns the min x, min y, minz, max x, max y and max z of the box. +-- @return int, int, int, int, int, int. + +function box:xyzxyz() +end + +--- Returns the size of the box as a vector (point). +-- @cstyle int box::size(box b). +-- @return point. + +function box:size() +end + +--- Shows if the box points has valid Z coordinates. +-- @cstyle bool box::IsValidZ(box b). +-- @return bool. + +function box:IsValidZ() +end + +--- Shows if the min point coordinates of the box are less than or equal to its max point coordinates. +-- @cstyle bool box::IsValid(box b). +-- @return bool + +function box:IsValid() +end + +--- Returns the geometric center of the box as a point. +-- @cstyle point box::Center(box b). +-- @return point. + +function box:Center() +end + +--- Checks if a point is inside a box +-- @cstyle bool box::PointInside(point pt). +-- @cstyle bool box::PointInside(int x, int y, int z). +-- @cstyle bool box::PointInside(bool any, point pt1, point pt2, point p3, ...). +-- @cstyle bool box::PointInside(bool any, table pts). +-- @return bool. + +function box:PointInside(pt, ...) +end + +--- Checks if a point is inside a box (2D variant) +-- @cstyle bool box::Point2DInside(point pt). +-- @cstyle bool box::Point2DInside(int x, int y). +-- @cstyle bool box::Point2DInside(bool any, point pt1, point pt2, point p3, ...). +-- @cstyle bool box::Point2DInside(bool any, table pts). +-- @return bool. + +function box:Point2DInside(pt, ...) +end + +--- Computes the distance to another box or to a point +-- @cstyle int box::Dist(box b). +-- @cstyle int box::Dist(point p). +-- @return int. + +function box:Dist(b) +end + +--- Computes the 2D distance to another box or to a point +-- @cstyle int box::Dist2D(box b). +-- @cstyle int box::Dist2D(point p). +-- @return int. + +function box:Dist2D(b) +end + +--- Returns a box, created by moving given box with specified offset. +-- @cstyle point box::Offset(box b, point offset). +-- @param offset point. +-- @return box. + +function Offset(box, offset) +end + +--- Returns a box, created by scaling its boundaries by given promile. +-- If the box is with invalid Z, the z coordinate would be omitted. +-- A point can be provided as parameter which combines all three values. +-- If only one value is given the all three coordinates are scaled by the given value. +-- If scalex and scaley are only given scalez defaults 1000(don't scale). +-- @cstyle point box::Scale(box b, int scalex, int scaley, int scalez). +-- @param scalex int. +-- @param scaley int. +-- @param scalez int. +-- @return box. + +function ScaleBox(box, scalex, scaley, scalez) +end + +--- Returns a box, created by resizing given box to specified size. +-- @cstyle point box::Resize(box b, point size). +-- @param size point. +-- @return box. + +function Resize(box, size) +end + +--- Returns a box, created by moving given box to specified point. +-- @cstyle point box::Resize(box b, point pos). +-- @param pos point. +-- @return box. + +function MoveTo(box, pos) +end + +--- Returns the minimal box containing b1 and b2. +-- @cstyle box AddRects(box b1, box b2). +-- @param b1 box. +-- @param b2 box. +-- @return box. + +function AddRects(b1, b2) +end + +--- Returns a box obtained from intersection of the given boxes. +-- @cstyle box IntersectRects(box b1, box b2). +-- @param b1 box. +-- @param b2 box. +-- @return box. + +function IntersectRects(b1, b2) +end + +--- Returns the given param is box. +-- @cstyle bool IsBox(param). +-- @param box any. +-- @return bool. + +function IsBox(param) +end + +--- Extends a given box by other boxes or points. +-- Accepts any number of both points and boxes. +-- Gracefully returns if there is no input. +-- @cstyle box Extend(box b1, box b2, ...). +-- @cstyle box Extend(box b1, point p1, ...). +-- @return box. + +function Extend(b1, b2, ...) +end + +--- Returns a transformed box by rotation, translation and scaling. +-- @cstyle box Transform(box, angle, offset, axis, scale) +-- @param box box; box to be transformed +-- @param angle int; rotation angle. +-- @param offset point/object; translation offset. +-- @param axis point; rotation axis. +-- @param scale int; scaling percents. +-- @return box; the transformed box. + +function Transform(box, angle, offset, axis, scale) +end + +function TransformXYZ(box, angle, offset, axis, scale) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/point.lua b/CommonLua/LuaExportedDocs/Global/point.lua new file mode 100644 index 0000000000000000000000000000000000000000..be44ee7a661f022fb576f7c31a9a7324dc97e1df --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/point.lua @@ -0,0 +1,442 @@ +--- Point functions. + +--- Returns a point with the specified coordinates, accepts 2 or 3 parameters. +-- @cstyle point point(int x, int y, int z). +-- @param x integer X-coordinate of the point. +-- @param y integer Y-coordinate of the point. +-- @param z integer (optional) Z-coordinate of the point. +-- @return point. + +function point(x, y, z) +end + +--- Returns the X coordinate of the point. +-- @cstyle int point::x(point p). +-- @return int. + +function point:x() +end + +--- Returns the Y coordinate of the point. +-- @cstyle int point::y(point p). +-- @return int. + +function point:y() +end + +--- Returns the Z coordinate of the point. +-- @cstyle int point::z(point p). +-- @return int. + +function point:z() +end + +--- Shows if the point has a valid Z coordinate. +-- @cstyle bool point::IsValidZ(point p). +-- @return bool. + +function point:IsValidZ() +end + +--- Shows if the point is valid. +-- @cstyle bool point::IsValid(point p). +-- @return bool. + +function point:IsValid() +end + +--- Returns the given point with x-cooridnate changed to x. +-- @cstyle point point:SetX(point self, int x). +-- @return point. + +function point:SetX() +end + +--- Returns the given point with y-cooridnate changed to y. +-- @cstyle point point:SetY(point self, int y). +-- @return point. + +function point:SetY() +end + +--- Returns the given point with z-cooridnate changed to z. +-- @cstyle point point:SetZ(point self, int z). +-- @return point. + +function point:SetZ() +end + +--- Returns the given point with z-cooridnate changed to invalid value; the returned point is considered 2D. +-- @cstyle point point:SetInvalidZ(point self). +-- @return point. + +function point:SetInvalidZ() +end + +--- Returns a point, created by resizing the given vector by given promile. +-- If the point is with invalid Z, the z coordinate would be omitted. +-- A point can be provided as parameter which combines all three values. +-- If only one value is given as parameter all three coordinates are scaled by the given value. +-- If scalex and scaley are only given scalez defaults 1000(don't scale). +-- @cstyle point box::Scale(box b, int scalex, int scaley, int scalez). +-- @param pt point. +-- @param scalex int. +-- @param scaley int. +-- @param scalez int. +-- @return box. + +function ScalePoint(pt, scalex, scaley, scalez) +end + +--- Returns the given point shortened by delta_len. +-- @cstyle point point:Shorten(point self, int delta_len). +-- @param pt point. +-- @param delta_len int. +-- @return point. + +function Shorten(pt, delta_len) +end + +--- Returns the given point lengthened by delta_len. +-- @cstyle point point:Lengthen(point self, int delta_len). +-- @param pt point. +-- @param delta_len int. +-- @return point. + +function Lengthen(pt, delta_len) +end + +--- Returns the given point with len set to the given value. +-- @cstyle point point:SetLen(point self, int new_len). +-- @param pt point. +-- @param new_len int. +-- @return point. + +function SetLen(pt, new_len) +end + +--- Returns a point in the same direction with a length no more than the limit. +-- Made for optimization and readability - doesn't make an allocation if the point remains the same. +-- @cstyle point point:LimitLen(point self, int limit). +-- @param pt point. +-- @param limit int. +-- @return point. + +function LimitLen(pt, limit) +end + +--- Returns the point rotated around the given axis. +-- @cstyle point RotateAxis(point self, point axis, int angle, point center = point30). +-- @param axis point. +-- @param angle int; rotation angle. +-- @param center point; rotation center (optional). +-- @return point. + +function RotateAxis(pt, axis, angle, center) +end + +--- Returns the point rotated around the Z axis. +-- @cstyle point RotateRadius(int radius, int angle, point center = point30, bool return_xyz = false, bool bSync = false). +-- @param radius int; radius length. +-- @param angle int; rotation angle. +-- @param center point; rotation center (could be Z value only). +-- @param return_xyz bool; return x, y and z, not a point. +-- @param bSync bool; Use integer arithmetic only. +-- @return point or int, int. + +function RotateRadius(radius, angle, center, return_xyz) +end + +--- Returns the point rotated around x, y and z axis. +-- @cstyle point RotateXYZ(point self, int x, int y, int z). +-- @param x int. +-- @param y int. +-- @param z int. +-- @return point. + +function RotateXYZ(x, y, z) +end + +--- Returns cross product of the two given points. +-- @cstyle point Cross(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return point. + +function Cross(pt1, pt2) +end + +--- Returns the Z coordinate of the 2D cross product of the two given points. +-- @cstyle int Cross2D(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return int. + +function Cross2D(pt1, pt2) +end + +--- Returns dot product of the two given points. +-- @cstyle int Dot(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return int. + +function Dot(pt1, pt2) +end + +--- Returns dot product of the two given points in 2D. +-- @cstyle int Dot(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return int. + +function Dot2D(pt1, pt2) +end + +--- Returns the axis/angle couple to use for rotating point pt1 to pt2. +-- @cstyle point, int GetAxisAngle(point pt1, point pt2). +-- @param pt1 point. +-- @param pt2 point. +-- @return point, int; axis/angle to use for rotating pt1 to pt2. + +function GetAxisAngle(pt1, pt2) +end + +--- Returns the length of the radius vector determined by this point. +-- @cstyle int point::Len(point p). +-- @return int. + +function point:Len() +end + +--- Returns the 2D length of the radius vector determined by this point. +-- @cstyle int point::Len2D(point p). +-- @return int. + +function point:Len2D() +end + +--- Returns the square length of the radius vector determined by this point. +-- @cstyle int point::Len2(point p). +-- @return int. + +function point:Len2() +end + +--- Returns the square length of the radius vector determined by this 2D point(ignores Z). +-- @cstyle int point::Len2D2(point p). +-- @return int. + +function point:Len2D2() +end + +--- Returns the euclidian distance between the current and specified points. +-- @cstyle int point::Dist(point p1, point p2). +-- @param p2 point target point to calculate distance to. +-- @return int. + +function point:Dist(p2) +end + +--- Returns the squared euclidian distance between the current and specified points. +-- @cstyle int point::Dist2(point p1, point p2). +-- @param p2 point target point to calculate distance to. +-- @return int. + +function point:Dist2(p2) +end + +--- Returns the euclidian distance between the current and specified 2D points(ignores Z). +-- @cstyle int point::Dist2D(point p1, point p2). +-- @param p2; point target point to calculate distance to. +-- @return int. + +function point:Dist2D(p2) +end + +--- Returns the squared euclidian distance between the current and specified 2D points(ignores Z). +-- @cstyle int point::Dist2D2(point p1, point p2). +-- @param p2; point target point to calculate distance to. +-- @return int. + +function point:Dist2D2(p2) +end + +--- Shows if the point is inside the box (including the borders). +-- @cstyle bool point::InBox(point p, box b). +-- @param b box. +-- @return bool. + +function point:InBox(b) +end + +--- Same as InBox but in 2D. +-- @cstyle bool point::InBox2D(point p, box b). +-- @param b box. +-- @return bool. + +function point:InBox2D(b) +end + +--- Check if two points are equal in 2D +-- @cstyle bool point::Equal2D(point p1, point p2). +-- @param p2; point target point to check. +-- @return bool. + +function point:Equal2D(p2) +end + +--- Shows if the point is inside the horizontally oriented hexagone (including the borders). +-- @cstyle bool point::InHHex(point p, box b). +-- @param b box. +-- @return bool. + +function point:InHHex(b) +end + +--- Shows if the point is inside the vertically oriented hexagone (including the borders). +-- @cstyle bool point::InVHex(point p, box b). +-- @param b box. +-- @return bool. + +function point:InVHex(b) +end + +--- Shows if the point is inside the inscribed ellpise. +-- @cstyle bool point::InEllipse(point p, box b). +-- @param b box. +-- @return bool. + +function point:InEllipse(b) +end + +--- Rotates the given point along Z-axis. +-- @cstyle point Rotate(point pt, int angle). +-- @param pt point target point to rotate. +-- @param angle int angle to rotate point (0-360). +-- @return point. + +function Rotate(pt, angle) +end + +--- Calculate the orientation between two points. +-- @cstyle int CalcOrientation(point/object p1, point/object p2). +-- @param p1 point. +-- @param p2 point. +-- @return int. + +function CalcOrientation(p1, p2) +end + +--- Check if the given parameter is a point +-- @cstyle bool IsPoint(point pt). +-- @param pt point. +-- @return bool. + +function IsPoint(pt) +end + +--- Returns what the application assumes is invalid position. +-- @cstyle point InvalidPos(). +-- @return point; a point which game assumes is the invalid positon. + +function InvalidPos() +end + +--- Returns true if 'pt' is closer to 'pt1' than to 'pt2'. +-- @cstyle bool IsCloser(point pt, point pt1, point pt2). +-- @param pt; point to check. +-- @param pt1; first point. +-- @param pt2; second point. +-- @return bool; |pt - pt1| < |pt - pt2| + +function IsCloser(pt, pt1, pt2) +end + +--- Returns true if 'pt' is closer to 'pt1' than the given dist. +-- @cstyle bool IsCloser(point pt, point pt1, int dist). +-- @param pt; point to check. +-- @param pt1; first point. +-- @param dist; distance to compare width. +-- @return bool; |pt - pt1| < dist + +function IsCloser(pt, pt1, dist) +end + +--- Returns true if 'pt' is closer to 'pt1' than to 'pt2' in 2D. +-- @cstyle bool IsCloser2D(point pt, point pt1, point pt2). +-- @param pt; point to check. +-- @param pt1; first point. +-- @param pt2; second point. +-- @return bool; + +function IsCloser2D(pt, pt1, pt2) +end + +--- Returns true if 'pt' is closer to 'pt1' than the given dist in 2D. +-- @cstyle bool IsCloser2D(point pt, point pt1, int dist). +-- @param pt; point to check. +-- @param pt1; first point. +-- @param dist; distance to compare width. +-- @return bool; + +function IsCloser2D(pt, pt1, dist) +end + +--- Returns true if the length of 'pt1' is smaller than the length of 'pt2' +-- @cstyle bool IsSmaller(point pt1, point pt2). +-- @param pt1; first point. +-- @param pt2; second point. +-- @return bool; |pt1| < |pt2| + +function IsSmaller(pt1, pt2) +end + +--- Returns true if the 2D length of 'pt1' is smaller than the 2D length of 'pt2' +-- @cstyle bool IsSmaller2D(point pt1, point pt2). +-- @param pt1; first point. +-- @param pt2; second point. +-- @return bool; + +function IsSmaller2D(pt1, pt2) +end + +function Normalize(pt) +end + +--- Returns a transformed point by rotation, translation and scaling. +-- @cstyle point Transform(pt, angle, offset, axis, scale, inverse) +-- @cstyle point Transform(x, y, z, angle, offset, axis, scale, inverse) +-- @param pt point/object; point to be transformed. +-- @param angle int; rotation angle. +-- @param offset point/object; translation offset. +-- @param axis point; rotation axis. +-- @param scale int; scaling percents. +-- @param inverse bool; perform the inverse transformation. +-- @return point; the transformed point. + +function Transform(pt, angle, offset, axis, scale, inv) +end +function Transform(x, y, z, angle, offset, axis, scale, inv) +end +function TransformXYZ(pt, angle, offset, axis, scale, inv) +end +function TransformXYZ(x, y, z, angle, offset, axis, scale, inv) +end + +---- + +function ResolvePos(pt_or_obj_or_x, y, z) +end +function ResolveVisualPos(pt_or_obj_or_x, y, z) +end +function ResolvePosXYZ(pt_or_obj_or_x, y, z) +end +function ResolveVisualPosXYZ(pt_or_obj_or_x, y, z) +end + +---- + +function ClampPoint(pos, box, border) +end + diff --git a/CommonLua/LuaExportedDocs/Global/pstr.lua b/CommonLua/LuaExportedDocs/Global/pstr.lua new file mode 100644 index 0000000000000000000000000000000000000000..6bef467f58d7e2e091e2d5f7b9ecf3ba89e61c50 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/pstr.lua @@ -0,0 +1,129 @@ +--- pstr. +-- pstr are string kept outside the Lua memoty. + +--- Creates a pstr. +-- @cstyle pstr pstr(string str = "", int capacity = 0). +-- @param str string Initial value of the string, empty by default. +-- @param capacity integer Allocated memory, taken into account only if bigger than the size of the string. +-- @return pstr. + +function pstr(str, capacity) +end + +--- Check if the given value is a pstr. +-- @cstyle bool IsPStr(pstr value). +-- @return true if value is a pstr. + +function IsPStr(value) +end + +--- Return stats for the current pstr usage. Only functional in debug mode. +-- @cstyle table GetPStrStats(). +-- @return table with statistics. + +function GetPStrStats() +end + +--- Free all resources allocated from a given pstr. +-- @cstyle void pstr::free(pstr self). + +function pstr:free() +end + +--- Returns the size of the pstr (same as # operator). +-- @cstyle int pstr::size(pstr self). +-- @return integer. + +function pstr:size() +end + +--- Compares a pstr with another string (same as == operator). +-- @cstyle boolean pstr::equals(pstr self, string value). +-- @return boolean. + +function pstr:equals(value) +end + +--- Append any number of arguments to the current pstr (same as .. operator, but inplace). +-- @cstyle pstr pstr::append(pstr self, ...). +-- @return pstr, the pstr itself. + +function pstr:append(...) +end + +--- Append a the same string several times. +-- @cstyle pstr pstr::appendr(pstr self, string str, int count). +-- @param str string: Text to repeat. +-- @param count int: Number of repetitions. +-- @return pstr, the pstr itself. + +function pstr:appendr(str, count) +end + +--- Append a formated string to the current pstr (same as printf). +-- @cstyle pstr pstr::appendf(pstr self, string fmt, ...). +-- @return pstr, the pstr itself. + +function pstr:appendf(fmt, ...) +end + +--- Append value to lua code +-- @cstyle pstr pstr::appendv(pstr self, T value, string indent). +-- @return pstr, the pstr itself. + +function pstr:appendv(value, indent) +end + +--- Append a table to lua code +-- @cstyle pstr pstr::appendt(pstr self, table tbl, string indent, bool as_array). +-- @return pstr, the pstr itself. + +function pstr:appendt(tbl, indent, as_array) +end + +--- Append string to lua code +-- @cstyle pstr pstr::appends(pstr self, string str, bool quote). +-- @param str string Quoted string to append. +-- @param quote bool, Use single quote (may be set to "auto" to auto-match). +-- @return pstr, the pstr itself. + +function pstr:appends(value, str, quote) +end + +--- Convert a pstr to a string (same as tostring() operator) +-- @cstyle string pstr::str(pstr self). +-- @return string. + +function pstr:str() +end + +--- Clear the contents of a pstr. +-- @cstyle void pstr::clear(pstr self). + +function pstr:clear() +end + +--- Return a substring +-- @cstyle string pstr::sub(pstr self, int from = 1, int to = -1). +-- @param from integer Starting index, 1 by default. +-- @param to integer Ending index, -1 by default, which marks the end of the string. +-- @return string. + +function pstr:sub(from, to) +end + +--- Return N integer values with the byte representation of the containing chars +-- @cstyle string pstr::byte(pstr self, int from, int to = from). +-- @param from integer Starting index. +-- @param to integer Ending index, Same as 'from' by default. +-- @return integer. + +function pstr:byte(from, to) +end + +--- Reserve the requested number of bytes +-- @cstyle pstr pstr::reserve(pstr self, int size). +-- @return bool. + +function pstr:reserve(size) +end diff --git a/CommonLua/LuaExportedDocs/Global/quaternion.lua b/CommonLua/LuaExportedDocs/Global/quaternion.lua new file mode 100644 index 0000000000000000000000000000000000000000..9b8a76e55096527dad265688b202fd4b3e73f320 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/quaternion.lua @@ -0,0 +1,39 @@ +--- Quaternion functions. + +--- Returns a quaternion with the specified axis and angle +-- @cstyle quaternion quaternion(point axis, int angle). +-- @param axis point +-- @param angle integer +-- @return quaternion. + +function quaternion(axis, angle) +end + +--- Returns a normalized quaternion +-- @cstyle quaternion quaternion:Norm(quaternion q). +-- @return quaternion. + +function quaternion:Norm() +end + +--- Returns an inverse quaternion +-- @cstyle quaternion quaternion:Inv(quaternion q). +-- @return quaternion. + +function quaternion:Inv() +end + +--- Returns the axis and angle composing the quaternion +-- @cstyle point, int quaternion:GetAxisAngle(quaternion q). +-- @return point, int. + +function quaternion:GetAxisAngle() +end + +--- Check if the given parameter is a quaternion +-- @cstyle bool IsQuaternion(quaternion q). +-- @param q quaternion. +-- @return bool. + +function IsQuaternion(q) +end diff --git a/CommonLua/LuaExportedDocs/Global/serialize.lua b/CommonLua/LuaExportedDocs/Global/serialize.lua new file mode 100644 index 0000000000000000000000000000000000000000..a099ca56b8e18c4d0a0b8dfb257d6c15bd1390e4 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/serialize.lua @@ -0,0 +1,26 @@ +function Serialize(...) +end + +function SerializeAndCompress(...) +end + +function Unserialize(str) +end + +function SerializeStr(string_table, ...) +end + +function UnserializeStr(string_table, str) +end + +function Compress(str) +end + +function Decompress(str) +end + +function DecompressAndUnserialize(str) +end + +function BinaryEscape(str, escape, compression, inplace) +end diff --git a/CommonLua/LuaExportedDocs/Global/string.lua b/CommonLua/LuaExportedDocs/Global/string.lua new file mode 100644 index 0000000000000000000000000000000000000000..19e71256f2b1768bdbd66fe34a78d0da0ef23440 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/string.lua @@ -0,0 +1,27 @@ +--- Checks if a string begins with a given string +-- @cstyle bool string.starts_with(string str, string str_to_find, bool case_insensitive = false) +function string.starts_with(str, str_to_find, case_insensitive) +end + +--- Checks if a string ends with a given string +-- @cstyle bool string.ends_with(string str, string str_to_find, bool case_insensitive = false) +function string.ends_with(str, str_to_find, case_insensitive) +end + +--- Search a string into a string lowercase +-- Returns the starting index of the first occurence of the string, or nil +-- @cstyle int string.find_lower(string str, string str_to_cmp, int start_idx = 1) +function string.find_lower(str, str_to_find, start_idx) +end + +--- Compares two strings lowercase +-- Returns zero if equal, negative if str1 < str2 or positive if str1 > str2 +-- @cstyle int string.cmp_lower(string str1, string str2) +function string.cmp_lower(str1, str2) +end + +--- Concatenates strings using a separator +-- Returns str1 .. sep .. str2 .. [sep ... ] +-- @cstyle int string.concat(string sep, string str1, string str2, ...) +function string.concat(sep, str1, str2, ...) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/table.lua b/CommonLua/LuaExportedDocs/Global/table.lua new file mode 100644 index 0000000000000000000000000000000000000000..d13384a8d89b3af466cd804c4bda155a190b02c8 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/table.lua @@ -0,0 +1,299 @@ +--- Search an array for a given value +-- Returns the index of the value in the array or nil +-- @cstyle int table.find(table array, string field, auto value) +-- @param array; table to search in +-- @param field; optional parameter, field to search with +-- @param value; value to search for) +-- @return index + +function table.find(array, field, value) +--[[ + if not array then return end + if value == nil then + value = field + for i = 1, #array do + if value == array[i] then return i end + end + else + for i = 1, #array do + if type(array[i]) ~= "boolean" and value == array[i][field] then return i end + end + end +--]] +end + +--- Search an array of arrays for a value that starts with the values provided as extra parameters +-- Returns the sub_array and the index of the sub_array in the array or nil +-- @cstyle int table.ifind(table array, ...) +-- @param array; table to search in +-- @param ...; values to search for +-- @return sub_array, index + +function table.ifind(array, ...) +--[[ + for i, sub_array in ipairs(array) do + local found = true + for j = 1, select("#", ...) do + if sub_array[j] ~= select(j, ...) then + found = false + break + end + end + if found then return sub_array, i end + end +--]] +end + +--- Search an array for the first element that matches a condition +-- Returns the index of the first matching element or nil +-- @param array; table to search in +-- @param predicate; function that return true for matching elements, getting (idx, value, ...) as parameters +-- @cstyle int table.findfirst(table array, function predicate, ...) +function table.findfirst(array, predicate, ...) +end + +--- Count the elements into a table +-- See table.find for parameter details +-- As the second parameter, you may provide a predicate function to count key/value pairs that satisfy a condition +-- This function receives parameters (key, value, ...) where ... are all the rest of the table.count parameters +function table.count(array, field_or_fn, value) +end + +--- Count the elements in an array +-- See table.find for parameter details +-- As the second parameter, you may provide a predicate function to count key/value pairs that satisfy a condition +-- This function receives parameters (key, value, ...) where ... are all the rest of the table.count parameters +function table.icount(array, field_or_fn, value) +end + +--- Sort the elements of a table (acscending) according to a member value (a.field < b.field) +function table.sortby_field(array, field) +end + +--- Sort the elements of a table (descending) according to a member value (a.field > b.field) +function table.sortby_field_descending(array, field) +end + +--- Sort a table of points / objects (acscending) according to the distаnce to a position +function table.sortby_dist(array, pos) +end + +--- Sort a table of points / objects (acscending) according to the 2D distаnce to a position +function table.sortby_dist2D(array, pos) +end + +--- Get the closest pos / object from a table of points / objects according to the distаnce to another pos / object +function table.closest(array, pos) +end + +--- Get the closest pos / object from a table of points / objects according to the 2D distаnce to a another pos / object +function table.closest2D(array, pos) +end + +--- Get the farthest pos / object from a table of points / objects according to the distаnce to another pos / object +function table.farthest(array, pos) +end + +--- Get the farthest pos / object from a table of points / objects according to the 2D distаnce to a another pos / object +function table.farthest2D(array, pos) +end + +--- Set table[param1][param2]..[paramN-1] = paramN +function table.set(t, param1, param2, ...) +end + +--- Returns table[param1][param2]..[paramN] +function table.get(t, key, ...) +end + +--- Same as table.get but also supports calling methods +-- examples: +-- table.fget(obj, "GetParam") is obj.GetParam +-- table.fget(obj, "GetParam", "()") is obj:GetParam() +-- table.fget(obj, "GetParam", "()", param2) is obj:GetParam()[param2] +-- table.fget(obj, "GetParam", "(", param2, ")") is obj:GetParam(param2) +-- table.fget(obj, "GetParam", "(", param2, ")", param3) is obj:GetParam(param2)[param3] +-- table.fget(obj, "GetParam", "(", ...) is obj:GetParam(...) +function table.fget(t, key, call, ...) +end + +function table.clear(t, keep_reserved_memory) +--[[ + if t then + for member in pairs(t) do + t[member] = nil + end + end + return t +--]] +end + +function table.iclear(t, from) +--[[ + if t then + from = from or 1 + for i=#t,from,-1 do + t[i] = nil + end + end + return t +--]] +end + +function table.iequal(t1, t2) +--[[ + if #t1 ~= #t2 then + return + end + for i=1,#t1 do + if t1[i] ~= t2[i] then + return + end + end + return true +--]] +end + +--- Performs a weighted random on the table elements +-- Returns random element, its index and the used seed +-- @cstyle int table.weighted_rand(table tbl, function calc_weight, int seed) +-- @param tbl; table to rand +-- @param calc_weight; weight compute function or member name +-- @param seed; optional random seed parameter. A random value by default. +-- @return tbl[idx], idx, new_seed + +function table.weighted_rand(tbl, calc_weight, seed) +--[[ + seed = seed or AsyncRand() + local accum_weight = 0 + for i=1,#tbl do + accum_weight = accum_weight + calc_weight(tbl[i]) + end + if accum_weight == 0 then + return nil, nil, seed + end + local idx = #tbl + if #tbl > 1 then + local target_weight + target_weight, seed = BraidRandom(seed, accum_weight) + accum_weight = 0 + for i=1,#tbl-1 do + accum_weight = accum_weight + calc_weight(tbl[i]) + if accum_weight > target_weight then + idx = i + break + end + end + end + return tbl[idx], idx, seed +--]] +end + +--- Copy the array part of a table +-- @cstyle table table.icopy(table t, bool deep = true) +function table.icopy(t, deep) +--[[ + local copy = {} + for i = 1, #t do + local v = t[i] + if deep and type(v) == "table" then v = table.icopy(v, deep) end + copy[i] = v + end + return copy +--]] +end + +--- Extracts the keys of a table into an array +-- @cstyle table table.keys(table t, bool sorted = false) +function table.keys(t, sorted) +--[[ + local res = {} + if t and next(t) then + for k in pairs(t) do + res[#res+1] = k + end + if sorted then + table.sort(res) + end + end + return res +--]] +end + +--- Inverts a table +-- @cstyle table table.invert(table t) +function table.invert(t) +--[[ + local t2 = {} + for k, v in pairs(t) do + t2[v] = k + end + return t2 +--]] +end + +--- Compare the values in two tables (deep) +-- @cstyle bool table.equal_values(table t1, table t2) +function table.equal_values(t1, t2) +end + +--- Append the key-value pairs from t2 in t1 +-- @cstyle void table.append(table t1, table t2, bool forced) +function table.append(t1, t2, forced) +--[[ + for key, value in pairs(t2 or empty_table) do + if forced or t1[key] == nil then + t1[key] = value + end + end + return t1 +end +--]] +end + +--- Overwrites the key-value pairs from t2 in t1 +-- @cstyle void table.overwrite(table t1, table t2) +function table.overwrite(t1, t2) +--[[ + for key, value in pairs(t2 or empty_table) do + t1[key] = value + end + return t1 +end +--]] +end + +--- Set the specified key-value pairs from src in dest +-- @cstyle void table.set_values(table dest, table src, string key1, string key2, ...) +function table.set_values(raw, dest, src, key1, key2, ...) +--[[ + for _, key in ipairs{key1, key2, ...} do + dest[key] = src[key] + end +end +--]] +end + +--- Set the specified key-value pairs from src in dest using raw access without metamethods +-- @cstyle void table.rawset_values(table dest, table src, string key1, string key2, ...) +function table.rawset_values(dest, src, key1, key2, ...) +--[[ + for _, key in ipairs{key1, key2, ...} do + rawset(dest, key, rawget(src, key)) + end +end +--]] +end + +-- Removes all invalid objects from an array +function table.validate(t) +--[[ + for i = #(t or ""), 1, -1 do + if not IsValid(t[i]) then + remove(t, i) + end + end + return t +--]] +end + diff --git a/CommonLua/LuaExportedDocs/Global/terminal.lua b/CommonLua/LuaExportedDocs/Global/terminal.lua new file mode 100644 index 0000000000000000000000000000000000000000..052d538ba233920f3ec22856e2d7db740b20157a --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/terminal.lua @@ -0,0 +1,29 @@ +--- Terminal functions. + +--- Returns if the given key is pressed. +-- @cstyle bool terminal.IsKeyPressed(int key). +-- @param key int; virtual key code; the codes are exported to const table. +-- @return bool; true if the key is pressed; false otherwise. + +function terminal.IsKeyPressed(key) +end + +--- Returns if the left, right and middle mouse buttons are currently pressed. +-- @cstyle bool, bool, bool terminal.IsLRMMouseButtonPressed(). +-- @return bool, bool, bool. + +function terminal.IsLRMMouseButtonPressed() +end + +--- Returns the current position of the mouse. +-- @cstyle point terminal.GetMousePos(). +-- @return point. + +function terminal.GetMousePos() +end + +--- Sets the text of the current window +-- @param text string; New value. +-- @return bool; success? +function terminal.SetOSWindowTitle(text) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/Global/thread.lua b/CommonLua/LuaExportedDocs/Global/thread.lua new file mode 100644 index 0000000000000000000000000000000000000000..3dfbcb11aed025bfe0fd5a84ff96506bd5e806c8 --- /dev/null +++ b/CommonLua/LuaExportedDocs/Global/thread.lua @@ -0,0 +1,82 @@ +-- @cstyle thread CreateRealTimeThread(function exec) +function CreateRealTimeThread(exec, ...) +end + +-- @cstyle thread CreateGameTimeThread(function exec) +function CreateGameTimeThread(exec, ...) +end + +-- @cstyle thread CreateMapRealTimeThread(function exec) +function CreateMapRealTimeThread(exec, ...) +end + +-- @cstyle thread IsRealTimeThread(thread thread) +function IsRealTimeThread(thread) +end + +-- @cstyle thread IsGameTimeThread(thread thread) +function IsGameTimeThread(thread) +end + +-- @cstyle int RealTime() +-- @return int, Current real time in ms +function RealTime() +end + +-- @cstyle int GameTime() +-- @return int, Current game time in ms +function GameTime() +end + +-- @cstyle int now() +-- @return int, Current time, depending on the current thread type, in ms +function now() +end + +-- @cstyle thread CurrentThread() +function CurrentThread() +end + +-- @cstyle bool IsValidThread(thread thread) +-- @return bool, True if the given thread is alive +function IsValidThread(thread) +end + +-- @cstyle string GetThreadStatus(thread thread) +function GetThreadStatus(thread) +end + +-- @cstyle bool CanYield() +function CanYield() +end + +-- @cstyle void Sleep(int time) +-- @param time int, Time to sleep in ms. +function Sleep(time) +end + +-- @cstyle void InterruptAdvance() +function InterruptAdvance() +end + +-- @cstyle void DeleteThread(thread thread, bool allow_if_current) +function DeleteThread(thread, allow_if_current) +end + +-- Wait the current thread to be woken up with Wakeup +-- @cstyle bool WaitWakeup(int timeout) +-- @param timeout int, Time to wait in ms. +-- @return bool, True if awaken before the time expires +function WaitWakeup(timeout) +end + +-- Wakes up a thread put to sleep with WaitWakeup +-- @cstyle void Wakeup(thread thread, ...) +function Wakeup(thread, ...) +end + +-- Wait for a specific message to be fired +-- @cstyle template bool WaitMsg(T msg, int timeout) +-- @return bool, True if message has been fired before the time expires +function WaitMsg(msg, timeout) +end \ No newline at end of file diff --git a/CommonLua/LuaExportedDocs/__load.lua b/CommonLua/LuaExportedDocs/__load.lua new file mode 100644 index 0000000000000000000000000000000000000000..1b7522974de5a021c779a62cf8db31cacb65c391 --- /dev/null +++ b/CommonLua/LuaExportedDocs/__load.lua @@ -0,0 +1 @@ +-- supress automatic loading when mounted in the game \ No newline at end of file diff --git a/CommonLua/LuaPerformance.lua b/CommonLua/LuaPerformance.lua new file mode 100644 index 0000000000000000000000000000000000000000..1bf31611674b963748a1abea69da26ebe0d6f303 --- /dev/null +++ b/CommonLua/LuaPerformance.lua @@ -0,0 +1,62 @@ +if Platform.developer then + +function MeasureMaxGameSpeedAchievable(speed_test_time, lo, hi) + if not IsRealTimeThread() then + CreateRealTimeThread(function() MeasureMaxGameSpeedAchievable(speed_test_time, lo, hi) end) + return + end + + speed_test_time = speed_test_time or 10000 + lo = lo or 1000 + hi = hi or 10000 + + table.change(config, "MeasureMaxGameSpeedAchievable", { + StoryBitsSuspended = true + }) + + local old_ignoreerrors = IgnoreDebugErrors(true) + Msg("LuaPerformanceBegin") + hr.GameTimeBehindDetect = true + + local time_factor = (lo < const.DefaultTimeFactor) and const.DefaultTimeFactor or lo + while hi - lo >= 1000 do + print(string.format("Testing time factor %d for %dms(+%dms tolerance)", time_factor, speed_test_time, config.GameTimeBehindTimeTolerance)) + SetTimeFactor(time_factor) + local start_time = GetPreciseTicks() + while (not hr.GameTimeBehindFlag) and (GetPreciseTicks() - start_time < speed_test_time + config.GameTimeBehindTimeTolerance) do + Sleep(1000) + end + if hr.GameTimeBehindFlag then + hi = time_factor + print(string.format("Time Factor %d FAIL", time_factor)) + else + lo = time_factor + print(string.format("Time Factor %d SUCCESS", time_factor)) + end + time_factor = (lo + hi) / 2 + end + print(string.format("Max Time Factor: %d", lo)) + + hr.GameTimeBehindDetect = false + Msg("LuaPerformanceEnd") + IgnoreDebugErrors(old_ignoreerrors) + + table.restore(config, "MeasureMaxGameSpeedAchievable") + + return lo +end + +function MeasureLuaPerformance(time) + time = time or 10000 + + CreateRealTimeThread(function() + local start_time = GetPreciseTicks() + local lo, hi = 1000, 100000 + local time_factor = MeasureMaxGameSpeedAchievable(time, lo, hi) + print(string.format("Time factor supported: %d", time_factor)) + ReloadLua() + print(string.format("Measurements executed in %ds", (GetPreciseTicks() - start_time) / 1000)) + end) +end + +end \ No newline at end of file diff --git a/CommonLua/MapGen/Biome.lua b/CommonLua/MapGen/Biome.lua new file mode 100644 index 0000000000000000000000000000000000000000..030fd1b567341858beef5ca9a024e9d345a7df90 --- /dev/null +++ b/CommonLua/MapGen/Biome.lua @@ -0,0 +1,286 @@ +if const.BiomeTileSize then + DefineMapGrid("BiomeGrid", 16, const.BiomeTileSize, 64, config.EditableBiomeGrid and "save_in_map") +end + +local max_value = 254 +local height_scale = const.TerrainHeightScale +local height_max = const.MaxTerrainHeight +local type_tile = const.TypeTileSize +local wd_max = const.RandomMap.BiomeMaxWaterDist +local h_max, h_scale = height_max/height_scale, guim/height_scale +local min_sl, max_sl = const.RandomMap.BiomeMinSeaLevel/height_scale, const.RandomMap.BiomeMaxSeaLevel/height_scale +assert(guim % height_scale == 0) + +BiomeMatchParams = { + { id = "Height", name = "Height", units = "(m)", min = 0, max = h_max, default = 0, scale = h_scale, help = "Absolute height value on the map" }, + { id = "Slope", name = "Slope", units = "(deg)", min = 0, max = 90*60, default = 0, scale = 60, help = "Slope angle: 0 flat, 90 vertical" }, + { id = "Wet", name = "Humidity", units = "(%)", min = 0, max = 100, default = 50, help = "Humidity % derived from erosion intensity" }, + { id = "Hardness", name = "Soil Hardness", units = "(%)", min = 0, max = 100, default = 0, scale = 1, help = "Defines how much rigid is the soil agains erosion: 100% is solid rock without any erosion" }, + { id = "Orient", name = "Orientation", min = 0, max = 1000, default = 0, scale = 1000, help = "Slope orientation towards the sun: 0 Shadow, 1 Sunlit" }, + { id = "SeaLevel", name = "Sea Level", units = "(m)", min = min_sl, max = max_sl, default = max_sl, scale = h_scale, help = "Height above or below Sea Level set it MapData" }, + { id = "WaterDist", name = "Water Dist", units = "(m)", min = -wd_max, max = wd_max, default = wd_max, scale = guim, help = "Distance in (-) or out (+) the water border line" }, +} + +function BiomeWaterDist(water_grid) + if not water_grid then return end + local dist_out = GridDest(water_grid) + if GridIsFlat(water_grid) then + local value = GridGet(water_grid, 0, 0) + dist_out:clear(value == 0 and wd_max or -wd_max) + return dist_out + end + GridInvert(water_grid) + GridDistance(water_grid, dist_out, type_tile, wd_max) + GridInvert(water_grid) + local dist_in = GridDest(water_grid) + GridDistance(water_grid, dist_in, type_tile, wd_max) + local dist = GridAddMulDiv(dist_out, dist_in, -1) + return dist +end + +function BiomeMatchItems() + local items = {} + for _, param in ipairs(BiomeMatchParams) do + items[#items + 1] = { value = param.id , text = param.name } + end + return items +end + +DefineClass.Biome = { + __parents = { "Preset", }, + properties = { + { category = "Biome", id = "grid_value", name = "Grid Value", editor = "number", default = 0, min = 0, max = max_value, read_only = true, help = "Value stored in the Biome grid" }, + { category = "Biome", id = "palette_color", name = "Palette Color", editor = "color", default = -16777216, }, + + { category = "Prefabs", id = "PrefabTypeWeights", name = "Prefab Types", editor = "nested_list", default = false, base_class = "BiomePrefabTypeWeight", inclusive = true }, + { category = "Prefabs", id = "FilteredPrefabsPreview", name = "Filtered Prefabs", editor = "number", default = 0, dont_save = true, read_only = true, }, + { category = "Prefabs", id = "TypeMixingPreset", name = "Mixing Pattern", editor = "preset_id", default = "", preset_class = "NoisePreset", }, + { category = "Prefabs", id = "TypeMixingPreview", name = "Mixing Preview", editor = "grid", default = false, no_edit = function(self) return self.TypeMixingPreset == "" end, frame = 1, min = 512, dont_save = true, read_only = true }, + + { category = "Matching", id = "CompareParam", name = "Compare Param", editor = "set", default = empty_table, items = BiomeMatchItems, max_items_in_set = 1, dont_save = true }, + }, + EditorMenubarName = "Biomes", + EditorMenubar = "Map.Generate", + EditorIcon = "CommonAssets/UI/Icons/biology plants seed.png", + EditorView = Untranslated(" "), + StoreAsTable = false, +} + +for _, match in ipairs(BiomeMatchParams) do + local maxw = 200 + local id, name, units, min, max, scale, help = match.id, match.name, match.units or "", match.min, match.max, match.scale, match.help + local function no_edit(self) + local cmp_id = self:GetCompareId() + return cmp_id and cmp_id ~= id + end + table.iappend(Biome.properties, { + { category = "Matching", id = id .. "From", name = name .. " From " .. units, editor = "number", default = false, min = min, max = max, scale = scale, no_edit = no_edit, slider = true, recalc_curve = id, help = help }, + { category = "Matching", id = id .. "Best", name = name .. " Best " .. units, editor = "number", default = false, min = min, max = max, scale = scale, no_edit = no_edit, slider = true, recalc_curve = id, help = help }, + { category = "Matching", id = id .. "To", name = name .. " To " .. units, editor = "number", default = false, min = min, max = max, scale = scale, no_edit = no_edit, slider = true, recalc_curve = id, help = help }, + { category = "Matching", id = id .. "Weight", name = name .. " Weight", editor = "number", default = 100, min = 0, max = maxw, scale = 100, no_edit = no_edit, slider = true, recalc_curve = id }, + { category = "Matching", id = id .. "Curve", name = name .. " Curve", editor = "grid", default = false, dont_save = true, read_only = true, no_edit = no_edit, dont_normalize = true, frame = 1, help = help }, + }) + Biome["Get" .. id .. "Curve"] = function(self) + return self[id .. "Curve"] or self["CalcCurve" .. id](self) + end + Biome["CalcCurve" .. id] = function(self, notify) + local grid = self[id .. "Curve"] + local w, h = 256, 64 + if not grid then + grid = NewComputeGrid(w, h, "U", 8) + self[id .. "Curve"] = grid + end + grid:clear() + local weight, x_from, x_best, x_to = self[id .. "Weight"], self[id .. "From"], self[id .. "Best"], self[id .. "To"] + local x0, x1 = x_from or min, x_to or max + if not x_best or x_best >= x0 and x_best <= x1 then + for gx = 0, w-1 do + local x = min + MulDivRound(max - min, gx, w - 1) + if x >= x0 and x <= x1 then + local wi = weight + if not x_best then + -- + elseif x_from and x >= x0 and x < x_best then + wi = MulDivRound(weight, x - x0, x_best - x0) + elseif x_to and x <= x1 and x > x_best then + wi = MulDivRound(weight, x1 - x, x1 - x_best) + end + local gy = MulDivRound(h - 1, maxw - wi, maxw) + GridDrawColumn(grid, gx, gy, 255, 128) + end + end + end + if notify then + ObjModified(self) + end + return grid + end +end + +AppendClass.MapDataPreset = { properties = { + { category = "Random Map", id = "BiomeGroup", editor = "choice", default = "", items = PresetGroupsCombo("Biome") }, + { category = "Random Map", id = "HeightMin", editor = "number", default = 10 * guim, scale = "m", min = 0, max = height_max, slider = true, help = "Value corresponding to black grayscale level" }, + { category = "Random Map", id = "HeightMax", editor = "number", default = height_max - 10 * guim, scale = "m", min = 0, max = height_max, slider = true, help = "Value corresponding to white grayscale level" }, + { category = "Random Map", id = "WetMin", editor = "number", default = 0, scale = "%", min = 0, max = 100, slider = true, help = "Value corresponding to black grayscale level" }, + { category = "Random Map", id = "WetMax", editor = "number", default = 100, scale = "%", min = 0, max = 100, slider = true, help = "Value corresponding to white grayscale level" }, + { category = "Random Map", id = "SeaLevel", editor = "number", default = 0, scale = "m", min = 0, max = height_max, slider = true }, + { category = "Random Map", id = "SeaPreset", editor = "preset_id", default = false, preset_class = "WaterObjPreset" }, + { category = "Random Map", id = "SeaMinDist", editor = "number", default = 32*guim, scale = "m", min = 0 }, + { category = "Random Map", id = "MinBumpSlope", editor = "number", default = 10*60, scale = "deg", min = 0, max = 90*60, slider = true }, + { category = "Random Map", id = "MaxBumpSlope", editor = "number", default = 40*60, scale = "deg", min = 0, max = 90*60, slider = true }, +}} + +function Biome:GetCompareId() + return next(self.CompareParam) +end + +function Biome:GetProperties() + local compare_id = self:GetCompareId() + if not compare_id then + return self.properties + end + local props = table.icopy(self.properties) + ForEachPreset("Biome", function(preset) + if self.id ~= preset.id and self.group == preset.group then + local id = preset.id .. "_Compare" + props[#props + 1] = { category = "Compare", id = id, name = preset.id, editor = "grid", default = false, dont_save = true, read_only = true, dont_normalize = true, frame = 1 }, + rawset(self, "Get" .. id, function() + local getter = preset["Get" .. compare_id .. "Curve"] + return getter and getter(preset) + end) + end + end) + return props +end + +function Biome:GetFilteredPrefabs() + local types = table.map(self.PrefabTypeWeights or empty_table, "PrefabType") + types = table.invert(types) + + local result = {} + for i,prefab in ipairs(PrefabMarkers) do + if types[prefab.type] then + table.insert(result, prefab.name) + end + end + return result +end + +function Biome:GetTypeMixingGrid(result, rand_seed, ptype_to_idx) + local preset = NoisePresets[self.TypeMixingPreset] + local weights = self.PrefabTypeWeights or empty_table + if not preset or #weights < 2 then + return false + end + + local noise = GridDest(result) + rand_seed = rand_seed and BraidRandom(rand_seed) or 0 + preset:GetNoise(rand_seed, noise) + local weights_sum = 0 + for i=1,#weights do + weights_sum = weights_sum + weights[i].Weight + end + local marks = 0 + local levels = GridLevels(noise) + local histogram = {} + for level, count in sorted_pairs(levels) do + histogram[#histogram + 1] = {count, level} + end + local w, h = noise:size() + local total_area = w * h + local mask = GridDest(noise) + local prev_level = -1 + local function Mark(level) + marks = marks + 1 + local idx = not ptype_to_idx and marks or ptype_to_idx[weights[marks].PrefabType] or 0 + GridMask(noise, mask, prev_level + 1, level) + prev_level = level + GridPaint(result, mask, idx) + end + + local idx, area, weight = 1, 0, 0 + for i=1,#weights-1 do + weight = weight + weights[i].Weight + local target_area = MulDivRound(total_area, weight, weights_sum) + while idx <= #histogram do + local entry = histogram[idx] + area = area + entry[1] + idx = idx + 1 + if area >= target_area then + Mark(entry[2]) + break + end + end + end + Mark(max_int) + return result +end + +function Biome:__paste(...) + local res = Preset.__paste(self, ...) + res.grid_value = nil + return res +end + +function Biome:PostLoad() + self:AssignValue() + Preset.PostLoad(self) +end + +function Biome:AssignValue() + if self.grid_value > 0 then return end + local value = 0 + ForEachPreset("Biome", function(p) + value = Max(value, p.grid_value) + end) + if value < max_value then + self.grid_value = value + 1 + return + end + local map = BiomeValueToPreset() + for i=1,max_value do + if not map[i] then + self.grid_value = i + return + end + end + assert(false, "No more biome grid values available!") + self.grid_value = -1 +end + +---- + +DefineClass.BiomePrefabTypeWeight = { + __parents = { "PropertyObject" }, + properties = { + { id = "PrefabType", name = "Type", editor = "preset_id", default = "", preset_class = "PrefabType" }, + { id = "Weight", name = "Weight", editor = "number", default = 100, min = 0, max = 100, slider = true }, + }, + EditorView = Untranslated(" (weight: )"), +} + +---- + +function BiomeValueToPreset() + local map = {} + ForEachPreset("Biome", function(preset, group, map) + local value = preset.grid_value + if value <= 0 then + -- + elseif map[value] then + print("Biome value", value, "collision", map[value].id, "/", preset.group, "-", preset.id) + else + map[value] = preset + end + end, map) + return map +end + +function DbgGetBiomePalette() + local palette = {} + ForEachPreset("Biome", function(preset) + palette[preset.grid_value] = preset.palette_color + end) + palette[255] = RGBA(255, 255, 255, 128) + return palette +end \ No newline at end of file diff --git a/CommonLua/MapGen/BiomeFiller.lua b/CommonLua/MapGen/BiomeFiller.lua new file mode 100644 index 0000000000000000000000000000000000000000..4ea373a355567e3da709a5ad2bfaba7ad4b23fce --- /dev/null +++ b/CommonLua/MapGen/BiomeFiller.lua @@ -0,0 +1,1973 @@ +local unit_weight = 4096 +local type_tile = const.TypeTileSize +local height_max = const.MaxTerrainHeight +local height_scale = const.TerrainHeightScale +local empty_table = empty_table +local gofPermanent = const.gofPermanent +local gofGenerated = const.gofGenerated +local maxh = height_max - 10*guim +local minh = 10*guim +local gmodes = {"Height", "Type", "Grass", "Objects", "POI"} +local smodes = {"Prefab", "POI"} +local pmodes = {"Marks", "Overlap", "Cover"} +local omodes = {"Marks", "Overlap", "Cover", "Types"} +local imodes = {"Rollover", "Pos", "POI"} +local def_gm = set(table.unpack(gmodes)) +local def_pn = set("Marks") +local b_dprint = CreatePrint{ "RM", format = print_format, output = DebugPrint } +local b_print = Platform.developer and CreatePrint{ "RM", format = print_format, color = yellow} or b_dprint +local pct_mul = 100 +local pct_100 = 100*pct_mul +local function to_pct(mul, div) + return div == 0 and 0 or MulDivRound(100 * pct_mul, mul, div) +end +local def_bd = "BiomeDistort" +DbgShowPrefab = empty_func + +---- + +DefineClass.BiomeFiller = { + __parents = { "GridOpInput", "DebugOverlayControl" }, + properties = { + { category = "General", id = "SlopeGrid", name = "Slope Grid", editor = "choice", default = "", items = function(self) return GridOpOutputNames(self) end, grid_input = true, optional = true, help = "Required by POI logic." }, + { category = "General", id = "MinHeight", name = "Min Height (m)", editor = "number", default = minh, min = 0, max = height_max, scale = guim, help = "Prefabs below that limit will be smart clamped." }, + { category = "General", id = "MaxHeight", name = "Max Height (m)", editor = "number", default = maxh, min = 0, max = height_max, scale = guim, help = "Prefabs above that limit will be smart clamped." }, + { category = "General", id = "LoadPrefabLoc", name = "Load Prefab Loc", editor = "bool", default = false, help = "Load any previously saved prefab locations found in the map." }, + { category = "General", id = "SavePrefabLoc", name = "Save Prefab Loc", editor = "bool", default = false, help = "Save any prefab locations with persistable tags." }, + { category = "General", id = "UseMeshOverlap",name = "Use Mesh Overlap", editor = "bool", default = true, log = true, help = "Detect prefab overlapping objects by analysing their collision mesh instead only their origin"}, + { category = "General", id = "OptionalChance",name = "Optional Chance (%)", editor = "number", default = 50, slider = true, min = 0, max = 100, log = true, help = "Chance for optional objects to be placed" }, + { category = "General", id = "SteepSlope", name = "Steep Slope", editor = "number", default = 30 * 60,slider = true, min = 0, max = 90*60, log = true, scale = "deg", help = "Slope threshold to start deleting objects marked to be removed on steep slopes" }, + + { category = "Debug", id = "GenMode", name = "Gen Mode", editor = "set", default = def_gm, items = gmodes, }, + { category = "Debug", id = "RemFadedObjs", name = "Rem Faded Objs", editor = "bool", default = true, log = true, help = "Remove border objects that wont be seen from the playable zone as they are always faded away"}, + { category = "Debug", id = "StepMode", name = "Step Mode", editor = "set", default = set(), items = smodes }, + { category = "Debug", id = "StepTime", name = "Step Time (ms)", editor = "number", default = 300, help = "Delay in each step during Step debug mode. Set to -1 to trigger a pause.", buttons = {{name = "Toggle Pause", func = "ActionTogglePause"}, {name = "Interrupt", func = "ActionInterrupt"}} }, + { category = "Debug", id = "Overlay", name = "Overlay Mode", editor = "set", default = set(), update_dbg = true, items = omodes, max_items_in_set = 1}, + { category = "Debug", id = "OverlayAlpha", name = "Overlay Alpha (%)", editor = "number", default = 30, slider = true, min = 0, max = 100, dont_save = true }, + { category = "Debug", id = "OverlayEdges", name = "Overlay Edges", editor = "bool", default = true, dont_save = true, update_dbg = true, }, + { category = "Debug", id = "InspectMode", name = "Inspect Mode", editor = "set", default = set(), dont_save = true, update_dbg = true, items = imodes, max_items_in_set = 1}, + { category = "Debug", id = "InspectFilter", name = "Inspect Filter", editor = "set", default = set(), dont_save = true, update_dbg = true, items = function() return PrefabTagsCombo() end, help = "Show only prefabs with the selected tags" }, + { category = "Debug", id = "InspectPattern",name = "Inspect Prefab", editor = "text", default = "", dont_save = true, update_dbg = true, buttons = {{name = "View", func = "ViewInspectedPrefab"}}, help = "Show only prefabs with names matching this pattern" }, + + { category = "Debug", id = "SelectedPrefab",name = "Selected Prefab", editor = "text", default = "", dont_save = true, buttons = {{name = "Goto", func = "GotoPrefabAction"}} }, + { category = "Debug", id = "SelectedPoi", name = "Selected Info", editor = "text", default = "", dont_save = true}, + { category = "Debug", id = "SelectedTags", name = "Selected Tags", editor = "text", default = "", dont_save = true}, + { category = "Debug", id = "SelectedMark", name = "Selected Mark", editor = "number", default = 0, dont_save = true}, + { category = "Debug", id = "SelectedBreak", name = "Selected Break", editor = "bool", default = false, dont_save = true}, + + { category = "Results", id = "PreviewSet", name = "Preview Name", editor = "set", default = def_pn, items = pmodes, object_update = true, max_items_in_set = 1 }, + { category = "Results", id = "GridPreview", name = "Preview Grid", editor = "grid", default = false, min = 128, max = 512, frame = 1, color = true, invalid_value = 0 }, + { category = "Results", id = "MarkGrid", name = "Prefab Marks", editor = "grid", default = false, no_edit = true }, + { category = "Results", id = "OverlapGrid", name = "Prefab Overlap", editor = "grid", default = false, no_edit = true }, + { category = "Results", id = "CoverGrid", name = "Area Cover", editor = "grid", default = false, no_edit = true }, + { category = "Results", id = "PTypeGrid", name = "PType Marks", editor = "grid", default = false, no_edit = true }, + { category = "Results", id = "PrefabCount", name = "Prefab Count", editor = "number", default = 0, log = true}, + { category = "Results", id = "PrefabVisible", name = "Prefab Visible (%)", editor = "number", default = 0, scale = pct_mul, log = true }, + { category = "Results", id = "OverlapMax", name = "Max Overlap Prefabs", editor = "number", default = 0, log = true }, + { category = "Results", id = "OverlapPct", name = "Area Overlap (%)", editor = "number", default = 0, scale = pct_mul, log = true }, + { category = "Results", id = "AreaUncovered", name = "Area Uncovered (%)", editor = "number", default = 0, scale = pct_mul, log = true }, + { category = "Results", id = "AreaSpill", name = "Area Spill (%)", editor = "number", default = 0, scale = pct_mul, log = true }, + { category = "Results", id = "ObjectCount", name = "Object Count", editor = "number", default = 0, log = true }, + { category = "Results", id = "RemObjects", name = "Removed Objects", editor = "number", default = 0, log = true }, + { category = "Results", id = "RemColls", name = "Removed Collections", editor = "number", default = 0, log = true }, + { category = "Results", id = "PlacedColls", name = "Placed Collections", editor = "number", default = 0, log = true }, + { category = "Results", id = "RasterMem", name = "Raster Mem (MB)", editor = "number", default = 0, scale = 1024*1024 }, + { category = "Results", id = "MixHash", name = "Prefab Types Hash", editor = "number", default = 0, log = true }, + { category = "Results", id = "PrefabHash", name = "Prefab Placed Hash", editor = "number", default = 0, log = true }, + { category = "Results", id = "MarkHash", name = "Marks Hash", editor = "number", default = 0, log = true }, + { category = "Results", id = "FirstRand", name = "First Rand", editor = "number", default = 0, log = true }, + { category = "Results", id = "LastRand", name = "Last Rand", editor = "number", default = 0, log = true }, + { category = "Results", id = "LocateTime", name = "Locate Time", editor = "number", default = 0, scale = "sec" }, + { category = "Results", id = "PoiTime", name = "Locate POI Time", editor = "number", default = 0, scale = "sec" }, + { category = "Results", id = "RasterizeTime", name = "Rasterize Time", editor = "number", default = 0, scale = "sec" }, + { category = "Results", id = "ObjectTime", name = "Object Time", editor = "number", default = 0, scale = "sec" }, + { category = "Results", id = "GenStep", name = "Generate Step", editor = "number", default = 0, scale = "m" }, + + { category = "Results", id = "PlacedPrefabs", name = "Placed Prefabs", editor = "text", default = false, lines = 10, text_style = "GedConsole", buttons = {{name = "Sort", func = "ActionSortPrefabs"}} }, + { category = "Results", id = "PrefabTypes", name = "Prefab Types", editor = "text", default = false, lines = 10, text_style = "GedConsole", log = true, buttons = {{name = "Sort", func = "ActionSortPrefabTypes"}} }, + { category = "Results", id = "VisiblePrefabs",name = "Prefab Visibility", editor = "text", default = false, lines = 10, text_style = "GedConsole", log = true, buttons = {{name = "Sort", func = "ActionSortVisible"}} }, + { category = "Results", id = "PlacedObjects", name = "Placed Objects", editor = "text", default = false, lines = 10, text_style = "GedConsole", log = true, buttons = {{name = "Sort", func = "ActionSortObjects"}} }, + { category = "Results", id = "PlacedPOI", name = "Placed POI", editor = "text", default = false, lines = 10, text_style = "GedConsole", log = true, buttons = {{name = "Sort", func = "ActionSortPoi"}} }, + { category = "Results", id = "PrefabList", editor = "prop_table", default = false, no_edit = true }, + }, + gen_thread = false, + gen_handles = false, + + DbgInit = empty_func, + DbgDone = empty_func, + DbgUpdate = empty_func, + DbgOnModified = empty_func, + + GridOpType = "Map Biome Fill", + recalc_on_change = false, +} + +do + for i, prop in ipairs(BiomeFiller.properties) do + if prop.category == "Results" and not prop.items then + prop.dont_save = true + prop.read_only = true + end + end +end + +function BiomeFiller:GetGridModes() + return { + Marks = self.MarkGrid, + Overlap = self.OverlapGrid, + Cover = self.CoverGrid, + Types = self.PTypeGrid, + } +end + +function BiomeFiller:CollectTags(tags) + tags.Terrain = true + tags.Objects = true + tags.Pause = true + return GridOp.CollectTags(self, tags) +end + +function BiomeFiller:SetGridInput(state, grid) + if not next(GetPrefabTypeList()) then + state.proc:AddLog(self:GetFullName() .. ": No prefab types found!", state) + return + end + + PauseInfiniteLoopDetection("BiomeFiller.Generate") + SuspendPassEdits("BiomeFiller.Generate") + SuspendObjModified("BiomeFiller.Generate") + + -- allow gen proc restarting (via Ged) + local map_thread = CurrentThread() + local gen_thread = CreateRealTimeThread(function() + self:Generate(state, grid) + Wakeup(map_thread) + end) + self.gen_thread = gen_thread + while self.gen_thread == gen_thread and IsValidThread(gen_thread) and not WaitWakeup(100) do + -- wait for the gen thread to finish + end + if self.gen_thread ~= gen_thread then + return + end + + ResumeObjModified("BiomeFiller.Generate") + ResumePassEdits("BiomeFiller.Generate") + ResumeInfiniteLoopDetection("BiomeFiller.Generate") +end + +function BiomeFiller:Generate(state, ptype_grid) + local gen_thread = CurrentThread() + if gen_thread ~= self.gen_thread then + return + end + + self:DbgInit() + + state = state or empty_table + local debug = state.run_mode ~= "GM" and (Platform.editor or Platform.developer) + local dump = state.dump + local gen_mode = self.GenMode + local step + local step_prefab, step_poi + local prefab_stats, prefab_stat_count + local AddPrefabStat = empty_func + local Min, Max = Min, Max + local irOutside = const.irOutside + local group_dist_pct = const.RandomMap.PrefabGroupSimilarDistPct + local map_divs = const.RandomMap.PrefabRasterParallelDiv + local group_attract = const.RandomMap.PrefabGroupSimilarWeight + local max_map_size = const.RandomMap.PrefabMaxMapSize + local raster_cache_memory = const.RandomMap.PrefabRasterCacheMemory + local table_append = table.append + local table_get = table.get + local ipairs, pairs = ipairs, pairs + local BraidRandom = BraidRandom + local LerpRandRange = LerpRandRange + local unpack = table.unpack + local table_keys = table.keys + local MulDivRound = MulDivRound + local GetHeight = terrain.GetHeight + local GetSlopeOrientation = terrain.GetSlopeOrientation + local IsPointInBounds = terrain.IsPointInBounds + + local start_seed = state.rand or AsyncRand() + local rand_seed = BraidRandom(start_seed) + + local g_print = b_print + if dump then + g_print = function(...) + dump(print_format("\n--", ...)) + return b_print(...) + end + end + + if debug then + if next(self.StepMode or empty_table) then + if self.StepMode.Prefab then + step_prefab = true + map_divs = 1 + end + if self.StepMode.POI then + step_poi = true + end + step = function(fmt, ...) + if self.dbg_interrupt then + return + end + if fmt then + printf(fmt, ...) + end + self:DbgOnModified() + if self.StepTime < 0 then + self.dbg_paused = true + else + Sleep(self.StepTime) + end + if self.dbg_paused then + print("Pause") + while self.dbg_paused do + WaitMsg(self) + end + print("Resume") + end + if gen_thread ~= self.gen_thread then + Halt() + end + end + end + prefab_stats, prefab_stat_count = {}, {} + AddPrefabStat = function(prefab, name, value) + local stat = prefab_stats[prefab] or {} + local count = prefab_stat_count[prefab] or {} + stat[name] = (stat[name] or 0) + (value or 1) + count[name] = (count[name] or 0) + 1 + prefab_stats[prefab] = stat + prefab_stat_count[prefab] = count + end + end + + local mw, mh = terrain.GetMapSize() + assert(mw == mh) + if mw > max_map_size then + g_print("map larger than", max_map_size) + return + end + local gw, gh = ptype_grid:size() + if gw ~= gh or mw / gw == 0 or mw % gw ~= 0 or (mw / gw) % type_tile ~= 0 then + return "Invalid mix grid size!" + end + local work_step = mw / gw + local work_ratio = work_step / type_tile + self.GenStep = work_step + + local function new_grid(packing) + return NewComputeGrid(gw, gh, "u", packing or 16) + end + local function free_grid(grid) + if grid then grid:free() end + end + + local function rand_init(name, ...) + rand_seed = xxhash(start_seed, name, ...) + if dump then + dump("\n*** INITRAND %d %s", rand_seed, name) + end + return rand_seed + end + local function trand(tbl, calc_weight) + rand_seed = BraidRandom(rand_seed) + return table.weighted_rand(tbl, calc_weight, rand_seed) + end + local function crand(chance, max_chance) + rand_seed = BraidRandom(rand_seed) + return LerpRandRange(rand_seed, max_chance or 100) < chance + end + local function rand(min, max) + rand_seed = BraidRandom(rand_seed) + return min and LerpRandRange(rand_seed, min, max) or rand_seed + end + + local prefab_markers = PrefabMarkers + local exported_prefabs = ExportedPrefabs + local ptype_to_preset = PrefabTypeToPreset + local prefab_list = {} + + local add_idx = 0 + local prefabs_count = {} + local bx_changes, levels_count, placed_marks + local height_out_of_lims + local mark_grid, ptype_grid_res, overlap_grid + local locate_time, poi_time, raster_time = 0, 0, 0 + local prefab_tag_loc, prefabs_to_persist = {}, {} + local point_pack, point_unpack = point_pack, point_unpack + + local function FindAndRasterPrefabs() + rand_init("FindAndRasterPrefabs") + local ptype_to_prefabs = PrefabTypeToPrefabs + local poi_type_to_preset = PrefabPoiToPreset + local idx_to_ptype = GetPrefabTypeList() + local ptypes_found, ptype_to_tags, ptype_to_area, ptype_to_idx = {}, {}, {}, {} + for idx, area in pairs(GridLevels(ptype_grid)) do + local ptype = idx_to_ptype[idx] + assert(ptype) + if ptype then + ptypes_found[#ptypes_found + 1] = ptype + ptype_to_area[ptype] = area + ptype_to_idx[ptype] = idx + local tags = ptype_to_preset[ptype].Tags + if next(tags) then + ptype_to_tags[ptype] = tags + end + end + end + local ptype_cmp = PrefabType.Compare + table.sort(ptypes_found, function(a, b) + local pa, pb = ptype_to_preset[a], ptype_to_preset[b] + return ptype_cmp(pa, pb) + end) + + mark_grid = new_grid() + ptype_grid_res = new_grid() + local cover_grid + if debug then + overlap_grid = new_grid() + cover_grid = new_grid() + self.MarkGrid = mark_grid + self.OverlapGrid = overlap_grid + self.CoverGrid = cover_grid + self.PTypeGrid = ptype_grid_res + end + + local prefab_tags, prefab_to_persist_tags = {}, {} + local persistable_tags = GetPrefabTagsPersistable() + for _, prefab in ipairs(prefab_markers) do + local poi_tags = table_get(poi_type_to_preset, prefab.poi_type, "Tags") + local ptype_tags = ptype_to_tags[prefab.type] + local marker_tags = prefab.tags + if next(ptype_tags) or next(marker_tags) or next(poi_tags) then + local tags = {} + table_append(tags, ptype_tags) + table_append(tags, marker_tags) + table_append(tags, poi_tags) + tags = table_keys(tags, true) + local persist_tags + for _, tag in ipairs(tags) do + if persistable_tags[tag] then + persist_tags = table.create_add(persist_tags, tag) + end + end + prefab_to_persist_tags[prefab] = persist_tags + prefab_tags[prefab] = tags + end + end + local persisted_prefabs, persisted_tag_count + if self.LoadPrefabLoc then + for _, entry in ipairs(mapdata.PersistedPrefabs) do + local name = entry[1] + local prefab = prefab_markers[name] + local persist_tags = prefab and prefab_to_persist_tags[prefab] + if not persist_tags then + g_print("Non persistable prefab loaded:", name) + else + persisted_prefabs = table.create_add(persisted_prefabs, entry) + persisted_tag_count = persisted_tag_count or {} + for _, tag in pairs(persist_tags) do + persisted_tag_count[tag] = (persisted_tag_count[tag] or 0) + 1 + end + end + end + end + local function IsPrefabAllowed(prefab) + if (prefab.max_count or -1) == (prefabs_count[prefab] or 0) then + return + end + if persisted_tag_count then + local tags = prefab_tags[prefab] + for _, tag in ipairs(tags) do + local tag_count = persisted_tag_count[tag] + if tag_count and tag_count <= 0 then + return + end + end + end + return true + end + + local map_angle = rand(360*60) + local similar_grids = {} + + local function MulDivWeight(weight, mul, div, pow) + for i=1,(pow or 0) do + weight = weight * mul / div + end + return weight + end + + local _overlap_reduct, _fit_effort -- prefab type props + local _place_x, _place_y, _radius_target, _radius_range -- prefab location props + + local radius_getters = PrefabRadiusEstimators() + local get_radius + + local repeat_weights = {} + local function prefab_weight(prefab) + local weight = MulDivRound(unit_weight, prefab.weight, 100) + --[[ + local sweight = 0 + for tag in pairs(prefab.tags) do + local sgrid = similar_grids[tag] + if sgrid then + sweight = sweight + sgrid:get(_place_x, _place_y) - 1 + end + end + if sweight > 0 then + weight = weight + MulDivRound(weight, sweight, group_attract) + end + --]] + weight = MulDivWeight(weight, prefab.min_radius, prefab.max_radius, _overlap_reduct) -- prioritize prefabs with better incircle to excircle radius ratio to reduce overlapping + local radius = get_radius(prefab) + local radius_err = abs(_radius_target - radius) + weight = MulDivWeight(weight, _radius_range - radius_err, _radius_range, _fit_effort) -- prioritize prefabs with radius closer to the desired one + local repeat_weight = repeat_weights[prefab] + if repeat_weight then + weight = MulDivRound(weight, repeat_weight, unit_weight) + end + return 1 + weight + end + + local function prefab_add(prefab_list, prefab, x, y, radius, mix_grid_idx, ptype, try_persist, skip_raster, angle) + --assert(ptype_grid:get(x, y) == mix_grid_idx) + assert((prefab.max_count or -1) ~= (prefabs_count[prefab] or 0)) + local count = (prefabs_count[prefab] or 0) + 1 + prefabs_count[prefab] = count + local reduct = prefab.repeat_reduct or 0 + if reduct > 0 then + local rstep = reduct / 10 + local weight = unit_weight + for i=1,count do + local new_weight = weight * (100 - reduct) / 100 + if new_weight == weight then + break + end + weight = new_weight + reduct = reduct - rstep + if reduct <= 0 then + break + end + end + repeat_weights[prefab] = weight + end + local mx, my = x * work_step, y * work_step + local mz = GetHeight(mx, my) + local prefab_pos = point(mx, my, mz) + assert(IsPointInBounds(prefab_pos)) + local bbox + if not skip_raster then + local excircle_m = prefab.max_radius * type_tile + bbox = box(mx - excircle_m, my - excircle_m, mx + excircle_m, my + excircle_m) + end + + if not angle then + local angle_variation = prefab.angle_variation or 180*60 + angle = rand(-angle_variation, angle_variation) - (prefab.angle or 0) + local rotation_mode = prefab.rotation_mode + if rotation_mode == "slope" then + angle = angle + GetSlopeOrientation(prefab_pos, work_step * prefab.min_radius / 2) + elseif rotation_mode == "map" then + angle = angle + map_angle + end + end + + local raster = { + pos = prefab_pos, + angle = angle, + place_idx = 0, + place_mask_idx = mix_grid_idx, + } + add_idx = add_idx + 1 + prefab_list[#prefab_list + 1] = {prefab, raster, add_idx, ptype, bbox} + + local remaining = max_int + local tags = prefab_tags[prefab] + if tags then + local loc = point_pack(x, y, radius) + for _, tag in ipairs(tags) do + local loc_list = prefab_tag_loc[tag] + if not loc_list then + prefab_tag_loc[tag] = { loc } + else + loc_list[#loc_list + 1] = loc + end + local tag_count = persisted_tag_count and persisted_tag_count[tag] + if tag_count then + assert(tag_count > 0) + tag_count = tag_count - 1 + persisted_tag_count[tag] = tag_count + remaining = Min(remaining, tag_count) + end + end + if try_persist and prefab_to_persist_tags[prefab] then + local name = prefab_markers[prefab] + prefabs_to_persist[#prefabs_to_persist + 1] = { name, ptype, x, y, angle } + end + end + return count, remaining + end + + local skip = debug and { + Height = not gen_mode.Height, + Type = not gen_mode.Type, + Grass = not gen_mode.Grass, + } + local raster_params = { + place_grid = mark_grid, + place_mask = ptype_grid, + place_mask_res = ptype_grid_res, + overlap_grid = overlap_grid, + dither_seed = rand(), + height_min = self.MinHeight, + height_max = self.MaxHeight, + } + local raster_meta = {__index = raster_params} + local prefab_cache = {} + local cache_info = {} + local current_memory = 0 + local peak_memory = 0 + local tasks_count = map_divs * map_divs + local PREFAB_META, PREFAB_RASTER, PREFAB_IDX, PREFAB_TYPE, PREFAB_BOX = 1, 2, 3, 4, 5 + local function FreeCache(prefab) + local cache = prefab_cache[prefab] + if not cache then + assert(false, "Missing cache") + return + end + local data = cache.__index + free_grid(data.height_grid) + free_grid(data.type_grid) + free_grid(data.grass_grid) + free_grid(data.mask_grid) + prefab_cache[prefab] = nil + current_memory = current_memory - (prefab.required_memory or 0) + assert(current_memory >= 0, "Wrong memory estimation") + end + local function LoadCache(prefab) + local cache, ignore_memory_limits + while true do + cache = prefab_cache[prefab] + if cache then + break + elseif cache == false then -- load error + return + end + local required_memory = prefab.required_memory or 0 + if ignore_memory_limits or current_memory + required_memory <= raster_cache_memory then + local preload_start_time = GetPreciseTicks() + local data = PrefabPreload(prefab, raster_meta, skip) + if debug then + AddPrefabStat(prefab, "grid_load_time", GetPreciseTicks() - preload_start_time) + end + cache = data and {__index = data} or false + if cache then + current_memory = current_memory + required_memory + peak_memory = Max(peak_memory, current_memory) + end + prefab_cache[prefab] = cache + break + end + local min_prefab + local min_required_memory = max_int + local locked = 0 + for i=1,#cache_info do + local prefab_i = cache_info[i] + if prefab_cache[prefab_i] then + local required_memory_i = prefab_i.required_memory or 0 + if min_required_memory > required_memory_i then + if cache_info[prefab_i].locks == 0 then + min_prefab = prefab_i + min_required_memory = required_memory_i + else + locked = locked + 1 + end + end + end + end + if min_prefab then + FreeCache(min_prefab) + elseif locked > 0 then + WaitMsg("PrefabCacheUnloaded") + else + g_print("Unable to free enough memory for rasterization!") + ignore_memory_limits = true + end + end + if not cache then + return + end + local info = cache_info[prefab] + assert(info, "Load cache error") + if info then + info.locks = info.locks + 1 + end + return cache + end + local function UnloadCache(prefab) + local info = cache_info[prefab] + if not info or info.locks <= 0 or info.count <= 0 then + assert(false, "Unload cache error") + return + end + info.locks = info.locks - 1 + info.count = info.count - 1 + Msg("PrefabCacheUnloaded") + if info.count ~= 0 then + return + end + assert(info.locks == 0, "Invalid lock count") + FreeCache(prefab) + end + local function WaitRaster(prefabs_to_raster) + if #prefabs_to_raster == 0 then + return + end + local start_time_raster = GetPreciseTicks() + -- try to reduce object removal from overlapping: + local function prefab_list_sort(a, b) + local p1, p2 = a[PREFAB_META], b[PREFAB_META] + if p1 ~= p2 then + local ptype1, ptype2 = a[PREFAB_TYPE], b[PREFAB_TYPE] + if ptype1 ~= ptype2 then + local preset1, preset2 = ptype_to_preset[ptype1], ptype_to_preset[ptype2] + if preset1 and preset2 then + return ptype_cmp(preset1, preset2) + end + end + local mul1 = (p1.obj_count or 1) * (p2.total_area or 1) + local mul2 = (p2.obj_count or 1) * (p1.total_area or 1) + if mul1 ~= mul2 then + return mul1 < mul2 + end + local mul1 = p1.max_radius * p2.min_radius + local mul2 = p2.max_radius * p1.min_radius + if mul1 ~= mul2 then + return mul1 < mul2 + end + end + return a[PREFAB_IDX] < b[PREFAB_IDX] + end + table.sort(prefabs_to_raster, prefab_list_sort) + -- add the new prefabs in to the final list + local place_idx = #prefab_list + for _, info in ipairs(prefabs_to_raster) do + place_idx = place_idx + 1 + local raster = info[PREFAB_RASTER] + raster.place_idx = place_idx + prefab_list[place_idx] = info + end + + local waiting = {} + local thread_idx = 0 + local y0 = 0 + for y=1,map_divs do + local y1 = MulDivRound(mh, y, map_divs) + assert(y1 % type_tile == 0) + local x0 = 0 + for x=1,map_divs do + local x1 = MulDivRound(mw, x, map_divs) + assert(x1 % type_tile == 0) + local mbox = box(x0, y0, x1, y1) + for i=1,#prefabs_to_raster do + local prefab, raster, add_idx, ptype, bbox = unpack(prefabs_to_raster[i]) + if bbox and bbox:Intersect2D(mbox) ~= irOutside then + local info = cache_info[prefab] + if not info then + info = { + count = 1, + locks = 0, + } + cache_info[prefab] = info + cache_info[#cache_info + 1] = prefab + else + info.count = info.count + 1 + end + end + end + local thread = CreateRealTimeThread(function() + for i=1,#prefabs_to_raster do + local prefab, raster, add_idx, ptype, bbox = unpack(prefabs_to_raster[i]) + local cache = bbox and bbox:Intersect2D(mbox) ~= irOutside and LoadCache(prefab) + if cache then + setmetatable(raster, cache) + local raster_start_time = GetPreciseTicks() + local err, ibox, out_of_lims = AsyncGridSetTerrain(raster, mbox) + if debug then + AddPrefabStat(prefab, "grid_place_time", GetPreciseTicks() - raster_start_time) + end + if err then + g_print("Failed to rasterize prefab", prefab_markers[prefab], err) + elseif ibox then + bx_changes = bx_changes or box() + bx_changes = Extend(bx_changes, ibox) + end + if out_of_lims then + height_out_of_lims = true + end + setmetatable(raster, nil) + UnloadCache(prefab) + if step_prefab then + terrain.InvalidateHeight(ibox) + terrain.InvalidateType(ibox) + DbgClear() + local name = prefab_markers[prefab] + DbgShowPrefab(raster.pos, name, white, raster.place_idx, prefab.min_radius, prefab.max_radius) + step("Terrain %d %s", add_idx, name) + end + end + end + waiting[CurrentThread()] = nil + Wakeup(gen_thread) + end) + thread_idx = thread_idx + 1 + waiting[thread] = thread_idx + x0 = x1 + end + y0 = y1 + end + while next(waiting) do + WaitWakeup(1000) + for thread in pairs(waiting) do + if not IsValidThread(thread) then + waiting[thread] = nil + end + end + end + local inv_bbox = bx_changes:grow(type_tile) + terrain.FixHeightBorder(inv_bbox) + if not step_prefab then + terrain.InvalidateHeight(inv_bbox) + terrain.InvalidateType(inv_bbox) + end + raster_time = raster_time + (GetPreciseTicks() - start_time_raster) + end + + local dist_mask + local dist_grid = new_grid() + local dist_prec = 8 + local dist_tile = work_ratio * dist_prec + + local function WaitFitAndRaster(ptype) + local ptype_preset = ptype_to_preset[ptype] + if dump then + dump("\n----\nPTYPE '%s' %s", ptype, TableToLuaCode(ptype_preset)) + end + + get_radius = radius_getters[ptype_preset.RadiusEstim] + _overlap_reduct = ptype_preset.OverlapReduct > 0 and 2 ^ (ptype_preset.OverlapReduct - 1) or 0 -- prefab_weight + _fit_effort = ptype_preset.FitEffort > 0 and 2 ^ (ptype_preset.FitEffort - 1) or 0 -- prefab_weight + + local prefabs = {} + local respect_bounds = ptype_preset.RespectBounds + local place_radius = ptype_preset.PlaceRadius / type_tile + local radius_min, radius_max = max_int, 0 + for _, prefab in ipairs(ptype_to_prefabs[ptype] or empty_table) do + local poi_type = prefab.poi_type or "" + if poi_type == "" and IsPrefabAllowed(prefab) then + local radius = get_radius(prefab) + if radius >= place_radius then + radius_max = Max(radius_max, radius) + radius_min = Min(radius_min, radius) + prefabs[#prefabs + 1] = prefab + end + end + end + if #prefabs == 0 then + return + end + _radius_range = radius_max - radius_min + 1 -- prefab_weight + + local ptype_idx = ptype_to_idx[ptype] + local mix_grid_idx = respect_bounds and ptype_idx + local min_fill_ratio = ptype_preset.MinFillRatio -- pct + local max_fill_error = ptype_preset.MaxFillError -- promil + local max_pass = ptype_preset.FitPasses + local placed_count = 0 + for pass = 1, max_pass do + local start_time_fit = GetPreciseTicks() + local prefab_list_pass = {} + local area_remaining, all_area = GridMaskMark(ptype_grid, dist_grid, ptype_idx, ptype_grid_res) + if not area_remaining then + return + end + local min_area_remaining = all_area - all_area * min_fill_ratio / 100 + if area_remaining <= min_area_remaining + all_area * max_fill_error / 1000 then + return + end + GridDistance(dist_grid, dist_tile, radius_max * dist_prec) + if dump then + local pct_x100 = all_area and MulDivRound(10000, all_area - area_remaining, all_area) or 0 + dump("\nPASS %d/%d START %s - %2d prefab(s) | form '%s' | min fill %2d%% | filled area %2d.%02d%%\n", pass, max_pass, ptype, #prefabs, ptype_preset.RadiusEstim, min_fill_ratio, pct_x100/100, pct_x100%100) + end + rand_init("FindAndRasterPrefabs", pass, ptype) + local pass_placed_count = 0 + GridRandomEnumMarkDist(dist_grid, rand(), dist_tile, function(x, y, dist, area) + if area <= min_area_remaining then + return + end + _radius_target = Max(dist / dist_prec, radius_min) -- prefab_weight + _place_x, _place_y = x, y -- prefab_weight + local prefab, idx = trand(prefabs, prefab_weight) + if not prefab then + g_print("Failed to find prefab for type", ptype) + return + end + local radius = get_radius(prefab) + local count, remaining = prefab_add(prefab_list_pass, prefab, x, y, radius, mix_grid_idx, ptype) + if (prefab.max_count or -1) == count or remaining <= 0 then + table.remove(prefabs, idx) + end + if dump then + pass_placed_count = pass_placed_count + 1 + local weight = prefab_weight(prefab) + local total_weight, min_weight, max_weight = 0 + for i=1,#prefabs do + local weight_i = prefab_weight(prefabs[i]) + total_weight = total_weight + weight_i + min_weight, max_weight = MinMax(weight_i, min_weight, max_weight) + end + local avg_weight = total_weight / #prefabs + dump("PREFAB %4d | rand 0x%016X | weight %5d (%5d -%5d -%5d) | dist %3d (rad %3d - %3d) | pos (%4d, %4d) | '%s'", + pass_placed_count, rand_seed, weight, min_weight, avg_weight, max_weight, + _radius_target, prefab.min_radius, prefab.max_radius, x, y, prefab_markers[prefab]) + end + return radius * dist_prec + end) + if dump then + placed_count = placed_count + pass_placed_count + dump("\nPASS %d/%d END %s PLACED %d TOTAL %d\n", pass, max_pass, ptype, pass_placed_count, placed_count) + end + locate_time = locate_time + (GetPreciseTicks() - start_time_fit) + if #prefab_list_pass == 0 then + return + end + WaitRaster(prefab_list_pass) + end + end + for _, ptype in ipairs(ptypes_found) do + WaitFitAndRaster(ptype) + end + + local function WaitPlaceAndRasterPois() + if not gen_mode.POI then + return + end + + local start_time_poi = GetPreciseTicks() + + local ptype_to_poi_prefabs = {} + local poi_type_to_ptypes = {} + local prefab_to_ptype_map = {} + local prefab_list_poi = {} + + local dbg_poi_count + local poi_count, poi_marks, poi_types = {}, {}, {} + local function poi_weight(prefab) + local weight = MulDivRound(unit_weight, prefab.weight, 100) + local count = prefabs_count[prefab] or 0 + if count > 0 then + local max_count = prefab.max_count or -1 + weight = max_count > 0 and MulDivRound(weight, max_count - count, max_count) or weight + end + return weight + end + local tag_to_tag_limits = GetPrefabTagsLimits() + local slope_grid = self:GetGridInput(self.SlopeGrid) + local slope_mask + local function PlacePois(poi_type, poi_area, ptypes, partial_count, max_count) + local total_count = poi_count[poi_type] or 0 + if partial_count == 0 or total_count == max_count then + return + end + local prefabs + local min_radius, max_radius = max_int, 0 + for _, ptype in ipairs(ptypes) do + for _, prefab in ipairs(ptype_to_poi_prefabs[ptype]) do + if poi_type == prefab.poi_type + and (not poi_area or poi_area == prefab.poi_area) + and prefab_to_ptype_map[prefab][ptype] + and IsPrefabAllowed(prefab) then + prefabs = prefabs or {} + if not prefabs[prefab] then + prefabs[prefab] = ptype + prefabs[#prefabs + 1] = prefab + local radius = get_radius(prefab) + max_radius = Max(max_radius, radius) + min_radius = Min(min_radius, radius) + end + end + end + end + if not prefabs then + return + end + if dump then + dump("\nPOI '%s' START {%s} - %2d prefab(s), count %d/%d\n", poi_type, table.concat(ptypes, ","), #prefabs, partial_count, max_count) + end + rand_init("PlacePois", poi_type, unpack(ptypes)) + local ptype_single + if #ptypes == 1 then + ptype_single = ptypes[1] + local ptype_idx = ptype_to_idx[ptype_single] + if ptype_idx then + GridMask(ptype_grid, dist_grid, ptype_idx) + end + else + local grid_idx_remap = {} + for _, ptype in ipairs(ptypes) do + local ptype_idx = ptype_to_idx[ptype] + if ptype_idx then + grid_idx_remap[ptype_idx] = 1 + end + end + GridReplace(ptype_grid, dist_grid, grid_idx_remap, 0) + end + local ptypes_map = table.invert(ptypes) + local poi_preset = poi_types[poi_type] + local fill_radius = poi_preset.FillRadius * dist_prec / type_tile + if fill_radius > 0 then + GridNot(dist_grid) + GridDistance(dist_grid, dist_tile, fill_radius) + GridMask(dist_grid, 0, fill_radius - 1) + GridDistance(dist_grid, dist_tile, fill_radius) + GridMask(dist_grid, fill_radius) + end + + local to_mark = {} + local function MarkDist(x, y, radius, min_dist, max_dist) + local pos = point_pack(x, y) + local limits = to_mark[pos] + if not limits then + limits = {} + to_mark[pos] = limits + end + if min_dist and min_dist >= 0 then + limits[1] = Max(limits[1] or min_int, radius + min_dist / type_tile) + end + if max_dist and max_dist < max_int then + limits[2] = Min(limits[2] or max_int, radius + max_dist / type_tile) + end + end + + local dist_to_same_m = poi_preset.DistToSame + for _, poi_info in pairs(poi_marks) do + local poi_type_i, x, y, radius = unpack(poi_info) + MarkDist(x, y, radius, poi_type_i == poi_type and dist_to_same_m or 0) + end + + for poi_tag in pairs(poi_preset.Tags) do + for tag, limits in pairs(tag_to_tag_limits[poi_tag]) do + local min_dist, max_dist = limits[1], limits[2] + for _, loc in ipairs(prefab_tag_loc[tag]) do + local x, y, radius = point_unpack(loc) + MarkDist(x, y, radius, min_dist, max_dist) + end + end + end + + local GridCircleSet = GridCircleSet + local limit_dist + for pos, limits in pairs(to_mark) do + local x, y = point_unpack(pos) + local min_dist, max_dist = limits[1], limits[2] + if min_dist and min_dist >= 0 then + GridCircleSet(dist_grid, 0, x, y, min_dist, 0, work_ratio) + end + if max_dist and max_dist < max_int then + if not limit_dist then + dist_mask = dist_mask or GridDest(dist_grid) + dist_mask:clear() + limit_dist = true + end + GridCircleSet(dist_mask, 1, x, y, max_dist, 0, work_ratio) + end + end + if limit_dist then + GridAnd(dist_grid, dist_mask) + end + + local frame = (mapdata.PassBorder - poi_preset.DistToPlayable) / type_tile + if frame > 0 then + if frame >= gw / 2 or frame >= gh / 2 then + g_print("Dist To Playable Area for", poi_type, "leaves no available space for placement!") + end + GridFrame(dist_grid, frame, 0) + end + local slope_min, slope_max = poi_preset.TerrainSlopeMin, poi_preset.TerrainSlopeMax + if slope_grid and (slope_min > 0 or slope_max < 90*60) then + if not slope_mask then + slope_mask = GridDest(dist_grid) + slope_grid = GridMakeSame(slope_grid, slope_mask) + end + GridMask(slope_grid, slope_mask, slope_min, slope_max) + GridAnd(dist_grid, slope_mask) + end + GridDistance(dist_grid, dist_tile, max_radius * dist_prec) + + if step_poi then + DbgClear() + local show_grid, poi_grid = GridDest(dist_grid), GridDest(dist_grid) + GridMask(dist_grid, show_grid, 1, max_int) + local colors = { yellow, green, cyan, purple, orange, white, black } + local palette = { [0] = 0, red } + for i, prefab in ipairs(prefabs) do + local radius = get_radius(prefab) + GridMask(dist_grid, poi_grid, radius * dist_prec, max_int) + GridAdd(show_grid, poi_grid) + palette[i + 1] = palette[i + 1] or colors[1 + (i - 1) % #colors] + end + DbgShowTerrainGrid(show_grid, palette) + step("POI %s START {%s}: %2d prefab(s) available", poi_type, table.concat(ptypes, ","), #prefabs) + end + + -- todo: make forced mode to ensure the placement of at least one? + local placed_count = 0 + local place_model = poi_preset.PlaceModel + local skip_raster = place_model ~= "terrain" + local place_mark = place_model ~= "point" + local dist_to_same = dist_to_same_m / type_tile + local prefab, idx, radius, max_radius, poi_radius + GridRandomFetchMarkDist(dist_grid, rand(), dist_tile, function(x, y, dist) + if idx then + if x < 0 then + table.remove(prefabs, idx) + else + local ptype = ptype_single + if not ptype then + local ptype_idx = ptype_grid:get(x, y) + ptype = idx_to_ptype[ptype_idx] + if not ptype or not ptypes_map[ptype] then + ptype = prefabs[prefab] + end + end + + assert(prefab_to_ptype_map[prefab][ptype]) + local count, remaining = prefab_add(prefab_list_poi, prefab, x, y, radius, false, ptype, true, skip_raster) + if (prefab.max_count or -1) == count or remaining <= 0 then + table.remove(prefabs, idx) + end + if place_mark then + poi_marks[#poi_marks + 1] = { poi_type, x, y, radius } + end + placed_count = placed_count + 1 + total_count = total_count + 1 + if debug then + if dump then + dump("POI %4d | rand 0x%016X | pos (%4d, %4d) | '%s'", placed_count, rand_seed, x, y, prefab_markers[prefab]) + end + if step_poi then + local mx, my = x * work_step, y * work_step + DbgShowPrefab(point(mx, my), prefab_markers[prefab], cyan, total_count, radius, poi_radius) + end + end + if placed_count == partial_count or total_count == max_count then + return + end + end + end + prefab, idx = trand(prefabs, poi_weight) + if not prefab then + return + end + radius = get_radius(prefab) + poi_radius = radius + dist_to_same + return radius * dist_prec, poi_radius * dist_prec + end) + poi_count[poi_type] = total_count + if debug then + local poi_name = poi_area and (poi_type .. "." .. poi_area) or poi_type + dbg_poi_count = dbg_poi_count or {} + dbg_poi_count[poi_name] = (dbg_poi_count[poi_name] or 0) + placed_count + local types_str = table.concat(ptypes, ",") + if step_poi then + step("POI %s END {%s}: %2d prefab(s) placed", poi_type, types_str, total_count) + end + if dump then + dump("\nPOI '%s' END {%s} PLACED %d TOTAL\n", poi_type, types_str, placed_count, total_count) + end + end + return placed_count + end + + for _, prefab in ipairs(prefab_markers) do + local poi_type = prefab.poi_type or "" + if poi_type ~= "" then + local poi_preset = poi_type_to_preset[poi_type] + if not poi_preset then + g_print("No such POI type", poi_type, "selected in", prefab_markers[prefab]) + else + local ptype_map + if #poi_preset.CustomTypes > 0 then + ptype_map = table.invert(poi_preset.CustomTypes) + elseif #poi_preset.PrefabTypeGroups > 0 then + local group = table.find_value(poi_preset.PrefabTypeGroups, "id", prefab.poi_area) or empty_table + ptype_map = table.invert(group.types) + else + ptype_map = {[prefab.type] = true} + end + assert(next(ptype_map)) + prefab_to_ptype_map[prefab] = ptype_map + local all_ptypes = poi_type_to_ptypes[poi_type] + if not all_ptypes then + poi_type_to_ptypes[poi_type] = ptype_map + else + table.append(all_ptypes, ptype_map) + end + + for ptype in pairs(ptype_map) do + if ptype_to_idx[ptype] then + ptype_to_poi_prefabs[ptype] = table.create_add_unique(ptype_to_poi_prefabs[ptype], prefab) + if not poi_types[poi_type] then + poi_types[poi_type] = poi_preset + poi_types[#poi_types + 1] = poi_type + end + end + end + end + end + end + + local PoiCmp = PrefabPOI.Compare + table.sort(poi_types, function (poi1, poi2) + local preset1, preset2 = poi_types[poi1], poi_types[poi2] + return PoiCmp(preset1, preset2) + end) + + if dump then + dump("\n----\nPersisted prefabs loaded: %d", #(persisted_prefabs or "")) + dump("Persistable tag counters: %s\n", TableToLuaCode(persisted_tag_count)) + end + + DbgClear() + local persisted_placed = 0 + for i, entry in ipairs(persisted_prefabs) do + local name, ptype, x, y, angle = unpack(entry) + local prefab = prefab_markers[name] + local poi_type = prefab.poi_type or "" + local poi_preset = poi_types[poi_type] + if not poi_preset then + g_print("Persisted prefab", name, "has invalid POI type", poi_type) + else + if not prefab_to_ptype_map[prefab][ptype] then + g_print("Persisted prefab", name, "has invalid prefab type", ptype) + end + get_radius = radius_getters[poi_preset.RadiusEstim] + local radius = get_radius(prefab) + local place_model = poi_preset.PlaceModel + local skip_raster = place_model ~= "terrain" + local place_mark = place_model ~= "point" + if place_mark then + poi_marks[#poi_marks + 1] = { poi_type, x, y, radius } + end + local show + for tag in pairs(poi_preset.Tags) do + for other_tag, limits in pairs(tag_to_tag_limits[tag]) do + local min_dist, max_dist = limits[1], limits[2] + for _, loc in ipairs(prefab_tag_loc[other_tag]) do + local xi, yi, radiusi = point_unpack(loc) + local dx, dy = x - xi, y - yi + local d = (sqrt(dx*dx + dy*dy) - radius - radiusi) * type_tile + local is_err + if min_dist and min_dist >= 0 and d < min_dist then + g_print("Persisted prefab", i, name, "is too close to tag", other_tag, "(", d, "/", min_dist, ")") + is_err = true + end + if max_dist and max_dist >= 0 and d > max_dist then + g_print("Persisted prefab", i, name, "is too far from tag", other_tag, "(", d, "/", max_dist, ")") + is_err = true + end + if is_err then + show = true + local pos = point(x, y) * type_tile + local posi = point(xi, yi) * type_tile + local r = radius * type_tile + local ri = radiusi * type_tile + DbgAddCircle(posi, ri, red) + DbgAddSegment(pos, posi, yellow) + if dump then + dump("DbgClear(); DbgAddCircle(%s, %d); DbgAddCircle(%s, %d, red); DbgAddSegment(%s, %s, yellow); ViewPos(%s, %d)\n", + ValueToLuaCode(pos), r, + ValueToLuaCode(posi), ri, + ValueToLuaCode(pos), ValueToLuaCode(posi), + ValueToLuaCode((pos + posi) / 2), pos:Dist2D(posi) + ri + r) + end + end + end + end + end + if show then + local pos = point(x, y) * type_tile + DbgAddText(string.format("[%d] %s", i, name), ValidateZ(pos):AddZ(101*guim)) + DbgAddVector(pos, 100*guim) + DbgAddCircle(pos, radius * type_tile) + end + local total_count = poi_count[poi_type] or 0 + total_count = total_count + 1 + poi_count[poi_type] = total_count + prefab_add(prefab_list_poi, prefab, x, y, radius, false, ptype, true, skip_raster, angle) + persisted_placed = persisted_placed + 1 + if debug then + if dump then + dump("PERSIST %3d | pos (%4d, %4d) | angle %6d | '%s'", persisted_placed, x, y, angle, name) + end + if step_poi then + local mx, my = x * work_step, y * work_step + DbgShowPrefab(point(mx, my), name, red, total_count, radius, poi_radius) + end + end + end + end + + for _, poi_type in ipairs(poi_types) do + local poi_preset = poi_types[poi_type] + local poi_max_count = -1 + if poi_preset.MaxCount ~= -1 then + poi_max_count = rand(Max(poi_preset.MinCount, 0), poi_preset.MaxCount) + end + local orig_max_count = poi_max_count + get_radius = radius_getters[poi_preset.RadiusEstim] + local custom_types = poi_preset.CustomTypes + local type_groups = poi_preset.PrefabTypeGroups + + if dump then + dump("\n----\nPOITYPE '%s' %s", poi_type, TableToLuaCode(poi_preset)) + end + + if #custom_types > 0 then + PlacePois(poi_type, false, custom_types, poi_max_count, orig_max_count) + elseif #type_groups > 0 then + local total_area = 0 + if poi_max_count ~= -1 then + for _, group in ipairs(type_groups) do + for _, ptype in ipairs(group.types) do + total_area = total_area + (ptype_to_area[ptype] or 0) + end + end + end + for i, group in ipairs(type_groups) do + local count = poi_max_count + if total_area > 0 and count ~= -1 and i ~= #type_groups then + local group_area = 0 + for _, ptype in ipairs(group.types) do + group_area = group_area + (ptype_to_area[ptype] or 0) + end + count = MulDivRound(poi_max_count, group_area, total_area) + total_area = total_area - group_area + end + count = PlacePois(poi_type, group.id, group.types, count, orig_max_count) + if count and poi_max_count ~= -1 then + poi_max_count = poi_max_count - count + end + end + else + local ptype_map = poi_type_to_ptypes[poi_type] + local ptypes = table_keys(ptype_map, true) + local total_area = 0 + if poi_max_count ~= -1 then + for _, ptype in ipairs(ptypes) do + total_area = total_area + (ptype_to_area[ptype] or 0) + end + end + for i, ptype in ipairs(ptypes) do + local count = poi_max_count + if total_area > 0 and count ~= -1 and i ~= #ptypes then + local ptype_area = (ptype_to_area[ptype] or 0) + count = MulDivRound(poi_max_count, ptype_area, total_area) + poi_max_count = poi_max_count - count + total_area = total_area - ptype_area + end + count = PlacePois(poi_type, false, { ptype }, count, orig_max_count) + if count and poi_max_count ~= -1 then + poi_max_count = poi_max_count - count + end + end + end + + local placed_count = poi_count[poi_type] or 0 + if placed_count < poi_preset.MinCount then + g_print("Not all", poi_type, "prefabs are placed:", placed_count, "/", poi_preset.MinCount) + end + end + poi_time = GetPreciseTicks() - start_time_poi + WaitRaster(prefab_list_poi) + if debug then + if step_poi then + DbgShowTerrainGrid(false) + end + local tmp = {} + for name, count in pairs(dbg_poi_count) do + tmp[#tmp + 1] = {name = name, count = count} + end + self.dbg_placed_poi = tmp + end + end + WaitPlaceAndRasterPois() + + free_grid(dist_grid) + free_grid(dist_mask) + placed_marks = GridLevels(mark_grid) + if debug then + self.LocateTime = locate_time + self.RasterizeTime = raster_time + self.PoiTime = poi_time + self.PrefabCount = #prefab_list + assert(add_idx == #prefab_list) + local list = {} + local prefab_hash + for i, info in ipairs(prefab_list) do + local prefab, raster = unpack(info) + list[i] = { prefab_markers[prefab], raster.pos } + if debug then + prefab_hash = xxhash(prefab.hash, prefab_hash) + end + end + self.PrefabHash = prefab_hash + self.MixHash = xxhash(ptype_grid) + self.MarkHash = xxhash(mark_grid) + self.PrefabList = list + self.RasterMem = peak_memory + local min, max = GridMinMax(overlap_grid) + local all = GridCount(overlap_grid, 0, max_int) + local overlap = GridCount(overlap_grid, 1, max_int) + self.OverlapMax = max + self.OverlapPct = to_pct(overlap, all) + + local prefabs_to_raster = 0 + local visible_prefabs = {} + local tmp = {} + for idx, info in ipairs(prefab_list) do + local prefab, raster = unpack(info) + if raster.place_mask_idx then + prefabs_to_raster = prefabs_to_raster + 1 + local visible_area = Min(placed_marks[idx] or 0, prefab.total_area) -- not accurate due to prefab rotation + assert(visible_area <= prefab.total_area) + local completely_hidden = visible_area == 0 and 1 or 0 + local stat = tmp[prefab] or {} + stat.area = (stat.area or 0) + visible_area + stat.hidden = (stat.hidden or 0) + completely_hidden + stat.count = (stat.count or 0) + 1 + tmp[prefab] = stat + end + end + for prefab, stat in pairs(tmp) do + local max_area = prefab.total_area * stat.count + assert(max_area > 0 and stat.area * work_ratio <= max_area) + visible_prefabs[#visible_prefabs + 1] = { + name = prefab_markers[prefab], + visible_area = max_area > 0 and MulDivRound(pct_100, stat.area * work_ratio, max_area) or 0, + fully_hidden = MulDivRound(pct_100, stat.hidden, stat.count), + } + end + self.dbg_visible_prefabs = visible_prefabs + + self.PrefabVisible = prefabs_to_raster > 0 and to_pct(table.count(placed_marks), prefabs_to_raster) or 0 + local mix_area = gw * gh + local area_spill, area_uncovered = GridGetCover(mark_grid, ptype_grid, cover_grid) + self.AreaUncovered = to_pct(area_uncovered, mix_area) + self.AreaSpill = to_pct(area_spill, mix_area) + + local tmp = {} + local w, h = ptype_grid:size() + for ptype, area in pairs(ptype_to_area) do + local stats = { + name = ptype, + area = pct_mul * 100 * area / (w * h), + prefabs = table.count(prefab_list, PREFAB_TYPE, ptype) + } + tmp[#tmp + 1] = stats + end + self.dbg_prefab_types = tmp + end + end + + local function PlacePrefabObjects() + if not gen_mode.Objects then + return + end + local start_time_po = GetPreciseTicks() + rand_init("PlacePrefabObjects") + + local IsValidPos = CObject.IsValidPos + local GetClassFlags = CObject.GetClassFlags + local SetGameFlags = CObject.SetGameFlags + local GetGameFlags = CObject.GetGameFlags + local SetCollectionIndex = CObject.SetCollectionIndex + local ClearCachedZ = CObject.ClearCachedZ + local GridGetMark = GridGetMark + local unpack = table.unpack + local g_Classes = g_Classes + local IsValid = IsValid + local handle_provider = empty_func + + if self.gen_handles then + local first_handle, handle_size = GetHandlesAutoLimits() + local first_handle_pool, handle_pool_size, handle_pool = GetHandlesAutoPoolLimits() + local last_handle = first_handle + handle_size - 1 + local last_handle_pool = first_handle_pool + handle_pool_size - handle_pool + local system_handles = 1000 + local start_marker_handle = first_handle + local next_handle = first_handle + system_handles + 1 + local next_handle_pool = first_handle_pool + local handle_collisions = 0 + local handle_to_prefab = {} + local handle_to_object = HandleToObject + local IsKindOf = IsKindOf + handle_provider = function(current_prefab, classname, reserved_handles, backwards) + if not reserved_handles then + local classdef = classname and g_Classes[classname] + if not classdef or not IsKindOf(classdef, "Object") then + return + end + reserved_handles = classdef.reserved_handles + end + local handle + if not reserved_handles or reserved_handles == 0 then + if last_handle <= next_handle then + assert(false, "No more handles!") + return false, "handles" + elseif backwards then + handle = last_handle + last_handle = handle - 1 + else + handle = next_handle + next_handle = handle + 1 + end + else + if last_handle_pool <= next_handle_pool then + assert(false, "No more handle pools!") + return false, "handles" + elseif backwards then + handle = last_handle_pool + last_handle_pool = handle - handle_pool + else + handle = next_handle_pool + next_handle_pool = handle + handle_pool + end + end + assert(handle > 0) + local existing_obj = handle_to_object[handle] + if IsValid(existing_obj) then + handle_collisions = handle_collisions + 1 + if handle_collisions == 100 then + assert(false, "Blank map used for generation isn't empty!") + return false, "map" + end + if backwards then + return false, "collision" + end + if handle_collisions < 100 and classname then + local prev_prefab = handle_to_prefab[handle] + g_print("Duplicated handle", handle, "\nNew object is", classname, "from", prefab_markers[current_prefab], "\nExisting object is", existing_obj.class, "from", prefab_markers[prev_prefab] or "map", "\n") + end + local new_handle, handle_err + if handle - start_marker_handle > system_handles then + while true do + new_handle, handle_err = handle_provider(current_prefab, existing_obj.class, existing_obj.reserved_handles, true) + if handle_err ~= "collision" then + break + end + end + end + if new_handle then + existing_obj.handle = new_handle + handle_to_object[handle] = nil + handle_to_object[new_handle] = existing_obj + else + g_print("Replacing existing object", existing_obj.class, existing_obj.handle, "by", classname) + DoneObject(existing_obj) + end + end + handle_to_prefab[handle] = current_prefab + return handle + end + end + + local placed_objects, object_source = {}, {} + local obj_count = 0 + local game_flags = gofPermanent | gofGenerated + local function PlacedObject(obj, mark, prefab) + mark = mark or 0 + local prev_mark = placed_objects[obj] + assert(not prev_mark or prev_mark == mark) + if prev_mark == mark then return end + placed_objects[obj] = mark or 0 + object_source[obj] = prefab + obj_count = obj_count + 1 + SetGameFlags(obj, game_flags) + --bp(obj:IsEqualPos2D(1185431, 1650466)) + end + --[[ + if dump then + local orig_PlacedObject = PlacedObject + PlacedObject = function(obj, ...) + orig_PlacedObject(obj, ...) + local x, y, z = obj:GetVisualPosXYZ() + dump("OBJECT %4d: pos (%7d, %7d, %7d) | angle %7d | '%s'", obj_count, x, y, z, obj:GetAngle(), obj.class) + end + end + --]] + + local max_chance = 100 * 1000 + local optional_chance = self.OptionalChance * 1000 + local steep_slope_cos = cos(self.SteepSlope) + local rem_faded_objs = self.RemFadedObjs + local use_mesh_overlap = self.UseMeshOverlap + local removed_count = 0 + local RemoveObject = DoneObject + local SkippedObject = empty_func + + if debug then + RemoveObject = function(obj) + DoneObject(obj) + removed_count = removed_count + 1 + end + SkippedObject = function() + removed_count = removed_count + 1 + end + end + + local OVRLP_DEL_NONE, OVRLP_DEL_ALL, OVRLP_DEL_IGNORE, OVRLP_DEL_PARTIAL, OVRLP_DEL_SINGLE = false, 0, 1, 2, 3 + + local rem_coll_count = 0 + local obj_to_coll, coll_to_objs, coll_indice, coll_is_partial, removed_coll = {}, {}, {}, {}, {} + local function RemoveColl(idx, nested_colls) + if removed_coll[idx] then + return + end + removed_coll[idx] = true + if not nested_colls then + return + end + for _, sub_idx in ipairs(nested_colls[idx]) do + RemoveColl(sub_idx, nested_colls) + end + end + + local prefab_defs = {} + local class_to_defaults = {} + local last_col_idx, placed_collections = 0, 0 + local collections = Collections + + local rmfOptionalPlacement = const.rmfOptionalPlacement + local rmfMeshOverlapCheck = const.rmfMeshOverlapCheck + local rmfDeleteOnSteepSlope = const.rmfDeleteOnSteepSlope + local cofComponentRandomMap = const.cofComponentRandomMap + local max_collection_idx = const.GameObjectMaxCollectionIndex + local base_prop_count = const.RandomMap.PrefabBasePropCount + + local GetPrefabFileObjs = GetPrefabFileObjs + local AsyncFileToString = async.AsyncFileToString -- avoid yielding + local Unserialize = Unserialize + local GetDefRandomMapFlags = GetDefRandomMapFlags + local GetPrefabObjPos = GetPrefabObjPos + local SetPrefabObjPos = SetPrefabObjPos + local PropObjSetProperty = PropObjSetProperty + local SetRandomMapFlags = CObject.SetRandomMapFlags + local selected_break = self.SelectedBreak and self.SelectedMark or 0 + local abort_prefab_placement + + local function PlacePrefab(prefab, prefab_pos, prefab_angle, mark, ptype, bbox) + if abort_prefab_placement then + return + end + + --assert((prefab.revision or 0) <= revision) + --assert((prefab.version or 1) <= version) + assert(prefab_pos:IsValidZ()) + + if dump then + local x, y = prefab_pos:xy() + dump("PlacePrefab %4d | pos (%7d, %7d) | angle %7d | '%s' ----------------------", mark, x, y, prefab_angle, prefab_markers[prefab]) + end + + mark = mark_grid and mark or 0 + + if selected_break > 0 then + bp(selected_break == mark) + end + + local ptype_preset = ptype_to_preset[ptype] or empty_table + local on_obj_overlap = ptype_preset.OnObjOverlap + local ignore_colls = on_obj_overlap == OVRLP_DEL_IGNORE + local ignore_partial_colls = on_obj_overlap == OVRLP_DEL_PARTIAL + local delete_no_colls = on_obj_overlap == OVRLP_DEL_SINGLE + + local objs + local nested_colls = prefab.nested_colls + local nested_opt_objs = prefab.nested_opt_objs + local save_collections = prefab.save_collections + local defs = prefab_defs[prefab] + if defs == nil then + local load_time_start = GetPreciseTicks() + local name = prefab_markers[prefab] + if not exported_prefabs[name] then + g_print("no such exported prefab", name) + return + end + local filename = GetPrefabFileObjs(name) + local err, bin = AsyncFileToString(nil, filename, nil, nil, "pstr") + if err then + g_print("failed to load prefab", name, ":", err) + return + end + defs = Unserialize(bin) + if not defs then + g_print("failed to unserialize objects from prefab", name) + return + end + prefab_defs[prefab] = (prefabs_count[prefab] or max_int) > 1 and defs or false + if debug then + AddPrefabStat(prefab, "obj_load_time", GetPreciseTicks() - load_time_start) + end + end + if not defs then + g_print("Uncached prefab", prefab_markers[prefab]) + return + end + + local place_time_start = GetPreciseTicks() + objs = {} + for _, def in ipairs(defs) do + do + local class, dpos, angle, daxis, + scale, rmf_flags, fade_dist, + ground_offset, normal_offset, + coll_idx, color, mirror = unpack(def, 1, base_prop_count) + + assert(class ~= "Collection") + assert(not coll_idx or coll_idx ~= 0) + + local classdef, entity, default_rmf_flags + local defaults = class_to_defaults[class] + if defaults then + classdef, entity, default_rmf_flags = unpack(defaults) + else + classdef = g_Classes[class] + assert(classdef) + if classdef then + default_rmf_flags = GetDefRandomMapFlags(classdef) + entity = classdef.entity or class + end + class_to_defaults[class] = {classdef, entity, default_rmf_flags} + end + if not classdef then + goto continue + end + rmf_flags = rmf_flags or default_rmf_flags + + if optional_chance > 0 and (rmf_flags & rmfOptionalPlacement) ~= 0 then + local reduction = coll_idx ~= 0 and nested_opt_objs and nested_opt_objs[coll_idx] or 1 + local chance = reduction > 1 and Max(1, optional_chance / reduction) or optional_chance + if crand(chance, max_chance) then + if coll_idx then + RemoveColl(coll_idx, nested_colls) + end + SkippedObject() + goto continue + end + end + local check_mark + if on_obj_overlap then + check_mark = bbox and mark + if coll_idx then + if removed_coll[coll_idx] then + SkippedObject() + goto continue + end + if ignore_partial_colls or delete_no_colls then + check_mark = nil + end + end + end + + local mesh_overlap = use_mesh_overlap and (rmf_flags & rmfMeshOverlapCheck) ~= 0 + local max_slope = (rmf_flags & rmfDeleteOnSteepSlope) ~= 0 and steep_slope_cos + + local new_pos, new_angle, new_axis, mark_found = GetPrefabObjPos( + dpos, angle, daxis, + rem_faded_objs and fade_dist, + prefab_pos, prefab_angle, + ground_offset, normal_offset, + mark_grid, check_mark, + mesh_overlap, entity, scale, mirror, + max_slope) + + if not new_pos then + if not ignore_colls and coll_idx then + RemoveColl(coll_idx, nested_colls) + end + SkippedObject() + goto continue + end + + local handle, err = handle_provider(prefab, class, false, true) + local components = 0 + if rmf_flags ~= default_rmf_flags then + components = components | cofComponentRandomMap + end + local obj = classdef:new{handle = handle} + if not IsValid(obj) then + g_print("Aborting object placement after a failure to create an object of type", class) + abort_prefab_placement = true + break + end + SetPrefabObjPos(obj, new_pos, new_angle, new_axis, scale, color, mirror) + for i=base_prop_count+1,#def,2 do + PropObjSetProperty(obj, def[i], def[i + 1]) + end + if rmf_flags ~= default_rmf_flags then + SetRandomMapFlags(obj, rmf_flags) + end + objs[#objs + 1] = obj + + if coll_idx then + obj_to_coll[obj] = coll_idx + local coll_objs = coll_to_objs[coll_idx] + if coll_objs then + coll_objs[#coll_objs + 1] = obj + else + coll_indice[#coll_indice + 1] = coll_idx + coll_to_objs[coll_idx] = { obj } + end + if ignore_partial_colls and not coll_is_partial[coll_idx] then + if bbox and mark_found ~= mark then + -- all seen objects from that collection are outside the prefab + goto continue + end + -- mark the collection as partial as at least one object is inside + coll_is_partial[coll_idx] = true + end + end + PlacedObject(obj, mark, prefab) + end + ::continue:: + end + if debug then + AddPrefabStat(prefab, "obj_place_time", GetPreciseTicks() - place_time_start) + end + if ignore_partial_colls then + if #coll_indice > 0 then + for _, coll_idx in ipairs(coll_indice) do + local coll_objs = coll_to_objs[coll_idx] + if coll_is_partial[coll_idx] then + -- at least on object from the collection is inside the prefab + for _, obj in ipairs(coll_objs) do + PlacedObject(obj, mark, prefab) + end + else + -- all objects from these collections are outside their prefabs + RemoveColl(coll_idx, nested_colls) + for _, obj in ipairs(coll_objs) do + RemoveObject(obj) + end + end + end + end + end + if save_collections then + for _, coll_idx in ipairs(coll_indice) do + local coll_objs = not removed_coll[coll_idx] and coll_to_objs[coll_idx] or "" + local count = #coll_objs + for i = count, 1, -1 do + if not IsValid(coll_objs[i]) then + coll_objs[i] = coll_objs[count] + coll_objs[count] = nil + count = count - 1 + end + end + if count > 0 then + local col_idx = last_col_idx + 1 + while collections[col_idx] do + col_idx = col_idx + 1 + end + last_col_idx = col_idx + assert(col_idx <= max_collection_idx) + if col_idx > max_collection_idx then + g_print("max collections reached!", max_map_size) + else + local col = Collection:new{ Index = col_idx } + col:SetName(string.format("MapGen_%d", col_idx)) + collections[col_idx] = col + PlacedObject(col) + for i=1,count do + SetCollectionIndex(coll_objs[i], col_idx) + end + placed_collections = placed_collections + 1 + end + end + end + end + local has_removed_colls = next(removed_coll) + for _, obj in ipairs(objs) do + if IsValid(obj) then + local coll_idx = has_removed_colls and obj_to_coll[obj] + if coll_idx and removed_coll[coll_idx] then + -- delete placed objects from removed collections + RemoveObject(obj) + elseif obj.__ancestors.Object then + obj:PostLoad() + end + end + end + if has_removed_colls then + rem_coll_count = rem_coll_count + table.count(removed_coll) + removed_coll = {} + end + if #coll_indice > 0 then + coll_indice, coll_to_objs = {}, {} + end + if next(obj_to_coll) then obj_to_coll = {} end + if next(coll_is_partial) then coll_is_partial = {} end + return objs + end + + -- place all prefab objects: + for mark, info in ipairs(prefab_list) do + local prefab, raster, add_idx, ptype, bbox = unpack(info) + assert(raster.place_idx == mark) + if not bbox or not placed_marks or placed_marks[mark] then + PlacePrefab(prefab, raster.pos, raster.angle, mark, ptype, bbox) + if step_prefab then + DbgClear() + local name = prefab_markers[prefab] + DbgShowPrefab(raster.pos, name, white, mark, prefab.min_radius, prefab.max_radius) + step("Objects %d %s", add_idx, name) + end + end + end + + if mark_grid then + MapForEach(bx_changes or "map", "attached", false, nil, nil, gofPermanent, function(obj) + --bp(obj:IsEqualPos2D(1185431, 1650466)) + local current_mark = GridGetMark(mark_grid, obj) + if current_mark == 0 then + -- existing map object outside the prefab zone + ClearCachedZ(obj) + elseif not placed_objects[obj] then + -- existing map object inside the prefab zone + DoneObject(obj) + end + end) + end + + MapForEach(bx_changes or "map", "attached", false, "EditorCallbackObject", nil, nil, gofPermanent | gofGenerated, function(obj, ...) + obj:EditorCallbackGenerate(...) + end, self, object_source, placed_objects, prefab_list) + + if debug then + self.ObjectTime = GetPreciseTicks() - start_time_po + self.ObjectCount = obj_count + self.RemObjects = removed_count + self.RemColls = rem_coll_count + self.PlacedColls = placed_collections + local class_to_count = {} + for obj in pairs(placed_objects) do + if IsValid(obj) then + class_to_count[obj.class] = (class_to_count[obj.class] or 0) + 1 + end + end + local tmp = {} + local total_count = 0 + for class, count in pairs(class_to_count) do + total_count = total_count + count + end + for class, count in pairs(class_to_count) do + local pct = total_count > 0 and MulDivRound(pct_100, count, total_count) or 0 + tmp[#tmp + 1] = {count = count, class = class, pct = pct} + end + self.dbg_placed_objects = tmp + self.dbg_obj_to_prefab_mark = placed_objects + end + --DbgAddTerrainRect(bx_changes, red) + end + + FindAndRasterPrefabs() + + local orig_rand = HandleRand + local handle_seed = rand_init("HandleRand") + HandleRand = function(range) + range, handle_seed = BraidRandom(handle_seed, range) + return range + end + PlacePrefabObjects() + HandleRand = orig_rand + + if self.SavePrefabLoc then + mapdata.PersistedPrefabs = prefabs_to_persist + if dump then + dump("\n----\nPersisted prefabs saved: %d", #(prefabs_to_persist or "")) + for i, entry in ipairs(prefabs_to_persist) do + local name, ptype, x, y, angle = unpack(entry) + dump("%3d | pos (%4d, %4d) | angle %6d | '%s'", i, x, y, angle, name) + end + end + end + + if height_out_of_lims then + g_print("The resulting map height is outside the allowed range!") + end + + if debug then + self.FirstRand = start_seed + self.LastRand = rand_seed + + local unpack = table.unpack + local tmp = {} + for prefab, stat in pairs(prefab_stats) do + local counters = prefab_stat_count[prefab] + local avg_stats = { + name = prefab_markers[prefab], + count = prefabs_count[prefab], + objs = prefab.obj_count, + grid = sqrt(prefab.total_area), + } + local sum = 0 + for name, value in pairs(stat) do + sum = sum + value + avg_stats[name] = value / counters[name] + end + avg_stats.impact = sum + tmp[#tmp + 1] = avg_stats + end + self.dbg_placed_prefabs = tmp + + if dump then + dump("\n\n\nUsed Prefabs Meta:\n") + local names = {} + for prefab, count in pairs(prefabs_count) do + local name = prefab_markers[prefab] + names[#names + 1] = name + names[name] = prefab + end + table.sort(names) + for _, name in ipairs(names) do + local prefab = table.copy(names[name]) + prefab.marker = nil -- too mush spam here + dump("%20s: %s\n", name, TableToLuaCode(prefab)) + end + dump("\n\n\nResults:\n") + for i, prop in ipairs(self:GetProperties()) do + if prop.log then + local name = prop.name or prop.id + local value = tostring(self:GetProperty(prop.id)) + if prop.editor == "text" then + dump("\n%s:", name) + dump(value) + else + dump("%20s: %s", name, value) + end + end + end + end + end + self:DbgUpdate() + self:DbgOnModified() +end \ No newline at end of file diff --git a/CommonLua/MapGen/BiomeZdit.lua b/CommonLua/MapGen/BiomeZdit.lua new file mode 100644 index 0000000000000000000000000000000000000000..9ad2ee283ef86c6624985c84ed510e23acfc911f --- /dev/null +++ b/CommonLua/MapGen/BiomeZdit.lua @@ -0,0 +1,630 @@ +if not Platform.editor or not Platform.developer then + return +end + +local type_tile = const.TypeTileSize +local pct_mul = 100 + +---- + +function Biome:CalcTypeMixingPreview() + local preview = NewComputeGrid(256, 256, "U", 16) + self:GetTypeMixingGrid(preview) + self.TypeMixingPreview = preview + return preview +end + +function Biome:GetFilteredPrefabsPreview() + return #(self:GetFilteredPrefabs() or empty_table) +end + +function Biome:GetTypeMixingPreview() + return self.TypeMixingPreview or self:CalcTypeMixingPreview() +end + +local function RecalcTypeMixingPreview(ged) + local parent = ged:GetParentOfKind("SelectedObject", "Biome") + if parent then + parent:CalcTypeMixingPreview() + end +end + +function BiomePrefabTypeWeight:OnAfterEditorNew(parent, ged, is_paste) + RecalcTypeMixingPreview(ged) +end + +function BiomePrefabTypeWeight:OnAfterEditorDelete(parent, ged) + RecalcTypeMixingPreview(ged) +end + +function BiomePrefabTypeWeight:OnEditorSetProperty(prop_id, old_value, ged) + RecalcTypeMixingPreview(ged) +end + +---- + +if FirstLoad then + g_BiomeFiller = false +end + +BiomeFiller.dbg_paused = false +BiomeFiller.dbg_interrupt = false +BiomeFiller.dbg_placed_prefabs = false +BiomeFiller.dbg_prefab_types = false +BiomeFiller.dbg_visible_prefabs = false +BiomeFiller.dbg_placed_objects = false +BiomeFiller.dbg_placed_poi = false +BiomeFiller.dbg_obj_to_prefab_mark = false +BiomeFiller.dbg_grid_thread = false +BiomeFiller.dbg_inspect = false +BiomeFiller.dbg_filter_tags = false +BiomeFiller.dbg_filter_name = "" +BiomeFiller.dbg_view_mark = 0 + +function BiomeFiller:DbgInit() + g_BiomeFiller = self + for _, prop in ipairs(self:GetProperties()) do + if prop.category == "Results" then + self[prop.id] = nil + end + end + self.dbg_paused = nil + self.dbg_interrupt = nil + self.dbg_placed_prefabs = nil + self.dbg_prefab_types = nil + self.dbg_visible_prefabs = nil + self.dbg_placed_objects = nil + self.dbg_placed_poi = nil + self.dbg_obj_to_prefab_mark = nil + self.dbg_inspect = nil + self.dbg_filter_tags = nil + self.dbg_filter_name = nil + self.dbg_view_mark = nil + self:DbgClear() + ObjModified(self) +end + +function BiomeFiller:ActionTogglePause() + self.dbg_paused = not self.dbg_paused + Msg(self) +end + +function BiomeFiller:ActionInterrupt() + self.dbg_interrupt = true + self.dbg_paused = false + Msg(self) +end + +local function DbgGetPalette(grid, name, prefab_list) + local palette, remap = {}, {} + local level_map = GridLevels(grid) + local count = 0 + if name == "Types" then + local ptype_to_preset = PrefabTypeToPreset + local idx_to_ptype = GetPrefabTypeList() + local ptypes = {} + for ptype_idx in pairs(level_map) do + local ptype = idx_to_ptype[ptype_idx] + if ptype then + local val = ptypes[ptype] + if not val then + count = count + 1 + val = count + ptypes[ptype] = val + local preset = ptype_to_preset[ptype] + local color = preset and preset.OverlayColor or RandColor(xxhash(ptype)) + palette[val] = color + end + remap[ptype_idx] = val + end + end + else + local i = 1 + local minv, maxv = max_int, 0 + for level in pairs(level_map) do + if level ~= 0 then + local val = count % 255 + 1 + remap[level] = val + minv = Min(minv, val) + maxv = Max(maxv, val) + count = count + 1 + end + end + if count > 0 then + palette[minv] = RGB(128, 128, 128) + palette[maxv] = white + if count > 2 then + count = Min(255, count) + local max_value = 1024 + local max_hue = 0 + local min_hue = max_value * 2 / 3 + for val = minv, maxv do + local hue = min_hue + (max_hue - min_hue) * (val - minv) / (maxv - minv) + palette[val] = HSB(hue, max_value, max_value, max_value) + end + end + end + end + return palette, remap +end + +function BiomeFiller:DbgDone() + self.Overlay = nil + self.InspectMode = nil + self.InspectFilter = nil + self:DbgUpdateShow() + self:DbgClear() + GedObjectDeleted(self) +end + +BiomeFiller.dbg_overlay_grid = false +function BiomeFiller:DbgClear() + DbgHideTerrainGrid(self.dbg_overlay_grid) + self.dbg_overlay_grid = nil + DbgClear() + editor.ClearSel() +end + +function BiomeFiller:OnEditorSetProperty(prop_id, ...) + local prop = self:GetPropertyMetadata(prop_id) + if prop and prop.update_dbg then + self:DbgUpdateShow() + end + return GridOp.OnEditorSetProperty(self, prop_id, ...) +end + +function BiomeFiller:GetGridPreview() + local modes = self:GetGridModes() + for key, value in pairs(self.PreviewSet) do + if value then + return modes[key] + end + end +end + +BiomeFiller.dbg_palettes = false +function BiomeFiller:DbgUpdate() + self.dbg_palettes = false + self:DbgUpdateShow() + editor.ClearSel() +end + +function BiomeFiller:DbgUpdatePalette(name) + name = name or self:DbgGetOverlay() + self.dbg_palettes = self.dbg_palettes or {} + local grid = self:GetGridModes()[name] + local info = self.dbg_palettes[name] or empty_table + local edges = self.OverlayEdges + local prev_grid, prev_edges = info[3], info[5] or false + if grid and (prev_grid ~= grid or prev_edges ~= edges) then + local palette, remap = DbgGetPalette(grid, name, self.PrefabList or empty_table) + local edge + if edges then + edge = GridDest(grid) + GridEdge(grid, edge) + GridNot(edge) + end + local g = GridDest(grid) + GridReplace(grid, g, remap) + if edges then + GridMulDiv(g, edge, 1) + end + info = { palette, remap, grid, g, edges } + self.dbg_palettes[name] = info + end + return table.unpack(info) +end + +function BiomeFiller:DbgGetColor(mark) + local overlay = self:DbgGetOverlay() or "Marks" + local palette, remap = self:DbgUpdatePalette(overlay) + local pidx = remap[mark] + return pidx and palette[pidx] +end + +function BiomeFiller:DbgGetOverlay() + for ov, value in pairs(self.Overlay) do + if value then + return ov + end + end +end + +function BiomeFiller:DbgUpdateShow() + local new_overlay = self:DbgGetOverlay() + DeleteThread(self.dbg_grid_thread) + if not new_overlay then + DbgShowTerrainGrid(false) + else + self.dbg_grid_thread = CreateRealTimeThread(function() + WaitNextFrame(1) + local palette, remap, dgrid, g = self:DbgUpdatePalette(new_overlay) + self.dbg_overlay_grid = g + DbgShowTerrainGrid(g, palette) + end) + end + + local new_inspect = false + for ins, value in pairs(self.InspectMode) do + if value then + new_inspect = ins + break + end + end + + local filter_tags = false + for tag, value in pairs(self.InspectFilter) do + if value then + filter_tags = table.create_set(filter_tags, tag, true) + end + end + local filter_name = self.InspectPattern + + local prefab_to_tags = {} + local function GetPrefabTags(prefab) + local tags = prefab_to_tags[prefab] + if tags == nil then + local poi_tags = prefab.poi_type and table.get(PrefabPoiToPreset, prefab.poi_type, "Tags") + local ptype_tags = prefab.type and table.get(PrefabTypeToPreset, prefab.type, "Tags") + local marker_tags = prefab.tags + tags = {} + table.append(tags, ptype_tags) + table.append(tags, marker_tags) + table.append(tags, poi_tags) + tags = table.keys(tags, true) + prefab_to_tags[prefab] = #tags > 0 and tags or false + end + return tags + end + local function PrefabTagsToStr(tags, sep) + return table.concat(tags, sep or ", ") + end + function DbgShowPrefab(pos, name, color, mark, rmin, rmax) + local prefab = name and PrefabMarkers[name] + local tags = prefab and GetPrefabTags(prefab) or empty_table + if filter_tags then + local found + for _, tag in ipairs(tags) do + if filter_tags[tag] then + found = true + break + end + end + if not found then + return + end + end + if name and filter_name ~= "" and not string.find(name, filter_name) then + return + end + if rmin then + DbgAddCircle(pos, rmin * type_tile, color, -1, guim) + if rmax then + DbgAddCircle(pos, rmax * type_tile, color, -1, guim) + end + end + DbgAddVector(pos, 50*guim, color) + if name then + if mark then + name = name .. " (" .. mark .. ")" + end + local poi_type = prefab and prefab.poi_type or "" + if poi_type ~= "" then + name = name .. " " .. poi_type + end + if #tags > 0 then + name = name .. " [" .. PrefabTagsToStr(tags) .. "]" + end + DbgAddText(name, ValidateZ(pos):AddZ(50*guim), color) + end + return true + end + + if self.dbg_inspect ~= new_inspect + or self.dbg_filter_name ~= filter_name + or not table.equal_values(self.dbg_filter_tags, filter_tags) then + self.dbg_inspect = new_inspect + self.dbg_filter_tags = filter_tags + self.dbg_filter_name = filter_name + DbgClear() + DbgStopInspect() + if new_inspect == "Pos" then + DbgInspectThread = CreateMapRealTimeThread(function() + WaitNextFrame(1) + local shown = 0 + for mark, info in ipairs(self.PrefabList or empty_table) do + local name, pos = table.unpack(info) + local color = self:DbgGetColor(mark) + if color and DbgShowPrefab(pos, name, color, mark) then + shown = shown + 1 + end + end + print("Shown", shown, "prefabs") + end) + elseif new_inspect == "POI" then + DbgInspectThread = CreateMapRealTimeThread(function() + WaitNextFrame(1) + local poi_to_preset = PrefabPoiToPreset + local shown = 0 + for mark, info in ipairs(self.PrefabList or empty_table) do + local name, pos = table.unpack(info) + local prefab = PrefabMarkers[name] + local poi_type = prefab and prefab.poi_type or "" + local preset = poi_type ~= "" and poi_to_preset[poi_type] + if preset and DbgShowPrefab(pos, name, preset.OverlayColor, mark, prefab.max_radius) then + shown = shown + 1 + end + end + print("Shown", shown, "prefabs") + end) + elseif new_inspect == "Rollover" then + DbgInspectThread = CreateMapRealTimeThread(function() + local last_mark, last_prefab, last_mark, last_click = 0 + while DbgInspectThread == CurrentThread() do + DbgClearColors() + local mark = 0 + if self.dbg_obj_to_prefab_mark then + local solid, transparent = GetPreciseCursorObj() + local obj = GetTopmostParent(transparent or solid) + mark = obj and self.dbg_obj_to_prefab_mark[obj] or 0 + if obj then + DbgSetColor(obj, white) + end + end + if mark == 0 and self.MarkGrid and self.PrefabList then + local pos = DbgGetInspectPos() + mark = GridGetMark(self.MarkGrid, pos) or 0 + end + if mark ~= last_mark then + last_mark = mark + DbgClear() + local name, pos, color + if mark ~= 0 then + local info = self.PrefabList[mark] or empty_table + name, pos = table.unpack(info) + pos = pos:SetInvalidZ() + color = self:DbgGetColor(mark) or white + end + if name then + local prefab = PrefabMarkers[name] + DbgShowPrefab(pos, name, color, mark, prefab.min_radius, prefab.max_radius) + end + last_prefab = name + WaitNextFrame(1) + else + WaitNextFrame(10) + end + local tool = GetDialog("XSelectObjectsTool") + local terrain_pos = tool and tool.last_mouse_click + if not terrain_pos then + last_click = nil + elseif terrain_pos ~= last_click then + last_click = terrain_pos + local prefab = PrefabMarkers[last_prefab] or empty_table + self.SelectedPrefab = last_prefab + self.SelectedMark = last_mark + self.SelectedTags = PrefabTagsToStr(GetPrefabTags(prefab)) + self.SelectedPoi = prefab.poi_type + self:DbgOnModified() + end + end + end) + end + end +end + +local function BiomeFiller_ObjModified(obj) + ObjModified(obj) +end + +function BiomeFiller:DbgOnModified() + DelayedCall(20, BiomeFiller_ObjModified, self) +end + +local function ChangeSortKey(key, ...) + local keys = {...} + local idx = table.find(keys, key) + return idx and keys[idx + 1] or keys[1] +end +local function GetSortedList(list, key, ...) + local keys = {...} + local sort = {} + for i=1,#keys do + sort[i] = keys[i] == key and "*" or "" + end + list = list or {} + local function CmpItemValues(a, b, k, ...) + if not k then + return + end + local va, vb = a[k], b[k] + if va ~= vb then + return va > vb + end + return CmpItemValues(a, b, ...) + end + table.sort(list, function(a, b) + return CmpItemValues(a, b, key, table.unpack(keys)) + end) + return list, keys, sort +end + +BiomeFiller.dbg_placed_objects_sort = "count" + +function BiomeFiller:ActionSortObjects() + self.dbg_placed_objects_sort = ChangeSortKey(self.dbg_placed_objects_sort, "class", "count") + self:DbgOnModified() +end + +function BiomeFiller:GetPlacedObjects() + local list, keys, sort = GetSortedList(self.dbg_placed_objects, self.dbg_placed_objects_sort, "class", "pct", "count") + local tmp = { + string.format("%35s | %6s | %s", table.unpack(sort)), + string.format("%35s | %6s | %s", table.unpack(keys)), + "-------------------------------------------------------------------------------------------", + } + for _, t in ipairs(list) do + local pct = t.pct + tmp[#tmp + 1] = string.format("%35s | %3d.%02d | %d", t.class, pct / pct_mul, pct % pct_mul, t.count) + end + return table.concat(tmp, "\n") +end + +BiomeFiller.dbg_placed_prefabs_sort = "impact" +function BiomeFiller:ActionSortPrefabs() + self.dbg_placed_prefabs_sort = ChangeSortKey(self.dbg_placed_prefabs_sort, "name", "count", "objs", "grid", "impact") + self:DbgOnModified() +end +function BiomeFiller:GetPlacedPrefabs() + local list, keys, sort = GetSortedList(self.dbg_placed_prefabs, self.dbg_placed_prefabs_sort, "name", "count", "objs", "load", "place", "grid", "load", "place", "impact") + local tmp = { + string.format("%35s %5s %5s %5s %5s %5s %5s %5s %s", table.unpack(sort)), + string.format("%35s %5s | %5s %5s %5s | %5s %5s %5s | %s", table.unpack(keys)), + "-------------------------------------------------------------------------------------------", + } + for _, t in ipairs(list) do + local prefab = t.prefab + tmp[#tmp + 1] = string.format("%35s %5d | %5d %5d %5d | %5d %5d %5d | %d", + t.name, t.count, + t.objs, t.obj_load_time or 0, t.obj_place_time or 0, + t.grid, t.grid_load_time or 0, t.grid_place_time or 0, + t.impact) + end + return table.concat(tmp, "\n") +end + +BiomeFiller.dbg_prefab_types_sort = "area" +function BiomeFiller:ActionSortPrefabTypes() + self.dbg_prefab_types_sort = ChangeSortKey(self.dbg_prefab_types_sort, "name", "area", "prefabs") + self:DbgOnModified() +end +function BiomeFiller:GetPrefabTypes() + local list, keys, sort = GetSortedList(self.dbg_prefab_types, self.dbg_prefab_types_sort, "name", "area", "prefabs") + local tmp = { + string.format("%35s %6s %s", table.unpack(sort)), + string.format("%35s | %6s | %s", table.unpack(keys)), + "-------------------------------------------------------------------------------------------", + } + for _, t in ipairs(list) do + local prefab = t.prefab + local area = t.area or 0 + local fully_hidden = t.fully_hidden or 0 + tmp[#tmp + 1] = string.format("%35s | %3d.%02d | %d", + t.name, area / pct_mul, area % pct_mul, t.prefabs) + end + return table.concat(tmp, "\n") +end + +BiomeFiller.dbg_visible_prefabs_sort = "visible_area" +function BiomeFiller:ActionSortVisible() + self.dbg_visible_prefabs_sort = ChangeSortKey(self.dbg_visible_prefabs_sort, "visible_area", "fully_hidden") + self:DbgOnModified() +end +function BiomeFiller:GetVisiblePrefabs() + local list, keys, sort = GetSortedList(self.dbg_visible_prefabs, self.dbg_visible_prefabs_sort, "name", "visible_area", "fully_hidden") + local tmp = { + string.format("%35s %12s %12s", table.unpack(sort)), + string.format("%35s | %12s | %12s", table.unpack(keys)), + "-------------------------------------------------------------------------------------------", + } + for _, t in ipairs(list) do + local prefab = t.prefab + local visible_area = t.visible_area or 0 + local fully_hidden = t.fully_hidden or 0 + tmp[#tmp + 1] = string.format("%35s | %9d.%02d | %9d.%02d", + t.name, visible_area / pct_mul, visible_area % pct_mul, fully_hidden / pct_mul, fully_hidden % pct_mul) + end + return table.concat(tmp, "\n") +end + +BiomeFiller.dbg_placed_poi_sort = "count" +function BiomeFiller:ActionSortPoi() + self.dbg_placed_poi_sort = ChangeSortKey(self.dbg_placed_poi_sort, "name", "count") + self:DbgOnModified() +end +function BiomeFiller:GetPlacedPOI() + local list, keys, sort = GetSortedList(self.dbg_placed_poi, self.dbg_placed_poi_sort, "name", "count") + local tmp = { + string.format("%35s %s", table.unpack(sort)), + string.format("%35s | %s", table.unpack(keys)), + "-------------------------------------------------------------------------------------------", + } + for _, t in ipairs(list) do + local prefab = t.prefab + tmp[#tmp + 1] = string.format("%35s | %d", t.name, t.count) + end + return table.concat(tmp, "\n") +end + +--[[ +function BiomeActionImport(ged) + if not g_BiomeFiller then + print("No biome filler found!") + return + end + local props = GetModifiedProperties(g_BiomeFiller) or {} + local preset = BiomeFillerPreset:new() + props.Id = preset:GenerateUniquePresetId("Imported") + preset:SetProperties(props) + preset:PostLoad() + ObjModified(Presets.BiomeFillerPreset) +end +--]] + +function BiomeFiller:ViewInspectedPrefab() + local pattern = self.InspectPattern + if pattern == "" then + print("No prefab name selected") + return + end + local unpack = table.unpack + local list = self.PrefabList or empty_table + local last_mark = self.dbg_view_mark + local mark, mark_pos, mark_radius, mark_name + local count = #list + local i = last_mark + 1 + for n = 1, count do + if i > count then i = 1 end + local name, pos = unpack(list[i]) + if string.find(name, pattern) then + mark, mark_pos, mark_name = i, pos, name + + break + end + i = i + 1 + end + if not mark then + print("No placed prefabs matching the pattern:", name) + return + end + self.dbg_view_mark = mark + local prefab = PrefabMarkers[mark_name] + local prefab_radius = prefab and prefab.max_radius * type_tile + ViewPos(mark_pos, 2 * prefab_radius) + printf("Shown: %s [%d]", mark_name, mark) +end + +function OnMsg.GedPropertyEdited(_, obj) + if IsKindOf(obj, "NoisePreset") then + ForEachPreset("Biome", function(biome) + if biome.TypeMixingPreset == obj.id then + ObjModified(biome) + end + end) + end +end + +function Biome:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "TypeMixingPreset" then + self:CalcTypeMixingPreview() + else + local prop = self:GetPropertyMetadata(prop_id) + if prop.recalc_curve then + self["CalcCurve" .. prop.recalc_curve](self) + end + end +end + +function OnMsg.ChangeMap() + g_BiomeFiller = false +end diff --git a/CommonLua/MapGen/GridOps.lua b/CommonLua/MapGen/GridOps.lua new file mode 100644 index 0000000000000000000000000000000000000000..e0c364ac5a6d44e125cff4a8c28b09f65650b4b4 --- /dev/null +++ b/CommonLua/MapGen/GridOps.lua @@ -0,0 +1,3011 @@ +const.TagLookupTable["GridOpName"] = "" +const.TagLookupTable["/GridOpName"] = "" +const.TagLookupTable["GridOpStr"] = "" +const.TagLookupTable["/GridOpStr"] = "" +const.TagLookupTable["GridOpParam"] = "" +const.TagLookupTable["/GridOpParam"] = "" +const.TagLookupTable["GridOpValue"] = "" +const.TagLookupTable["/GridOpValue"] = "" +const.TagLookupTable["GridOpGlobal"] = "" +const.TagLookupTable["/GridOpGlobal"] = "" + +local max_uint16 = 65535 + +local eval = prop_eval +local developer = Platform.developer +function DivToStr(v, s) + local v100 = 1000 * v / (s or 1) + v = v100 / 1000 + local r = v100 % 1000 + if r == 0 then + return v + elseif r < 10 then + return v .. ".00" .. r + elseif r < 100 then + return v .. ".0" .. r + end + return v .. "." .. r +end + +-- Return 'grid' in the same format and size as 'ref' +function GridMakeSame(grid, ref) + grid = GridResample(grid, ref:size()) + grid = GridRepack(grid, IsComputeGrid(ref)) + return grid +end + +-- Return true if 'grid' is the same format and size as 'ref' +function GridCheckSame(grid, ref) + local fmt1, bits1 = IsComputeGrid(ref) + local fmt2, bits2 = IsComputeGrid(grid) + if fmt1 ~= fmt2 or bits1 ~= bits2 then + return + end + local w1, h1 = ref:size() + local w2, h2 = grid:size() + if w1 ~= w2 or h1 ~= h2 then + return + end + return true +end + +function GridMapGet(grid, mx, my, coord_scale, value_scale) + coord_scale = coord_scale or 1 + value_scale = value_scale or 1 + local mw, mh = terrain.GetMapSize() + local gw, gh = grid:size() + local gx, gy = MulDivTrunc(coord_scale * gw, mx, mw), MulDivTrunc(coord_scale * gh, my, mh) + local gv = GridGet(grid, gx, gy, value_scale, coord_scale) + return gv, gx, gy +end + +---- + +if FirstLoad then + LastGridProcName = "" + LastGridProcDump = "" +end + +function OnMsg.ChangeMap() + LastGridProcName = "" + LastGridProcDump = "" +end + +local function OnChangeCombo() + return { + {value = "", name = "Do nothing"}, + {value = "recalc_op", name = "Run cursor only"}, + {value = "recalc_to", name = "Run to cursor"}, + {value = "recalc_from", name = "Run from cursor"}, + {value = "recalc_all", name = "Run All"}, + } +end + +local run_mode_items = {"Debug", "Release", "GM", "Profile"} +local all_run_modes = set(table.unpack(run_mode_items)) + +DefineClass.GridProc = { + __parents = { "PropertyObject" }, + properties = { + { category = "Operations", id = "OnChange", name = "On Change", editor = "choice", default = "recalc_op", items = OnChangeCombo }, + { category = "Operations", id = "Lightmodel", name = "Lightmodel", editor = "preset_id", default = "", preset_class = "LightmodelPreset" }, + { category = "Operations", id = "RunMode", name = "Run Mode", editor = "choice", default = "Release", items = run_mode_items, max_items_in_set = 1 }, + { category = "Operations", id = "RunOnce", name = "Run Once", editor = "bool", default = false, }, + { category = "Operations", id = "Randomize", name = "Randomize", editor = "bool", default = false, no_edit = PropGetter("LoadSeed") }, + { category = "Operations", id = "Seed", name = "Fixed Seed", editor = "number", default = 0, no_edit = function(self) return self.Randomize or self.LoadSeed end, buttons = {{ name = "Rand", func = "ActionRand" }} }, + { category = "Operations", id = "SaveSeed", name = "Save Seed", editor = "bool", default = false, help = "Save the generation seed", no_edit = function(self) return self.LoadSeed or not self:GetSeedSaveDest() end }, + { category = "Operations", id = "LoadSeed", name = "Load Seed", editor = "bool", default = false, help = "Load the generation seed", no_edit = function(self) return not self:GetSeedSaveDest() end }, + { category = "Operations", id = "Dump", name = "Dump", editor = "bool", default = false, help = "Will create a generation dump log in Release and Debug runs. May affect performance." }, + { category = "Stats", id = "Count", name = "Own Ops Count", editor = "number", default = 0, read_only = true, dont_save = true }, + { category = "Stats", id = "ExecCount", name = "Executed Ops", editor = "number", default = 0, read_only = true, dont_save = true }, + { category = "Stats", id = "Time", name = "Total Time (ms)", editor = "number", default = -1, read_only = true, dont_save = true }, + { category = "Stats", id = "Log", name = "Log", editor = "text", default = "", lines = 2, max_lines = 20, read_only = true, dont_save = true }, + }, + start_time = 0, + log = false, + err_msg = false, + err_op = false, +} + +function GridProc:GetSeedSaveDest() +end + +function GridProc:ActionRand() + self.Seed = AsyncRand() + ObjModified(self) +end + +function GridProc:GetLog() + return self.log and table.concat(self.log, "\n") or "" +end + +function GridProc:GetCount() + return #self +end + +function GridProc:Invalidate(state) +end + +function GridProc:RunOps(state, from, to) + local old_indent = state.indent + local new_indent = (old_indent or "") .. " . " + for i = 1, #self do + local op = self[i] + if not from or op == from then + SuspendObjModified("RunOp") + from = false + state.indent = new_indent + local err = op:RunOp(state) + state.indent = old_indent + if err and op.Optional then + op.ignored_err = err + err = nil + else + op.ignored_err = nil + end + op.run_err = err + op.proc = state.proc + ObjModified(op) + ResumeObjModified("RunOp") + if err then + self.err_op = op + self.err_msg = err + return err + end + end + if op == to then + break + end + end +end + +function GridProc:Run(state, from, to) + state = state or {} + PauseInfiniteLoopDetection("GridProc.Run") + GridStatsReset() + DbgStopInspect() + local run_mode = state.run_mode or developer and self.RunMode or "GM" + if run_mode == "Profile" then + run_mode = "GM" + FunctionProfilerStart() + end + if run_mode == "GM" then + SuspendObjModified("GridProc.Run") + end + state.run_mode = run_mode + local seed = state.rand + if not seed then + local seed_key, seed_tbl = self:GetSeedSaveDest() + assert(not seed_key or seed_tbl) + if self.LoadSeed then + seed = seed_key and seed_tbl and seed_tbl[seed_key] or 0 + else + seed = self.Randomize and AsyncRand() or self.Seed + if self.SaveSeed and seed_key and seed_tbl then + seed_tbl[seed_key] = seed + end + end + state.rand = seed + end + local grids = state.grids or {} + local params = state.params or {} + state.env = state.env or setmetatable({}, {__index = function(tbl, key) + local grid = rawget(grids, key) + if grid ~= nil then + return grid + end + local param = rawget(params, key) + if param ~= nil then + return param + end + assert(rawget(tbl, key) == nil) + return rawget(_G, key) + end}) + state.grids = grids + state.params = params + state.tags = state.tags or {} + state.refs = state.refs or {} + state.proc = state.proc or self + + local dump_str + if self.Dump and state.run_mode ~= "GM" and state.proc == self then + dump_str = pstr("", 1024*1024) + local unpack = table.unpack + local appendf = dump_str.appendf + local append = dump_str.append + state.dump = function(fmt, ...) + appendf(dump_str, fmt, ...) + append(dump_str, "\n") + end + end + + local start_exec_count = state.exec_count or 0 + state.exec_count = start_exec_count + + self.err_msg = nil + self.err_op = nil + self.start_time = GetPreciseTicks() + self.Time = 0 + self.log = {} + + if self.Lightmodel ~= "" then + SetLightmodelOverride(false, self.Lightmodel) + end + + self:AddLog("Running") + ObjModified(self) + + state.test = true + local err = self:RunOps(state, from, to) + if not err then + state.test = nil + self:RunInit(state) + self:RunOps(state, from, to) + end + + self.ExecCount = state.exec_count - start_exec_count + + local finalize_idx = self:AddLog("Finalize generation...") + local start_finalize = GetPreciseTicks() + self:RunDone(state) + + if dump_str then + LastGridProcName = self:GetFullName() + LastGridProcDump = dump_str + CreateRealTimeThread(function() + local filename = LastGridProcName .. ".txt" + local err = AsyncStringToFile(filename, dump_str) + if err then + print("failed to write log:", err) + elseif config.DebugMapGen then + ---[[ + local path = ConvertToOSPath(filename) + print("log file saved to:", path) + OpenTextFileWithEditorOfChoice(path) + --]] + end + end) + end + + local stat_alive = GridStatsAlive() or "" + if #stat_alive > 0 then + DebugPrint("Grids:\n") + DebugPrint(print_format(stat_alive)) + DebugPrint("\n") + end + local stat_usage = GridStatsUsage() or "" + if #stat_usage > 0 then + DebugPrint("Grid ops:\n") + DebugPrint(print_format(stat_usage)) + DebugPrint("\n") + end + ResumeObjModified("GridProc.Run") + ResumeInfiniteLoopDetection("GridProc.Run") + FunctionProfilerStop() + + local time_finalize = GetPreciseTicks() - start_finalize + if finalize_idx and time_finalize > 0 then + self.log[finalize_idx] = "Finalize generation: " .. time_finalize .. " ms" + end + self:AddLog(self:GetError() or "Finished with success") + + return self.err_msg +end + +function GridProc:GetFullName() + return self.class +end + +function GridProc:AddLog(text, state) + if not text then return end + text = (state and state.indent or "") .. text + local log = self.log + local idx = #log + 1 + log[idx] = text + self.Time = GetPreciseTicks() - self.start_time + ObjModified(self) + return idx +end + +function GridProc:RunInit(state) +end + +function GridProc:RunDone(state) +end + +function GridProc:GetError() + return self.err_msg and string.format("Error in '%s': '%s'", self.err_op.GridOpType, self.err_msg) +end + +---- + +DefineClass.GridProcPreset = { + __parents = { "Preset", "GridProc" }, + EditorCustomActions = { + { Menubar = "Action", Toolbar = "main", Name = "Run All", FuncName = "ActionRunAll", Icon = "CommonAssets/UI/Ged/play.tga", Shortcut = "R", }, + { Menubar = "Action", Toolbar = "main", Name = "Run From Cursor", FuncName = "ActionRunFromCursor", Icon = "CommonAssets/UI/Ged/right.tga", }, + { Menubar = "Action", Toolbar = "main", Name = "Run To Cursor", FuncName = "ActionRunToCursor", Icon = "CommonAssets/UI/Ged/log-focused.tga", }, + { Menubar = "Action", Toolbar = "main", Name = "Run Cursor Only", FuncName = "ActionRunCursorOnly", Icon = "CommonAssets/UI/Ged/filter.tga", Split = true}, + }, + ContainerClass = "GridOp", + StoreAsTable = false, + + EnableReloading = false, + + run_thread = false, + run_start = 0, + run_state = false, + run_from = false, + run_to = false, + + EditorMenubarName = false, +} + +function GridProcPreset:ActionRunAll(ged) + self:ScheduleRun() +end + +local function RecalcProc(proc, op, recalc) + recalc = recalc or proc.OnChange + local from, to + if recalc == "recalc_op" then + from = op + to = op + elseif recalc == "recalc_to" then + to = op + elseif recalc == "recalc_from" then + from = op + elseif recalc ~= "recalc_all" then + return + end + local prev_state = proc.run_state or empty_table + local state = { + grids = prev_state.grids, + params = prev_state.params, + } + proc:ScheduleRun(state, from, to) +end + +function GridProcPreset:ActionRunToCursor(ged) + if IsKindOf(ged.selected_object, "GridOp") then + RecalcProc(self, ged.selected_object, "recalc_to") + end +end + +function GridProcPreset:ActionRunFromCursor(ged) + if IsKindOf(ged.selected_object, "GridOp") then + RecalcProc(self, ged.selected_object, "recalc_from") + end +end + +function GridProcPreset:ActionRunCursorOnly(ged) + if IsKindOf(ged.selected_object, "GridOp") then + RecalcProc(self, ged.selected_object, "recalc_op") + end +end + +function ActionOpenMapgenFolder(ged) + local path = ConvertToOSPath("svnAssets/Source/MapGen/" .. GetMapName()) + CreateRealTimeThread(function() + AsyncExec(string.format('cmd /c start /D "%s" .', path)) + end) +end + +function GridProcPreset:ScheduleRun(state, from, to, delay) + self.run_start = RealTime() + (delay or 0) + self.run_state = state or {} + self.run_from = from + self.run_to = to + if IsValidThread(self.run_thread) then + return + end + self.run_thread = CreateRealTimeThread(function(self) + while self.run_start > RealTime() do + Sleep(self.run_start - RealTime()) + end + self:Run(self.run_state, self.run_from, self.run_to) + end, self) +end + + +function GridProcPreset:Run(state, ...) + local err = GridProc.Run(self, state, ...) + local proc = state and state.proc or self + if proc == self then + ObjModified(Presets[self.class]) + end + return err +end + +function GridProcPreset:GetFullName() + return self.id +end + +---- + +local preview_size = 512 +local preview_recision = 1000 +local float_recision = 1000000 +local function ResampleGridForPreview(grid) + grid = grid and not IsComputeGrid(grid) and GridRepack(grid, "F") or grid + return GridResample(grid, preview_size, preview_size, false) +end +local function no_outputs(op) + for id in pairs(op.output_props or empty_table) do + if (op[id] or "") ~= "" then + return + end + end + return true +end +local function output_names(op) + local items = {} + local outputs = op.outputs or empty_table + local default = op.output_default + for id in pairs(op.output_props or empty_table) do + items[#items + 1] = { value = id ~= default and id, name = op[id] } + end + table.sortby_field(items, "name") + return items +end +local function allowed_run_mode_items(op) + local def_modes = getmetatable(op).RunModes or all_run_modes + if def_modes == all_run_modes then + return run_mode_items + end + local items = {} + for _, mode in ipairs(run_mode_items) do + if def_modes[mode] then + items[#items + 1] = mode + end + end + return items +end + +DefineClass.GridOp = { + __parents = { "PropertyObject" }, + properties = { + { category = "General", id = "Enabled", name = "Enabled", editor = "bool", default = true }, + { category = "General", id = "Optional", name = "Optional", editor = "bool", default = false }, + { category = "General", id = "Breakpoint", name = "Breakpoint", editor = "bool", default = false }, + { category = "General", id = "GridOpType", name = "Type", editor = "text", read_only = true, dont_save = true }, + { category = "General", id = "RunModes", name = "Run Modes", editor = "set", default = all_run_modes, items = allowed_run_mode_items }, + { category = "General", id = "UseParams", name = "Use Params", editor = "bool", default = false, no_edit = function(self) return not self.param_props end }, + { category = "General", id = "Operations", name = "Operation", editor = "set", default = empty_table, items = function(self) return self.operations or empty_table end, max_items_in_set = 1, dont_save = true, no_edit = function(self) return #(self.operations or "") <= 1 end }, + { category = "General", id = "Operation", editor = "text", default = "", no_edit = true }, + { category = "General", id = "Seed", name = "Seed", editor = "number", default = false, buttons = {{name = "Rand", func = "RandSeed"}}, help = "Custom rand seed. If not specified, it will be generated based on the operation name." }, + { category = "General", id = "Comment", name = "Comment", editor = "text", default = "" }, + + { category = "Stats", id = "RunTime", name = "Time (ms)", editor = "number", default = -1, read_only = true, dont_save = true }, + { category = "Stats", id = "RunError", name = "Error", editor = "text", default = false, read_only = true, dont_save = true }, + { category = "Stats", id = "ParamValues", name = "Params", editor = "text", default = false, lines = 1, max_lines = 3, read_only = true, dont_save = true }, + + { category = "Preview", id = "OutputSelect", name = "Output Select", editor = "choice", default = false, items = output_names, dont_save = true, max_items_in_set = 1, no_edit = no_outputs }, + { category = "Preview", id = "OutputSize", name = "Output Size", editor = "point", default = false, read_only = true, dont_save = true, no_edit = no_outputs }, + { category = "Preview", id = "OutputLims", name = "Output Lims", editor = "point", default = false, scale = preview_recision, read_only = true, dont_save = true, no_edit = no_outputs }, + { category = "Preview", id = "OutputType", name = "Output Type", editor = "text", default = false, read_only = true, dont_save = true, no_edit = no_outputs }, + { category = "Preview", id = "OutputPreview", name = "Output Grid", editor = "grid", default = false, min = preview_size, max = preview_size, read_only = true, dont_save = true, no_edit = no_outputs }, + }, + + GridOpType = "", + EditorName = false, + + start_time = 0, + recalc_on_change = true, + proc = false, + + reset_props = false, + + inputs = false, + input_props = false, + input_fmt = false, + input_bits = false, + + outputs = false, + output_props = false, + output_default = false, + + prop_to_param = false, + params = false, + param_props = false, + + operations = false, + operation_text_only = false, + props_processed = false, + + run_err = false, + ignored_err = false, +} + +local function is_optional(obj, prop) + return eval(prop.optional, obj, prop) +end + +local function is_ignored(obj, prop) + return eval(prop.ignore_errors, obj, prop) +end + +local function is_disabled(obj, prop) + return eval(prop.no_edit, obj, prop) +end + +function OnMsg.ClassesPostprocess() + ClassDescendants("GridOp", function(class, def) + if not def.props_processed then + print("GridOp class", class, "requires GridOpType value") + end + local prop_to_param, param_props + local input_props, output_props, reset_props + for _, prop in ipairs(def.properties or empty_table) do + if prop.grid_param then + if not param_props then + param_props = {} + def.param_props = param_props + end + param_props[prop.id] = prop + elseif prop.use_param then + if not prop_to_param then + prop_to_param = {} + def.prop_to_param = prop_to_param + end + prop_to_param[prop.id] = prop.use_param + end + if prop.grid_input then + if not input_props then + input_props = {} + def.input_props = input_props + end + input_props[prop.id] = prop + end + if prop.grid_output then + if not output_props then + output_props = {} + def.output_props = output_props + def.output_default = prop.id + end + output_props[prop.id] = prop + end + if prop.to_reset then + if not reset_props then + reset_props = {} + def.reset_props = reset_props + end + reset_props[prop.id] = prop + end + end + end) +end + +function OnMsg.ClassesGenerate(classdefs) + for class, def in pairs(classdefs) do + local op_type = def.GridOpType + if op_type then + def.props_processed = true + local operations = def.operations + if op_type ~= "" then + if operations then + def.Operation = operations[1] + if #operations > 1 then + op_type = op_type .. ": " .. table.concat(operations, '-') + end + end + def.EditorName = op_type + end + local prop_to_param, param_props + local props = def.properties or empty_table + local prop_idx = 1 + while prop_idx <= #props do + local prop = props[prop_idx] + local category = prop.category + if prop.grid_param then + local no_edit = prop.no_edit + prop.no_edit = function(self, prop) + return not self.UseParams or eval(no_edit, self, prop) + end + elseif prop.use_param then + local no_edit = prop.no_edit + local param_id = prop.id .. "Param" + prop.use_param = param_id + table.insert(props, prop_idx + 1, { + id = param_id, name = prop.name .. " Param", editor = "choice", + default = "", items = GridOpParams, grid_param = true, optional = true, + category = category, operation = prop.operation, + enabled_by = prop.enabled_by, no_edit = no_edit, + }) + prop.no_edit = function(self, prop) + return self.UseParams and self[param_id] ~= "" or eval(no_edit, self, prop) + end + end + if prop.operation then + local no_edit = prop.no_edit + local list = prop.operation + if type(list) ~= "table" then + list = { list } + end + local disable_by, enable_by + for _, name in ipairs(list) do + if string.starts_with(name, "!") then + name = string.sub(name, 2) + disable_by = table.create_set(disable_by, name, true) + else + enable_by = table.create_set(enable_by, name, true) + end + end + prop.no_edit = function(self, prop) + if disable_by and disable_by[self.Operation] or enable_by and not enable_by[self.Operation] then + return true + end + return eval(no_edit, self, prop) + end + end + if prop.enabled_by then + local no_edit = prop.no_edit + local list = prop.enabled_by + if type(list) ~= "table" then + list = { list } + end + local disable_by, enable_by + for _, name in ipairs(list) do + if string.starts_with(name, "!") then + name = string.sub(name, 2) + disable_by = table.create_set(disable_by, name, true) + else + enable_by = table.create_set(enable_by, name, true) + end + end + prop.no_edit = function(self, prop) + for prop_id in pairs(disable_by) do + if (self[prop_id] or "") ~= "" then + return true + end + end + if enable_by then + local found + for prop_id in pairs(enable_by) do + if (self[prop_id] or "") ~= "" then + found = true + break + end + end + if not found then + return true + end + end + return eval(no_edit, self, prop) + end + end + if prop.optional and prop.help then + prop.help = prop.help .. " (optional)" + end + if category == "Preview" or category == "Stats" then + prop.dont_save = true + prop.to_reset = prop.read_only + prop.dont_recalc = true + end + prop_idx = prop_idx + 1 + end + end + end +end + +function GridOp:RandSeed() + self.Seed = AsyncRand() + ObjModified(self) +end + +function GridOp:SetOutputSelect(name) + self.OutputSelect = name + self.OutputSize = nil + self.OutputLims = nil + self.OutputType = nil + self.OutputPreview = nil +end + +function GridOp:SetGridOutput(name, grid) + local outputs = (name or "") ~= "" and self.outputs + if outputs then + assert(grid) + outputs[name] = grid + end +end + +function GridOp:GetGridInput(name) + local inputs = (name or "") and self.inputs + return inputs and inputs[name] or nil +end + +function GridOp:GetOutputSelectGrid() + local output = self.OutputSelect or self.output_default + local name = output and self[output] + return name and (self.outputs or empty_table)[name] +end + +function GridOp:GetOutputSize() + local size = self.OutputSize + if not size then + local grid = self:GetOutputSelectGrid() + size = grid and point(grid:size()) or point20 + self.OutputSize = size + end + return size +end + +function GridOp:GetOutputLims() + local lims = self.OutputLims + if not lims then + local grid = self:GetOutputSelectGrid() + lims = IsComputeGrid(grid) and point(GridMinMax(grid, preview_recision)) or point20 + self.OutputLims = lims + end + return lims +end + +function GridOp:GetOutputType() + local gtype = self.OutputType + if not gtype then + local grid = self:GetOutputSelectGrid() + gtype = grid and GridGetPID(grid) or "" + self.OutputType = gtype + end + return gtype +end + +function GridOp:GetOutputPreview() + local preview = self.OutputPreview + if not preview then + local grid = self:GetOutputSelectGrid() + preview = grid and ResampleGridForPreview(grid) + self.OutputPreview = preview + end + return preview +end + +function GridOp:SetOperations(opset) + local op + for key, value in pairs(opset) do + if value then + op = key + break + end + end + self.Operation = op +end + +function GridOp:GetOperations() + return self.Operation ~= "" and set(self.Operation) or set() +end + +function GridOp:GetValue(prop_id) + local prop_to_param = self.UseParams and self.prop_to_param + local param_id = prop_to_param and prop_to_param[prop_id] + local param = param_id and self[param_id] or "" + if param ~= "" then + return self.params and self.params[param] + end + return self[prop_id] +end + +function GridOp:GetValueText(prop_id, default) + local prop_to_param = self.UseParams and self.prop_to_param + local param_id = prop_to_param and prop_to_param[prop_id] + local param = param_id and self[param_id] or "" + if param ~= "" then + return "<" .. param_id .. ">", param + end + local value = self[prop_id] + if value ~= default then + return "<" .. prop_id .. ">", value + end + return "" +end + +function GridOp:CollectTags(tags) +end + +function GridOp:RunTest(state) +end + +function GridOp:RunOp(state) + local run_mode = state.run_mode + if not self.Enabled or not self.RunModes[run_mode] then + return + end + + local test = state.test + local grids = state.grids + local refs = state.refs + + local params + local param_props = self.param_props + if param_props then + params = self.params or {} + self.params = params + local state_params = state.params + for id, prop in pairs(param_props) do + if not is_disabled(self, prop) then + local name = self[id] or "" + if name ~= "" then + if not test then + -- TODO: remove non-existent params from the previous run + local param = state_params[name] + if not param then + -- possible in partial runs + param = params[name] + state_params[name] = param + end + if param ~= nil then + params[name] = param + else + return "Param Not Found: " .. name + end + end + elseif not is_optional(self, prop) then + return "Param Name Expected: " .. prop.name + end + end + end + end + + local outputs + local output_props = self.output_props + if output_props then + outputs = {} + self.outputs = outputs + for id, prop in pairs(output_props) do + if not is_disabled(self, prop) and not is_optional(self, prop) then + local name = self[id] or "" + if name == "" then + return "Output Name Expected: " .. prop.name + end + end + end + end + + local inputs + local input_props = self.input_props + if input_props then + local input_fmt, input_bits = self.input_fmt, self.input_bits + inputs = self.inputs or {} + self.inputs = inputs + for id, prop in pairs(input_props) do + if not is_disabled(self, prop) then + local name = self[id] or "" + if name ~= "" then + if not test then + local grid = grids[name] + if not grid then + -- possible in partial runs + grid = inputs[name] + grids[name] = grid + end + if grid then + if input_fmt then + grid = GridRepack(grid, input_fmt, input_bits or nil) + end + inputs[name] = grid + elseif not (output_props or empty_table)[id] then + if not is_ignored(self, prop) then + return "Input Not Found: " .. name + end + end + else + refs[name] = (refs[name] or 0) + 1 + end + elseif not is_optional(self, prop) then + return "Input Name Expected: " .. prop.name + end + end + end + end + + local operations = self.operations + if operations and not table.find(operations, self.Operation) then + return "Grid Operation Expected" + end + + if test then + self.RunTime = nil + self:CollectTags(state.tags) + return self:RunTest(state) + end + + for id, prop in pairs(self.reset_props or empty_table) do + self[id] = nil + end + local exec_count = state.exec_count + 1 + state.exec_count = exec_count + + local name = self:GetFullName() + local prev_rand = state.rand + local rand = xxhash(state.rand, self.Seed or name) + state.rand = rand + + bp(self.Breakpoint) + + local dump = state.dump + if dump then + dump("\nGridOp %03d 0x%016X: %s", exec_count, rand, name) + end + + self.start_time = GetPreciseTicks() + local err = self:Run(state) + self.RunTime = GetPreciseTicks() - self.start_time + + state.rand = prev_rand -- restore the original seed, thus avoiding the order/count of operations affecting the randomness + + if err then + return err + end + + if output_props then + for id, prop in pairs(output_props) do + local name = self[id] or "" + if name ~= "" then + local grid = outputs[name] + if not grid and not is_optional(self, prop) then + return "Output Missing: " .. name + end + if dump and grid then + local w, h = grid:size() + local t, b = IsComputeGrid(grid) + dump("* grid '%s' %d x %d '%s%s' 0x%016X", name, w, h, t and tostring(t) or "", b and tostring(b) or "", xxhash(grid)) + end + if run_mode == "GM" then + local prev_grid = grids[name] + if prev_grid then + prev_grid:free() + end + end + grids[name] = grid + end + end + end + + state.proc:AddLog(self:GetLogMessage(), state) + + if run_mode == "GM" then + self.inputs = nil + self.outputs = nil + self.params = nil + for name, grid in pairs(inputs or empty_table) do + refs[name] = refs[name] - 1 + if grid ~= grids[name] then + grid:free() + end + end + for name, grid in pairs(grids) do + if (refs[name] or 0) <= 0 then + grid:free() + grids[name] = nil + end + end + end +end + +function GridOp:GetFullName() + return string.strip_tags(_InternalTranslate(T(self:GetLogText()), self, false)) +end + +function GridOp:GetLogMessage() + if self.RunTime <= 1 then return end + return string.format("%s: %d ms", self:GetFullName(), self.RunTime) +end + +function GridOp:GetParamValues() + local prop_to_param = self.UseParams and self.prop_to_param + local params = self.params + if not next(params) or not next(prop_to_param) then + return "" + end + local list, passed = {}, {} + for prop_id, param_id in pairs(prop_to_param) do + local param = param_id and self[param_id] or "" + if not passed[param] then + passed[param] = true + local value = param ~= "" and params[param] + if value then + list[#list + 1] = string.format("%s = %s", param, tostring(value)) + end + end + end + table.sort(list) + return table.concat(list, ", ") +end + +function GridOp:Run() +end + +function GridOp:GetError() + return self.run_err +end + +function GridOp:GetRunError() + return self.run_err or self.ignored_err +end + +function GridOp:GetEditorText() + if self.Operation == "" then + return "" + elseif self.operation_text_only then + return "" + else + return " " + end +end + +function GridOp:GetLogText() + return self:GetEditorText() +end + +function GridOp:GetEditorView() + local text = self:GetEditorText() or "" + if text == "" then + text = "" + end + local my_run_modes = self.RunModes + local run_mode = (self:GetPreset() or empty_table).RunMode + if run_mode and not my_run_modes[run_mode] then + text = "" + elseif not self.Enabled then + text = "[] " .. text + elseif self.run_err then + text = "" + elseif self.ignored_err then + text = "[] " .. text + --[[ + elseif my_run_modes ~= all_run_modes then + text = " " .. text + --]] + elseif self.RunTime > 0 then + text = text .. " " + end + if (self.Comment or "") ~= "" then + text = "\n" .. text + end + return Untranslated(text) +end + +function GridOp:GetPreset() + return GetParentTable(self) +end + +function GridOp:Recalc() + local proc = self.proc + if proc then + RecalcProc(proc, self) + end +end + +function GridOp:OnEditorSetProperty(prop_id, old_value, ged) + local proc = self.recalc_on_change and self.proc + if not proc then return end + local meta = self:GetPropertyMetadata(prop_id) or empty_table + if meta.dont_recalc then return end + RecalcProc(proc, self) +end + +---- + +DefineClass.GridOpComment = { + __parents = { "GridOp" }, + GridOpType = "Comment", + recalc_on_change = false, +} + +function GridOpComment:GetEditorView() + if (self.Comment or "") ~= "" then + return Untranslated("") + end + return "" +end + +---- + +local function GridOpItems(grid_op, def, callback) + local local_items, global_items = {}, {} + local parent = grid_op and grid_op:GetPreset() + if not parent then + return {} + end + ForEachPreset(parent.class, function(preset) + local is_local = preset == parent + local items = is_local and local_items or global_items + for _, op in ipairs(preset) do + callback(op, items, is_local) + end + end) + for key in pairs(local_items) do + global_items[key] = nil + end + local names = table.keys(local_items, true) + table.iappend(names, table.keys(global_items, true)) + if def then + table.insert(names, 1, def) + end + return names +end + +function GridOpOutputNames(grid_op) + return GridOpItems(grid_op, nil, function(op, items) + for _, prop in ipairs(op:GetProperties() or empty_table) do + if prop.grid_output then + items[op[prop.id]] = true + end + end + end) +end + +function GridOpParams(grid_op) + return GridOpItems(grid_op, "", function(op, items, is_local) + if IsKindOf(op, "GridOpParam") then + if not op.ParamLocal or is_local then + items[op.ParamName] = true + end + end + end) +end + +DefineClass.GridOpParam = { + __parents = { "GridOp" }, + properties = { + { category = "General", id = "ParamName", name = "Name", editor = "combo", default = "", items = GridOpParams }, + { category = "General", id = "ParamValue", name = "Value", editor = "text", default = "" }, + { category = "General", id = "ParamLocal", name = "Local", editor = "bool", default = false }, + }, + GridOpType = "", +} + +function GridOpParam:GetParamStr() + return tostring(self.ParamValue) +end + +function GridOpParam:GetParam(state) + return self.ParamValue +end + +function GridOpParam:Run(state) + if self.ParamName == "" then + return "Missing Param Name" + end + local value, err = self:GetParam(state) + if err then + return err + end + state.params[self.ParamName] = value + local dump = state.dump + if dump then + dump("* %s '%s' = %s", type(value), self.ParamName, ValueToLuaCode(value)) + end +end + +function GridOpParam:GetEditorText() + return " = " +end + +---- + +DefineClass.GridOpParamEval = { + __parents = { "GridOpParam" }, + properties = { + { category = "Preview", id = "ParamPreview", name = "Evaluated", editor = "text", default = "", read_only = true, dont_save = true }, + }, + GridOpType = "Param", + value = false, +} + +function GridOpParamEval:GetParam(state) + local func, err = load("return " .. self.ParamValue, nil, nil, state.env) + if not func then + return nil, err + end + local success, value = pcall(func) + if not success then + return nil, value + end + self.value = value + return value +end + +function GridOpParamEval:GetParamPreview() + if not self.proc then + return "" + end + return ValueToLuaCode(self.value) +end + +---- + +DefineClass.GridOpRun = { + __parents = { "GridOp" }, + properties = { + { category = "General", id = "Sequence", name = "Sequence", editor = "preset_id", default = "", preset_class = function(self) return (self:GetPreset() or empty_table).class end, operation = "Proc" }, + { category = "General", id = "Iterations", name = "Iterations", editor = "number", default = 1, min = 1, operation = "Proc" }, + { category = "General", id = "InputName", name = "Input Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, operation = {"Code", "Func"} }, + { category = "General", id = "OutputName", name = "Output Name", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true, optional = true, operation = {"Code", "Func"} }, + { category = "General", id = "Tags", name = "Tags", editor = "set", items = {"Terrain", "Objects"}, default = false }, + { category = "General", id = "Code", name = "Code", editor = "func", default = false, lines = 1, max_lines = 100, params = "state, grid", operation = "Code" }, + { category = "General", id = "Func", name = "Func", editor = "text", default = "", operation = "Func" }, + { category = "General", id = "Param1", name = "Param 1", editor = "choice", default = "", operation = "Func", items = GridOpParams, optional = true, grid_param = true }, + { category = "General", id = "Param2", name = "Param 2", editor = "choice", default = "", operation = "Func", items = GridOpParams, optional = true, grid_param = true }, + { category = "General", id = "Param3", name = "Param 3", editor = "choice", default = "", operation = "Func", items = GridOpParams, optional = true, grid_param = true }, + }, + GridOpType = "Run", + operations = {"Proc", "Code", "Func"}, +} + + +function GridOpRun:CollectTags(tags) + table.append(tags, self.tags) +end + +function GridOpRun:GetEditorText() + local op = self.Operation + if op == "Proc" then + local iters = self.Iterations > 1 and " x " or "" + return " " .. iters + elseif op == "Func" then + if self.Func == "" then + return "" + end + local params = {} + if self.InputName ~= "" then + params[1] = "" + end + if self.Param1 ~= "" then + params[#params + 1] = "" + elseif self.Param2 ~= "" or self.Param3 ~= "" then + params[#params + 1] = "nil" + end + if self.Param2 ~= "" then + params[#params + 1] = "" + elseif self.Param3 ~= "" then + params[#params + 1] = "nil" + end + if self.Param3 ~= "" then + params[#params + 1] = "" + end + + local params_str = #params > 0 and table.concat(params, ", ") or "" + local func_str = "(" .. params_str .. ")" + if self.OutputName ~= "" then + return " = " .. func_str + end + return " " .. func_str + elseif op == "Code" then + local source = FuncSource[self.Code] + local source_str = "" + if source and source[3] then + source_str = "\n" + end + if self.OutputName ~= "" then + if self.InputName ~= "" then + return " = ()" .. source_str + end + return " = ()" .. source_str + end + if self.InputName ~= "" then + return " ()" .. source_str + end + return "" .. source_str + end + return GridOp.GetEditorText(self) +end + +function GridOpRun:GetLogText() + return " " +end + +function GridOpRun:GetTarget(state) + local sequences = {} + local parent = state.proc + ForEachPreset(parent.class, function(preset, group, sequence, sequences) + if preset.id == sequence then + sequences[#sequences + 1] = preset + end + end, self.Sequence, sequences) + if #sequences == 0 then + return nil, "Cannot Find Sequence: " .. self.Sequence + elseif #sequences > 1 then + return nil, "Multiple Sequences Named: " .. self.Sequence + elseif sequences[1] == parent then + return nil, "Cannot Run Itself: " .. self.Sequence + end + return sequences[1] +end + +function GridOpRun:RunTest(state) + local op = self.Operation + if op == "Proc" then + local target, err = self:GetTarget(state) + if err then + return err + end + self.target = target + for it = 1,self.Iterations do + local err = target:RunOps(state) + if err then + return err + end + end + elseif op == "Code" then + local source = FuncSource[self.Code] + if source and source[4] then + return source[4] + end + elseif op == "Func" then + local name = self.Func or "" + if name == "" then + return "Function name expected" + end + if not _G[name] then + return "No such global function" + end + end +end + +function GridOpRun:Run(state) + local op = self.Operation + if op == "Proc" then + local target = self.target + if not target then + return "Gather Run Error" + end + state.running = state.running or {} + if state.running[target] then + return "Infinite Recursion" + end + state.completed = state.completed or {} + if state.completed[target] and target.RunOnce then + return + end + state.running[target] = true + local iters_str = self.Iterations > 1 and " x " .. self.Iterations or "" + state.proc:AddLog("Running " .. self.Sequence .. iters_str, state) + for it = 1,self.Iterations do + local err = target:RunOps(state) + if err then + return err + end + end + state.running[target] = nil + state.completed[target] = true + elseif op == "Code" then + local input_grid = self:GetGridInput(self.InputName) + if self.InputName ~= "" and not input_grid then + return "Input grid " .. self.InputName .. "not found" + end + local success, err, output_grid = pcall(self.Code, state, input_grid) -- todo: debug missing error propagation + if err then + return err + end + if self.OutputName ~= "" then + if not output_grid then + return "Grid result expected" + end + self:SetGridOutput(self.OutputName, output_grid) + end + elseif op == "Func" then + local input_grid = self:GetGridInput(self.InputName) + if self.InputName ~= "" and not input_grid then + return "Input grid " .. self.InputName .. "not found" + end + local func = _G[self.Func] + local params = self.params + local param1 = params and params[self.Param1] + local param2 = params and params[self.Param2] + local param3 = params and params[self.Param3] + local success, output_grid = pcall(func, input_grid, param1, param2, param3) + if not success then + return output_grid + end + if self.OutputName ~= "" then + if not output_grid then + return "Grid result expected" + end + self:SetGridOutput(self.OutputName, output_grid) + end + end +end + +---- + +DefineClass.GridOpDir = { + __parents = { "GridOp" }, + properties = { + { category = "General", id = "BaseDir", name = "Base Dir", editor = "browse", default = "", folder = "svnAssets" }, + }, + GridOpType = "Directory Change", +} + +function GridOpDir:GetEditorText() + return "Set Directory " +end + +function GridOpDir:SetBaseDir(path) + local path, fname, ext = SplitPath(path) + self.BaseDir = SlashTerminate(path) +end + +function GridOpDir:Run(state) + if self.BaseDir == "" then + return "Base Dir Expected" + end + if not io.exists(self.BaseDir) then + return "Base Dir Do Not Exists" + end + state.base_dir = self.BaseDir +end + +local function GridOpBaseDirs(grid_op) + local base_dirs = GridOpItems(grid_op, "svnAssets/Source/MapGen", function(op, items) + if IsKindOf(op, "GridOpDir") then + items[op.BaseDir] = true + end + end) + return base_dirs +end + +---- + +DefineClass.GridOpOutput = { + __parents = { "GridOp" }, + properties = { + { category = "General", id = "OutputName", name = "Output Name", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true }, + }, + output_preview = false, + GridOpType = "", +} + +function GridOpOutput:GetEditorText() + local op_str = GridOp.GetEditorText(self) + return op_str .. " to " +end + +function GridOpOutput:Run(state) + local err, grid = self:GetGridOutput(state) + if err then + return err + end + self:SetGridOutput(self.OutputName, grid) +end + +function GridOpOutput:GetGridOutput(state) +end + +---- + +DefineClass.GridOpInput = { + __parents = { "GridOp" }, + properties = { + { category = "General", id = "InputName", name = "Input Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + }, + GridOpType = "", +} + +function GridOpInput:GetEditorText() + local op_str = GridOp.GetEditorText(self) + return op_str .. " " +end + +function GridOpInput:Run(state) + return self:SetGridInput(state, self:GetGridInput(self.InputName)) +end + +function GridOpInput:SetGridInput(state, grid) +end + +---- + +DefineClass.GridOpInputOutput = { + __parents = { "GridOpInput", "GridOpOutput" }, + properties = { + { category = "Preview", id = "OutputCurtain", name = "Output Curtain (%)", editor = "number", default = 100, min = 0, max = 100, slider = true, dont_save = true }, + }, + input_preview = false, + output_preview = false, + GridOpType = "", +} + +function GridOpInputOutput:GetGridOutputFromInput(state, grid) +end + +function GridOpInputOutput:SetGridInput(state, input) + return self:GetGridOutputFromInput(state, input) +end + +function GridOpInputOutput:GetGridOutput(state) + return GridOpInput.Run(self, state) +end + +function GridOpInputOutput:Run(state) + self.input_preview = nil + self.output_preview = nil + return GridOpOutput.Run(self, state) +end + +function GridOpInputOutput:GetEditorText() + local ops_str = GridOp.GetEditorText(self) + if self.InputName == self.OutputName then + return ops_str .. " " + end + return ops_str .. " to " +end + +function GridOpInputOutput:SetOutputCurtain(curtain) + self.OutputCurtain = curtain + self.OutputPreview = nil +end + +function GridOpInputOutput:SetOutputSelect(name) + self.input_preview = nil + self.output_preview = nil + GridOp.SetOutputSelect(self, name) +end + +function GridOpInputOutput:GetOutputPreview() + local preview = self.OutputPreview + if not preview then + local output = self.output_preview or self:GetOutputSelectGrid() + local input = self.input_preview or (self.inputs or empty_table)[self.InputName] + if output and input then + local curtain = self.OutputCurtain + output = ResampleGridForPreview(output) + self.output_preview = output + preview = output + if curtain < 100 then + input = ResampleGridForPreview(input) + input = GridRepack(input, IsComputeGrid(output)) + self.input_preview = input + preview = input + if curtain > 0 then + local l = MulDivRound(preview_size, curtain, 100) + local mask = NewComputeGrid(preview_size, preview_size, "U", 8) + for x = 0, l do + GridDrawColumn(mask, x, preview_size - 1, 1, 1, 1) + end + preview = GridDest(output) + GridRepack(mask, preview) + GridLerp(input, preview, output, preview) + end + end + self.OutputPreview = preview + end + end + return preview +end + +---- + +local ref_available = function(self) return self.RefName ~= "" end +local grid_fmts = {"", "float", "uint16", "uint8"} +local function GridTypeToFmt(gt) + if gt == "float" then + return "f", 32 + elseif gt == "uint16" then + return "u", 16 + elseif gt == "uint8" then + return "u", 8 + end +end + +DefineClass.GridOpDest = { + __parents = { "GridOpOutput" }, + properties = { + { category = "General", id = "RefName", name = "Grid Reference", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, help = "Needed to match the same grid size and type" }, + { category = "General", id = "Width", name = "Grid Width", editor = "number", default = 0, min = 0, use_param = true, no_edit = ref_available }, + { category = "General", id = "Height", name = "Grid Height", editor = "number", default = 0, min = 0, use_param = true, no_edit = ref_available }, + { category = "General", id = "GridType", name = "Grid Type", editor = "choice", default = "", items = grid_fmts, no_edit = ref_available }, + { category = "General", id = "GridDefault", name = "Grid Default", editor = "number", default = 0 }, + }, + GridOpType = "", +} + +function GridOpDest:GetGridOutput(state) + local ref = self:GetGridInput(self.RefName) + local value = self.GridDefault + if ref then + if value == 0 then + return nil, GridDest(ref, true) + else + local grid = GridDest(ref) + GridFill(grid, value) + return nil, grid + end + end + local w = self:GetValue("Width") + local h = self:GetValue("Height") + local t, b = GridTypeToFmt(self.GridType) + if not t then + return "Grid Type Not Specified" + end + local grid = NewComputeGrid(w, h, t, b) + if value ~= 0 then + GridFill(grid, value) + end + return nil, grid +end + +---- + +DefineClass.GridOpFile = { + __parents = { "PropertyObject" }, + properties = { + { category = "General", id = "FileRelative", name = "File Relative", editor = "bool", default = true }, + { category = "General", id = "FileName", name = "File Name", editor = "browse", default = "", folder = GridOpBaseDirs, dont_validate = true, allow_missing = function(self) return self.AllowMissing end }, + { category = "General", id = "FileFormat", name = "File Format", editor = "choice", default = "", items = {"", "image", "grid", "raw8", "raw16"} }, + { category = "Preview", id = "FilePath", name = "File Path Game",editor = "text", default = "", read_only = true, dont_save = true }, + { category = "Preview", id = "FilePathOs", name = "File Path OS", editor = "text", default = "", read_only = true, dont_save = true }, + }, + AllowMissing = false, + DefaultFormat = "image", +} + +function GridOpFile:SetFileName(path) + if self.FileRelative then + local dir, fname, ext = SplitPath(path) + path = fname .. ext + end + self.FileName = path +end + +function GridOpFile:ResolveFilePath(state) + if self.FileRelative and not state.base_dir then + return "Base Dir Not Set" + end + if self.FileName == "" then + return "File Name Expected" + end + local path = self.FileName + if self.FileRelative then + path = state.base_dir .. path + end + if not self.AllowMissing and not io.exists(path) then + return "File Does Not Exist" + end + self.FilePath = path + return nil, path +end + +function GridOpFile:GetFilePathOs() + return ConvertToOSPath(self.FilePath) +end + +function GridOpFile:ResolveFileFormat() + local fmt = self.FileFormat + if fmt == "" then + local ext = string.lower(GetPathExt(self.FileName)) + if ext == "grid" then + fmt = "grid" + elseif ext == "r16" then + fmt = "raw16" + elseif ext == "raw" or ext == "r8" then + fmt = "raw8" + elseif ext == "tga" or ext == "png" or ext == "jpg" then + fmt = "image" + else + fmt = self.DefaultFormat + end + end + return fmt +end + +---- + +local function not_img(op) + return op:ResolveFileFormat() ~= "image" +end + +DefineClass.GridOpRead = { + __parents = { "GridOpOutput", "GridOpFile" }, + properties = { + { category = "General", id = "OutputName2", name = "Output Name 2", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true, optional = true, no_edit = not_img}, + { category = "General", id = "OutputName3", name = "Output Name 3", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true, optional = true, no_edit = not_img }, + { category = "General", id = "OutputName4", name = "Output Name 4", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true, optional = true, no_edit = not_img }, + }, + GridOpType = "Read", +} + +function GridOpRead:GetEditorText() + local outputs = { "" } + if self.OutputName2 ~= "" then + outputs[#outputs + 1] = "" + end + if self.OutputName3 ~= "" then + outputs[#outputs + 1] = "" + end + if self.OutputName4 ~= "" then + outputs[#outputs + 1] = "" + end + local str = table.concat(outputs, ", ") + str = " " .. str .. " from " + if self.FileFormat ~= "" then + str = str .. " as " + end + return str +end + +function GridOpRead:GetGridOutput(state) + local err, path = self:ResolveFilePath(state) + if err then + return err + end + local grid + local fmt = self:ResolveFileFormat() + if fmt == "grid" then + grid, err = GridReadFile(path) + elseif fmt == "image" then + local r, g, b, a = ImageToGrids(path) + if not r then + err = g + else + grid = r + if self.OutputName2 ~= "" then + self:SetGridOutput(self.OutputName2, g) + end + if self.OutputName3 ~= "" then + self:SetGridOutput(self.OutputName3, b) + end + if self.OutputName4 ~= "" then + self:SetGridOutput(self.OutputName4, a) + end + end + elseif fmt == "raw16" then + grid = NewComputeGrid(0, 0, "U", 16) + err = GridLoadRaw(path, grid) + elseif fmt == "raw8" then + grid = NewComputeGrid(0, 0, "U", 8) + err = GridLoadRaw(path, grid) + else + err = "Unknown File Format" + end + return err, grid +end + +---- + +DefineClass.GridOpWrite = { + __parents = { "GridOpInput", "GridOpFile" }, + properties = { + { category = "General", id = "Normalize", name = "Normalize", editor = "bool", default = true, no_edit = not_img }, + }, + GridOpType = "Write", + FileRelative = false, + AllowMissing = true, + DefaultFormat = "", +} + +function GridOpWrite:GetEditorText() + local str = " to " + if self.FileFormat ~= "" then + str = str .. " as " + end + return str +end + +function GridOpWrite:SetGridInput(state, grid) + local err, path = self:ResolveFilePath(state) + if err then + return err + end + local fmt = self:ResolveFileFormat() + if fmt == "grid" then + local success + success, err = GridWriteFile(grid, path) + elseif fmt == "image" then + if self.Normalize then + grid = GridNormalize(grid, GridDest(grid), 0, 255) + end + err = GridToImage(path, grid) + elseif fmt == "raw8" then + local grid_fmt, grid_bits = IsComputeGrid(grid) + if grid_fmt ~= "U" or grid_bits ~= 8 then + return "Incompatible grid format" + end + err = GridSaveRaw(path, grid) + elseif fmt == "raw16" then + local grid_fmt, grid_bits = IsComputeGrid(grid) + if grid_fmt ~= "U" or grid_bits ~= 16 then + return "Incompatible grid format" + end + err = GridSaveRaw(path, grid) + else + return "Unsupported File Format" + end + return err +end + +---- + +local empty_kernel = { + 0, 0, 0, + 0, 1, 0, + 0, 0, 0 +} + +local function GridFilters() + return { + { + value = "", + name = "Custom", + }, + { + value = "none", + name = "None", + kernel = empty_kernel, + }, + { + value = "gaussian", + name = "Blur (Gaussian)", + kernel = { + 1, 2, 1, + 2, 4, 2, + 1, 2, 1 + }, + scale = 16, + }, + { + value = "box", + name = "Blur (Box)", + kernel = { + 1, 1, 1, + 1, 1, 1, + 1, 1, 1 + }, + scale = 9, + }, + { + value = "laplacian", + name = "Edges (Laplacian)", + kernel = { + -1, -1, -1, + -1, 8, -1, + -1, -1, -1, + }, + scale = 8, + }, + { + value = "sobel", + name = "Slope (Sobel)", + kernel = { + -2, -2, 0, + -2, 0, 2, + 0, 2, 2, + }, + scale = 6, + }, + { + value = "sharpen", + name = "Sharpen", + kernel = { + 0, -1, 0, + -1, 5, -1, + 0, -1, 0 + }, + scale = 1, + }, + } +end + +DefineClass.GridOpFilter = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Intensity", name = "Filter Degree", editor = "number", default = 1, min = 1, max = 10, step = 1, buttons_step = 1, slider = true, help = "Defines the filter strength" }, + { category = "General", id = "Strength", name = "Filter Strength",editor = "number", default = 100, min = 0, max = 100, scale = "%", slider = true }, + { category = "General", id = "Filter", name = "Filter Preset", editor = "choice", default = "none", items = GridFilters, dont_save = true, operation = "Convolution" }, + { category = "General", id = "Kernel", name = "Filter Kernel", editor = "prop_table", default = empty_kernel, operation = "Convolution" }, + { category = "General", id = "Scale", name = "Filter Scale", editor = "number", default = 0, operation = "Convolution" }, + { category = "General", id = "Fast", name = "Fast Mode", editor = "bool", default = true, operation = "Smooth" }, + { category = "General", id = "RestoreLims", name = "Restore Limits", editor = "bool", default = false }, + }, + input_fmt = "F", + GridOpType = "Filter", + operations = {"Smooth", "Convolution"}, +} + +function GridOpFilter:SetFilter(value) + local filter = table.find_value(GridFilters(), "value", value) or empty_table + if not filter.kernel then + return + end + self.Kernel = table.icopy(filter.kernel) + self.Scale = filter.scale +end + +function GridOpFilter:GetFilter() + local kernel = self.Kernel + local scale = self.Scale + for _, filter in ipairs(GridFilters()) do + if scale == (filter.scale or 0) and table.iequal(kernel, filter.kernel) then + return filter.value + end + end + return "" +end + +function GridOpFilter:GetGridOutputFromInput(state, grid) + local strength = self.Strength + if strength == 0 then + return nil, grid:clone() + end + local filtered + local count = self.Intensity + local op = self.Operation + local restore = self.RestoreLims + if op == "Convolution" then + local kernel = self.Kernel + if not kernel then + return "Missing kernel" + end + local scale = self.Scale + local tmp = GridDest(grid) + filtered = grid + for i=1,count do + GridFilter(filtered, tmp, kernel, scale, restore) + filtered, tmp = tmp, filtered + end + elseif op == "Smooth" then + filtered = GridDest(grid) + local w, h = grid:size() + local fast = self.Fast + if fast and (not IsPowerOf2(w) or not IsPowerOf2(h)) then + fast = false + state.proc:AddLog("Ignoring Fast smooth - the grid size is not a power of 2") + end + GridSmooth(grid, filtered, count, fast, restore) + end + if strength < 100 then + GridLerp(grid, filtered, filtered, strength, 0, 100) + end + return nil, filtered +end + +function GridOpFilter:GetEditorText() + local op = self.Operation + local str_intensity = self.Intensity > 1 and " (x" .. self.Intensity .. ")" or "" + if op == "Smooth" then + local str = "Smooth " .. str_intensity + if self.InputName ~= self.OutputName then + str = str .. " to " + end + return str + elseif op == "Convolution" then + local str = "Apply filter" .. str_intensity .. " in " + if self.InputName ~= self.OutputName then + str = str .. " to " + end + return str + end + return GridOpInputOutput.GetEditorText(self) +end + +---- + +DefineClass.GridOpLerp = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "TargetName", name = "Target Grid Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + { category = "General", id = "MaskName", name = "Mask Grid Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + { category = "General", id = "Normalize", name = "Mask Normalize", editor = "bool", default = true }, + { category = "General", id = "MaskMin", name = "Mask Min", editor = "number", default = 0, enabled_by = "!Normalize" }, + { category = "General", id = "MaskMax", name = "Mask Max", editor = "number", default = 100, enabled_by = "!Normalize" }, + { category = "General", id = "Convert", name = "Convert Grids", editor = "bool", default = true, help = "Allow converting the grid params to match" }, + }, + GridOpType = "Lerp", +} + +function GridOpLerp:GetGridOutputFromInput(state, grid) + local target = self:GetGridInput(self.TargetName) + local mask = self:GetGridInput(self.MaskName) + if self.Convert then + target = GridMakeSame(target, grid) + mask = GridMakeSame(mask, grid) + end + local res = GridDest(grid) + if self.Normalize then + GridLerp(grid, res, target, mask) + else + GridLerp(grid, res, target, mask, self.MaskMin, self.MaskMax) + end + return nil, res +end + +function GridOpLerp:GetEditorText() + return "Lerp - in " +end + +---- + +---- + +DefineClass.GridOpMorph = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Depth", name = "Depth", editor = "number", default = 1, min = 1 }, + }, + GridOpType = "Binary Morphology", + operations = {"Erode", "Dilate", "Open", "Close"}, +} + +function GridOpMorph:GetGridOutputFromInput(state, grid) + local dst, src = grid, GridDest(grid) + local function do_morph(erode) + local depth = self.Depth + while depth > 0 do + src, dst = dst, src + if dst == grid then + dst = GridDest(grid) + end + if erode then + GridErode(src, dst) + else + GridDilate(src, dst) + end + depth = depth - 1 + end + end + local op = self.Operation + if op == "Erode" then + do_morph(true) + elseif op == "Dilate" then + do_morph(false) + elseif op == "Open" then + do_morph(true) + do_morph(false) + elseif op == "Close" then + do_morph(false) + do_morph(true) + end + return nil, dst +end + +function GridOpMorph:GetEditorText() + local str = "Morphologically " + if self.Depth > 1 then + str = str .. " x " + end + if self.InputName ~= self.OutputName then + str = str .. " to " + end + return str +end + +---- + +DefineClass.GridOpConvert = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "GridType", name = "Grid Type", editor = "choice", default = "", items = {"", "float", "uint16", "uint8"}, operation = "Repack", no_edit = ref_available }, + { category = "General", id = "RefName", name = "Grid Reference", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, operation = "Repack", help = "Needed to match the same grid type" }, + { category = "General", id = "GridRound", name = "Grid Round", editor = "bool", default = false, operation = "Repack" }, + { category = "General", id = "Granularity", name = "Granularity", editor = "number", default = 1, operation = "Round" }, + }, + GridOpType = "Convert", + operations = {"Invert", "Abs", "Not", "Round", "Copy", "Repack"}, +} + +function GridOpConvert:GetGridOutputFromInput(state, grid) + local op = self.Operation + if op == "Repack" then + local t, b = GridTypeToFmt(self.GridType) + if not t then + local ref = self:GetGridInput(self.RefName) + t, b = IsComputeGrid(ref) + if not t then + return "Grid Type Not Specified" + end + end + local src = grid + if self.GridRound then + src = GridDest(src) + GridRound(grid, src) + end + return nil, GridRepack(src, t, b, true) + end + local res = GridDest(grid) + if op == "Abs" then + GridAbs(grid, res) + elseif op == "Not" then + GridNot(grid, res) + elseif op == "Invert" then + GridInvert(grid, res) + elseif op == "Round" then + GridRound(grid, res, self.Granularity) + elseif op == "Copy" then + res:copy(grid) + end + return nil, res +end + +function GridOpConvert:GetEditorText() + local text = "" + if self.InputName ~= "" then + text = text .. " " + if self.OutputName ~= "" and self.InputName ~= self.OutputName then + text = text .. " to " + end + end + if self.Operation == "Repack" then + text = text .. " as " + end + return text +end + +---- + +local function ExtendModeItems() + return { + { value = 0, text = "" }, + { value = 1, text = "Filled" }, + { value = 2, text = "Centered" }, + } +end + +DefineClass.GridOpResample = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "RefName", name = "Ref Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, help = "Needed to match the same grid size and type" }, + { category = "General", id = "Width", name = "Width", editor = "number", default = 256, min = 0, use_param = true, enabled_by = "!RefName" }, + { category = "General", id = "Height", name = "Height", editor = "number", default = 256, min = 0, use_param = true, enabled_by = "!RefName" }, + { category = "General", id = "InPercents", name = "In Percents", editor = "bool", default = false, enabled_by = "!RefName" }, + { category = "General", id = "Interpolate", name = "Interpolate", editor = "bool", default = true, operation = "Resample" }, + { category = "General", id = "RestoreLims", name = "Restore Limits", editor = "bool", default = false, operation = "Resample" }, + { category = "General", id = "ExtendMode", name = "Mode", editor = "choice", default = 0, items = ExtendModeItems, operation = "Extend" }, + }, + GridOpType = "Change Dimensions", + operations = {"Resample", "Extend"}, + operation_text_only = true, +} + +local function CanFastResample(dim2, dim1) + if dim2 == dim1 then + return + end + local dir = 1 + if dim1 > dim2 then + dim2, dim1 = dim1, dim2 + dir = -1 + end + if dim2 % dim1 ~= 0 then + return + end + local k = dim2 / dim1 + if not IsPowerOf2(k) then + return + end + return k * dir +end + +function GridOpResample:GetGridOutputFromInput(state, grid) + local w, h + local gw, gh = grid:size() + local ref = self:GetGridInput(self.RefName) + if ref then + w, h = ref:size() + else + w = self:GetValue("Width") + h = self:GetValue("Height") + if self.InPercents then + w = MulDivRound(gw, w, 100) + h = MulDivRound(gh, h, 100) + end + end + if w == 0 or h == 0 then + return "Invalid Size" + end + if w == gw and h == gh then + return nil, grid:clone() + end + local op = self.Operation + if op == "Resample" then + local interpolate, restore = self.Interpolate, self.RestoreLims + if interpolate then + local kw, kh = CanFastResample(gw, w), CanFastResample(gh, h) + if kw and kw == kh then + while gw < w do + gw, gh = 2 * gw, 2 * gh + grid = GridResample(grid, gw, gh, true, restore) + end + while gw > w do + gw, gh = gw / 2, gh / 2 + grid = GridResample(grid, gw, gh, true, restore) + end + return nil, grid + end + end + return nil, GridResample(grid, w, h, interpolate, restore, true) + elseif op == "Extend" then + return nil, GridExtend(grid, w, h, self.ExtendMode) + end +end + +function GridOpResample:GetEditorText() + if self.InputName == "" or self.OutputName == "" then + return "" + end + local str = " " + if self.InputName ~= self.OutputName then + str = str .. " in " + end + if self.RefName ~= "" then + str = str .. " as " + else + local wstr, w = self:GetValueText("Width") + local hstr, h = self:GetValueText("Height") + if not self.InPercents then + str = str .. " to (" .. wstr .. ", " .. hstr .. ")" + elseif w ~= h then + str = str .. " to (" .. wstr .. "%, " .. hstr .. "%)" + else + str = str .. " to " .. wstr .. "%" + end + end + return str +end + +---- + +DefineClass.GridOpChangeLim = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Min", name = "Min", editor = "number", default = 0, use_param = true }, + { category = "General", id = "Max", name = "Max", editor = "number", default = 1, use_param = true }, + { category = "General", id = "Scale", name = "Scale", editor = "number", default = 1, use_param = true }, + { category = "General", id = "Smooth", name = "Smooth", editor = "bool", default = false }, + { category = "General", id = "Remap", name = "Remap", editor = "bool", default = false }, + { category = "General", id = "RemapMin", name = "Remap Min", editor = "number", default = 0, use_param = true, enabled_by = "Remap" }, + { category = "General", id = "RemapMax", name = "Remap Max", editor = "number", default = 1, use_param = true, enabled_by = "Remap" }, + }, + operations = {"Normalize", "Mask", "Clamp", "Band", "Remap"}, + GridOpType = "Change Limits", +} + +function GridOpChangeLim:GetGridOutputFromInput(state, grid) + local min = self:GetValue("Min") + local max = self:GetValue("Max") + local scale = self:GetValue("Scale") + local new_min, new_max + local op = self.Operation + local res = GridDest(grid) + if op == "Normalize" then + if min >= max then + return "Invalid Range" + end + res = GridNormalize(grid, res, min, max, scale) + elseif op == "Mask" then + GridMask(grid, res, min, max, scale) + min, max = 0, 1 + elseif op == "Clamp" then + if min >= max then + return "Invalid Range" + end + GridClamp(grid, res, min, max, scale) + elseif op == "Band" then + if min >= max then + return "Invalid Range" + end + GridBand(grid, res, min, max, scale) + elseif op == "Remap" then + res:copy(grid) + elseif op == "Max" then + GridMax(grid, res, min, scale) + elseif op == "Min" then + GridMin(grid, res, max, scale) + end + if self.Smooth and not GridIsFlat(res) then + GridSin(res, min, max) + new_min, new_max = min, max + min, max = -1, 1 + end + if self.Remap then + new_min = self:GetValue("RemapMin") + new_max = self:GetValue("RemapMax") + end + if min ~= (new_min or min) or max ~= (new_max or max) then + GridRemap(res, min, max, new_min, new_max) + end + return nil, res +end + +function GridOpChangeLim:GetEditorText() + local min_str = self:GetValueText("Min") + local max_str = self:GetValueText("Max") + local range_str, grids_str, remap_str = " between ", "", "" + if self.InputName ~= "" then + grids_str = " " + if self.OutputName ~= "" and self.InputName ~= self.OutputName then + grids_str = grids_str .. " to " + end + end + if self.Remap then + local from_str = self:GetValueText("RemapMin") + local to_str = self:GetValueText("RemapMax") + remap_str = " to " .. from_str .. " - " .. to_str + range_str = " from " + end + return "" .. grids_str .. range_str .. min_str .. " - " .. max_str .. remap_str +end + +---- + +---- + +DefineClass.GridOpMinMax = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "RefName", name = "Grid Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true }, + { category = "General", id = "RefValue", name = "Value", editor = "number", default = 0, use_param = true, enabled_by = "!GridName" }, + { category = "General", id = "Scale", name = "Scale", editor = "number", default = 1, use_param = true }, + }, + operations = {"Min", "Max"}, + GridOpType = "MinMax", +} + +function GridOpMinMax:GetGridOutputFromInput(state, grid) + local ref_value, scale = self:GetValue("RefValue"), self:GetValue("Scale") + local ref_grid = self:GetGridInput(self.RefName) + local op = self.Operation + local res = GridDest(grid) + if op == "Max" then + if ref_grid then + GridMax(grid, res, ref_grid) + else + GridMax(grid, res, ref_value, scale) + end + elseif op == "Min" then + if ref_grid then + GridMin(grid, res, ref_grid) + else + GridMin(grid, res, ref_value, scale) + end + end + return nil, res +end + +function GridOpMinMax:GetEditorText() + if self.InputName == "" or self.OutputName == "" then + return "" + end + local str = " = (, " + if self.RefName ~= "" then + str = str .. "" + else + str = str .. self:GetValueText("RefValue") + end + return str .. ")" +end + +---- + +DefineClass.GridOpMulDivAdd = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "MulName", name = "Mul Grid Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true }, + { category = "General", id = "AddName", name = "Add Grid Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true }, + { category = "General", id = "SubName", name = "Sub Grid Name", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true }, + { category = "General", id = "Mul", name = "Mul", editor = "number", default = 1, use_param = true }, + { category = "General", id = "Div", name = "Div", editor = "number", default = 1, use_param = true }, + { category = "General", id = "Add", name = "Add", editor = "number", default = 0, use_param = true }, + { category = "General", id = "Sub", name = "Sub", editor = "number", default = 0, use_param = true }, + { category = "General", id = "Convert", name = "Convert Grids", editor = "bool", default = true, help = "Allow converting the grid params to match" }, + }, + GridOpType = "Mul Div Add", +} + +function GridOpMulDivAdd:GetGridOutputFromInput(state, grid) + local grid_mul = self:GetGridInput(self.MulName) + local grid_add = self:GetGridInput(self.AddName) + local grid_sub = self:GetGridInput(self.SubName) + local mul = self:GetValue("Mul") + local div = self:GetValue("Div") + local add = self:GetValue("Add") + local sub = self:GetValue("Sub") + if div == 0 then + return "Division By Zero" + end + local res = grid:clone() + if grid_mul then + if self.Convert then + grid_mul = GridMakeSame(grid_mul, res) + end + GridMulDiv(res, grid_mul, 1) + end + GridMulDiv(res, mul, div) + GridAdd(res, add - sub) + if grid_add then + if self.Convert then + grid_add = GridMakeSame(grid_add, res) + end + GridAdd(res, grid_add) + end + if grid_sub then + if self.Convert then + grid_sub = GridMakeSame(grid_sub, res) + end + GridAddMulDiv(res, grid_sub, -1) + end + return nil, res +end + +function GridOpMulDivAdd:GetEditorText() + local txt = "" + local negate = self:GetValue("Mul") == -1 + if self.OutputName ~= "" and self.InputName ~= "" then + txt = " = " + if negate then + txt = txt .. "-" + end + txt = txt .. "" + end + if self.MulName ~= "" then + txt = txt .. " x " + end + local mul_str = not negate and self:GetValueText("Mul", 1) or "" + local div_str = self:GetValueText("Div", 1) + local add_str = self:GetValueText("Add", 0) + local sub_str = self:GetValueText("Sub", 0) + if mul_str ~= "" then + txt = txt .. " x " .. mul_str + end + if div_str ~= "" then + txt = txt .. " / " .. div_str + end + if add_str ~= "" then + txt = txt .. " + " .. add_str + end + if sub_str ~= "" then + txt = txt .. " - " .. sub_str + end + if self.AddName ~= "" then + txt = txt .. " + " + end + if self.SubName ~= "" then + txt = txt .. " - " + end + + return txt +end + +---- + +DefineClass.GridOpReplace = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Old", name = "Old", editor = "number", default = 0, use_param = true }, + { category = "General", id = "New", name = "New", editor = "number", default = 1, use_param = true }, + }, + GridOpType = "Replace", +} + +function GridOpReplace:GetGridOutputFromInput(state, grid) + local old = self:GetValue("Old") + local new = self:GetValue("New") + local res = GridDest(grid) + res = GridReplace(grid, res, old, new) + return nil, res +end + +function GridOpReplace:GetEditorText() + local old_str = self:GetValueText("Old") + local new_str = self:GetValueText("New") + return " " .. old_str .. " by " .. new_str .. " in to " +end + +---- + +DefineClass.GridOpRandPos = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Tile", name = "Tile", editor = "number", default = 1, use_param = true }, + { category = "General", id = "Count", name = "Count", editor = "number", default = -1, use_param = true }, + { category = "General", id = "ForEachPos", name = "ForEachPos", editor = "func", default = false, lines = 1, max_lines = 100, params = "x, y, v, area, tile, state", }, + }, + GridOpType = "Rand Pos", +} + +function GridOpRandPos:GetGridOutputFromInput(state, grid) + local tile = self:GetValue("Tile") + local min, max = GridMinMax(grid) + local output, remap + local limit = 0xffff + if min < 0 or max > limit then + remap = true + output = GridDest(grid) + GridMax(grid, output, 0) + GridMulDiv(output, limit, max) + output = GridRepack(output, "u", 16) + else + output = GridRepack(grid, "u", 16, true) + end + local count = 0 + local max_count = self:GetValue("Count") + GridRandomEnumMarkDist(output, state.rand, tile, function(x, y, v, area) + if count == max_count then + return + end + count = count + 1 + if remap then + v = v * limit / max + end + local success, radius = pcall(self.ForEachPos, x, y, v, area, tile, state) -- todo: debug missing error propagation + if not success or not radius then + return + end + if remap then + radius = radius * max / limit + end + return radius + end) + return nil, output +end + +---- + +DefineClass.GridOpDistance = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Min", name = "Min", editor = "number", default = 0, use_param = true }, + { category = "General", id = "Max", name = "Max", editor = "number", default = -1, use_param = true }, + { category = "General", id = "Tile", name = "Tile", editor = "number", default = 1, use_param = true }, + }, + GridOpType = "Distance", + operations = {"Transform", "Wave"}, +} + +function GridOpDistance:GetGridOutputFromInput(state, grid) + local op = self.Operation + local res = GridDest(grid) + local tile, max_dist, min_dist = self:GetValue("Tile"), self:GetValue("Max"), self:GetValue("Min") + if max_dist < 0 then + max_dist = max_int + end + if op == "Transform" then + GridDistance(grid, res, tile, max_dist) + elseif op == "Wave" then + GridWave(grid, res, tile, max_dist) + end + if min_dist > 0 then + GridBand(res, min_dist, max_dist) + end + return nil, res +end + +---- + +DefineClass.GridOpEnumAreas = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "MinArea", name = "Min Tiles Count", editor = "number", default = 0, use_param = true, min = 0, help = "Will be computed based on the min border distance if not specified" }, + { category = "General", id = "MinBorder", name = "Min Border Dist", editor = "number", default = 0, min = 0, use_param = true }, + { category = "General", id = "Tile", name = "Tile Size", editor = "number", default = 1, min = 1, use_param = true }, + { category = "Stats", id = "AreasCount", name = "Areas Found", editor = "number", default = 0, read_only = true, dont_save = true }, + }, + GridOpType = "Enum Areas", +} + +function GridOpEnumAreas:GetGridOutputFromInput(state, grid) + local tile, min_border, min_area = self:GetValue("Tile"), self:GetValue("MinBorder"), self:GetValue("MinArea") + min_area = Max(min_area, (min_border * min_border * 22) / (tile * tile * 7)) + if min_area == 0 then + return "Zero area size" + end + local work_grid = min_border > max_uint16 and GridRepack(grid, "f") or grid + local zones = GridEnumZones(work_grid, min_area) + local level_dist + local gw, gh = work_grid:size() + local found = 0 + local res = GridDest(work_grid) + res:clear() + for i=1,#zones do + local zone = zones[i] + level_dist = level_dist or GridDest(work_grid) + GridMask(work_grid, level_dist, zone.level) + local accepted = true + if min_border > 0 then + GridDistance(level_dist, tile, min_border) + local minv, maxv = GridMinMax(level_dist) + accepted = maxv >= min_border + end + if accepted then + found = found + 1 + GridPaint(res, level_dist, found) + end + end + self.AreasCount = found + if found == 0 then + return "No areas found" + end + res = GridRepack(res, IsComputeGrid(grid)) + return nil, res +end + +---- + +DefineClass.GridOpMean = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "OperandName", name = "Second Operand", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + }, + GridOpType = "Mean", + input_fmt = "F", + operations = {"Arithmetic", "Geometric", "Root Square"}, +} + +function GridOpMean:GetGridOutputFromInput(state, grid) + local operand = self:GetGridInput(self.OperandName) + local op = self.Operation + local res = GridDest(grid) + if op == "Arithmetic" then + GridAdd(grid, res, operand) + GridMulDiv(res, 1, 2) + elseif op == "Geometric" then + GridMulDiv(grid, res, operand) + GridPow(res, 1, 2) + else + local res2 = GridDest(grid) + GridPow(grid, res, 2) + GridPow(operand, res2, 2) + GridAdd(res, res2) + GridPow(res, 1, 2) + res2:free() + end + return nil, res +end + +function GridOpMean:GetEditorText() + return " and into " +end + +---- + +DefineClass.GridOpPow = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "PowMul", name = "Pow Mul", editor = "number", default = 1, min = 1, }, + { category = "General", id = "PowDiv", name = "Pow Div", editor = "number", default = 1, min = 1, }, + }, + GridOpType = "Pow", + input_fmt = "F", +} + +function GridOpPow:GetGridOutputFromInput(state, grid) + local res = GridDest(grid) + GridPow(grid, res, self.PowMul, self.PowDiv) + return nil, res +end + +function GridOpPow:GetEditorText() + return " = ^ (/)" +end + +---- + +DefineClass.GridOpNoise = { + __parents = { "GridOpDest", "PerlinNoiseBase" }, + properties = { + { category = "General", id = "NoisePreset", name = "Noise Preset", editor = "preset_id", default = "", preset_class = "NoisePreset" }, + }, + GridOpType = "Noise", +} + +function GridOpNoise:GetGridOutput(state) + local ref = self:GetGridInput(self.RefName) + local err, noise + if ref then + noise = GridDest(ref) + else + err, noise = GridOpDest.GetGridOutput(self, state) + if err then + return err + end + end + if self.NoisePreset ~= "" then + local preset = NoisePresets[self.NoisePreset] + if not preset then + return "No such noise preset " .. self.NoisePreset + end + preset:GetNoise(state.rand, noise) + elseif not GridPerlin(state.rand, self:ExportOctaves(), noise) then + return "Perlin Noise Failed" + end + return nil, noise +end + +function GridOpNoise:GetEditorText() + return "Generate Noise in " +end + +---- + +DefineClass.GridOpDistort = { + __parents = { "GridOpInputOutput", "PerlinNoiseBase" }, + properties = { + { category = "General", id = "Strength", name = "Strength", editor = "number", default = 50, min = 0, max = 100, scale = 100, slider = true, help = "Distortion Strength" }, + { category = "General", id = "Scale", name = "Scale", editor = "number", default = 100, min = 1, max = 1000, scale = 100, slider = true, help = "Distortion Strength" }, + { category = "General", id = "Iterations", name = "Iterations", editor = "number", default = 1, min = 1, max = 10, slider = true, help = "Distortion Iterations" }, + { category = "General", id = "NoiseX", name = "Noise X", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, grid_output = true, optional = true }, + { category = "General", id = "NoiseY", name = "Noise Y", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, grid_output = true, optional = true }, + { category = "General", id = "NoiseAmp", name = "Noise Amp", editor = "number", default = 4096, min = 1 }, + }, + GridOpType = "Distort", +} + +function GridOpDistort:GetGridOutputFromInput(state, grid) + local unity = self.NoiseAmp + local noise_x = self:GetGridInput(self.NoiseX) + local noise_y = self:GetGridInput(self.NoiseY) + if not noise_x then + noise_x, noise_y = GridDest(grid), GridDest(grid) + if not GridPerlin(state.rand, self:ExportOctaves(), noise_x, noise_y) then + return "Perlin Noise Failed" + end + GridNormalize(noise_x, 0, unity) + GridNormalize(noise_y, 0, unity) + end + local stength = MulDivRound(self.Scale * unity, self.Strength, 1000 * 100) + local src, res = grid, grid + for i=1,self.Iterations do + src, res = res, src + res = res == grid and GridDest(grid) or res + if not GridPerturb(src, res, noise_x, noise_y, stength, unity) then + return "Grid Perturb Failed" + end + end + self:SetGridOutput(self.NoiseX, noise_x) + self:SetGridOutput(self.NoiseY, noise_y) + return nil, res +end + +---- + +DefineClass.GridOpDraw = { + __parents = { "GridOpDest" }, + properties = { + { category = "General", id = "DrawValue", name = "Value", editor = "number", default = 1 }, + { category = "General", id = "DrawBorder", name = "Border", editor = "number", default = 1, min = 1, use_param = true, operation = "Frame" }, + { category = "General", id = "DrawBox", name = "Box", editor = "box", default = false, operation = "Box" }, + { category = "General", id = "DrawCenter", name = "Center", editor = "point", default = false, operation = "Circle" }, + { category = "General", id = "DrawRadius", name = "Radius", editor = "number", default = false, min = 1, operation = "Circle" }, + { category = "General", id = "DrawFallout", name = "Fallout", editor = "number", default = 0, min = 0, operation = "Circle" }, + }, + GridOpType = "Draw", + operations = {"Frame", "Box", "Circle", "Blank"}, +} + +function GridOpDraw:GetGridOutput(state) + local err, res = GridOpDest.GetGridOutput(self, state) + if err then + return err + end + local op = self.Operation + if op == "Frame" then + GridFrame(res, self:GetValue("DrawBorder"), self.DrawValue) + elseif op == "Box" then + GridDrawBox(res, self.DrawBox, self.DrawValue) + elseif op == "Circle" then + local center = self.DrawCenter or point(res:size()) / 2 + local radius = self.DrawRadius or Min(res:size()) / 2 + GridCircleSet(res, self.DrawValue, center, radius + self.DrawFallout, self.DrawFallout) + end + return nil, res +end + +---- + +local function show_not_grid(op) + return op.Show ~= "grid" +end + +local function no_edit_colors(op) + return op.Show ~= "grid" or op.ColorRand +end + +local function no_rand_colors(op) + return op.Show ~= "grid" or not op.ColorRand +end + +DefineClass.GridOpDbg = { + __parents = { "GridOp", "DebugOverlayControl" }, + properties = { + { category = "General", id = "Show", name = "Show", editor = "choice", default = "grid",items = {"clear", "grid", "biome", "passability", "grass" } }, + { category = "General", id = "Grid", name = "Grid", editor = "choice", default = "", items = GridOpOutputNames, grid_input = true, optional = true, no_edit = function(self) return self.Show ~= "grid" end }, + { category = "General", id = "AllowInspect", name = "Allow Inspect", editor = "bool", default = false, no_edit = show_not_grid }, + { category = "General", id = "ColorRand", name = "Color Rand", editor = "bool", default = false, no_edit = show_not_grid }, + { category = "General", id = "InvalidValue", name = "Invalid Value", editor = "number", default = -1, no_edit = no_rand_colors }, + { category = "General", id = "Granularity", name = "Granularity", editor = "number", default = 1, no_edit = show_not_grid }, + { category = "General", id = "ColorFrom", name = "Color From", editor = "color", default = red, no_edit = no_edit_colors }, + { category = "General", id = "ColorTo", name = "Color To", editor = "color", default = green, no_edit = no_edit_colors }, + { category = "General", id = "ColorLimits", name = "Color Limits", editor = "bool", default = false, no_edit = no_edit_colors }, + { category = "General", id = "ColorMin", name = "Color Min", editor = "color", default = 0, no_edit = no_edit_colors, enabled_by = "ColorLimits" }, + { category = "General", id = "ColorMax", name = "Color Max", editor = "color", default = blue, no_edit = no_edit_colors, enabled_by = "ColorLimits" }, + { category = "General", id = "Normalize", name = "Normalize", editor = "bool", default = true, no_edit = no_edit_colors }, + { category = "General", id = "ValueMin", name = "Value Min", editor = "number", default = 0, no_edit = no_edit_colors, enabled_by = "!Normalize" }, + { category = "General", id = "ValueMax", name = "Value Max", editor = "number", default = 1, no_edit = no_edit_colors, enabled_by = "!Normalize" }, + { category = "General", id = "OverlayAlpha", name = "Overlay Alpha (%)", editor = "number", default = 60, slider = true, buttons_step = 1, min = 0, max = 100, dont_save = true, dont_recalc = true }, + { category = "General", id = "WaitFrames", name = "Wait Frames", editor = "number", default = 0 }, + { category = "General", id = "Invalidate", name = "Invalidate", editor = "bool", default = false }, + }, + GridOpType = "Debug", + RunModes = set("Debug", "Release"), + palette = false, +} + +function GridOpDbg:Run(state) + if GetMap() == "" then + return "No Map Loaded" + end + local show = self.Show + if show == "clear" then + hr.TerrainDebugDraw = 0 + elseif show == "grid" then + local grid = self:GetGridInput(self.Grid) + if not grid then + return + end + local dbg_grid = grid + local palette = self.palette or {} + if self.ColorRand then + local invalid = self.InvalidValue + for i=0,255 do + palette[i] = i == invalid and 0 or RandColor(i) + end + if self.Granularity ~= 1 then + dbg_grid = GridDest(grid) + GridRound(grid, dbg_grid, self.Granularity) + end + else + dbg_grid = GridDest(grid) + if self.Normalize then + GridNormalize(grid, dbg_grid, 0, 255) + else + GridRemap(grid, dbg_grid, self.ValueMin, self.ValueMax, 0, 255) + end + local cfrom, cto = self.ColorFrom, self.ColorTo + local InterpolateRGB = InterpolateRGB + for i=1,254 do + palette[i] = InterpolateRGB(cfrom, cto, i, 255) + end + if self.ColorLimits then + palette[0], palette[255] = self.ColorMin, self.ColorMax + else + palette[0], palette[255] = cfrom, cto + end + end + self.palette = palette + DbgShowTerrainGrid(dbg_grid, palette) + if self.AllowInspect then + DbgStartInspectPos(function(pos) + if not self.AllowInspect then + return + end + local mx, my = pos:xy() + local gv, gx, gy = GridMapGet(grid, mx, my, 1, 100) + return string.format("%s(%d : %d) = %s\n", self.Grid, gx, gy, DivToStr(gv, 100)) + end) + end + + else + hr.TerrainDebugDraw = 1 + local palette + if show == "biome" then + palette = DbgGetBiomePalette() + end + DbgSetTerrainOverlay(show, palette) + end + if self.Invalidate then + state.proc:InvalidateProc(state) + end + if self.WaitFrames ~= 0 then + WaitNextFrame(self.WaitFrames) + end +end + +--[[ Example +function DebugGrid3D() + local grid_width = 64 + local grid_height = 64 + local grid_depth = 64 + local grid = NewComputeGrid(grid_width, grid_height * grid_depth, "U", 8) + GridAdd(grid, 255) + DbgSetTerrainOverlay3D("grid", 0, grid, 179178, 170817, 6949, grid_width, grid_height, grid_depth, 1200, 1200, 700) + + hr.TerrainDebug3DDraw = 1 +end]] + +if FirstLoad then + g_ShowPassability3DThread = false +end + +function EnablePassability3DVisualization(enable) + DeleteThread(g_ShowPassability3DThread) + if enable then + hr.TerrainDebug3DDraw = 1 + g_ShowPassability3DThread = CreateRealTimeThread(function() + local grid_width = 256 + local grid_height = 256 + local grid_depth = 128 + while true do + local cursor = GetTerrainGamepadCursor() + if cursor then + local x, y, z = cursor:xyz() + DbgSetTerrainOverlay3D("passability", 0, x, y, z, grid_width, grid_height, grid_depth, 1200, 1200, 700) + end + Sleep(200) + end + end) + else + hr.TerrainDebug3DDraw = 0 + end +end + +function GridOpDbg:GetEditorText() + local txt = {} + if self.WaitFrames ~= 0 then + txt[#txt + 1] = "Wait frames" + end + if self.Invalidate then + txt[#txt + 1] = "Invalidate terrain" + end + if self.Show == "grid" then + if self.Grid ~= "" then + txt[#txt + 1] = "Show " + end + elseif self.Show ~= "" then + if self.Show == "clear" then + txt[#txt + 1] = "Clear overlay" + else + txt[#txt + 1] = "Show " + end + end + return table.concat(txt, ", ") +end + + +DefineClass.GridOpHistogram = { + __parents = { "GridOpInput" }, + properties = { + { category = "General", id = "Levels", name = "Histo Levels", editor = "number", default = 100, min = 10, max = 1000 }, + { category = "General", id = "Normalize", name = "Normalize", editor = "bool", default = true }, + { category = "General", id = "MinValue", name = "From", editor = "number", default = 0, enabled_by = "!Normalize" }, + { category = "General", id = "MaxValue", name = "To", editor = "number", default = 100, enabled_by = "!Normalize" }, + { category = "Preview", id = "Histogram", name = "Histogram", editor = "grid", default = false, dont_save = true, min = 128, read_only = true, dont_normalize = true, frame = 1 }, + { category = "Preview", id = "Average", name = "Average", editor = "number", default = 0, scale = preview_recision, read_only = true }, + { category = "Preview", id = "Deviation", name = "Deviation", editor = "number", default = 0, scale = preview_recision, read_only = true }, + { category = "Preview", id = "Volume", name = "Volume", editor = "number", default = 0, scale = preview_recision, read_only = true }, + }, + GridOpType = "Histogram", + RunModes = set("Debug", "Release"), +} + +function GridOpHistogram:SetGridInput(state, grid) + local hsize = self.Levels + local from, to + if not self.Normalize then + from, to = self.MinValue, self.MaxValue + end + local histogram, maxh = GridHistogram(grid, hsize, from, to) + if not histogram then + return "Histogram Failed" + end + local hgrid = self.Histogram + local w, h = hsize, 64 + if not hgrid or hgrid:size() ~= hsize then + hgrid = NewComputeGrid(w, h, "U", 8) + self.Histogram = hgrid + else + hgrid:clear() + end + if maxh == 0 then + return + end + for gx = 0, w-1 do + local gy = MulDivRound(h - 1, maxh - histogram[gx + 1], maxh) + GridDrawColumn(hgrid, gx, gy, 0, 255) + end + local avg, dev, vol = GridStats(grid, preview_recision) + if not avg then + return "Statistics Failed" + end + self.Average = avg + self.Deviation = dev + self.Volume = vol +end \ No newline at end of file diff --git a/CommonLua/MapGen/MapGen.lua b/CommonLua/MapGen/MapGen.lua new file mode 100644 index 0000000000000000000000000000000000000000..993196e879031ec82ba8917a6af82f2fae3d1cd8 --- /dev/null +++ b/CommonLua/MapGen/MapGen.lua @@ -0,0 +1,1213 @@ +local height_scale = const.TerrainHeightScale +local height_tile = const.HeightTileSize +local height_max = const.MaxTerrainHeight +local type_tile = const.TypeTileSize +local developer = Platform.developer +local unity = 1000 + +local function print_concat(tbl) + return table.concat(tbl, " ") +end + +---- + +DefineClass.GridOpMapExport = { + __parents = { "GridOpOutput" }, + GridOpType = "Map Export", + operations = {"Type", "Height", "Biome", "Grass", "Water"} +} + +function GridOpMapExport:GetGridOutput(state) + local grid + local op = self.Operation + if op == "Height" then + grid = terrain.GetHeightGrid() + elseif op == "Type" then + grid = terrain.GetTypeGrid() + elseif op == "Grass" then + grid = terrain.GetGrassGrid() + elseif op == "Water" then + grid = terrain.GetWaterGrid() + elseif op == "Biome" then + grid = BiomeGrid:clone() + end + if not grid then + return "Export Grid Failed" + end + return nil, grid +end + +function GridOpMapExport:GetEditorText() + return "Export to " +end + +---- + +DefineClass.GridOpMapImport = { + __parents = { "GridOpInput" }, + properties = { + { category = "General", id = "TextureParam", name = "Texture Param", editor = "choice", default = "", items = GridOpParams, grid_param = true, optional = true, operation = "Type" }, + { category = "General", id = "TextureType", name = "Texture Type", editor = "choice", default = "", items = GetTerrainNamesCombo(), use_param = "TextureParam", operation = "Type" }, + { category = "General", id = "TexturePreview", name = "Texture Preview", editor = "image", default = false, img_size = 128, img_box = 1, dont_save = true, base_color_map = true, operation = "Type", no_edit = function(self) return self.TextureType == "" or self.UseParams and self.TextureParam ~= "" end }, + { category = "General", id = "Alpha", name = "Alpha", editor = "number", default = unity, min = 0, max = unity, slider = true, scale = unity, operation = "Type" }, + { category = "General", id = "Contrast", name = "Contrast", editor = "number", default = 0, min = -unity/2, max = unity/2, slider = true, scale = unity, operation = "Type" }, + { category = "General", id = "Normalize", name = "Normalize", editor = "bool", default = false, operation = {"Height", "Color"} }, + { category = "General", id = "HeightMin", name = "Height Min (m)", editor = "number", default = 0, scale = guim, min = 0, max = height_max, slider = true, operation = "Height", enabled_by = "Normalize" }, + { category = "General", id = "HeightMax", name = "Height Max (m)", editor = "number", default = height_max, scale = guim, min = 0, max = height_max, slider = true, operation = "Height", enabled_by = "Normalize" }, + { category = "General", id = "ColorRed", name = "Red", editor = "number", default = 0, min = -unity, max = unity, scale = unity, slider = true, operation = "Color" }, + { category = "General", id = "ColorGreen", name = "Green", editor = "number", default = 0, min = -unity, max = unity, scale = unity, slider = true, operation = "Color" }, + { category = "General", id = "ColorBlue", name = "Blue", editor = "number", default = 0, min = -unity, max = unity, scale = unity, slider = true, operation = "Color" }, + { category = "General", id = "ColorAlpha", name = "Alpha", editor = "number", default = unity, min = 0, max = unity, scale = unity, slider = true, operation = "Color" }, + { category = "General", id = "MaskMin", name = "Mask Min", editor = "number", default = 0, scale = unity, operation = "Color", }, + { category = "General", id = "MaskMax", name = "Mask Max", editor = "number", default = 100 * unity, scale = unity, operation = "Color", }, + + }, + GridOpType = "Map Import", + operations = {"Type", "Height", "Biome", "Grass", "Color"}, +} + +function GridOpMapImport:CollectTags(tags) + tags.Terrain = true + return GridOp.CollectTags(self, tags) +end + +function GridOpMapImport:SetGridInput(state, grid) + local success, err + local op = self.Operation + if op == "Height" then + if not self.Normalize then + local min, max = GridMinMax(grid) + if min < 0 or max * height_scale > height_max then + return "Height Limits Exceeded" + end + success, err = terrain.ImportHeightMap(grid) + else + success, err = terrain.ImportHeightMap(grid, self.HeightMin, self.HeightMax) + end + terrain.InvalidateHeight() + elseif op == "Type" then + local type_idx + local type_name = self:GetValue("TextureType") or "" + if type_name ~= "" then + type_idx = GetTerrainTextureIndex(type_name) + if not type_idx then + return "No such terrain type: " .. type_name + end + end + if not type_idx then + err = terrain.SetTypeGrid(grid) + success = not err + else + success = terrain.ImportTypeDithered{ + grid = GridRepack(grid, "F"), + seed = state.rand, + type = type_idx, + gamma_mul = unity - self.Contrast, + gamma_div = unity + self.Contrast, + alpha_mul = self.Alpha, + alpha_div = unity, + } + end + terrain.InvalidateType() + elseif op == "Biome" then + BiomeGrid:copy(grid) + success = true + elseif op == "Grass" then + success = terrain.SetGrassGrid(grid) + elseif op == "Color" then + local min, max = self.MaskMin, self.MaskMax + local gmin, gmax = GridMinMax(grid, unity) + if self.Normalize then + min, max = gmin, gmax + elseif min > gmin or max < gmax then + return "Mask Limits Exceeded" + end + success = GridSetTerrainColor(grid, self.ColorRed, self.ColorGreen, self.ColorBlue, min, max, unity, self.ColorAlpha) + end + if not success then + return err or "Map Import Failed" + end +end + +function GridOpMapImport:GetEditorText() + local value = " " + if self.Operation == "Type" then + local type_str = self:GetValueText("TextureType", "") + if type_str ~= "" then + value = " " .. type_str .. " " + end + end + local grid_str = self.InputName ~= "" and "from " or "" + return "Import " .. value .. grid_str +end + +function GridOpMapImport:GetTexturePreview() + return GetTerrainTexturePreview(self.TextureType) +end + +---- + +DefineClass.GridOpMapReset = { + __parents = { "GridOp" }, + properties = { + { category = "General", id = "Type", name = "Texture Type", editor = "choice", default = "", items = GetTerrainNamesCombo(), operation = "Type", help = "If not specified, the default invalid terrain will be used" }, + { category = "General", id = "TypePreview", name = "Preview", editor = "image", default = false, img_size = 128, img_box = 1, base_color_map = true, dont_save = true, operation = "Type" }, + { category = "General", id = "Height", name = "Height", editor = "number", default = 10*guim, min = 0, max = height_max, slider = true, scale = "m", operation = "Height" }, + { category = "General", id = "Grass", name = "Grass", editor = "number", default = 0, min = 0, max = 100, slider = true, operation = "Grass" }, + { category = "General", id = "Color", name = "Color", editor = "color", default = RGB(200, 200, 200), operation = "Color" }, + { category = "General", id = "Overwrite", name = "Overwrite", editor = "bool", default = false, operation = "Backup" }, + { category = "General", id = "DeleteObjects", name = "Delete Objects", editor = "bool", default = true, operation = "Backup" }, + { category = "General", id = "FilterClass", name = "Class", editor = "text", default = "", operation = "Objects" }, + { category = "General", id = "FilterFlagsAll",name = "Flags All", editor = "set", default = set("Generated"), items = {"Generated", "Permanent"}, operation = "Objects" }, + { category = "General", id = "FilterFlagsAny",name = "Flags Any", editor = "set", default = set(), items = {"Generated", "Permanent"}, operation = "Objects" }, + { category = "General", id = "DeletedCount", name = "Deleted", editor = "number", default = 0, operation = "Objects", read_only = true, dont_save = true }, + { category = "General", id = "HeightMap", name = "Height Map", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true, optional = true, operation = "Backup" }, + { category = "General", id = "TypeMap", name = "Type Map", editor = "combo", default = "", items = GridOpOutputNames, grid_output = true, optional = true, operation = "Backup" }, + }, + GridOpType = "Map Reset", + operations = {"Type", "Height", "Grass", "Biome", "Objects", "Color", "Backup"}, +} + +function GridOpMapReset:CollectTags(tags) + local op = self.Operation + if op == "Type" or op == "Height" or op == "Biome" or op == "Backup" then + tags.Terrain = true + end + if op == "Objects" or op == "Backup" and self.DeleteObjects then + tags.Objects = true + end + return GridOp.CollectTags(self, tags) +end + +function GridOpMapReset:ResolveTerrainType() + local ttype = self.Type or "" + if not TerrainNameToIdx[ttype] then ttype = mapdata and mapdata.BaseLayer or "" end + if not TerrainNameToIdx[ttype] then ttype = const.Prefab.InvalidTerrain or "" end + if not TerrainNameToIdx[ttype] then ttype = TerrainTextures[0].id end + return ttype +end + +function GridOpMapReset:GetTypePreview() + return GetTerrainTexturePreview(self:ResolveTerrainType()) +end + +local function CreatePath(path, param, ...) + if not io.exists(path) then + local err = AsyncCreatePath(path) + if err then + return false, err + end + SVNAddFile(path) + end + if not param then + return path + end + return CreatePath(path .. "/" .. param, ...) +end + +local function ExtractFlags(flags) + local gameFlags = 0 + gameFlags = gameFlags + (flags.Generated and const.gofGenerated or 0) + gameFlags = gameFlags + (flags.Permanent and const.gofPermanent or 0) + return gameFlags ~= 0 and gameFlags or nil +end + +function GridOpMapReset:Run() + local success, err = true + local op = self.Operation + if op == "Type" then + success = terrain.SetTerrainType{type = self:ResolveTerrainType()} + elseif op == "Height" then + success = terrain.SetHeight{height = self.Height} + elseif op == "Biome" then + BiomeGrid:clear() + success = true + elseif op == "Grass" then + success = terrain.ClearGrassGrid(self.Grass) + elseif op == "Color" then + success = terrain.ClearColorizeGrid(self.Color) + elseif op == "Objects" then + local enumFlagsAll, enumFlagsAny + local gameFlagsAll = ExtractFlags(self.FilterFlagsAll) + local gameFlagsAny = ExtractFlags(self.FilterFlagsAny) + if (self.FilterClass or "") == "" then + self.DeletedCount = MapDelete(true, enumFlagsAll, enumFlagsAny, gameFlagsAll, gameFlagsAny) + else + self.DeletedCount = MapDelete(true, self.FilterClass, enumFlagsAll, enumFlagsAny, gameFlagsAll, gameFlagsAny) + end + elseif op == "Backup" then + local trunc + trunc, err = CreatePath("svnAssets/Source/MapGen", GetMapName()) + if err then + return err + end + local overwrite = self.Overwrite + local height_file = trunc .. "/height.grid" + local height_exists = io.exists(height_file) + local height_grid = not overwrite and height_exists and GridReadFile(height_file) + if not height_grid then + height_grid = terrain.GetHeightGrid() + success, err = GridWriteFile(height_grid, height_file, true) + if success and not height_exists then + SVNAddFile(height_file) + end + else + err = terrain.SetHeightGrid(height_grid) + terrain.InvalidateHeight() + end + if err then + return err + end + if self.HeightMap ~= "" then + self:SetGridOutput(self.HeightMap, height_grid) + end + local type_file = trunc .. "/type.grid" + local type_exists = io.exists(height_file) + local type_grid = not overwrite and type_exists and GridReadFile(type_file) + if not type_grid then + type_grid = terrain.GetTypeGrid() + success, err = GridWriteFile(type_grid, type_file, true) + if success and not type_exists then + SVNAddFile(type_file) + end + else + err = terrain.SetTypeGrid(type_grid) + terrain.InvalidateType() + end + if err then + return err + end + if self.TypeMap ~= "" then + self:SetGridOutput(self.TypeMap, type_grid) + end + if self.DeleteObjects then + MapDelete("map", nil, nil, const.gofGenerated) + end + mapdata.IsPartialGen = true + end + if not success then + return op .. " Reset Failed" + end +end + +function GridOpMapReset:GetEditorText() + local value = "" + local op = self.Operation + if op == "Type" then + value = "" + elseif op == "Height" then + value = "" + elseif op == "Grass" then + value = "" + elseif op == "Objects" then + value = "" + end + return "Reset " .. value +end + +---- + +DefineClass.GridOpMapSlope = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Units", name = "Units", editor = "choice", default = "degrees", items = {"", "degrees", "minutes", "radians", "normalized"} }, + { category = "General", id = "SunAzimuth", name = "Sun Azimuth (deg)", editor = "number", default = 0, scale = 60, min = -180*60, max = 180*60, slider = true, operation = "Orientation", step = 60, buttons_step = 60, }, + { category = "General", id = "SunElevation", name = "Sun Elevation (deg)", editor = "number", default = 0, scale = 60, min = 0, max = 90*60, slider = true, operation = "Orientation", step = 60, buttons_step = 60 }, + { category = "General", id = "Approx", name = "Approximate", editor = "bool", default = true, help = "Computation speed at the cost of precision" }, + }, + GridOpType = "Map Slope", + operations = {"Slope", "Orientation"}, + input_fmt = "F", +} + +function GridOpMapSlope:GetGridOutputFromInput(state, grid) + local res = GridDest(grid) + local units_to_unity = { + radians = 0, + normalized = 1, + degrees = 180, + minutes = 180*60, + } + local unity = units_to_unity[self.Units] + local apporx = self.Approx + local op = self.Operation + if op == "Slope" then + GridSlope(grid, res, height_tile, height_scale) + if unity then + GridASin(res, apporx, unity) + end + elseif op == "Orientation" then + GridOrientation(grid, res, height_tile, height_scale, self.SunAzimuth, self.SunElevation) + if unity then + GridACos(res, apporx, unity) + end + end + return nil, res +end + +function GridOpMapSlope:GetEditorText() + return "Calc of in " +end + +---- + +DefineClass.GridOpMapParamType = { + __parents = { "GridOpParam" }, + properties = { + { category = "General", id = "ParamValue", name = "Type", editor = "choice", default = "", items = GetTerrainNamesCombo() }, + { category = "General", id = "TypePreview", name = "Preview", editor = "image", default = false, img_size = 128, img_box = 1, base_color_map = true, dont_save = true }, + }, + GridOpType = "Map Param Terrain Type", +} + +function GridOpMapParamType:GetTypePreview() + return GetTerrainTexturePreview(self.ParamValue) +end + +---- + +DefineClass.GridOpMapParamColor = { + __parents = { "GridOpParam" }, + properties = { + { category = "General", id = "ParamValue", name = "Color", editor = "color", default = white }, + { category = "General", id = "R", name = "R", editor = "number", default = 0, min = 0, max = 255, slider = true, dont_save = true, buttons_step = 1 }, + { category = "General", id = "G", name = "G", editor = "number", default = 0, min = 0, max = 255, slider = true, dont_save = true, buttons_step = 1 }, + { category = "General", id = "B", name = "B", editor = "number", default = 0, min = 0, max = 255, slider = true, dont_save = true, buttons_step = 1 }, + }, + GridOpType = "Map Param Color", +} + +function GridOpMapParamColor:GetParamStr() + return string.format("%d %d %d", GetRGB(self.ParamValue)) +end + +function GridOpMapParamColor:SetParamValue(value) + self.ParamValue = value + self.R, self.G, self.B = GetRGB(value) +end + +function GridOpMapParamColor:SetR(c) self:SetParamValue(SetR(self.ParamValue, c)) end +function GridOpMapParamColor:SetG(c) self:SetParamValue(SetG(self.ParamValue, c)) end +function GridOpMapParamColor:SetB(c) self:SetParamValue(SetB(self.ParamValue, c)) end + +---- + +DefineClass.GridOpMapColorDist = { + __parents = { "GridOpOutput" }, + properties = { + { category = "General", id = "GridR", name = "Input Name R", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + { category = "General", id = "GridG", name = "Input Name G", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + { category = "General", id = "GridB", name = "Input Name B", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true }, + { category = "General", id = "Color", name = "Color Value", editor = "color", default = white, alpha = false, use_param = true }, + }, + GridOpType = "Map Color Dist", +} + +function GridOpMapColorDist:GetGridOutput(state) + local err + local cr, cg, cb = GetRGB(self:GetValue("Color")) + local gr, gg, gb = self:GetGridInput(self.GridR), self:GetGridInput(self.GridG), self:GetGridInput(self.GridB) + local mr, mg, mb = GridDest(gr), GridDest(gg), GridDest(gb) + GridAdd(gr, mr, -cr) + GridAdd(gg, mg, -cg) + GridAdd(gb, mb, -cb) + GridPow(mr, 2) + GridPow(mg, 2) + GridPow(mb, 2) + GridAdd(mr, mg) + GridAdd(mr, mb) + GridPow(mr, 1, 2) -- sqrt + return err, mr +end + +function GridOpMapColorDist:GetEditorText() + local color = self.UseParams and self.ColorParam ~= "" and "" or "" .. string.format("%d %d %d", GetRGB(self.ColorValue)) .. "" + return "Color Dist of " .. color .. " from in " +end + +---- + +DefineClass.GridInspect = { + __parents = { "DebugOverlayControl" }, + properties = { + { category = "Debug", id = "AllowInspect", name = "Allow Inspect", editor = "bool", default = false, buttons = {{ name = "Toggle", func = "ToggleInspect" }} }, + { category = "Debug", id = "OverlayAlpha", name = "Overlay Alpha (%)", editor = "number", default = 60, slider = true, min = 0, max = 100, dont_save = true }, + }, + inspect_thread = false, +} + +function GridInspect:GetInspectInfo() +end + +function GridInspect:ToggleInspect() + if IsValidThread(self.inspect_thread) then + DbgStopInspect() + DbgShowTerrainGrid(false) + return + end + local grid, palette, callback = self:GetInspectInfo() + if not grid then + print("Inpsect grid not found!") + return + end + DbgShowTerrainGrid(grid, palette) + self.inspect_thread = DbgStartInspectPos(callback, grid) +end + +function ToggleInspectDelayed(self) + self:ToggleInspect() +end + +function GridInspect:AutoStartInspect(state) + if developer and state.run_mode == "Debug" and self.AllowInspect then + DelayedCall(0, ToggleInspectDelayed, self) + end +end + +---- + +DefineClass.GridOpMapBiomeMatch = { + __parents = { "GridOpOutput", "GridInspect" }, + properties = { + { category = "General", id = "BiomeGroup", name = "Biome Group", editor = "choice", default = "", items = PresetGroupsCombo("Biome"), use_param = true }, + { category = "Preview", id = "Biomes", name = "All Biomes", editor = "number", default = 0, dont_save = true, read_only = true}, + { category = "Debug", id = "MatchedBiomes", name = "Matched Biomes", editor = "text", default = false, dont_save = true, lines = 10, max_lines = 10, text_style = "GedConsole" }, + { category = "Debug", id = "ClickPosition", name = "Map Position", editor = "point", default = false, dont_save = true}, + { category = "Debug", id = "GridPosition", name = "Grid Position", editor = "point", default = false, dont_save = true}, + }, + GridOpType = "Map Biome Match", + input_fmt = "F", + match_grids = false, + match_biomes = false, + bvalue_to_preset = false, +} + +for _, match in ipairs(BiomeMatchParams) do + local id, name, help = match.id, match.name, match.help + table.iappend(GridOpMapBiomeMatch.properties, { + { category = "General", id = id .. "Map", name = name .. " Match", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, ignore_errors = true, help = help }, + }) +end + +function GridOpMapBiomeMatch:OnMoveCallback(pos) + local biome_map = self.outputs[self.OutputName] + if not biome_map then + return + end + local mx, my = pos:xy() + local bvalue, gx, gy = GridMapGet(biome_map, mx, my) + local tmp = { "", + print_concat{"map", DivToStr(mx, guim), ":", DivToStr(my, guim), "(m)"}, + print_concat{"grid", gx, ":", gy}, + } + local biome_preset = self.bvalue_to_preset[bvalue] + tmp[#tmp + 1] = print_concat{ "Biome", bvalue, biome_preset and biome_preset.id or "" } + local grids = self.match_grids + for i, params in ipairs(BiomeMatchParams) do + local grid = grids[i] + if grid then + local v = GridMapGet(grid, mx, my, 1024) + tmp[#tmp + 1] = print_concat{ params.id, DivToStr(v, params.scale), params.units } + end + end + local h = #tmp + for i=1,h do + tmp[#tmp + 1] = "" + end + return table.concat(tmp, "\n") +end + +function GridOpMapBiomeMatch:OnClickCallback(pos) + local biome_map = self.outputs[self.OutputName] + if not biome_map then + return + end + local mx, my = pos:xy() + self.ClickPosition = pos + local _, gx, gy = GridMapGet(biome_map, mx, my) + self.GridPosition = point(gx, gy) + local err, biome_weights = BiomePosMatch(gx, gy, self.match_biomes, self.match_grids, BiomeMatchParams) + local matched_dump_str + if not err then + local bvalue_to_preset = self.bvalue_to_preset + table.sortby_field_descending(biome_weights, "weight") + for i, entry in ipairs(biome_weights) do + entry.value = bvalue_to_preset[entry.biome].id + end + local nweight = biome_weights[1].weight / 1000 + for i, entry in ripairs(biome_weights) do + entry.weight = DivRound(entry.weight, nweight) + if entry.weight == 0 then + table.remove(biome_weights, i) + end + end + local header = string.format("%20s | %6s", "Biome", "Weight") + local line = "---------------------+--------" + for _, entry in ipairs(BiomeMatchParams) do + header = string.format("%s | %9s", header, entry.id) + line = line .. "+-----------" + end + matched_dump_str = { header, line } + for _, entry in ipairs(biome_weights) do + local str = string.format("%20s | %6d", entry.value, entry.weight) + for _, weight in ipairs(entry) do + str = string.format("%s | %9d", str, weight) + end + matched_dump_str[#matched_dump_str + 1] = str + end + matched_dump_str = table.concat(matched_dump_str, "\n") + end + self.MatchedBiomes = matched_dump_str + ObjModified(self) +end + +function GridOpMapBiomeMatch:GetInspectInfo() + local biome_map = self.outputs[self.OutputName] + if not biome_map then + return + end + local palette = DbgGetBiomePalette() + self.bvalue_to_preset = BiomeValueToPreset() + local function MoveCallback(pos) + return self:OnMoveCallback(pos) + end + local function ClickCallback(pos) + return self:OnClickCallback(pos) + end + return biome_map, palette, { MoveCallback, ClickCallback } +end + +function GridOpMapBiomeMatch:GetGridOutput(state) + local grids = {} + for _, match in ipairs(BiomeMatchParams) do + local prop_id = match.id .. "Map" + local grid_name = self[prop_id] + local grid = self:GetGridInput(grid_name) + grids[#grids + 1] = grid or false + if grid then + local prec = 10 + local min, max = GridMinMax(grid, prec) + if min < match.min * prec or max > match.max * prec then + if min < match.min then + print("Match grid", match.id, "is below its min:", min * 1.0 / prec, "<", match.min) + else + print("Match grid", match.id, "is above its max:", max * 1.0 / prec, ">", match.max) + end + return "Match grid out of bounds" + end + end + end + local match_group = self:GetValue("BiomeGroup") or "" + if match_group == "" then + return "Biome group not specified!" + end + state.BiomeGroup = match_group + local biomes = {} + ForEachPreset("Biome", function(preset) + if preset.group == match_group then + local biome = { preset.grid_value } + for _, match in ipairs(BiomeMatchParams) do + local id = match.id + for _, prop in ipairs{"From", "To", "Best", "Weight"} do + biome[#biome + 1] = preset[id .. prop] + end + end + biomes[#biomes + 1] = biome + end + end) + if #biomes == 0 then + return "No biome presets found" + end + self.Biomes = #biomes + local gw, gh = grids[1]:size() + local biome_map = NewComputeGrid(gw, gh, "U", 8) + biome_map:clear() + local err = BiomeGridMatch(biome_map, biomes, grids, BiomeMatchParams) + if err then + return err + end + self.match_grids = grids + self.match_biomes = biomes + if developer then + local x, y = GridFind(biome_map, 0) + if x then + local w, h = terrain.GetMapSize() + StoreErrorSource(point(x * w / gw, y * h / gh), "Biome non matched!") + end + end + self:AutoStartInspect(state) + return nil, biome_map +end + +---- + +DefineClass.GridOpMapPrefabTypes = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "AllowEmptyTypes", name = "Allow Empty Types", editor = "bool", default = false }, + { category = "Preview", id = "PrefabsFound", name = "Prefabs Found", editor = "string_list", default = false, read_only = true, dont_save = true }, + }, + GridOpType = "Map Biome Prefab Types", +} + +function GridOpMapPrefabTypes:GetGridOutputFromInput(state, grid) + local levels = GridLevels(grid) + local bvalue_to_preset = BiomeValueToPreset() + local ptype_to_idx = table.invert(GetPrefabTypeList()) + local type_to_prefabs = PrefabTypeToPrefabs + local allow_empty_types = self.AllowEmptyTypes + local debug = state.run_mode ~= "GM" + local biomes, ptypes = {}, {} + for value, count in pairs(levels) do + if value == 0 then + goto continue + end + local preset = bvalue_to_preset[value] + local weights = preset and preset.PrefabTypeWeights or empty_table + local invalid_type + local valid_weights = {} + for _, pw in ipairs(weights) do + local ptype = pw.PrefabType + if not ptype_to_idx[ptype] then + invalid_type = ptype + break + end + if allow_empty_types or type_to_prefabs[ptype] then + valid_weights[#valid_weights + 1] = pw + end + end + if not preset then + print("Missing preset with value:", value) + elseif #weights == 0 then + print("Biome without prefab types:", preset.id) + elseif invalid_type then + print("Biome", preset.id, "contains an invalid prefab type", invalid_type) + elseif #weights > 1 and not NoisePresets[preset.TypeMixingPreset] then + print("Biome", preset.id, "has an invalid mixing pattern", preset.TypeMixingPreset) + elseif #valid_weights == 0 then + print("Biome", preset.id, "doesn't match any prefabs") + else + biomes[#biomes + 1] = { + preset = preset, + count = count, + weights = valid_weights, + } + if debug then + for _, pw in ipairs(valid_weights) do + ptypes[pw.PrefabType] = true + end + end + end + ::continue:: + end + + if debug then + local legend = {} + for ptype in pairs(ptypes) do + legend[#legend + 1] = string.format("%d. %s: %d", ptype_to_idx[ptype], ptype, #(type_to_prefabs[ptype] or empty_table)) + end + table.sort(legend) + self.PrefabsFound = legend + end + + table.sort(biomes, function(a, b) return a.preset.grid_value < b.preset.grid_value end) + local remap = {} + local w, h = grid:size() + local rand = state.rand + for _, biome in ipairs(biomes) do + local value = biome.preset.grid_value + local valid_weights = biome.weights + local weights = biome.preset.PrefabTypeWeights or empty_table + if #valid_weights == 0 then + -- continue + elseif #weights > 1 then + local type_mix = NewComputeGrid(w, h, "U", 16) + rand = BraidRandom(rand) + biome.preset:GetTypeMixingGrid(type_mix, rand, ptype_to_idx) + remap[value] = type_mix + else + local type_idx = ptype_to_idx[weights[1].PrefabType] + remap[value] = type_idx + end + end + local mix_grid = NewComputeGrid(w, h, "U", 16) + BiomeGridRemap(grid, mix_grid, remap) + return nil, mix_grid +end + +---- + +DefineClass.GridOpMapErosion = { + __parents = { "GridOpInputOutput" }, + properties = { + { category = "General", id = "Iterations", name = "Iterations", editor = "number", default = 100, min = 1 }, + { category = "General", id = "DropSize", name = "Drop Size (m)", editor = "number", default = 10, min = 0, max = unity, scale = unity, slider = true }, + { category = "General", id = "Capacity", name = "Capacity", editor = "number", default = 10, min = 0, max = unity, scale = unity, slider = true }, + { category = "General", id = "Evaporation", name = "Evaporation", editor = "number", default = unity/2, min = 0, max = unity, scale = unity, slider = true }, + { category = "General", id = "Solubility", name = "Solubility", editor = "number", default = 10, min = 0, max = unity, scale = unity, slider = true }, + { category = "General", id = "ThermalErosion",name = "Thermal Erosion", editor = "number", default = 10, min = 0, max = unity, scale = unity, slider = true }, + { category = "General", id = "WindForce", name = "Wind Force", editor = "number", default = unity, min = 0, max = unity, scale = unity, slider = true }, + { category = "General", id = "TalusAngle", name = "Talus Angle (deg)", editor = "number", default = 45*60, min = 0, max = 90*60, scale = 60, slider = true }, + { category = "General", id = "WaterMap", name = "Water Map", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, grid_output = true, optional = true }, + { category = "General", id = "SedimentMap", name = "Sediment Map", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, grid_output = true, optional = true }, + }, + GridOpType = "Map Erosion", +} + +function GridOpMapErosion:GetGridOutputFromInput(state, grid) + local eroded = GridRepack(grid, "F", 32, true) + local water = self:GetGridInput(self.WaterMap) or GridDest(eroded, true) + local sediment = self:GetGridInput(self.SedimentMap) or GridDest(eroded, true) + GridErosion( + eroded, water, sediment, self.Iterations, + self.DropSize, self.Capacity, self.Evaporation, self.Solubility, + self.ThermalErosion, self.WindForce, self.TalusAngle, + unity, state.rand) + if self.WaterMap ~= "" then + self:SetGridOutput(self.WaterMap, water) + end + if self.SedimentMap ~= "" then + self:SetGridOutput(self.SedimentMap, sediment) + end + return nil, eroded +end + +---- + +local def_tex = set("Main", "Noise", "Flow") +local function no_flow(self) return not self.Textures.Flow end +local function no_noise(self) return not self.Textures.Noise end + +DefineClass.GridOpMapBiomeTexture = { + __parents = { "GridOpInput", "GridInspect" }, + properties = { + { category = "General", id = "Textures", name = "Textures", editor = "set", default = def_tex, items = {"Main", "Noise", "Flow"}, }, + { category = "General", id = "FlowMap", name = "Flow Map", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, no_edit = no_flow }, + { category = "General", id = "FlowMax", name = "Flow Max", editor = "number", default = 1, use_param = true, no_edit = no_flow }, + { category = "General", id = "HeightMap", name = "Height Map", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, no_edit = no_noise }, + { category = "General", id = "Transition", name = "Transition", editor = "bool", default = true, }, + { category = "General", id = "ApplyGrass", name = "Apply Grass", editor = "bool", default = true, }, + { category = "General", id = "GrassMap", name = "Grass Map", editor = "combo", default = "", items = GridOpOutputNames, grid_input = true, optional = true, enabled_by = "ApplyGrass" }, + { category = "General", id = "InvalidTerrain",name = "Invalid Terrain", editor = "choice", default = "", items = GetTerrainNamesCombo(), help = "If not specified, the default invalid terrain will be used" }, + }, + GridOpType = "Map Biome Import Textures", + inspect_thread = false, +} + +function GridOpMapBiomeTexture:CollectTags(tags) + tags.Terrain = true + return GridOp.CollectTags(self, tags) +end + +function GridOpMapBiomeTexture:SetGridInput(state, grid) + local invalid_terrain = self.InvalidTerrain or "" + if invalid_terrain == "" then + invalid_terrain = const.Prefab.InvalidTerrain or "" + end + local invalid_idx = invalid_terrain ~= "" and GetTerrainTextureIndex(invalid_terrain) or 0 + local ptype_list = GetPrefabTypeList() + local type_presets, ptype_to_idx = {}, {} + for i, ptype in ipairs(ptype_list) do + if GridFind(grid, i) then + type_presets[#type_presets + 1] = PrefabTypeToPreset[ptype] + ptype_to_idx[ptype] = i + end + end + table.sort(type_presets, function(a, b) + local sa, sb = a.TexturingOrder, b.TexturingOrder + if sa ~= sb then + return sa < sb + end + local ta, tb = a.Transition, b.Transition + if ta ~= tb then + return ta > tb + end + return a.id < b.id + end) + local rand = state.rand + local textures = self.Textures + local flow_map = self:GetGridInput(self.FlowMap) + local height_map = self:GetGridInput(self.HeightMap) + local grass_map = self:GetGridInput(self.GrassMap) + local marks, maski = GridDest(grid), GridDest(grid) + local mask, noise, hmod + local last_idx = 0 + local idx_to_type, idx_to_grass = {}, {} + local function add_idx(type_idx, grass_mod) + last_idx = last_idx + 1 + idx_to_type[last_idx] = type_idx + idx_to_grass[last_idx] = grass_mod + return last_idx + end + marks:clear() + for i, type_preset in ipairs(type_presets) do + local idx = ptype_to_idx[type_preset.id] + GridMask(grid, maski, idx) + if mask then + GridRepack(maski, mask) + else + mask = GridRepack(maski, "F") + end + local transition = self.Transition and type_preset.Transition or 0 + if transition > 0 then + GridNot(mask) + GridDistance(mask, type_tile, transition) + GridRemap(mask, 0, transition, 1, 0) + end + rand = BraidRandom(rand) + local apply_config = { marks = marks, mask = mask, seed = rand } + if textures.Main and type_preset.TextureMain ~= "" then + local main_idx = GetTerrainTextureIndex(type_preset.TextureMain) + if not main_idx then + return "Invalid main terrain type: " .. type_preset.TextureMain + end + apply_config.main_idx = add_idx(main_idx, type_preset.GrassMain) + end + if textures.Noise and type_preset.TextureNoise ~= "" then + local noise_idx = GetTerrainTextureIndex(type_preset.TextureNoise) + local noise_preset = NoisePresets[type_preset.NoisePreset] + if not noise_preset then + return "Noise preset missing: " .. type_preset.id + elseif noise_preset.Min ~= 0 then + return "Invalid noise preset: " .. type_preset.id + elseif not noise_idx then + return "Invalid noise terrain type: " .. type_preset.TextureNoise + else + rand = BraidRandom(rand) + noise = noise or GridDest(mask) + noise_preset:GetNoise(rand, noise) + if type_preset.HeightModulated then + if not height_map then + return "Height map not provided!" + end + if not hmod then + hmod = GridDest(noise) + GridHeightMaskLevels(height_map, hmod) + end + noise = GridModulate(noise, hmod, hmod) + end + apply_config.noise = noise + apply_config.noise_idx = add_idx(noise_idx, type_preset.GrassNoise) + apply_config.noise_max = noise_preset.Max + apply_config.noise_stength = type_preset.NoiseStrength + apply_config.noise_contrast = type_preset.NoiseContrast + end + end + if textures.Flow and flow_map and type_preset.TextureFlow ~= "" then + local flow_idx = GetTerrainTextureIndex(type_preset.TextureFlow) + local flow_max = self:GetValue("FlowMax") + if not flow_max then + return "Undefined max flow value!" + elseif not flow_idx then + return "Invalid flow terrain type: " .. type_preset.TextureFlow + end + apply_config.flow = flow_map + apply_config.flow_idx = add_idx(flow_idx, type_preset.GrassFlow) + apply_config.flow_max = flow_max + apply_config.flow_strength = type_preset.FlowStrength + apply_config.flow_contrast = type_preset.FlowContrast + end + GridMarkPrefabTypeTerrain(apply_config) + end + if last_idx > 0 then + if self.ApplyGrass then + if not grass_map then + return "Grass map expected" + end + GridModPrefabTypeGrass(marks, grass_map, idx_to_grass) + end + GridReplace(marks, idx_to_type, invalid_idx) + else + marks:clear(invalid_idx) + end + local err = terrain.SetTypeGrid(marks) + if err then + return err + end + terrain.InvalidateType() + self:AutoStartInspect(state) +end + +function GridOpMapBiomeTexture:GetInspectInfo() + local grid = self.inputs[self.InputName] + if not grid then + return + end + local ptype_list = GetPrefabTypeList() + local level_map = GridLevels(grid) + local ptype_to_preset = PrefabTypeToPreset + local palette = {} + for ptype_idx in pairs(level_map) do + local ptype = ptype_list[ptype_idx] + local preset = ptype_to_preset[ptype] + local color = preset and preset.OverlayColor or RandColor(xxhash(ptype)) + palette[ptype_idx] = color + end + local bvalue_to_preset = BiomeValueToPreset() + return grid, palette, function(pos) + local idx = terrain.GetTerrainType(pos) + local texture = TerrainTextures[idx] + local bvalue = BiomeGrid:get(pos) + local biome_preset = bvalue_to_preset[bvalue] + local ptype_idx = GridMapGet(grid, pos:xy()) + local ptype = ptype_list[ptype_idx] + local tmp = { + print_concat{"Texture", idx, texture and texture.id or ""}, + print_concat{"Prefab Type", ptype_idx, ptype or ""}, + print_concat{"Biome", bvalue, biome_preset and biome_preset.id or ""}, + } + return table.concat(tmp, "\n") + end +end + +function OnMsg.GedPropertyEdited(_, obj, prop_id) + local op_classes + if IsKindOf(obj, "Biome") then + local category = obj:GetPropertyMetadata(prop_id).category + if category == "Prefabs" then + return + end + op_classes = {"GridOpMapBiomeMatch", "GridOpMapPrefabTypes"} + elseif IsKindOf(obj, "PrefabType") then + op_classes = {"GridOpMapBiomeTexture"} + else + return + end + local proc, target + ForEachPreset("MapGen", function(preset) + if GedObjects[preset] then + for _, op in ipairs(preset) do + if not op.proc or not table.find(op_classes, op.class) then + -- + elseif proc == op.proc then + if not target or target.start_time > op.start_time then + target = op + end + elseif not proc or proc.start_time < op.proc.start_time then + proc = op.proc + target = op + end + end + end + end) + if target then + target:Recalc() + end +end + +---- + +DefineClass.MapGen = { + __parents = { "GridProcPreset" }, + GlobalMap = "MapGenProcs", + EditorMenubarName = "Map Gen", + EditorMenubar = "Map.Generate", + EditorIcon = "CommonAssets/UI/Icons/gear option setting setup.png", + EditorMapGenActions = { + { Menubar = "Action", Toolbar = "main", Name = "Open MapGen Folder", FuncName = "ActionOpenMapgenFolder", Icon = "CommonAssets/UI/Ged/explorer.tga", }, + }, +} + +function MapGen:GatherEditorCustomActions(actions) + GridProcPreset.GatherEditorCustomActions(self, actions) + table.iappend(actions, self.EditorMapGenActions) +end + +function MapGen:GetSeedSaveDest() + return "MapGenSeed", mapdata +end + +function MapGen:RunOps(state, ...) + if GetMap() == "" then + return "No Map Loaded" + end + return GridProcPreset.RunOps(self, state, ...) +end + +function GetMapGenSource(map_name) + return string.format("svnAssets/Source/MapGen/%s/", map_name) +end + +function MapGen:RunInit(state) + if state.proc ~= self then + return + end + local map_name = GetMapName() or "" + if map_name == "" then + return + end + Msg("MapGenStart", self) + state.base_dir = GetMapGenSource(map_name) + self:AddLog("Output dir: " .. state.base_dir, state) + if state.tags.Pause then + Pause("MapGen") + end + if state.tags.Terrain then + SuspendTerrainInvalidations("MapGen") + end + if state.tags.Objects then + NetPauseUpdateHash("MapGen") + table.change(config, "MapGen", { + PartialPassEdits = false, + BillboardsSuspendInvalidate = true, + }) + SuspendPassEdits("MapGen") + DisablePassTypes() + collision.Activate(false) + end + table.change(_G, "MapGen", { + pairs = g_old_pairs, + GetDiagnosticMessage = empty_func, + DiagnosticMessageSuspended = true, + }) + return GridProcPreset.RunInit(self, state) +end + +function MapGen:InvalidateProc(state) + if state.tags.Terrain then + ResumeTerrainInvalidations("MapGen", true) + end +end + +function MapGen:RunDone(state) + if state.proc ~= self then + return + end + self:InvalidateProc(state) + if state.tags.Objects then + -- Hide editor objects shown after filter reset + MapForEach(true, "EditorObject", function(obj) + obj:ClearEnumFlags(const.efVisible) + end) + collision.Activate(true) + ResumePassEdits("MapGen") + table.restore(config, "MapGen", true) + NetResumeUpdateHash("MapGen") + XEditorFiltersReset() + EnablePassTypes() + end + if state.tags.Pause then + assert(IsPaused()) + Resume("MapGen") + end + table.restore(_G, "MapGen", true) + Msg("MapGenDone", self) + return GridProcPreset.RunDone(self, state) +end + +function TestOcclude(pt0, hg, count) + count = count or 1 + hg = hg or terrain.GetHeightGrid() + --hg = GridResample(hg, 512, 512) + --hg = GridRepack(hg, "F") + local occlude = GridDest(hg) + local gw, gh = hg:size() + local mw, mh = terrain.GetMapSize() + local goffset = 10*guim/height_scale + while true do + pt0 = pt0 or GetTerrainCursor() + local x, y = pt0:xy() + x, y = Clamp(x, 0, mw - 1), Clamp(y, 0, mh - 1) + local pt = point(x, y) + DbgClear() + DbgAddCircle(pt, 5*guim) + DbgAddVector(pt, 10*guim) + if GridOccludeHeight(hg, occlude, x * gw / mw, y * gh / mh, goffset) then + --GridNormalize(occlude, 0, 255) DbgShowTerrainGrid(occlude) + terrain.SetHeightGrid(occlude) terrain.InvalidateHeight() + end + count = count - 1 + if count <= 0 then + return pt0 + end + while pt0 == GetTerrainCursor() do + WaitNextFrame(1) + end + pt0 = GetTerrainCursor() + end +end + +function OccludePlayable(hg, eyeZ) + hg = hg or terrain.GetHeightGrid() + local st = GetPreciseTicks() + eyeZ = eyeZ or 10*guim + + local border_divs, playable_divs = 8, 4 + local gw, gh = hg:size() + local mw, mh = terrain.GetMapSize() + local goffset = eyeZ / height_scale + + local occlude = GridDest(hg) + local result = GridDest(hg) + result:clear(height_max / height_scale) + + --DbgClearVectors() + local function Occlude(gx, gy) + --local mp = point(gx * mw / gw, gy * mh / gw); DbgAddVector(mp, eyeZ) DbgAddCircle(mp, eyeZ) + if GridOccludeHeight(hg, occlude, gx, gy, goffset) then + GridMin(result, occlude) + end + end + + local bbox = GetPlayBox() + local minx, miny, maxx, maxy = bbox:xyxy() + + local pts = {{minx, miny}, {maxx - 1, miny}, {maxx - 1, maxy - 1}, {minx, maxy - 1}} -- box is exclusive + local pt0 = pts[#pts] + for _, pt1 in ipairs(pts) do + local x0, y0, x1, y1 = pt0[1], pt0[2], pt1[1], pt1[2] + for k = 1, border_divs do + local x = x0 + (x1 - x0) * k / border_divs + local y = y0 + (y1 - y0) * k / border_divs + Occlude(x * gw / mw, y * gh / mh) + end + pt0 = pt1 + end + local dx, dy = maxx - minx, maxy - miny + local y0 = miny + for i = 1, playable_divs do + local y1 = miny + dy * i / playable_divs + local x0 = minx + for j = 1, playable_divs do + local x1 = minx + dx * j / playable_divs + local v, gx, gy = GridGetMaxHeight(hg, x0 * gw / mw, y0 * gw / mw, x1 * gw / mw, y1 * gw / mw) + Occlude(gx, gy) + x0 = x1 + end + y0 = y1 + end + + return result +end + +function OnMsg.ChangeMap() + ForEachPreset("MapGen", function(preset) + preset.run_state = nil + for _, op in ipairs(preset) do + op.inputs = nil + op.outputs = nil + op.params = nil + end + end) +end + +function OnMsg.SaveMap() + if LastGridProcDump == "" then + return + end + CreateRealTimeThread(function(name, str) + local filename = GetMap() .. LastGridProcName .. ".log" + local err = AsyncStringToFile(filename, str) + if err then + print("Mapgen dump write error:", err) + else + local path = ConvertToOSPath(filename) + print("Mapgen dump file saved to:", path) + end + end, LastGridProcName, LastGridProcDump) + LastGridProcDump = "" +end + +AppendClass.MapDataPreset = { properties = { + { category = "Random Map", id = "MapGenSeed", editor = "number", default = 0 }, +}} \ No newline at end of file diff --git a/CommonLua/MapGen/PlacePrefabMarker.lua b/CommonLua/MapGen/PlacePrefabMarker.lua new file mode 100644 index 0000000000000000000000000000000000000000..927339d315f70e9b543e8b3c46d2c08bea2df018 --- /dev/null +++ b/CommonLua/MapGen/PlacePrefabMarker.lua @@ -0,0 +1,262 @@ +local function GetPrefabItems(self) + local items = {} + local PrefabMarkers = PrefabMarkers + for _, prefab in ipairs(self:FilterPrefabs()) do + items[#items + 1] = PrefabMarkers[prefab] + end + table.sort(items) + table.insert(items, 1, "") + return items +end + +DefineClass.PlacePrefabLogic = { + __parents = { "PropertyObject" }, + properties = { + { category = "Prefab", id = "FixedPrefab", name = "Fixed Prefabs", editor = "string_list", default = false, items = GetPrefabItems, no_validate = true, buttons = {{name = "Test", func = "TestPlacePrefab"}}}, + { category = "Prefab", id = "PrefabPOIType", name = "Prefab POI Type", editor = "preset_id", default = "", preset_class = "PrefabPOI" }, + { category = "Prefab", id = "PrefabType", name = "Prefab Type", editor = "preset_id", default = "", preset_class = "PrefabType" }, + { category = "Prefab", id = "PrefabTagsAny", name = "Prefab Tags Any", editor = "set", default = empty_table, items = function() return PrefabTagsCombo() end, three_state = true }, + { category = "Prefab", id = "PrefabTagsAll", name = "Prefab Tags All", editor = "set", default = empty_table, items = function() return PrefabTagsCombo() end, three_state = true }, + { category = "Prefab", id = "MaxPrefabRadius", name = "Max Allowed Radius", editor = "number", default = 0, scale = "m" }, + { category = "Prefab", id = "FixAtCenter", name = "Fix At Center", editor = "bool", default = true, help = "Allow the prefab to be spawned anywhere inside the max radius" }, + { category = "Prefab", id = "RandAngle", name = "Rand Angle", editor = "number", default = 0, scale = "deg" }, + { category = "Prefab", id = "PlacedName", name = "Placed Name", editor = "text", default = "", read_only = true, dont_save = true, buttons = {{name = "Goto", func = "GotoPrefabAction"}} }, + { category = "Prefab", id = "PlaceError", name = "Place Error", editor = "text", default = "", read_only = true, dont_save = true }, + { category = "Prefab", id = "PrefabCount", name = "Prefab Count", editor = "number", default = 0, read_only = true, dont_save = true }, + }, + reserved_locations = false, +} + +function PlacePrefabLogic:SetFixedPrefab(prefab) + if type(prefab) == "string" then + prefab = { prefab } + end + self.FixedPrefab = prefab +end + +function PlacePrefabLogic:FilterPrefabs(params, all_prefabs) + all_prefabs = all_prefabs or PrefabMarkers + local prefabs = {} + local poi_type = params and params.poi_type or self.PrefabPOIType or "" + local ptype = params and params.prefab_type or self.PrefabType or "" + local max_radius = params and params.max_radius or self.MaxPrefabRadius or 0 + local tags_any = params and params.tags_any or self.PrefabTagsAny + local tags_all = params and params.tags_all or self.PrefabTagsAll + local type_tile = const.TypeTileSize + for _, prefab in ipairs(all_prefabs) do + if (poi_type == "" or prefab.poi_type == poi_type) + and (ptype == "" or prefab.type == "" or prefab.type == ptype) + and (max_radius == 0 or prefab.max_radius * type_tile <= max_radius) + and MatchThreeStateSet(prefab.tags, tags_any, tags_all) + then + prefabs[#prefabs + 1] = prefab + end + end + return prefabs +end + +function PlacePrefabLogic:GetPrefabs(params) + local fixed_prefabs = params and params.name or self.FixedPrefab + if fixed_prefabs then + local prefabs = {} + if type(fixed_prefabs) == "string" then + fixed_prefabs = { fixed_prefabs } + end + for _, name in ipairs(fixed_prefabs) do + local prefab = PrefabMarkers[name] + if not prefab then + StoreErrorSource(self, "No such prefab:", name) + else + prefabs[#prefabs + 1] = prefab + end + end + if params and params.filter_fixed then + local tags_any = params and params.tags_any or self.PrefabTagsAny + local tags_all = params and params.tags_all or self.PrefabTagsAll + for i = #prefabs,1,-1 do + if not MatchThreeStateSet(prefabs[i].tags, tags_any, tags_all) then + table.remove_rotate(prefabs, i) + end + end + end + if #prefabs > 0 then + return prefabs + end + end + return self:FilterPrefabs(params) +end + +function PlacePrefabLogic:GetError() + if mapdata.IsPrefabMap and self:GetPrefabCount() == 0 then + return "No matching prefabs found!" + end +end + +function PlacePrefabLogic:GetPrefabCount(params) + return #self:GetPrefabs(params) +end + +function PlacePrefabLogic:ReserveLocation(pos, radius) + self.reserved_locations = table.create_add(self.reserved_locations, {pos, radius}) +end + +function PlacePrefabLogic:GetReservedRatio() + local max_radius = self.MaxPrefabRadius + if max_radius <= 0 then + return 100 + end + local radius_sum2 = 0 + for _, info in ipairs(self.reserved_locations) do + local radius = info[2] + radius_sum2 = radius * radius + end + return 100 * sqrt(radius_sum2) / max_radius +end + +function PlacePrefabLogic:CheckReservedLocations(pos, radius) + --DbgClear(true) DbgAddCircle(self, self.MaxPrefabRadius, yellow) DbgAddCircle(pos, radius, blue) + for _, info in ipairs(self.reserved_locations) do + if IsCloser2D(pos, info[1], radius + info[2]) then + --DbgAddCircle(info[1], info[2], red) + return + end + end + --DbgAddVector(pos) + return true +end + +function PlacePrefabLogic:GetPrefabLoc(seed, params) + seed = seed or InteractionRand(nil, "PlacePrefab") + local name, pos, angle, prefab, idx + local prefabs = self:GetPrefabs(params) + local retry + while true do + local idx + if #prefabs > 1 then + prefab, idx, seed = table.weighted_rand(prefabs, "weight", seed) + else + prefab = prefabs[1] + end + assert(prefab) + if not prefab then + return + end + pos = params and params.pos + if not pos then + pos = self:GetVisualPos() + if not self.FixAtCenter then + local reserved_radius + if params and params.avoid_reserved_locations and self.reserved_locations then + reserved_radius = (prefab.min_radius + prefab.max_radius) * const.TypeTileSize / 2 + end + local radius = prefab.max_radius * const.TypeTileSize + local free_dist = self.MaxPrefabRadius - radius + if free_dist > 0 then + local center = pos + pos = false + local retries = params and params.avoid_reserved_retries or 16 + for i=1,retries do + local ra, rr + ra, seed = BraidRandom(seed, 360*60) + rr, seed = BraidRandom(seed, free_dist) + local pos_i = RotateRadius(rr, ra, center) + if not reserved_radius or self:CheckReservedLocations(pos_i, reserved_radius) then + pos = pos_i + break + end + end + elseif reserved_radius and not self:CheckReservedLocations(pos, reserved_radius) then + pos = false + end + end + end + if pos then + name = PrefabMarkers[prefab] + angle = params and params.angle + if not angle then + angle = self:GetAngle() + local rand_angle = self.RandAngle + if rand_angle > 0 then + local desired_angle = params and params.desired_angle + if desired_angle then + local angle_diff = AngleDiff(desired_angle, angle) + if abs(angle_diff) <= rand_angle then + angle = desired_angle + else + local min_angle, max_angle = angle - rand_angle, angle + rand_angle + if abs(AngleDiff(desired_angle, min_angle)) < abs(AngleDiff(desired_angle, max_angle)) then + angle = min_angle + else + angle = max_angle + end + end + else + local da + da, seed = BraidRandom(seed, -rand_angle, rand_angle) + angle = angle + da + end + end + end + return name, pos, angle, prefab, seed + end + if #prefabs == 1 then + return + end + table.remove_rotate(prefabs, idx) + end +end + +function PlacePrefabLogic:PlacePrefab(seed, params) + local success, err, objs, inv_bbox + local name, pos, angle, prefab, seed = self:GetPrefabLoc(seed, params) + if not name then + err = "No matching prefabs found!" + else + success, err, objs, inv_bbox = procall(PlacePrefab, name, pos, angle, seed, params) + end + self.PlaceError = err + self.PlacedName = name + ObjModified(self) + return err, objs, pos, prefab, name, inv_bbox +end + +function PlacePrefabLogic:EditorCallbackGenerate(generator, object_source, placed_objects, prefab_list) + local mark = placed_objects[self] + local info = mark and prefab_list[mark] + local ptype = info and info[4] + if ptype then + self.PrefabType = ptype + end +end + +---- + +DefineClass.PlacePrefabMarker = { + __parents = { "RadiusMarker", "PlacePrefabLogic", "PrefabSourceInfo" }, + editor_text_color = RGB(50, 50, 100), + editor_color = RGB(150, 150, 0), +} + +function PlacePrefabMarker:GetMeshRadius() + local max_radius = self.MaxPrefabRadius + for _, prefab in ipairs(self:GetPrefabs()) do + max_radius = Max(max_radius, prefab.max_radius) + end + return max_radius +end + +function PlacePrefabMarker:OnEditorSetProperty(prop_id, old_value, ged) + local meta = self:GetPropertyMetadata(prop_id) + if meta and meta.category == "Prefab" then + self:UpdateMeshRadius() + end +end + +function PlacePrefabMarker:TestPlacePrefab() + local err, objs = self:PlacePrefab(AsyncRand(), { + create_undo = true, + }) + if err then + print(err) + end +end diff --git a/CommonLua/MapGen/PrefabMarker.lua b/CommonLua/MapGen/PrefabMarker.lua new file mode 100644 index 0000000000000000000000000000000000000000..461b0f5253ca46fcc2de205a279e21231990435b --- /dev/null +++ b/CommonLua/MapGen/PrefabMarker.lua @@ -0,0 +1,828 @@ +local type_tile = const.TypeTileSize +local height_tile = const.HeightTileSize +local grass_tile = const.GrassTileSize +local height_scale = const.TerrainHeightScale +local empty_table = empty_table +local GetClassFlags = CObject.GetClassFlags +local SetGameFlags = CObject.SetGameFlags +local developer = Platform.developer and Platform.desktop +local unpack = table.unpack +local def_size = point(20*guim, 20*guim) +local capture_items = {"Height", "Terrain", "Grass"} +local def_capt = set(table.unpack(capture_items)) +local mask_max = 255 +local invalid_type_value = 255 +local invalid_grass_value = 255 +local transition_max_pct = 30 + +function GetTerrainGridsMaxGranularity() + local granularity = Max(grass_tile, type_tile, height_tile) + assert(granularity % grass_tile == 0) + assert(granularity % type_tile == 0) + assert(granularity % height_tile == 0) + return granularity +end + +local function ApplyHeightOpCombo(first) + local items = { + {value = '=', text = "Equals"}, + {value = '<', text = "Min"}, + {value = '>', text = "Max"}, + {value = '~', text = "Average"}, + {value = '+', text = "Add"}, + } + if first then + table.insert(items, 1, first) + end + return items +end + +local post_process_items = { + { value = 0, text = "" }, + { value = 1, text = "fill holes"}, + { value = 2, text = "adjust capture"}, +} + +assert(type_tile == const.HeightTileSize) + +if FirstLoad then + PrefabMarkers = {} + ExportedPrefabs = {} + + PrefabTypes = {} + PrefabTypeToPrefabs = {} + + PrefabDimensions = empty_table +end + +PrefabMarkerVersion = '1' + +local p_dprint = CreatePrint{ "RM", format = print_format, output = DebugPrint } +local p_print = developer and CreatePrint{ "RM", format = print_format, color = yellow} or p_dprint + +function GetPrefabFileObjs(name) + return string.format("Prefabs/%s.bin", name) +end + +function GetPrefabFileHeight(name) + return string.format("Prefabs/%s.h.grid", name) +end + +function GetPrefabFileType(name) + return string.format("Prefabs/%s.t.grid", name) +end + +function GetPrefabFileGrass(name) + return string.format("Prefabs/%s.g.grid", name) +end + +function GetPrefabFileMask(name) + return string.format("Prefabs/%s.m.grid", name) +end + +local function GetRotationModesCombo() + return { + { value = false, text = "" }, + { value = "slope", text = "Follow Slope Angle" }, + { value = "map", text = "Follow Map Orientation" }, + } +end + +local function GetPoiAreas(self) + local names = {} + local poi_preset = PrefabPoiToPreset[self.PoiType] + for _, group in pairs(poi_preset and poi_preset.PrefabTypeGroups) do + names[#names + 1] = group.id + end + table.sort(names) + return names +end + +local function GetPoiAreasCount(self) + local poi_preset = PrefabPoiToPreset[self.PoiType] + return #(poi_preset and poi_preset.PrefabTypeGroups or "") +end + +---- + +DefineClass.PrefabObj = { + __parents = { "Object", "EditorVisibleObject" }, + flags = { efWalkable = false, efCollision = false, efApplyToGrids = false }, + Scale = 250, +} + +function PrefabObj:Init() + self:SetScale(self.Scale) + self:SetVisible(IsEditorActive()) +end + +---- + +DefineClass.DebugOverlayControl = { + __parents = { "PropertyObject" }, +} + +---- + +DefineClass.PrefabMarker = { + __parents = { "MapMarkerObj", "PrefabObj", "DebugOverlayControl" }, + flags = { efWalkable = false, efApplyToGrids = false, efCollision = false, gofAlwaysRenderable = true }, + entity = "WayPointBig", + properties = { + { id = "PrefabName", name = "Name", editor = "text", default = "", category = "Prefab", read_only = true, dont_save = true, }, + { id = "ExportedName", name = "Exported", editor = "text", default = "", category = "Prefab", read_only = true, buttons = {{name = "Export", func = "ActionPrefabExport"}, {name = "Revision", func = "ActionPrefabRevision"}, {name = "Folder", func = "ActionExploreTo"}}}, + { id = "ExportError", name = "Export Error", editor = "text", default = "", category = "Prefab", read_only = true, no_edit = function(self) return self.ExportError == "" end }, + { id = "ExportedHash", editor = "number", default = false, category = "Prefab", export = "hash", no_edit = true }, + { id = "AssetsRevision", name = "Assets Revision", editor = "number", default = false, category = "Prefab", export = "revision", read_only = true }, + { id = "PrefabType", name = "Type", editor = "preset_id", default = "", category = "Prefab", export = "type", compatibility = true, preset_class = "PrefabType", }, + { id = "PrefabWeight", name = "Weight", editor = "number", default = 100, category = "Prefab", export = "weight", compatibility = true, min = 0 }, + { id = "PrefabMaxCount", name = "Max Count", editor = "number", default = -1, category = "Prefab", export = "max_count", compatibility = true }, + { id = "RepeatReduct", name = "Repeat Reduct (%)", editor = "number", default = 0, category = "Prefab", export = "repeat_reduct", compatibility = true, min = 0, max = 100, slider = true, no_edit = function(self) return self.PoiType ~= "" end }, + { id = "PrefabOrient", name = "Orientation", editor = "point", default = axis_x, category = "Prefab", helper = "relative_pos", compatibility = true, helper_outside_object = true, use_object = true, help = "Used to specify a common orientation for a set of prefabs." }, + { id = "PrefabAngle", name = "Angle (deg)", editor = "number", default = 0, category = "Prefab", export = "angle", compatibility = true, scale = 60, read_only = true, dont_save = true }, + { id = "PrefabAngleVar", name = "Angle Variate (deg)", editor = "number", default = 180*60, category = "Prefab", export = "angle_variation", compatibility = true, min = 0, max = 180*60, slider = true, scale = 60, help = "Random variation around the prefab angle" }, + { id = "PrefabRotateMode", name = "Rotation Mode", editor = "choice", default = "map", category = "Prefab", export = "rotation_mode", compatibility = true, items = GetRotationModesCombo }, + { id = "DecorObstruct", name = "Decor Obstruct", editor = "bool", default = false, category = "Prefab", export = "decor_obstruct", compatibility = true, help = "Obstruct placement of other decor prefabs in its area. Option valid only when placed as decor." }, + { id = "SaveCollections", name = "Important Collections", editor = "bool", default = false, category = "Prefab", export = "save_collections", help = "If specified, the collections in the prefab will be persisted after the generation." }, + { id = "Tags", name = "Tags", editor = "set", default = empty_table, category = "Prefab", export = "tags", compatibility = true, items = function() return PrefabTagsCombo() end, help = "Keywords used to group similar prefabs" }, + { id = "AllTags", name = "All Tags", editor = "text", default = "", category = "Prefab", read_only = true, dont_save = true, help = "Includes the tags inherited from prefab type and POI type" }, + { id = "PoiType", name = "POI Type", editor = "preset_id", default = "", category = "Prefab", export = "poi_type", compatibility = true, preset_class = "PrefabPOI", }, + { id = "PoiArea", name = "POI Area", editor = "choice", default = "", category = "Prefab", export = "poi_area", compatibility = true, items = GetPoiAreas, no_edit = function(self) return GetPoiAreasCount(self) ==0 end, help = "Disregard prefab type and use POI area instead." }, + + { id = "CaptureSet", name = "Capture", editor = "set", default = def_capt, category = "Terrain", items = capture_items }, + { id = "CaptureSize", name = "Size", editor = "point", default = def_size, category = "Terrain", export = "size", scale = "m", min = 0, granularity = GetTerrainGridsMaxGranularity(), + helper = "terrain_rect", terrain_rect_color = RGBA(64, 196, 0, 96), terrain_rect_step = guim/2, terrain_rect_zoffset = guim/4, terrain_rect_depth_test = true, terrain_rect_grid = true, + buttons = { {name = "Capture", func = "ActionCaptureTerrain"}, {name = "Clear", func = "ActionClearTerrain"}, }, + }, + { id = "Centered", name = "Centered", editor = "bool", default = false, category = "Terrain", compatibility = true, help = "Specify if the marker is in the center of the capture area"}, + { id = "HeightOp", name = "Apply Height", editor = "dropdownlist", default = '+', category = "Terrain", export = "height_op", items = function() return ApplyHeightOpCombo() end, help = "Specify how the captured terrain height would be applied over the existing" }, + { id = "InvalidTerrain", name = "Invalid Terrain", editor = "dropdownlist", category = "Terrain", items = function() return GetTerrainNamesCombo() end, help = "Tiles with invalid terrain type wont be captured" }, + { id = "InvalidGrass", name = "Invalid Grass", editor = "number", default = -1, category = "Terrain", help = "Tiles with invalid grass density wont be captured. The final values will be remapped if possible to fit the whole density range." }, + { id = "SkippedTerrains", name = "Skipped Terrains", editor = "string_list", default = false, category = "Terrain", items = function() return GetTerrainNamesCombo() end, help = "Tiles with invalid terrain type wont be captured" }, + { id = "ApplyTerrain", name = "Apply Terrain", editor = "dropdownlist", default = '', category = "Terrain", items = {'', 'invalid'}, help = "Specify how the captured terrain type would be applied over the existing", }, + { id = "TransitionZone", name = "Transition Zone", editor = "number", default = 64*guim, category = "Terrain", compatibility = true, min = 0, max = function(o) return o:GetMaxTransitionDist() end, slider = true, scale = "m", granularity = type_tile, help = "Transition zone for smooth stitching" }, + { id = "CircleMask", name = "Circle Mask", editor = "bool", default = false, category = "Terrain",}, + { id = "CircleMaskRadius", name = "Custom Mask Radius",editor = "number", default = false, category = "Terrain", scale = "m", no_edit = PropChecker("CircleMask", false) }, + { id = "PostProcess", name = "Post Process", editor = "dropdownlist", default = 2, category = "Terrain", items = post_process_items, no_edit = function(self) return self.CircleMask end}, + { id = "TerrainPreview", name = "Terrain Preview", editor = "bool", default = true, category = "Terrain" }, + { id = "HeightMap", name = "Height Map", editor = "grid", default = false, category = "Terrain", read_only = true, dont_save = true, min = 128, max = 256, no_edit = function(self) return not self.TerrainPreview end, grid_offset = function(self) return self.HeightOffset end }, + { id = "HeightHash", editor = "number", default = false, category = "Terrain", export = "height_hash", no_edit = true }, + { id = "HeightOffset", editor = "number", default = 0, category = "Terrain", export = "height_offset", no_edit = true }, + { id = "HeightMin", editor = "point", default = point30, category = "Terrain", export = "min", no_edit = true }, + { id = "HeightMax", editor = "point", default = point30, category = "Terrain", export = "max", no_edit = true }, + { id = "TypeMap", name = "Terrain Type", editor = "grid", default = false, category = "Terrain", read_only = true, dont_save = true, min = 128, max = 256, color = true, invalid_value = invalid_type_value, no_edit = function(self) return not self.TerrainPreview end, }, + { id = "TypeHash", editor = "number", default = false, category = "Terrain", export = "type_hash", no_edit = true }, + { id = "TypeNames", editor = "prop_table", default = false, category = "Terrain", export = "type_names", no_edit = true,}, + { id = "GrassMap", name = "Grass", editor = "grid", default = false, category = "Terrain", read_only = true, dont_save = true, min = 128, max = 256, invalid_value = invalid_grass_value, no_edit = function(self) return not self.TerrainPreview end, }, + { id = "GrassHash", editor = "number", default = false, category = "Terrain", export = "grass_hash", no_edit = true }, + { id = "MaskMap", name = "Transition Mask", editor = "grid", default = false, category = "Terrain", read_only = true, dont_save = true, min = 128, max = 256, no_edit = function(self) return not self.TerrainPreview end, }, + { id = "MaskHash", editor = "number", default = false, category = "Terrain", export = "mask_hash", no_edit = true }, + + { id = "RequiredMemory", name = "Required Memory (KB)", editor = "number", default = 0, category = "Stats", export = "required_memory", read_only = true, scale = 1024 }, + { id = "TerrainCaptureTime", name = "Terrain Capture (ms)", editor = "number", default = 0, category = "Stats", read_only = true, dont_save = true }, + { id = "PlayArea", editor = "number", default = 0, category = "Stats", export = "play_area", compatibility = true, no_edit = true }, + { id = "TotalArea", editor = "number", default = 0, category = "Stats", export = "total_area", compatibility = true, no_edit = true }, + { id = "RadiusMin", editor = "number", default = 0, category = "Stats", export = "min_radius", compatibility = true, no_edit = true }, + { id = "RadiusMax", editor = "number", default = 0, category = "Stats", export = "max_radius", compatibility = true, no_edit = true }, + { id = "PlayAreaRatio", name = "Play Area (%)", editor = "number", default = 0, category = "Stats", read_only = true, dont_save = true }, + { id = "MapTotalArea", name = "Total Area (m^2)", editor = "number", default = 0, category = "Stats", scale = guim*guim, read_only = true, dont_save = true }, + { id = "MapRadiusMin", name = "Min Radius (m)", editor = "number", default = 0, category = "Stats", scale = guim, read_only = true, dont_save = true }, + { id = "MapRadiusMax", name = "Max Radius (m)", editor = "number", default = 0, category = "Stats", scale = guim, read_only = true, dont_save = true }, + { id = "HeightRougness", name = "Height Rougness", editor = "number", default = 0, category = "Stats", read_only = true, help = "Quantative estimation of the maximum height map roughness" }, + { id = "ObjCount", name = "Obj Count", editor = "number", default = 0, category = "Stats", export = "obj_count", compatibility = true, read_only = true, help = "Relative to the maximum allowed object density" }, + { id = "ObjMaxCount", name = "Obj Max Count", editor = "number", default = 0, category = "Stats", read_only = true, help = "Maximum allowed objects for the current size of the prefab" }, + { id = "ObjRadiusMin", name = "Obj Radius Min (m)", editor = "number", default = 0, category = "Stats", export = "obj_min_radius", read_only = true, scale = guim, }, + { id = "ObjRadiusMax", name = "Obj Radius Max (m)", editor = "number", default = 0, category = "Stats", export = "obj_max_radius", read_only = true, scale = guim, }, + { id = "ObjRadiusAvg", name = "Obj Radius Avg (m)", editor = "number", default = 0, category = "Stats", export = "obj_avg_radius", read_only = true, scale = guim, }, + { id = "NestedColls", name = "Nested Collections", editor = "prop_table", default = false, category = "Stats", export = "nested_colls", read_only = true, indent = ' '}, + { id = "NestedOptObjs", name = "Nested Optional Objs", editor = "prop_table", default = false, category = "Stats", export = "nested_opt_objs", read_only = true, indent = ' '}, + }, + InvalidTerrain = "", -- will be assigned later from the constant defs +} + +function OnMsg.ClassesGenerate() + PrefabMarker.InvalidTerrain = table.get(const, "Prefab", "InvalidTerrain") or "" +end + +function PrefabMarker:GetPrefabAngle() + return CalcOrientation(self.PrefabOrient) +end + +local function PrefabFilter(pstyles, ptypes, revision, version) + revision = revision or AssetsRevision + version = version or max_int + local matched + local markers = PrefabMarkers + for i=1,#markers do + local marker = markers[i] + if (not pstyles or marker.style == "" or pstyles[marker.style]) + and (not ptypes or marker.type == "" or ptypes[marker.type]) + and revision >= (marker.revision or 0) + and version >= (marker.version or 1) then + matched = matched or {} + matched[#matched + 1] = marker + end + end + return matched or empty_table +end + +function PrefabMarker:GetPlayAreaRatio() + return self.TotalArea > 0 and MulDivRound(100, self.PlayArea, self.TotalArea) or 0 +end + +function PrefabMarker:GetMapTotalArea() + return self.TotalArea * type_tile * type_tile +end + + +function PrefabMarker:GetMapRadiusMin() + return self.RadiusMin * type_tile +end + +function PrefabMarker:GetMapRadiusMax() + return self.RadiusMax * type_tile +end + +function PrefabComposeName(props) + local prefab_name = props.name or "" + if #prefab_name > 0 then + local prefab_type = props.type or "" + if prefab_type == "" then + prefab_type = "Any" + end + prefab_name = prefab_type .. "." .. prefab_name + local prefab_style = props.style or "" + if #prefab_style ~= 0 then + prefab_name = prefab_style .. "." .. prefab_name + end + end + return prefab_name +end + +function PrefabMarker:GetPrefabName() + return PrefabComposeName{name = self.MarkerName, type = self.PrefabType} +end + +function PrefabMarker:GetAllTags() + local poi_tags = table.get(PrefabPoiToPreset, self.PoiType, "Tags") + local ptype_tags = table.get(PrefabTypeToPreset, self.PrefabType, "Tags") + local marker_tags = self.Tags + local tags = {} + table.append(tags, ptype_tags) + table.append(tags, marker_tags) + table.append(tags, poi_tags) + return table.concat(table.keys(tags, true), ", ") +end + +---- + +function GetTypeRemapping(name_to_idx) + local type_remapping + local TerrainTextures = TerrainTextures + local GetTerrainTextureIndex = GetTerrainTextureIndex + for name, idx in pairs(name_to_idx or empty_table) do + if TerrainTextures[idx].id ~= name then + local new_idx = GetTerrainTextureIndex(name) + if not new_idx then + assert(false, "No such terrain type: " .. name) + else + type_remapping = type_remapping or {} + type_remapping[idx] = new_idx + end + end + end + if type_remapping then + for i = 0, MaxTerrainTextureIdx() do + type_remapping[i] = type_remapping[i] or i + end + end + return type_remapping +end + +function PrefabMarker:GetMaxTransitionDist() + local min_size = Min(self.CaptureSize:xy()) + return Min(type_tile * mask_max, min_size * transition_max_pct / 100) +end + +function PrefabMarker:GetTransitionDist() + return Max(0, Min(self.TransitionZone, self:GetMaxTransitionDist())) +end + +---- + +local function PrefabUpdateExported() + ExportedPrefabs = {} + local err, list = AsyncListFiles("Prefabs", "*.bin") + if err then + p_print("PrefabUpdateExported: ", err) + return + end + for i=1,#list do + local file = list[i] + local dir, name, ext = SplitPath(file) + if ExportedPrefabs[name] then + p_print("Duplicated exported prefab name:", name) + else + ExportedPrefabs[name] = true + end + end +end + +function PrefabCalcStats(prefabs) + local min_prefab_radius, max_prefab_radius = max_int, min_int + local avg_prefab_radius, radius_prefabs = 0, 0 + local min_height, max_height = max_int, min_int + local avg_max_height, avg_min_height, height_prefabs = 0, 0, 0 + local min_play_area, max_play_area = max_int, min_int + local avg_play_area, play_prefabs = 0, 0 + local function get_avg(avg, count, value) + return (avg * count + value) / (count + 1) + end + for i = 1,#prefabs do + local prefab = prefabs[i] + local play_area = prefab.play_area or 0 + if play_area > 0 then + min_play_area = Min(min_play_area, play_area) + max_play_area = Max(max_play_area, play_area) + avg_play_area = get_avg(avg_play_area, play_prefabs, play_area) + play_prefabs = play_prefabs + 1 + end + local radius = prefab.max_radius or 0 + if radius > 0 then + min_prefab_radius = Min(min_prefab_radius, radius) + max_prefab_radius = Max(max_prefab_radius, radius) + avg_prefab_radius = get_avg(avg_prefab_radius, radius_prefabs, radius) + radius_prefabs = radius_prefabs + 1 + end + if prefab.max and prefab.min then + max_height = Max(max_height, prefab.max:z()) + min_height = Min(min_height, prefab.min:z()) + avg_max_height = get_avg(avg_max_height, height_prefabs, max_height) + avg_min_height = get_avg(avg_min_height, height_prefabs, min_height) + height_prefabs = height_prefabs + 1 + end + end + return { + MinRadius = min_prefab_radius, + MaxRadius = max_prefab_radius, + AvgRadius = avg_prefab_radius, + MinPlayArea = min_play_area, + MaxPlayArea = max_play_area, + AvgPlayArea = avg_play_area, + MaxHeight = max_height, + MinHeight = min_height, + AvgMaxHeight = avg_max_height, + AvgMinHeight = avg_min_height, + } +end + +local function ConvertTags(tags) + return tags and table.invert(tags) or nil +end + +function PrefabUpdateMarkers() + local markers = Markers + local hash_keys = {"hash", "height_hash", "type_hash", "mask_hash"} + local versions = {} + local prefabs = {} + local deprecated = 0 + local type_to_prefabs = {} + + local defaults = {} + for _, prop in ipairs(PrefabMarker:GetProperties()) do + local export_id = prop.export + if export_id then + defaults[export_id] = PrefabMarker:GetProperty(prop.id) + end + end + + local PrefabTypeToPreset = PrefabTypeToPreset + local ExportedPrefabs = ExportedPrefabs + local PrefabComposeName = PrefabComposeName + + local prefab_meta = { __index = defaults } + local any_type = {} + for i = 1,#markers do + local marker = markers[i] + local data = marker.type == "Prefab" and marker.data + if data then + local prefab = dostring(data) + if not prefab then + p_print("Prefab", marker.name, "unserialize props error!") + else + local name = PrefabComposeName(prefab) + if not ExportedPrefabs[name] then + p_print("No exported prefab found ", name, "on", marker.map, "at", marker.pos) + elseif prefabs[name] then + local prev = prefabs[name] + p_print("Duplicated prefabs:\n\t1.", name, "on", marker.map, "at", marker.pos, "\n\t2.", prefabs[prev], "on", prev.marker.map, "at", prev.marker.pos) + else + local ptype = prefab.type or "" + if ptype == "" or PrefabTypeToPreset[ptype] then + setmetatable(prefab, prefab_meta) + prefabs[#prefabs + 1] = prefab + prefabs[name] = prefab + prefabs[prefab] = name + + local type_prefabs = ptype == "" and any_type or type_to_prefabs[ptype] + if type_prefabs then + type_prefabs[#type_prefabs + 1] = prefab + else + type_to_prefabs[prefab.type] = {prefab} + end + if marker.data_version ~= PrefabMarkerVersion then + deprecated = deprecated + 1 + end + local version = prefab.version or 1 + local info = versions[version] or {count = 0} + versions[version] = info + info.count = info.count + 1 + for _, key in ipairs(hash_keys) do + info[key] = xxhash(prefab[key], info[key]) + end + prefab.marker = marker + end + end + end + end + end + for ptype, prefabs in ipairs(type_to_prefabs) do + table.iappend(prefabs, any_type) + end + if deprecated > 0 then + p_print(deprecated, "deprecated prefabs need re-export") + end + if const.RandomMap.PrefabVersionLog then + p_dprint("Prefab marker versions:", TableToLuaCode(versions)) + end + table.sort(prefabs, function(a, b) return prefabs[a] < prefabs[b] end) + PrefabMarkers = prefabs + PrefabDimensions = PrefabCalcStats(prefabs) + PrefabTypeToPrefabs = type_to_prefabs + PrefabTypes = table.keys(type_to_prefabs, true) + + Msg("PrefabMarkersChanged") + DelayedCall(0, ReloadShortcuts) +end + +function PrefabSaveCmp(cmp_version, filename, cmp_fmt, cmp_props) + filename = filename or "cmp.txt" + cmp_props = cmp_props or {"hash", "height_hash", "type_hash", "mask_hash"} + cmp_fmt = cmp_fmt or "%40s | %12s | %12s | %12s | %12s |\n" + local props = {} + local cmp_list = {string.format(cmp_fmt, "name", unpack(cmp_props)), string.rep('-', 120), "\n"} + for i, marker in ipairs(PrefabMarkers) do + if not cmp_version or (marker.version or 1) == cmp_version then + local name = PrefabMarkers[marker] or "" + for i, key in ipairs(cmp_props) do + props[i] = marker[key] or "" + end + cmp_list[#cmp_list + 1] = string.format(cmp_fmt, name, unpack(props)) + end + end + return AsyncStringToFile(filename, cmp_list) +end + +function OnMsg.DataLoaded() + CreateRealTimeThread(function() + PrefabUpdateExported() + PrefabUpdateMarkers() + end) +end + +function PrefabPreload(prefab, params_meta, skip) + local name = PrefabMarkers[prefab] + if not ExportedPrefabs[name] then + p_print("No such exported prefab", name) + return + end + local load_err, height_grid, type_grid, grass_grid, mask_grid, type_remapping, height_op, height_offset + local skip_height = skip and skip.Height + local height_file = not skip_height and prefab.height_hash and GetPrefabFileHeight(name) + if height_file then + height_grid, load_err = GridReadFile(height_file) + if load_err then + p_print("Failed to load height map of", name, ":", load_err or "failed") + return + end + if developer and xxhash(height_grid) ~= prefab.height_hash then + p_print("Detected changes in the height map of", name) + end + height_op = prefab.height_op + height_offset = prefab.height_offset + end + local skip_type = skip and skip.Type + local type_file = not skip_type and prefab.type_hash and GetPrefabFileType(name) + if type_file then + type_grid, load_err = GridReadFile(type_file) + if load_err then + p_print("Failed to load type map of", name, ":", load_err or "failed") + return + end + if developer and xxhash(type_grid) ~= prefab.type_hash then + p_print("Detected changes in the type map of", name) + end + type_remapping = GetTypeRemapping(prefab.type_names) + end + local skip_grass = skip and skip.Grass + local grass_file = not skip_grass and prefab.grass_hash and GetPrefabFileGrass(name) + if grass_file then + grass_grid, load_err = GridReadFile(grass_file) + if load_err then + p_print("Failed to load grass map of", name, ":", load_err or "failed") + return + end + if developer and xxhash(grass_grid) ~= prefab.grass_hash then + p_print("Detected changes in the grass map of", name) + end + end + local mask_file = prefab.mask_hash and GetPrefabFileMask(name) + if mask_file then + mask_grid, load_err = GridReadFile(mask_file) + if load_err then + p_print("Failed to load mask of", name, ":", load_err or "failed") + return + end + if developer and xxhash(mask_grid) ~= prefab.mask_hash then + p_print("Detected changes in the mask of", name) + end + end + local params = { + height_grid = height_grid, + height_op = height_op, + height_offset = height_offset, + type_grid = type_grid, + type_remapping = type_remapping, + grass_grid = grass_grid, + mask_grid = mask_grid, + } + if params_meta then + setmetatable(params, params_meta) + end + return params +end + +function PlacePrefab(name, prefab_pos, prefab_angle, seed, place_params) + if (name or "") == "" or not ExportedPrefabs[name] then + return "no exported prefab found" + end + local filename = GetPrefabFileObjs(name) + local err, bin = AsyncFileToString(filename, nil, nil, "pstr") + if err then + return err + end + local defs = Unserialize(bin) + if not defs then + return "Failed to unserialize objects" + end + + local prefab = PrefabMarkers[name] + if not prefab then + return "no such prefab marker" + end + local raster_params = PrefabPreload(prefab) + if not raster_params then + return "prefab loading failed" + end + + local existing_objs = MapGet(prefab_pos, prefab.max_radius * type_tile, "attached", false, function(obj) + return not IsClutterObj(obj) and GetClassFlags(obj, const.cfCodeRenderable) == 0 + end) or {} + + place_params = place_params or empty_table + + local change_height = not not raster_params.height_grid + local change_type = not not raster_params.type_grid + local change_grass = not not raster_params.grass_grid + + local create_undo = place_params.create_undo + if create_undo then + XEditorUndo:BeginOp{ + name = "PlacePrefab", + height = change_height, + terrain_type = change_type, + grass_density = change_grass, + objects = existing_objs, + } + end + + prefab_angle = (prefab_angle or 0) - (prefab.angle or 0) + + local inv_bbox + if change_height or change_type or change_grass then + raster_params.pos = prefab_pos + raster_params.angle = prefab_angle + raster_params.dither_seed = seed + err, inv_bbox = AsyncGridSetTerrain(raster_params) + if err then + if create_undo then + XEditorUndo:EndOp(existing_objs) + end + return "failed to apply terrain" + elseif inv_bbox then + if change_height then + terrain.InvalidateHeight(inv_bbox) + end + if change_type then + terrain.InvalidateType(inv_bbox) + end + end + end + + local gof = const.gofPermanent | const.gofGenerated + local cofComponentRandomMap = const.cofComponentRandomMap + local g_Classes = g_Classes + local GetPrefabObjPos, SetPrefabObjPos = GetPrefabObjPos, SetPrefabObjPos + local PropObjSetProperty = PropObjSetProperty + local dont_clamp_objects = place_params.dont_clamp_objects + local ignore_ground_offset = place_params.ignore_ground_offset + local fadein = place_params.fadein + local save_collections = prefab.save_collections + local placed_cols = 0 + local remap_col_idx, last_col_idx + local objs = {} + local base_prop_count = const.RandomMap.PrefabBasePropCount + + SuspendPassEdits("PlacePrefab") + + for _, def in ipairs(defs) do + local class, dpos, angle, daxis, + scale, rmf_flags, fade_dist, + ground_offset, normal_offset, + coll_idx, color, mirror = unpack(def, 1, base_prop_count) + + assert(class ~= "Collection") + local class_def = g_Classes[class] + assert(class_def) + + if dont_clamp_objects then + fade_dist = false + end + if ignore_ground_offset then + ground_offset = false + end + + local new_pos, new_angle, new_axis = GetPrefabObjPos( + dpos, angle, daxis, fade_dist, + prefab_pos, prefab_angle, + ground_offset, normal_offset) + + if class_def and new_pos then + local components = 0 + if rmf_flags then + components = components | cofComponentRandomMap + end + local obj = class_def:new(nil, components) + if fadein then + obj:SetOpacity(0) + if fadein ~= -1 then + obj:SetOpacity(100, fadein) + end + end + SetPrefabObjPos(obj, new_pos, new_angle, new_axis, scale, color, mirror) + for i=base_prop_count+1,#def,2 do + PropObjSetProperty(obj, def[i], def[i + 1]) + end + if rmf_flags then + obj:SetRandomMapFlags(rmf_flags) + end + if coll_idx and save_collections then + local placed_idx = remap_col_idx and remap_col_idx[coll_idx] + if not placed_idx then + placed_idx = (last_col_idx or 0) + 1 + local collections = Collections + while collections[placed_idx] do + placed_idx = placed_idx + 1 + end + if placed_idx <= const.GameObjectMaxCollectionIndex then + local col = Collection:new() + col:SetIndex(placed_idx) + col:SetName(string.format("MapGen_%s", placed_idx)) + SetGameFlags(col, gof) + remap_col_idx = table.create_set(remap_col_idx, coll_idx, placed_idx) + else + assert(false, "Too many collections created!") + placed_idx = 0 + end + remap_col_idx[coll_idx] = placed_idx + end + if placed_idx ~= 0 then + obj:SetCollectionIndex(placed_idx) + end + end + SetGameFlags(obj, gof) + objs[#objs + 1] = obj + end + end + + for _, obj in ipairs(objs) do + if obj.__ancestors.Object then + obj:PostLoad() + end + end + if IsEditorActive() then + for _, obj in ipairs(objs) do + if obj.__ancestors.EditorObject then + obj:EditorEnter() + end + end + end + + if change_height then + local ClearCachedZ = CObject.ClearCachedZ + local IsValidZ = CObject.IsValidZ + for _, obj in ipairs(existing_objs) do + if IsValid(obj) and not IsValidZ(obj) then + ClearCachedZ(obj) + end + end + end + + ResumePassEdits("PlacePrefab") + + if create_undo then + table.iappend(existing_objs, objs) + XEditorUndo:EndOp(existing_objs) + end + Msg("PrefabPlaced", name, objs) + return nil, objs, inv_bbox +end + +---- + +RandomMapFlags = { + { id = "IgnoreHeightOffset", name = "Ignore Height Offset", flag = const.rmfNoGroundOffset, help = "Disregard the original terrain height offset when placing the object" }, + { id = "KeepNormalOffset", name = "Keep Normal Offset", flag = const.rmfNormalOffset, help = "Keep the original terrain normal offset when placing the object" }, + { id = "OptionalPlacement", name = "Optional Placement", flag = const.rmfOptionalPlacement, help = "The object could be removed when placing its prefab" }, + { id = "MeshOverlapCheck", name = "Mesh Overlap Check", flag = const.rmfMeshOverlapCheck, help = "Check mesh overlap ratio to detect prefab out-of-bounds objects. Otherwise only the object's position is checked" }, + { id = "DeleteOnSteepSlope", name = "Delete On Steep Slope", flag = const.rmfDeleteOnSteepSlope, help = "Will be deleted if placed on a too steep slope" }, +} + +function GetDefRandomMapFlags(classdef) + local flags = 0 + for _, info in ipairs(RandomMapFlags) do + if classdef[info.id] then + flags = flags | info.flag + end + end + return flags +end + +DefineClass.StripRandomMapProps = { + __parents = { "PropertyObject" }, + properties = {}, +} + +for _, info in ipairs(RandomMapFlags) do + local id = info.id + local flag = info.flag + CObject[id] = false + local prop = { category = "Random Map", id = id, name = info.name, editor = "bool", help = info.help } + table.insert(CObject.properties, prop) + table.insert(StripCObjectProperties.properties, { id = id } ) + table.insert(StripRandomMapProps.properties, { id = id }) + CObject["Set" .. id] = function(self, set) + local flags = self:GetRandomMapFlags() + local def_flags + if not flags then + flags = GetDefRandomMapFlags(self) + def_flags = flags + end + if set then + flags = flags | flag + else + flags = flags & ~flag + end + if flags == def_flags then + return + end + self:SetRandomMapFlags(flags) + end + CObject["Get" .. id] = function(self) + local flags = self:GetRandomMapFlags() + if not flags then + return self[id] + end + return (flags & flag) ~= 0 + end +end + +DefineClass.PrefabSourceInfo = { + __parents = { "Object", "EditorCallbackObject" }, + properties = { + { category = "Random Map", id = "Prefab", name = "Placed From", editor = "text", default = "", read_only = true, developer = true, buttons = {{name = "Goto", func = "GotoPrefabAction"}} }, + }, +} + +function PrefabSourceInfo:EditorCallbackGenerate(generator, object_source) + local prefab = object_source[self] + self.Prefab = prefab and PrefabMarkers[prefab] +end + +if not Platform.developer then + PrefabSourceInfo.SetPrefab = empty_func +end + +AppendClass.FXSource = { + __parents = { "PrefabSourceInfo" }, +} diff --git a/CommonLua/MapGen/PrefabMarkerEdit.lua b/CommonLua/MapGen/PrefabMarkerEdit.lua new file mode 100644 index 0000000000000000000000000000000000000000..176806fd2877cc305f3a45744ecd0a4a398e38cf --- /dev/null +++ b/CommonLua/MapGen/PrefabMarkerEdit.lua @@ -0,0 +1,2021 @@ +if not Platform.editor then + return +end + +if FirstLoad then + l_LockedCol = false + l_ResaveAllMapsThread = false +end + +local height_tile = const.HeightTileSize +local type_tile = const.TypeTileSize +local height_scale = const.TerrainHeightScale +local gofPermanent = const.gofPermanent +local height_roughness_unity = 1000 +local height_roughness_err = 1200 +local height_outline_offset_err = guim +local invalid_type_value = 255 +local GetHeight = terrain.GetHeight + +local mask_max = 255 +local invalid_type_value = 255 +local invalid_grass_value = 255 +local transition_max_pct = 30 +local GetClassFlags = CObject.GetClassFlags +local cfCodeRenderable = const.cfCodeRenderable + +local granularity = GetTerrainGridsMaxGranularity() +local clrNoModifier = SetA(const.clrNoModifier, 255) + +local function get_surface(...) + return Max(GetWalkableZ(...), GetHeight(...)) +end +local function set_surface_z(pt, offset) + return pt:SetZ(get_surface(pt) + (offset or 0)) +end + +function OnMsg.MarkersRebuildStart() + if mapdata.IsPrefabMap then + GridStatsReset() + end + l_LockedCol = Collection.GetLockedCollection() + if l_LockedCol then + l_LockedCol:SetLocked(false) + end +end + +function OnMsg.MarkersRebuildEnd() + if mapdata.IsPrefabMap then + local stat_usage = GridStatsUsage() or "" + if #stat_usage > 0 then + DebugPrint("Grid ops:\n") + DebugPrint(print_format(stat_usage)) + DebugPrint("\n") + end + end +end + +local function PrefabIsResaving() + return IsValidThread(l_ResaveAllMapsThread) +end + +function OnMsg.MarkersChanged() + if PrefabIsResaving() then + return + end + if l_LockedCol then + l_LockedCol:SetLocked(true) + end + l_LockedCol = false + PrefabUpdateMarkers() +end + +local function get_marker_terrains(obj) + local items = {} + local bbox = obj:GetBBox() + local invalid_terrain_idx = GetTerrainTextureIndex(obj.InvalidTerrain) or -1 + local mask = GridGetTerrainMask(bbox, invalid_terrain_idx) + local skipped_terrain_idxs = obj:GetSkippedTextureList() + local _, types = GridGetTerrainType(bbox, mask, invalid_type_value, invalid_terrain_idx, skipped_terrain_idxs) + for _, idx in ipairs(types) do + local texture = TerrainTextures[idx] + if texture then + items[#items + 1] = texture.id + end + end + table.sort(items) + table.insert(items, 1, "") + return items +end +local debug_show_types = { + "capture_box", "radius", "flat_zone", "crit_slope", "roughness", + "height_offset", "height_lims", "missing_types", "transition", + "large_objs", "optional_objs", "collections", +} +local def_show_types = set("capture_box") + +DefineClass.PrefabMarkerEdit = { + __parents = { "InitDone" }, + properties = { + { category = "Checks", id = "CheckObjCount", name = "Objects Count", editor = "bool", default = true, help = "Perform check of the object's density on export" }, + { category = "Checks", id = "CheckObjRadius", name = "Objects Radius", editor = "bool", default = true, help = "Perform check of the object's max radius on export" }, + { category = "Checks", id = "CheckRougness", name = "Height Rougness", editor = "bool", default = true, help = "Perform check of the height map rougness" }, + { category = "Checks", id = "CheckRadiusRatio", name = "Radius Ratio", editor = "bool", default = true, help = "Perform check of the max to min radius ratio" }, + { category = "Checks", id = "CheckVisibility", name = "Objects Visibility", editor = "bool", default = true, help = "Search for invisible or completely transparent objects" }, + + { category = "Stats", id = "ClassToCountStat", name = "Objs by Class", editor = "text", default = "", lines = 1, max_lines = 10, read_only = true, developer = true, dont_save = true }, + { category = "Stats", id = "ClassToCount", editor = "prop_table", default = false, no_edit = true }, + { category = "Stats", id = "ExportTime", name = "Prefab Export (ms)", editor = "number", default = 0, read_only = true, dont_save = true }, + + { category = "Debug", id = "source", name = "Source", editor = "prop_table", default = false, export = "marker", read_only = true, indent = ' ', buttons = {{name = "View", func = "ActionViewSource"}}}, + { category = "Debug", id = "place_mark", name = "Place Mark", editor = "number", default = -1, read_only = true, dont_save = true }, + { category = "Debug", id = "apply_pos", name = "Apply Pos", editor = "point", default = false, read_only = true, dont_save = true }, + { category = "Debug", id = "DebugShow", name = "Debug Show", editor = "set", default = def_show_types, items = debug_show_types, developer = true, dont_save = true, help = "collections and optional_objs cannot be used at the same time"}, + { category = "Debug", id = "ShowType", name = "Show Terrain Type", editor = "set", default = set(), items = get_marker_terrains, developer = true, dont_save = true, buttons = {{name = "Update", func = "ActionShowTypeUpdate"}}, }, + { category = "Debug", id = "OverlayAlpha", name = "Overlay Alpha (%)", editor = "number", default = 30, slider = true, min = 0, max = 100, dont_save = true }, + }, + editor_objects_visible = false, + editor_objects = false, + object_colors = false, + editor_update_thread = false, + editor_update_time = false, + DebugErrorShow = false, +} + +function PrefabMarkerEdit:ActionShowTypeUpdate() + self:DbgShowTypes() +end + +function DebugOverlayControl:SetOverlayAlpha(alpha) + hr.TerrainDebugAlphaPerc = alpha +end + +function DebugOverlayControl:GetOverlayAlpha() + return hr.TerrainDebugAlphaPerc +end + +if FirstLoad then + dbg_common_grid = false +end + +function OnMsg.ChangeMap() + DbgHideTerrainGrid(dbg_common_grid) + dbg_common_grid = false +end + +function DbgUpdateTypesGrid() + local tgrid, dest_grid, type_to_palette, palette, mask, tmp + local last_palette_idx = 1 + local markers = MapGet("map", "PrefabMarker") + for _, marker in ipairs(markers) do + local remap, grid + for tname, show in pairs(IsValid(marker) and marker.ShowType or empty_table) do + local type_idx = show and GetTerrainTextureIndex(tname) + if type_idx then + type_to_palette = type_to_palette or {} + local palette_idx = type_to_palette[type_idx] + if not palette_idx then + palette_idx = last_palette_idx + 1 + last_palette_idx = palette_idx + type_to_palette[type_idx] = palette_idx + palette = palette or {0} + palette[palette_idx] = RandColor(xxhash(tname)) + end + remap = remap or {} + remap[type_idx] = palette_idx + end + end + if remap then + tgrid = tgrid or terrain.GetTypeGrid() + dest_grid = dest_grid or GridDest(tgrid, true) + mask = mask or GridDest(tgrid) + tmp = tmp or GridDest(tgrid) + mask:clear() + GridDrawBox(mask, marker:GetBBox():grow(type_tile / 2) / type_tile, 1) + GridMulDiv(tgrid, tmp, mask, 1) + GridReplace(tmp, remap, 0) + GridAdd(dest_grid, tmp) + end + end + if not dest_grid then + DbgHideTerrainGrid(dbg_common_grid) + dbg_common_grid = false + else + DbgShowTerrainGrid(dest_grid, palette) + dbg_common_grid = dest_grid + end +end + +function PrefabMarkerEdit:DbgShowTypes() + CreateRealTimeThread(DbgUpdateTypesGrid) +end + +local function ApplyObjectColors(obj_colors, apply) + for obj, obj_color in pairs(obj_colors or empty_table) do + local new_clr, orig_clr = table.unpack(obj_color) + if IsValid(obj) then + local prev_clr = obj:GetColorModifier() + if apply and new_clr ~= prev_clr then + obj_color[2] = prev_clr + obj:SetColorModifier(new_clr) + elseif not apply and orig_clr and prev_clr == new_clr then + obj:SetColorModifier(orig_clr) + end + end + end +end + +function PrefabMarkerEdit:EditorObjectsDestroy() + DoneObjects(self.editor_objects ) + ApplyObjectColors(self.object_colors, false) + self.editor_objects = nil + self.object_colors = nil +end + +function PrefabMarkerEdit:PostLoad(reason) + self:EditorObjectsCreate() +end + +function PrefabMarkerEdit:Done() + self:EditorObjectsDestroy() +end + +local function GridExtrem(grid) + local laplacian = { + -8, -11, -8, + -11, 76, -11, + -8, -11, -8, + } + local extrem = GridFilter(grid, laplacian, 76) + GridMulDiv(extrem, height_scale * height_roughness_unity, height_tile) + GridAbs(extrem) + return extrem +end + +local function PrefabEvalPlayableArea(height_map, mask, tile_size, play_zone, border) + local mw, mh = height_map:size() + local flat_zone = GridSlope(height_map, tile_size, height_scale) + local max_play_sin = sin(const.RandomMap.PrefabMaxPlayAngle) + GridMulDiv(flat_zone, 4096, 1) + GridMask(flat_zone, 0, max_play_sin) + if mask then + GridAnd(flat_zone, mask) + end + border = border and (border + tile_size - 1) / tile_size or 0 + if border > 0 then + assert(border >= 0 and 2 * border < Min(mw, mh), "Invalid border size") + GridFrame(flat_zone, border, 0) + end + if play_zone then + play_zone:clear() + end + local play_area = 0 + local min_play_radius = const.RandomMap.PrefabMinPlayRadius + local radius = min_play_radius / tile_size + local min_area = radius * radius * 22 / 7 + local zones = GridEnumZones(flat_zone, min_area) + local level_dist = GridDest(flat_zone) + for i=1,#zones do + local zone = zones[i] + assert(zone.size >= min_area) + GridMask(flat_zone, level_dist, zone.level) + GridDistance(level_dist, tile_size, min_play_radius) + local minv, maxv = GridMinMax(level_dist) + if maxv >= min_play_radius then + play_area = play_area + zone.size + if play_zone then + GridOr(play_zone, level_dist) + end + end + end + return play_area +end + +function PrefabMarkerEdit:EditorObjectsCreate() + if not self:IsValidPos() then + StoreErrorSource("silent", self, "Object on invalid pos!") + return + end + self:EditorObjectsDestroy() + local show = self.DebugShow + if self.DebugErrorShow then + show = table.copy(show) + show[self.DebugErrorShow] = true + end + local objects, obj_colors = {}, {} + local points, colors = {}, {} + local function add_line() + if #(points or "") == 0 then + return + end + local v_pstr = pstr("") + local line = PlaceObject("Polyline") + for i, point in ipairs(points) do + v_pstr:AppendVertex(point, colors[i]) + end + --line:SetDepthTest(true) + line:SetMesh(v_pstr) + line:SetPos(AveragePoint(points)) + objects[#objects + 1] = line + points, colors = {}, {} + end + local function add_vector(pt, vec, color) + vec = vec or 10*guim + if type(vec) == "number" then + vec = point(0, 0, vec) + end + local v_pstr = pstr("") + v_pstr:AppendVertex(pt, color) + v_pstr:AppendVertex(pt + vec) + local line = PlaceObject("Polyline") + line:SetMesh(v_pstr) + line:SetPos(pt) + objects[#objects + 1] = line + end + local function add_circle(center, radius, color) + local circle = PlaceTerrainCircle(center, radius, color) + --circle:SetDepthTest(false) + objects[#objects + 1] = circle + end + local pt1 = self:GetVisualPos() + local x0, y0, z0 = pt1:xyz() + local clr_mod = SetA(self:GetColorModifier(), 255) + local color = clr_mod ~= clrNoModifier and clr_mod or self.ExportError ~= "" and red or editor.IsSelected(self) and cyan or white + local terrain_lines = {} + local angle = self:GetAngle() + local w0, h0 = self.CaptureSize:xy() + local selected = editor.IsSelected(self) + + if show.capture_box and w0 > type_tile and h0 > type_tile then + if angle == 0 then + local box = PlaceBox(box(x0, y0, guim, x0 + w0 - type_tile, y0 + h0 - type_tile, guim), color, nil, "depth test") + box:AddMeshFlags(const.mfTerrainDistorted) + objects[#objects + 1] = box + else + local w = w0 - type_tile + local h = h0 - type_tile + local edges = { + point(-w,-h), + point( w,-h), + point( w, h), + point(-w, h), + } + if not self.Centered then + for i=1,#edges do + edges[i] = edges[i] + point(w0, h0) + end + end + if angle then + for i=1,#edges do + edges[i] = Rotate(edges[i], angle) + end + end + for i=1,#edges do + edges[i] = pt1 + edges[i] / 2 + end + edges[#edges + 1] = edges[1] + for i=1,#edges-1 do + local line = PlaceTerrainLine(edges[i], edges[i + 1], color) + objects[#objects + 1] = line + end + end + end + local minr, maxr = self.RadiusMin, self.RadiusMax * type_tile + if show.radius and maxr > 0 then + local prefab = { + min_radius = self.RadiusMin * type_tile, + max_radius = self.RadiusMax * type_tile, + total_area = self.TotalArea * type_tile * type_tile, + } + local pos = pt1 + Rotate(self.CaptureSize / 2, angle) + local estimators = PrefabRadiusEstimators() + local items = PrefabRadiusEstimItems() + for _, item in ipairs(items) do + local estimator = estimators[item.value] + add_circle(pos, estimator(prefab), item.color) + local r, g, b = GetRGB(item.color) + printf("once", "%s", r, g, b, item.text) + end + end + local function add_box(center, size, color) + local edges = { + point(-1,-1) * size / 2, + point( 1,-1) * size / 2, + point( 1, 1) * size / 2, + point(-1, 1) * size / 2, + } + for i=1,#edges do + local pt = edges[i] + center + edges[i] = set_surface_z(pt, guim/2) + end + local dz = point(0, 0, 2*size) + edges[#edges + 1] = edges[1] + local N = 1 + for i=1,#edges do + points[N] = edges[i] + colors[N] = color + N = N + 1 + end + add_line() + end + local height_map = self.HeightMap + local type_map = self.TypeMap + local mask = self.MaskMap + local msize = mask and mask:size() or 0 + local require_ex = show.flat_zone or show.crit_slope or show.roughness or show.transition or show.height_offset + local height_map_ex = require_ex and height_map and GridRepack(height_map, "F") + local height_offset = self.HeightOffset + if height_map_ex then + GridAdd(height_map_ex, height_offset) + end + local mask_ex = require_ex and mask and GridRepack(mask, "F") + local xc2, yc2 = 2 * x0 + w0 + 1, 2 * y0 + h0 + 1 + local function show_grid(grid, minv, maxv, color0, color1) + local w, h = grid:size() + local min_step = type_tile + local max_step = type_tile * Min(w, h) / 64 + local step = Max(min_step, Min(max_step, type_tile)) + color0 = color0 or red + minv = minv or min_int + maxv = maxv or max_int + local count, max_count = 0, 8*1024 + local data = {} + GridForeach(grid, function(v, x, y) + count = count + 1 + if count >= max_count then + return + end + local px = (xc2 + (2 * x - w + 1) * type_tile) / 2 + local py = (yc2 + (2 * y - h + 1) * type_tile) / 2 + local clr = color1 and InterpolateRGB(color0, color1, v - minv, maxv - minv) or color0 + data[#data + 1] = {point(px, py), clr} + end, minv, maxv, step, type_tile) + if count >= max_count then + print("Debug show cancelled, too much to draw!") + end + for i=1,#data do + local pos, clr = table.unpack(data[i]) + add_box(pos, step - min_step/2, clr) + if count < 100 then + add_vector(set_surface_z(pos), 10*guim, clr) + end + end + end + if show.flat_zone and height_map_ex and mask_ex then + local flat_zone = GridDest(height_map_ex) + PrefabEvalPlayableArea(height_map_ex, mask_ex, type_tile, flat_zone) + show_grid(flat_zone, 0, max_int, green) + end + local hsize = height_map_ex and height_map_ex:size() or 0 + local function grid_to_world(gx, gy) + if not gy then + return point(grid_to_world(gx:xy())) + end + local x = (xc2 + (2 * gx - hsize + 1) * height_tile) / 2 + local y = (yc2 + (2 * gy - hsize + 1) * height_tile) / 2 + return x, y + end + if show.crit_slope and hsize > 0 then + local crit_angle = const.MaxPassableTerrainSlope + local tol_angle = 3*60 + 30 + local mesh = {} + local slope = GridSlope(height_map_ex, height_tile, height_scale) + GridASin(slope, true, 180*60) + GridAdd(slope, -crit_angle) + GridAbs(slope) + show_grid(slope, 0, tol_angle, red, yellow) + end + if show.height_lims and hsize > 0 then + local minv, maxv, minp, maxp = GridMinMax(height_map, true) + minp = grid_to_world(minp):SetZ(minv) + maxp = grid_to_world(maxp):SetZ(maxv) + add_vector(minp, 100*guim, red) + add_vector(maxp, 100*guim, green) + add_circle(minp, 2*guim, red) + add_circle(maxp, 2*guim, green) + end + if show.roughness and hsize > 0 then + local extrem = GridExtrem(height_map_ex, height_tile, height_scale) + show_grid(extrem, height_roughness_err) + end + if show.height_offset and height_map and msize then + local mesh = {} + local outline = GridDest(mask_ex) + GridOutline(mask_ex, outline, true) + GridMulDiv(outline, height_map_ex, 1) + GridAbs(outline) + show_grid(outline, height_outline_offset_err) + end + if show.transition and msize > 0 then + show_grid(mask_ex, 1, 255 - 1, yellow, red) + end + local objs + if show.large_objs then + local obj_max_radius = const.RandomMap.PrefabMaxObjRadius or GetEntityMaxSurfacesRadius() + objs = objs or self:CollectObjs() + for _, obj in ipairs(objs) do + if obj:GetRadius() > obj_max_radius then + local bbox = PlaceBox(ObjectHierarchyBBox(obj), red, nil, "depth test") + objects[#objects + 1] = bbox + StoreErrorSource(obj, "Too large object") + end + end + end + if show.optional_objs then + objs = objs or self:CollectObjs() + for _, obj in ipairs(objs) do + if obj:GetOptionalPlacement() then + obj_colors[obj] = {cyan, 0} + end + end + elseif show.collections then + objs = objs or self:CollectObjs() + local hsb_max = 1020 + local Collections = Collections + local GetCollectionIndex = CObject.GetCollectionIndex + local clrNoModifier = const.clrNoModifier + local topmost_coll, nested_count, parents_count = {}, {}, {} + for _, obj in ipairs(objs) do + local col_idx = GetCollectionIndex(obj) or 0 + if col_idx ~= 0 and not parents_count[col_idx] then + local topmost_idx = col_idx + local parents = 0 + while true do + local topmost = Collections[topmost_idx] + local parent_idx = GetCollectionIndex(topmost) or 0 + if parent_idx == 0 then + break + end + parents = parents + 1 + topmost_idx = parent_idx + end + nested_count[topmost_idx] = Max(nested_count[topmost_idx] or 0, parents) + parents_count[col_idx] = parents + topmost_coll[col_idx] = topmost_idx + end + end + local col_to_color, topmost_colors = {}, {} + for _, obj in ipairs(objs) do + local col_idx = obj:GetCollectionIndex() or 0 + if col_idx ~= 0 then + local color = col_to_color[col_idx] + if not color then + local topmost_idx = topmost_coll[col_idx] + local topmost_color = topmost_colors[topmost_idx] + local nested_max = nested_count[topmost_idx] + if not topmost_color then + local max_dist, h, s + local b = (hsb_max * 2 - hsb_max * Min(4, nested_max) / 4) / 3 + local i = 0 + local rand = BraidRandomCreate(col_idx) + while true do + h, s = rand(hsb_max + 1), rand(hsb_max + 1) + local rand_clr = HSB(h, s, b, hsb_max) + if ColorDist(rand_clr, clrNoModifier) > 100 then + i = i + 1 + if i == 10 then + break + end + local min_dist = max_int + for _, clr in pairs(topmost_colors) do + min_dist = Min(min_dist, ColorDist(clr, rand_clr)) + end + if not max_dist or max_dist < min_dist then + max_dist = min_dist + topmost_color = rand_clr + if max_dist > 100 then + break + end + end + end + end + topmost_colors[topmost_idx] = topmost_color + end + local parents = parents_count[col_idx] + if parents == 0 then + color = topmost_color + else + local h, s, b = RGBtoHSB(topmost_color, hsb_max) + local s1 = (s > hsb_max * 2 / 3) and (hsb_max / 3) or hsb_max + local ds = (s1 - s) * Min(4, nested_max) / 4 + local db = hsb_max - b + s = s + ds * parents / nested_max + b = b + db * parents / nested_max + color = HSB(h, s, b, hsb_max) + end + col_to_color[col_idx] = color + end + obj_colors[obj] = {color, 0} + end + end + end + ApplyObjectColors(obj_colors, true) + self.object_colors = obj_colors + self.editor_objects = objects +end + +function PrefabMarkerEdit:EditorObjectsUpdate() + self:EditorObjectsDestroy() + self.editor_update_time = RealTime() + 30 + if IsValidThread(self.editor_update_thread) then + return + end + self.editor_update_thread = CreateRealTimeThread(function() + while RealTime() < self.editor_update_time do + Sleep(self.editor_update_time - RealTime()) + end + if IsValid(self) then + self:EditorObjectsShow() + end + end) +end + +function PrefabMarkerEdit:EditorObjectsShow(show) + if show == nil then show = IsEditorActive() end + local prev_show = self.editor_objects and self.editor_objects_visible + if prev_show == show then + return + end + self.editor_objects_visible = show + if not self.editor_objects and show then + self:EditorObjectsCreate() + end + for _, object in ipairs(self.editor_objects or empty_table) do + if IsValid(object) then + object:SetVisible(show) + end + end + ApplyObjectColors(self.object_colors, show) + self:SetVisible(show) + if IsValid(self.editor_text_obj) then + self.editor_text_obj:SetVisible(show) + end + ObjModified(self) + PropertyHelpers_Refresh(self) +end + +function PrefabMarkerEdit:EditorEnter() + self:EditorObjectsShow(true) +end + +function PrefabMarkerEdit:EditorExit() + self:EditorObjectsShow(false) +end + +function PrefabMarkerEdit:OnEditorSetProperty(prop_id, old_value) + if prop_id == "DebugShow" then + local show = self.DebugShow + if show.collections and show.optional_objs then + show = table.copy(show) + if old_value.collections then -- collections and optional objects are mutually exclusive + show.collections = nil + else + show.optional_objs = nil + end + self.DebugShow = show + end + self:EditorObjectsUpdate() + end + if prop_id == "Centered" or prop_id == "ColorModifier" then + self:EditorObjectsUpdate() + end + if prop_id == "ShowType" then + self:DbgShowTypes() + elseif prop_id == "CaptureSize" then + local w, h = self.CaptureSize:xy() + if w < 0 then w = max_int end + if h < 0 then h = max_int end + local x, y = self:GetVisualPosXYZ() + w = Min(w, terrain.GetMapWidth() - x) + h = Min(h, terrain.GetMapHeight() - y) + w = (w / granularity) * granularity + h = (h / granularity) * granularity + self.CaptureSize = point(w, h) + self:EditorObjectsUpdate() + elseif prop_id == "CircleMaskRadius" then + self:CaptureTerrain() + end +end + +PrefabMarkerEdit.EditorCallbackPlace = PrefabMarkerEdit.EditorObjectsUpdate +PrefabMarkerEdit.EditorCallbackRotate = PrefabMarkerEdit.EditorObjectsUpdate +PrefabMarkerEdit.EditorCallbackMove = PrefabMarkerEdit.EditorObjectsUpdate + +local function GetMaxNegShape(mask) + GridNot(mask) + local zones = GridEnumZones(mask, 32, max_int, 256) + if #zones == 256 then + return "Too many shapes found" + end + local zone = table.max(zones, "size") + if not zone then + return "No shapes found" + end + GridMask(mask, zone.level) +end + +function PrefabMarkerEdit:GetSkippedTextureList() + local indexes + for _, terrain in ipairs(self.SkippedTerrains or empty_table) do + local idx = GetTerrainTextureIndex(terrain) + if not idx then + StoreErrorSource(self, "Terrain name not found:", terrain) + else + indexes = indexes or {} + indexes[#indexes + 1] = idx + end + end + return indexes or empty_table +end + +function PrefabMarkerEdit:GetBBox() + local bbox + local x, y = self:GetVisualPosXYZ() + local w, h = self.CaptureSize:xy() + if w <= 0 or h <= 0 then + return + end + if self.Centered then + local dx, dy = w / 2, h / 2 + return box(x - dx, y - dy, x + dx, y + dy) + else + return box(x, y, x + w, y + h) + end +end + +function PrefabMarkerEdit:SetBBox(bbox) + local x, y, z = self:GetPosXYZ() + local bw, bh = bbox:size():xy() + local x1, y1 + if self.Centered then + x1, y1 = bbox:Center():xy() + else + x1, y1 = bbox:minxyz() + end + self:SetPos(x1, y1, z) + self.CaptureSize = point(bw, bh) +end + +function PrefabMarkerEdit:CaptureTerrain(shrinked, extended) + local st = GetPreciseTicks() + self:ClearTerrain() + local bbox = self:GetBBox() + if not bbox then + return + end + local abox = bbox:Align(granularity) + if abox ~= bbox then + bbox = abox + self:SetBBox(abox) + end + + local memory = 0 + local x, y, z = self:GetVisualPosXYZ() + local w, h = self.CaptureSize:xy() + local mask + + local invalid_terrain_idx = -1 + if self.InvalidTerrain ~= "" then + invalid_terrain_idx = GetTerrainTextureIndex(self.InvalidTerrain) or -1 + if invalid_terrain_idx == -1 then + StoreErrorSource(self, "Invalid terrain type specified") + end + end + local transition_dist = self:GetTransitionDist() + if self.CircleMask then + mask = GridGetEmptyMask(bbox) + local radius = self.CircleMaskRadius or Min(w, h) / 2 + GridCircleSet(mask, mask_max, w / 2, h / 2, radius, transition_dist, type_tile) + elseif invalid_terrain_idx ~= -1 then + local extend_retries = 0 + local post_process = self.PostProcess or 0 + while true do + mask = GridGetTerrainMask(bbox, invalid_terrain_idx) + if not mask then + return + elseif GridEquals(mask, 0) then + StoreErrorSource(self, "Only invalid terrain found!") + return + elseif not GridFind(mask, 0) then + StoreErrorSource(self, "Invalid terrain not found!") + return + end + if post_process > 0 then + local err = GetMaxNegShape(mask) or GetMaxNegShape(mask) + if err then + StoreErrorSource(self, err) + return + end + end + if extended or post_process < 2 then + break + end + local mw, mh = mask:size() + local minx, miny, maxx, maxy = GridBBox(mask) + if minx > 0 and maxx < mw and miny > 0 and maxy < mh then + if extend_retries == 0 then + break + end + self:SetBBox(bbox) + return self:CaptureTerrain(false, true) + elseif extend_retries > 100 then + StoreErrorSource(self, "Capture size extend error. Adjustment disabled!") + return self:CaptureTerrain(true, true) + end + local bminx, bminy, bmaxx, bmaxy = bbox:xyxy() + if minx <= 0 then bminx = bminx - granularity end + if miny <= 0 then bminy = bminy - granularity end + if maxx >= mw then bmaxx = bmaxx + granularity end + if maxy >= mh then bmaxy = bmaxy + granularity end + bbox = box(bminx, bminy, bmaxx, bmaxy) + assert(bbox == bbox:Align(granularity)) + extend_retries = extend_retries + 1 + end + if not shrinked and post_process > 1 then + local minx, miny, maxx, maxy = GridBBox(mask) + local new_bbox = box( + x + (minx - 1) * type_tile, y + (miny - 1) * type_tile, + x + (maxx + 1) * type_tile, y + (maxy + 1) * type_tile) + new_bbox = new_bbox:Align(granularity) + if new_bbox ~= bbox then + self:SetBBox(new_bbox) + return self:CaptureTerrain(true, true) + end + end + if transition_dist > 0 then + GridNot(mask) + local fmask = GridRepack(mask, "F") + GridDistance(fmask, type_tile, transition_dist, false) -- no approximation, will take longer to compute + GridMulDiv(fmask, mask_max, transition_dist) + GridRound(fmask) + GridRepack(fmask, mask) + fmask:free() + else + GridNormalize(mask, 0, mask_max) + end + end + assert(bbox == bbox:Align(granularity)) + if mask then + self.MaskHash = mask and xxhash(mask) + self.MaskMap = mask + memory = memory + GridGetSizeInBytes(mask) + end + local capture_type = self.CaptureSet + if capture_type.Terrain then + local skipped_terrain_idxs = self:GetSkippedTextureList() + local type_map, types = GridGetTerrainType(bbox, mask, invalid_type_value, invalid_terrain_idx, skipped_terrain_idxs) + if not type_map then + return + end + local type_names + for _, idx in ipairs(types or empty_table) do + local texture = TerrainTextures[idx] + local name = texture and texture.id + if name then + type_names = type_names or {} + type_names[name] = idx + end + end + self.TypeHash = xxhash(type_map) + self.TypeMap = type_map + self.TypeNames = type_names + memory = memory + GridGetSizeInBytes(type_map) + end + --DbgClearVectors() DbgAddTerrainRect(bbox) + if capture_type.Height then + local terrain_z = z / height_scale + local height_map, hmin, hmax = GridGetTerrainHeight(bbox, mask, terrain_z) + if not height_map then + return + end + self.HeightHash = xxhash(height_map) + self.HeightMap = height_map + self.HeightOffset = -terrain_z + self.HeightMin = hmin - point(0, 0, terrain_z) + self.HeightMax = hmax - point(0, 0, terrain_z) + memory = memory + GridGetSizeInBytes(height_map) + end + + if capture_type.Grass then + local grass_map = GridGetTerrainGrass(bbox, mask, invalid_grass_value, self.InvalidGrass) + if not grass_map then + return + end + if not GridEquals(grass_map, 0) then + self.GrassHash = xxhash(grass_map) + self.GrassMap = grass_map + end + memory = memory + GridGetSizeInBytes(grass_map) + end + self.TerrainCaptureTime = GetPreciseTicks() - st + self.RequiredMemory = memory + self:EditorObjectsUpdate() + return bbox +end + +function PrefabMarkerEdit:ActionCaptureTerrain() + self:CaptureTerrain() + ObjModified(self) + PropertyHelpers_Refresh(self) +end + +function PrefabMarkerEdit:ActionClearTerrain() + self:ClearTerrain() + ObjModified(self) + PropertyHelpers_Refresh(self) +end + +function PrefabMarkerEdit:TerrainRectIsCentered() + return self.Centered +end + +function PrefabMarkerEdit:TerrainRectIsEnabled(prop_id) + return prop_id == "CaptureSize" and next(self.CaptureSet) and not self.HeightHash and not self.TypeHash and not self.GrassHash +end + +function PrefabMarkerEdit:OnEditorSelect(selected) + self:EditorObjectsUpdate() +end + +function PrefabMarkerEdit:ClearTerrain() + self.HeightMap = nil + self.HeightHash = nil + self.HeightOffset = nil + self.HeightMin = nil + self.HeightMax = nil + + self.TypeMap = nil + self.TypeHash = nil + self.TypeNames = nil + + self.MaskMap = nil + self.MaskHash = nil + + self.GrassMap = nil + self.GrassHash = nil +end + +function PrefabMarkerEdit:EditorCallbackDelete() + self:EditorObjectsDestroy() + self:DeleteExports() +end + +function PrefabMarkerEdit:CollectObjs(bbox) + bbox = bbox or self:GetBBox() + return bbox and (MapGet(bbox, "attached", false, nil, nil, gofPermanent) or empty_table) or {self} +end + +function PrefabMarkerEdit:ForceEditorMode(set) + if not IsEditorActive() then + return + end + MapForEach(self:GetBBox(), "attached", false, "EditorObject", nil, nil, gofPermanent, function(obj, set, self) + if set then + obj:EditorEnter() + else + obj:EditorExit() + end + end, set, self) +end + +function PrefabMarkerEdit:ActionPrefabExport(root) + local err, objs, defs = self:ExportPrefab() + if err then + print("Prefab", self:GetPrefabName(), "export failed:", err) + else + DebugPrint(TableToLuaCode(defs)) + end +end + +function PrefabMarkerEdit:ActionPrefabRevision() + local info = self:GetRevisionInfo() + if info then + self:ShowMessage(info, "Revision") + end +end + +function PrefabMarkerEdit:ActionExploreTo() + local exported = self.ExportedName or "" + if exported ~= "" and IsFSUnpacked() and ExportedPrefabs[exported] then + local filename = GetPrefabFileObjs(exported) + local dir, file, ext = SplitPath(filename) + AsyncExec("explorer " .. ConvertToOSPath(dir)) + end +end + +local function svn_process(file, prev_hash, new_hash) + if new_hash then + if SvnToAdd then + SvnToAdd[#SvnToAdd + 1] = file + return + end + return SVNAddFile(file) + elseif not new_hash and prev_hash then + if SvnToDel then + SvnToDel[#SvnToDel + 1] = file + return + end + return SVNDeleteFile(file) + end +end + +if FirstLoad then + SvnToAdd = false + SvnToDel = false +end + +function OnMsg.MarkersRebuildStart() + SvnToAdd, SvnToDel = {}, {} +end + +function OnMsg.MarkersRebuildEnd() + SVNAddFile(SvnToAdd) + SVNDeleteFile(SvnToDel) + SvnToAdd, SvnToDel = false, false +end + +local function PrefabToMarkerName(name) + return name and ("Prefab." .. name) or "" +end + +function ShowPrefabMarker(prefab_name, ged) + local marker_name = PrefabToMarkerName(prefab_name) + local marker = Markers[marker_name] + if not marker then + print("No such prefab marker:", prefab_name) + return + end + EditorWaitViewMapObjectByHandle(marker.handle, marker.map, ged) +end + +function GotoPrefabAction(root, obj, prop_id, ged) + local name = obj[prop_id] or "" + if name == "" then + print("No prefab provided") + return + end + ShowPrefabMarker(name, ged) +end + +local function GetMarkerSource(prefab_name) + local marker_props = PrefabMarkers[prefab_name] + local source = marker_props and marker_props.marker + if not source then + local marker_name = PrefabToMarkerName(prefab_name) + source = Markers[marker_name] + end + return source or empty_table +end + +function PrefabMarkerEdit:DeleteExports() + local name = self.ExportedName or "" + if name == "" or not ExportedPrefabs[name] then + return + end + local marker = GetMarkerSource(name) + if marker.handle ~= self.handle or marker.map ~= GetMapName() then + return + end + ExportedPrefabs[name] = nil + SVNDeleteFile{ + GetPrefabFileObjs(name), + GetPrefabFileType(name), + GetPrefabFileGrass(name), + GetPrefabFileHeight(name), + GetPrefabFileMask(name), + } +end + +function PrefabMarkerEdit:DbgShow(what) + self.DebugErrorShow = what +end + +function PrefabMarkerEdit:GetClassToCountStat() + local list = {} + for class,count in pairs(self.ClassToCount or empty_table) do + list[#list+1] = {class = class, count = count} + end + table.sortby_field_descending(list, "count") + for i, entry in ipairs(list) do + list[i] = string.format("%s = %d\n", entry.class, entry.count) + end + return table.concat(list) +end + +function PrefabMarkerEdit:GetCaptureCenter() + return self:GetVisualPos() + Rotate(self.CaptureSize:SetZ(0), self:GetAngle()) / 2 +end + +function PrefabMarkerEdit:ExportPrefab() + self:ForceEditorMode(false) + local err, param1, param2 = self:DoExport() + self:ForceEditorMode(true) + self.ExportError = err + self:EditorObjectsUpdate() + self:EditorTextUpdate() + return err, param1, param2 +end + +local function save_grid(grid, filename, old_hash, new_hash) + if old_hash == new_hash and io.exists(filename) then + return + end + if grid then + local success, err = GridWriteFile(grid, filename, true) + if err then + return err + end + end + svn_process(filename, old_hash, new_hash) +end + +local function DumpObjDiffs(filename, defs, name, bin, hash, prev_hash) + print("DumpObjDiffs", name) + local err, prev_bin = AsyncFileToString(filename, nil, nil, "pstr") + if err then + print("-", err) + return + end + if prev_bin == bin then + print("- same binary!!!") + return + end + local prev_defs = Unserialize(prev_bin) + assert(prev_defs) + if not prev_defs then + return + end + print("prev_defs", #prev_defs, "prev_bin", #prev_bin, "prev_hash", prev_hash) + print(" new_defs", #defs, " new_bin", #bin, " new_hash", hash) + if #prev_defs ~= #defs then + print("- defs count changed") + return + end + local total = 0 + for i, def in ipairs(defs) do + local prev_def = prev_defs[i] + local found + for j, v in ipairs(def) do + local prev_v = prev_def[j] + if prev_v ~= v then + if found then + print("- def", i) + end + print("- -", j, prev_v, "-->", v) + total = total + 1 + end + end + end + if total == 0 then + print("- no diff found!!!") + end +end + +function PrefabMarkerEdit:DoExport(name) + self.ExportTime = nil + local start_time = GetPreciseTicks() + self:DbgShow() + if not IsFSUnpacked() then + return "unpacked sources required" + end + if self.PrefabType ~= "" and not PrefabTypeToPreset[self.PrefabType] then + return "no such prefab type" + end + if self.PoiType ~= "" then + local poi_preset = PrefabPoiToPreset[self.PoiType] + if not poi_preset then + return "no such POI type" + end + local poi_areas = poi_preset.PrefabTypeGroups or empty_table + if #poi_areas > 0 then + if self.PoiArea == "" or not table.find(poi_areas, "id", self.PoiArea) then + return "missing POI area" + end + end + end + + name = name or self:GetPrefabName() + if #name == 0 then + return "no name" + end + local marker_name = PrefabToMarkerName(name) + local marker = Markers[marker_name] + if marker and (marker.handle ~= self.handle or marker.map ~= GetMapName()) then + return "duplicated prefab", marker.map, marker.pos + end + + if self:GetGameFlags(gofPermanent) == 0 then + return "invalid prefab" + end + local old_height_hash = self.HeightHash + local old_type_hash = self.TypeHash + local old_grass_hash = self.GrassHash + local old_mask_hash = self.MaskHash + + self:SetAngle(0) + self:SetInvalidZ() + local bbox, adjusted = self:CaptureTerrain() + if not bbox then + return "capture failed" + end + local grass_map = self.GrassMap + local height_map = self.HeightMap + local type_map = self.TypeMap + if type_map then + local valid_types = GridDest(type_map) + GridReplace(type_map, valid_types, invalid_type_value, 0) + GridMask(valid_types, 0, MaxTerrainTextureIdx()) + if not GridEquals(valid_types, 1) then + self:DbgShow("missing_types") + return "unknown terrain types detected" + end + end + + local mask = self.MaskMap + if (height_map or type_map) and not mask then + return "Prefab mask unavailable" + end + + self.PlayArea = nil + self.HeightRougness = nil + if height_map then + local height_offset = self.HeightOffset + local height_map_ex = GridRepack(height_map, "F") + local mask_ex = mask and GridRepack(mask, "F") + GridAdd(height_map_ex, height_offset) + local extrem = GridExtrem(height_map_ex, height_tile, height_scale) + local mine, maxe = GridMinMax(extrem) + self.HeightRougness = maxe + if maxe > height_roughness_err and self.CheckRougness then + self:DbgShow("roughness") + return "height map too rough!", maxe, height_roughness_err + end + self.PlayArea = PrefabEvalPlayableArea(height_map_ex, mask_ex, type_tile) + local outline + if not mask_ex then + outline = GridDest(height_map_ex, true) + GridFrame(outline, 1, 1) + elseif self:GetTransitionDist() == 0 then + outline = GridDest(mask_ex) + GridOutline(mask_ex, outline, true) + end + if outline then + GridMulDiv(outline, height_map_ex, 1) + local minh, maxh = GridMinMax(outline) + if maxh > height_outline_offset_err then + self:DbgShow("height_offset") + return "height offset found at the prefab border", maxh, height_outline_offset_err + end + end + end + + self.TotalArea = nil + self.RadiusMin = nil + self.RadiusMax = nil + local total_area + if mask then + local bmask = GridDest(mask) + local mw, mh = mask:size() + local gcenter = point(mw / 2, mh / 2) + GridNot(mask, bmask) + local minx, miny, maxx, maxy = GridMinMaxDist(bmask, gcenter) + local radius_min = gcenter:Dist2D(minx, miny) + GridNot(bmask) + local minx, miny, maxx, maxy = GridMinMaxDist(bmask, gcenter) + local radius_max = gcenter:Dist2D(maxx, maxy) + assert(radius_min <= radius_max) + total_area = GridCount(mask, 0, max_int) + self.TotalArea = total_area + self.RadiusMin = radius_min + self.RadiusMax = radius_max + if self.CheckRadiusRatio and (2 * radius_min < radius_max) then + self:DbgShow("radius") + return "max to min radius ratio is too big", radius_max, radius_min + end + end + + local new_height_hash = self.HeightHash + local new_type_hash = self.TypeHash + local new_grass_hash = self.GrassHash + local new_mask_hash = self.MaskHash + + local IsOptional = CObject.GetOptionalPlacement + local objs = self:CollectObjs(bbox) + + local rmin, rmax, rsum, rcount = max_int, 0, 0, 0 + local efCollision = const.efCollision + local efVisible = const.efVisible + local HasAnySurfaces = HasAnySurfaces + local HasMeshWithCollisionMask = HasMeshWithCollisionMask + local GetEnumFlags = CObject.GetEnumFlags + local GetClassEnumFlags = GetClassEnumFlags + local class_to_count = {} + for i=#objs,1,-1 do + local obj = objs[i] + assert(GetClassFlags(obj, cfCodeRenderable) == 0) + local class = obj.class + local obj_err = obj:GetError() + if obj_err then + StoreErrorSource(obj, obj_err) + return "object with errors" + end + if obj.__ancestors.PrefabObj then + table.remove(objs, i) + else + class_to_count[class] = (class_to_count[class] or 0) + 1 + if GetEnumFlags(obj, efCollision) ~= 0 and not HasCollisions(obj) then + obj:ClearEnumFlags(efCollision) + print("Removed collision flags for", obj.class, "at", obj:GetPos(), "in", name) + end + if self.CheckVisibility then + if obj:GetEnumFlags(efVisible) == 0 and GetClassEnumFlags(obj, efVisible) ~= 0 then + StoreErrorSource(obj, "Invisible object") + return "invisible objects detected" + elseif obj:GetOpacity() == 0 then + StoreErrorSource(obj, "Transparent object") + return "transparent objects detected" + end + end + local r = obj:GetRadius() + if r > 0 then + rmin = Min(rmin, r) + rmax = Max(rmax, r) + rsum = rsum + r + rcount = rcount + 1 + end + end + end + self.ObjRadiusMin = rmin + self.ObjRadiusMax = rmax + self.ObjRadiusAvg = rcount > 0 and (rsum / rcount) or 0 + self.ClassToCount = next(class_to_count) and class_to_count + self.HeightMap = height_map + self.TypeMap = type_map + self.GrassMap = grass_map + self.MaskMap = mask + + local obj_max_radius = const.RandomMap.PrefabMaxObjRadius or GetEntityMaxSurfacesRadius() + if self.CheckObjRadius and rmax > obj_max_radius then + self:DbgShow("large_objs") + return "too large objects detected", rmax, obj_max_radius + end + + local nObjs2x = 0 + for _,obj in ipairs(objs) do + if not IsOptional(obj) then + nObjs2x = nObjs2x + 2 + else + nObjs2x = nObjs2x + 1 + end + end + self.ObjCount = nObjs2x/2 + self.ObjMaxCount = nil + if total_area then + local obj_avg_radius = const.RandomMap.PrefabAvgObjRadius + local max_objs = MulDivRound(total_area, type_tile * type_tile * 7, obj_avg_radius * obj_avg_radius * 22) + self.ObjMaxCount = max_objs + if self.CheckObjCount and self.ObjCount > max_objs then + return "too many objects", self.ObjCount, max_objs + end + end + + local center = self:GetCaptureCenter() + local prev_hash = self.ExportedHash + local prev_name = self.ExportedName or "" + local prev_rev = self.AssetsRevision + self.ExportedHash = nil + self.ExportedName = nil + self.RequiredMemory = nil + self.AssetsRevision = nil + + local table_find = table.find + local Collections = Collections + local GetCollectionIndex = CObject.GetCollectionIndex + + local coll_idx_found, optional_objs = {}, {} + for _, obj in ipairs(objs) do + local col_idx = GetCollectionIndex(obj) + if col_idx ~= 0 then + if not table_find(coll_idx_found, col_idx) then + coll_idx_found[#coll_idx_found + 1] = col_idx + end + if IsOptional(obj) then + optional_objs[col_idx] = (optional_objs[col_idx] or 0) + 1 + end + end + end + + local nested_colls + local function ProcessCollection(col_idx) + local col = Collections[col_idx] + if not col then + return + end + local parent_idx = GetCollectionIndex(col) + if parent_idx == 0 then + return + end + nested_colls = nested_colls or {} + local subcolls = nested_colls[parent_idx] + if not subcolls then + nested_colls[parent_idx] = { col_idx } + elseif not table_find(subcolls, col_idx) then + subcolls[#subcolls + 1] = col_idx + else + return + end + return ProcessCollection(parent_idx) + end + for _, col_idx in ipairs(coll_idx_found) do + ProcessCollection(col_idx) + end + local function GetNestedOptionalObjs(col_idx) + local count = optional_objs[col_idx] or 0 + for _, sub_col_idx in ipairs(nested_colls and nested_colls[col_idx]) do + count = count + GetNestedOptionalObjs(sub_col_idx) + end + return count + end + local nested_opt_objs + for _, col_idx in ipairs(coll_idx_found) do + local count = GetNestedOptionalObjs(col_idx) + if count > 0 then + nested_opt_objs = nested_opt_objs or {} + nested_opt_objs[col_idx] = count + end + end + + self.NestedColls = nested_colls + self.NestedOptObjs = nested_opt_objs + + local class_to_defaults = {} + local prop_eval = prop_eval + local GetClassValue = GetClassValue + local GetDefRandomMapFlags = GetDefRandomMapFlags + local ListPrefabObjProps = ListPrefabObjProps + + local ignore_props = { + CollectionIndex = true, + Pos = true, Axis = true, Angle = true, + ColorModifier = true, Scale = true, + Entity = true, Mirrored = true, + } + for _, info in ipairs(RandomMapFlags) do + ignore_props[info.id] = true + end + + local defs = {} + local base_prop_count = const.RandomMap.PrefabBasePropCount + for i, obj in ipairs(objs) do + local class = obj.class + local props = obj:GetProperties() + local default_props = class_to_defaults[class] + if not default_props then + default_props = {} + class_to_defaults[class] = default_props + local get_default = obj.GetDefaultPropertyValue + for _, prop in ipairs(props) do + local id = prop.id + if not ignore_props[id] and not prop_eval(prop.dont_save, obj, prop) and prop_eval(prop.editor, obj, prop) then + default_props[id] = get_default(obj, id, prop) + end + end + end + + local def_rmf = GetDefRandomMapFlags(obj) + local def_entity = GetClassValue(obj, "entity") or class + + local dpos, angle, daxis, coll_idx, + scale, color, entity, mirror, fade_dist, + rmf_flags, ground_offset, normal_offset = ListPrefabObjProps(obj, center, def_rmf, def_entity, obj.prefab_no_fade_clamp) + + local def = { + class, dpos, angle, daxis, scale, rmf_flags, fade_dist, + ground_offset, normal_offset, coll_idx, color, mirror + } + local count = #def + assert(count == base_prop_count) + local prop_get = obj.GetProperty + for _, prop in ipairs(props) do + local id = prop.id + local default_value = default_props[id] + if default_value ~= nil then + local value = prop_get(obj, id) + if value ~= nil and value ~= default_value then + assert(value == "" or not IsT(value) and not ObjectClass(value)) + count = count + 2 + def[count - 1] = id + def[count] = value + end + end + end + while not def[count] do + def[count] = nil + count = count - 1 + end + defs[i] = def + end + local bin, err = SerializePstr(defs) + if not bin then + return err + end + if FindSerializeError(bin, defs) then + return "Objects serialization mismatch" + end + + local new_hash = xxhash(bin) + local filename = GetPrefabFileObjs(name) + if prev_hash ~= new_hash or not io.exists(filename) then + --DumpObjDiffs(filename, defs, name, bin, new_hash, prev_hash) + local err = AsyncStringToFile(filename, bin) + if err then + return "Failed to save prefab", filename, err + end + svn_process(filename, prev_hash, new_hash) + end + + local filename_height = GetPrefabFileHeight(name) + local err = save_grid(height_map, filename_height, old_height_hash, new_height_hash) + if err then + return "Failed to save grid", filename_height, err + end + + local filename_type = GetPrefabFileType(name) + local err = save_grid(type_map, filename_type, old_type_hash, new_type_hash) + if err then + return "Failed to save grid", filename_type, err + end + + local filename_grass = GetPrefabFileGrass(name) + local err = save_grid(grass_map, filename_grass, old_grass_hash, new_grass_hash) + if err then + return "Failed to save grid", filename_grass, err + end + + local filename_mask = GetPrefabFileMask(name) + local err = save_grid(mask, filename_mask, old_mask_hash, new_mask_hash) + if err then + return "Failed to save grid", filename_mask, err + end + + ExportedPrefabs[name] = true + if prev_name ~= name then + self:DeleteExports() + end + self.ExportedName = name + self.ExportedHash = new_hash + self.AssetsRevision = prev_name == name and prev_rev or AssetsRevision + self.ExportTime = GetPreciseTicks() - start_time + return nil, objs, defs +end + +function PrefabMarkerEdit:ActionViewSource(root) + if self.source then + ViewMarker(root, self.source) + end +end + +function PrefabMarkerEdit:GetRevisionInfo() + if ExportedPrefabs[self.ExportedName] then return end + return SVNLocalRevInfo(GetPrefabFileObjs(self.ExportedName)) +end + +local function GetPrefabVersion(map_name) + map_name = map_name or GetMapName() + if string.find_lower(map_name, "gameplay") then + return 0 + end + local version_str = map_name and string.match(map_name, "_[Vv](%d+)$") + return version_str and tonumber(version_str) or 1 +end + +function PrefabMarker:CreateMarker() + assert(self:GetGameFlags(gofPermanent) ~= 0) + local err, param1, param2 = self:ExportPrefab() + if err then + StoreErrorSource("silent", self, "Failed to export", self:GetPrefabName(), ":", err, param1, param2) + return false, err + end + local map_name = GetMapName() + local version = GetPrefabVersion(map_name) + local props = { "name", name = self.MarkerName, } + local get_prop = self.GetProperty + local get_default = self.GetDefaultPropertyValue + for _, prop in ipairs(self:GetProperties()) do + local export_id = prop.export + if export_id then + local prop_id = prop.id + local value = get_prop(self, prop_id) + if value ~= get_default(self, prop_id, prop) then + props[export_id] = value + props[#props + 1] = export_id + end + end + end + if mapdata.LockMarkerChanges then + local err = self:CheckCompatibility(props) + if err then + StoreErrorSource("silent", self, "Prefab", self:GetPrefabName(), "compatibility error:", err) + return false, err + end + return + end + local data_concat = {"return {"} + local tmp_concat = {"", "=", "", ","} + for _, id in ipairs(props) do + tmp_concat[1] = id + tmp_concat[3] = ValueToLuaCode(props[id], ' ') + data_concat[#data_concat + 1] = table.concat(tmp_concat) + end + data_concat[#data_concat + 1] = "}" + local data_str = table.concat(data_concat) + local marker = PlaceObject('Marker', { + name = PrefabToMarkerName(self.ExportedName), + type = "Prefab", + handle = self.handle, + pos = self:GetPos(), + map = map_name, + data = data_str, + data_version = PrefabMarkerVersion, + }) + self.source = { + handle = self.handle, + map = map_name, + } + return marker +end + +function PrefabMarkerEdit:CheckCompatibility(new_props) + local marker_name = PrefabToMarkerName(self.ExportedName) + local marker = Markers[marker_name] + if not marker then + return "Missing marker" + end + local data = marker.type == "Prefab" and marker.data + local props = data and dostring(data) + if not props then + return "Unserialize props error" + end + local max_pct_err = 5 + local to_check = {} + for _, prop in ipairs(self:GetProperties()) do + local export_id = prop.export + if export_id and prop.compatibility then + to_check[#to_check + 1] = export_id + end + end + for _, prop in ipairs(to_check) do + local value = props[prop] + local new_value = new_props[prop] + if new_value ~= value then + local ptype = type(new_value) + if ptype ~= type(value) then + return "Changed type of prop " .. prop + end + if ptype == "number" then + if abs(new_value - value) > MulDivRound(value, max_pct_err, 100) then + return "Too big difference in value of prop " .. prop + end + elseif IsPoint(value) then + if new_value:Dist(value) > MulDivRound(value:Len(), max_pct_err, 100) then + return "Too big difference in value of prop " .. prop + end + elseif not compare(value, new_value) then + return "Changed value of prop " .. prop + end + end + end +end + +---- + +function OnMsg.PreSaveMap() + local prefabs = MapCount("map", "PrefabMarker", nil, nil, gofPermanent) + if mapdata.IsPrefabMap then + if prefabs == 0 then + mapdata.IsPrefabMap = false + print("This map is no more a prefab map.") + else + MapClearGameFlags(gofPermanent, "map", "PropertyHelper", "CameraObj") + end + else + if prefabs ~= 0 then + mapdata.IsPrefabMap = true + print("This map is now declared as a prefab map.") + end + end + if mapdata.IsPrefabMap then + SaveTerrainWaterObjArea() + MapForEach("map", "PrefabMarker", function(prefab) + prefab:EditorObjectsShow(false) + end) + end +end + +function OnMsg.PostSaveMap() + if mapdata.IsPrefabMap then + MapForEach("map", "PrefabMarker", function(prefab) + prefab:EditorObjectsShow() + end) + end +end + +---- + +AppendClass.PrefabMarker = { + __parents = { "PrefabMarkerEdit" }, + editor_text_depth_test = false, +} + +function PrefabMarker:EditorGetText(line_separator) + local name = self:GetPrefabName() + line_separator = line_separator or "\n" + if self.ExportError ~= "" then + name = name .. line_separator .. "Error: " .. self.ExportError + elseif self.PoiType ~= "" then + name = name .. line_separator .. self.PoiType + if self.PoiArea ~= "" then + name = name .. "." .. self.PoiArea + end + end + return name +end + +function PrefabMarker:EditorGetTextColor() + if self.ExportError ~= "" then + return red + end + local poi_preset = self.PoiType ~= "" and PrefabPoiToPreset[self.PoiType] + if poi_preset then + return poi_preset.OverlayColor or RandColor(xxhash(self.PoiType)) + end + return EditorTextObject.EditorGetTextColor(self) +end + +---- + +function ResaveAllPrefabs(version) + if IsValidThread(l_ResaveAllMapsThread) then + return + end + l_ResaveAllMapsThread = CreateRealTimeThread(function() + local start_time = GetPreciseTicks() + if not version then + local err, files = AsyncListFiles("Prefabs", "*") + if #files > 0 then + print("Deleting all prefabs...") + for i=1,#files do + local success, err = os.remove(files[i]) + if err then + print("Error", err, "deleting prefab file", files[i]) + end + end + end + ExportedPrefabs = {} + end + print("Resaving maps...") + local prefab_maps = {} + for map, data in pairs(MapData) do + if data.IsPrefabMap then + prefab_maps[map] = true + end + end + for i = 1,#Markers do + local marker = Markers[i] + if marker.type == "Prefab" and MapData[marker.map] then + prefab_maps[marker.map] = true + end + end + + if version then + for map in pairs(prefab_maps) do + if version ~= GetPrefabVersion(map) then + prefab_maps[map] = nil + end + end + end + prefab_maps = table.keys(prefab_maps, true) + + LoadingScreenOpen("idLoadingScreen", "ResaveAllPrefabs") + EditorActivate() + ForEachMap(prefab_maps, function() + print("Resaving map ", GetMap()) + SaveMap("no backup") + end) + LoadingScreenClose("idLoadingScreen", "ResaveAllPrefabs") + + l_ResaveAllMapsThread = false + PrefabUpdateMarkers() + + print("Resaving all prefabs complete in", DivRound(GetPreciseTicks() - start_time, 1000), "sec") + end) +end + +function ResaveAllGameMaps(filter) + if IsValidThread(l_ResaveAllMapsThread) then + return + end + l_ResaveAllMapsThread = CreateRealTimeThread(function() + local start_time = GetPreciseTicks() + print("Resaving maps...") + local maps = GetAllGameMaps() + EditorActivate() + for _, map in ipairs(maps) do + local data = MapData[map] + if not filter or filter(map, data) then + local logic + if not config.ResaveAllGameMapsKeepsGameLogic then + logic = data.GameLogic + data.GameLogic = false -- avoid starting game stuff that could spawn objects / modify the map + end + print("Resaving map ", map) + ChangeMap(map) + if not config.ResaveAllGameMapsKeepsGameLogic then + data.GameLogic = logic + else + Msg("GameEnterEditor") + end + if not filter or filter(map, data, "loaded check") then + SaveMap("no backup") + end + end + end + + l_ResaveAllMapsThread = false + print("Resaving", #maps, "maps complete in", DivRound(GetPreciseTicks() - start_time, 1000), "sec") + end) +end + +function RegenerateMap(map, reload_on_finish) + map = map or GetMapName() or "" + map = GetOrigMapName(map) + if map == "" then + return + end + if not IsValidThread(l_ResaveAllMapsThread) then + l_ResaveAllMapsThread = CreateRealTimeThread(RegenerateMap, map, reload_on_finish) + return + elseif l_ResaveAllMapsThread ~= CurrentThread() then + return + end + local data = MapData[map] + if not data or not data.IsRandomMap then + print("Not a random map") + return + end + local active = IsEditorActive() + if not active then + EditorActivate() + end + print("Regenerating map", map, "...") + local logic = data.GameLogic + if logic then + data.GameLogic = false -- avoid starting game stuff that could spawn objects / modify the map + local st = GetPreciseTicks() + ChangeMap(map) + printf("Map reloaded without game logic in %.3f s", (GetPreciseTicks() - st) * 0.001) + Sleep(1) + end + SetGameSpeed("pause") + assert(mapdata == data) + assert(not data.GameLogic) + local st = GetPreciseTicks() + Presets.MapGen.Default.BiomeCreator:Run() + printf("Map gen finished in %.3f s", (GetPreciseTicks() - st) * 0.001) + Sleep(1) + data.GameLogic = logic + SaveMap("no backup") + Sleep(1) + if not active then + EditorDeactivate() + end + if logic and reload_on_finish then + local st = GetPreciseTicks() + ChangeMap(map) + printf("Map reloaded with game logic in %.3f s", (GetPreciseTicks() - st) * 0.001) + Sleep(1) + end +end + +function GetRandomMaps(filter, ...) + local maps = {} + for id, map_data in pairs(MapData) do + if map_data.IsRandomMap and GameMapFilter(id, map_data) and (not filter or filter(id, map_data, ...)) then + maps[#maps + 1] = id + end + end + table.sort(maps) + return maps +end + +function TimeToHHMMSS(ms) + local sec = DivRound(ms, 1000) + local hours = sec / (60 * 60) + sec = sec - hours * (60 * 60) + local mins = sec / 60 + sec = sec - mins * 60 + return string.format("%02d:%02d:%02d", hours, mins, sec) +end + +function RegenerateRandomMaps(maps) + if IsValidThread(l_ResaveAllMapsThread) then + return + end + l_ResaveAllMapsThread = CreateRealTimeThread(function() + local start_time = GetPreciseTicks() + local old_ignoreerrors = IgnoreDebugErrors(true) + print("Regenerating maps...") + maps = maps or GetRandomMaps() + local active = IsEditorActive() + if not active then + EditorActivate() + end + for i, map in ipairs(maps) do + printf("Regenerating map %d / %d...", i, #maps) + local success, err = sprocall(RegenerateMap, map) + if not success then + print("Critical error for", map, err) + end + end + print("Regenerating", #maps, "maps complete in", TimeToHHMMSS(GetPreciseTicks() - start_time)) + + if not active then + EditorDeactivate() + end + ChangeMap("") + IgnoreDebugErrors(old_ignoreerrors) + l_ResaveAllMapsThread = false + end) +end + +function GetRegenerateMapLists() + return GatherMsgItems("GatherRegenerateMapLists") +end + +function RegenerateMapList(list_name) + local maps = GetRegenerateMapLists()[list_name] + if maps then + RegenerateRandomMaps(maps) + end +end + +local function GetFilesHashes(path) + local hashes = { } + for _, file in ipairs(io.listfiles(path)) do + local err, hash = AsyncFileToString(file, nil, nil, "hash") + if err then + GameTestsError("Failed to open " .. file .. " for " .. map .. " due to err: " .. err) + return + end + hashes[file] = hash + end + return hashes +end + +TestNightlyPrefabMethods = {} +function TestNightlyPrefabMethods.TestDoesPrefabMapSavingGenerateFakeDeltas(map, result) + SaveMap("no backup") + local path = "svnAssets/Source/Maps/" .. map .. "/" + local hashes_before = GetFilesHashes(path) + SaveMap("no backup") + local hashes_after = GetFilesHashes(path) + for file, hash in pairs(hashes_before or empty_table) do + if hash ~= hashes_after[file] then + result["fake deltas"] = result["fake deltas"] or { + err = "Resaving prefab maps produced differences!", + texts = {} + } + table.insert(result["fake deltas"].texts, map .. ": difference in " .. file) + end + end +end + +function GameTestsNightly.TestPrefabMaps() + WaitSaveGameDone() + StopAutosaveThread() + table.change(config, "TestPrefabMaps", { + AutosaveSuspended = true, + }) + local thread = CreateRealTimeThread(function() + WaitDataLoaded() + if not IsEditorActive() then + EditorActivate() + end + local test_times = { } + local result = { } + for map, data in sorted_pairs(MapData) do + if data.IsPrefabMap then + assert(not data.GameLogic) + GameTestsPrint("Testing map", map) + ChangeMap(map) + if GetMapName() ~= map then + GameTestsError("Failed to change map to " .. map .. "! ") + return + end + for method_name, method in sorted_pairs(TestNightlyPrefabMethods) do + local start = GetPreciseTicks() + method(map, result) + test_times[method_name] = (test_times[method_name] or 0) + (GetPreciseTicks() - start) + end + end + end + if IsEditorActive() then + EditorDeactivate() + end + for _, res in sorted_pairs(result) do + GameTestsError(res.err) + for _, text in ipairs(res.texts) do + GameTestsPrint(text) + end + end + for method_name, time in sorted_pairs(test_times) do + GameTestsPrint(method_name, "took", time, "ms") + end + Msg(CurrentThread()) + end) + while IsValidThread(thread) do + WaitMsg(thread, 1000) + end + table.restore(config, "TestPrefabMaps") +end + diff --git a/CommonLua/MapGen/PrefabPOI.lua b/CommonLua/MapGen/PrefabPOI.lua new file mode 100644 index 0000000000000000000000000000000000000000..9105ddf54c308288fd85623c48e3187b1ed2b25f --- /dev/null +++ b/CommonLua/MapGen/PrefabPOI.lua @@ -0,0 +1,96 @@ +DefineClass.PrefabPOI = { + __parents = { "Preset", }, + properties = { + { category = "General", id = "PlaceModel", name = "Placement Model", editor = "choice", default = "", items = { "", "terrain", "spawn", "point" }}, + { category = "General", id = "RadiusEstim", name = "Radius Estimate", editor = "choice", default = "excircle", items = function() return PrefabRadiusEstimItems() end }, + { category = "General", id = "FillRadius", name = "Fill Radius", editor = "number", default = 0, min = 0, step = const.HeightTileSize, scale = "m", help = "Fill holes in the placement area to make the fitting of large prefabs easier" }, + { category = "General", id = "MinCount", name = "Min Count", editor = "number", default = 0, help = "An error will be shown if the placed prefabs are less" }, + { category = "General", id = "MaxCount", name = "Max Count", editor = "number", default = -1, help = "The maximum allowed prefabs form that POI type" }, + { category = "General", id = "Tags", name = "Tags", editor = "set", default = empty_table, items = function() return PrefabTagsCombo() end, help = "Keywords used to define similar POI characteristics" }, + { category = "General", id = "TagDist", name = "Dist To Tags", editor = "text", default = "", lines = 1, max_lines = 10, dont_save = true, read_only = true }, + { category = "General", id = "DistToPlayable", name = "Dist To Playable Area", editor = "number", default = 0, scale = "m", help = "Limit placement distance to the playable area. Positive numbers are for the map border direction, while negative numbers are for the map center direction" }, + { category = "General", id = "DistToSame", name = "Dist to Same", editor = "number", default = 0, min = 0, scale = "m", help = "Min distance between the bounderies of POI prefabs of the same type", no_edit = function(self) return self.PoiType == "" end }, + { category = "General", id = "TerrainSlopeMin", name = "Min Terrain Slope", editor = "number", default = 0, scale = "deg", help = "Limit placement by terrain slope" }, + { category = "General", id = "TerrainSlopeMax", name = "Max Terrain Slope", editor = "number", default = 90*60, scale = "deg", help = "Limit placement by terrain slope" }, + { category = "General", id = "PrefabTypeGroups", name = "Prefab Type Areas", editor = "nested_list", default = empty_table, base_class = "PrefabTypeGroup", help = "Disregard the prefab type and use type groups (areas)." }, + { category = "General", id = "CustomTypes", name = "Custom Prefab Types", editor = "preset_id_list", default = empty_table, preset_class = "PrefabType", help = "(deprecated) Disregard the POI prefab types and use a custom list for all", }, + + { category = "Editor", id = "OverlayColor", name = "Overlay Color", editor = "color", default = false, alpha = false }, + }, + EditorMenubarName = "Prefab POI", + EditorMenubar = "Map.Generate", + EditorIcon = "CommonAssets/UI/Icons/puzzle.png", + EditorView = Untranslated(" "), + + StoreAsTable = false, + GlobalMap = "PrefabPoiToPreset", + HasSortKey = true, +} + +function PrefabPOI:GetTagDist() + local type_tile = const.TypeTileSize + local min_dist_to, max_dist_to = {}, {} + local tag_to_tag_limits = GetPrefabTagsLimits() + for poi_tag in pairs(self.Tags) do + for tag, limits in pairs(tag_to_tag_limits[poi_tag]) do + local min_dist, max_dist = limits[1] or min_int, limits[2] or max_int + if min_dist >= 0 then + min_dist_to[tag] = Max(min_dist_to[tag] or 0, min_dist) + end + if max_dist < max_int then + max_dist_to[tag] = Min(max_dist_to[tag] or max_int, max_dist) + end + end + end + local text = {} + for tag, dist in sorted_pairs(min_dist_to) do + text[#text + 1] = string.format("%s > %.1f m", tag, 1.0 * dist / guim) + end + for tag, dist in sorted_pairs(max_dist_to) do + text[#text + 1] = string.format("%s < %.1f m", tag, 1.0 * dist / guim) + end + return table.concat(text, "\n") +end + +function PrefabPOI:GetError() + if self.PlaceModel == "" then + return "Placement model must be specified" + end + if self.TerrainSlopeMax < self.TerrainSlopeMin then + return "Invalid slope range" + end + if self.MaxCount >= 0 and self.MaxCount < self.MinCount then + return "Invalid count range" + end + local ids = {} + for _, area in ipairs(self.PrefabTypeGroups) do + if ids[area.id] then + return "Duplicated prefab type area " .. area.id + end + ids[area.id] = true + end +end + +---- + +DefineClass.PrefabTypeGroup = { + __parents = { "PropertyObject" }, + properties = { + { id = "id", name = "Id", editor = "text", default = "Default" }, + { id = "types", name = "Types", editor = "preset_id_list", default = false, preset_class = "PrefabType", auto_expand = true, }, + }, + EditorView = Untranslated(" []"), +} + +function PrefabTypeGroup:GetTypesCount() + return #(self.types or "") +end + +function PrefabTypeGroup:GetError() + if (self.id or "") == "" then + return "Group name expected." + end + if #(self.types or "") == 0 then + return "At least one prefab type is required to form a group." + end +end diff --git a/CommonLua/MapGen/PrefabTag.lua b/CommonLua/MapGen/PrefabTag.lua new file mode 100644 index 0000000000000000000000000000000000000000..5b4b79d510e23cde7b91382185916b36a5555429 --- /dev/null +++ b/CommonLua/MapGen/PrefabTag.lua @@ -0,0 +1,171 @@ +DefineClass.DistToTag = { + __parents = { "PropertyObject", }, + properties = { + { category = "General", id = "Tag", name = "Tag", editor = "preset_id", default = false, preset_class = "PrefabTag" }, + { category = "General", id = "Dist", name = "Dist", editor = "number", default = 0, scale = "m" }, + { category = "General", id = "Op", name = "Op", editor = "choice", default = '>', items = {'>', '<'} }, + }, + EditorView = Untranslated(" "), +} + +DefineClass.PrefabTag = { + __parents = { "Preset", }, + properties = { + { category = "General", id = "Persistable", name = "Persistable", editor = "bool", default = false, help = "POI prefabs with such tag will try to persist their location between map generations." }, + { category = "General", id = "TagDist", name = "Dist To Tags", editor = "nested_list", default = empty_table, base_class = "DistToTag", help = "Defines the distances to the border of other POI with specified tags" }, + { category = "General", id = "TagDistStats", name = "All Dist Stats", editor = "text", default = "", lines = 1, max_lines = 20, dont_save = true, read_only = true }, + { category = "General", id = "PrefabPOI", name = "POI Types", editor = "text", default = "", lines = 1, max_lines = 20, dont_save = true, read_only = true }, + { category = "General", id = "PrefabTypes", name = "Prefab Types", editor = "text", default = "", lines = 1, max_lines = 20, dont_save = true, read_only = true }, + { category = "General", id = "Prefabs", name = "Prefabs", editor = "text", default = "", lines = 1, max_lines = 30, dont_save = true, read_only = true }, + }, + EditorMenubarName = "Prefab Tags", + EditorIcon = "CommonAssets/UI/Icons/list.png", + EditorMenubar = "Map.Generate", + StoreAsTable = false, + GlobalMap = "PrefabTags", +} + +function PrefabTag:GetEditorViewPresetPrefix() + return self.Persistable and "" or "" +end + +function PrefabTag:GetEditorViewPresetPostfix() + return self.Persistable and "" or "" +end + +function PrefabTag:GetTagDistStats() + local tag_to_tag_limits = GetPrefabTagsLimits(true) + local stats = {} + for tag, limits in sorted_pairs(tag_to_tag_limits[self.id]) do + local min_dist, max_dist = limits[1] or min_int, limits[2] or max_int + if min_dist and min_dist >= 0 then + stats[#stats + 1] = string.format("%s > %d m", tag, min_dist / guim) + end + if max_dist and max_dist < max_int then + stats[#stats + 1] = string.format("%s < %d m", tag, max_dist / guim) + end + end + table.sort(stats) + return table.concat(stats, "\n") +end + +function PrefabTag:GetPrefabPOI() + local tag = self.id + local presets = {} + ForEachPreset("PrefabPOI", function(preset, group, tag, presets) + local tags = preset.Tags or empty_table + if tags[tag] then + presets[#presets + 1] = preset.id + end + end, tag, presets) + table.sort(presets) + return table.concat(presets, "\n") +end + +function PrefabTag:GetPrefabTypes() + local tag = self.id + local presets = {} + ForEachPreset("PrefabType", function(preset, group, tag, presets) + local tags = preset.Tags or empty_table + if tags[tag] then + presets[#presets + 1] = preset.id + end + end, tag, presets) + table.sort(presets) + return table.concat(presets, "\n") +end + +function PrefabTag:GetPrefabs() + local tag = self.id + local presets = {} + local markers = PrefabMarkers + for _, marker in ipairs(markers) do + local tags = marker.tags or empty_table + if tags[tag] then + presets[#presets + 1] = markers[marker] + end + end + table.sort(presets) + return table.concat(presets, "\n") +end + +function PrefabTag:GetError() + local tag_to_tag_limits = GetPrefabTagsLimits() + for tag, limits in sorted_pairs(tag_to_tag_limits[self.id]) do + local min_dist, max_dist = limits[1] or min_int, limits[2] or max_int + if min_dist >= max_dist then + return "Invalid limitst" + end + end +end + +---- + +function GetPrefabTagsLimits(mirror) + local tag_to_tag_limits = {} + for tag1, tag_info in pairs(PrefabTags) do + local tag_limits + for _, entry in ipairs(tag_info.TagDist) do + if not tag_limits then + tag_limits = tag_to_tag_limits[tag1] + if not tag_limits then + tag_limits = {} + tag_to_tag_limits[tag1] = tag_limits + end + end + local tag2 = entry.Tag + local limits = tag_limits[tag2] + if not limits then + limits = {} + tag_limits[tag2] = limits + if mirror and tag1 ~= tag2 then + table.set(tag_to_tag_limits, tag2, tag1, limits) + end + end + local dist = entry.Dist + local op = entry.Op + if op == '>' then + limits[1] = Max(limits[1] or min_int, dist) + elseif op == '<' then + limits[2] = Min(limits[2] or max_int, dist) + end + end + end + return tag_to_tag_limits +end + +function GetPrefabTagsPersistable() + local tags = {} + for tag, tag_info in pairs(PrefabTags) do + if tag_info.Persistable then + tags[tag] = true + end + end + return tags +end + +---- + +function PrefabTagsCombo() + local tags = {} + ForEachPreset("PrefabTag", function(preset, group, tags) + tags[#tags + 1] = preset.id + end, tags) + table.sort(tags) + return tags +end + +---- + +AppendClass.MapDataPreset = { properties = { + { category = "Random Map", id = "PersistedPrefabs", editor = "prop_table", default = empty_table, no_edit = true }, + { category = "Random Map", id = "PersistedPrefabsPreview", name = "Persisted Prefabs", editor = "text", default = "", read_only = true, lines = 1, max_lines = 10 } +}} + +function MapDataPreset:GetPersistedPrefabsPreview() + local text = {} + for _, entry in ipairs(self.PersistedPrefabs) do + text[#text + 1] = table.concat(entry, ", ") + end + return table.concat(text, "\n") +end diff --git a/CommonLua/MapGen/PrefabType.lua b/CommonLua/MapGen/PrefabType.lua new file mode 100644 index 0000000000000000000000000000000000000000..7a5ff228d788a0848b4940b418a41dfd91a7f682 --- /dev/null +++ b/CommonLua/MapGen/PrefabType.lua @@ -0,0 +1,189 @@ +local height_tile = const.HeightTileSize +local table_find = table.find + +local function CheckProp(name, value) + value = value or false + return function(self) return self[name] == value end +end +local OVRLP_DEL_NONE, OVRLP_DEL_ALL, OVRLP_DEL_IGNORE, OVRLP_DEL_PARTIAL, OVRLP_DEL_SINGLE = false, 0, 1, 2, 3 +local function OnObjOverlapItems() + return { + { value = OVRLP_DEL_NONE, text = "Do nothing" }, + { value = OVRLP_DEL_ALL, text = "Delete the entire collection" }, + { value = OVRLP_DEL_IGNORE, text = "Delete ignoring collections" }, + { value = OVRLP_DEL_PARTIAL, text = "Delete if the collection is outside" }, + { value = OVRLP_DEL_SINGLE, text = "Delete if not in collection" }, + } +end + +function PrefabRadiusEstimItems() + return { + { value = "incircle", text = "Incircle (Min)", color = red }, + { value = "excircle", text = "Excircle (Max)", color = green }, + { value = "amean", text = "Arithmetic Mean (Average)", color = yellow }, + { value = "gmean", text = "Geometric Mean (Ellipse)", color = blue }, + { value = "bestfit", text = "Best Fit (Circle)", color = cyan }, + } +end +function PrefabRadiusEstimators() + return { + incircle = function(prefab) return prefab.min_radius end, + excircle = function(prefab) return prefab.max_radius end, + amean = function(prefab) return (prefab.min_radius + prefab.max_radius) / 2 end, + gmean = function(prefab) return sqrt(prefab.min_radius * prefab.max_radius) end, + bestfit = function(prefab) return sqrt(prefab.total_area * 7 / 22) end, + } +end + +DefineClass.PrefabType = { + __parents = { "Preset", }, + properties = { + { category = "General", id = "OnObjOverlap", name = "On Object Overlap", editor = "choice", default = OVRLP_DEL_ALL, items = OnObjOverlapItems }, + { category = "General", id = "RespectBounds", name = "Respect Type Bounds", editor = "bool", default = true, help = "Disable prefab objects spill beyond the their prefab type boundaries. Doesn't affect POI prefabs as they can share multiple prefab types." }, + { category = "General", id = "OverlapReduct", name = "Lim Excircle Overlap", editor = "number", default = 1, min = 0, max = 4, slider = true, help = "Prioritize prefabs with better incircle to excircle radius ratio (the best being 1, a perfect circle)" }, + { category = "General", id = "FitEffort", name = "Prefab Fit Effort", editor = "number", default = 1, min = 0, max = 4, slider = true, help = "Prioritize prefabs fitting better the available space (using the radius estimate)" }, + { category = "General", id = "RadiusEstim", name = "Radius Estimate", editor = "choice", default = "bestfit", items = PrefabRadiusEstimItems, help = "Used to estimate the prefab real form by a circle when fitting prefabs" }, + { category = "General", id = "PlaceRadius", name = "Min Prefab Radius", editor = "number", default = height_tile, scale = "m", help = "Ignore prefabs with radius estimate below that value" }, + { category = "General", id = "FitPasses", name = "Max Fit Passes", editor = "number", default = 5, min = 1, max = 5, slider = true, help = "Maximum number of prefab fitting passes" }, + { category = "General", id = "MinFillRatio", name = "Min Fill Ratio (%)", editor = "number", default = 100, min = 0, max = 100, slider = true, help = "How much of the prefab type surface would be filled at least (actual value can be bigger, but not lesser)" }, + { category = "General", id = "MaxFillError", name = "Max Fill Err (%)", editor = "number", default = 10, min = 0, max = 1000, scale = 10, slider = true, help = "How much of the prefab type surface specified to be filled could remain unfilled" }, + { category = "General", id = "Tags", name = "Tags", editor = "set", default = empty_table, items = PrefabTagsCombo }, + + { category = "Terrain", id = "Transition", name = "Transition", editor = "number", default = 0, scale = "m", granularity = height_tile, help = "Transition zone for texture dithering" }, + { category = "Terrain", id = "TexturingOrder", name = "Texturing Order", editor = "number", default = 0, help = "Sort key used when applying prefab type terrain. Types with equal order are compared based on the descending transition dist." }, + { category = "Terrain", id = "TextureMain", name = "Main Texture", editor = "choice", default = "", items = GetTerrainNamesCombo(), }, + { category = "Terrain", id = "GrassMain", name = "Main Grass (%)", editor = "number", default = 100, min = 0, max = 200, slider = true, no_edit = CheckProp("TextureMain", "") }, + { category = "Terrain", id = "PreviewMain", name = "Main Preview", editor = "image", default = false, img_size = 128, img_box = 1, base_color_map = true, dont_save = true, no_edit = CheckProp("TextureMain", "") }, + + { category = "Terrain", id = "TextureFlow", name = "Flow Texture", editor = "choice", default = "", items = GetTerrainNamesCombo(), }, + { category = "Terrain", id = "GrassFlow", name = "Flow Grass (%)", editor = "number", default = 100, min = 0, max = 200, slider = true, no_edit = CheckProp("TextureFlow", "") }, + { category = "Terrain", id = "FlowStrength", name = "Flow Strength", editor = "number", default = 100, min = 0, max = 200, scale = 100, slider = true }, + { category = "Terrain", id = "FlowContrast", name = "Flow Contrast", editor = "number", default = 100, min = 0, max = 300, scale = 100, slider = true }, + { category = "Terrain", id = "PreviewFlow", name = "Flow Preview", editor = "image", default = false, img_size = 128, img_box = 1, base_color_map = true, dont_save = true, no_edit = CheckProp("TextureFlow", "") }, + + { category = "Terrain", id = "TextureNoise", name = "Noise Texture", editor = "choice", default = "", items = GetTerrainNamesCombo(), }, + { category = "Terrain", id = "GrassNoise", name = "Noise Grass (%)", editor = "number", default = 100, min = 0, max = 200, slider = true, no_edit = CheckProp("TextureNoise", "") }, + { category = "Terrain", id = "PreviewNoise", name = "Noise Preview", editor = "image", default = false, img_size = 128, img_box = 1, base_color_map = true, dont_save = true, no_edit = CheckProp("TextureNoise", "") }, + { category = "Terrain", id = "HeightModulated",name = "Height Modulated", editor = "bool", default = false, }, + { category = "Terrain", id = "NoiseStrength", name = "Noise Strength", editor = "number", default = 100, min = 0, max = 200, scale = 100, slider = true }, + { category = "Terrain", id = "NoiseContrast", name = "Noise Contrast", editor = "number", default = 100, min = 0, max = 300, scale = 100, slider = true }, + { category = "Terrain", id = "NoisePreset", name = "Noise Pattern", editor = "preset_id", default = "", preset_class = "NoisePreset" }, + { category = "Terrain", id = "NoisePreview", name = "Noise Preview", editor = "grid", default = false, no_edit = function(self) return self.NoisePreset == "" end, frame = 1, min = 64, dont_save = true, read_only = true }, + + { category = "Editor", id = "OverlayColor", name = "Overlay Color", editor = "color", default = false, alpha = false }, + { category = "Editor", id = "FillPrefabList", name = "Fill Prefab List", editor = "string_list", default = false, read_only = true, dont_save = true }, + { category = "Editor", id = "POIPrefabList", name = "POI Prefab List", editor = "string_list", default = false, read_only = true, dont_save = true }, + { category = "Editor", id = "POIList", name = "POI List", editor = "string_list", default = false, read_only = true, dont_save = true }, + }, + EditorMenubarName = "Prefab Types", + EditorMenubar = "Map.Generate", + EditorIcon = "CommonAssets/UI/Icons/puzzle.png", + EditorView = Untranslated(" "), + + StoreAsTable = false, + GlobalMap = "PrefabTypeToPreset", + HasSortKey = true, + + GetPreviewMain = function(self) return GetTerrainTexturePreview(self.TextureMain) end, + GetPreviewFlow = function(self) return GetTerrainTexturePreview(self.TextureFlow) end, + GetPreviewNoise = function(self) return GetTerrainTexturePreview(self.TextureNoise) end, + GetNoisePreview = function(self) return GetNoisePreview(self.NoisePreset) end, +} + +function PrefabType:Compare(other) + local sa, sb = self.SortKey, other.SortKey + if sa ~= sb then + return sa < sb + end + local ba, bb = self.RespectBounds and 1 or 0, other.RespectBounds and 1 or 0 + if ba ~= bb then + return ba > bb + end + local ooa, oob = (self.OnObjOverlap or -1), (other.OnObjOverlap or -1) + if ooa ~= oob then + return ooa > oob + end + return self.id < other.id +end + +function PrefabType:GetPOIPrefabList() + local names = {} + local prefabs = PrefabMarkers or empty_table + local poi_to_preset = PrefabPoiToPreset or empty_table + local ptype = self.id + for _, prefab in ipairs(prefabs) do + local poi_type = prefab and prefab.poi_type or "" + if poi_type ~= "" then + local preset = poi_to_preset[poi_type] + for _, group in pairs(preset and preset.PrefabTypeGroups) do + if table_find(group.types, ptype) then + names[#names + 1] = prefabs[prefab] + break + end + end + end + end + if #names == 0 then return end + table.sort(names) + return names +end + +function PrefabType:GetPOIList() + local ptype = self.id + local list = {} + for name, preset in pairs(PrefabPoiToPreset) do + for _, group in pairs(preset.PrefabTypeGroups) do + if table_find(group.types, ptype) then + list[#list + 1] = name + break + end + end + end + table.sort(list) + return list +end + +function PrefabType:GetFullPrefabList() + local names = {} + local prefabs = PrefabMarkers or empty_table + local ptype = self.id + for _, prefab in ipairs(prefabs) do + local poi_type = prefab and prefab.poi_type or "" + if poi_type == "" and (prefab.type == "" or prefab.type == ptype) then + names[#names + 1] = prefabs[prefab] + end + end + if #names == 0 then return end + table.sort(names) + return names +end + +function GetPrefabTypeList() + return table.keys(PrefabTypeToPreset, true) +end + +function GetPrefabTypeTags(add_empty) + local tags = {} + for ptype, preset in pairs(PrefabTypeToPreset) do + for tag in pairs(preset and preset.Tags or empty_table) do + if tag ~= "" then + tags[tag] = true + end + end + end + tags = table.keys(tags, true) + if add_empty then + table.insert(tags, 1, add_empty) + end + return tags +end + +function OnMsg.GedPropertyEdited(_, obj) + if IsKindOf(obj, "NoisePreset") then + ForEachPreset("PrefabType", function(ptype) + if ptype.NoisePreset == obj.id then + ObjModified(ptype) + end + end) + end +end \ No newline at end of file diff --git a/CommonLua/MapGrids.lua b/CommonLua/MapGrids.lua new file mode 100644 index 0000000000000000000000000000000000000000..b0eb9a0e6c5315c0fd75432b382c7d69a71cd788 --- /dev/null +++ b/CommonLua/MapGrids.lua @@ -0,0 +1,150 @@ +----- Lua-defined saved in maps +-- +-- To add a new grid that a part of the map data, call DefineMapGrid: +-- * the grid will be saved in the map folder if 'save_in_map' is true (otherwise, it gets recreated when the map changes) +-- * the OnMapGridChanged message is invoked when the grid is changed via the Map Editor + +if FirstLoad then + MapGridDefs = {} +end + +function DefineMapGrid(name, bits, tile_size, patch_size, save_in_map) + assert(type(bits) == "number" and type(tile_size) == "number" and tile_size >= 50*guic) -- just a reasonable tile size limit, feel free to lower + MapGridDefs[name] = { + bits = bits, + tile_size = tile_size, + patch_size = patch_size, + save_in_map = save_in_map, + } +end + +function DefineMapHexGrid(name, bits, patch_size, save_in_map) + assert(const.HexWidth) + MapGridDefs[name] = { + bits = bits, + tile_size = const.HexWidth, + patch_size = patch_size, + save_in_map = save_in_map, + hex_grid = true, + } +end + + +----- Utilities + +function MapGridTileSize(name) + return MapGridDefs[name] and MapGridDefs[name].tile_size +end + +function MapGridSize(name, mapdata) + -- can't use GetMapBox, the realm might not have been created yet + mapdata = mapdata or _G.mapdata + local map_size = point(mapdata.Width - 1, mapdata.Height - 1) * const.HeightTileSize + + local data = MapGridDefs[name] + local tile_size = data.tile_size + if data.hex_grid then + local tile_x = tile_size + local tile_y = MulDivRound(tile_size, const.HexGridVerticalSpacing, const.HexWidth) + local width = (map_size:x() + tile_x - 1) / tile_x + local height = (map_size:y() + tile_y - 1) / tile_y + return point(width, height) + end + return map_size / tile_size +end + +function MapGridWorldToStorageBox(name, bbox) + if not bbox then + return sizebox(point20, MapGridSize(name)) + end + + local data = MapGridDefs[name] + if data.hex_grid then + return HexWorldToStore(bbox) + end + return bbox / data.tile_size +end + + +---- Grid saving/loading with map + +function OnMsg.MapFolderMounted(map, mapdata) + for name, data in pairs(MapGridDefs) do + if rawget(_G, name) then + _G[name]:free() + end + + local grid + local filename = string.format("Maps/%s/%s", map, name:lower():gsub("grid", ".grid")) + if data.save_in_map and io.exists(filename) then + grid = GridReadFile(filename) + else + local width, height = MapGridSize(name, mapdata):xy() + if data.patch_size then + grid = NewHierarchicalGrid(width, height, data.patch_size, data.bits) + else + grid = NewGrid(width, height, data.bits) + end + end + rawset(_G, name, grid) + end +end + +function OnMsg.SaveMap(folder) + for name, data in pairs(MapGridDefs) do + local filename = string.format("%s/%s", folder, name:lower():gsub("grid", ".grid")) + if data.save_in_map and not _G[name]:equals(0) then + GridWriteFile(_G[name], filename) + SVNAddFile(filename) + else + SVNDeleteFile(filename) + end + end +end + + +----- Engine function overrides + +if Platform.editor then + +local old_GetGridNames = editor.GetGridNames +function editor.GetGridNames() + local grids = old_GetGridNames() + for name in sorted_pairs(MapGridDefs) do + table.insert_unique(grids, name) + end + return grids +end + +local old_GetGrid = editor.GetGrid +function editor.GetGrid(name, bbox, source_grid, mask_grid, mask_grid_tile_size) + local data = MapGridDefs[name] + if data then + local bxgrid = MapGridWorldToStorageBox(name, bbox) + local new_grid = _G[name]:new_instance(bxgrid:sizex(), bxgrid:sizey()) + new_grid:copyrect(_G[name], bxgrid, point20) + return new_grid + end + return old_GetGrid(name, bbox, source_grid, mask_grid, mask_grid_tile_size) +end + +local old_SetGrid = editor.SetGrid +function editor.SetGrid(name, source_grid, bbox, mask_grid, mask_grid_tile_size) + local data = MapGridDefs[name] + if data then + local bxgrid = MapGridWorldToStorageBox(name, bbox) + _G[name]:copyrect(source_grid, bxgrid - bxgrid:min(), bxgrid:min()) + DbgInvalidateTerrainOverlay(bbox) + Msg("OnMapGridChanged", name, bbox) + return + end + old_SetGrid(name, source_grid, bbox, mask_grid, mask_grid_tile_size) +end + +local old_GetGridDifferenceBoxes = editor.GetGridDifferenceBoxes +function editor.GetGridDifferenceBoxes(name, grid1, grid2, bbox) + local data = MapGridDefs[name] + return old_GetGridDifferenceBoxes(name, grid1, grid2, bbox or empty_box, data and data.tile_size or 0) +end + +end -- Platform.editor diff --git a/CommonLua/MetadataCache.lua b/CommonLua/MetadataCache.lua new file mode 100644 index 0000000000000000000000000000000000000000..38f639ae25634b9c64a48f6720b8c11ab50f0b24 --- /dev/null +++ b/CommonLua/MetadataCache.lua @@ -0,0 +1,95 @@ +DefineClass.MetadataCache = { + __parents = {"InitDone"}, + cache_filename = "saves:/save_metadata_cache.lua", + folder = "saves:/", + mask = "*.sav", +} + +function MetadataCache:Save() + local data_to_save = {} + for _, data in ipairs(self) do + data_to_save[#data_to_save + 1] = data + end + local err = AsyncStringToFile(self.cache_filename, ValueToLuaCode(data_to_save, nil, pstr("", 1024))) + return err +end + +function MetadataCache:Load() + self:Clear() + local err, data_to_load = FileToLuaValue(self.cache_filename) + if err then return err end + if not data_to_load then return end + for _, data in ipairs(data_to_load) do + self[#self + 1] = data + end +end + +function MetadataCache:Refresh() + assert(CurrentThread()) + local err, new_entries = self:Enumerate() + if err then return err end + local cached_dict = {} + for idx, cached in ipairs(self) do + cached_dict[cached[1]] = cached + cached_dict[cached[1]]["idx"] = idx + end + local new_entries_dict = {} + for _, entry in ipairs(new_entries) do + new_entries_dict[entry[1]] = entry + end + + for key, entry in pairs(new_entries_dict) do + local cached = cached_dict[key] + if cached then --refresh existing + for i = 3, #cached do + if cached[i] ~= entry[i] then + self[cached.idx] = entry + err, meta = self:GetMetadata(entry[1]) + if err then + return err + end + self[cached.idx][2] = meta + break + end + end + else --add new + self[#self + 1] = entry + local err, meta = self:GetMetadata(entry[1]) + if err then + return err + end + self[#self][2] = meta + end + + end + + for i = #self, 1, -1 do + if not new_entries_dict[self[i][1]] then --remove inexistent + table.remove(self, i) + end + end +end + +function MetadataCache:Enumerate() + local err, files = AsyncListFiles(self.folder, self.mask, "relative,size,modified") + local result = {} + if err then + return err + end + for idx, file in ipairs(files) do + result[#result + 1] = { file, false, files.size[idx], files.modified[idx] } + end + return err, result +end + +function MetadataCache:GetMetadata(filename) + local loaded_meta, load_err + local err = Savegame.Load(filename, function(folder) + load_err, loaded_meta = LoadMetadata(folder) + end) + return load_err, loaded_meta +end + +function MetadataCache:Clear() + table.iclear(self) +end \ No newline at end of file diff --git a/CommonLua/Movable.lua b/CommonLua/Movable.lua new file mode 100644 index 0000000000000000000000000000000000000000..6ac915a7c67b0d22c53c7e3316dbd762a6ed7d82 --- /dev/null +++ b/CommonLua/Movable.lua @@ -0,0 +1,757 @@ +DefineClass.Destlock = { + __parents = { "CObject" }, + --entity = "WayPoint", + flags = { gofOnSurface = true, efDestlock = true, efVisible = false, cofComponentDestlock = true }, + radius = 6 * guic, + GetRadius = pf.GetDestlockRadius, + GetDestlockOwner = pf.GetDestlockOwner, +} + +if Libs.Network == "sync" then + Destlock.flags.gofSyncObject = true +end + +---- + +DefineClass.Movable = +{ + __parents = { "Object" }, + + flags = { + cofComponentPath = true, cofComponentAnim = true, cofComponentInterpolation = true, cofComponentCurvature = true, cofComponentCollider = false, + efPathExecObstacle = true, efResting = true, + }, + pfclass = 0, + pfflags = const.pfmDestlockSmart + const.pfmCollisionAvoidance + const.pfmImpassableSource + const.pfmOrient, + + GetPathFlags = pf.GetPathFlags, + ChangePathFlags = pf.ChangePathFlags, + GetStepLen = pf.GetStepLen, + SetStepLen = pf.SetStepLen, + SetSpeed = pf.SetSpeed, + GetSpeed = pf.GetSpeed, + SetMoveSpeed = pf.SetMoveSpeed, + GetMoveSpeed = pf.GetMoveSpeed, + GetMoveAnim = pf.GetMoveAnim, + SetMoveAnim = pf.SetMoveAnim, + GetWaitAnim = pf.GetWaitAnim, + SetWaitAnim = pf.SetWaitAnim, + ClearMoveAnim = pf.ClearMoveAnim, + GetMoveTurnAnim = pf.GetMoveTurnAnim, + SetMoveTurnAnim = pf.SetMoveTurnAnim, + GetRotationTime = pf.GetRotationTime, + SetRotationTime = pf.SetRotationTime, + GetRotationSpeed = pf.GetRotationSpeed, + SetRotationSpeed = pf.SetRotationSpeed, + PathEndsBlocked = pf.PathEndsBlocked, + SetDestlockRadius = pf.SetDestlockRadius, + GetDestlockRadius = pf.GetDestlockRadius, + GetDestlock = pf.GetDestlock, + RemoveDestlock = pf.RemoveDestlock, + GetDestination = pf.GetDestination, + SetCollisionRadius = pf.SetCollisionRadius, + GetCollisionRadius = pf.GetCollisionRadius, + RestrictArea = pf.RestrictArea, + GetRestrictArea = pf.GetRestrictArea, + CheckPassable = pf.CheckPassable, + + GetPath = pf.GetPath, + GetPathLen = pf.GetPathLen, + GetPathPointCount = pf.GetPathPointCount, + GetPathPoint = pf.GetPathPoint, + SetPathPoint = pf.SetPathPoint, + IsPathPartial = pf.IsPathPartial, + GetPathHash = pf.GetPathHash, + PopPathPoint = pf.PopPathPoint, + + SetPfClass = pf.SetPfClass, + GetPfClass = pf.GetPfClass, + + Step = pf.Step, + ResolveGotoTarget = pf.ResolveGotoTarget, + ResolveGotoTargetXYZ = pf.ResolveGotoTargetXYZ, + + collision_radius = false, + collision_radius_mod = 1000, -- used to auto-calculate the collision radius based on the radius. + radius = 1 * guim, + forced_collision_radius = false, + forced_destlock_radius = false, + outside_pathfinder = false, + outside_pathfinder_reasons = false, + + last_move_time = 0, + last_move_counter = 0, +} + +local pfSleep = Sleep +local pfFinished = const.pfFinished +local pfTunnel = const.pfTunnel +local pfFailed = const.pfFailed +local pfStranded = const.pfStranded +local pfDestLocked = const.pfDestLocked +local pfOutOfPath = const.pfOutOfPath + +function GetPFStatusText(status) + if type(status) ~= "number" then + return "" + elseif status >= 0 then + return "Moving" + elseif status == pfFinished then + return "Finished" + elseif status == pfTunnel then + return "Tunnel" + elseif status == pfFailed then + return "Failed" + elseif status == pfStranded then + return "Stranded" + elseif status == pfDestLocked then + return "DestLocked" + elseif status == pfOutOfPath then + return "OutOfPath" + end + return "" +end + +function Movable:InitEntity() + if not IsValidEntity(self:GetEntity()) then + return + end + if self:HasState("walk") then + self:SetMoveAnim("walk") + elseif self:HasState("moveWalk") then + self:SetMoveAnim("moveWalk") + elseif not self:HasState(self:GetMoveAnim() or -1) and self:HasState("idle") then + -- temp move stub in case that there isn't any walk anim + self:SetMoveAnim("idle") + self:SetStepLen(guim) + end + if self:HasState("idle") then + self:SetWaitAnim("idle") + end +end + +function Movable:Init() + self:InitEntity() + self:InitPathfinder() +end + +function Movable:InitPathfinder() + self:ChangePathFlags(self.pfflags) + self:UpdatePfClass() + self:UpdatePfRadius() +end + +local efPathExecObstacle = const.efPathExecObstacle +local efResting = const.efResting +local pfStep = pf.Step +local pfStop = pf.Stop + +function Movable:ClearPath() + if self.outside_pathfinder then + return + end + return pfStop(self) +end + +if Platform.asserts then + +function Movable:Step(...) + assert(not self.outside_pathfinder) + return pfStep(self, ...) +end + +end -- Platform.asserts + + +function Movable:ExitPathfinder(forced) + -- makes the unit invisible to the pathfinder + if not forced and self.outside_pathfinder then + return + end + self:ClearPath() + self:RemoveDestlock() + self:UpdatePfRadius() + self:ClearEnumFlags(efPathExecObstacle | efResting) + self.outside_pathfinder = true +end + +function Movable:EnterPathfinder(forced) + if not forced and not self.outside_pathfinder then + return + end + self.outside_pathfinder = nil + self:UpdatePfRadius() + self:SetEnumFlags(efPathExecObstacle & GetClassEnumFlags(self) | efResting) +end + +function Movable:AddOutsidePathfinderReason(reason) + local reasons = self.outside_pathfinder_reasons or {} + if reasons[reason] then return end + reasons[reason] = true + if not self.outside_pathfinder then + self:ExitPathfinder() + end + self.outside_pathfinder_reasons = reasons +end + +function Movable:RemoveOutsidePathfinderReason(reason, ignore_error) + if not IsValid(self) then return end + local reasons = self.outside_pathfinder_reasons + assert(ignore_error or reasons and reasons[reason], "Unit trying to remove invalid outside_pathfinder reason: "..reason) + if not reasons or not reasons[reason] then return end + reasons[reason] = nil + if next(reasons) then + self.outside_pathfinder_reasons = reasons + return + end + self:EnterPathfinder() + self.outside_pathfinder_reasons = nil +end + +function Movable:ChangeDestlockRadius(forced_destlock_radius) + if self.forced_destlock_radius == forced_destlock_radius then + return + end + self.forced_destlock_radius = forced_destlock_radius + self:UpdatePfRadius() +end + +function Movable:RestoreDestlockRadius(forced_destlock_radius) + if self.forced_destlock_radius ~= forced_destlock_radius then + return + end + self.forced_destlock_radius = nil + self:UpdatePfRadius() +end + +function Movable:ChangeCollisionRadius(forced_collision_radius) + if self.forced_collision_radius == forced_collision_radius then + return + end + self.forced_collision_radius = forced_collision_radius + self:UpdatePfRadius() +end + +function Movable:RestoreCollisionRadius(forced_collision_radius) + if self.forced_collision_radius ~= forced_collision_radius then + return + end + self.forced_collision_radius = nil + self:UpdatePfRadius() +end + +function Movable:UpdatePfRadius() + local forced_collision_radius, forced_destlock_radius = self.forced_collision_radius, self.forced_destlock_radius + if self.outside_pathfinder then + forced_collision_radius, forced_destlock_radius = 0, 0 + end + local radius = self:GetRadius() + self:SetDestlockRadius(forced_destlock_radius or radius) + self:SetCollisionRadius(forced_collision_radius or self.collision_radius or radius * self.collision_radius_mod / 1000) +end + +function Movable:GetPfClassData() + return pathfind[self:GetPfClass() + 1] +end + +function Movable:GetPfSpheroidRadius() + local pfdata = self:GetPfClassData() + local pass_grid = pfdata and pfdata.pass_grid or PF_GRID_NORMAL + return pass_grid == PF_GRID_NORMAL and const.passSpheroidWidth or const.passLargeSpheroidWidth +end + +if config.TraceEnabled then +function Movable:SetSpeed(speed) + pf.SetSpeed(self, speed) +end +end + +function Movable:OnCommandStart() + self:OnStopMoving() + if IsValid(self) then + self:ClearPath() + end +end + +function Movable:FindPath(...) + local pfFindPath = pf.FindPath + while true do + local status, partial = pfFindPath(self, ...) + if status <= 0 then + return status, partial + end + Sleep(status) + end +end + +function Movable:HasPath(...) + local status = self:FindPath(...) + return status == 0 +end + +function Movable:FindPathLen(...) + if self:HasPath(...) then + return pf.GetPathLen(self) + end +end + +local Sleep = Sleep +function Movable:MoveSleep(time) + return Sleep(time) +end + +AutoResolveMethods.CanStartMove = "and" +function Movable:CanStartMove(status) + return status >= 0 or status == pfTunnel or status == pfStranded or status == pfDestLocked or status == pfOutOfPath +end + +function Movable:TryContinueMove(status, ...) + if status == pfTunnel then + if self:TraverseTunnel() then + return true + end + elseif status == pfStranded then + if self:OnStrandedFallback(...) then + return true + end + elseif status == pfDestLocked then + if self:OnDestlockedFallback(...) then + return true + end + elseif status == pfOutOfPath then + if self:OnOutOfPathFallback(...) then + return true + end + end +end + +function Movable:Goto(...) + local err = self:PrepareToMove(...) + if err then + return false, pfFailed + end + local status = self:Step(...) + if not self:CanStartMove(status) then + return status == pfFinished, status + end + self:OnStartMoving(...) + local pfSleep = self.MoveSleep + while true do + if status > 0 then + if self:OnGotoStep(status) then + break -- interrupted + end + pfSleep(self, status) + elseif not self:TryContinueMove(status, ...) then + break + end + status = self:Step(...) + end + self:OnStopMoving(status, ...) + return status == pfFinished, status +end + +AutoResolveMethods.OnGotoStep = "or" +Movable.OnGotoStep = empty_func + +function Movable:TraverseTunnel() + local tunnel, param = pf.GetTunnel(self) + if not tunnel then + return self:OnTunnelMissingFallback() + elseif not tunnel:TraverseTunnel(self, self:GetPathPoint(-1), param) then + self:ClearPath() + return false + end + + self:OnTunnelTraversed(tunnel) + return true +end + +AutoResolveMethods.OnTunnelTraversed = "call" +-- function Movable:OnTunnelTraversed(tunnel) +Movable.OnTunnelTraversed = empty_func + +function Movable:OnTunnelMissingFallback() + if Platform.developer then + local pos = self:GetPos() + local next_pos = self:GetPathPoint(-1) + local text_pos = ValidateZ(pos, 3*guim) + DbgAddSegment(pos, text_pos, red) + if next_pos then + DbgAddVector(pos + point(0, 0, guim/2), next_pos - pos, yellow) + end + DbgAddText("Tunnel missing!", text_pos, red) + StoreErrorSource("silent", pos, "Tunnel missing!") + end + assert(false, "Tunnel missing!") + Sleep(100) + self:ClearPath() + return true +end + +function Movable:OnOutOfPathFallback() + assert(false, "Unit out of path!") + Sleep(100) + self:ClearPath() + return true +end + +AutoResolveMethods.PickPfClass = "or" +Movable.PickPfClass = empty_func + +function Movable:UpdatePfClass() + local pfclass = self:PickPfClass() or self.pfclass + return self:SetPfClass(pfclass) +end + +function Movable:OnInfiniteMoveDetected() + Sleep(100) +end + +function Movable:CheckInfinteMove(dest, ...) + local time = GameTime() + RealTime() + if time ~= self.last_move_time then + self.last_move_counter = nil + self.last_move_time = time + elseif self.last_move_counter == 100 then + assert(false, "Infinte move loop!") + self:OnInfiniteMoveDetected() + else + self.last_move_counter = self.last_move_counter + 1 + end +end + +AutoResolveMethods.PrepareToMove = "or" +function Movable:PrepareToMove(dest, ...) + self:CheckInfinteMove(dest, ...) +end + +AutoResolveMethods.OnStartMoving = true +Movable.OnStartMoving = empty_func --function Movable:OnStartMoving(dest, ...) + +AutoResolveMethods.OnStopMoving = true +Movable.OnStopMoving = empty_func --function Movable:OnStopMoving(status, dest, ...) + +function Movable:OnStrandedFallback(dest, ...) +end + +function Movable:OnDestlockedFallback(dest, ...) +end + +local pfmDestlock = const.pfmDestlock +local pfmDestlockSmart = const.pfmDestlockSmart +local pfmDestlockAll = pfmDestlock + pfmDestlockSmart + +function Movable:Goto_NoDestlock(...) + local flags = self:GetPathFlags(pfmDestlockAll) + if flags == 0 then + return self:Goto(...) + end + self:ChangePathFlags(0, flags) + if flags == pfmDestlock then + self:PushDestructor(function(self) + if IsValid(self) then self:ChangePathFlags(pfmDestlock, 0) end + end) + elseif flags == pfmDestlockSmart then + self:PushDestructor(function(self) + if IsValid(self) then self:ChangePathFlags(pfmDestlockSmart, 0) end + end) + else + self:PushDestructor(function(self) + if IsValid(self) then self:ChangePathFlags(pfmDestlockAll, 0) end + end) + end + local res = self:Goto(...) + self:PopDestructor() + self:ChangePathFlags(flags, 0) + return res +end + +function Movable:InterruptPath() + pf.ChangePathFlags(self, const.pfInterrupt) +end + +function OnMsg.PersistGatherPermanents(permanents, direction) + permanents["pf.Step"] = pf.Step + permanents["pf.FindPath"] = pf.FindPath + permanents["pf.RestrictArea"] = pf.RestrictArea +end + + +----- PFTunnel + +DefineClass.PFTunnel = { + __parents = { "Object" }, + dbg_tunnel_color = const.clrGreen, + dbg_tunnel_zoffset = 0, +} + +function PFTunnel:Done() + self:RemovePFTunnel() +end + +function PFTunnel:AddPFTunnel() +end + +function PFTunnel:RemovePFTunnel() + pf.RemoveTunnel(self) +end + +function PFTunnel:TraverseTunnel(unit, end_point, param) + unit:SetPos(end_point) + return true +end + +function PFTunnel:TryAddPFTunnel() + return self:AddPFTunnel() +end + +function OnMsg.LoadGame() + MapForEach("map", "PFTunnel", function(obj) return obj:TryAddPFTunnel() end) +end + +---- + +function IsExactlyOnPassableLevel(unit) + local x, y, z = unit:GetVisualPosXYZ() + return terrain.FindPassableZ(x, y, z, unit:GetPfClass(), 0, 0) +end + +---- + +function Movable:FindPathDebugCallback(status, ...) + local params = {...} + local target = ... + local dist, target_str = 0, "" + local target_pos + if IsPoint(target) then + target_pos = target + dist = self:GetDist2D(target) + target_str = tostring(target) + elseif IsValid(target) then + target_pos = target:GetVisualPos() + dist = self:GetDist2D(target) + target_str = string.format("%s:%d", target.class, target.handle) + elseif type(target) == "table" then + target_pos = target[1] + dist = self:GetDist2D(target[1]) + for i = 1, #target do + local p = target[i] + local d = self:GetDist2D(p) + if i == 1 or d < dist then + dist = d + target_pos = p + end + target_str = target_str .. tostring(p) + end + end + local o = DebugPathObj:new{} + o:SetPos(self:GetVisualPos()) + o:ChangeEntity(self:GetEntity()) + o:SetScale(30) + o:Face(target_pos) + o.obj = self + o.command = self.command + o.target = target + o.target_pos = target_pos + o.params = params + o.txt = string.format( + "handle:%d %15s %20s, dist:%4dm, status %d, pathlen:%4.1fm, restrict_r:%.1fm, target:%s", + self.handle, self.class, self.command, dist/guim, status, 1.0*pf.GetPathLen(self)/guim, 1.0*self:GetRestrictArea()/guim, target_str) + printf("Path debug: time:%d, %s", GameTime(), o.txt) + pf.SetPfClass(o, self:GetPfClass()) + pf.ChangePathFlags(o, self.pfflags) + pf.SetCollisionRadius(o, self:GetCollisionRadius()) + pf.SetDestlockRadius(o, self:GetRadius()) + pf.RestrictArea(o, self:GetRestrictArea()) + --TogglePause() + --ViewObject(self) +end + +-- !DebugPathObj.target_pos +-- !DebugPathObj.command +-- SelectedObj:DrawPath() +DefineClass.DebugPathObj = { + __parents = { "Movable" }, + flags = { efSelectable = true }, + entity = "WayPoint", + obj = false, + command = "", + target = false, + target_pos = false, + params = false, + restrict_pos = false, + restrict_radius = 0, + txt = "", + FindPathDebugCallback = empty_func, + DrawPath = function(self) + pf.FindPath(self, table.unpack(self.params)) + DrawWayPointPath(self, self.target_pos) + end, +} + +-- generate clusters of objects around "leaders" (selected from the objs) where each obj is no more than dist_threshold apart from its leader +function LeaderClustering(objs, dist_threshold, func, ...) + local other_leaders -- objs[1] is always a leader but not included here + for _, obj in ipairs(objs) do + -- find the nearest leader + local leader = objs[1] + local dist = leader:GetDist2D(obj) + for _, leader2 in ipairs(other_leaders) do + local dist2 = leader2:GetDist2D(obj) + if dist > dist2 then + leader, dist = leader2, dist2 + end + end + if dist > dist_threshold then -- new leader + leader = obj + dist = 0 + other_leaders = other_leaders or {} + other_leaders[#other_leaders + 1] = leader + end + func(obj, leader, dist, ...) + end +end + +-- splits objs in clusters and moves the center of each cluster close to the destination, keeping relative positions of objs within the cluster +function ClusteredDestinationOffsets(objs, dist_threshold, dest, func, ...) + if #(objs or "") == 0 then return end + local x0, y0, z0 = dest:xyz() + local invalid_z = const.InvalidZ + z0 = z0 or invalid_z + if #objs == 1 then + z0 = terrain.FindPassableZ(x0, y0, z0, objs[1].pfclass) or z0 + func(objs[1], x0, y0, z0, ...) + return + end + local clusters = {} + local base_x, base_y = 0, 0 + LeaderClustering(objs, dist_threshold, function(obj, leader, dist, clusters) + local cluster = clusters[leader] + if not cluster then + cluster = { x = 0, y = 0, } + clusters[leader] = cluster + clusters[#clusters + 1] = cluster + end + local x, y = obj:GetPosXYZ() + cluster.x = cluster.x + x + cluster.y = cluster.y + y + base_x = base_x + x + base_y = base_y + y + cluster[#cluster + 1] = obj + end, clusters) + base_x, base_y = base_x / #objs, base_y / #objs + local offs = dist_threshold / 4 + for idx, cluster in ipairs(clusters) do + local x, y = cluster.x / #cluster, cluster.y / #cluster + -- move cluster center a bit in the direction of its relative position to the group + local dx, dy = x - base_x, y - base_y + local len = sqrt(dx * dx + dy * dy) + if len > 0 then -- offset dest + dx, dy = dx * offs / len, dy * offs / len + end + -- vector from cluster center to dest + x, y = x0 - x + dx, y0 - y + dy + for _, obj in ipairs(cluster) do + local obj_x, obj_y, obj_z = obj:GetPosXYZ() + local x1, y1, z1 = obj_x + x, obj_y + y, z0 + z1 = terrain.FindPassableZ(x1, y1, z1, obj.pfclass) or z1 + func(obj, x1, y1, z1, ...) + end + end +end + +---- + +MapVar("PathTestObj", false) + +DefineClass.TestPathObj = { + __parents = { "Movable" }, + flags = { + cofComponentAnim = false, cofComponentInterpolation = false, cofComponentCurvature = false, + efPathExecObstacle = false, efResting = false, efSelectable = false, efVisible = false, + }, + pfflags = 0, +} + +function GetPathTestObj() + if not IsValid(PathTestObj) then + PathTestObj = PlaceObject("TestPathObj") + CreateGameTimeThread(function() + DoneObject(PathTestObj) + PathTestObj = false + end) + end + return PathTestObj +end + +---- + +--[[ example usage +ClusteredDestinationOffsets(objs, dist_threshold, dest, function (obj, x, y, z) + obj:SetCommand("GotoPos", point(x, y, z)) +end) +--]] + +--[[ +DefineClass.Destblockers = +{ + __parents = { "Object" }, + flags = { efResting = true }, + + entity = "Guard_01", +} + +DefineClass.PathTest = +{ + __parents = { "Movable", "CommandObject" }, + entity = "Guard_01", + Goto = function(self, ...) + self:ChangePathFlags(const.pfmCollisionAvoidance) + self:SetCollisionRadius(self:GetRadius() / 2) + return Movable.Goto(self, ...) + end, +} + + + +function TestPath2() + local o = GetObjects{classes = "PathTest"}[1] + local target_pt = GetTerrainCursor() + if not IsValid(o) then + o = PlaceObject("PathTest") + end + o:SetPos(point(141754, 117046, 20000)) + o:SetCommand("Goto", point(132353, 125727, 20000)) +end + +function TestPath() + local o = GetObjects{classes = "PathTest"}[1] + local target_pt = GetTerrainCursor() + if not IsValid(o) then + o = PlaceObject("PathTest") + o:SetPos(target_pt) + target_pt = target_pt + point(1000, 0, 0) + end + o:SetCommand("Goto", target_pt) +end + +function TestCollisionAvoid() + GetObjects{classes = "PathTest"}:Destroy() + CreateGameTimeThread(function() + local pt = point(134941, 153366, 20000) + + for i = 0, 5 do + local g1 = PathTest:new() + g1:SetPos(pt+point(-6 * guim, i*2*guim)) + g1:SetCommand("Goto", g1:GetPos() + point(12*guim, 0)) + Sleep(200) + + local g1 = PathTest:new() + g1:SetPos(pt+point(6 * guim, i*2*guim)) + g1:SetCommand("Goto", g1:GetPos() + point(-12*guim, 0)) + Sleep(200) + end + end) +end +]] diff --git a/CommonLua/MovieRecording.lua b/CommonLua/MovieRecording.lua new file mode 100644 index 0000000000000000000000000000000000000000..24738177e4448eeb969a384219acdaa75f88c514 --- /dev/null +++ b/CommonLua/MovieRecording.lua @@ -0,0 +1,170 @@ +local waitFrameEnd = 0 +local screenshotName +local sampleOffsetX, sampleOffsetY +local samplesCount = 0 +local upsampling = 0 + +local oldAdvance = false +local oldGAdvance = false +local recording_in_process = false + +local function SetupUpsampleLodBias(upsample) + local lodbias = 0 + if upsample == 2 then lodbias = -1000 + elseif upsample == 3 then lodbias = -1584 + elseif upsample == 4 then lodbias = -2000 + elseif upsample == 5 then lodbias = -2321 + elseif upsample == 6 then lodbias = -2584 + elseif upsample == 7 then lodbias = -2807 + elseif upsample > 7 then lodbias = -3000 + end + hr.MipmapLodBias = lodbias +end + +local function mrStartScreenshot() + if upsampling > 0 then + SetupUpsampleLodBias(upsampling) + BeginUpsampledScreenshot(upsampling) + else + SetupUpsampleLodBias(2) + BeginUpsampledScreenshot(1) + end + samplesCount = 0 +end + +local function mrAddSample(x, y) + sampleOffsetX = x or 0 + sampleOffsetY = y or 0 + + SetCameraOffset(x, y) + if upsampling > 0 then + local x = x * upsampling / 1024 + local y = y * upsampling / 1024 + AddUpsampledScreenshotSample(upsampling - 1 - x, y) + else + AddUpsampledScreenshotSample(0, 0) + end + samplesCount = samplesCount + 1 + + RenderFrame() +end + +local function mrWriteScreenshot(screenshot) + SetCameraOffset(0, 0) + if upsampling > 0 then + EndUpsampledScreenshot(screenshot) + else + EndAAMotionBlurScreenshot(screenshot, samplesCount) + end + RenderFrame() +end + +local oldParticlesGameTime = 0 +local oldThreadsMaxTimeStep +local oldAA = hr.EnablePostProcAA + +function mrInit(width, height) + recording_in_process = true + table.change(config, "mrInit", { ThreadsMaxTimeStep = 1 }) + table.change(hr, "mrInit", { + ParticlesGameTime = 1, + EnablePostProcAA = 0, + }) + WaitNextFrame(3) + BeginAAMotionBlurMovie(width, height) + WaitRenderMode("movie") + Msg("RecordingStarted") +end + +function mrEnd() + EndAAMotionBlurMovie() + table.restore(config, "mrInit") + table.restore(hr, "mrInit") + recording_in_process = false + SetupUpsampleLodBias(0) + WaitRenderMode("scene") +end + +local function mrTakeScreenshot(name, frameDuration, subsamples, shutter) + mrStartScreenshot() + assert(shutter >= 0 and shutter <= 100) + local remaining_frame_duration = frameDuration + if shutter > 0 then + frameDuration = (frameDuration * shutter + 99) / 100 + end + for subFrame = 1, subsamples do + mrAddSample(g_SubsamplePairs[subFrame][1], g_SubsamplePairs[subFrame][2]) + if shutter > 0 then + local sub_sleep = subFrame * frameDuration / subsamples - (subFrame - 1) * frameDuration / subsamples + Sleep(sub_sleep) + remaining_frame_duration = remaining_frame_duration - sub_sleep + end + end + mrWriteScreenshot(name) + return remaining_frame_duration +end + +local function mrTakeUpsampledScreenshot(name, upsample) + upsampling = upsample or 1 + mrStartScreenshot() + for x = 0, upsampling - 1 do + for y = 0, upsampling - 1 do + mrAddSample(x * 1024 / upsampling, y * 1024 / upsampling) + end + end + mrWriteScreenshot(name) + upsampling = 0 +end + +function MovieWriteScreenshot(name, frameDuration, subsamples, shutter, width, height) + if GetRenderMode() ~= "scene" then + WriteScreenshot(name) + return + end + mrInit(width, height) + local result = mrTakeScreenshot(name, frameDuration, subsamples, shutter or 0) + mrEnd() + return result +end + +function WriteUpsampledScreenshot(name, upsample) + mrInit() + mrTakeUpsampledScreenshot(name, upsample) + mrEnd() +end + +-- shutter is base 100 "shutter angle": https://en.wikipedia.org/wiki/Rotary_disc_shutter +-- shutter = 0 - no motion blur +-- shutter = 50 - 180 degrees shutter angle motion blur +-- shutter = 100 - 360 degrees shutter angle motion blur +function RecordMovie(filename, start_frame, fps, duration, subsamples, shutter, stop) + local path, filename, ext = SplitPath(filename) + if ext == "" then + ext = ".png" + end + mrInit() + -- Note: When we don't know the duration and rely on the stop() function to end the recording + -- a default duration is used for the calculations + duration = duration or 3600000 -- 1h + local frames = MulDivTrunc(fps, duration, 1000) + local frame_time + local name + shutter = shutter or 0 + subsamples = subsamples or 64 + for f = start_frame, start_frame + frames do + if stop and stop() then break end + frame_time = MulDivTrunc(f, duration, frames) - MulDivTrunc(f - 1, duration, frames) + name = string.format("%s%s%05d%s", path, filename, f, ext) + Sleep(mrTakeScreenshot(name, frame_time, subsamples, shutter)) + + while not ScreenshotWritten() do + RenderFrame() + end + end + mrEnd() +end + +function IsRecording() + return not not recording_in_process +end + diff --git a/CommonLua/Music.lua b/CommonLua/Music.lua new file mode 100644 index 0000000000000000000000000000000000000000..a821efd06377b067890f9ce913e5dd52f8dd987f --- /dev/null +++ b/CommonLua/Music.lua @@ -0,0 +1,296 @@ +if FirstLoad or ReloadForDlc then + Playlists = { + Default = {}, + [""] = {}, + } +end + +DefaultMusicCrossfadeTime = rawget(_G, "DefaultMusicCrossfadeTime") or 1000 +DefaultMusicSilenceDuration = rawget(_G, "DefaultMusicSilenceDuration") or 1*60*1000 + +config.DebugMusicTracks = false + +function DbgMusicPrint(...) + if config.DebugMusicTracks then + print(...) + end +end + +function PlaylistAddTracks(playlist, folder, mode) + local tracks = io.listfiles(folder, "*", mode or "non recursive") + for i = 1, #tracks do + local path = string.match(tracks[i], "(.*)%..+") + if path then + table.insert(playlist, { path = path, frequency = 100 }) + end + end + return playlist +end + +function PlaylistCreate(folder, mode) + local playlist = {} + PlaylistAddTracks(playlist, folder, mode) + return playlist +end + +DefineClass.MusicClass = +{ + __parents = { "InitDone" }, + + sound_handle = false, + sound_duration = -1, + sound_start_time = -1, + MusicThread = false, + Playlist = "", + Blacklist = empty_table, + Track = false, + Volume = 1000, + TracksPlayed = 0, + fadeout_thread = false, + + Init = function(self) + self.MusicThread = CreateRealTimeThread(function() + while true do + self:UpdateMusic() + end + end) + end, + + SetPlaylist = function(self, playlist, fade, force) + local old = self.Playlist + self.Playlist = playlist or nil + if force or old ~= self.Playlist then + self:PlayTrack(self:ChooseTrack(self.Playlist, self.Track), fade) + ObjModified(self) + end + end, + + SetBlacklist = function(self, blacklist) + local map = {} + for i=1,#(blacklist or "") do + map[blacklist[i]] = true + end + self.Blacklist = map + end, + + UpdateMusic = function(self) + Sleep(1000) + + if IsSoundPlaying(self.sound_handle) or (self.Track and self.Track.empty and self:RemainingTrackTime() > 0) then + return + end + + local oldTracksPlayed = self.TracksPlayed + local track = self.Track + if track then + local playlist = Playlists[self.Playlist] + local duration = track.SilenceDuration or (playlist and playlist.SilenceDuration) or DefaultMusicSilenceDuration + Msg("MusicTrackEnded", self.Playlist, self.Track) + if oldTracksPlayed == self.TracksPlayed then + WaitWakeup(duration) + end + end + + if oldTracksPlayed == self.TracksPlayed then + self:PlayTrack(self:ChooseTrack(self.Playlist, track), true) + end + end, + + ChooseTrack = function(self, list, ignore) + list = list and Playlists[list] or Playlists[self.Playlist] + if type(list) ~= "table" then + return + end + if list.mode == "list" then + return self:ChooseNextTrack(list) + end + local total = 0 + for i = 1, #list do + local track = list[i] + if track ~= ignore and not self.Blacklist[track.path] then + total = total + track.frequency + end + end + if total == 0 then return list[1] end + local rand = AsyncRand(total) + for i = 1, #list do + local track = list[i] + if track ~= ignore and not self.Blacklist[track.path] then + rand = rand - track.frequency + if rand < 0 then + return list[i] + end + end + end + end, + + ChooseNextTrack = function(self, list) + assert(#list > 0) + if #list == 0 then + return + end + local start_idx = table.find(list, "path", self.Track and self.Track.path) or #list + local idx = start_idx + while true do + idx = idx + 1 + if idx > #list then + idx = 1 + end + if idx == start_idx then + return + end + local track = list[idx] + assert(track) + if track and not self.Blacklist[track.path] then + return track + end + end + end, + + PlayNextTrack = function(self) + local list = Playlists[self.Playlist] + if type(list) ~= "table" then + return + end + local track = self:ChooseNextTrack(list) + if track then + self:PlayTrack(track, true) + end + end, + + IsTrackRestricted = function(self, track) + return track.restricted + end, + + PlayTrack = function(self, track, fade, time_offset) + if fade == true or fade == nil then + fade = track and track.crossfade or DefaultMusicCrossfadeTime + end + self:StopTrack(fade) + self.Track = nil + Wakeup(self.MusicThread) + if track then + assert(not self.Blacklist[track.path]) + if track.empty then + self.Track = track + self.sound_handle = false + self.sound_duration = track.duration + self.sound_start_time = RealTime() - (time_offset or 0) + self.TracksPlayed = self.TracksPlayed + 1 + DbgMusicPrint(string.format("playing silince for %dms from %s", track.duration, self.Playlist)) + else + local sound_type = self:IsTrackRestricted(track) and SoundTypePresets.MusicRestricted and "MusicRestricted" or "Music" + local playlist = Playlists[self.Playlist] + local volume = playlist and playlist.Volume or self.Volume + local looping, loop_start, loop_end = track.looping, track.loop_start, track.loop_end + local point_or_object, loud_distance + local handle, err = PlaySound( + track.path, sound_type, volume, fade or 0, + looping, point_or_object, loud_distance, + time_offset, loop_start, loop_end) + if handle then + self.Track = track + DbgMusicPrint("playing", track, "from", self.Playlist, "Handle:", handle) + self.sound_handle = handle + self.sound_duration = GetSoundDuration(handle) or -1 + self.sound_start_time = RealTime() - (time_offset or 0) + self.TracksPlayed = self.TracksPlayed + 1 + if playlist and playlist.FadeOutVolume then + self:FadeOutVolume(playlist.FadeOutVolume, playlist.FadeOutTime) + end + end + end + end + end, + + RemainingTrackTime = function(self) + if self.Track.empty then + return Max(self.sound_duration - (RealTime() - self.sound_start_time), 0) + end + + if not self.sound_handle or self.sound_duration <= -1 or self.sound_start_time == -1 then + return 0 + end + local elapsed_time = RealTime() - self.sound_start_time + assert(elapsed_time >= 0) + return self.sound_duration - elapsed_time % self.sound_duration + end, + + StopTrack = function(self, fade) + if fade == true or fade == nil then + fade = self.Track and self.Track.crossfade or DefaultMusicCrossfadeTime + end + if self.sound_handle then + SetSoundVolume(self.sound_handle, -1, fade or 0) + end + self.sound_handle = false + self.sound_duration = -1 + self.sound_start_time = -1 + end, + + SetVolume = function(self, volume, time) + self.Volume = volume + SetSoundVolume(self.sound_handle, volume, time) + end, + + GetVolume = function(self) + return self.Volume + end, + + FadeOutVolume = function(self, fadeout_volume, fadeout_time) + DeleteThread(self.fadeout_thread) + self.fadeout_thread = CreateRealTimeThread(function(fadeout_volume, fadeout_time) + Sleep(fadeout_time) + SetSoundVolume(self.sound_handle, fadeout_volume, self.Track.crossfade or DefaultMusicCrossfadeTime) + self.fadeout_thread = false + end, fadeout_volume, fadeout_time) + end, +} + +if FirstLoad then + Music = false +end + +function SetMusicPlaylist(playlist, fade, force) + Music = Music or MusicClass:new() + Music:SetPlaylist(playlist, fade ~= false, force) +end + +function SetMusicBlacklist(blacklist) + Music = Music or MusicClass:new() + Music:SetBlacklist(blacklist) +end + +function GetMusicPlaylist() + return Music and Music.Playlist +end + +function MusicPlayTrack(track, fade) + Music = Music or MusicClass:new() + Music:PlayTrack(track, fade) +end + +function PlaylistComboItems() + local items = {} + for name, v in pairs(Playlists) do + if type(v) == "table" then + items[#items + 1] = name + end + end + table.sort(items) + return items +end + +function PlaylistTracksCombo(playlist) + playlist = playlist and {playlist} or Playlists + + local tracks = {} + for name, list in pairs(playlist) do + if type(list) == "table" then + for i=1,#list do + tracks[list[i].path] = true + end + end + end + return table.keys(tracks, true) +end \ No newline at end of file diff --git a/CommonLua/NonDevStubs.lua b/CommonLua/NonDevStubs.lua new file mode 100644 index 0000000000000000000000000000000000000000..f9ac602633e8fe4ed7954a21ab06e64ac0faea25 --- /dev/null +++ b/CommonLua/NonDevStubs.lua @@ -0,0 +1,23 @@ +if not Platform.editor then + PropertyHelpers_Refresh = empty_func + IsEditorActive = empty_func + GetDarkModeSetting = empty_func +end +DbgClear = empty_func +DbgSetColor = empty_func +DbgClearColors = empty_func + +SaveMinimap = empty_func +PrepareMinimap = empty_func + +DbgSetErrorOnPassEdit = empty_func +DbgClearErrorOnPassEdit = empty_func + +ToggleFramerateBoost = empty_func + +GetBugReportTagsForGed = empty_func + +function OnMsg.Autorun() + DbgToggleOverlay = rawget(_G, "DbgToggleOverlay") or empty_func + DbgUpdateOverlay = rawget(_G, "DbgUpdateOverlay") or empty_func +end diff --git a/CommonLua/OptionsObject.lua b/CommonLua/OptionsObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..fc2ada11d7faae4b8c9b771a989f2c0e8e6fea9b --- /dev/null +++ b/CommonLua/OptionsObject.lua @@ -0,0 +1,806 @@ +GameDisabledOptions = {} + +if FirstLoad then + OptionsObj = false -- the one that is being edited + OptionsObjOriginal = false -- the one that stores the values when the UI was opened, to be applied on Cancel() +end + +g_PlayStationControllerText = T(521078061184, --[[PS controller]] "Controller") +g_PlayStationWirelessControllerText = T(424526275353, --[[PS controller message]] "Wireless Controller") + +if config.DisableOptions then return end + +MapVar("g_SessionOptions", {}) -- session options in savegame-type games are just a table in the savegame + +function CheckIfMoreThanOneVideoPresetIsAllowed() + local num_allowed_presets = 0 + for _, preset in pairs(OptionsData.Options.VideoPreset) do + if not preset.not_selectable then + num_allowed_presets = num_allowed_presets + 1 + end + end + + return num_allowed_presets > 1 +end + +DefineClass.OptionsObject = { + __parents = { "PropertyObject" }, + shortcuts = false, + props_cache = false, + + -- storage is "local", "account", "session" + -- items is Options.OptionsData[id], if not specified + -- default values are taken from GetDefaultEngineOptions() + properties = { + -- Video + { name = T(590606477665, "Preset"), id = "VideoPreset", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().VideoPreset, no_edit = not CheckIfMoreThanOneVideoPresetIsAllowed() and Platform.goldmaster, + help_text = T(582113441661, "A predefined settings preset for different levels of hardware performance."), }, + { name = T(864821413961, "Antialiasing"), id = "Antialiasing", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Antialiasing, no_edit = Platform.console and Platform.goldmaster, + help_text = T(110177097630, "Smooths out jagged edges, reduces shimmering, and improves overall visual quality."), }, + { name = T(809013434667, "Resolution Percent"), id = "ResolutionPercent", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().ResolutionPercent, no_edit = Platform.console and Platform.goldmaster, + help_text = T(111324603062, "Reduces the internal resolution used to render the game, improving performance at the expense on visual quality."), }, + { name = T(956327389735, "Upscaling"), id = "Upscaling", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Upscaling, + no_edit = function(self) return (Platform.console and Platform.goldmaster) or self.ResolutionPercent == "100" end, + help_text = T(129568659116, "Method used to convert rendering to display resolution."), }, + { name = T(964510417589, "Shadows"), id = "Shadows", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Shadows, no_edit = Platform.console and Platform.goldmaster, + help_text = T(500018129164, "Affects the quality and visibility of in-game sun shadows."), }, + { name = T(940888056560, "Textures"), id = "Textures", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Textures, no_edit = Platform.console and Platform.goldmaster, + help_text = T(532136930067, "Affects the resolution of in-game textures."), }, + { name = T(946251115875, "Anisotropy"), id = "Anisotropy", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Anisotropy, no_edit = Platform.console and Platform.goldmaster, + help_text = T(808058265518, "Affects the clarity of textures viewed at oblique angles."), }, + { name = T(871664438848, "Terrain"), id = "Terrain", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Terrain, no_edit = Platform.console and Platform.goldmaster, + help_text = T(545382529099, "Affects the quality of in-game terrain textures and geometry."), }, + { name = T(318842515247, "Effects"), id = "Effects", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Effects, no_edit = Platform.console and Platform.goldmaster, + help_text = T(563094626410, "Affects the quality of in-game visual effects."), }, + { name = T(484841493487, "Lights"), id = "Lights", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Lights, no_edit = Platform.console and Platform.goldmaster, + help_text = T(307628509612, "Affects the quality and visibility of in-game lights and shadows."), }, + { name = T(682371259474, "Postprocessing"), id = "Postprocess", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Postprocess, no_edit = Platform.console and Platform.goldmaster, + help_text = T(291876705355, "Adds additional effects to improve the overall visual quality."), }, + { name = T(668281727636, "Bloom"), id = "Bloom", category = "Video", storage = "local", editor = "bool", on_value = "On", off_value = "Off", default = GetDefaultEngineOptions().Bloom, no_edit = Platform.console and Platform.goldmaster, + help_text = T(441093875283, "Simulates scattering light, creating a glow around bright objects."), }, + { name = T(886248401356, "Eye Adaptation"), id = "EyeAdaptation", category = "Video", storage = "local", editor = "bool", on_value = "On", off_value = "Off", default = GetDefaultEngineOptions().EyeAdaptation, no_edit = Platform.console and Platform.goldmaster, + help_text = T(663427521283, "Affects the exposure of the image based on the brightess of the scene."), }, + { name = T(281819101205, "Vignette"), id = "Vignette", category = "Video", storage = "local", editor = "bool", on_value = "On", off_value = "Off", default = GetDefaultEngineOptions().Vignette, no_edit = Platform.console and Platform.goldmaster, + help_text = T(177496557870, "Creates a darker border around the edges for a more cinematic feel."), }, + { name = T(364284725511, "Chromatic Aberration"), id = "ChromaticAberration", category = "Video", storage = "local", editor = "bool", on_value = "On", off_value = "Off", default = GetDefaultEngineOptions().ChromaticAberration, no_edit = Platform.console and Platform.goldmaster, + help_text = T(584969955603, "Simulates chromatic abberation due to camera lens imperfections around the image's edges for a more cinematic feel."), }, + { name = T(739108258248, "SSAO"), id = "SSAO", category = "Video", storage = "local", editor = "bool", on_value = "On", off_value = "Off", default = GetDefaultEngineOptions().SSAO, no_edit = Platform.console and Platform.goldmaster, + help_text = T(113014960666, "Simulates the darkening ambient light by nearby objects to improve the depth and composition of the scene."), }, + { name = T(743968865763, "Reflections"), id = "SSR", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().SSR, no_edit = Platform.console and Platform.goldmaster, + help_text = T(806489659507, "Adjust the quality of in-game screen-space reflections."), }, + { name = T(799060022637, "View Distance"), id = "ViewDistance", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().ViewDistance, no_edit = Platform.console and Platform.goldmaster, + help_text = T(987276010188, "Affects how far the game will render objects and effects in the distance."), }, + { name = T(595681486860, "Object Detail"), id = "ObjectDetail", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().ObjectDetail, no_edit = Platform.console and Platform.goldmaster, + help_text = T(351986265823, "Affects the number of less important objects and the overall level of detail."), }, + { name = T(717555024369, "Framerate Counter"), id = "FPSCounter", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().FPSCounter, no_edit = Platform.console and Platform.goldmaster, + help_text = T(773245251495, "Displays a framerate counter in the upper-right corner of the screen."), }, + { name = T(489981061317, "Sharpness"), id = "Sharpness", category = "Video", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Sharpness, no_edit = Platform.console and Platform.goldmaster, + help_text = T(540870423363, "Affects the sharpness of the image"), }, + + -- Audio + { name = T(723387039210, "Master Volume"), id = "MasterVolume", category = "Audio", storage = "local", editor = "number", min = 0, max = const.MasterMaxVolume or 1000, slider = true, default = GetDefaultEngineOptions().MasterVolume, step = (const.MasterMaxVolume or 1000)/100, + help_text = T(963240239070, "Sets the overall audio volume."), }, + { name = T(490745782890, "Music"), id = "Music", category = "Audio", storage = "local", editor = "number", min = 0, max = const.MusicMaxVolume or 600, slider = true, default = GetDefaultEngineOptions().Music, step = (const.MusicMaxVolume or 600)/100, + no_edit = function () return not IsSoundOptionEnabled("Music") end, + help_text = T(186072536391, "Sets the volume for the music."), }, + { name = T(397364000303, "Voice"), id = "Voice", category = "Audio", storage = "local", editor = "number", min = 0, max = const.VoiceMaxVolume or 1000, slider = true, default = GetDefaultEngineOptions().Voice, step = (const.VoiceMaxVolume or 1000)/100, + no_edit = function () return not IsSoundOptionEnabled("Voice") end, + help_text = T(792392113273, "Sets the volume for all voiced content."), }, + { name = T(163987433981, "Sounds"), id = "Sound", category = "Audio", storage = "local", editor = "number", min = 0, max = const.SoundMaxVolume or 1000, slider = true, default = GetDefaultEngineOptions().Sound, step = (const.SoundMaxVolume or 1000)/100, + no_edit = function () return not IsSoundOptionEnabled("Sound") end, + help_text = T(582366412662, "Sets the volume for the sound effects like gunshots and explosions."), }, + { name = T(316134644192, "Ambience"), id = "Ambience", category = "Audio", storage = "local", editor = "number", min = 0, max = const.AmbienceMaxVolume or 1000, slider = true, default = GetDefaultEngineOptions().Ambience, step = (const.AmbienceMaxVolume or 1000)/100, + no_edit = function () return not IsSoundOptionEnabled("Ambience") end, + help_text = T(674715210365, "Sets the volume for the non-ambient sounds like the sounds of waves and rain"), }, + { name = T(706332531616, "UI"), id = "UI", category = "Audio", storage = "local", editor = "number", min = 0, max = const.UIMaxVolume or 1000, slider = true, default = GetDefaultEngineOptions().UI, step = (const.UIMaxVolume or 1000)/100, + no_edit = function () return not IsSoundOptionEnabled("UI") end, + help_text = T(597810411326, "Sets the volume for the user interface sounds."), }, + { name = T(362201382843, "Mute when Minimized"), id = "MuteWhenMinimized", category = "Audio", storage = "local", editor = "bool", default = GetDefaultEngineOptions().MuteWhenMinimized, no_edit = Platform.console, + help_text = T(365470337843, "All sounds will be muted when the game is minimized."), }, + { name = T(3583, "Radio Station"), id = "RadioStation", category = "Audio", editor = "choice", default = GetDefaultEngineOptions().RadioStation, + items = function() return DisplayPresetCombo("RadioStationPreset")() end, + no_edit = not config.Radio, }, + + --Gameplay + --{ name = T{"Subtitles"}, id = "Subtitles", category = "Gameplay", storage = "account", editor = "bool", default = GetDefaultAccountOptions().Subtitles}, + --{ name = T{"Colorblind Mode"}, id = "Colorblind", category = "Gameplay", storage = "account", editor = "bool", default = GetDefaultAccountOptions().Colorblind}, + { name = T(243042020683, "Language"), id = "Language", category = "Gameplay", SortKey = -1000, storage = "account", editor = "choice", default = GetDefaultAccountOptions().Language, no_edit = Platform.console, + help_text = T(769937279342, "Sets the game language."), }, + { name = T(267365977133, "Camera Shake"), id = "CameraShake", category = "Gameplay", SortKey = -900, storage = "account", editor = "bool", on_value = "On", off_value = "Off", default = GetDefaultEngineOptions().CameraShake, + help_text = T(456226716309, "Allow camera shake effects."), }, + + -- Display + { name = T(273206229320, "Fullscreen Mode"), id = "FullscreenMode", category = "Display", storage = "local", editor = "choice", default = GetDefaultEngineOptions().FullscreenMode, + help_text = T(597120074418, "The game may run in a window or on the entire screen.")}, + { name = T(124888650840, "Resolution"), id = "Resolution", category = "Display", storage = "local", editor = "choice", default = GetDefaultEngineOptions().Resolution, + help_text = T(515304581653, "The number of pixels rendered on the screen; higher resolutions provide sharper and more detailed images."), }, + { name = T(276952502249, "Vsync"), id = "Vsync", category = "Display", storage = "local", editor = "bool", default = GetDefaultEngineOptions().Vsync, + help_text = T(456307855876, "Synchronizes the game's frame rate with the screen's refresh rate thus eliminating screen tearing. Enabling it may reduce performance."), }, + { name = T(731920619011, "Graphics API"), id = "GraphicsApi", category = "Display", storage = "local", editor = "choice", default = GetDefaultEngineOptions().GraphicsApi, no_edit = function () return not Platform.pc end, + help_text = T(184665121668, "The DirectX version used by the game renderer."), }, + { name = T(899898011812, "Graphics Adapter"), id = "GraphicsAdapterIndex", category = "Display", storage = "local", dont_save = true, editor = "choice", default = GetDefaultEngineOptions().GraphicsAdapterIndex, no_edit = function () return not Platform.pc end, + help_text = T(988464636767, "The GPU that would be used for rendering. Please use the dedicated GPU if possible."), }, + { name = T(418391988068, "Frame Rate Limit"), id = "MaxFps", category = "Display", storage = "local", editor = "choice", default = GetDefaultEngineOptions().MaxFps, + help_text = T(190152008288, "Limits the maximum number of frames that the GPU will render per second."), }, + { name = T(313994466701, "Display Area Margin"), id = "DisplayAreaMargin", category = Platform.console and "Video" or "Display", storage = "local", editor = "number", min = const.MinDisplayAreaMargin, max = const.MaxDisplayAreaMargin, slider = true, default = GetDefaultEngineOptions().DisplayAreaMargin, no_edit = not Platform.xbox and Platform.goldmaster }, + { name = T(106738401126, "UI Scale"), id = "UIScale", category = Platform.console and "Video" or "Display", storage = "local", editor = "number", min = function() return const.MinUserUIScale end, max = function() return const.MaxUserUIScaleHighRes end, slider = true, default = GetDefaultEngineOptions().UIScale, step = 5, snap_offset = 5, + help_text = T(316233466560, "Affects the size of the user interface elements, such as menus and text."), }, + { name = T(106487158051, "Brightness"), id = "Brightness", category = Platform.console and "Video" or "Display", storage = "local", editor = "number", min = -50, max = 1050, slider = true, default = GetDefaultEngineOptions().Brightness, step = 50, snap_offset = 50, + help_text = T(144889353073, "Affects the overall brightness level."), }, + } +} + +function OptionsObject:GetShortcuts() + self["shortcuts"] = {} + self.props_cache = false + if Platform.console and not g_KeyboardConnected then + return + end + local actions = XShortcutsTarget and XShortcutsTarget:GetActions() + if actions then + for _, action in ipairs(actions) do + if action.ActionBindable then + local id = action.ActionId + local defaultActions = false + if action.default_ActionShortcut and action.default_ActionShortcut ~= "" then + defaultActions = defaultActions or {} + defaultActions[1] = action.default_ActionShortcut + end + if action.default_ActionShortcut2 and action.default_ActionShortcut2 ~= "" then + defaultActions = defaultActions or {} + defaultActions[2] = action.default_ActionShortcut2 + end + if action.default_ActionGamepad and action.default_ActionGamepad ~= "" then + defaultActions = defaultActions or {} + defaultActions[3] = action.default_ActionGamepad + end + self[id] = defaultActions + table.insert(self["shortcuts"], { + name = action.ActionName, + id = id, + sort_key = action.ActionSortKey, + mode = action.ActionMode or "", + category = "Keybindings", + action_category = action.BindingsMenuCategory, + storage = "shortcuts", + editor = "hotkey", + keybinding = true, + default = defaultActions, + mouse_bindable = action.ActionMouseBindable, + single_key = action.ActionBindSingleKey, + }) + end + end + end + table.stable_sort(self["shortcuts"], function(a, b) + if a.action_category == b.action_category then + return a.sort_key < b.sort_key + else + return a.action_category < b.action_category + end + end) + + local currentCategory = false + for i, s in ipairs(self["shortcuts"]) do + local newCategory = s.action_category + if currentCategory ~= newCategory then + local preset = table.get(Presets, "BindingsMenuCategory", "Default", newCategory) + s.separator = preset and preset.Name or Untranslated(newCategory) + end + currentCategory = newCategory + end +end + +function OptionsObject:GetProperties() + if self.props_cache then + return self.props_cache + end + local props = {} + local static_props = PropertyObject.GetProperties(self) + -- add keybindings + if not self["shortcuts"] or not next(self["shortcuts"]) then + self:GetShortcuts() + end + props = table.copy(static_props,"deep") + table.stable_sort(props, function(a,b) + return (a.SortKey or 0) < (b.SortKey or 0) + end) + props = table.iappend(props,self["shortcuts"]) + self.props_cache = props + return props +end + +function OptionsObject:SaveToTables(properties) + properties = properties or self:GetProperties() + local storage_tables = { + ["local"] = EngineOptions, + account = AccountStorage and AccountStorage.Options, + session = g_SessionOptions, + shortcuts = AccountStorage and AccountStorage.Shortcuts, + } + for _, prop in ipairs(properties) do + local storage = prop.storage or "account" + local storage_table = storage_tables[storage] + if storage_table then + local saved_value + local value = self:GetProperty(prop.id) + -- only add differences to storage tables + local default = prop_eval(prop.default, self, prop) + + if value ~= default then + if type(value) == "table" then + saved_value = table.copy(value) + for key, val in pairs(saved_value or empty_table) do + if default and default[key] == val then + saved_value[key] = nil + end + end + if not next(saved_value) then + saved_value = nil + end + else + saved_value = prop_eval(value, self, prop) + end + end + storage_table[prop.id] = saved_value + end + end +end + +-- Returns a table with the default values of all option properties with the given storage +-- This will capture option props defined only in one game and those who don't have defaults in DefaultEngineOptions +function GetTableWithStorageDefaults(storage) + local obj = OptionsObject:new() + local defaults_table = {} + for _, prop in ipairs(obj:GetProperties()) do + local prop_storage = prop.storage or "account" + + if not storage or prop_storage == storage then + defaults_table[prop.id] = prop_eval(prop.default, obj, prop) + end + end + + return defaults_table +end + +function OptionsObject:SetProperty(id, value) + local ret = PropertyObject.SetProperty(self, id, value) + local preset = self.VideoPreset + if OptionsData.VideoPresetsData[preset] and OptionsData.VideoPresetsData[preset][id] and + PresetVideoOptions[id] and value ~= self:FindFirstSelectable(id, OptionsData.VideoPresetsData[preset][id]) then + PropertyObject.SetProperty(self, "VideoPreset", "Custom") + ObjModified(self) + end + return ret +end + +function OptionsObject:SyncUpscaling() + local not_selectable = function(item) + if type(item.not_selectable) == "function" then + return item.not_selectable(item, self) + end + return item.not_selectable + end + + if self.ResolutionPercent == "100" and not IsTemporalAntialiasingOption(self.Antialiasing) then + if self.Upscaling ~= "Off" then + PropertyObject.SetProperty(self, "Upscaling", "Off") + end + return + end + + local uscaling_option = table.find_value(OptionsData.Options.Upscaling, "value", self.Upscaling) + if not_selectable(uscaling_option) then + local upscaling_index = table.findfirst(OptionsData.Options.Upscaling, function(idx, item) return not not_selectable(item) end) + uscaling_option = OptionsData.Options.Upscaling[upscaling_index] + if self.Upscaling ~= uscaling_option.value then + PropertyObject.SetProperty(self, "Upscaling", uscaling_option.value) + end + end + + local resolution_percent_option = table.find_value(OptionsData.Options.ResolutionPercent, "value", self.ResolutionPercent) + if not resolution_percent_option then + --handle case where self.ResolutionPercent is not in the list OptionsData.Options.ResolutionPercent + --this way it will go in the logic below to find the closest available option + resolution_percent_option = { hr = {ResolutionPercent = self.ResolutionPercent}, not_selectable = function() return true end } + end + if not_selectable(resolution_percent_option) then + local original_percent = resolution_percent_option.hr.ResolutionPercent + local closest_percent = max_int + for _, current_option in ipairs(OptionsData.Options.ResolutionPercent) do + if not not_selectable(current_option) then + local current_percent = current_option.hr.ResolutionPercent + if abs(current_percent - original_percent) < abs(closest_percent - original_percent) then + closest_percent = current_percent + resolution_percent_option = current_option + end + end + end + end + + if self.ResolutionPercent ~= resolution_percent_option.value then + PropertyObject.SetProperty(self, "ResolutionPercent", resolution_percent_option.value) + end +end + +function OptionsObject:SetAntialiasing(value) + self.Antialiasing = value + self:SyncUpscaling() +end + +function OptionsObject:SetResolutionPercent(value) + self.ResolutionPercent = value + self:SyncUpscaling() +end + +function OptionsObject:SetUpscaling(value) + self.Upscaling = value + self:SyncUpscaling() +end + +function IsSoundOptionEnabled(option) + return config.SoundOptionGroups[option] +end + +function OptionsObject:SetMasterVolume(x) + self.MasterVolume = x + for option in pairs(config.SoundOptionGroups) do + self:UpdateOptionVolume(option) + end +end + +function OptionsObject:UpdateOptionVolume(option, volume) + volume = volume or self[option] + self[option] = volume + SetOptionVolume(option, volume * self.MasterVolume / 1000) +end + +function OnMsg.ClassesPreprocess() + for option in sorted_pairs(config.SoundOptionGroups) do + OptionsObject["Set" .. option] = function(self, x) + self:UpdateOptionVolume(option, x) + end + end +end + +function OptionsObject:SetMuteWhenMinimized(x) + self.MuteWhenMinimized = x + config.DontMuteWhenInactive = not x +end + +function OptionsObject:SetMuteWhenMinimized(x) + self.MuteWhenMinimized = x + config.DontMuteWhenInactive = not x +end + +function OptionsObject:SetBrightness(x) + self.Brightness = x + ApplyBrightness(x) +end + +function OptionsObject:SetDisplayAreaMargin(x) + if self.DisplayAreaMargin == x then return end + self.DisplayAreaMargin = x + self:UpdateUIScale() +end + +function OptionsObject:UpdateUIScale() + if Platform.playstation or not const.UIScaleDAMDependant then return end + + local dam_value = self.DisplayAreaMargin or 0 + local ui_scale_value = self.UIScale or 100 + local mapped_value = MapRange(dam_value, const.MinUserUIScale, const.MaxUserUIScaleHighRes, const.MaxDisplayAreaMargin, const.MinDisplayAreaMargin) + + local prop_meta = self:GetPropertyMetadata("UIScale") + local step = prop_meta and prop_meta.step or 1 + self.UIScale = Clamp(Min(ui_scale_value, round(mapped_value, step)), const.MinUserUIScale, const.MaxUserUIScaleHighRes) +end + +function OptionsObject:SetUIScale(x) + if self.UIScale == x then return end + self.UIScale = x + self:UpdateDisplayAreaMargin() +end + +function OptionsObject:UpdateDisplayAreaMargin() + if Platform.playstation or not const.UIScaleDAMDependant then return end + + local dam_value = self.DisplayAreaMargin or 0 + local ui_scale_value = self.UIScale or 100 + local mapped_value = MapRange(ui_scale_value, const.MinDisplayAreaMargin, const.MaxDisplayAreaMargin, const.MaxUserUIScaleHighRes, const.MinUserUIScale) + + local prop_meta = self:GetPropertyMetadata("DisplayAreaMargin") + local step = prop_meta and prop_meta.step or 1 + self.DisplayAreaMargin = Clamp(Min(dam_value, round(mapped_value, step)), const.MinDisplayAreaMargin, const.MaxDisplayAreaMargin) +end + +function OptionsCreateAndLoad() + local storage_tables = { + ["local"] = EngineOptions, + account = AccountStorage and AccountStorage.Options, + session = g_SessionOptions, + shortcuts = AccountStorage and AccountStorage.Shortcuts, + } + EngineOptions.DisplayIndex = GetMainWindowDisplayIndex() + Options.InitVideoModesCombo() + local obj = OptionsObject:new() + for _, prop in ipairs(obj:GetProperties()) do + local storage = prop.storage or "account" + local storage_table = storage_tables[storage] + if storage_table then + local default = prop_eval(prop.default, obj, prop) + local value = storage_table[prop.id] + local loaded_val + + if value ~= default then + if type(value) == "table" then + -- merge saved and default values + loaded_val = table.copy(value) + table.set_defaults(loaded_val, default) + elseif value ~= nil then + loaded_val = prop_eval(value, obj, prop) + else + loaded_val = default + end + end + if loaded_val ~= nil then --false could be a valid value + obj:SetProperty(prop.id, loaded_val) + end + end + end + Options.InitGraphicsAdapterCombo(obj.GraphicsApi) + return obj +end + +function OptionsObject:SetGraphicsApi(api) + self.GraphicsApi = api + local adapterData = EngineOptions.GraphicsAdapter or {} + Options.InitGraphicsAdapterCombo(api) + self:SetProperty("GraphicsAdapterIndex", GetRenderDeviceAdapterIndex(api, adapterData)) +end + +function OptionsObject:SetGraphicsAdapterIndex(adapterIndex) + self.GraphicsAdapterIndex = adapterIndex + EngineOptions.GraphicsAdapter = GetRenderDeviceAdapterData(self.GraphicsApi, adapterIndex) +end + +function OptionsObject:FindFirstSelectable(option, item_names) + local is_selectable = function(option, item_name, options_obj) + local item = table.find_value(OptionsData.Options[option], "value", item_name) + local not_selectable = item.not_selectable + if type(not_selectable) == "function" then + not_selectable = not_selectable(item, options_obj) + end + return not not_selectable + end + + if type(item_names) == "function" then + item_names = item_names() + end + if type(item_names) == "table" then + for _, item_name in ipairs(item_names) do + if is_selectable(option, item_name, self) then + return item_name + end + end + else + if is_selectable(option, item_names, self) then + return item_names + end + end +end + +function OptionsObject:SetVideoPreset(preset) + for k, v in pairs(OptionsData.VideoPresetsData[preset]) do + local first_selectable = self:FindFirstSelectable(k, v) + if first_selectable then + self:SetProperty(k, first_selectable) + else + printf("Video preset %s's option %s is not a selectable value %s!", preset, k, tostring(v)) + end + end + self.VideoPreset = preset +end + +function OptionsObject:WaitApplyOptions() + self:SaveToTables() + Options.ApplyEngineOptions(EngineOptions) + WaitNextFrame(2) + Msg("OptionsApply") + return true +end + +function ApplyVideoPreset(preset) + local obj = OptionsCreateAndLoad() + obj:SetVideoPreset(preset) + ApplyOptionsObj(obj) +end + +function ApplyOptionsObj(obj) + obj:SaveToTables() + Options.ApplyEngineOptions(EngineOptions) + Msg("OptionsApply") +end + +function OptionsObject:CopyCategoryTo(other, category) + for _, prop in ipairs(self:GetProperties()) do + if prop.category == category then + local value = self:GetProperty(prop.id) + value = type(value) == "table" and table.copy(value) or value + other:SetProperty(prop.id, value) + end + end +end + +function WaitChangeVideoMode() + while GetVideoModeChangeStatus() == 1 do + Sleep(50) + end + + if GetVideoModeChangeStatus() ~= 0 then + return false + end + + -- wait a few frames for all systems to allocate resources for the new video mode + for i = 1, 2 do + WaitNextFrame() + end + + return true +end + +function OptionsObject:FindValidVideoMode(display) + local modes = GetVideoModes(display, 1024, 720) + table.sort(modes, function(a, b) + if a.Height ~= b.Height then + return a.Height > b.Height + end + return a.Width > b.Width + end) + local best = modes[1] + self.Resolution = point(best.Width, best.Height) +end + +function OptionsObject:IsValidVideoMode(display) + local modes = GetVideoModes(display, 1024, 720) + for _, mode in ipairs(modes) do + if mode.Width == self.Resolution:x() and + mode.Height == self.Resolution:y() then + return true + end + end + return false +end + +function OptionsObject:ApplyVideoMode() + local display = GetMainWindowDisplayIndex() + ChangeVideoMode(self.Resolution:x(), self.Resolution:y(), self.FullscreenMode, self.Vsync, false) + + if not WaitChangeVideoMode() then return false end + --recalc viewport + SetupViews() + EngineOptions.DisplayIndex = display + local UIScale_meta = self:GetPropertyMetadata("UIScale") + self:SaveToTables({UIScale_meta}) + terminal.desktop:OnSystemSize(UIL.GetScreenSize()) + + local value = table.find_value(OptionsData.Options.MaxFps, "value", self.MaxFps) + if value then + for k,v in pairs(value.hr or empty_table) do + hr[k] = v + end + end + Msg("VideoModeApplied") + + if self.FullscreenMode > 0 then + return "confirmation" + end + + return true +end + +function OptionsObject:ResetOptionsByCategory(category, sub_category, additional_skip_props) + additional_skip_props = additional_skip_props or {} + if category == "Keybindings" then + if sub_category then + for key, shortcut in pairs(AccountStorage.Shortcuts) do + local actionCat = table.find_value(OptionsObj:GetProperties(),"id",key).action_category + if actionCat == sub_category then + AccountStorage.Shortcuts[key] = nil + end + end + else + AccountStorage.Shortcuts = {} + end + ReloadShortcuts() + self:GetShortcuts() + end + local skip_props = {} + if category == "Display" then + skip_props = { + FullscreenMode = true, + Resolution = true, + } + end + for _, prop in ipairs(self:GetProperties()) do + local isFromSubCat = sub_category and prop.action_category == sub_category or not sub_category + if prop.category == category and not skip_props[prop.id] and not additional_skip_props[prop.id] and not GameDisabledOptions[prop.id] and not prop_eval(prop.no_edit, self, prop) and isFromSubCat then + local default = table.find_value(OptionsData.Options[prop.id], "default", true) + local default_prop_value = self:GetDefaultPropertyValue(prop.id) + default = (default and default.value) or (default_prop_value and prop_eval(default_prop_value, self, prop)) or false + --copy from default value so that we don't try to write to it or remove it afterwards + if type(default) == "table" then + default = table.copy(default) + end + self:SetProperty(prop.id, default) + end + end +end + +function OptionsObject:SetRadioStation(station) + local old = rawget(self, "RadioStation") + if not old or old ~= station then + self.RadioStation = station + StartRadioStation(station) + end +end + +function GetAccountStorageOptionValue(prop_id) + local value = table.get(AccountStorage, "Options", prop_id) + if value ~= nil then return value end + return rawget(OptionsObject, prop_id) +end + +function SyncCameraControllerSpeedOptions() +end + +function SetAccountStorageOptionValue(prop_id, val) + if AccountStorage and AccountStorage.Options and AccountStorage.Options[prop_id] then + AccountStorage.Options[prop_id] = val + end +end + +function ApplyOptions(host, next_mode) + CreateRealTimeThread(function(host) + if host.window_state == "destroying" then return end + local obj = ResolvePropObj(host.context) + local original_obj = ResolvePropObj(host.idOriginalOptions.context) + local category = host:GetCategoryId() + if not obj:WaitApplyOptions() then + WaitMessage(terminal.desktop, T(824112417429, "Warning"), T(862733805364, "Changes could not be applied and will be reverted."), T(325411474155, "OK")) + else + local object_detail_changed = obj.ObjectDetail ~= original_obj.ObjectDetail + obj:CopyCategoryTo(original_obj, category) + SaveEngineOptions() + SaveAccountStorage(5000) + ReloadShortcuts() + ApplyLanguageOption() + if category == obj:GetPropertyMetadata("UIScale").category then + terminal.desktop:OnSystemSize(UIL.GetScreenSize()) -- force refresh, UIScale might be changed + end + if object_detail_changed then + SetObjectDetail(obj.ObjectDetail) + end + Msg("GameOptionsChanged", category) + end + if not next_mode then + SetBackDialogMode(host) + else + SetDialogMode(host, next_mode) + end + end, host) +end + +function CancelOptions(host, next_mode) + CreateRealTimeThread(function(host) + if host.window_state == "destroying" then return end + local obj = ResolvePropObj(host.context) + local original_obj = ResolvePropObj(host.idOriginalOptions.context) + local category = host:GetCategoryId() + original_obj:WaitApplyOptions() + original_obj:CopyCategoryTo(obj,category) + if not next_mode then + SetBackDialogMode(host) + else + SetDialogMode(host, next_mode) + end + end, host) +end + +function ApplyDisplayOptions(host, next_mode) + CreateRealTimeThread( function(host) + if host.window_state == "destroying" then return end + local obj = ResolvePropObj(host.context) + local original_obj = ResolvePropObj(host.idOriginalOptions.context) + local graphics_device_changed = obj.GraphicsApi ~= original_obj.GraphicsApi + if not graphics_device_changed then + local originalAdapter = GetRenderDeviceAdapterData(original_obj.GraphicsApi, original_obj.GraphicsAdapterIndex) + local adapter = GetRenderDeviceAdapterData(obj.GraphicsApi, obj.GraphicsAdapterIndex) + graphics_device_changed = + originalAdapter.vendorId ~= adapter.vendorId or + originalAdapter.deviceId ~= adapter.deviceId or + originalAdapter.localId ~= adapter.localId + end + local ok = obj:ApplyVideoMode() + if ok == "confirmation" then + ok = WaitQuestion(terminal.desktop, T(145768933497, "Video mode change"), T(751908098091, "The video mode has been changed. Keep changes?"), T(689884995409, "Yes"), T(782927325160, "No")) == "ok" + end + --options obj should always show the current resolution + obj:SetProperty("Resolution", point(GetResolution())) + if ok then + obj:CopyCategoryTo(original_obj, "Display") + original_obj:SaveToTables() + SaveEngineOptions() -- save the original + the new display options to disk, in case user cancels options menu + else + -- user doesn't like it, restore + original_obj:ApplyVideoMode() + original_obj:CopyCategoryTo(obj, "Display") + end + if graphics_device_changed then + WaitMessage(terminal.desktop, T(1000599, "Warning"), T(714163709235, "Changing the Graphics API or Graphics Adapter options will only take effect after the game is restarted."), T(325411474155, "OK")) + end + if not next_mode then + SetBackDialogMode(host) + else + SetDialogMode(host, next_mode) + end + end, host) +end + +function CancelDisplayOptions(host, next_mode) + local obj = ResolvePropObj(host.context) + local original_obj = ResolvePropObj(host.idOriginalOptions.context) + original_obj:CopyCategoryTo(obj, "Display") + obj:SetProperty("Resolution", point(GetResolution())) + if not next_mode then + SetBackDialogMode(host) + else + SetDialogMode(host, next_mode) + end +end + +function SetupOptionRollover(rollover, options_obj, option) + local help_title = prop_eval(option.help_title, option, options_obj) + local help_text = prop_eval(option.help_text, option, options_obj) + if help_text then + rollover:SetRolloverText(help_text) + if not help_title then + help_title = prop_eval(option.name, option, options_obj) + end + end + if help_title then + rollover:SetRolloverTitle(help_title) + end +end + +function DbgLoadOptions(video_preset, options_obj) + assert(options_obj and options_obj.class) + + if video_preset and video_preset ~= "Custom" then + options_obj:SetVideoPreset(video_preset) + end + + CreateRealTimeThread(function() + options_obj:ApplyVideoMode() + end) + + ApplyOptionsObj(options_obj) +end + +function GetOptionsString() + local options_obj = OptionsCreateAndLoad() + -- Serialize all prop values, even defaults, in case they've been changed since the bug was reported. + -- However we can skip the shortcuts/keybindings. + options_obj.IsDefaultPropertyValue = function(self, id, prop, value) + return prop.storage == "shortcuts" and true or false + end + return string.format("DbgLoadOptions(\"%s\", %s)\n", options_obj.VideoPreset, ValueToLuaCode(options_obj)) +end + +function ToggleFullscreen() + OptionsObj = OptionsObj or OptionsCreateAndLoad() + OptionsObj.FullscreenMode = FullscreenMode() == 0 and 1 or 0 + OptionsObj:ApplyVideoMode() + OptionsObj:SaveToTables() + SaveEngineOptions() +end diff --git a/CommonLua/PGOTrain.lua b/CommonLua/PGOTrain.lua new file mode 100644 index 0000000000000000000000000000000000000000..6f5bbda36e751cc911f503d774b8ed665819c3fe --- /dev/null +++ b/CommonLua/PGOTrain.lua @@ -0,0 +1,53 @@ +local function CameraThread() + local time_factor = 5000 + SetTimeFactor(time_factor) + while true do + --rotate cam positions. Take random objects and look at them + local target = AsyncRand(MapGet("map") or empty_table) + ViewObject(target, 500) + Sleep(2000) + SetTimeFactor(time_factor) + end +end + +local start_on_loading_screen_close = false + +function RunPGOTrain() + local trainMap = config.TrainMap or "TrainMap.savegame.sav" + + local PgoDataFolder = string.match(GetAppCmdLine(), "-PGOTrain=([^ ]*)") + if not PgoDataFolder or not io.exists(PgoDataFolder) then + quit(1) + end + PgoDataFolder = SlashTerminate(PgoDataFolder) + config.PgoTrainDataFolder = PgoDataFolder + PgoDataFolder = PgoDataFolder .. "saves:/" + + if not io.exists(PgoDataFolder .. trainMap) then + print("Savefile not found!") + quit(1) + end + + -- load train map + GetPCSaveFolder = function() + return PgoDataFolder + end + + start_on_loading_screen_close = true + LoadGame(trainMap) +end + +function OnMsg.LoadingScreenPreClose() + if start_on_loading_screen_close and Platform.pgo_train and config.PgoTrainDataFolder then + start_on_loading_screen_close = false + DebugPrint("Starting up PGO threads\n") + CreateRealTimeThread(CameraThread) + CreateRealTimeThread(function() + Sleep(60 * 1000) + DebugPrint("Sweeping and exiting.\n") + PgoAutoSweep("PGOResult") + Sleep(2000) --Give some time to PgoAutoSweep to write the data. + quit(0) + end) + end +end \ No newline at end of file diff --git a/CommonLua/PhotoMode.lua b/CommonLua/PhotoMode.lua new file mode 100644 index 0000000000000000000000000000000000000000..3d4cb02c4746f2d09a6786d4790070b0e0e6850a --- /dev/null +++ b/CommonLua/PhotoMode.lua @@ -0,0 +1,393 @@ +if FirstLoad then + g_PhotoMode = false + PhotoModeObj = false + + -- Used in PP_Rebuild + g_PhotoFilter = false + g_PhotoFilterData = false +end + +function PhotoModeDialogOpen() -- override in project + OpenDialog("PhotoMode") +end + +local function ActivateFreeCamera() + Msg("PhotoModeFreeCameraActivated") + --table.change(hr, "FreeCamera", { FarZ = 1500000 }) + local _, _, camType, zoom, properties, fov = GetCamera() + PhotoModeObj.initialCamera = { + camType = camType, + zoom = zoom, + properties = properties, + fov = fov + } + cameraFly.Activate(1) + cameraFly.DisableStickMovesChange() + if g_MouseConnected then + SetMouseDeltaMode(true) + end +end + +local function DeactivateFreeCamera() + if g_MouseConnected then + SetMouseDeltaMode(false) + end + cameraFly.EnableStickMovesChange() + local current_pos, current_look_at = GetCamera() + if config.PhotoMode_FreeCameraPositionChange then + SetCamera(current_pos, current_look_at, PhotoModeObj.initialCamera.camType, PhotoModeObj.initialCamera.zoom, PhotoModeObj.initialCamera.properties, PhotoModeObj.initialCamera.fov) + end + PhotoModeObj.initialCamera = false + Msg("PhotoModeFreeCameraDeactivated") +end + +function PhotoModeEnd() + if PhotoModeObj then + CreateMapRealTimeThread(function() + PhotoModeObj:Save() + end) + end + if g_PhotoFilter and g_PhotoFilter.deactivate then + g_PhotoFilter.deactivate(g_PhotoFilter.filter, g_PhotoFilterData) + end + g_PhotoMode = false + g_PhotoFilter = false + g_PhotoFilterData = false + --restore from initial values + table.restore(hr, "photo_mode") + --rebuild the postprocess + PP_Rebuild() + --restore lightmodel + SetLightmodel(1, PhotoModeObj.preStoredVisuals.lightmodel, 0) + table.insert(PhotoModeObj.preStoredVisuals.dof_params, 0) + SetDOFParams(unpack_params(PhotoModeObj.preStoredVisuals.dof_params)) + SetGradingLUT(1, ResourceManager.GetResourceID(PhotoModeObj.preStoredVisuals.LUT:GetResourcePath()), 0, 0) + Msg("PhotoModeEnd") +end + +function PhotoModeApply(pm_object, prop_id) + if prop_id == "filter" then + if g_PhotoFilter and g_PhotoFilter.deactivate then + g_PhotoFilter.deactivate(g_PhotoFilter.filter, g_PhotoFilterData) + end + local filter = PhotoFilterPresetMap[pm_object.filter] + if filter and filter.shader_file ~= "" then + g_PhotoFilterData = {} + if filter.activate then + filter.activate(filter, g_PhotoFilterData) + end + g_PhotoFilter = filter:GetShaderDescriptor() + else + g_PhotoFilter = false + g_PhotoFilterData = false + end + if not filter then + pm_object:SetProperty("filter", pm_object.filter) --revert to default filter + end + PP_Rebuild() + elseif prop_id == "fogDensity" then + SetSceneParam(1, "FogGlobalDensity", pm_object.fogDensity, 0, 0) + elseif prop_id == "bloomStrength" then + SetSceneParamVector(1, "Bloom", 0, pm_object.bloomStrength, 0, 0) + elseif prop_id == "exposure" then + SetSceneParam(1, "GlobalExposure", pm_object.exposure, 0, 0) + elseif prop_id == "ae_key_bias" then + SetSceneParam(1, "AutoExposureKeyBias", pm_object.ae_key_bias, 0, 0) + elseif prop_id == "vignette" then + SetSceneParamFloat(1, "VignetteDarkenOpacity", (1.0 * pm_object.vignette) / pm_object:GetPropertyMetadata("vignette").scale, 0, 0) + elseif prop_id == "colorSat" then + SetSceneParam(1, "Desaturation", -pm_object.colorSat, 0, 0) + elseif prop_id == "depthOfField" or prop_id == "focusDepth" or prop_id == "defocusStrength" then + local detail = 3 + local focus_depth = Lerp(hr.NearZ, hr.FarZ, pm_object.focusDepth ^ detail, 100 ^ detail) + local dof = Lerp(0, hr.FarZ - hr.NearZ, pm_object.depthOfField ^ detail, 100 ^ detail) + local strength = sqrt(pm_object.defocusStrength * 100) + SetDOFParams( + strength, + Max(focus_depth - dof / 3, hr.NearZ), + Max(focus_depth - dof / 6, hr.NearZ), + strength, + Min(focus_depth + dof / 3, hr.FarZ), + Min(focus_depth + dof * 2 / 3, hr.FarZ), + 0) + elseif prop_id == "freeCamera" then + if pm_object.freeCamera then + ActivateFreeCamera() + else + DeactivateFreeCamera() + end + return -- don't send Msg + elseif prop_id == "fov" then + camera.SetAutoFovX(1, 0, pm_object.fov, 16, 9) + elseif prop_id == "frame" then + pm_object:ToggleFrame() + elseif prop_id == "LUT" then + if pm_object.LUT == "None" then + SetGradingLUT(1, ResourceManager.GetResourceID(pm_object.preStoredVisuals.LUT:GetResourcePath()), 0, 0) + else + SetGradingLUT(1, ResourceManager.GetResourceID(GradingLUTs[pm_object.LUT]:GetResourcePath()), 0, 0) + end + end + Msg("PhotoModePropertyChanged") +end + +function PhotoModeDoTakeScreenshot(frame_duration, max_frame_duration) + local hideUIWindow + if not config.PhotoMode_DisablePhotoFrame and PhotoModeObj.photoFrame then + table.change(hr, "photo_mode_frame_screenshot", { + InterfaceInScreenshot = true, + }) + hideUIWindow = GetDialog("PhotoMode").idHideUIWindow + hideUIWindow:SetVisible(false) + end + PhotoModeObj.shotNum = PhotoModeObj.shotNum or 0 + frame_duration = frame_duration or 0 + + local folder = "AppPictures/" + local proposed_name = string.format("Screenshot%04d.png", PhotoModeObj.shotNum) + if io.exists(folder .. proposed_name) then + local files = io.listfiles(folder, "Screenshot*.png") + for i = 1, #files do + PhotoModeObj.shotNum = Max(PhotoModeObj.shotNum, tonumber(string.match(files[i], "Screenshot(%d+)%.png") or 0)) + end + PhotoModeObj.shotNum = PhotoModeObj.shotNum + 1 + proposed_name = string.format("Screenshot%04d.png", PhotoModeObj.shotNum) + end + local width, height = GetResolution() + WaitNextFrame(3) + LockCamera("Screenshot") + if frame_duration == 0 and hr.TemporalGetType() ~= "none" then + MovieWriteScreenshot(folder .. proposed_name, frame_duration, 1, frame_duration, width, height) + else + local quality = Lerp(128, 128, frame_duration, max_frame_duration) + MovieWriteScreenshot(folder .. proposed_name, frame_duration, quality, frame_duration, width, height) + end + UnlockCamera("Screenshot") + PhotoModeObj.shotNum = PhotoModeObj.shotNum + 1 + local file_path = ConvertToOSPath(folder .. proposed_name) + Msg("PhotoModeScreenshotTaken", file_path) + if Platform.steam and IsSteamAvailable() then + SteamAddScreenshotToLibrary(file_path, "", width, height) + end + if hideUIWindow then + hideUIWindow:SetVisible(true) + table.restore(hr, "photo_mode_frame_screenshot") + end +end + +function PhotoModeTake(frame_duration, max_frame_duration) + if IsValidThread(PhotoModeObj.shotThread) then return end + PhotoModeObj.shotThread = CreateMapRealTimeThread(function() + if Platform.console then + local photoModeDlg = GetDialog("PhotoMode") + local hideUIWindow = photoModeDlg.idHideUIWindow + hideUIWindow:SetVisible(false) + + local err + if Platform.xbox then + err = AsyncXboxTakeScreenshot() + elseif Platform.playstation then + err = AsyncPlayStationTakeScreenshot() + else + err = "Not supported!" + end + if err then + CreateErrorMessageBox(err, "photo mode") + end + hideUIWindow:SetVisible(true) + photoModeDlg:ToggleUI(true) -- fix prop selection + else + PhotoModeDoTakeScreenshot(frame_duration, max_frame_duration) + end + Sleep(1000) -- Prevent screenshot spamming + end, frame_duration, max_frame_duration) +end + +function PhotoModeBegin() + local obj = PhotoModeObject:new() + obj:StoreInitialValues() + local props = obj:GetProperties() + if AccountStorage.PhotoMode then + for _, prop in ipairs(props) do + local value = AccountStorage.PhotoMode[prop.id] + if value ~= nil then --false could be a valid value + obj:SetProperty(prop.id, value) + end + end + else + -- set initial values from current lightmodel + obj:ResetProperties() + end + obj.prev_camera = pack_params(GetCamera()) + PhotoModeObj = obj + + Msg("PhotoModeBegin") + g_PhotoMode = true + table.change(hr, "photo_mode", { + InterfaceInScreenshot = false, + LODDistanceModifier = Max(hr.LODDistanceModifier, 200), + DistanceModifier = Max(hr.DistanceModifier, 100), + ObjectLODCapMin = Min(hr.ObjectLODCapMin, 0), + EnablePostProcDOF = 1, + Anisotropy = 4, + }) + + return obj +end + +function OnMsg.AfterLightmodelChange() + if g_PhotoMode and GetTimeFactor() ~= 0 then + --in photo mode in resumed state + local lm_name = CurrentLightmodel[1].id or "" + PhotoModeObj.preStoredVisuals.lightmodel = lm_name ~= "" and lm_name or CurrentLightmodel[1] + end +end + +function GetPhotoModeFilters() + local filters = {} + ForEachPreset("PhotoFilterPreset", function(preset, group, filters) + filters[#filters + 1] = { value = preset.id, text = preset.display_name } + end, filters) + + return filters +end + +function GetPhotoModeFrames() + local frames = {} + ForEachPreset("PhotoFramePreset", function(preset, group, frames) + frames[#frames + 1] = { value = preset.id, text = preset:GetName()} + end, frames) + + return frames +end + +function GetPhotoModeLUTs() + local LUTs = {} + LUTs[#LUTs + 1] = { value = "None", text = T(1000973, "None")} + ForEachPreset("GradingLUTSource", function(preset) + if preset.group == "PhotoMode" or preset:IsModItem() then + LUTs[#LUTs + 1] = { value = preset.id, text = preset:GetDisplayName()} + end + end, LUTs) + + return LUTs +end + +function PhotoModeGetPropStep(gamepad_val, mouse_val) + return GetUIStyleGamepad() and gamepad_val or mouse_val +end + +DefineClass.PhotoModeObject = { + __parents = {"PropertyObject"}, + properties = + { + { name = T(335331914221, "Free Camera"), id = "freeCamera", editor = "bool", default = false, dont_save = true, }, + { name = T(915562435389, "Photo Filter"), id = "filter", editor = "choice", default = "None", items = GetPhotoModeFilters, no_edit = not not config.PhotoMode_DisablePhotoFilter}, -- enabled when config.DisablePhotoFilter doesn't exist + { name = T(650173703450, "Motion Blur"), id = "frameDuration", editor = "number", slider = true, default = 0, min = 0, max = 100, step = function() return PhotoModeGetPropStep(5, 1) end, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = true}, + { name = T(281819101205, "Vignette"), id = "vignette", editor = "number", slider = true, default = 0, min = 0, max = 255, scale = 255, step = function() return PhotoModeGetPropStep(10, 1) end, dpad_only = config.PhotoMode_SlidersDpadOnly, }, + { name = T(394842812741, "Exposure"), id = "exposure", editor = "number", slider = true, default = 0, min = -200, max = 200, step = function() return PhotoModeGetPropStep(20, 1) end, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = function(obj) return hr.AutoExposureMode == 1 end, }, + { name = T(394842812741, "Exposure"), id = "ae_key_bias", editor = "number", slider = true, default = 0, min = -3000000, max = 3000000, step = function() return PhotoModeGetPropStep(100000, 10000) end, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = function(obj) return hr.AutoExposureMode == 0 end, }, + { name = T(764862486527, "Fog Density"), id = "fogDensity", editor = "number", slider = true, default = 0, min = 0, max = 1000, step = function() return PhotoModeGetPropStep(50, 1) end, dpad_only = config.PhotoMode_SlidersDpadOnly, }, + { name = T(493626846649, "Depth of Field"), id = "depthOfField", editor = "number", slider = true, default = 100, min = 0, max = 100, step = 1, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = not not config.PhotoMode_DisableDOF }, + { name = T(775319101921, "Focus Depth"), id = "focusDepth", editor = "number", slider = true, default = 0, min = 0, max = 100, step = 1, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = not not config.PhotoMode_DisableDOF}, + { name = T(194124087753, "Defocus Strength"), id = "defocusStrength", editor = "number", slider = true, default = 10, min = 0, max = 100, step = 1, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = not not config.PhotoMode_DisableDOF }, + { name = T(462459069592, "Bloom Strength"), id = "bloomStrength", editor = "number", slider = true, default = 0, min = 0, max = 100, step = function() return PhotoModeGetPropStep(5,1) end, dpad_only = config.PhotoMode_SlidersDpadOnly, no_edit = not not config.PhotoMode_DisableBloomStrength}, -- enabled when config.DisableBloomStrength doesn't exist + { name = T(265619974713, "Saturation"), id = "colorSat", editor = "number", slider = true, default = 0, min = -100, max = 100, dpad_only = config.PhotoMode_SlidersDpadOnly, }, + { name = T(3451, "FOV"), id = "fov", editor = "number", default = const.DefaultCameraRTS and const.DefaultCameraRTS.FovX or 90*60, slider = true, min = 20*60, max = 120*60, scale = 60, step = function() return PhotoModeGetPropStep(300, 10) end, dpad_only = config.PhotoMode_SlidersDpadOnly, }, + { name = T(985831418702, "Photo Frame"), id = "frame", editor = "choice", default = "None", items = GetPhotoModeFrames, no_edit = not not config.PhotoMode_DisablePhotoFrame }, -- enabled when config.DisablePhotoFrame doesn't exist + { name = T(970914453104, "Color Grading"), id = "LUT", editor = "choice", default = "None", items = GetPhotoModeLUTs, no_edit = not not config.PhotoMode_DisablePhotoLUTs }, -- enabled when config.PhotoMode_DisablePhotoLUTs doesn't exist + }, + preStoredVisuals = false, + shotNum = false, + shotThread = false, + initialCamera = false, + photoFrame = false, +} + +function PhotoModeObject:StoreInitialValues() + self.preStoredVisuals = {} + local lm_name = CurrentLightmodel[1].id or "" + self.preStoredVisuals.lightmodel = self.preStoredVisuals.lightmodel or (lm_name ~= "" and lm_name or CurrentLightmodel[1]) + self.preStoredVisuals.dof_params = self.preStoredVisuals.dof_params or { GetDOFParams() } + local lut_name = CurrentLightmodel[1].grading_lut or "Default" + self.preStoredVisuals.LUT = self.preStoredVisuals.LUT or (GradingLUTs[lut_name] or GradingLUTs["Default"]) +end + +function PhotoModeObject:SetProperty(id, value) + local ret = PropertyObject.SetProperty(self, id, value) + PhotoModeApply(self, id) + return ret +end + +function PhotoModeObject:ResetProperties() + for i, prop in ipairs(self:GetProperties()) do + if not prop.dont_save then + self:SetProperty(prop.id, nil) + end + end + self:SetProperty("fogDensity", CurrentLightmodel[1].fog_density) + self:SetProperty("bloomStrength", CurrentLightmodel[1].pp_bloom_strength) + self:SetProperty("exposure", CurrentLightmodel[1].exposure) + self:SetProperty("ae_key_bias", CurrentLightmodel[1].ae_key_bias) + self:SetProperty("colorSat", -CurrentLightmodel[1].desaturation) + self:SetProperty("vignette", floatfloor(CurrentLightmodel[1].vignette_darken_opacity * self:GetPropertyMetadata("vignette").scale)) + + self.photoFrame = false +end + +function PhotoModeObject:Save() + AccountStorage.PhotoMode = {} + local storage_table = AccountStorage.PhotoMode + for _, prop in ipairs(self:GetProperties()) do + if not prop.dont_save then + local value = self:GetProperty(prop.id) + storage_table[prop.id] = value + end + end + SaveAccountStorage(5000) +end + +function PhotoModeObject:Pause() + Pause(self) +end + +function PhotoModeObject:Resume(force) + Resume(self) + local lm_name = CurrentLightmodel[1].id or "" + if (lm_name ~= "" and lm_name or CurrentLightmodel[1]) ~= PhotoModeObj.preStoredVisuals.lightmodel then + SetLightmodel(1, PhotoModeObj.preStoredVisuals.lightmodel, 0) + end +end + +function PhotoModeObject:DeactivateFreeCamera() + if PhotoModeObj.freeCamera then + self:SetProperty("freeCamera", nil) + end +end + +function PhotoModeObject:ToggleFrame() + if config.PhotoMode_DisablePhotoFrame then return end + local dlg = GetDialog("PhotoMode") + if dlg and dlg.idFrameWindow then + local frameName = self:GetProperty("frame") + if frameName == "None" then + dlg.idFrameWindow:SetVisible(false) + self.photoFrame = false + else + dlg.idFrameWindow:SetVisible(true) + local photoFramePreset = PhotoFramePresetMap[frameName] + if not photoFramePreset then + self:SetProperty("frame", "None") + dlg.idFrameWindow:SetVisible(false) + self.photoFrame = false + dlg.idScrollArea:RespawnContent() + elseif not photoFramePreset.frame_file then + self.photoFrame = false + dlg.idFrameWindow:SetVisible(false) + else + self.photoFrame = true + dlg.idFrameWindow.idFrame:SetImage(photoFramePreset.frame_file) + end + end + end +end \ No newline at end of file diff --git a/CommonLua/Platforms/steam/SteamAchievements.lua b/CommonLua/Platforms/steam/SteamAchievements.lua new file mode 100644 index 0000000000000000000000000000000000000000..f9bc700530eea71cc4f69f451e24bf58559fd38b --- /dev/null +++ b/CommonLua/Platforms/steam/SteamAchievements.lua @@ -0,0 +1,163 @@ +if FirstLoad then + s_AchievementReceivedSignals = {} + s_AchievementUnlockedSignals = {} + _AchievementsToUnlock = {} + _UnlockThread = false + g_SteamIdToAchievementName = {} + g_AchievementNameToSteamId = {} +end + +function OnMsg.DataLoaded() + g_SteamIdToAchievementName = {} + g_AchievementNameToSteamId = {} + ForEachPreset(Achievement, function(achievement, group_list) + local steam_id = achievement.steam_id ~= "" and achievement.steam_id or achievement.id + g_SteamIdToAchievementName[steam_id] = achievement.id + g_AchievementNameToSteamId[achievement.id] = steam_id + end) +end + +-- Steam account -> AccountStorage sync policy +TransferUnlockedAchievementsFromSteam = true + +function OnSteamAchievementsReceived() + Msg(s_AchievementReceivedSignals) +end + +function OnSteamAchievementUnlocked(unlock_status) + if unlock_status == "success" then + Msg(s_AchievementUnlockedSignals) + end +end + +local function WaitGetAchievements() + if IsSteamLoggedIn() and SteamQueryAchievements(table.values(table.map(AchievementPresets, "steam_id"))) then + local ok, data + if WaitMsg( s_AchievementReceivedSignals, 5 * 1000 ) then + data = SteamGetAchievements() + if data then + return true, table.map(data, g_SteamIdToAchievementName) + end + end + end + + return false +end + +local function GetSteamAchievementIds(achievements) + local steam_achievements = { } + for i, name in ipairs(achievements) do + if AchievementPresets[name] then + local steam_id = g_AchievementNameToSteamId[name] + if not steam_id then + print("Achievement", name, "doesn't have a Steam ID!") + else + table.insert(steam_achievements, steam_id) + end + end + end + return steam_achievements +end + +local function WaitAchievementUnlock(achievements) + if not Platform.steam or not IsSteamLoggedIn() then + return true + end + local steam_achievements = GetSteamAchievementIds(achievements) + local steam_unlocked = SteamUnlockAchievements(steam_achievements) and WaitMsg(s_AchievementUnlockedSignals, 5*1000) + if not steam_unlocked then + -- Currently our publisher wants to test if achievements work even if they haven't been + -- created in the steam backend. + -- To do this we unlock all achievements in AccountStorage even if they haven't been unlocked in steam. + -- We also pop a notification if steam failed to unlock the achievement. + Msg("SteamUnlockAchievementsFailed", steam_achievements) + end + return true +end + +-------------------------------------------[ Higher level functions ]----------------------------------------------- + +-- Asynchronous version, launches a thread +function AsyncAchievementUnlock(achievement) + _AchievementsToUnlock[achievement] = true + if not IsValidThread(_UnlockThread) then + _UnlockThread = CreateRealTimeThread( function() + local achievement = next(_AchievementsToUnlock) + while achievement do + if WaitAchievementUnlock{achievement} then + Msg("AchievementUnlocked", achievement) + else + AccountStorage.achievements.unlocked[achievement] = false + end + _AchievementsToUnlock[achievement] = nil + achievement = next(_AchievementsToUnlock) + end + end) + end +end + +function SynchronizeAchievements() + if not IsSteamLoggedIn() then return end + + -- check progress, auto-unlock if sufficient progress is made + for k, v in pairs(AccountStorage.achievements.progress) do + _CheckAchievementProgress(k, "don't unlock in provider") + end + + local account_storage_unlocked = AccountStorage.achievements.unlocked + CreateRealTimeThread(function() + if account_storage_unlocked ~= AccountStorage.achievements.unlocked then + print("Synchronize achievements aborted!") + return + end + + -- transfer unlocked achievements to Steam account + WaitAchievementUnlock(table.keys(account_storage_unlocked)) + + if not TransferUnlockedAchievementsFromSteam then + return + end + + if account_storage_unlocked ~= AccountStorage.achievements.unlocked then + print("Synchronize achievements aborted!") + return + end + + -- transfer unlocked achievements to AccountStorage + local ok, steam_unlocked = WaitGetAchievements() + + if account_storage_unlocked ~= AccountStorage.achievements.unlocked then + print("Synchronize achievements aborted!") + return + end + + if not ok then + print("Synchronize achievements failed!") + return + end + + local save = false + for i = 1, #steam_unlocked do + local id = steam_unlocked[i] + if not account_storage_unlocked[id] then + save = true + end + account_storage_unlocked[id] = true + end + if save then + SaveAccountStorage(5000) + end + end) +end + +function CheatPlatformUnlockAllAchievements() + if not Platform.steam or not IsSteamLoggedIn() then end + local steam_achievements = GetSteamAchievementIds(table.keys(AchievementPresets, true)) + SteamUnlockAchievements(steam_achievements) +end + +function CheatPlatformResetAllAchievements() + if not Platform.steam or not IsSteamLoggedIn() then end + local steam_achievements = GetSteamAchievementIds(table.keys(AchievementPresets, true)) + SteamResetAchievements(steam_achievements) +end diff --git a/CommonLua/Platforms/steam/SteamGame.lua b/CommonLua/Platforms/steam/SteamGame.lua new file mode 100644 index 0000000000000000000000000000000000000000..e5fb9be721372bafcc6d1c35eab86061c224bd9b --- /dev/null +++ b/CommonLua/Platforms/steam/SteamGame.lua @@ -0,0 +1,120 @@ +function IsDlcOwned(dlc) + return Platform.developer or IsSteamAvailable() and SteamIsDlcAvailable(dlc.steam_dlc_id) +end + +function GetPCSaveFolder() + local path = "saves:/" + if IsSteamAvailable() then + path = string.format("saves:/%s/", tostring(SteamGetUserId64())) + end + local ok, err = io.createpath(path) + if not ok then + print("Failed to create save path", err) + end + return path +end + +function GetSteamLobbyVisibility(visible) + visible = visible or netGameInfo.visible_to + if not visible or visible == "friends" or visible == "public" then + return "friendsonly" + else + return "invisible" + end +end + +function OnMsg.NetGameJoined() + CreateRealTimeThread( function() + if netGameInfo.steam_lobby then + local err = AsyncSteamJoinLobby( tonumber(netGameInfo.steam_lobby) ) + if err then + DebugPrint("AsyncSteamJoinLobby failed: " .. err) + end + else + local err, lobby = AsyncSteamCreateLobby( GetSteamLobbyVisibility(), netGameInfo.max_players or 4 ) + if err then + DebugPrint("AsyncSteamCreateLobby failed: " .. err) + return + end + SteamSetLobbyData(tonumber(lobby), "game_address", tostring(netGameAddress)) + NetChangeGameInfo( { steam_lobby = tonumber(lobby) } ) + end + end) +end + +function OnMsg.NetGameLeft() + if netGameInfo.steam_lobby then + SteamLeaveLobby( tonumber(netGameInfo.steam_lobby) ) + end +end + +function OnMsg.NetGameInfo(info) + if info.visible_to ~= nil then + CreateRealTimeThread(function() + if netInGame and netGameInfo.steam_lobby then + SteamSetLobbyType(netGameInfo.steam_lobby, GetSteamLobbyVisibility(info.visible_to)) + end + end) + end +end + +function OnSteamEnterLobby(lobby) +end + +function ProcessSteamInvite(lobby) + CreateRealTimeThread( function() + local lobbyNumber = tonumber(lobby) + local err = AsyncSteamJoinLobby( lobbyNumber ) + if err then + DebugPrint("AsyncSteamJoinLobby failed: " .. err) + return + end + local game_address = tonumber(SteamGetLobbyData(lobbyNumber, "game_address")) + if game_address == 0 then + DebugPrint("invalid game address for lobby") + return + end + local err = NetSteamGameInviteAccepted(game_address, lobbyNumber) + if err then + DebugPrint(err) + return + end + end ) +end + +OnSteamGameLobbyJoinRequest = ProcessSteamInvite + +function OnMsg.StartAcceptingInvites() + local lobby = GetAppCmdLine():match("%+connect_lobby%s+(%d+)") + if lobby then + ProcessSteamInvite(lobby) + end +end + +function NetSteamGameInviteAccepted(game_address, lobby) + print("Steam Invitation accepted for ", game_address, lobby) + return "Need to override NetSteamGameInviteAccepted for current game" +end + +function OnMsg.BugReportStart(print_func) + local steam_beta, steam_branch = SteamGetCurrentBetaName() + if (steam_branch or "") ~= "" then + print_func("Steam Branch:", steam_branch) + end +end + +if Platform.steam and IsSteamAvailable() then + _InternalFilterUserTexts = function(unfilteredTs) + local filteredTs = {} + local errors = {} + for _, T in ipairs(unfilteredTs) do + local res = SteamFilterText(T._user_text_type or "", T._steam_id or "0", TDevModeGetEnglishText(T, "deep", "no_assert") or "") + if res == nil then + table.insert(errors, { error = "Unknown Steam Error", user_text = T}) + end + filteredTs[T] = res + end + if next(errors) == nil then errors =false end + return errors, filteredTs + end +end \ No newline at end of file diff --git a/CommonLua/Platforms/steam/SteamMods.lua b/CommonLua/Platforms/steam/SteamMods.lua new file mode 100644 index 0000000000000000000000000000000000000000..e30866564fdb7fc0a3608abb3c579b7880d3054f --- /dev/null +++ b/CommonLua/Platforms/steam/SteamMods.lua @@ -0,0 +1,72 @@ +if not config.Mods then return end + +function OnMsg.GatherModEditorLogins(context) + context.steam_login = not not (IsSteamAvailable() and SteamGetUserId64()) +end + +function OnMsg.GatherModAuthorNames(authors) + if IsSteamAvailable() then + authors.steam = SteamGetPersonaName() + end +end + +function OnMsg.GatherModDefFolders(folders) + if SteamIsWorkshopAvailable() then + local steam_folders = SteamWorkshopItems() + if #steam_folders > 0 then + local workshop_item_ids = {} + for i,folder in ipairs(steam_folders) do + local dir, name = SplitPath(folder) + workshop_item_ids[#workshop_item_ids + 1] = name + end + end + for i=1,#steam_folders do + steam_folders[i] = string.gsub(steam_folders[i], "\\", "/") + steam_folders[i] = { path = steam_folders[i], source = "steam" } + end + table.iappend(folders, steam_folders) + end +end + +function GedOpUploadModToSteam(socket, root) + local mod = root[1] + local err = ValidateModBeforeUpload(socket, mod) + if err then return end + + if "ok" ~= socket:WaitQuestion("Confirmation", Untranslated{"Mod will be uploaded to Steam.", mod}) then + return + end + + local params = { } + UploadMod(socket, mod, params, Steam_PrepareForUpload, Steam_Upload) +end + +function OnMsg.GatherModDeleteFailReasons(mod, reasons) + if mod.source == "steam" then + table.insert(reasons, "This mod is downloaded from Steam and cannot be deleted - go in your Steam client and unsubscribe it from there.") + end +end + +function OnMsg.ClassesGenerate(classdefs) + local steam_properties = { + { + category = "Mod", + id = "steam_id", + name = "Steam ID", + editor = "number", + default = 0, + read_only = true, + no_edit = not Platform.steam, + modid = true, + }, + } + for i,steam_prop in ipairs(steam_properties) do + table.insert(ModDef.properties, steam_prop) + end +end + +function OnMsg.ModBlacklistPrefixes(list) + list[#list + 1] = "Steam" + list[#list + 1] = "OnSteam" + list[#list + 1] = "AsyncSteam" +end diff --git a/CommonLua/Platforms/steam/SteamNetwork.lua b/CommonLua/Platforms/steam/SteamNetwork.lua new file mode 100644 index 0000000000000000000000000000000000000000..a57a5ad1ae8dfff789c94365f07378fa2ce1fcc7 --- /dev/null +++ b/CommonLua/Platforms/steam/SteamNetwork.lua @@ -0,0 +1,39 @@ +if FirstLoad then + threadSteamGetAppTicket = false +end + +config.NetCheckUpdates = false + +function ProviderName() + return "steam" +end + +function PlatformGetProviderLogin(official_connection) + local err, auth_provider, auth_provider_data, display_name + + if not IsSteamLoggedIn() then + DebugPrint("IsSteamLoggedIn() failed\n") + return "steam-auth" + end + + auth_provider = "steam" + display_name = SteamGetPersonaName() + if not display_name then + DebugPrint("SteamGetPersonaName() failed\n") + return "steam-auth" + end + while threadSteamGetAppTicket do -- wait any other calls to AsyncSteamGetAppTicket + Sleep(10) + end + threadSteamGetAppTicket = CurrentThread() or true + err, auth_provider_data = AsyncSteamGetAppTicket(tostring(display_name)) + assert(threadSteamGetAppTicket == (CurrentThread() or true)) + threadSteamGetAppTicket = false + + if err then + DebugPrint("AsyncSteamGetAppTicket() failed: " .. err .. "\n") + return "steam-auth" + end + + return err, auth_provider, auth_provider_data, display_name +end \ No newline at end of file diff --git a/CommonLua/Platforms/steam/SteamWorkshop.lua b/CommonLua/Platforms/steam/SteamWorkshop.lua new file mode 100644 index 0000000000000000000000000000000000000000..b807f03d09813c2e732559da369a7f38ab5367cd --- /dev/null +++ b/CommonLua/Platforms/steam/SteamWorkshop.lua @@ -0,0 +1,214 @@ +function Steam_PrepareForUpload(ged_socket, mod, params) + local err + if mod.steam_id ~= 0 then + local owned, exists + local appId = SteamGetAppId() + local userId = SteamGetUserId64() + err, owned, exists = AsyncSteamWorkshopUserOwnsItem(userId, appId, mod.steam_id) + if err then + return false, T{773036833561, --[[Mod upload error]] "Failed looking up Steam Workshop item ownership ()", err = Untranslated(err)} + end + if not exists then + mod.steam_id = 0 + elseif not owned then + return false, T(898162117742, --[[Mod upload error]] "Upload failed - this mod is not owned by your Steam user") + end + end + if mod.steam_id == 0 then + local item_id, bShowLegalAgreement + err, item_id, bShowLegalAgreement = AsyncSteamWorkshopCreateItem() + mod.steam_id = not err and item_id or nil + params.publish = true + else + params.publish = false + end + + if not err then + if mod.steam_id == 0 then + return false, T(484936159811, --[[Mod upload error]] "Failed generating Steam Workshop item ID for this mod") + end + else + return false, T{532854821730, --[[Mod upload error]] "Failed generating Steam Workshop item ID for this mod ()", err = Untranslated(err)} + end + + return true +end + +function Steam_Upload(ged_socket, mod, params) + --screenshots uploaded through the mod editor can be distinguished by their file prefix (see ModsScreenshotPrefix) + --others (uploaded somewhere else by the user) must not be updated/removed + local remove_screenshots = { } + local update_screenshots = { } + local add_screenshots = params.screenshots + --query already present screenshots in this mod + local appId = SteamGetAppId() + local userId = SteamGetUserId64() + local err, present_screenshots = AsyncSteamWorkshopGetPreviewImages(userId, appId, mod.steam_id) + if not err and type(present_screenshots) == "table" then + local add_by_filename = { } + for i=1,#add_screenshots do + local full_path = add_screenshots[i] + local path, file, ext = SplitPath(full_path) + add_by_filename[file..ext] = full_path + end + --iterate already present screenshots to figure out if they need to be updated/removed + for i=1,#present_screenshots do + local entry = present_screenshots[i] + --do not modify user uploaded screenshots, only mod editor uploaded ones + if string.starts_with(entry.file, ModsScreenshotPrefix) then + --if we're trying to add a file that already exists - update it + --if we're not trying to add that file - remove it + local add_full_path = add_by_filename[entry.file] + if add_full_path then + --update already present file + local update_entry = { index = entry.index, file = add_full_path } + table.insert(update_screenshots, update_entry) + table.remove_entry(add_screenshots, add_full_path) + else + --remove old file + table.insert(remove_screenshots, entry.index) + end + end + end + --now: + --only new screenshots are left in `add_screenshots` + --only old screenshots are left in `remove_screenshots` + --in `update_screenshots` are pairs with preview image indices and file paths + end + + --check screenshots file size + local max_image_size = 1*1024*1024 --1MB + if mod.image then + local os_image_path = ConvertToOSPath(mod.image) + if io.exists(os_image_path) then + local image_size = io.getsize(os_image_path) + if image_size > max_image_size then + return false, T{452929163591, --[[Mod upload error]] "Preview image file size must be up to 1MB (current one is )", filesize = image_size} + end + end + end + local new_screenshot_files = table.union(add_screenshots, table.map(update_screenshots, "file")) + for i,screenshot in ipairs(new_screenshot_files) do + --check file existance for mod.screenshot1, mod.screenshot2, mod.screenshot3, mod.screenshot4, mod.screenshot5 + local os_screenshot_path = ConvertToOSPath(screenshot) + if io.exists(os_screenshot_path) then + local screenshot_size = io.getsize(os_screenshot_path) + if screenshot_size > max_image_size then + return false, T{741444571224, --[[Mod upload error]] "Screenshot file size must be up to 1MB (current one is )", i = i, filesize = screenshot_size} + end + end + end + + local err = AsyncSteamWorkshopUpdateItem({ + item_id = mod.steam_id, + title = mod.title, + description = mod.description, + tags = mod:GetTags(), + content_os_folder = params.os_pack_path, + image_os_filename = mod.image ~= "" and ConvertToOSPath(mod.image) or "", + change_note = mod.last_changes, + publish = params.publish, + add_screenshots = add_screenshots, + remove_screenshots = remove_screenshots, + update_screenshots = update_screenshots, + }) + + if err then + return false, T{589249152995, --[[Mod upload error]] "Failed to update Steam workshop item ()", err = Untranslated(err)} + else + return true + end +end + +function DebugCopySteamMods(mods) + local copy = {} + for i, path in ipairs(SteamWorkshopItems()) do + local _, steam_id = SplitPath(path) + local mod_def = table.find_value(mods, "steam_id", steam_id) + if mod_def then + copy[mod_def.id] = path + end + end + if not next(copy) then return end + return CreateRealTimeThread(function(copy) + local dest = ConvertToOSPath("AppData/Mods") + local i, count = 1, table.count(copy) + printf("Copying %d mods to '%s'...", count, dest) + for mod_id, path in sorted_pairs(copy) do + printf("\tCopying mod %d/%d '%s'...", i, count, mod_id) + local err = AsyncUnpack(path .. "\\" .. ModsPackFileName, dest .. "\\Dbg " .. mod_id) + if err then + print("\t\tError:", err) + end + i = i + 1 + end + printf("Finished copying mods") + end, copy) +end + +function DebugDownloadSteamMods(mods, ...) + if type(mods) ~= "table" then + mods = {mods, ...} + end + return CreateRealTimeThread(function(mods) + local count = 0 + local err_count = 0 + for _, mod_id in ipairs(mods) do + local err + local isUpToDate = SteamIsWorkshopItemUpToDate(mod_id) + if not isUpToDate then + local err = AsyncSteamWorkshopUnsubscribeItem(mod_id) -- delete old mod + end + err = AsyncSteamWorkshopSubscribeItem(mod_id) --always subscribe to them + local err = AsyncSteamWorkshopDownloadItem(mod_id, true) + if err then + printf("Steam ID %s: %s", mod_id, err) + err_count = err_count + 1 + else + printf("Mod with Steam ID %s downloaded successfully", mod_id) + count = count + 1 + end + end + if err_count > 0 then + printf("%d Steam workshop mods were not downloaded", err_count) + end + if count > 0 then + printf("%d Steam workshop mods successfully downloaded", count) + printf("You can copy them to be used in a non-steam game version via DebugCopySteamMods()") + end + end, mods) +end + +local function GatherSteamModsDownloadList(mods, filter) + local steam_ids = {} + for i,mod in ipairs(mods) do + if (not filter or filter(mod)) and mod.steam_id then + table.insert(steam_ids, mod.steam_id) + end + end + return steam_ids +end + +function OnMsg.GatherModDownloadCode(codes) + local steam_ids = GatherSteamModsDownloadList(ModsLoaded, function(mod) return mod.source == "steam" end) + if not next(steam_ids) then return end + codes.steam = string.format('DebugDownloadSteamMods%s', TableToLuaCode(steam_ids, ' ')) +end + +function OnMsg.DebugDownloadExternalMods(out_threads, mods) + local steam_ids = GatherSteamModsDownloadList(mods) + if not next(steam_ids) then return end + local thread = DebugDownloadSteamMods(steam_ids) + table.insert(out_threads, thread) +end + +function OnMsg.DebugCopyExternalMods(out_threads, mods) + local thread = DebugCopySteamMods(mods) + if thread then + table.insert(out_threads, thread) + end +end + +function SteamIsWorkshopAvailable() + return IsSteamAvailable() +end \ No newline at end of file diff --git a/CommonLua/Preset.lua b/CommonLua/Preset.lua new file mode 100644 index 0000000000000000000000000000000000000000..af9ba0bcefa5fe6f46b9de0098a4871f8145da1a --- /dev/null +++ b/CommonLua/Preset.lua @@ -0,0 +1,2371 @@ +-- Presets +-- Inherit preset class, if GedEditor is provided, a menu entry is created for editing +-- Presets[class] - the array part is a list of all preset groups, the name part maps to group by name +-- Presets[class][group] - the array part is a list of all presets in the group, the name part maps to preset by name +-- Presets[class][group][id] - the preset of with and +-- Preset.id - unique id within the group +-- Preset.group - group id +DefineClass.Preset = { + __parents = { "GedEditedObject", "Container", "InitDone" }, + properties = { + { category = "Preset", id = "Group", editor = "combo", default = "Default", + items = function(obj) + local group_class = g_Classes[obj.PresetGroupPreset] + if group_class then + assert(_G[group_class.GlobalMap]) -- groups need to have unique ids + return PresetsCombo(group_class.PresetClass or group_class.class)() + else + return PresetGroupsCombo(obj.PresetClass or obj.class)() + end + end, + validate = function(self, value, ged) + -- presets in the new group must not have the same id + local groups = Presets[self.PresetClass or self.class] + local presets = groups and groups[value] + if presets and presets[self.id] and presets[self.id].save_in == self.save_in then + return "A preset with the same id exists in the target group." + end + local group_class = g_Classes[self.PresetGroupPreset] + if group_class then + local map = _G[group_class.GlobalMap] + assert(map) -- preset's group class must have a global map + if not map or not map[value] then + return "Preset group doesn't exist." + end + elseif value == "" then + return "Preset group can't be empty." + end + end, + no_edit = function (obj) return not obj.HasGroups end, }, + + { category = "Preset", id = "SaveIn", name = "Save in", editor = "choice", default = "", + items = function(obj) return obj:GetPresetSaveLocations() end, }, + + { category = "Preset", id = "Id", editor = "text", default = "", validate = function(self, value) + -- ids must be valid identifiers + if type(value) ~= "string" or not value:match(self.PresetIdRegex) then + return "Id must be a valid identifier (starts with a letter, contains letters/numbers/_ only)." + end + -- ids must be unique in the group + local groups = Presets[self.PresetClass or self.class] + local presets = groups and groups[self.group] + local with_same_id = presets and presets[value] + if with_same_id and with_same_id ~= self and with_same_id:GetSaveFolder() == self:GetSaveFolder() then + return "A preset with this Id already exists in this group (for the same save location)." + end + -- for some presets ids must be globally unique + with_same_id = self.GlobalMap and _G[self.GlobalMap][value] + if with_same_id and with_same_id ~= self and with_same_id:GetSaveFolder() == self:GetSaveFolder() then + return "A preset with this Id already exists." + end + end}, + { category = "Preset", id = "SortKey", name = "Sort key", editor = "number", default = 0, + no_edit = function(obj) return not obj.HasSortKey end, + dont_save = function(obj) return not obj.HasSortKey end, + help = "An arbitrary number used to sort items in ascending order" }, + { category = "Preset", id = "Parameters", name = "Parameters", editor = "nested_list", + base_class = "PresetParam", default = false, no_edit = function(self) return not self.HasParameters end, + help = "Create named parameters for numeric values and use them in multiple places.\n\nFor example, if an event checks that an amount of money is present, subtracts this exact amount, and displays it in its text, you can create an Amount parameter and reference it in all three places. When you later adjust this amount, you can do it from a single place.\n\nThis can prevent omissions and errors when numbers are getting tweaked later.", + }, + -- hidden property for param bindings, injected to all subobjects for saving purposes + { id = "param_bindings", editor = "prop_table", default = false, no_edit = true, + inject_in_subobjects = function(self) return self.HasParameters end, }, + { category = "Preset", id = "Comment", name = "Comment", editor = "text", default = "", + lines = 1, max_lines = 10 }, + { category = "Preset", id = "TODO", name = "To do", + editor = "set", default = false, + no_edit = function(self) return not self.TODOItems end, + dont_save = function(self) return not self.TODOItems end, + items = function (self) return self.TODOItems end, }, + { category = "Preset", id = "Obsolete", editor = "bool", default = false, + no_edit = function(self) return not self.HasObsolete end, + dont_save = function(self) return not self.HasObsolete end, + help = "Obsolete presets are kept for backwards compatibility and should not be visible in the game", }, + { category = "Preset", id = "Documentation", editor = "documentation", dont_save = true, sort_order = 9999999 }, -- display collapsible Documentation at this position + }, + group = "Default", + id = "", + save_in = "", + + StoreAsTable = true, -- to optimize loading times + PropertyTranslation = false, + PersistAsReference = true, -- when true preset instances will only be referenced by savegames, if false used preset instance data will be saved + UnpersistedPreset = false, -- a special preset ID used as a fallback if a preset is missing during savegame loading + __hierarchy_cache = true, + + -- preset settings + PresetIdRegex = "^[%w_+-]*$", + HasGroups = true, + PresetGroupPreset = false, + HasSortKey = false, + HasParameters = false, + HasObsolete = false, + PresetClass = false, -- override preset class + FilePerGroup = false, -- save each preset group in a separate file + SingleFile = true, -- save in a single file or each preset in its own file (if FilePerGroup is false) + LocalPreset = false, -- if set the file will not be added to subversion + GlobalMap = false, -- if set the ids will be globally unique and stored in this global table + FilterClass = false, + SubItemFilterClass = false, + AltFormat = false, -- an alternative format string for the preset tree panel in Ged, e.g. to show presets by display name instead of id + HasCompanionFile = false, + GeneratesClass = false, + TODOItems = false, + NoInstances = false, -- do not allow instantiating the preset in the editor + + -- Ged editor settings + GedEditor = "PresetEditor", + SingleGedEditorInstance = true, -- At most one editor can be opened at any time. + EditorMenubarName = "", -- name of editor in dev menu, empty string means "use the class name" + EditorMenubarSortKey = "", + EditorShortcut = false, -- shortcut to editor + EditorIcon = false, -- icon of the dev menu entry + EditorMenubar = "Editors", -- position in the dev menu + EditorName = false, -- name as viewed inside the editors, e.g. name in the menu for creating a new object/subitem + EditorView = Untranslated(""), + EditorViewPresetPrefix = "", + EditorViewPresetPostfix = "", + EditorCustomActions = false, + + EnableReloading = true, + ValidateAfterSave = false, +} + +if FirstLoad then + g_PresetParamCache = setmetatable({}, weak_keys_meta) + g_PresetLastSavePaths = rawget(_G, "g_PresetLastSavePaths") or setmetatable({}, weak_keys_meta) + g_PresetAllSavePaths = setmetatable({}, weak_keys_meta) + g_PresetDirtySavePaths = {} + g_PresetFileTimestampAtSave = {} + g_PresetCurrentLuaFileSavePath = false + g_PresetForbidSerialize = false + g_PresetRefreshingFunctionValues = false + g_PendingPresetImageAdds = false + PresetsLoadingFileName = false +end + +function Preset:GatherEditorCustomActions(actions) + table.iappend(actions, self.EditorCustomActions) +end + +function Preset:GetEditorView() + return self.EditorView +end + +function Preset:GetPresetRolloverText() +end + +function Preset:GetPresetStatusText() + return "" +end + +function Preset:IsReadOnly() + return config.ModdingToolsInUserMode +end + +function Preset:IsOpenInGed() + local presets = Presets[self.PresetClass or self.class] + return not not GedObjects[presets] +end + +function Preset:Done() + local id = self.id + local groups = Presets[self.PresetClass or self.class] + local presets = groups[self.group] + if presets then + table.remove_entry(presets, self) + if presets[id] == self then + presets[id] = nil + --restore old preset + for i=#presets,1,-1 do + local preset_i = presets[i] + if preset_i ~= self and preset_i.id == id then + presets[id] = preset_i + break + end + end + end + if #presets == 0 then + table.remove_entry(groups, presets) + groups[self.group] = nil + end + end + local global = rawget(_G, self.GlobalMap) + if global and global[id] == self then + global[id] = nil + --restore old preset + for i=#groups,1,-1 do + local group_i = groups[i] + for j=#group_i,1,-1 do + local preset_j = group_i[j] + if preset_j ~= self and preset_j.id == id then + global[id] = preset_j + goto found + end + end + end + ::found:: + end +end + +function Preset:SetGroup(group) + if g_PresetRefreshingFunctionValues then -- reloading after save to refresh function values with correct debug info + self.group = group + return + end + + group = group ~= "" and group or "Default" + local id = self.id + local groups = Presets[self.PresetClass or self.class] + local presets = groups[self.group] + if presets then + table.remove_entry(presets, self) + if presets[id] == self then + presets[id] = nil + --restore old preset + for i=#presets,1,-1 do + local preset_i = presets[i] + if preset_i ~= self and preset_i.id == id then + presets[id] = preset_i + break + end + end + end + if #presets == 0 then + table.remove_entry(groups, presets) + groups[self.group] = nil + end + end + self.group = group + presets = groups[group] + if not presets then + presets = {} + groups[group] = presets + groups[#groups + 1] = presets + end + presets[#presets + 1] = self + if id ~= "" then + presets[id] = self + if ParentTableCache[self] then + UpdateParentTable(self, presets) -- set the correct table parent after undo operations + end + end + ObjModified(groups) +end + +function Preset:GetGroup() + return self.group +end + +function Preset:SetId(id) + if g_PresetRefreshingFunctionValues then -- reloading after save to refresh function values with correct debug info + self.id = id + return + end + + local groups = Presets[self.PresetClass or self.class] + local old_id = self.id + local global = rawget(_G, self.GlobalMap) + if global then + if global[old_id] == self then + global[old_id] = nil + --restore old preset + for i=#groups,1,-1 do + local group_i = groups[i] + for j=#group_i,1,-1 do + local preset_j = group_i[j] + if preset_j ~= self and preset_j.id == old_id then + global[old_id] = preset_j + goto found + end + end + end + ::found:: + end + if id ~= "" then + local existing = global[id] + if existing and existing ~= self then + assert(self:GetSaveIn() ~= existing:GetSaveIn(), + string.format("Multiple copies of presets with id %s exist in %s.", id, self.save_in)) + end + global[id] = self + end + end + local presets = groups[self.group] + if presets then + if presets[old_id] == self then + presets[old_id] = nil + --restore old preset + for i=#presets,1,-1 do + local preset_i = presets[i] + if preset_i ~= self and preset_i.id == old_id then + presets[old_id] = preset_i + break + end + end + end + else + assert(false, "You should call :Register() on new presets") + presets = { } + groups[self.group] = presets + groups[#groups + 1] = presets + end + self.id = id + if id ~= "" then + presets[id] = self + if ParentTableCache[self] or ParentTableCache[presets] then + UpdateParentTable(self, presets) -- set the correct table parent after undo operations + end + end +end + +function Preset:GetId() + return self.id +end + +function Preset:SetSaveIn(save_in) + self.save_in = save_in ~= "" and save_in or nil +end + +function Preset:GetSaveIn() + return self.save_in +end + +function Preset:GetSaveLocationType() + local save_in = self:GetSaveIn() + if save_in == "Common" or save_in == "Ged" or save_in:starts_with("Lib") then + return "common" + end + return "game" +end + +function GetDefaultSaveLocations() + local locations = DlcComboItems{ text = "Common", value = "Common" } + ForEachLib(nil, function(lib, path, locations) + locations[#locations + 1] = { text = "lib " .. lib, value = "Libs/" .. lib } + end, locations) + return locations +end + +function Preset:GetPresetSaveLocations() + return GetDefaultSaveLocations() +end + +function Preset:PostLoad() + if self.HasParameters then + self:ForEachSubObject(function(obj) + if not obj:IsKindOf("PresetParam") then + rawset(obj, "param_bindings", rawget(obj, "param_bindings") or false) + end + end) + end + if #(self.Parameters or empty_table) > 0 then + local cache = {} + for _, param in ipairs(self.Parameters) do + cache[param.Name] = param.Value + end + g_PresetParamCache[self] = cache + end +end + +function Preset:ResolveValue(key) + local value = self:GetProperty(key) + if not value and g_PresetParamCache[self] then + return g_PresetParamCache[self][key] + end + return value +end + +function Preset:Register(id, update_parent_table_cache) + local group = self.group + local groups = Presets[self.PresetClass or self.class] + local presets = groups[group] + if not presets then + presets = {} + groups[group] = presets + groups[#groups + 1] = presets + end + presets[#presets + 1] = self + + if update_parent_table_cache then + UpdateParentTable(presets, groups) + ParentTableModified(self, presets, "recursive") + end + + id = id or self.id + if id ~= "" then + presets[id] = self + local global = rawget(_G, self.GlobalMap) + if global then + global[id] = self + end + end +end + +function Preset:GetEditorViewTODO() + local v + local todo = self.TODO or empty_table + for _, item in ipairs(self.TODOItems) do + if todo[item] then + if v then + v[#v + 1] = " " + else + v = {" ["} + end + v[#v + 1] = item + end + end + if not v then return "" end + v[#v + 1] = "]" + return Untranslated(table.concat(v, "")) +end + +-- The preset has just been saved; update its function values from +-- reloaded_obj, so the resaved functions have correct debug info. +function preset_reset_fn_values(preset, reloaded_obj) + for k, v in pairs(preset) do + if k ~= "__index" then + local reloaded_value = reloaded_obj[k] + if type(v) == "table" and type(reloaded_value) == "table" then + preset_reset_fn_values(v, reloaded_value) + elseif type(v) == "function" and type(reloaded_value) == "function" then + preset[k] = reloaded_value + end + end + end +end + +function Preset:FindOriginalPreset() + local presets = Presets[self.PresetClass or self.class][self.group] + for _, original_preset in ipairs(presets) do + if original_preset.id == self.id and original_preset.save_in == self.save_in then + return original_preset + end + end +end + +function Preset:RefreshFunctionValues() + local original_preset = self:FindOriginalPreset() + assert(original_preset, "Unable to find a preset that was just saved") + if original_preset then + preset_reset_fn_values(original_preset, self) + original_preset:MarkClean() + end +end + +-- set up special functions for loading presets, so we can capture the filenames they are loaded from +local function instrument_loading(fn_name) + return function(...) + local old_fn = dofile + dofile = function(name, fenv) + PresetsLoadingFileName = name + old_fn(name, fenv) + PresetsLoadingFileName = false + end + _G[fn_name](...) + dofile = old_fn + end +end + +LoadPresetFiles = instrument_loading("dofolder_files") +LoadPresetFolders = instrument_loading("dofolder_folders") +LoadPresetFolder = instrument_loading("dofolder") +LoadPresets = function(name, fenv) + PresetsLoadingFileName = name + pdofile(name, fenv) + PresetsLoadingFileName = false +end + +function Preset:StoreLoadedFromPaths() + if PresetsLoadingFileName and Platform.developer and not Platform.cmdline and not Platform.console and self.class ~= "DLCPropsPreset" then + g_PresetLastSavePaths[self] = PresetsLoadingFileName + + local save_paths = self:GetCompanionFilesList(PresetsLoadingFileName) + if save_paths then + for key, name in pairs(save_paths) do + if not io.exists(name) then + assert(false, string.format("Missing auto-generated file\n%s\n\nTry resaving presets to regenerate it.", name)) + end + end + save_paths[false] = g_PresetLastSavePaths[self] -- main preset file + g_PresetAllSavePaths[self] = save_paths + end + end +end + +function Preset:__fromluacode(data, arr) + local obj + if self.StoreAsTable then + obj = self:new(data) + if g_PresetRefreshingFunctionValues then -- reloading after save to refresh function values with correct debug info + obj:RefreshFunctionValues() + return + else + obj:Register() + end + else + obj = self:new(arr) + if g_PresetRefreshingFunctionValues then -- reloading after save to refresh function values with correct debug info + -- SetId, SetGroup do not register the preset if g_PresetRefreshingFunctionValues is set + SetObjPropertyList(obj, data) + obj:RefreshFunctionValues() + return + else + obj:Register() + SetObjPropertyList(obj, data) + end + end + obj:StoreLoadedFromPaths() + return obj +end + +function Preset:__toluacode(...) + assert(not g_PresetForbidSerialize, "Attempt to save preset not from Ged - are you storing presets instead of their ids in a savegame?\nPreset class: " .. self.class .. "\nPreset id: " .. self.id) + return InitDone.__toluacode(self, ...) +end + +-- doesn't register the preset, so we don't wipe the existing preset with the same id (if still present) +function Preset:__paste(table, arr) + local ret + if self.StoreAsTable then + ret = self:new(table) + else + ret = self:new(arr) + ret.SetId = function(self, id) self.id = id end + ret.SetGroup = function(self, group) self.group = group end + SetObjPropertyList(ret, table) + ret.SetId = nil + ret.SetGroup = nil + end + return ret +end + +function Preset:GetSaveFolder(save_in) + save_in = save_in or self.save_in + if save_in == "" then return "Data" end + if save_in == "Common" then return "CommonLua/Data" end + if save_in:starts_with("Libs/") then + return string.format("CommonLua/%s/Data", save_in) + end + -- save_in is a DLC name + return string.format("svnProject/Dlc/%s/Presets", save_in) +end + +function NormalizeGamePath(path) + if not path then return end + return path:gsub("%s*[/\\]+[/\\%s]*", "/") +end +local NormalizeSavePath = NormalizeGamePath + +function Preset:GetNormalizedSavePath() + local path = self:GetSavePath() + if not path or path == "" then return false end + return NormalizeSavePath(path) +end + +function Preset:GetSavePath(save_in, group) + group = group or self.group + local class = self.PresetClass or self.class + local folder = self:GetSaveFolder(save_in) + if not folder then return end + if self.FilePerGroup then + if type(self.FilePerGroup) == "string" then + return string.format("%s/%s/%s-%s.lua", folder, self.FilePerGroup, class, group) + else + return string.format("%s/%s-%s.lua", folder, class, group) + end + elseif self.SingleFile then + return string.format("%s/%s.lua", folder, class) + elseif self.GlobalMap then + return string.format("%s/%s/%s.lua", folder, class, self.id) + else + return string.format("%s/%s/%s-%s.lua", folder, class, group, self.id) + end +end + +-- constructs the companion file path (the default is ".generated.lua" in the Lua code folder) from self:GetSavePath()'s return value +function Preset:GetCompanionFileSavePath(path) + if not path then return end + if path:starts_with("Data") then + path = path:gsub("^Data", "Lua") -- save in the game folder + elseif path:starts_with("CommonLua/Data") then + path = path:gsub("^CommonLua/Data", "CommonLua/Classes") -- save in common lua + elseif path:starts_with("CommonLua/Libs/") then -- lib + path = path:gsub("/Data/", "/Classes/") + else + path = path:gsub("^(svnProject/Dlc/[^/]*)/Presets", "%1/Code") -- save in a DLC + end + return path:gsub(".lua$", ".generated.lua") +end + +function Preset:GetCompanionFilesList(save_path) + -- return a table with pairs if you want to generate multiple companion files for this preset + -- GenerateCompanionFileCode will be called once for each file, with passed as a parameter + assert(self.id ~= "") -- called for the class instead of the object? + if self.HasCompanionFile then + return { [true] = self:GetCompanionFileSavePath(save_path) } + end +end + +local generated_preset_files_header = "-- ========== GENERATED BY Editor DO NOT EDIT MANUALLY! ==========\n\n" + +function Preset:GetCompanionFileHeader(key) + local titleT = T(generated_preset_files_header, { PresetClass = self.class, EditorShortcut = self.EditorShortcut }) + local header = _InternalTranslate(titleT, nil, not "check_errors") or exported_files_header_warning + + -- returns the initial pstr to which the companion file code will be appended + return pstr(header, 16384) +end + +function Preset:GenerateCompanionFileCode(code, key) + -- override this to generate companion file code; 'key' is used when multiple companion files exist - see GetCompanionFilesList +end + +function Preset:GetError() + return self:CheckIfIdExistsInGlobal() +end + +function Preset:CheckIfIdExistsInGlobal(preset) + preset = preset or self + if preset.GeneratesClass then + -- Check if a class with this id as name has already been generated by another preset + -- The __generated_by_class prop can be used to check the class of the preset that generated a certain class + local name = rawget(_G, preset.id) + local class = g_Classes[preset.id] + if name and name == class then + -- Generated class exists and preset is not DLC + local generated_by = class.__generated_by_class + if preset.save_in == "" and generated_by and generated_by ~= "EntityClass" and generated_by ~= preset.class then + return string.format("Another preset (%s - %s) has already generated a class with this name!", preset.id, class.__generated_by_class) + -- Non-generated class exists + elseif not generated_by then + return string.format("The class \"%s\" already exists!", preset.id) + end + -- Global name exists + elseif name and name ~= class then + return string.format("The id \"%s\" is a reserved global name!", preset.id) + end + end +end + +function Preset:AppendGeneratedByProps(code, preset) + preset = preset or self + -- The __generated_by_class prop can be used to check the class of the preset that generated a certain class + code:append(string.format("\t__generated_by_class = \"%s\",\n\n", preset.class)) +end + +function Preset:GenerateCode(code) + ValueToLuaCode(self, nil, code, {} --[[ make ValueToLuaCode process property injection ]]) + code:append("\n\n") +end + +function Preset:LocalizationContextBase() + if self.GlobalMap then + return string.format("%s %s", self.class, self.id) + else + return string.format("%s %s %s", self.class, self.group, self.id) + end +end + +function Preset:GetLastSavePath() + return g_PresetLastSavePaths[self] or self:GetNormalizedSavePath() +end + +local function create_name_template_with_id(name_template, id) + return name_template:gsub("%%s", id) +end + +local function is_template_ui_image_property(prop) + return prop.editor == "ui_image" and prop.placeholder ~= nil and prop.name_template ~= nil +end + +local function get_template_ext_dest_path(ui_image, id) + local _1, _2, ext = SplitPath(ui_image.placeholder) + local dest = create_name_template_with_id(ui_image.name_template, id) + local path = "svnAssets/Source/" .. dest .. ext + return ext, dest, path +end + +function CreateAndSetDefaultUIImage(ui_image, id, object, skipNonDefault) + local createdProperty = "" + + local ext, dest, osPathDest = get_template_ext_dest_path(ui_image, object.id) + local osPathOrig = ui_image.placeholder + + -- has non default id, so we don't want to create anything + if skipNonDefault and object[ui_image.id] ~= nil and object[ui_image.id] ~= dest then + return createdProperty + end + + if not io.exists(osPathDest) then + local err = AsyncCopyFile(osPathOrig, osPathDest) + if err then + print("Failed copying placeholder portrait " .. osPathOrig .. " to " .. osPathDest) + print(err) + return err + else + createdProperty = "Created " .. ui_image.id .. " for " .. object.id .. "!" + local ok, msg = SVNAddFile(osPathDest) + if not ok then + print("Failed to add (" .. osPathDest .. ") to SVN!") + print(msg) + end + end + end + object[ui_image.id] = dest + return createdProperty +end + +function Preset:CreateNameFromEditorName() + local name + if self.EditorName and self.EditorName ~= "" then + name = "New" .. self.EditorName + elseif self.id and self.id ~= "" then + return self.id + else + return "New" .. self.class + end + + name = string.split(name, " ") + + for idx, seq in ipairs(name) do + local newSeq = string.gsub(seq, "%(.-%)", "") + newSeq = string.gsub(newSeq, "%A", "") + newSeq = string.gsub(newSeq, "^%l", string.upper) + name[idx] = newSeq + end + + name = table.concat(name, "") + + return name +end + +function Preset:OnEditorNew(parent, ged, is_paste, old_id) + if Platform.developer and is_paste then + for id, prop_meta in pairs(self:GetProperties()) do + -- when user changes the image, if the current image does not exist or matches the template, and it exists, we delete it + if is_template_ui_image_property(prop_meta) then + local isDefault = hasDefaultUIImage(prop_meta, self, old_id) + if isDefault then + self[prop_meta.id] = create_name_template_with_id(prop_meta.name_template, self.id) + end + end + end + end + + self.group = self:IsPropertyDefault("group") and parent[1] and parent[1].group or self.group + self.id = self:GenerateUniquePresetId(self:IsPropertyDefault("id") and self:CreateNameFromEditorName() or self.id) + self:Register(self.id, "update_parent_table_cache") + self:PostLoad() + self:MarkDirty() + if not is_paste then self:SortPresets() end -- because GedOpTreePaste already calls it after pasting (one or more) items +end + +function Preset:OnAfterEditorNew(parent, ged, is_paste) + local presets = Presets[self.PresetClass or self.class] + ObjModified(presets) +end + +RecursiveCallMethods.OnPreSave = "call" +function Preset:OnPreSave(by_user_request, ged) + if not Platform.developer or not by_user_request then return end + + g_PendingPresetImageAdds = g_PendingPresetImageAdds or {} + + for id, prop_meta in pairs(self:GetProperties()) do + if is_template_ui_image_property(prop_meta) then + local property = CreateAndSetDefaultUIImage(prop_meta, self.id, self, false) + if property and property ~= "" then + table.insert(g_PendingPresetImageAdds, property) + end + end + end + + if next(g_PendingPresetImageAdds) == nil then g_PendingPresetImageAdds = false end +end + +RecursiveCallMethods.OnPostSave = "call" +function Preset:OnPostSave(by_user_request, ged) +end + +function Preset:GetAllFileSavePaths(main_path) + main_path = main_path or self:GetSavePath() + local paths = self:GetCompanionFilesList(main_path) or {} + paths[false] = main_path + return paths +end + +-- Patch the debug info for the existing functional values to the source code being saved, +-- allowing Edit Code and breakpoints to work without reloading any presets after saving +function OnMsg.OnFunctionSerialized(pstr, func) + -- N.B: THIS CAUSES CORRUPTION, e.g. saved presets have bogus symbols instead of function 'end' clauses + --[[if g_PresetCurrentLuaFileSavePath then + local result, count = pstr:str():gsub("\n", "\n") -- find line number + SetFuncDebugInfo(g_PresetCurrentLuaFileSavePath, count + 1, func) + end]] +end + +-- filters out DLC properties with SaveAsTable == true +function Preset:ShouldCleanPropForSave(id, prop_meta, value) + local dlc = prop_meta.dlc + return g_PresetCurrentLuaFileSavePath and dlc and dlc ~= self.save_in or + PropertyObject.ShouldCleanPropForSave(self, id, prop_meta, value) +end + +-- filters out DLC properties with SaveAsTable == false +function Preset:GetPropertyForSave(id, prop_meta) + local dlc = prop_meta.dlc + if not (g_PresetCurrentLuaFileSavePath and dlc and dlc ~= self.save_in) then + return self:GetProperty(id) + end +end + +function Preset:GetSaveData(file_path, preset_list, code_pstr) + local code = code_pstr or self:GetCompanionFileHeader() + for _, preset in ipairs(preset_list) do + preset:GenerateCode(code) + end + return code +end + +function Preset:GetAllFilesSaveData(file_path, preset_list) + for _, preset in ipairs(preset_list) do + local save_paths = preset:GetCompanionFilesList(file_path) + if save_paths then + save_paths[false] = file_path + g_PresetAllSavePaths[preset] = save_paths + else + g_PresetLastSavePaths[preset] = file_path + end + end + + -- prepare a file_path / code structure for all files + local file_data = preset_list[1]:GetAllFileSavePaths(file_path) + for key, path in pairs(file_data) do + file_data[key] = { file_path = path, code = self:GetCompanionFileHeader(key) } + end + + -- generate main file data + g_PresetCurrentLuaFileSavePath = "@"..file_path + file_data[false].code = self:GetSaveData(file_path, preset_list, file_data[false].code) + g_PresetCurrentLuaFileSavePath = false + + -- generate code for companion files + for _, preset in ipairs(preset_list) do + for key, data in pairs(file_data) do + if key then -- skip main file, it's already generated above + preset:GenerateCompanionFileCode(data.code, key) + end + end + end + return file_data +end + +function Preset:HandleRenameDuringSave(save_path, path_to_preset_list) + local preset_list = path_to_preset_list[save_path] + if #preset_list ~= 1 or self.SingleFile or self.FilePerGroup then + return + end + + local preset = preset_list[1] + local last_save_path = g_PresetLastSavePaths[preset] + if not last_save_path or last_save_path == save_path then + return + end + + local last_save_presets = path_to_preset_list[last_save_path] + assert(last_save_presets) -- file_map should have been generated by SaveAll + if not last_save_presets then + return + end + + local old_paths = g_PresetAllSavePaths[preset] or { [false] = g_PresetLastSavePaths[preset] } + local new_paths = preset:GetAllFileSavePaths(save_path) + for key, path in pairs(old_paths) do + local ok, msg = SVNMoveFile(path, new_paths[key]) + if not ok then + printf("Failed to move file %s to %s. %s", path, new_paths[key], tostring(msg)) + end + end +end + +function PreloadFunctionsSourceCodes(obj) + local result + if IsKindOf(obj, "PropertyObject") then + for _, prop in ipairs(obj:GetProperties()) do + if prop.editor == "func" or prop.editor == "expression" then + local func = obj:GetProperty(prop.id) + if func and not obj:IsDefaultPropertyValue(prop.id, prop, func) then + local name, params, body = GetFuncSource(func) + if not body then return "error" end + result = "preloaded" + end + elseif prop.editor == "nested_obj" or prop.editor == "script" then + local child = obj:GetProperty(prop.id) + if child then + local res = PreloadFunctionsSourceCodes(child) + if res == "error" then return "error" end + result = res or result + end + elseif prop.editor == "nested_list" then + for _, child in ipairs(obj:GetProperty(prop.id)) do + local res = PreloadFunctionsSourceCodes(child) + if res == "error" then return "error" end + result = res or result + end + end + end + + for _, child in ipairs(obj) do + local res = PreloadFunctionsSourceCodes(child) + if res == "error" then return "error" end + result = res or result + end + end + return result +end + +function Preset:SaveFiles(file_map, by_user_request, ged) + SuspendFileSystemChanged("SaveFiles") + table.clear(g_PresetDirtySavePaths) + + local path_to_preset_list = table.map(file_map, function(value) return { } end) + local class = self.PresetClass or self.class + ForEachPresetExtended(class, function(preset, group) + local editor_data = preset:EditorData() + local path = editor_data.save_path or preset:GetNormalizedSavePath() + local preset_list = path_to_preset_list[path] + if preset_list then + -- Check if this preset id exists as a global name/class + local class_exists_err = self:CheckIfIdExistsInGlobal(preset) + if class_exists_err then + print(class_exists_err) + assert(false, class_exists_err) + else + table.insert(preset_list, preset) + end + end + end) + + -- fetch the source of any Lua functions here, as we might not be able to do that mid-save + local msg_displayed + local lua_source_failed_files = {} + for path, preset_list in pairs(path_to_preset_list) do + for _, preset in ipairs(preset_list) do + Msg("OnPreSavePreset", preset, by_user_request, ged) + procall(preset.OnPreSave, preset, by_user_request, ged) + + local result = PreloadFunctionsSourceCodes(preset) + if result == "error" then + table.insert(lua_source_failed_files, path) + elseif not msg_displayed and result == "preloaded" then + print("Fetching source code of Lua functions saved in presets...") + msg_displayed = true + end + end + end + + if by_user_request and next(lua_source_failed_files) then + local files = table.concat(lua_source_failed_files, "\n") + ged:ShowMessage("Error Saving", + string.format("Could not fetch a function's source code in file%s:\n\n%s", #files > 1 and "s" or "", files)) + end + + local to_delete = {} + for path, preset_list in sorted_pairs(path_to_preset_list) do + self:HandleRenameDuringSave(path, path_to_preset_list) + ContextCache = {} + + if #preset_list > 0 then + printf("Saving %s...", path) + + local file_data = self:GetAllFilesSaveData(path, preset_list) -- key => { file_path = "...", code = "..." } + local errors + for key, data in pairs(file_data) do + local err = SaveSVNFile(data.file_path, data.code, self.LocalPreset) + if err then + errors = true + printf("Failed to save %s... %s", data.file_path, err) + end + end + + if not errors then + -- reload the file and apply the newly-loaded function values, so that they include the proper debug info + if Platform.developer and by_user_request and not self:IsKindOfClasses("MapDataPreset", "ConstDef") then + g_PresetRefreshingFunctionValues = true + dofile(path) + g_PresetRefreshingFunctionValues = false + CacheLuaSourceFile(path, file_data[false].code) -- make sure we have a cached Lua source that matches the loaded Lua file + end + + for _, preset in ipairs(preset_list) do + preset:MarkClean() + end + + local ferr, timestamp = AsyncGetFileAttribute(path, "timestamp") + if ferr then + print("Failed to get timestamp for", path) + else + g_PresetFileTimestampAtSave[path] = timestamp + end + end + else + local paths = self:GetAllFileSavePaths(path) + for key, path in pairs(paths) do + table.insert(to_delete, path) + end + end + end + + local saved_presets = {} + for path, preset_list in pairs(path_to_preset_list) do + for _, preset in ipairs(preset_list) do + Msg("OnPostSavePreset", preset, by_user_request, ged) + procall(preset.OnPostSave, preset, by_user_request, ged) + saved_presets[preset] = path + end + end + + local res, err = SVNDeleteFile(to_delete) + ResumeFileSystemChanged("SaveFiles") + return saved_presets +end + +function Preset:Save(by_user_request, ged) + local dirty_paths = {} + dirty_paths[self:GetNormalizedSavePath()] = true + dirty_paths[self:GetLastSavePath()] = true + self:SortPresets() + self:SaveFiles(dirty_paths, by_user_request, ged) + if by_user_request then + self:OnDataSaved() + self:OnDataUpdated() + Msg("PresetSave", self.PresetClass or self.class, not "force_save_all", by_user_request, ged) + end +end + +function Preset:SaveAllCollectAndRun(force_save_all, by_user_request, ged) + PauseInfiniteLoopDetection("Preset:SaveAllCollectAndRun") + + local dirty_paths = {} + local class = self.PresetClass or self.class + ForEachPresetExtended(class, function(preset, group) + local path = preset:GetNormalizedSavePath() + if path and (force_save_all or preset:IsDirty()) then + preset:EditorData().save_path = path + dirty_paths[path] = true + dirty_paths[g_PresetLastSavePaths[preset] or path] = true + end + end) + + for path, preset_class in pairs(g_PresetDirtySavePaths) do + if preset_class == class then + dirty_paths[path] = true + end + end + + local saved_presets = self:SaveFiles(dirty_paths, by_user_request, ged) + ResumeInfiniteLoopDetection("Preset:SaveAllCollectAndRun") + return saved_presets +end + +function Preset:SaveAll(force_save_all, by_user_request, ged) + local class = self.PresetClass or self.class + ReloadingDisabled["saveall_" .. class] = "wait" + + local start_time = GetPreciseTicks() + self:SortPresets() + ForEachPresetExtended(self, CreateDLCPresetsForSaving) -- properties with dlc = ... are saved to the DLC folder by creating fake presets + local saved_presets = self:SaveAllCollectAndRun(force_save_all, by_user_request, ged) + CleanupDLCPresetsForSaving() + self:OnDataSaved() + self:OnDataUpdated() + Msg("PresetSave", class, force_save_all, by_user_request, ged) + printf("%s presets saved in %d ms", class, GetPreciseTicks() - start_time) + + ReloadingDisabled["saveall_" .. class] = false + + if by_user_request then + if Platform.developer and g_PendingPresetImageAdds then + table.insert(g_PendingPresetImageAdds, "\nDon't forget to commit the assets folder!") + ged:ShowMessage("Placeholder images were created!", table.concat(g_PendingPresetImageAdds, "\n") ) + g_PendingPresetImageAdds = false + end + end + + return saved_presets +end + +function Preset:OnEditorDelete(group, ged) + if not self.SingleFile and not self.FilePerGroup then + local fn = self.LocalPreset and AsyncFileDelete or SVNDeleteFile + for key, path in pairs(self:GetAllFileSavePaths()) do + fn(path) + end + g_PresetLastSavePaths[self] = nil + g_PresetAllSavePaths[self] = nil + end + local path = self:GetLastSavePath() + if path then + g_PresetDirtySavePaths[path] = self.PresetClass or self.class + end + + if Platform.developer then + for k,prop_meta in pairs(self:GetProperties()) do + -- when user changes the image, if the current image matches the template, and it exists, we delete it + if is_template_ui_image_property(prop_meta) then + local ext, dest, osPathDest = get_template_ext_dest_path(prop_meta, self.id) + if self[prop_meta.id] == dest or self[prop_meta.id] == nil and io.exists(osPathDest) then + local ok, msg = SVNDeleteFile(osPathDest) + if not ok then + print("Failed to remove (" .. osPathDest .. ") from SVN!") + print("SVN MSG: " .. msg) + end + end + end + end + end +end + +if FirstLoad or ReloadForDlc then + Presets = rawget(_G, "Presets") or {} + setmetatable(Presets, { + __newindex = function(self, key, value) + assert(key == (_G[key].PresetClass or key)) + rawset(self, key, value) + end + }) +end + +function OnMsg.ClassesBuilt() + ClassDescendantsList("Preset", function(name, class, Presets) + local preset_class = class.PresetClass or name + Presets[preset_class] = Presets[preset_class] or {} + + local map = class.GlobalMap + if map then + assert(type(map) == "string") + rawset(_G, map, rawget(_G, map) or {}) + end + end, Presets) +end + +function OnMsg.PersistGatherPermanents(permanents, direction) + local format = string.format + for preset_class_name, groups in pairs(Presets) do + local preset_class = g_Classes[preset_class_name] + if (direction == "load" or preset_class.PersistAsReference) and preset_class_name ~= "ListItem" then + if preset_class.GlobalMap then + for preset_name, preset in pairs(_G[preset_class.GlobalMap]) do + permanents[format("Preset:%s.%s", preset_class_name, preset_name)] = preset + end + end + if not preset_class.GlobalMap or direction == "load" then + for group_name, group in pairs(groups or empty_table) do + if type(group_name) == "string" then + for preset_name, preset in pairs(group or empty_table) do + if type(preset_name) == "string" then + permanents[format("Preset:%s.%s.%s", preset_class_name, group_name, preset_name)] = preset + end + end + end + end + end + end + end + permanents["Preset:UnpersistedMissingPreset.MissingPreset"] = UnpersistedMissingPreset:new{id = "MissingPreset"} +end + +Preset.persist_baseclass = "Preset" +function Preset:UnpersistMissingClass(id, permanents) + assert(id:starts_with("Preset:")) + local dot = id:find(".", 9, true) + local preset_class = g_Classes[id:sub(8, dot and dot - 1)] + return preset_class and preset_class:UnpersistMissingPreset(id, permanents) or permanents["Preset:UnpersistedMissingPreset.MissingPreset"] +end + +function Preset:UnpersistMissingPreset(id, permanents) + if self.GlobalMap and self.UnpersistedPreset then + local preset = table.get(_G, self.GlobalMap, self.UnpersistedPreset) + if preset then + return preset + end + end + local preset_class_name = self.PresetClass or self.class + for group, presets in sorted_pairs(Presets[preset_class_name]) do + local preset = presets[1] + if preset then + return preset + end + end +end + +-- Fallback Preset class for when a preset can't be found due to change of group or deletion +DefineClass.UnpersistedMissingPreset = { + __parents = { "Preset" }, + GedEditor = false, +} + +function Preset:OnDataSaved() +end + +function Preset:OnDataReloaded() +end + +function Preset:OnDataUpdated() -- called after initial load, save and reload +end + +function OnMsg.DataLoaded() + local g_Classes = g_Classes + for class_name in pairs(Presets) do + local class = g_Classes[class_name] + if class then + class:OnDataUpdated() + end + end +end + +function PresetGetPath(target) + if not target then return end + local groups = Presets[target.PresetClass or target.class] + local group = groups[target.group] + assert(group) + local group_index = table.find(groups, group) + local preset_index = table.find(group, target) + return { group_index, preset_index } +end + +-- returns true if the current ui_image matches the ui_image template name (with the supplied id) +function hasDefaultUIImage(ui_image, obj, oldId) + local currentImage = obj[ui_image.id] + local defaultImage = create_name_template_with_id(ui_image.name_template, oldId) + + return currentImage == defaultImage +end + +function Preset:OnEditorSetProperty(prop_id, old_value, ged) + local oldId = prop_id == "Id" and old_value or self.id + local newId = self.id + + -- when user changes the image, if the current image matches the template, we delete it + local prop_meta = self:GetPropertyMetadata(prop_id) + if prop_id == prop_meta.id and is_template_ui_image_property(prop_meta) then + local ext, dest, osPathDest = get_template_ext_dest_path(prop_meta, self.id) + local patternFileExists = io.exists(osPathDest) + if self[prop_id] and (old_value == dest or old_value == nil and patternFileExists) then + local err = AsyncDeletePath(osPathDest) + if err then + print(err) + else + local ok, msg = SVNDeleteFile(osPathDest) + if not ok then + print("Failed to remove file from SVN!") + end + end + end + elseif prop_id == "Id" then + for id, prop_meta in pairs(self:GetProperties()) do + -- when the user changes the id, if the current image matches the template (with old id), and it exists, we rename it + if is_template_ui_image_property(prop_meta) then + + local ext, prevLocalDest, prevPath = get_template_ext_dest_path(prop_meta, oldId) + local _, nextLocalDest, nextPath = get_template_ext_dest_path(prop_meta, self.id) + + local currentImage = self[prop_meta.id] + + if hasDefaultUIImage(prop_meta, self, oldId) then + self[prop_meta.id] = nextLocalDest + local ok, msg = SVNMoveFile(prevPath, nextPath) + if not ok then + printf("Failed to move file %s to %s. %s", prevPath, nextPath, tostring(msg)) + end + end + end + end + end + + if prop_id == "Id" or prop_id == "SortKey" or prop_id == "Group" then + self:SortPresets() + end +end + +function Preset:Compare(other) + if self.HasSortKey then + local k1, k2 = self.SortKey, other.SortKey + if k1 ~= k2 then + return k1 < k2 + end + end + local k1, k2 = self.id, other.id + if k1 ~= k2 then + return k1 < k2 + end + return self.save_in < other.save_in +end + +function Preset:SortPresets() + local presets = Presets[self.PresetClass or self.class] or empty_table + if self.HasSortKey then + table.sort(presets, function(a, b) + local k1, k2 = a[1] and a[1].SortKey or 0, b[1] and b[1].SortKey or 0 + if k1 ~= k2 then + return k1 < k2 + end + return a[1].group < b[1].group + end) + else + table.sort(presets, function(a, b) return a[1].group < b[1].group end) + end + for _, group in ipairs(presets) do + table.sort(group, self.Compare) + end + ObjModified(presets) +end + +preset_print = CreatePrint{ + "preset", + format = "printf", + output = DebugPrint, +} + +function ValidatePresetDataIntegrity(validate_all, game_tests, verbose) + if validate_all then + local dlc = DbgAreDlcsMissing() + if dlc then + if GameTestsRunning then + GameTestsPrint("Presets are validated with DLCs missing:", dlc) + else + CreateMessageBox(nil, Untranslated("Warning"), Untranslated{"Presets were validated with DLCs missing ().\n\nInvalid errors about missing references may occur.", dlc = Untranslated(dlc)}) + end + end + end + + Msg("ValidatingPresets") + SuspendThreadDebugHook("PresetIntegrity") + NetPauseUpdateHash("PresetIntegrity") + + for class, presets in pairs(Presets) do + if class ~= "ListItem" then + PopulateParentTableCache(presets) + end + end + + local validation_start = GetPreciseTicks() + local property_errors = {} + for class, presets in pairs(Presets) do + local preset_class = _G[class] + if preset_class.ValidateAfterSave or validate_all then + for _, group in ipairs(presets) do + for _, preset in ipairs(group) do + local warning = GetDiagnosticMessage(preset, verbose, verbose and "\t") + if warning then + table.insert(property_errors, { preset, warning[1], warning[2]}) + end + end + end + end + end + + preset_print("Preset validation took %i ms.", GetPreciseTicks() - validation_start) + + NetResumeUpdateHash("PresetIntegrity") + ResumeThreadDebugHook("PresetIntegrity") + Msg("ValidatingPresetsDone") + + if #property_errors > 0 then + local indent = verbose and "\n\t" or " " + for _, err in ipairs(property_errors) do + local preset = err[1] + local warn_msg = err[2] + assert(type(warn_msg) == "string") + local warn_type = err[3] + local address = string.format("%s.%s.%s", preset.PresetClass or preset.class, preset.group, preset:GetIdentification()) + + if game_tests then + if warn_type == "error" then + local assert_msg = string.format("[ERROR] %s:%s%s.\n\tUse Debug->ValidatePresetDataIntegrity from the game menu for more info.", address, indent, warn_msg) + GameTestsErrorf(assert_msg) + else + local err_msg = string.format("[WARNING] %s:%s%s", address, indent, warn_msg) + GameTestsPrintf(err_msg) + end + else + local err_msg = string.format("[!] %s:%s%s", warn_type == "warning" and RGB(255, 140, 0) or RGB(240, 0, 0), address, indent, warn_msg) + StoreErrorSource(preset, err_msg) + end + end + end + return property_errors +end + +function OnMsg.PresetSave(class, force_save_all, by_user_request, ged) + local preset_class = g_Classes[class] + if Platform.developer and force_save_all ~= "resave_all" and preset_class.ValidateAfterSave then + ValidatePresetDataIntegrity(false, false) + end +end + +function OnMsg.DataPostprocess() + local start = GetPreciseTicks() + + for class, presets in pairs(Presets) do + _G[class]:SortPresets() + for _, group in ipairs(presets) do + for _, preset in ipairs(group) do + assert(not IsFSUnpacked() or g_PresetLastSavePaths[preset] ~= nil, "A preset didn't register the file it was loaded from; use LoadPresetXXX functions to load presets.\n\nThis problem could lead to saving issues when the preset's save location changes.") + preset:PostLoad() + end + end + end + + preset_print("Preset postprocess took %i ms.", GetPreciseTicks() - start) +end + +function ForEachPresetExtended(class, func, ...) -- includes all presets even when with duplicate IDs (due to DLC loading) + class = g_Classes[class] or class -- get class table + class = class.PresetClass or class.class + for group_index, group in ipairs(Presets[class] or empty_table) do + for preset_index, preset in ipairs(group) do + if func(preset, group, ...) == "break" then + return ... + end + end + end + return ... +end + +function ForEachPreset(class, func, ...) -- does not return presets with duplicate IDs (due to DLC loading) + class = g_Classes[class] or class -- get class table + class = class.PresetClass or class.class + for group_index, group in ipairs(Presets[class]) do + for preset_index, preset in ipairs(group) do + local id = preset.id + if (id == "" or group[id] == preset) and not preset.Obsolete then + if func(preset, group, ...) == "break" then + return ... + end + end + end + end + return ... +end + +function PresetArray(class, func, ...) + return ForEachPreset(class, function(preset, group, presets, func, ...) + if not func or func(preset, group, ...) then + presets[#presets + 1] = preset + end + end, {}, func, ...) +end + +function PresetGroupArray(class, input_group, func, ...) + return ForEachPresetInGroup(class, input_group, function(preset, group, presets, func, ...) + if not func or func(preset, group, ...) then + presets[#presets + 1] = preset + end + end, {}, func, ...) +end + +function ForEachPresetInGroup(class, group, func, ...) + if type(class) == "table" then + class = class.PresetClass or class.class + end + group = (Presets[class] or empty_table)[group] + for preset_index, preset in ipairs(group) do + if group[preset.id] == preset and not preset.Obsolete then + if func(preset, group, ...) == "break" then + return ... + end + end + end + return ... +end + +function ForEachPresetGroup(class, func, ...) + if type(class) == "table" then + class = class.PresetClass or class.class + end + for _, group in ipairs(Presets[class] or empty_table) do + if group[1] and group[1].group ~= "" then + func(group[1].group, ...) + end + end + return ... +end + +function PresetGroupNames(class) + local groups = {} + for _, group in ipairs(Presets[class] or empty_table) do + if group[1] and group[1].group ~= "" then + groups[#groups + 1] = group[1].group + end + end + table.sort(groups) + return groups +end + +function PresetGroupsCombo(class, additional) + return function() + local groups = PresetGroupNames(class) + if type(additional) == "table" then + for i, entry in ipairs(additional) do + table.insert(groups, i, entry) + end + else + table.insert(groups, 1, "") + if additional then + table.insert(groups, 2, additional) + end + end + return groups + end +end + +function PresetsCombo(class, group, additional, filter, format) + return function(obj, prop_meta) + local ids = {} + local encountered = {} + if class and class ~= "" then + local classdef = g_Classes[class] + if not group and classdef and classdef.GlobalMap then -- list all presets + ForEachPreset(class, function(preset, preset_group, ids) + local id = preset.id + if id ~= "" and (not filter or filter(preset, obj, prop_meta)) and not encountered[id] then + ids[#ids + 1] = id + encountered[id] = preset + end + end, ids) + else + local class = classdef and classdef.PresetClass or class + group = group or IsPresetWithConstantGroup(classdef) and classdef.group or false + assert(group, "PresetsCombo requres a group when presets are not with unique ids (GlobalMap = true) or with a constant group") + for _, preset in ipairs((Presets[class] or empty_table)[group]) do + local id = preset.id + if id ~= "" and not encountered[id] and (not filter or filter(preset, obj, prop_meta)) then + ids[#ids + 1] = id + encountered[id] = preset + end + end + end + end + table.sort(ids) + if type(additional) == "table" then + for i = #additional, 1, -1 do + table.insert(ids, 1, additional[i]) + end + elseif additional ~= nil then + table.insert(ids, 1, additional) + end + if format then + for i, id in ipairs(ids) do + local preset = encountered[id] + if preset then + ids[i] = { value = id, text = _InternalTranslate(format, preset) } + else + ids[i] = { value = id, } + end + end + end + return ids + end +end + +function PresetGroupCombo(class, group, filter, first_entry, format) + return function() + local ids = first_entry ~= "no_empty" and {first_entry or ""} or {} + local encountered = {} + local classdef = g_Classes[class] + local preset_class = classdef and classdef.PresetClass or class + assert(preset_class) + for _, preset in ipairs((Presets[preset_class] or empty_table)[group]) do + if preset.id ~= "" and not encountered[preset.id] and (not filter or filter(preset, group)) then + ids[#ids + 1] = format and _InternalTranslate(format, preset) or preset.id + encountered[preset.id] = true + end + end + return ids + end +end + +function PresetMultipleGroupsCombo(class, groups, filter, first_entry, format) + return function() + local ids = first_entry ~= "no_empty" and {first_entry or ""} or {} + local encountered = {} + local classdef = g_Classes[class] + local preset_class = classdef and classdef.PresetClass or class + assert(preset_class) + for _, group in ipairs(groups or empty_table) do + for _, preset in ipairs((Presets[preset_class] or empty_table)[group] or empty_table) do + if preset.id ~= "" and not encountered[preset.id] and (not filter or filter(preset, group)) then + ids[#ids + 1] = format and _InternalTranslate(format, preset) or preset.id + encountered[preset.id] = true + end + end + end + return ids + end +end + +function PresetsPropCombo(class_or_instance, prop, additional, recursive) + if type(class_or_instance) == "table" then + class_or_instance = class_or_instance.PresetClass or class_or_instance.class + end + if type(prop) == "table" then + prop = prop.id + end + if type(class_or_instance) ~= "string" then + return + end + + local function traverse(obj, prop, values, encountered, recursive) + if not obj then + return + end + local value = obj:ResolveValue(prop) + if value and not encountered[value] then -- skip 'false' on purpose + values[#values + 1] = value + encountered[value] = true + end + if recursive then + for _, prop_meta in ipairs(obj:GetProperties()) do + local editor = prop_meta.editor + if editor == "nested_obj" then + traverse(obj:GetProperty(prop_meta.id), prop, values, encountered, recursive) + elseif editor == "nested_list" then + local value = obj:GetProperty(prop_meta.id) + for _, subobj in ipairs(value or empty_table) do + traverse(subobj, prop, values, encountered, recursive) + end + end + end + for _, subitem in ipairs(obj) do + traverse(subitem, prop, values, encountered, recursive) + end + end + end + + return function(obj) + local encountered = {} + local values = {} + ForEachPreset(class_or_instance, function(preset, group, prop, values, encountered, recursive) + traverse(preset, prop, values, encountered, recursive) + end, prop, values, encountered, recursive) + + table.sort(values, function(a, b) + return (IsT(a) and TDevModeGetEnglishText(a) or a) < (IsT(b) and TDevModeGetEnglishText(b) or b) + end) + if additional ~= nil and not table.find(values, additional) then + table.insert(values, 1, additional) + end + return values + end +end + +function PresetsTagsCombo(class_or_instance, prop) + if type(class_or_instance) == "table" then + class_or_instance = class_or_instance.PresetClass or class_or_instance.class + end + if type(prop) == "table" then + prop = prop.id + end + if type(class_or_instance) ~= "string" or type(prop) ~= "string" then + return + end + return function(obj) + local items = {} + ForEachPreset(class_or_instance, function(preset, group, prop, items) + local tags = preset:ResolveValue(prop) + if next(tags) == 1 then + for _, tag in ipairs(tags) do + items[tag] = true + end + else + for tag in pairs(tags) do + items[tag] = true + end + end + end, prop, items) + return table.keys(items, true) + end +end + +function Preset:GenerateUniquePresetId(name) + local id = name or self.id + local group = self.group + local class = self.PresetClass or self.class + local global_map = _G[class].GlobalMap + global_map = global_map and rawget(_G, global_map) + group = Presets[class][group] + if (not global_map or not global_map[id]) and (not group or not group[id]) then + return id + end + + local new_id + local n = 0 + local id1, n1 = id:match("(.*)_(%d+)$") + if id1 and n1 then + id, n = id1, tonumber(n1) + end + repeat + n = n + 1 + new_id = id .. "_" .. n + until (not global_map or not global_map[new_id]) and (not group or not group[new_id]) + return new_id +end + +function Preset:EditorContext() + local PresetClass = self.PresetClass or self.class + local classes = ClassDescendantsList(PresetClass, function(classname, class, PresetClass) + return class.PresetClass == PresetClass and class.GedEditor == g_Classes[PresetClass].GedEditor and not rawget(class, "NoInstances") + end, PresetClass) + if not rawget(self, "NoInstances") then + table.insert(classes, 1, PresetClass) + end + local mod_item_class = g_Classes["ModItem" .. PresetClass] + + local custom_actions = {} + self:GatherEditorCustomActions(custom_actions) + return { + PresetClass = PresetClass, + Classes = classes, + ContainerClass = self.ContainerClass, + ContainerTree = IsKindOf(g_Classes[self.ContainerClass], "Container") + and g_Classes[self.ContainerClass].ContainerClass == self.ContainerClass or false, + ContainerGraphItems = IsKindOf(self, "GraphContainer") and self:EditorItemsMenu(), + ModItemClass = mod_item_class and mod_item_class.class, + EditorShortcut = self.EditorShortcut, + EditorCustomActions = custom_actions, -- avoid trying to serialize the metamethod + FilterClass = self.FilterClass, + SubItemFilterClass = self.SubItemFilterClass, + AltFormat = self.AltFormat, + WarningsUpdateRoot = "root", + ShowUnusedPropertyWarnings = IsKindOf(g_Classes[PresetClass], "CompositeDef") + } +end + +function FindPreset(preset_class, preset_id, prop_id) + prop_id = prop_id or "id" + + local presets = Presets[preset_class] or empty_table + for _, group in ipairs(presets) do + local preset = table.find_value(group, prop_id, preset_id) + if preset then + return preset + end + end +end + +function FindPresetEditor(preset_class, activate) + for _, conn in pairs(GedConnections) do + if conn.context and conn.context.PresetClass == preset_class then + if not activate then + return conn + end + local activated = conn:Call("rfnApp", "Activate") + if activated ~= "disconnected" then + return conn + end + end + end +end + +function OpenPresetEditor(class_name, context) + if not IsRealTimeThread() or not CanYield() then + CreateRealTimeThread(OpenPresetEditor, class_name, context) + return + end + + local class = g_Classes[class_name] + local editor_ctx = context or class:EditorContext() or empty_table + if class.SingleGedEditorInstance then + local ged = FindPresetEditor(editor_ctx.PresetClass, "activate") + if ged then return ged end + end + + local preset_class = g_Classes[class.PresetClass] or class + local presets = Presets[preset_class.class] + PopulateParentTableCache(presets) + return OpenGedApp(class.GedEditor, presets, editor_ctx) +end + +function Preset:OpenEditor() + if not IsRealTimeThread() or not CanYield() then + CreateRealTimeThread(Preset.OpenEditor, self) + return + end + + local ged = OpenPresetEditor(self.PresetClass or self.class) + if ged and self.id ~= "" then + ged:SetSelection("root", PresetGetPath(self)) + end +end + +-- for ValidatePresetDataIntegrity +function Preset:GetIdentification() + return self.id +end + +-- persist the preset groups that are to be displayed collapsed in Ged +if Platform.developer and not Platform.ged then + GedSaveCollapsedPresetGroupsThread = false + + function SaveCollapsedPresetGroups() + local collapsed = {} + for presets_name, groups in pairs(Presets) do + for group_name, group in pairs(groups) do + if type(group_name) == "string" and GedTreePanelCollapsedNodes[group] then + table.insert(collapsed, { presets_name, group_name }) + end + end + end + SetDeveloperOption("CollapsedPresetGroups", collapsed) + end + + function LoadCollapsedPresetGroups() + local collapsed = GetDeveloperOption("CollapsedPresetGroups") + if not collapsed then return end + + for _,item in ipairs(collapsed) do + local preset_group = Presets[item[1]] + local group = preset_group and preset_group[item[2]] + if group then + GedTreePanelCollapsedNodes[group] = true + end + end + end + + function OnMsg.GedTreeNodeCollapsedChanged() + if GedSaveCollapsedPresetGroupsThread ~= CurrentThread then + DeleteThread(GedSaveCollapsedPresetGroupsThread) + end + GedSaveCollapsedPresetGroupsThread = CreateRealTimeThread(function() Sleep(250) SaveCollapsedPresetGroups() end) + end + + function OnMsg.DataLoaded() + LoadCollapsedPresetGroups() + end +end + + +----- PropertyCategories + +DefineClass.PropertyCategory = { + __parents = { "Preset" }, + properties = { + { category = "Category", id = "SortKey", name = "Sort key", editor = "number", default = 0 }, + { category = "Category", id = "display_name", name = "Display Name", editor = "text", translate = true, default = T(159662765679, "") }, + { id = "SaveIn", editor = false}, + }, + + PresetIdRegex = "^[%w _+-]*$", + HasSortKey = true, + SingleFile = true, + GlobalMap = "PropertyCategories", + EditorViewPresetPostfix = Untranslated(" = "), + EditorMenubarName = "Property categories", + EditorMenubar = "Editors.Engine", + EditorIcon = "CommonAssets/UI/Icons/map sitemap structure.png", + Documentation = "Allows you to create a custom property category sort order.\n\nBy default property categories have SortKey = 0, and are listed in the order they first appear as properties are defined." +} + + +--------------- Preset reloading --------------- + +if FirstLoad then + ReloadDataFiles = false + ReloadPresetsThread = false + ReloadPlannedTime = false + ReloadingDisabled = {} +end + +function OnMsg.ReloadLua() + ReloadingDisabled["reloadlua"] = "wait" +end + +function OnMsg.Autorun() + ReloadingDisabled["reloadlua"] = false +end + +function PresetSaveFolders() + local paths = {} + for a, save_in in ipairs(table.imap(Preset.GetPresetSaveLocations(), function(v) return v.value end) ) do + table.insert(paths, Preset:GetSaveFolder(save_in)) + end + return paths +end + +function QueueReloadAllPresets(file, change, force_reload) + if Platform.ged or not force_reload and not Platform.developer then return end + if file and not file:ends_with(".lua") then return end + + -- The resulting event was produced by us saving presets from the editor. No reason to reload. + if not force_reload and g_PresetFileTimestampAtSave[file] then + local err, timestamp = AsyncGetFileAttribute(file, "timestamp") + if not err and g_PresetFileTimestampAtSave[file] == timestamp then + return + end + end + + Msg("DataReload") + preset_print("----- Reload request %s, %s", file, change) + ReloadDataFiles = ReloadDataFiles or {} + ReloadPlannedTime = now() + 500 + if file then + ReloadDataFiles[file] = true + end + + ReloadPresetsThread = ReloadPresetsThread or CreateRealTimeThread(function() + while now() < ReloadPlannedTime or table.has_value(ReloadingDisabled, "wait") do + Sleep(25) + end + + PauseInfiniteLoopDetection("ReloadPresetsFromFiles") + ReloadPresetsFromFiles() + ResumeInfiniteLoopDetection("ReloadPresetsFromFiles") + + ReloadPresetsThread = false + Msg("DataReloadDone") + + if ReloadDataFiles and table.has_value(ReloadDataFiles, true) then + QueueReloadAllPresets() -- start the new reload + end + end) +end + +function ReloadPresetsFromFiles() + if not Platform.developer and not Platform.cmdline then return end + print("Reloading presets...") + + preset_print("Gathering preset types with (possibly) modified runtime data.") + local changed_file_paths = ReloadDataFiles + ReloadDataFiles = false + local preset_classes_modified_in_ged = {} + local presets_to_delete = {} + + -- do not reload files that do not compile + local source_lua_files = {} + local compiled_lua_files = {} + for name in pairs(changed_file_paths) do + if io.exists(name) then + local err, content = AsyncFileToString(name) + if not content then + print(string.format("Unable to read Lua file '%s'", name)) + changed_file_paths[name] = nil + end + local func, err = loadfile(name, nil, _ENV) + if not func then + print(string.format("Lua compilation error in '%s'", name)) + changed_file_paths[name] = nil + end + if func and content then + source_lua_files[name] = content + compiled_lua_files[name] = func + end + end + end + + for class_name, groups in pairs(Presets) do + local class = _G[class_name] + if class.EnableReloading then + for _, group in ipairs(groups) do + for _, preset in ipairs(group) do + if changed_file_paths[preset:GetLastSavePath()] then + presets_to_delete[preset] = true + if preset:IsDirty() then + preset_classes_modified_in_ged[class_name] = "conflict" -- presets from this class have modified runtime copies. + presets_to_delete[preset] = "modified" + preset_print("Conflict %s %s: old_hash %s, current_hash %s", class_name, preset.id, preset:EditorData().old_hash, preset:EditorData().current_hash) + else + preset_classes_modified_in_ged[class_name] = preset_classes_modified_in_ged[class_name] or "affected" -- No runtime changes, but will need to be reloaded + end + end + end + end + end + end + + local conflicted_classes = {} + for class_name, value in pairs(preset_classes_modified_in_ged) do + if value == "conflict" then + table.insert(conflicted_classes, class_name) + end + preset_print("Preset file status %s %s", class_name, value) + end + + if #conflicted_classes > 0 then + if rawget(terminal, "BringToTop") then + terminal.BringToTop() + end + local conflicted_preset_ids = table.map(table.keys(table.filter(presets_to_delete, function(k, v) return v == "modified" end)), function(preset) return preset.id end) + + -- The question is in the game and all opened Ged editors + local title = ("Overwrite preset data?") + local q = "Preset data loaded from a file is about to overwrite changes made in the editor.\n\n" .. + "You will lose ALL changes you have made in the following editors: " .. table.concat(conflicted_classes) .. "\n" .. + "Ged UNDO/REDO will be lost as well.\n\n" .. + "Modified presets: " .. table.concat(conflicted_preset_ids, ", ") .. "\n\nContinue?" + + local result = GedAskEverywhere(title, q) + if result ~= "ok" then + print("Reload canceled.") + Msg("DataReloadDone") + return false + end + end + + -- DROP ALL presets from the affected files + local dropped = 0 + for preset, _ in pairs(presets_to_delete) do + preset:delete() + dropped = dropped + 1 + end + preset_print("Deleted %s presets.", dropped) + + -- Load the new presets + local loaded_presets = {} + local loaded_preset_count = 0 + local old_place_obj = PlaceObj + rawset(_G, "PlaceObj", function(class, ...) + local object_class = _G[class] + local spawned_preset_class + if not IsKindOf(object_class, "Preset") then + return old_place_obj(class, ...) + end + spawned_preset_class = object_class.PresetClass or class + if not _G[spawned_preset_class].EnableReloading then + return "reloading_disabled" + end + preset_classes_modified_in_ged[spawned_preset_class] = preset_classes_modified_in_ged[spawned_preset_class] or "loaded" + local object = old_place_obj(class, ...) + loaded_presets[object] = true + loaded_preset_count = loaded_preset_count + 1 + object:MarkDirty(not "notify") -- make the SaveAll call below re-save the preset + return object + end) + SuspendObjModified("ReloadPresetsFromFiles") + for name, func in pairs(compiled_lua_files) do + PresetsLoadingFileName = name + procall(func) + CacheLuaSourceFile(name, source_lua_files[name]) -- make sure we have a cached Lua source that matches the loaded Lua file + end + PresetsLoadingFileName = false + ResumeObjModified("ReloadPresetsFromFiles") + rawset(_G, "PlaceObj", old_place_obj) + + preset_print("Loaded %s presets", loaded_preset_count) + for preset in pairs(loaded_presets) do + preset:PostLoad() + end + + if not Platform.cmdline then + preset_print("Updating Geds.") + for class_name in pairs(preset_classes_modified_in_ged) do + _G[class_name]:OnDataReloaded() + _G[class_name]:OnDataUpdated() + GedRebindRoot(Presets[class_name], Presets[class_name]) + PopulateParentTableCache(Presets[class_name]) + end + + preset_print("Resaving for reformatting and companion files.") + for class_name in pairs(preset_classes_modified_in_ged) do + _G[class_name]:SaveAll() + end + end + + preset_print("Data reload done.") +end + +if Platform.developer then + local exclude_presets = { + -- Presets with frequent deltas + "MapDataPreset", "ParticleSystemPreset", "PersistedRenderVars", "ThreePointLighting", + -- HG presets + "HGPreset", "HGAccount", "HGInventoryAsset", + "HGMember", "HGMilestone", "HGProjectFeature", "HGTest", + "Build_Settings", + } + local preset_path_pattern = "([%w:/\\_-]+[/\\]([%w_-]+)[/\\]([%w_-]+).([%w.]+))" + function ResaveAllPresetsTest(game_tests) + if not IsRealTimeThread() then + CreateRealTimeThread(ResaveAllPresetsTest, game_tests) + return + end + + local errors = {} + + local ok, status = SVNStatus("svnProject/", "quiet") -- "quiet" ignores unversioned files + if not ok then + table.insert(errors, { nil, " Could not get status of svnProject/" }) + HandleResavePresetErrors(errors, game_tests) + return + end + + -- Resave all presets + SuspendThreadDebugHook("ResaveAllPresetsTest") + SuspendFileSystemChanged("ResaveAllPresetsTest") + if game_tests then + ChangeMap("") + end + + -- Populate parent table cache + for class, presets in sorted_pairs(Presets) do + if class ~= "ListItem" then + PopulateParentTableCache(presets) + end + end + + local count = 0 + for preset_name, _ in sorted_pairs(Presets) do + if not table.find(exclude_presets, preset_name) then + local preset = _G[preset_name] + preset:SaveAll("resave_all") + count = count + 1 + end + end + + Sleep(250) + ResumeFileSystemChanged("ResaveAllPresetsTest") + ResumeThreadDebugHook("ResaveAllPresetsTest") + + local new_ok, new_status = SVNStatus("svnProject/", "quiet") + if not new_ok then + table.insert(errors, { nil, " Could not get status of svnProject/" }) + HandleResavePresetErrors(errors, game_tests) + return + end + + print("All presets resaved. Differences?", status ~= new_status and "Yes!!" or "No", "\nResaved preset classes: ", count) + if status ~= new_status then + local ok_diff, str = SVNDiff("svnProject/", "ignore_whitespaces", 20000) + if not ok_diff then + table.insert(errors, { nil, " " .. str }) + + if str == "Running process time out" then + table.insert(errors, { nil, "The diff might be too long!" }) + end + + HandleResavePresetErrors(errors, game_tests) + return + end + local only_whitespace_changes = str == "" + local diff = {} + + -- Now that we know the diff is only whitespaces, get the details + if only_whitespace_changes then + ok_diff, str = SVNDiff("svnProject/") + end + + local in_entity_data_diff = false + for s in str:gmatch("[^\r\n]+") do + local starts_with_index = string.sub(s, 1, 6) == "Index:" + local ends_with_entity_data = string.sub(s, -25) == "_EntityData.generated.lua" + + if not in_entity_data_diff then + -- _EntityData diff start + in_entity_data_diff = starts_with_index and ends_with_entity_data + else + -- _EntityData diff end + in_entity_data_diff = not (starts_with_index and not ends_with_entity_data) + end + + -- Don't add _EntityData changes to the diff lines + if not in_entity_data_diff then + diff[#diff+1] = s + end + if #diff == 30 then break end + end + + -- Save which files are changed but don't count those with only whitespace changes or _EntityData + -- Those are the files we want to log errors for + local changed_files = {} + for full_path, folder, file, ext in str:gmatch("Index:%s+" .. preset_path_pattern) do + if file ~= "_EntityData" then + table.insert(changed_files, { full_path = full_path, folder = folder, file = file, ext = ext }) + end + end + + -- [NOTE] The diff is only in _EntityData.generated.lua files. This happens after committing a change in the ArtSpec editor + -- because this generates changes in both the Source and the Assets repositories. There's no need to do anything. + -- This diff will disappear in one of the next autobuilds. + local only_entity_data_changes = #diff == 0 + + -- Ignore changes if they're only in _EntityData files + if not only_entity_data_changes and not only_whitespace_changes then + table.insert(errors, { nil, " Resaving all presets created deltas! See changed files below. Use Tools->\"Resave All Presets\" from the game editor menu to test this.", "error" }) + + -- Summary of changed files + for full_path, folder, file, ext in new_status:gmatch("M%s+" .. preset_path_pattern) do + -- Display _EntityData changes as warnings + local entity_data_file = string.find(file, "_EntityData") + -- Log errors only for files that have at least one non-whitespace change + local whitespace_changes_file = not entity_data_file and table.find_value(changed_files, "file", file) + if whitespace_changes_file and whitespace_changes_file.ext == ext then + local err = string.format("Preset: %s | Preset type: %s | File: %s", string.find(file, "ClassDef") and "-" or file, folder, full_path) + table.insert(errors, { nil, err, entity_data_file and "warning" or "error" }) + end + end + + local err_msg = string.format("\nOld status:\n%s \nNew Status:\n%s \nDiff (up to 30 lines):\n%s", status, new_status, table.concat(diff, "\n")) + table.insert(errors, { nil, err_msg, "warning" }) + end + end + + --- Additional checks for integrity of the "preset -> generated file" pairs + local preset_id_to_gen_file = {} + for preset_name, presets in sorted_pairs(Presets) do + local preset_class = _G[preset_name] + + if preset_class and preset_class.GeneratesClass then + preset_id_to_gen_file[preset_name] = {} + assert(preset_class:GetCompanionFileSavePath(preset_class:GetSavePath())) + local gen_path = preset_class:GetCompanionFileSavePath(preset_class:GetSavePath()) + + -- Check if generated file is missing for an existing preset entry + ForEachPresetExtended(preset_name, function(preset, group) + if preset:GetSavePath() then -- might be nil for mod items + assert(preset:GetCompanionFileSavePath(preset:GetSavePath())) + local preset_gen_path = preset:GetCompanionFileSavePath(preset:GetSavePath()) + -- Check if the generated file exists + if io.exists(preset_gen_path) then + if not preset_id_to_gen_file[preset_name][preset.save_in] then + preset_id_to_gen_file[preset_name][preset.save_in] = {} + end + preset_id_to_gen_file[preset_name][preset.save_in][preset.id] = preset_gen_path + else + local err_msg = string.format("Generated lua file is missing for this preset: %s.%s.%s! Expected: %s", preset_name, preset.group, preset.id, preset_gen_path) + table.insert(errors, { preset, err_msg }) + end + end + end) + + -- Check if a preset entry is missing for an existing generated file + local preset_folder = string.match(gen_path, "(Lua/.+)/") + if not preset_folder then + goto continue + end + local files = io.listfiles(preset_folder, "*.generated.lua") + local base_class = preset_class.ObjectBaseClass or preset_class.PresetClass or "" + local extra_def_id = "__" .. base_class + + for _, f_path in ipairs(files) do + if string.find(f_path, "ClassDef", 1, true) then + goto skip_file + end + + local id = string.match(f_path, "/.+/(.+)%.generated%.lua$") -- the file name is the preset id + local dlc = string.match(f_path, "/Dlc/(.+)/Presets/") or "" -- get dlc name (if any) + + if id ~= extra_def_id and preset_id_to_gen_file[preset_name][dlc][id] ~= f_path then + local err_msg = string.format("Preset entry is missing for this generated file: %s! Expected %s preset with id %s", f_path, preset_name, id ) + table.insert(errors, { nil, err_msg }) + end + + ::skip_file:: + end + end + + ::continue:: + end + + -- Process stored errors + HandleResavePresetErrors(errors, game_tests) + end + + function HandleResavePresetErrors(errors, game_tests) + if #errors > 0 and not DbgAreDlcsMissing() then + for idx, err in ipairs(errors) do + local preset = err[1] + local msg = err[2] + assert(type(msg) == "string", "Error message should be a string") + local err_type = err[3] + + if game_tests then + if err_type == "warning" then + GameTestsPrint(msg) + else + GameTestsError(msg) + end + else + local err_msg = string.format("[!] %s", RGB(240, 0, 0), msg) + if err_type == "warning" then + StoreWarningSource(preset, err_msg) + else + StoreErrorSource(preset, err_msg) + end + end + end + end + end +end + +function GetAvailablePresets(presets) + if not presets then + return + end + local forbidden = {} + Msg("GatherForbiddenPresets", presets, forbidden) + if not next(forbidden) then + return presets + end + local filtered = {} + for _, preset in ipairs(presets) do + if not forbidden[preset.id] then + filtered[#filtered + 1] = preset + end + end + return filtered +end + +function DisplayPresetCombo(class, default, group) + local function add_item(preset, group, items) + if preset:filter() then + items[#items + 1] = { text = preset:GetDisplayName(), value = preset.id } + end + end + + local items = {default} + if group then + ForEachPresetInGroup(class, group, add_item, items) + else + ForEachPreset(class, add_item, items) + end + return items +end + +-- Bookmarks - preset or preset group unique path + +function GetPresetOrGroupUniquePath(obj) + return IsKindOf(obj, "Preset") and + { obj:GetGroup(), obj:GetId() } or + { obj[1]:GetGroup() } +end + +function PresetOrGroupByUniquePath(class, path) + local group, id = path[1], path[2] + local class_table = g_Classes[class] + local presets = Presets[class_table.PresetClass or class_table.class] + local group = presets and presets[group] + if not id then + return group + end + return group and group[id] +end + +function ListPresets(class, predicate, ...) + local list = {} + ForEachPreset(class, function(preset, group, list, ...) + local predicate_func = predicate and preset[predicate] + if not preset.Obsolete and (not predicate_func or predicate_func(preset, ...)) then + list[#list + 1] = preset + end + end, list, ...) + return list +end + +function ListPresetIds(class, predicate, ...) + local list = ListPresets(class, predicate, ...) + for i, preset in ipairs(list) do + list[i] = preset.id + end + return list +end diff --git a/CommonLua/PresetDLCSplitting.lua b/CommonLua/PresetDLCSplitting.lua new file mode 100644 index 0000000000000000000000000000000000000000..c95056409419575bbbdc0b6c96e6d5eb7a1eecac --- /dev/null +++ b/CommonLua/PresetDLCSplitting.lua @@ -0,0 +1,211 @@ +----- Support for adding DLC-only properties of presets that are saved in the DLC folder + +-- This is done for properties with dlc = "". DefineDLCProperties adds properties to a specified main game preset class, and sets this up for you. +-- Values of existing main game properties can also be overridden; this works as follows: +-- a) the existing main game property will now be held and edited in a new property with id MainGame +-- b) at game startup, the property will take the value of the main game property or the DLC property depending on wheter the DLC is enabled + +-- 'class' is the class to insert properties into; it will be the same as 'preset_class' unless a CompositeDef preset is involved +function DefineDLCProperties(class, preset_class, dlc, prop_class) + local old_to_new_props = {} + + local base_props = _G[class].properties + local base_prop_ids = {} + for _, prop in ipairs(base_props) do + base_prop_ids[prop.id] = prop + end + + local i = 1 + for _, prop in ipairs(_G[prop_class].properties) do + local main_id = prop.maingame_prop_id + if main_id then + assert(base_prop_ids[main_id] and prop.dlc) + -- create a new MainGame property for editing purposes + local new_id = main_id .. "MainGame" + local old_idx = table.find(base_props, "id", main_id) + if not base_prop_ids[new_id] then + local old_prop = base_props[old_idx] + local new_prop = table.copy(old_prop, "deep") + new_prop.id = main_id .. "MainGame" + new_prop.name = old_prop.name or old_prop.id + table.insert(base_props, old_idx + 1, new_prop) + old_prop.no_edit = true -- the "old" property will only be used by the game to use the value from + old_prop.dlc_override = prop.dlc -- used by saving code in Composite.lua + + old_to_new_props[main_id] = new_prop.id + end + -- move this property after the MainGame property + table.insert(base_props, old_idx + 2, prop) + else + assert(not base_prop_ids[prop.id], + string.format("Duplicate property ids in DefineDLCProperties(\"%s\", \"%s\", \"%s\", ...). To override a property in the DLC, define a new property with maingame_prop_id = ", + class, preset_class, dlc)) + table.insert(base_props, i, prop) + i = i + 1 + end + prop.dlc = dlc + end + + -- Here is how overriding main game properties is supported below: + -- * have the proper value for the game to use in the "old" props + -- * editing is done via the "new" props + -- * saving is done by temporarily moving "new" prop to "old" prop, then restoring them + + -- at startup, copy "old" props to "new" ones (that are used for editing in Ged) + function OnMsg.DataPreprocess() + local class = preset_class.PresetClass or preset_class + for _, group in ipairs(Presets[class]) do + for _, preset in ipairs(group) do + if preset:IsKindOf(preset_class) then + for main_id, new_id in pairs(old_to_new_props) do + preset:SetProperty(new_id, preset:GetProperty(main_id)) + end + end + end + end + end + + local restore_data = {} + function OnMsg.OnPreSavePreset(preset) + if preset:IsKindOf(preset_class) then + for main_id, new_id in pairs(old_to_new_props) do + restore_data[main_id] = preset:GetProperty(main_id) + preset:SetProperty(main_id, preset:GetProperty(new_id)) + preset:SetProperty(new_id, nil) + end + end + end + function OnMsg.OnPostSavePreset(preset) + if preset:IsKindOf(preset_class) then + for main_id, new_id in pairs(old_to_new_props) do + preset:SetProperty(new_id, preset:GetProperty(main_id)) + preset:SetProperty(main_id, restore_data[main_id]) + restore_data[main_id] = nil + end + end + end +end + + +----- "fake" presets that are temporarily created while saving (to save the DLC properties in the DLC folder) + +if FirstLoad then + DLCPresetsForSaving = {} +end + +DefineClass.DLCPropsPreset = { + __parents = { "Preset" }, + GedEditor = false, +} + +function DLCPropsPreset:GetProperties() + local main_class = g_Classes[self.MainPresetClass] + local props = table.ifilter(main_class:GetProperties(), function(idx, prop) return prop.dlc == self.save_in end) + table.insert(props, { id = "MainPresetClass", editor = "text", default = "", save_in = self.save_in }) -- we want this saved + return props +end + +function DLCPropsPreset:CleanupForSave(injected_props, restore_data) + restore_data = PropertyObject.CleanupForSave(self, injected_props, restore_data) + restore_data[#restore_data + 1] = { obj = self, key = "PresetClass", value = self.PresetClass } + restore_data[#restore_data + 1] = { obj = self, key = "FilePerGroup", value = self.FilePerGroup } + restore_data[#restore_data + 1] = { obj = self, key = "SingleFile", value = self.SingleFile } + restore_data[#restore_data + 1] = { obj = self, key = "GlobalMap", value = self.GlobalMap } + self.PresetClass = nil + self.FilePerGroup = nil + self.SingleFile = nil + self.GlobalMap = nil + return restore_data +end + +function CreateDLCPresetsForSaving(preset) + if IsKindOf(preset, "DLCPropsPreset") then return end + + -- split properties into different presets, depending on their dlc metavalue + local dlc_presets = {} -- dlc => preset + for _, prop in ipairs(preset:GetProperties()) do + local dlc = prop.dlc + if dlc then + local id = prop.id + local value = preset:GetProperty(id) + if not preset:IsDefaultPropertyValue(id, prop, value) then + local dlc_preset = dlc_presets[dlc] + if not dlc_preset then + dlc_preset = DLCPropsPreset:new{ + MainPresetClass = preset.class, + save_in = dlc, + id = preset.id, + group = preset.group, + + -- Preset members related to saving presets, to make sure the DLCPropsPreset goes into the proper filename + -- (GetSavePath uses these values to construct the path) + PresetClass = preset.PresetClass or preset.class, + FilePerGroup = preset.FilePerGroup, + SingleFile = preset.SingleFile, + GlobalMap = preset.GlobalMap and "DLCPropsPresets", + } + -- register without an id (""), we don't want to overwrite the original preset in the preset group or GlobalMap + dlc_preset:Register("") + if preset:IsDirty() then + dlc_preset:MarkDirty() + end + table.insert(DLCPresetsForSaving, dlc_preset) + dlc_presets[dlc] = dlc_preset + end + dlc_presets[dlc]:SetProperty(id, value) + end + end + end +end + +function CleanupDLCPresetsForSaving() + for _, preset in ipairs(DLCPresetsForSaving) do + preset:delete() + end + DLCPresetsForSaving = {} +end + +function DLCPropsPreset:FindOriginalPreset() + local class = g_Classes[self.MainPresetClass] + local preset_class = class.PresetClass or class.class + local presets = Presets[preset_class] + local group = presets and presets[self.group] + return group and group[self.id] +end + +function DLCPropsPreset:OnDataUpdated() + local dlc_presets = {} + ForEachPresetExtended("DLCPropsPreset", function(dlc_preset) + local main_preset = dlc_preset:FindOriginalPreset() + assert(not Platform.developer or Platform.console or DbgAreDlcsMissing() or main_preset, string.format("Unable to find main preset for class %s, group %s, id %s", dlc_preset.MainPresetClass, dlc_preset.group, dlc_preset.id)) + if main_preset then + for _, prop in ipairs(dlc_preset:GetProperties()) do + local id = prop.id + if id ~= "Id" and id ~= "Group" and id ~= "SaveIn" and id ~= "MainPresetClass" then + local value = dlc_preset:GetProperty(prop.id) + main_preset:SetProperty(id, value) + if prop.maingame_prop_id then + main_preset:SetProperty(prop.maingame_prop_id, value) + end + end + end + end + table.insert(dlc_presets, dlc_preset) + end) + for _, preset in ipairs(dlc_presets) do + preset:delete() + end +end + +-- if the active DLC property is edited, transfer the value to the main property (from where it is used in the game) +function OnMsg.GedPropertyEdited(ged_id, obj, id, old_value) + if IsKindOf(obj, "Preset") then + local prop_meta = obj:GetPropertyMetadata(id) + if prop_meta.maingame_prop_id then + local main_prop = obj:GetPropertyMetadata(prop_meta.maingame_prop_id) + if main_prop.dlc_override == prop_meta.dlc then + obj:SetProperty(main_prop.id, obj:GetProperty(id)) + end + end + end +end diff --git a/CommonLua/PresetDef.lua b/CommonLua/PresetDef.lua new file mode 100644 index 0000000000000000000000000000000000000000..6db48535854cd58adaa25f1468ade77ae6329e4a --- /dev/null +++ b/CommonLua/PresetDef.lua @@ -0,0 +1,228 @@ +function PresetClassesCombo(base_class, filter, param1, param2) + return function (obj) + return ClassDescendantsList(base_class or "Preset", filter, obj, param1, param2) + end +end + +DefineClass.PresetDef = { + __parents = { "ClassDef" }, + properties = { + { category = "Misc", id = "DefGlobalMap", name = "GlobalMap", editor = "text", default = "", }, + { category = "Misc", id = "DefHasGroups", name = "Organize in groups", editor = "bool", default = Preset.HasGroups, }, + { category = "Misc", id = "DefPresetGroupPreset", name = "Groups preset class", editor = "choice", default = false, items = PresetClassesCombo(), }, + { category = "Misc", id = "DefHasSortKey", name = "Has SortKey", editor = "bool", default = Preset.HasSortKey, }, + { category = "Misc", id = "DefHasParameters", name = "Has parameters", editor = "bool", default = Preset.HasParameters, }, + { category = "Misc", id = "DefHasCompanionFile", name = "Has companion file", editor = "bool", default = Preset.HasCompanionFile, }, + { category = "Misc", id = "DefHasObsolete", name = "Has Obsolete", editor = "bool", default = Preset.HasObsolete, }, + { category = "Misc", id = "DefSingleFile", name = "Store in single file", editor = "bool", default = Preset.SingleFile, }, + { category = "Misc", id = "DefPropertyTranslation", name = "Translate property names", editor = "bool", default = Preset.PropertyTranslation, }, + { category = "Misc", id = "DefPresetClass", name = "Preset base class", editor = "choice", default = false, items = PresetClassesCombo(), }, + { category = "Misc", id = "DefContainerClass", name = "Container sub-items class", editor = "text", default = "", }, + { category = "Misc", id = "DefPersistAsReference", name = "Persist as reference", editor = "bool", default = true, help = "When true preset instances will only be referenced by savegames, if false used preset instance data will be saved."}, + { category = "Misc", id = "DefModItem", name = "Define ModItem", editor = "bool", default = false, }, + { category = "Misc", id = "DefModItemName", name = "ModItem name", editor = "text", default = "", no_edit = function(self) return not self.DefModItem end, }, + { category = "Misc", id = "DefModItemSubmenu", name = "ModItem submenu", editor = "text", default = "Other", no_edit = function(self) return not self.DefModItem end, }, + { category = "Editor", id = "DefGedEditor", name = "Editor class", editor = "text", default = Preset.GedEditor, }, + { category = "Editor", id = "DefEditorName", name = "Editor menu name", editor = "text", default = "", }, + { category = "Editor", id = "DefEditorShortcut", name = "Editor shortcut", editor = "shortcut", default = Preset.EditorShortcut, }, + { category = "Editor", id = "DefEditorIcon", name = "Editor icon", editor = "text", default = Preset.EditorIcon, }, + { category = "Editor", id = "DefEditorMenubar", name = "Editor menu", editor = "combo", default = "Editors", items = ClassValuesCombo("Preset", "EditorMenubar"), }, + { category = "Editor", id = "DefEditorMenubarSortKey", name = "Editor SortKey", editor = "text", default = "" }, + { category = "Editor", id = "DefFilterClass", name = "Filter class", editor = "combo", items = ClassDescendantsCombo("GedFilter"), default = "", }, + { category = "Editor", id = "DefSubItemFilterClass", name = "Subitems filter class", editor = "combo", items = ClassDescendantsCombo("GedFilter"), default = "", }, + { category = "Editor", id = "DefAltFormat", name = "Alternative format string", editor = "text", default = "", }, + { category = "Editor", id = "DefEditorCustomActions", name = "Custom editor actions", editor = "nested_list", default = false, base_class = "EditorCustomActionDef", inclusive = true }, + { category = "Editor", id = "DefTODOItems", name = "TODO items", editor = "string_list", default = false, }, + }, + group = "PresetDefs", + DefParentClassList = { "Preset" }, + GlobalMap = "PresetDefs", + EditorViewPresetPrefix = "[Preset] ", +} + +function PresetDef:GenerateConsts(code) + self:AppendConst(code, "HasGroups") + self:AppendConst(code, "PresetGroupPreset", "") + self:AppendConst(code, "HasSortKey") + self:AppendConst(code, "HasParameters") + self:AppendConst(code, "HasCompanionFile") + self:AppendConst(code, "HasObsolete") + self:AppendConst(code, "SingleFile") + self:AppendConst(code, "PropertyTranslation") + self:AppendConst(code, "GlobalMap", "") + self:AppendConst(code, "PresetClass", "") + self:AppendConst(code, "ContainerClass") + self:AppendConst(code, "PersistAsReference") + self:AppendConst(code, "GedEditor") + self:AppendConst(code, "EditorMenubarName", false, "DefEditorName") + self:AppendConst(code, "EditorShortcut") + self:AppendConst(code, "EditorIcon") + self:AppendConst(code, "EditorMenubar") + self:AppendConst(code, "EditorMenubarSortKey") + self:AppendConst(code, "FilterClass", "") + self:AppendConst(code, "SubItemFilterClass", "") + self:AppendConst(code, "AltFormat", "") + self:AppendConst(code, "TODOItems") + + if self.DefEditorCustomActions and #self.DefEditorCustomActions > 0 then + local result = {} + for idx, action in ipairs(self.DefEditorCustomActions) do + if action.Name ~= "" then + local action_copy = table.raw_copy(action) + table.insert(result, action_copy) + end + end + code:append("\tEditorCustomActions = ") + code:appendv(result) + code:append(",\n") + end + ClassDef.GenerateConsts(self, code) +end + +function PresetDef:GenerateMethods(code) + if self.DefModItem then + code:appendf('DefineModItemPreset("%s", { EditorName = "%s", EditorSubmenu = "%s" })\n\n', self.id, self.DefModItemName, self.DefModItemSubmenu) + end + ClassDef.GenerateMethods(self, code) +end + +function PresetDef:GetError() + if self.DefModItem and (self.DefModItemName or "") == "" then + return "ModItem name must be specified." + end + return ClassDef.GetError(self) +end + + +----- ClassAsGroupPresetDef + +DefineClass.ClassAsGroupPresetDef = { + __parents = { "PresetDef", }, + properties = { + { category = "Preset", id = "GroupPresetClass", name = "Preset group class", editor = "choice", default = false, + items = PresetClassesCombo("Preset", function(class_name, class) return class.PresetClass == class_name end), + help = "Only Presets with .PresetClass == are listed here"}, + { id = "DefHasGroups", editor = false }, + { id = "DefGedEditor", editor = false }, + { id = "DefEditorName", editor = false }, + { id = "DefEditorShortcut", editor = false }, + { id = "DefEditorIcon", editor = false }, + { id = "DefEditorMenubar", editor = false }, + { id = "DefEditorMenubarSortKey", editor = false }, + { id = "DefFilterClass", editor = false }, + { id = "DefSubItemFilterClass", editor = false }, + { id = "DefEditorCustomActions", editor = false }, + { id = "DefTODOItems", editor = false }, + { id = "DefPresetClass", editor = false }, + }, + EditorViewPresetPrefix = Untranslated("[] "), +} + +function ClassAsGroupPresetDef:Init() + self.DefParentClassList = rawget(self, "DefParentClassList") or self.GroupPresetClass and { self.GroupPresetClass } or nil +end + +function ClassAsGroupPresetDef:GetDefaultPropertyValue(id, prop_meta) + if id == "DefParentClassList" then + return { self.GroupPresetClass } + end + return PresetDef.GetDefaultPropertyValue(self, id, prop_meta) +end + +function ClassAsGroupPresetDef:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "GroupPresetClass" then + table.remove_entry(self.DefParentClassList, old_value) + self.DefParentClassList = rawget(self, "DefParentClassList") or {} + for _, class_name in ipairs(self.DefParentClassList) do + local class = g_Classes[class_name] + if class and class.__ancestors[self.GroupPresetClass] then + return + end + end + table.insert(self.DefParentClassList, 1, self.GroupPresetClass) + end + return PresetDef.OnEditorSetProperty(self, prop_id, old_value, ged) +end + +function ClassAsGroupPresetDef:GenerateConsts(code) + PresetDef.GenerateConsts(self, code) + code:appendf("\tgroup = \"%s\",\n", self.id) +end + + +----- EditorCustomActionDef + +DefineClass.EditorCustomActionDef = { + __parents = { "PropertyObject" }, + properties = { + { id = "Name", editor = "text", default = "", }, + { id = "Rollover", editor = "text", default = "", }, + { id = "FuncName", editor = "text", default = "", }, + { id = "IsToggledFuncName", editor = "text", default = "" }, + { id = "Toolbar", editor = "text", default = "", }, + { id = "Menubar", editor = "text", default = "", }, + { id = "SortKey", editor = "text", default = "", }, + { id = "Shortcut", editor = "shortcut", default = "", }, + { id = "Icon", editor = "ui_image", default = "CommonAssets/UI/Ged/cog.tga" }, + }, + EditorView = Untranslated(""), +} + + +----- DCLPropertiesDef + +local blacklist = { + ClassDef = true, FXPreset = true, XTemplate = true, AnimMetadata = true, + SoundPreset = true, SoundTypePreset = true, ReverbDef = true, NoisePreset = true, +} + +DefineClass.DLCPropertiesDef = { + __parents = { "ClassDef" }, + properties = { + { id = "SaveIn", name = "Add properties in DLC", editor = "choice", default = "", + items = function(obj) return obj:GetPresetSaveLocations() end, }, + { id = "add_to_preset", name = "In preset class", editor = "choice", default = "", + -- try to list only game-specific presets + items = ClassDescendantsCombo("Preset", false, function(name, preset) + local class = preset.PresetClass or preset.class + return preset.class == class and next(Presets[class]) and Presets[class][1][1].save_in == "" and not blacklist[class] + end) + }, + { id = "DefParentClassList", editor = false, }, + { id = "DefPropertyTranslation", editor = false, }, + { id = "DefStoreAsTable", editor = false, }, + { id = "DefPropertyTabs", editor = false, }, + { id = "DefUndefineClass", editor = false, }, + }, +} + +function DLCPropertiesDef:GetObjectClass() + local preset_class = g_Classes[self.add_to_preset] + local is_composite = preset_class:IsKindOf("CompositeDef") + return is_composite and preset_class.ObjectBaseClass or self.add_to_preset, is_composite +end + +function DLCPropertiesDef:GeneratePropExtraCode(prop_def) + local object_class, is_composite = self:GetObjectClass() + local override_prop = object_class and g_Classes[object_class]:GetPropertyMetadata(prop_def.id) + assert(not override_prop or override_prop.dlc_override or override_prop.dlc == self.save_in) + local template_str = is_composite and "template = true, " or "" + return override_prop and not override_prop.dlc and + string.format('%sdlc = "%s", maingame_prop_id = "%s", id = "%s%sDLC"', template_str, self.save_in, prop_def.id, prop_def.id, self.save_in) or + string.format('%sdlc = "%s"', template_str, self.save_in) +end + +function DLCPropertiesDef:GenerateGlobalCode(code) + ClassDef.GenerateGlobalCode(self, code) + code:appendf('DefineDLCProperties("%s", "%s", "%s", "%s")\n\n', + self:GetObjectClass(), self.add_to_preset, self.save_in, self.id) +end + +local hintColor = RGB(210, 255, 210) +function DLCPropertiesDef:GetError() + if self.save_in == "" then + return { "Specify the DLC to add the properties of this class to.", hintColor } + elseif self.add_to_preset == "" then + return { "Add the properties to which preset?\n\nIn case of composite objects, specify the CompositDef preset; properties will be added to its ObjectBaseClass.", hintColor } + end +end diff --git a/CommonLua/PresetParam.lua b/CommonLua/PresetParam.lua new file mode 100644 index 0000000000000000000000000000000000000000..0a7c7860fcb3a040c981ae425c141798bdfaef85 --- /dev/null +++ b/CommonLua/PresetParam.lua @@ -0,0 +1,149 @@ +DefineClass.PresetParam = { + __parents = { "PropertyObject", }, + properties = { + { id = "Name", editor = "text", default = false, }, + { id = "Value", editor = "number", default = 0, }, + { id = "Tag", editor = "text", translate = false, read_only = true, default = "", help = "Paste this tag into texts to display the parameter's value.", }, + }, + EditorView = Untranslated("Param = "), + Type = "number", +} + +function PresetParam:GetTag() + return "<" .. (self.Name or "") .. ">" +end + +function PresetParam:GetError() + if not self.Name then + return "Please name your parameter." + elseif not self.Name:match("^[%w_]*$") then + return "Parameter name must only contain alpha-numeric characters and underscores." + end +end + +DefineClass.PresetParamNumber = { + __parents = { "PresetParam", }, + properties = { + { id = "Value", editor = "number", default = 0, }, + }, + EditorName = "New Param (number)", +} + +DefineClass.PresetParamPercent = { + __parents = { "PresetParam", }, + properties = { + { id = "Value", editor = "number", default = 0, scale = "%" }, + }, + EditorView = Untranslated("Param = %"), + EditorName = "New Param (percent)", +} + +function PresetParamPercent:GetTag() + return "<" .. (self.Name or "") .. ">%" +end + +function PickParam(root, obj, prop_id, ged) + local param_obj = ged:GetParentOfKind(obj, "Preset").Parameters + local params = {} + local params_to_num = {} + for _,item in ipairs(param_obj) do + if item.Name then + params[#params + 1] = item.Name + params_to_num[item.Name] = item.Value + end + end + + if #params == 0 then + ged:ShowMessage("Error", "There are no Parameters defined for this Preset.") + return + end + + local pick = obj.param_bindings and obj.param_bindings[prop_id] or params[1] + if #params > 1 then + pick = ged:WaitUserInput("Select Param", pick, params) + if not pick then return end + end + obj.param_bindings = obj.param_bindings or {} + obj.param_bindings[prop_id] = pick + obj:SetProperty(prop_id, params_to_num[pick]) + GedForceUpdateObject(obj) + ObjModified(obj) + ObjModified(root) +end + +local function PresetParamOnEditorNew(obj, parent, ged) + local preset = ged:GetParentOfKind(parent, "Preset") or obj + if obj:IsKindOf("PresetParam") then + local preset_param_cache = g_PresetParamCache[preset] or {} + preset_param_cache[obj.Name] = obj.Value + g_PresetParamCache[preset] = preset_param_cache + elseif preset:HasMember("HasParameters") and preset.HasParameters == true and not obj:HasMember("param_bindings") then + rawset(obj, "param_bindings", false) + end +end + +local function PresetParamOnEditorSetProperty(obj, prop_to_change, prev_value, ged) + local preset = ged.selected_object + if not preset then return end + + if obj:IsKindOf("PresetParam") then + local preset_param_cache = g_PresetParamCache[preset] or {} + + if prop_to_change == "Value" then + preset:ForEachSubObject(function(subobj, parents, key, param_name, new_value) + for prop, param in pairs(rawget(subobj, "param_bindings")) do + if param == param_name then + subobj:SetProperty(prop, new_value) + ObjModified(subobj) + end + end + end, obj.Name, obj.Value) + elseif prop_to_change == "Name" then + preset:ForEachSubObject(function(subobj, parents, key, new_name, old_name) + for prop, param in pairs(rawget(subobj, "param_bindings")) do + if param == old_name then + subobj.param_bindings[prop] = new_name + ObjModified(subobj) + end + end + end, obj.Name, prev_value) + + preset_param_cache[prev_value] = nil + end + + preset_param_cache[obj.Name] = obj.Value + g_PresetParamCache[preset] = preset_param_cache + elseif obj:HasMember("param_bindings") and obj.param_bindings and obj.param_bindings[prop_to_change] then + obj.param_bindings[prop_to_change] = nil + end +end + +local function PresetParamOnEditorDelete(obj, parent, ged) + local preset = ged.selected_object + if not preset then return end + + if obj:IsKindOf("PresetParam") then + preset:ForEachSubObject(function(subobj, parents, key, deleted_param) + for prop, param in pairs(rawget(subobj, "param_bindings")) do + if param == deleted_param then + subobj.param_bindings[prop] = nil + ObjModified(subobj) + end + end + end, obj.Name) + + if g_PresetParamCache[preset] then + g_PresetParamCache[preset][obj.Name] = nil + end + end +end + +function OnMsg.GedNotify(obj, method, ...) + if method == "OnEditorNew" then + PresetParamOnEditorNew(obj, ...) + elseif method == "OnEditorSetProperty" then + PresetParamOnEditorSetProperty(obj, ...) + elseif method == "OnAfterEditorDelete" then + PresetParamOnEditorDelete(obj, ...) + end +end diff --git a/CommonLua/PresetWithQA.lua b/CommonLua/PresetWithQA.lua new file mode 100644 index 0000000000000000000000000000000000000000..ff06e3f8ddec91bf0df32927e3904559276e063d --- /dev/null +++ b/CommonLua/PresetWithQA.lua @@ -0,0 +1,64 @@ +DefineClass.PresetWithQA = { + __parents = { "Preset" }, + properties = { + { category = "Preset", id = "qa_info", name = "QA Info", editor = "nested_obj", base_class = "PresetQAInfo", inclusive = true, default = false, + buttons = {{name = "Just Verified!", func = "OnVerifiedPress"}}, + no_edit = function(obj) return obj:IsKindOf("ModItem") end, + }, + }, + EditorMenubarName = false, +} + +function PresetWithQA:OnPreSave(user_requested) + if Platform.developer and user_requested and self:IsDirty() then + self.qa_info = self.qa_info or PresetQAInfo:new() + self.qa_info:LogAction("Modified") + ObjModified(self) + end +end + +function PresetWithQA:OnVerifiedPress(parent, prop_id, ged) + self.qa_info = self.qa_info or PresetQAInfo:new() + self.qa_info:LogAction("Verified") + ObjModified(self) +end + +DefineClass.PresetQAInfo = { + __parents = { "InitDone" }, + properties = { + { id = "Log", name = "Full Log", editor = "text", lines = 1, max_lines = 10, default = false, read_only = true, }, + }, + + data = false, -- entries in the format { user = "Ivko", action = "Verified", time = os.time() } + StoreAsTable = true, -- persist 'data' too +} + +function PresetQAInfo:GetEditorView() + if not self.data then return "[Empty]" end + local last = self.data[#self.data] + return T{Untranslated("[Last Entry] by on "), last, timestamp = os.date("%Y-%b-%d", last.time)} +end + +function PresetQAInfo:GetLog() + local log = {} + for _, entry in ipairs(self.data or empty_table) do + log[#log + 1] = string.format("%s by %s on %s", entry.action, entry.user, os.date("%Y-%b-%d", entry.time)) + end + return table.concat(log, "\n") +end + +function PresetQAInfo:LogAction(action) + local user_data = GetHGMemberByIP(LocalIPs()) + if not user_data then return end -- outside of HG network, can't get user from his local IP + + self.data = self.data or {} + + local user = user_data.id + local time = os.time() + local data = self.data + local last = data[#data] + if not (last and last.user == user and (last.action == action or action == "Modified") and time - last.time < 24 * 60 * 60) then -- 24 hours + data[#data + 1] = { user = user, action = action, time = time } + end + ObjModified(self) +end diff --git a/CommonLua/PrgPreset.lua b/CommonLua/PrgPreset.lua new file mode 100644 index 0000000000000000000000000000000000000000..2fcf9681442c10c96346e672f93af29d6a965743 --- /dev/null +++ b/CommonLua/PrgPreset.lua @@ -0,0 +1,752 @@ +const.TagLookupTable["keyword"] = "" +const.TagLookupTable["/keyword"] = "" + +if Platform.developer then + function prgdbg(li, level, idx) + li[level] = idx + Msg("OnPrgLine", li) + end +else + prgdbg = empty_func +end + +g_PrgPresetPropsCache = {} + +DefineClass.PrgPreset = { + __parents = { "Preset" }, + + properties = { + { id = "Params", editor = "string_list", default = {} }, + }, + + SingleFile = false, + ContainerClass = "PrgStatement", + EditorMenubarName = false, + HasCompanionFile = true, + + StatementTags = { "Basics" }, -- list the PrgStatement tags usable in this Prg class + FuncTable = "Prgs", -- override in child classes +} + +function PrgPreset:GenerateFuncName() + return self.id +end + +function PrgPreset:GetParamString() + return table.concat(self.Params, ", ") +end + +function PrgPreset:GenerateCodeAtFunctionStart(code) + code:append("\tlocal rand = BraidRandomCreate(seed or AsyncRand())\n") +end + +function PrgPreset:GenerateCompanionFileCode(code) + -- generate static code + local has_statements = false + for _, statement in ipairs(self) do + if not statement.Disable then + local len = code:size() + statement:GenerateStaticCode(code) + if len ~= code:size() then + code:append("\n") + has_statements = true + end + end + end + if has_statements then + code:append("\n") + end + + -- generate function code + code:appendf("rawset(_G, '%s', rawget(_G, '%s') or {})\n", self.FuncTable, self.FuncTable) + code:appendf("%s.%s = function(seed, %s)\n", self.FuncTable, self:GenerateFuncName(), self:GetParamString()) + code:appendf("\tlocal li = { id = \"%s\" }\n", self.id) + self:GenerateCodeAtFunctionStart(code) + for idx, statement in ipairs(self) do + if not statement.Disable then + statement:GenerateCode(code, "\t", idx) + code:append("\n") + end + end + code:append("end") +end + +function PrgPreset:EditorContext() + local context = Preset.EditorContext(self) + context.ContainerTree = true + return context +end + +function PrgPreset:FilterSubItemClass(class) + return not class.StatementTag or table.find(self.StatementTags, class.StatementTag) +end + +function OnMsg.ClassesBuilt() + local undefined = ClassLeafDescendantsList("PrgStatement", function(name, class) return not class.StatementTag end) + assert(#undefined == 0, string.format("Prg statement %s has no StatementTag defined.", undefined[1])) + + -- add a validate function to enforce variables names to be identifiers + ClassLeafDescendantsList("PrgStatement", function(name, class) + for _, prop_meta in ipairs(class:GetProperties()) do + if prop_meta.items == PrgVarsCombo or prop_meta.variable then + prop_meta.validate = function(self, value) + return ValidateIdentifier(self, value) + end + end + end + end) +end + + +----- Statement & Block + +function PrgVarsCombo() + return function(obj) + local vars = table.keys(obj:VarsInScope()) + table.insert(vars, "") + table.sort(vars) + return vars + end +end + +function PrgLocalVarsCombo() + return function(obj) + local vars = {} + for k, v in pairs(obj:VarsInScope()) do + if v ~= "static" then + vars[#vars + 1] = k + end + end + table.insert(vars, "") + table.sort(vars) + return vars + end +end + +DefineClass.PrgStatement = { + __parents = { "PropertyObject" }, + properties = { + { id = "Disable", editor = "bool", default = false, }, + }, + DisabledPrefix = Untranslated(""), + EditorName = "Command", + StoreAsTable = true, + StatementTag = false, -- each PrgStatement must have a tag; each PrgPreset defines the list of tags that can be used in it +} + +function PrgStatement:VarsInScope() + local vars = {} + local current = self + local block = GetParentTableOfKindNoCheck(self, "PrgBlock", "PrgPreset") + while block do + for _, statement in ipairs(block) do + if statement ~= self then + statement:GatherVars(vars) + end + if statement == current then break end + end + current = block + block = GetParentTableOfKindNoCheck(block, "PrgBlock", "PrgPreset") + end + -- 'current' is now the PrgPreset + for _, var in ipairs(current.Params) do + vars[var] = true + end + vars[""] = nil -- skip "" vars due to unset properties + return vars +end + +function PrgStatement:LinePrefix(indent, idx) + return string.format("%sprgdbg(li, %d, %d) ", indent, #indent, idx) +end + +function PrgStatement:GatherVars(vars) + -- add variables declared by this statement as keys in the 'vars' table, with value "local" or "static" +end + +function PrgStatement:GenerateStaticCode(code) + -- generate code to be inserted before the Prg function body, generates the code that declares the static vars +end + +function PrgStatement:GenerateCode(code, indent, idx) + -- generate code for the Prg function body +end + +function PrgStatement:GetEditorView() + return _InternalTranslate(Untranslated(""), self, false) .. _InternalTranslate(self.EditorView, self, false) +end + +DefineClass.PrgBlock = { + __parents = { "PrgStatement", "Container" }, + ContainerClass = "PrgStatement", +} + +function PrgBlock:GenerateStaticCode(code) + if #self == 0 then return end + for i = 1, #self - 1 do + self[i]:GenerateStaticCode(code) + end + self[#self]:GenerateStaticCode(code) +end + +function PrgBlock:GenerateCode(code, indent, idx) + if #self == 0 then return end + indent = indent .. "\t" + for i = 1, #self - 1 do + if not self[i].Disable then + self[i]:GenerateCode(code, indent, i) + code:append("\n") + end + end + if not self[#self].Disable then + self[#self]:GenerateCode(code, indent, #self) + code:appendf(" li[%d] = nil", #indent) + end +end + + +----- Variables + +local function get_expr_string(expr) + if not expr or expr == empty_func then return "nil" end + + local name, parameters, body = GetFuncSource(expr) + body = type(body) == "table" and table.concat(body, "\n") or body + return body:match("^%s*return%s*(.*)") or body +end + +DefineClass.PrgAssign = { + __parents = { "PrgStatement" }, + properties = { + { id = "Variable", editor = "combo", default = "", items = PrgLocalVarsCombo, }, + }, + EditorView = Untranslated(" = "), +} + +function PrgAssign:GatherVars(vars) + vars[self.Variable] = "local" +end + +function PrgAssign:GenerateCode(code, indent, idx) + local var_exists = self:VarsInScope()[self.Variable] + code:appendf("%s%s%s = %s", self:LinePrefix(indent, idx), var_exists and "" or "local ", self.Variable, self:GetValueCode()) +end + +function PrgAssign:GetValueCode() + -- define in child classes +end + +function PrgAssign:GetValueDescription() + -- define in child classes +end + +DefineClass.PrgAssignExpr = { + __parents = { "PrgAssign" }, + properties = { + { id = "Value", editor = "expression", default = empty_func }, + }, + EditorName = "Set variable", + EditorSubmenu = "Basics", + StatementTag = "Basics", +} + +function PrgAssignExpr:GetValueCode() + return get_expr_string(self.Value) +end + +function PrgAssignExpr:GetValueDescription() + return get_expr_string(self.Value) +end + + +----- Flow control - if / else, while, loops + +DefineClass.PrgIf = { + __parents = { "PrgBlock" }, + properties = { + { id = "Repeat", name = "Repeat while satisfied", editor = "bool", default = false, }, + { id = "Condition", editor = "expression", default = empty_func, }, + }, + EditorName = "Condition check (if/while)", + EditorSubmenu = "Code flow", + StatementTag = "Basics", +} + +function PrgIf:GetExprCode(for_preview) + return get_expr_string(self.Condition) +end + +function PrgIf:GenerateCode(code, indent, idx) + code:appendf(self.Repeat and "%swhile %s do\n" or "%sif %s then\n", self:LinePrefix(indent, idx), self:GetExprCode(false)) + PrgBlock.GenerateCode(self, code, indent) + + local parent = GetParentTableOfKind(self, "PrgBlock") or GetParentTableOfKind(self, "PrgPreset") + local next_statement = parent[table.find(parent, self) + 1] + if not IsKindOf(next_statement, "PrgElse") then + code:appendf("\n%send", indent) + end +end + +function PrgIf:GetEditorView() + return Untranslated("" .. (self.Repeat and "while " or "if ") .. self:GetExprCode(true)) +end + +DefineClass.PrgElse = { + __parents = { "PrgBlock" }, + EditorName = "Condition else", + EditorView = Untranslated("else"), + EditorSubmenu = "Code flow", + StatementTag = "Basics", +} + +function PrgElse:GenerateCode(code, indent, idx) + if self:CheckPrgError() then return end + code:appendf("%selse\n\t%s\n", indent, self:LinePrefix(indent, idx)) + PrgBlock.GenerateCode(self, code, indent, idx) + code:appendf("\n%send", indent) +end + +function PrgElse:CheckPrgError() + local parent = GetParentTableOfKind(self, "PrgBlock") or GetParentTableOfKind(self, "PrgPreset") + local prev_statement = parent[table.find(parent, self) - 1] + return not IsKindOf(prev_statement, "PrgIf") or prev_statement.Repeat +end + +DefineClass.PrgForEach = { + __parents = { "PrgBlock" }, + properties = { + { id = "List", name = "List variable", editor = "choice", default = "", items = PrgVarsCombo, }, + { id = "Value", name = "Value variable", editor = "text", default = "value" }, + { id = "Index", name = "Index variable", editor = "text", default = "i", }, + }, + EditorName = "For each", + EditorView = Untranslated("for each '' in ''"), + EditorSubmenu = "Code flow", + StatementTag = "Basics", +} + +function PrgForEach:GatherVars(vars) + vars[self.List] = "local" + vars[self.Value] = "local" + vars[self.Index] = "local" +end + +function PrgForEach:GenerateCode(code, indent, idx) + if self.List == "" then return end + code:appendf("%sfor %s, %s in ipairs(%s) do\n", self:LinePrefix(indent, idx), self.Index, self.Value, self.List) + PrgBlock.GenerateCode(self, code, indent) + code:appendf("\n%send", indent) +end + + +----- Calls (function and Prg) + +-- calls self:Exec with sprocall, passing all property values in order as parameters +DefineClass.PrgExec = { + __parents = { "PrgStatement" }, + ExtraParams = {}, -- extra params to pass before the properties, e.g. { "rand" } to use the random generator for the Prg + AssignTo = "", -- variable name to assign function result to; create a property with the same name to allow the user specify it + PassClassAsSelf = true, +} + +function PrgExec:GetParamProps() + return self:GetProperties() +end + +function PrgExec:GetParamString() + local params = self.PassClassAsSelf and { self.class } or {} + table.iappend(params, self.ExtraParams) + for _, prop in ipairs(self:GetParamProps()) do + if prop.editor ~= "help" and prop.editor ~= "buttons" and prop.id ~= "Disable" then + local value = self:GetProperty(prop.id) + params[#params + 1] = + type(value) == "function" and get_expr_string(value) or + prop.variable and value == "" and "nil" or + prop.variable and value ~= "" and value or ValueToLuaCode(value):gsub("[\t\r\n]", "") + end + end + return table.concat(params, ", ") +end + +function PrgExec:GatherVars(vars) + vars[self.AssignTo] = "local" +end + +function PrgExec:GenerateCode(code, indent, idx) + if self.AssignTo and self.AssignTo ~= "" then + local var_exists = self:VarsInScope()[self.AssignTo] + code:appendf("%slocal _%s\n", indent, var_exists and "" or ", " .. self.AssignTo) + code:appendf("%s_, %s = sprocall(%s.Exec, %s)", self:LinePrefix(indent, idx), self.AssignTo, self.class, self:GetParamString()) + else + code:appendf("%ssprocall(%s.Exec, %s)", self:LinePrefix(indent, idx), self.class, self:GetParamString()) + end +end + +function PrgExec:Exec(...) + -- IMPORTANT: 'self' will be the class and not the instance + -- implement the function to execute here; all properties of your class are passed AS PARAMETERS in the order of their declaration +end + +-- override to "export" an existing Lua function as a Prg statement +DefineClass.PrgFunction = { + __parents = { "PrgExec" }, + properties = { + { id = "VarArgs", name = "Add extra parameters", editor = "string_list", default = false, no_edit = function(self) return not self.HasExtraParams end }, + }, + + PassClassAsSelf = false, + + Params = "", -- specify comma-separated parameters here + HasExtraParams = false, -- has variable arguments? + Exec = empty_func, -- function to execute +} + +function PrgFunction:GetParamProps() + local props = {} + for param in string.gmatch(self.Params, "[^, ]+") do + props[#props + 1] = { id = param, editor = "expression", default = empty_func, } + end + return props +end + +function PrgFunction:GetProperties() + local props = g_PrgPresetPropsCache[self] + if not props then + props = self:GetParamProps() + local class_props = table.copy(PropertyObject.GetProperties(self), "deep") + local idx = table.find(class_props, "id", "VarArgs") + if idx then + table.insert(props, class_props[idx]) + table.remove(class_props, idx) + end + table.iappend(class_props, props) + g_PrgPresetPropsCache[self] = class_props + end + return props +end + +function PrgFunction:GetParamString() + local ret = PrgExec.GetParamString(self) + if self.HasExtraParams and self.VarArgs then + local extra = table.concat(self.VarArgs, ", ") + ret = ret == "" and extra or (ret .. ", " .. extra) + end + return ret +end + +-- call any Lua function by name +DefineClass.PrgCallLuaFunction = { + __parents = { "PrgFunction" }, + properties = { + { id = "FunctionName", name = "Function name", editor = "text", default = "", + validate = function(self, value) + return value ~= "" and not self:FindFunction(value) and "Can't find function with the specified name" + end, + help = "Lua function to call - use Object:MethodName if you'd like to call a class method." + }, + }, + StoreAsTable = false, -- so SetFunctionName gets called upon loading + + EditorName = "Call function", + EditorView = Untranslated("Call ()"), + EditorSubmenu = "Code flow", + StatementTag = "Basics", +} + +function PrgCallLuaFunction:FindFunction(fn_name) + local ret = _G + for field in string.gmatch(fn_name, "[^:. ]+") do + ret = rawget(ret, field) + if not ret then return end + end + return fn_name ~= "" and ret +end + +function PrgCallLuaFunction:SetFunctionName(fn_name) + if self.FunctionName == fn_name then return end + + local fn = self:FindFunction(fn_name) + local name, parameters, body = GetFuncSource(fn) + if name then + local extra = parameters:ends_with(", ...") + self.Params = extra and parameters:sub(1, -6) or parameters + self.HasExtraParams = extra + else + self.Params = nil + self.HasExtraParams = nil + end + if string.find(fn_name, ":") then + self.Params = self.Params == "" and "self" or ("self, " .. self.Params) + end + self.FunctionName = fn_name + g_PrgPresetPropsCache[self] = nil +end + +function PrgCallLuaFunction:GenerateCode(code, indent, idx) + code:appendf("%ssprocall(%s, %s)", self:LinePrefix(indent, idx), self.FunctionName:gsub(":", "."), self:GetParamString()) +end + +DefineClass.PrgCallPrgBase = { + __parents = { "PrgStatement" }, + properties = { + { id = "PrgClass", name = "Prg class", editor = "choice", default = "", items = ClassDescendantsCombo("PrgPreset") }, + { id = "PrgGroup", name = "Prg preset group", editor = "choice", default = "", + items = function(self) return PresetGroupsCombo(self.PrgClass) end, + no_edit = function(self) return self.PrgClass == "" or g_Classes[self.PrgClass].GlobalMap end, }, + { id = "Prg", editor = "preset_id", default = "", + preset_group = function(self) return self.PrgGroup ~= "" and self.PrgGroup end, + preset_class = function(self) return self.PrgClass ~= "" and self.PrgClass or "PrgPreset" end }, + }, + + EditorName = "Call Prg", + EditorView = Untranslated("Call Prg ''"), + EditorSubmenu = "Code flow", + StatementTag = "Basics", +} + +function PrgCallPrgBase:GetProperties() + local prg = self.Prg ~= "" and PresetIdPropFindInstance(self, table.find_value(self.properties, "id", "Prg"), self.Prg) + if not prg then return self.properties end + + local props = g_PrgPresetPropsCache[self] + if not props then + props = table.copy(PropertyObject.GetProperties(self), "deep") + for _, param in ipairs(prg.Params or empty_table) do + props[#props + 1] = { id = param, editor = "expression", default = empty_func, } + end + g_PrgPresetPropsCache[self] = props + end + return props +end + +function PrgCallPrgBase:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "PrgClass" then + self.PrgGroup = self.PrgClass ~= "" and not g_Classes[self.PrgClass].GlobalMap and PresetGroupsCombo(self.PrgClass)()[2] or "" + end + if prop_id == "PrgClass" or prop_id == "PrgGroup" then + self.Prg = nil + end + if prop_id == "PrgClass" or prop_id == "PrgGroup" or prop_id == "Prg" then + local prop_cache = g_PrgPresetPropsCache[self] + for _, prop in ipairs(prop_cache) do + if not table.find(self.properties, "id", prop.id) then + self[prop.id] = nil + end + end + g_PrgPresetPropsCache[self] = nil + end +end + +function PrgCallPrgBase:OnAfterEditorNew() + local parent_prg = GetParentTableOfKind(self, "PrgPreset") + self.PrgClass = parent_prg.class + self.PrgGroup = self.PrgClass ~= "" and not g_Classes[self.PrgClass].GlobalMap and PresetGroupsCombo(self.PrgClass)()[2] or "" +end + +DefineClass("PrgCallPrg", "PrgCallPrgBase") + +function PrgCallPrg:GetParamString() + local prg = PresetIdPropFindInstance(self, table.find_value(self.properties, "id", "Prg"), self.Prg) + local params = {} + for _, param in ipairs(prg.Params or empty_table) do + params[#params + 1] = get_expr_string(rawget(self, param)) + end + return table.concat(params, ", ") +end + +function PrgCallPrg:GenerateCode(code, indent, idx) + if self.PrgClass == "" or self.Prg == "" then return end + + local prg = PresetIdPropFindInstance(self, table.find_value(self.properties, "id", "Prg"), self.Prg) + if prg then + code:appendf("%ssprocall(%s.%s, rand(), %s)", self:LinePrefix(indent, idx), prg.FuncTable, prg:GenerateFuncName(), self:GetParamString()) + end +end + +DefineClass.PrgPrint = { + __parents = { "PrgFunction" }, + Params = "", + HasExtraParams = true, + Exec = print, + EditorName = "Print on console", + EditorView = Untranslated("Print "), + EditorSubmenu = "Basics", + StatementTag = "Basics", +} + +DefineClass.PrgExecuteEffects = { + __parents = { "PrgExec" }, + properties = { + { id = "Effects", editor = "nested_list", default = false, base_class = "Effect", all_descendants = true }, + }, + EditorName = "Execute effects", + EditorSubmenu = "Basics", + StatementTag = "Effects", +} + +function PrgExecuteEffects:GetEditorView() + local items = { _InternalTranslate("Execute effects:", self, false) } + for _, effect in ipairs(self.Effects or empty_table) do + items[#items + 1] = "--> " .. _InternalTranslate(Untranslated(""), effect, false) + end + return table.concat(items, "\n") +end + +function PrgExecuteEffects:Exec(effects) + return ExecuteEffectList(effects) +end + + +----- Get objects (add/remove/assign a list of objects to a variable) + +DefineClass.PrgGetObjs = { + __parents = { "PrgExec" }, + properties = { + { id = "Action", editor = "choice", default = "Assign", items = { "Assign", "Add to", "Remove from" }, }, + { id = "AssignTo", name = "Objects variable", editor = "combo", default = "", items = PrgVarsCombo, variable = true, }, + }, + EditorSubmenu = "Objects", + StatementTag = "Objects", +} + +function PrgGetObjs:GetEditorView() + local prefix = _InternalTranslate("", self, false) + if self.Action == "Assign" then + return string.format("'%s' = %s", self.AssignTo, self:GetObjectsDescription()) + elseif self.Action == "Add to" then + return string.format("'%s' += %s", self.AssignTo, self:GetObjectsDescription()) + else -- if self.Action == "Remove from" then + return string.format("'%s' -= %s", self.AssignTo, self:GetObjectsDescription()) + end +end + +function PrgGetObjs:Exec(Action, AssignTo, ...) + if self.Action == "Assign" then + return self:GetObjects(...) + elseif self.Action == "Add to" then + local objs = IsKindOf(AssignTo, "Object") and { AssignTo } or AssignTo or {} + return table.iappend(objs, self:GetObjects(...)) + else -- if self.Action == "Remove from" then + local objs = IsKindOf(AssignTo, "Object") and { AssignTo } or AssignTo or {} + return table.subtraction(objs, self:GetObjects(...)) + end + return AssignTo +end + +function PrgGetObjs:GetObjectsDescription() + -- return a text that describes the objects here, e.g. "enemy units of the unit in 'Variable'" +end + +function PrgGetObjs:GetObjects(...) + -- the properties after Action and Variable are passed to this function; return the list of objects here +end + +DefineClass.GetObjectsInGroup = { +__parents = { "PrgGetObjs" }, + properties = { + { id = "Group", editor = "choice", default = "", items = function() return table.keys2(Groups, true, "") end, }, + }, + EditorName = "Get objects from group", +} + +function GetObjectsInGroup:GetObjectsDescription() + return string.format("objects from group '%s'", self.Group) +end + +function GetObjectsInGroup:GetObjects(Group) + return table.copy(Groups[Group] or empty_table) +end + + +----- Object list filtering + +DefineClass.PrgFilterObjs = { + __parents = { "PrgExec" }, + properties = { + { id = "AssignTo", name = "Objects variable", editor = "combo", default = "", items = PrgVarsCombo, variable = true, }, + }, + EditorSubmenu = "Objects", + StatementTag = "Objects", +} + +DefineClass.FilterByClass = { + __parents = { "PrgFilterObjs" }, + properties = { + { id = "Classes", editor = "string_list", default = false, items = ClassDescendantsCombo("Object"), arbitrary_value = true, }, + { id = "Negate", editor = "bool", default = false, }, + }, + EditorName = "Filter by class", +} + +function FilterByClass:GetEditorView() + return self.Negate and + string.format("Leave only objects of classes %s in '%s'", table.concat(self.Classes, ", "), self.AssignTo) or + string.format("Remove objects of classes %s in '%s'", table.concat(self.Classes, ", "), self.AssignTo) +end + +function FilterByClass:Exec(objs, Negate, Classes) + return table.ifilter(objs, function(i, obj) return Negate == not IsKindOfClasses(obj, table.unpack(Classes)) end) +end + +DefineClass.SelectObjectsAtRandom = { + __parents = { "PrgFilterObjs" }, + properties = { + { id = "Percentage", editor = "number", default = 100, min = 1, max = 100, slider = true }, + { id = "MaxCount", editor = "number", default = 0, }, + }, + ExtraParams = { "rand" }, + EditorName = "Filter at random", +} + +function SelectObjectsAtRandom:GetEditorView() + if self.MaxCount <= 0 then + return string.format("Leave %d%% of the objects in '%s'", self.Percentage, self.AssignTo) + elseif self.Percentage == 100 then + return string.format("Leave no more than %d objects in '%s'", self.MaxCount, self.AssignTo) + else + return string.format("Leave %d%% of the objects in '%s', but no more than %d", self.Percentage, self.AssignTo, self.MaxCount) + end +end + +function SelectObjectsAtRandom:Exec(rand, objs, Percentage, MaxCount) + local count = MulDivRound(#objs, Percentage, 100) + if MaxCount > 0 then + count = Min(count, MaxCount) + end + + local ret, taken, len = {}, {}, #objs + --local added = {} + while count > 0 do + local idx = rand(len) + 1 + ret[count] = objs[taken[idx] or idx] + --assert(not added[ret[count]]) + --added[ret[count]] = true + count, len = count - 1, len - 1 + taken[idx] = taken[len] or len + end + return ret +end + + +----- Others + +DefineClass.DeleteObjects = { + __parents = { "PrgExec" }, + properties = { + { id = "ObjectsVar", name = "Objects variable", editor = "choice", default = "", items = PrgLocalVarsCombo, variable = true, }, + }, + EditorName = "Delete objects", + EditorView = Untranslated("Delete the objects in ''"), + EditorSubmenu = "Objects", + StatementTag = "Objects", +} + +function DeleteObjects:Exec(ObjectsVar) + ObjectsVar = ObjectsVar or empty_table + XEditorUndo:BeginOp{ objects = ObjectsVar } -- does nothing if outside of editor + if IsEditorActive() then + Msg("EditorCallback", "EditorCallbackDelete", ObjectsVar) + end + for _, obj in ipairs(ObjectsVar) do obj:delete() end + XEditorUndo:EndOp() +end diff --git a/CommonLua/PropertyObject.lua b/CommonLua/PropertyObject.lua new file mode 100644 index 0000000000000000000000000000000000000000..41f3d1d4b054a18d78e041798327e371556beaec --- /dev/null +++ b/CommonLua/PropertyObject.lua @@ -0,0 +1,1431 @@ +RecursiveCallMethods.GetError = "or" +RecursiveCallMethods.GetWarning = "or" + +-- these properties are tables - :Clone copies them via table.copy +TableProperties = { + range = true, + set = true, + prop_table = true, + objects = true, + number_list = true, + string_list = true, + preset_id_list = true, + T_list = true, + point_list = true, +} + +-- these properties don't store a value +PlaceholderProperties = { + help = true, + documentation = true, + buttons = true, + linked_presets = true, +} + +-- these properties sometimes point at "parent", not "child" tables, and shouldn't be visited in recursive descents +MembersReferencingParents = { + mod = true, -- ModItem keeps a .mod member to the parent ModDef + env = true, -- ModDef keeps a .env which contains the current ModDef + own_mod = true, -- in ModDependency + container = true, -- ContinuousEffect.container + __index = true, + __mod = true, -- ModOptionsObject +} + +DefineClass.PropertyObject = { + __parents = {}, + __hierarchy_cache = true, + properties = {}, + + GedTreeChildren = false, + GedEditor = false, + EditorView = Untranslated(""), + Documentation = false, -- collapsible documentation to show in Ged on the place of the property with editor = "documentation" + PropertyTranslation = false, + StoreAsTable = false, -- use 'true' for much faster loading, however it doesn't call property setters + GetPropertyForSave = false, -- use to override values while saving (or return nil to skip a property); works with StoreAsTable == false only! +} + +function ChangeClassPropertyMeta(class, prop_id, field, value) + assert(type(class) == "table") + if type(class) == "table" then + local prop_meta = table.find_value(class.properties, "id", prop_id) + if prop_meta then + prop_meta[field] = value + end + end +end + +function PropertyObject.new(class, obj) + return setmetatable(obj or {}, class) +end + +function PropertyObject:delete() +end + +function PropertyObject:ResolveValue(id) + local value = self:GetProperty(id) + if value ~= nil then return value end + return rawget(self, id) +end + +local function member_assert(self, key) + if type(key) ~= "number" and not self:HasMember(key) then + assert(false, self.class .. "." .. key .. " assignment - object should not have dynamic members", 2) + end +end + +function PropertyObject.__newindex(self, key, value) + dbg(value ~= nil and member_assert(self, key)) + rawset(self, key, value) +end + +function PropertyObject:EditorContext() + return +end + +function PropertyObject:OpenEditor() + if self.GedEditor then + return OpenGedApp(self.GedEditor, self, self:EditorContext()) + end +end + +PropertyObject.IsReadOnly = empty_func -- Ged will not allow editing of objects for which this returns 'true' + +-- Override to report a warning (for Ged) for any missing properties / data inconsistencies +-- tables with extra parameters are supported { warning_text, warning_color, underline_subitemidx1, underline_subitemidx2, ... } +PropertyObject.GetWarning = empty_func +PropertyObject.GetError = empty_func + +function eval_items(items, obj, prop_meta) + local validate_fn + while type(items) == "function" do + local ok + ok, items, validate_fn = procall(items, obj, prop_meta, "validate_fn") + if not ok then return "err" end + end + return items, validate_fn +end + +function PropertyObject:ValidateProperty(prop_meta, value, verbose, indent) + local prop_eval = prop_eval + local find = table.find + local no_edit = prop_eval(prop_meta.no_edit, self, prop_meta) + local no_validate = prop_eval(prop_meta.no_validate, self, prop_meta) + if no_edit or no_validate then return false end + + local editor = prop_eval(prop_meta.editor, self, prop_meta) + if editor == "text" then + if Platform.developer and value and value ~= "" then + local isT = IsT(value) + local translate = prop_eval(prop_meta.translate, self, prop_meta) + if not translate and IsT(value) then + return string.format("Translated string '%s' set for a non-translated property.", TDevModeGetEnglishText(value)) + elseif translate then + if not IsT(value) then + return string.format("Untranslated string '%s' set for a translated property.", value) + elseif config.RunUnpacked then + local text = TDevModeGetEnglishText(value) + if type(text) == "string" then + local err = XTextCompileText(text) + if err then return err end + if text ~= ReplaceNonStandardCharacters(text) then + return "Non-standard quotes, etc. in text, please run FixupPresetTs." + end + end + end + end + end + elseif editor == "preset_id" then + if value and value ~= "" and value ~= prop_meta.extra_item and not PresetIdPropFindInstance(self, prop_meta, value) then + return string.format("Missing preset '%s'.", value) + end + elseif editor == "preset_id_list" then + for _, preset_id in ipairs(value or empty_table) do + local extracted_preset_id = preset_id + if prop_meta.weights then + local value_key = prop_meta.value_key or "value" + extracted_preset_id = preset_id[value_key] + end + if extracted_preset_id and extracted_preset_id ~= "" and extracted_preset_id ~= prop_meta.extra_item and not PresetIdPropFindInstance(self, prop_meta, extracted_preset_id) then + return string.format("Missing preset '%s'.", extracted_preset_id) + end + end + elseif editor == "number" then + if type(value) == "number" then + local min = prop_eval(prop_meta.min, self, prop_meta) + local max = prop_eval(prop_meta.max, self, prop_meta) + if min or max then + if min and value < min then + return "Value < min" + end + if max and value > max then + return "Value > max" + end + end + end + elseif editor == "choice" or editor == "dropdownlist" or editor == "set" then + local arbitrary_value = prop_eval(prop_meta.arbitrary_value, self, prop_meta) + if arbitrary_value or not value or not prop_meta.items then return false end + + local items, validate_fn = eval_items(prop_meta.items, self, prop_meta) + if not validate_fn then + if not items then + return "prop_meta.items is empty." + elseif items == "err" then + return "prop_meta.items has crashed." + end + end + + local table_values = items and type(items[1]) == "table" + if editor == "set" then + for key in pairs(value) do + if validate_fn then + if not validate_fn(key, self, prop_meta) then + return string.format("Value %s not found in items.", key) + end + elseif not table_values then + if not find(items, key) then + return string.format("Value %s not found in items.", key) + end + elseif not find(items, "value", key) and not find(items, "id", key) then + return string.format("Value %s not found in items.", key) + end + end + return + end + + if validate_fn then + return not validate_fn(value, self, prop_meta) and "Current value is not in items" or nil + elseif not table_values then + if not find(items, value) then + return string.format("Current value '%s' is not in items.", tostring(value)) + end + elseif not find(items, "value", value) and not find(items, "id", value) then + return "Current value is not in items." + end + elseif editor == "string_list" then + local arbitrary_value = prop_eval(prop_meta.arbitrary_value, self, prop_meta) + if arbitrary_value or not value or not prop_meta.items then return false end + + local items, validate_fn = eval_items(prop_meta.items, self, prop_meta) + if not validate_fn then + if not items then + return "prop_meta.items is empty." + elseif items == "err" then + return "prop_meta.items has crashed." + end + end + + for _, subvalue in ipairs(value) do + if subvalue then + local extracted_subvalue = subvalue + if prop_meta.weights then + local value_key = prop_meta.value_key or "value" + extracted_subvalue = subvalue[value_key] + end + if validate_fn then + if not validate_fn(extracted_subvalue, self, prop_meta) then + return string.format("Value '%s' is not in items.", extracted_subvalue) + end + elseif type(items[1]) ~= "table" then + if not find(items, extracted_subvalue) then + return string.format("Value '%s' is not in items.", extracted_subvalue) + end + else + if not find(items, "value", extracted_subvalue) and not find(items, "id", value) then + return string.format("Value '%s' is not in items.", extracted_subvalue) + end + end + end + end + elseif editor == "func" or editor == "expression" then + local storage = UncompilableFuncPropsSources[self] + if storage and storage[prop_meta.id] then + return "The code doesn't compile. Lua code can't be saved until it is correct." + end + if value == missing_source_func then + return "Missing function code." + end + elseif editor == "nested_obj" and value then + local qualifier = "Nested obj '%s'" + if not IsKindOf(value, "PropertyObject") then + return "Invalid value type.", qualifier + end + return GetDiagnosticMessage(value, verbose, indent), qualifier + elseif editor == "nested_list" then + for i, obj in ipairs(value) do + local qualifier = string.format("Nested list '%%s'[%d]", i) + if obj and not IsKindOf(obj, "PropertyObject") then + return "Invalid value type.", qualifier + end + local warn = obj and GetDiagnosticMessage(obj, verbose, indent) + if warn then + return warn, qualifier + end + end + elseif editor == "script" and value then + local qualifier = "Script '%s'" + if not IsKindOf(value, "PropertyObject") then + return "Invalid value type.", qualifier + end + local prop_params = prop_eval(prop_meta.params, self, prop_meta, "self") + if prop_params ~= value.Params then + return string.format("Script parameters mismatch - current '%s', expected '%s'.", value.Params, prop_params), qualifier + end + local warn = GetDiagnosticMessage(value, verbose, indent) + if warn then + return warn, qualifier + end + if value.eval then + local code, has_upvalues = value:GenerateCode() + local name, params, body, first, last, srclines = GetFuncSource(value.eval, has_upvalues and "no_cache") + if type(body) == "string" then body = { body } end + if has_upvalues then + if not first then return "Can't find source code for script eval function." end + -- find enclosing function that returns the eval function + for i = first, 1, -1 do + local line = srclines[i] + if line:find("(function()", 1, true) then break end + table.insert(body, 1, line) + end + table.insert(body, "end") + end + body = table.concat(body, "\n") + if body:gsub("\t", "") ~= code:gsub("\t", "") then + return string.format("Script compiled function is stale - please resave.\n%s\n%s", body, code), qualifier + end + end + elseif editor == "ui_image" and value and value ~= "" and not self:IsDefaultPropertyValue(prop_meta.id, prop_meta, value) then + if type(value) ~= "string" then + return "Image path must be a string." + end + + local extension = prop_meta.force_extension + if extension and not value:ends_with(extension) then + return string.format("Image file %s is expected to be with extention '%s'.", value, extension) + end + + local mod_def = TryGetModDefFromObj(self) + local preset = GetParentTableOfKindNoCheck(self, "Preset") + local save_location = preset and preset:GetSaveLocationType() or "game" + + local dir, name, ext = SplitPath(value) + local path_in_common = value:starts_with("CommonAssets/UI/") + local os_path = prop_eval(prop_meta.os_path, self, prop_meta) + if mod_def or Platform.goldmaster then + local path = dir .. name + local found_path = io.exists(path .. ".png") or io.exists(path .. ".tga") or io.exists(path .. ".dds") or io.exists(path .. ".jpg") + + if not found_path then + return string.format("Image file %s missing.", value) + end + elseif save_location == "common" or path_in_common or os_path then + if not os_path and not path_in_common then + return "Image paths referenced from CommonLua presets must be in CommonAssets/UI/" + end + local path = dir .. name + if not io.exists(path .. ".png") and not io.exists(path .. ".tga") and not io.exists(path .. ".dds") then + return string.format("Image file %s missing.", path) + end + elseif save_location == "game" then + local path = "svnAssets/Source/" .. dir .. name .. ".png" -- in the source art (images are picked in the browse popup from there) + if not io.exists(path) then + return string.format("Image file %s missing at %s", value, path) + end + end + end + return false +end + +local nested_obj_warn = { "One or more nested objects have warnings!", "warning" } +local nested_obj_err = { "One or more nested objects have errors!", "error" } +local subitem_warn = { "One or more subitems have warnings!", "warning" } +local subitem_err = { "One or more subitems have errors!", "error" } + +local IsKindOf = IsKindOf + +-- this function should be used to get the diagnostic message; it's replaced in PropertyObjectWarningsCache.lua +function GetDiagnosticMessage(obj, ...) return obj:GetDiagnosticMessage(...) end + +local function process_msg(self, warn, verbose, indent, qualifier, prop, value, editor) + if not warn then return end + if type(warn) ~= "table" then + warn = { warn, "error" } + end + + local generic_warn, generic_err + if editor == "nested_obj" or editor == "nested_list" then + generic_warn, generic_err = nested_obj_warn, nested_obj_err + elseif qualifier and qualifier:starts_with("Subitem") then + generic_warn, generic_err = subitem_warn, subitem_err + end + + if verbose or not generic_warn then + local prop_name = "" + if prop then + local name = prop_eval(prop.name, self, prop) + if IsT(name) then name = GedTranslate(name) end + prop_name = name and name ~= "" and name or prop.id + end + return { + string.format((qualifier or "Property '%s'") .. " of type '%s':\n%s%s", prop_name, + warn[1] ~= "Invalid value type." and editor or type(value) == "table" and value.class or type(value), + indent, warn[1]), + warn[2] + } + else + return warn[2] == "warning" and generic_warn or generic_err + end +end + +function PropertyObject:GetDiagnosticMessage(verbose, indent) + local ok, current_error = procall(self.GetError, self) + if not ok then + return { string.format("'%s' GetError has crashed.", self.class), "error" } + elseif current_error then + if type(current_error) == "table" then + if current_error[#current_error] ~= "error" then + table.insert(current_error, "error") + end + return current_error + end + return { current_error, "error" } + end + + local ok, current_warning = procall(self.GetWarning, self) + if not ok then + return { string.format("'%s' GetWarning has crashed.", self.class), "error" } + elseif current_warning then + if type(current_warning) == "table" then + if current_warning[#current_warning] ~= "warning" then + table.insert(current_warning, "warning") + end + return current_warning + end + return { current_warning, "warning" } + end + + indent = verbose and ((indent or "") .. "\t") or " " + + local ok, properties = procall(self.GetProperties, self) + if not ok or not properties then + return "GetProperties has crashed." + end + + local GetProperty = self.GetProperty + for _, prop in ipairs(properties) do + local ok, value = procall(GetProperty, self, prop.id) + if not ok then + return process_msg(self, "Getter has crashed.", verbose, indent, nil, prop, nil, prop.editor) + end + if value ~= Undefined() then + local warn, qualifier = self:ValidateProperty(prop, value, verbose, indent) + if warn then + return process_msg(self, warn, verbose, indent, qualifier, prop, value, prop.editor) + end + end + end + + for i, subitem in ipairs(self) do + local warn = IsKindOf(subitem, "PropertyObject") and GetDiagnosticMessage(subitem, verbose, indent) + if warn then + local message = process_msg(self, warn, verbose, indent, string.format("Subitem #%d%%s", i), nil, subitem) + if message then return message end + end + end +end + +function PropertyObject:FindSubObjectParentList(subobj) + if subobj == self then return {} end + + for _, prop_meta in ipairs(self:GetProperties()) do + local editor = prop_meta.editor + if editor == "nested_obj" or editor == "script" then + local obj = self:GetProperty(prop_meta.id) + if obj then + local list = obj:FindSubObjectParentList(subobj, self) + if list then + table.insert(list, 1, self) + return list + end + end + elseif editor == "nested_list" then + local value = self:GetProperty(prop_meta.id) + for _, obj in ipairs(value or empty_table) do + local list = obj:FindSubObjectParentList(subobj, self) + if list then + table.insert(list, 1, self) + return list + end + end + end + end + for _, obj in ipairs(self) do + local list = obj:FindSubObjectParentList(subobj, self) + if list then + table.insert(list, 1, self) + return list + end + end +end + +function PropertyObject:FindSubObjectLocation(obj) + local info = self:ForEachSubObject(function(subobj, parents, key, obj) + if subobj == obj then + return { parents[#parents], key } + end + end, obj) + if not info then return end + return info[1], info[2] +end + +function PropertyObject:ForEachSubObject(class, func, ...) + if type(class) == "function" then + return self:ForEachSubObject(false, class, func, ...) + end + + local function traverse(self, class, func, parents, key, ...) + if not class or IsKindOf(self, class) then + local res = func(self, parents, key, ...) + if res ~= nil then + return res + end + end + if not IsKindOf(self, "PropertyObject") then return end + table.insert(parents, self) + for _, prop_meta in ipairs(self:GetProperties()) do + local editor = prop_meta.editor + if editor == "nested_obj" or editor == "script" then + local obj = self:GetProperty(prop_meta.id) + if obj then + local res = traverse(obj, class, func, parents, prop_meta.id, ...) + if res ~= nil then + return res + end + end + elseif editor == "nested_list" then + local value = self:GetProperty(prop_meta.id) + for _, obj in ipairs(value or empty_table) do + local res = traverse(obj, class, func, parents, prop_meta.id, ...) + if res ~= nil then + return res + end + end + end + end + for i, obj in ipairs(self) do + if type(obj) == "table" then + local res = traverse(obj, class, func, parents, i, ...) + if res ~= nil then + return res + end + end + end + table.remove(parents) + end + + return traverse(self, class, func, {}, false, ...) +end + +function IsKindOfClasses(object, class, ...) + if not object or not class then + return false + elseif type(class) == "table" then + for i = 1, #class do + if IsKindOf(object, class[i]) then + return true + end + end + return false + elseif IsKindOf(object, class) then + return true + end + return IsKindOfClasses(object, ...) +end + +PropertyObject.IsKindOf = IsKindOf +PropertyObject.IsKindOfClasses = IsKindOfClasses + +setmetatable(PropertyGetMethod, { + __index = function (table, name) + local method = "Get" .. tostring(name) + table[name] = method + return method + end +}) + +setmetatable(PropertySetMethod, { + __index = function (table, name) + local method = "Set" .. tostring(name) + table[name] = method + return method + end +}) + +PropertyObject.GetProperty = PropObjGetProperty +PropertyObject.SetProperty = PropObjSetProperty +PropertyObject.HasMember = PropObjHasMember + +local g_Classes = g_Classes +local FuncProps = { + ["func"] = true, + ["expression"] = true, + ["script"] = true, +} +function PropertyObject:GetDefaultPropertyValue(prop, prop_meta) + local default = g_Classes[self.class][prop] + if default ~= nil then + return default + end + prop_meta = prop_meta or self:GetPropertyMetadata(prop) + if prop_meta then + default = prop_meta.default + if not FuncProps[prop_meta.editor] then + while type(default) == "function" do + default = default(self) or false + end + end + return default + end +end + +function PropertyObject:PrepareForEditing() + -- table properties with defaults coming from the class need to be copied in order to be edited + for _, prop_meta in ipairs(self:GetProperties()) do + local prop_id = prop_meta.id + if not prop_eval(prop_meta.read_only, self, prop_meta) then + local value = self:GetProperty(prop_id) + if type(value) == "table" and value == self:GetDefaultPropertyValue(prop_id, prop_meta) then + local clone = self:ClonePropertyValue(value, prop_meta) + if clone ~= value then + self:SetProperty(prop_id, clone) + end + end + end + end +end + +function PropertyObject:IsPropertyDefault(prop, prop_meta) + return self:IsDefaultPropertyValue(prop, prop_meta, self:GetProperty(prop)) +end + +function PropertyObject:IsDefaultPropertyValue(prop, prop_meta, value) + prop_meta = prop_meta or self:GetPropertyMetadata(prop) + local default_value = self:GetDefaultPropertyValue(prop, prop_meta) + return value == nil or value == default_value or + type(value) == "table" and type(default_value) == "table" and table.hash(value) == table.hash(default_value) +end + +function PropertyObject:GetRandomPropertyValue(prop, prop_meta, seed) + prop_meta = prop_meta or self:GetPropertyMetadata(prop) + seed = seed or AsyncRand() + local items = prop_meta.items + if type(items) == "function" then items = items(self) end + if type(items) == "table" and #items > 0 then + local value + local random_chances = prop_meta.random_chances + if random_chances then + local sum = 0 + for i = 1, #items do + sum = sum + (random_chances[items[i]] or 100) + end + local r = 1 + BraidRandom(seed, sum) + sum = 0 + for i = 1, #items do + value = items[i] + sum = sum + (random_chances[value] or 100) + if sum >= r then + break + end + end + else + value = items[1 + BraidRandom(seed, #items)] + end + if type(value) == "table" and value.value ~= nil then + return value.value + end + return value + end + + if prop_meta.editor == "color" then + if prop_meta.pallete and #prop_meta.pallete > 0 then + return prop_meta.pallete[1 + BraidRandom(seed, #prop_meta.pallete)] + end + if prop_meta.color and prop_meta.variation then + return GenerateColor(prop_meta.color, prop_meta.variation) + end + elseif prop_meta.editor == "number" then + local min = prop_meta.min or 0 + local max = prop_meta.max or 100 + return ValidateNumberPropValue(min + BraidRandom(seed, max - min + 1), prop_meta) + elseif prop_meta.editor == "bool" then + return BraidRandom(seed, 1000000) < 500000 + end +end + +function PropertyObject:RandomizeProperties(seed) + seed = seed or AsyncRand() + local props = self:GetProperties() + for i = 1, #props do + local prop_meta = props[i] + if prop_meta.randomize then + local value = self:GetRandomPropertyValue(prop_meta.id, prop_meta, xxhash(seed, prop_meta.id)) + if value ~= nil then + self:SetProperty(prop_meta.id, value) + end + end + end +end + +function PropertyObject:GetPropertyMetadata(property_id) + return table.find_value(self:GetProperties(), "id", property_id) +end + +function PropertyObject:GetProperties() + return self.properties +end + +function PropertyObject:SetProperties(props) + local all_props = self:GetProperties() + for i = 1, #all_props do + local id = all_props[i].id + local value = props[id] + if value ~= nil then + self:SetProperty(id, value) + end + end +end + +function PropertyObject:ClonePropertyValue(value, prop_meta) + local editor = prop_meta.editor + if value then + if editor == "nested_obj" or editor == "script" then + return value:Clone() + elseif editor == "nested_list" then + local ok, new_value = procall(table.imap, value, function(obj) return obj:Clone() end) + if not ok then return {} end + return new_value + elseif TableProperties[editor] then + return table.copy(value) + elseif editor == "property_array" then + value = GedDynamicProps:Instance(self, value, prop_meta) + return value:Clone() + end + end + return value +end + +function PropertyObject:CopyProperties(source, properties) + properties = properties or self:GetProperties() + for i = 1, #properties do + local prop_meta = properties[i] + if not prop_eval(prop_meta.dont_save, source, prop_meta) then + local prop_id = prop_meta.id + local value = source:GetProperty(prop_id) + if not self:IsDefaultPropertyValue(prop_id, prop_meta, value) then + self:SetProperty(prop_id, self:ClonePropertyValue(value, prop_meta)) + end + end + end +end + +function PropertyObject:Clone(class, ...) + class = class or self.class + local obj = g_Classes[class]:new(...) + obj:CopyProperties(self) + return obj +end + +function CloneObject(obj) + if obj then + return obj:Clone() + end +end + +function CopyPropertiesBlacklisted(src, dest, blacklist) + local props + if blacklist and #blacklist > 0 then + props = table.icopy(src:GetProperties()) + for _, id in ipairs(blacklist) do + table.remove_entry(props, "id", id) + end + end + dest:CopyProperties(src, props) +end + +function PropertyObject:__enum() + local keys, key = {} + local t = self + repeat + key = next(t, key) + if key ~= nil then + if keys[key] == nil then + keys[key] = t[key] + end + else + t = getmetatable(t) + if t then t = t.__index end + end + until type(t) ~= "table" + return next, keys, nil +end + +function PropGetter(prop_name) + return function(self) + return self:GetProperty(prop_name) + end +end + +function PropChecker(prop_id, prop_value, neg) + if prop_value == nil then + prop_value = false + end + return function(self) + if neg then + return prop_value ~= self:GetProperty(prop_id) + else + return prop_value == self:GetProperty(prop_id) + end + end +end + +function PropertyObject.ReplacePropertyMeta(class, prop_id, new_meta) + local class_properties = class.properties + local idx = table.find(class_properties, "id", prop_id) + assert(idx ~= nil) + class_properties[idx] = new_meta +end + +-- flatten all property lists, so that GetProperties is an O(1) operation +function OnMsg.ClassesPreprocess(classdefs) + local HasMember = ClassdefHasMember + local props + local function ResolveProperties(class_name, props_to_idx, ancestors) + local classdef = classdefs[class_name] + props_to_idx = props_to_idx or {} + ancestors = ancestors or {} + if not classdef or ancestors[class_name] then + return props + end + ancestors[class_name] = true + + local parents = classdef.__parents or {} + for i = 1, #parents do + ResolveProperties(parents[i], props_to_idx, ancestors) + end + + local class_properties = classdef.properties + if class_properties then + props = props or {} + for i = 1, #class_properties do + local prop = class_properties[i] + local id = prop.id + local idx = props_to_idx[id] + + if not idx then + idx = #props + 1 + props_to_idx[id] = idx + end + props[idx] = prop + end + end + end + + -- if a property has no accessors, it is accessed by 'object[prop.id]' + -- report conflicts between the property default and the property value in the classdef (if it exists) + -- define the member that holds the property value for properties without accessors (if not explicitly defined) + ProcessClassdefChildren("PropertyObject", function(classdef, class_name) + local existing_props = {} + for _, prop_meta in ipairs(classdef.properties or empty_table) do + local prop_id = prop_meta.id + if existing_props[prop_id] then + printf("Duplicate property %s.%s", class_name, prop_id) + end + existing_props[prop_id] = prop_id + if not HasMember(classdef, "Get" .. prop_id) or not HasMember(classdef, "Set" .. prop_id) then + local default = prop_meta.default + if not FuncProps[prop_meta.editor] then + while type(default) == "function" do + default = default(classdef) or false + end + end + if classdef[prop_id] ~= nil and default ~= nil then + printf("%s.%s has default value both as a class member, and in the property definition", class_name, prop_id) + if classdef[prop_id] ~= default then + -- report conflict + printf("Also, the values are different: class default is \"%s\" and property default is \"%s\"", + tostring(classdef[prop_id]), + tostring(default)) + end + elseif default ~= nil then + classdef[prop_id] = default -- define property member + if prop_meta.modifiable and prop_meta.editor == "number" then + classdef["base_" .. prop_id] = default + end + elseif prop_meta.editor and not PlaceholderProperties[prop_meta.editor] and not HasMember(classdef, prop_id) then + printf("%s.%s must have either Get/Set accessors, or a default value", class_name, prop_id) + end + end + end + end) + + -- apply class-wide property metadata defaults + for name, classdef in pairs(classdefs) do + props = classdef.properties or empty_table + for id, value in pairs(props) do + if type(id) == "string" then + for _, prop_meta in ipairs(props) do + if prop_meta[id] == nil then + prop_meta[id] = value + end + end + end + end + end + + local remove = table.remove + for name, classdef in pairs(classdefs) do + if classdef.properties or #(classdef.__parents or empty_table) > 1 then + props = nil + ResolveProperties(name) + if props then + -- remove properties without editor. editor=false is the way to remove parent properties in child classes that don't want them + for i = #props, 1, -1 do + if not props[i].editor then + remove(props, i) + end + end + classdef.__properties = props + end + end + end + + -- apply flattened properties + for name, classdef in pairs(classdefs) do + classdef.properties = classdef.__properties + classdef.__properties = nil + end +end + +-- support for instancing classes during Lua loading, when the class system is not present yet +-- the object tables are created, and they are made into class instances in-place, on the ClassesPostprocess message +local delayed_place_objs = {} + +function PlaceObj(class_name, tbl, arr, ...) + local class = g_Classes[class_name] + if not class then + if not delayed_place_objs then + assert(false, "Trying to place non-existent class " .. class_name) + return + elseif tbl and type(tbl[1]) == "string" or arr then -- StoreAsTable == false + arr = arr or {} + arr.class = class_name + arr.__props__ = tbl + table.insert(delayed_place_objs, arr) + return arr + else -- StoreAsTable == true + tbl = tbl or {} + tbl.class = class_name + table.insert(delayed_place_objs, tbl) + return tbl + end + end + return class:__fromluacode(tbl, arr, ...) +end + +function OnMsg.ClassesPostprocess() -- WARNING: Can't be OnMsg.ClassesBuilt, e.g. for DefineModifiableClassTemplates + local objs, classes = delayed_place_objs, g_Classes + delayed_place_objs = nil + for _, obj in ipairs(objs) do + local class_def = classes[obj.class] + assert(class_def, "Trying to place non-existent class " .. obj.class) + if obj.__props__ then + local props = obj.__props__ + obj.__props__ = nil + class_def:new(obj) + SetObjPropertyList(obj, props) + else + class_def:new(obj) + end + obj.class = nil + end +end + +local SetObjPropertyList = SetObjPropertyList +function PropertyObject:__fromluacode(table, arr) + if self.StoreAsTable then + assert(not table or type(table[1]) ~= "string", "Object was saved with StoreAsTable == false") + return self:new(table) + end + + local obj = self:new(arr) + SetObjPropertyList(obj, table) + return obj +end + +local prop_eval = prop_eval +local copy = table.copy +local list_props = { set = true, T_list = true, nested_list = true, preset_id_list = true, string_list = true, number_list = true, point_list = true } + +function PropertyObject:ShouldCleanPropForSave(id, prop_meta, value) + return + prop_eval(prop_meta.dont_save, self, prop_meta) or -- property with dont_save == true + self:IsDefaultPropertyValue(id, prop_meta, value) or -- default value + list_props[prop_meta.editor] and next(self:GetDefaultPropertyValue(id, prop_meta)) == nil and next(value) == nil -- {} or false for a list/set property +end + +function PropertyObject:CleanupForSave(injected_props, restore_data) + restore_data = restore_data or {} + + -- gather properties + local props_by_id = {} + for _, prop_meta in ipairs(injected_props) do + props_by_id[prop_meta.id] = prop_meta + end + for _, prop_meta in ipairs(self:GetProperties()) do + local id = prop_meta.id + props_by_id[id] = prop_meta + if injected_props and prop_eval(prop_meta.inject_in_subobjects, self, prop_meta) then + injected_props[#injected_props + 1] = prop_meta + end + end + + -- perform cleanup + local class = getmetatable(self) + for key, value in pairs(self) do + local prop_meta = props_by_id[key] + if prop_meta then + if self:ShouldCleanPropForSave(key, prop_meta, value) then + -- cleanup members corresponding to properties that shouldn't be saved; these are restored afterwards + restore_data[#restore_data + 1] = { obj = self, key = key, value = value } + self[key] = nil + end + + -- call recursively for nested objects + local editor = prop_meta.editor + if (editor == "nested_obj" or editor == "script") and IsKindOf(value, "PropertyObject") then + value:CleanupForSave(injected_props, restore_data) + elseif editor == "nested_list" then + for _, obj in ipairs(value or empty_table) do + obj:CleanupForSave(injected_props, restore_data) + end + end + elseif type(key) == "number" then + if IsKindOf(value, "PropertyObject") then + value:CleanupForSave(injected_props, restore_data) + end + elseif injected_props and not class:HasMember(key) then + restore_data[#restore_data + 1] = { obj = self, key = key, value = value } + self[key] = nil + end + end + + return restore_data +end + +function PropertyObject:RestoreAfterSave(restore_data) + for _, data in ipairs(restore_data) do + data.obj[data.key] = data.value + end +end + +function PropertyObject:__toluacode(indent, pstr, GetPropFunc, injected_props) + self:GenerateLocalizationContext(self) + + if self.StoreAsTable then + assert(not GetPropFunc) + local restore_data = self:CleanupForSave(injected_props) + if not pstr then + local ret = string.format("PlaceObj('%s', %s)", self.class, TableToLuaCode(self, indent)) + self:RestoreAfterSave(restore_data) + return ret + end + pstr:appendf("PlaceObj('%s', ", self.class) + pstr:appendt(self, indent, false, injected_props) + pstr:append(")") + self:RestoreAfterSave(restore_data) + return pstr + end + + if not pstr then + local props = ObjPropertyListToLuaCode(self, indent, GetPropFunc or self.GetPropertyForSave, nil, nil, injected_props) + local arr = ArrayToLuaCode(self, indent, injected_props) + if arr then + return string.format("PlaceObj('%s', %s, %s)", self.class, props or "nil", arr) + else + return string.format("PlaceObj('%s', %s)", self.class, props or "nil") + end + else + pstr:appendf("PlaceObj('%s', ", self.class) + if not ObjPropertyListToLuaCode(self, indent, GetPropFunc or self.GetPropertyForSave, pstr, nil, injected_props) then + pstr:append("nil") + end + local len0 = #pstr + if #self > 0 then + pstr:append(", ") + if not ArrayToLuaCode(self, indent, pstr, injected_props) then + pstr:resize(len0) + end + end + return pstr:append(")") + end +end + +function PropertyObject:CreateInstance(instance) + local meta = self.__index == self and self or { __index = self } + instance = setmetatable(instance or {}, meta) + instance.Instance = true + return instance +end + +function PropertyObject:LocalizationContextBase() +end + +function PropertyObject:GenerateLocalizationContext(obj, visited, base) + base = base or self:LocalizationContextBase() + if not base then return end + + visited = visited or {} + for key, value in pairs(obj) do + if not MembersReferencingParents[key] then + if value ~= "" and IsT(value) then + if not ContextCache[value] then + local prop_meta = ObjectClass(obj) and obj:GetPropertyMetadata(key) + local context = prop_meta and prop_meta.context or "" + if type(context) == "function" then + context = context(obj, prop_meta, self) + end + ContextCache[value] = string.concat(" ", base, key, context ~= "" and context) + end + elseif type(value) == "table" and not visited[value] then + visited[value] = true + self:GenerateLocalizationContext(value, visited, base) + end + end + end +end + +local loc_id_cache = setmetatable({}, weak_keys_meta) +function PropertyObject:UpdateLocalizedProperty(prop_id, translate) + local text = rawget(self, prop_id) + if text and text ~= "" then + if translate and type(text) == "string" then + local id = loc_id_cache[self] + self[prop_id] = id and T{id, text} or T{text} + elseif not translate and IsT(text) then + loc_id_cache[self] = TGetID(text) or nil + self[prop_id] = TDevModeGetEnglishText(text) + end + end +end + +local PropertyObjectHash +local table_hash +local IsKindOf = IsKindOf +local xxhash = xxhash +local tostring = tostring + +local function ValueHash(value, injected_props, processed) + local value_type = type(value) + if value_type == "table" then + if IsKindOf(value, "PropertyObject") then + return PropertyObjectHash(value, injected_props, processed) + else + return table_hash(value, injected_props, processed) + end + elseif value_type == "function" then + return xxhash(tostring(value)) + elseif value_type ~= "thread" then + return xxhash(value) + end +end + +table_hash = function(table, injected_props, processed) + local hash + if next(table) then + local key_hash, value_hash + for key, value in sorted_pairs(table) do + key_hash = ValueHash(key, injected_props, processed) + value_hash = ValueHash(value, injected_props, processed) + hash = xxhash(hash, key_hash, value_hash) + end + end + return hash +end + +PropertyObjectHash = function(obj, injected_props, processed) + -- prevent stack overflow in the case of circular references + processed = processed or {} + if processed[obj] then + return 1 + end + processed[obj] = true + + local hash = 13482575171670380201 + local properties = obj:GetProperties() + local prop_count = #properties + for i = 1, prop_count + (injected_props and #injected_props or 0) do + local property = i > prop_count and injected_props[i - prop_count] or properties[i] + if property.editor then + local value = obj:GetProperty(property.id) + if not obj:ShouldCleanPropForSave(property.id, property, value) then -- will skip props with dont_save + local subhash = ValueHash(value, injected_props, processed) + hash = xxhash(hash, property.id, subhash) + end + if injected_props and i <= prop_count and prop_eval(property.inject_in_subobjects, obj, property) then + table.insert_unique(injected_props, property) + end + end + end + if obj.StoreAsTable then + -- array part + for _, value in ipairs(obj) do + local obj_hash = IsKindOf(value, "GedEditedObject") and value:EditorData().current_hash -- reuse calculated hash + hash = xxhash(hash, obj_hash or ValueHash(value, injected_props, processed)) + end + else + for _, value in ipairs(obj) do + if IsKindOf(value, "PropertyObject") then + local obj_hash = IsKindOf(value, "GedEditedObject") and value:EditorData().current_hash -- reuse calculated hash + hash = xxhash(hash, obj_hash or PropertyObjectHash(value, injected_props, processed)) + end + end + end + return hash +end + +-- function that calculates the hash of a PropertyObject's value - but only the part of it that gets persisted +function PropertyObject:CalculatePersistHash() + return PropertyObjectHash(self, {} --[[ make PropertyObjectHash process property injection ]] ) +end + +local function GetProperty(object, prop) + local _GetProperty = object.GetProperty + if _GetProperty and _GetProperty ~= GetProperty then + return _GetProperty(object, prop) + end + return object[prop] +end +_G.GetProperty = GetProperty + +function SetProperty(object, prop, value) + if IsKindOf(object, "PropertyObject") then + object:SetProperty(prop, value) + return + end + object[prop] = value +end + +function ValidateNumberPropValue(value, prop_meta) + local step = prop_meta.step + if step then + value = (value + step / 2) / step * step + end + if prop_meta.min and prop_meta.max then + value = Clamp(value, prop_meta.min, prop_meta.max) + end + return value +end + +g_traceMeta = rawget(_G, "g_traceMeta") or { __name = "trace_log", } +g_traceEntryMeta = rawget(_G, "g_traceEntryMeta") or +{ + __tostring = function(o) + return TraceEntryToStr(o) + end +} + +function TraceEntryToStr(entry) + return string.gsub(entry[2], "%{(%d+)%}", function(item) + local idx = tonumber(item) + if idx == 0 then + return "" + end + return ValueToStr(entry[2 + idx]) + end) +end + +if config.TraceEnabled then + function PropertyObject:TraceCall(member) + assert(self:HasMember(member) and type(self[member]) == "function", "TraceCall unexisting member given as parameter") + + local orig_member_fn = self[member] + self[member] = function(self, ...) + self:Trace("[Call]", member, GetStack(2), ...) + return orig_member_fn(self, ...) + end + end + + function PropertyObject:Trace(...) + local t = rawget(self, "trace_log") + if not t then + t = {} + setmetatable(t, g_traceMeta) + rawset(self, "trace_log", t) + end + local threshold = GameTime() - (config.TraceLogTime or 60000) + while #t >= 50 and t[#t][1] < threshold do + table.remove(t) + end + local data = { GameTime(), ...} + setmetatable(data, g_traceEntryMeta) + table.insert(t, 1, data) + end +else + function PropertyObject:TraceCall() + end + + function PropertyObject:Trace() + end +end + +function ValidateGameObjectProperties(class) + return function() + MapForEach("map", class, function(obj) + local msg = obj:GetDiagnosticMessage("verbose") + if not msg then + -- + elseif msg[#msg] == "warning" then + StoreWarningSource(obj, msg[1]) + else + StoreErrorSource(obj, msg[1]) + end + end) + end +end + +if Platform.developer then + +function PropertyObject:FindAssociatedThreads() + local threads + for thread, src in pairs(ThreadsRegister) do + local found = ForEachThreadUpvalue(thread, function(name, value, self) + return value == self + end, self) + if found then + if type(src) ~= "string" then + src = "" + end + threads = table.create_add(threads, src) + end + end + table.sort(threads) + return threads +end + +end + + +-- !!! backwards compatibility +----- RecursiveCalls + +DefineClass("RecursiveCalls") + +function RecursiveCalls:Recursive(method, ...) + return self[method](self, ...) +end + +function RecursiveCalls:RecursiveCall(_, method, ...) + return self[method](self, ...) +end + + + +-- InitDone class + +DefineClass("InitDone", "PropertyObject", "RecursiveCalls") + +RecursiveCallMethods.Init = "procall" +RecursiveCallMethods.Done = "procall_parents_last" + +InitDone.Init = empty_func +InitDone.Done = empty_func + +function InitDone.new(class, obj, ...) + obj = obj or {} + setmetatable(obj, class) + obj:Init(...) + return obj +end + +function InitDone:delete(...) + self:Done(...) +end + + +if Platform.developer then +-- verifying if classes that do not inherit InitDone have Init() or Done() methods +function OnMsg.ClassesBuilt() + for name, class in pairs(g_Classes) do + if name ~= "InitDone" and not class.__ancestors["InitDone"] and (class.Init or class.Done) then + assert(false, "Class " .. name .. " must inherit InitDone class to use Init() or Done()") + end + end +end +end -- Platform.developer + + +----- cleanup edit values of developer-only properties + +if not Platform.developer then + function OnMsg.ClassesPreprocess(classdefs) + for name, class in pairs(classdefs) do + for _, prop_meta in ipairs(class.properties or empty_table) do + if prop_meta.developer then + prop_meta.no_edit = true + prop_meta.name = nil + prop_meta.category = nil + prop_meta.help = nil + end + prop_meta.developer = nil + end + end + end +end + +function GetPropScale(scale) + return type(scale) == "number" and scale or const.Scale[scale] or 1 +end + +function ClassCategoriesCombo(class, add) + return function() + assert(g_Classes[class]) + local categories = {} + for _, prop_meta in ipairs(g_Classes[class] and g_Classes[class]:GetProperties() or empty_table) do + categories[prop_meta.category or ""] = true + end + return table.keys2(categories, true, add) + end +end + +function ClassPropertiesCombo(class, category_field, add) + return function(obj) + assert(g_Classes[class]) + local category = category_field and obj[category_field] + local items = { add } + for _, prop_meta in ipairs(g_Classes[class] and g_Classes[class]:GetProperties() or empty_table) do + if not category or (prop_meta.category or "") == category then + items[#items + 1] = prop_meta.id + end + end + return items + end +end + + +--- property time scale + +local time_scales = {"sec", "h", "days", "months", "years", "turns"} +local valid_time_scales +function GetTimeScalesCombo() + if not valid_time_scales then + valid_time_scales = {""} + for _, scale in ipairs(time_scales) do + if const.Scale[scale] then + valid_time_scales[#valid_time_scales + 1] = scale + end + end + end + return valid_time_scales +end diff --git a/CommonLua/PropertyObjectContainers.lua b/CommonLua/PropertyObjectContainers.lua new file mode 100644 index 0000000000000000000000000000000000000000..050cdc12c3711baccaad94f3590df0e14affdc61 --- /dev/null +++ b/CommonLua/PropertyObjectContainers.lua @@ -0,0 +1,114 @@ +DefineClass.ContainerBase = { + __parents = { "PropertyObject", }, + ContainerClass = "", +} + +function ContainerBase:FilterSubItemClass(class) + return true +end + +function ContainerBase:IsValidSubItem(item_or_class) + local class = type(item_or_class) == "string" and _G[item_or_class] or item_or_class + return self.ContainerClass ~= "" and (not self.ContainerClass or IsKindOf(class, self.ContainerClass)) and self:FilterSubItemClass(class) +end + + +----- Container +-- +-- Contains sub-objects (PropertyObject) in its array part, editable in Ged + +DefineClass.Container = { + __parents = { "ContainerBase", }, + + ContainerAddNewButtonMode = false, + -- Possible values: + -- children - always visible, adds children only (used in Mod Editor) + -- floating - appears on hover, adds children only + -- floating_combined - appears on hover, adds children or siblings + -- docked - adds an "Add new..." button in the XTree for adding a child, on the spot where the added child would be + -- docked_if_empty - same as docked, but appears only if there are no children yet +} + +function Container:GetContainerAddNewButtonMode() + local mod = not IsKindOf(self, "ModItem") and GetParentTableOfKindNoCheck(self, "ModDef") + return mod and "floating" or self.ContainerAddNewButtonMode +end + +function Container:EditorItemsMenu() + if not g_Classes[self.ContainerClass] then return end + return GedItemsMenu(self.ContainerClass, self.FilterSubItemClass, self) +end + +function Container:GetDiagnosticMessage(verbose, indent) + if self.ContainerClass and self.ContainerClass ~= "" then + for i, subitem in ipairs(self) do + if not self:IsValidSubItem(subitem) and subitem.class ~= "TestHarness" then + if IsKindOf(subitem, self.ContainerClass) then + return { string.format("Invalid subitem #%d of class %s (expected to be a kind of %s)", i, self.class, self.ContainerClass), "error" } + else + return { string.format("Invalid subitem #%d (was filtered out by FilterSubItemClass, subitem class is %s)", i, self.class), "error" } + end + end + end + end + return PropertyObject.GetDiagnosticMessage(self, verbose, indent) +end + + +----- GraphContainer +-- +-- Contains a graph editable in Ged: +-- * graph nodes (PropertyObject) specify "sockets" for connecting to other nodes in 'GraphLinkSockets': +-- - socket definitions are specified as { id = , name = , input = , type = , }, +-- - if 'input' is specified, "input" sockets can only connect to non-"input" (output) sockets +-- - if 'type' is specified, this socket can only connect to sockets with a matching 'type' value +-- * the graph node classes eligible for the graph are controlled via 'ContainerClass' and 'FilterSubItemClass' +-- * the array part of GraphContainer contains the graph nodes +-- * the 'links' member contains the connections between them as an array of +-- { start_node = , start_socket = , end_node = , end_socket = } +-- +-- Inherit GraphContainer in a Preset or a preset sub-item. + +DefineClass.GraphContainer = { + __parents = { "ContainerBase", }, + properties = { + { id = "links", editor = "prop_table", default = true, no_edit = true }, + + -- hidden x, y properties, injected to all subobjects for saving purposes + { id = "x", editor = "number", default = 0, no_edit = true, inject_in_subobjects = true, }, + { id = "y", editor = "number", default = 0, no_edit = true, inject_in_subobjects = true, }, + }, +} + +-- extracts the data for the graph structure to be sent to Ged +function GraphContainer:GetGraphData() + local data = { links = self.links } + for idx, node in ipairs(self) do + node.handle = node.handle or idx + table.insert(data, { x = node.x, y = node.y, node_class = node.class, handle = node.handle }) + end + return data +end + +-- applies changes to the graph structure received from Ged +function GraphContainer:SetGraphData(data) + local handle_to_idx = {} + for idx, node in ipairs(self) do + handle_to_idx[node.handle] = idx + end + + local new_nodes = {} + for _, node_data in ipairs(data) do + local idx = handle_to_idx[node_data.handle] + local node = idx and self[idx] or g_Classes[node_data.node_class]:new() + node.x = node_data.x + node.y = node_data.y + node.handle = node_data.handle + table.insert(new_nodes, node) + end + table.iclear(self) + table.iappend(self, new_nodes) + + self.links = data.links + self:UpdateDirtyStatus() +end diff --git a/CommonLua/PropertyObjectWarningsCache.lua b/CommonLua/PropertyObjectWarningsCache.lua new file mode 100644 index 0000000000000000000000000000000000000000..858e02a3441c0f78d2535e2be09f3fa5f060fbd4 --- /dev/null +++ b/CommonLua/PropertyObjectWarningsCache.lua @@ -0,0 +1,194 @@ +if Platform.ged then return end + +---- A diagnostic message cache to improve Ged performance +-- +-- 1. The cache is only maintained for objects in the active Ged window. +-- 2. It is kept up-to-date by periodic updates. +-- +-- Motivation: Diagnostic messages of objects often depend on external data. The usual way to update +-- the messages is with ObjModified calls whenever that external data changes. However, we can't +-- expect people to put this effort merely for a warning message. Thus we need an automated system. + +function ClearDiagnosticMessageCache() + DiagnosticMessageCache = {} + DiagnosticMessageObjs = {} -- topologically sorted (child objects first) +end + +if FirstLoad then + ClearDiagnosticMessageCache() +end + +local GetDiagnosticMessageNoCache = GetDiagnosticMessage + +function GetDiagnosticMessage(obj, verbose, indent) + local cached = DiagnosticMessageCache[obj] + if cached ~= nil then return cached end + + local message = GetDiagnosticMessageNoCache(obj, verbose, indent) or false + DiagnosticMessageObjs[#DiagnosticMessageObjs + 1] = obj + DiagnosticMessageCache[obj] = message + return message +end + +function UpdateDiagnosticMessage(obj) + local no_cache = not DiagnosticMessageCache[obj] + local old_msg = DiagnosticMessageCache[obj] or false + local new_msg = GetDiagnosticMessageNoCache(obj) or false + DiagnosticMessageCache[obj] = new_msg + return no_cache or new_msg ~= old_msg and ValueToLuaCode(new_msg) ~= ValueToLuaCode(old_msg) +end + +----- Keeping the cache up-to-date + +if FirstLoad then + DiagnosticMessageActiveGed = false + DiagnosticMessageActivateGedThread = false + DiagnosticMessageSuspended = false +end + +local function for_each_subobject(t, class, fn) + if IsKindOf(t, class) then + fn(t) + end + for _, obj in ipairs(t and t.GedTreeChildren and t:GedTreeChildren() or t) do + if type(obj) == "table" then + for_each_subobject(obj, class, fn) + end + end +end + +local function init_cache_for_object(ged, root, initial) + local old_cache = DiagnosticMessageCache + ClearDiagnosticMessageCache() + + local total, count = 0, 0 + for_each_subobject(root, "GedEditedObject", function() total = total + 1 end) + + local time = GetPreciseTicks() + for_each_subobject(root, "GedEditedObject", function(obj) + local old_msg = old_cache[obj] or false + local new_msg = GetDiagnosticMessage(obj) or false + if new_msg ~= old_msg and ValueToLuaCode(new_msg) ~= ValueToLuaCode(old_msg) then + GedObjectModified(obj, "warning") + end + + count = count + 1 + if GetPreciseTicks() > time + 150 then + time = GetPreciseTicks() + if initial then + ged:SetProgressStatus("Updating warnings/errors...", count, total) + end + Sleep(50) + end + end) + + ged:SetProgressStatus(false) +end + +local function ged_update_warnings(ged) + -- update Ged with the warnings cache via GedGetCachedDiagnosticMessages (to show ! marks) + GedUpdateObjectValue(ged, nil, "root|warnings_cache") + -- update the status bar that contains warning/error information + for name, obj in pairs(ged.bound_objects) do + if name:find("|GedPresetStatusText", 1, true) or name:find("|GedModStatusText", 1, true) or name:find("|warning_error_count", 1, true) then + GedUpdateObjectValue(ged, nil, name) + end + end +end + +function InitializeWarningsForGedEditor(ged, initial) + if IsValidThread(DiagnosticMessageActivateGedThread) and DiagnosticMessageActivateGedThread ~= CurrentThread then + DeleteThread(DiagnosticMessageActivateGedThread) + end + + DiagnosticMessageActiveGed = ged + Msg("WakeupQuickDiagnosticThread") + DiagnosticMessageActivateGedThread = CreateRealTimeThread(function() + ged:SetProgressStatus(false) + init_cache_for_object(ged, ged:ResolveObj(ged.context.WarningsUpdateRoot), initial) + ged_update_warnings(ged) + Msg("WakeupFullDiagnosticThread") + end) +end + +function OnMsg.GedActivated(ged, initial) + if ged.context.WarningsUpdateRoot and ged:ResolveObj(ged.context.WarningsUpdateRoot) then + InitializeWarningsForGedEditor(ged, initial) + else + ClearDiagnosticMessageCache() + DiagnosticMessageActiveGed = false + end +end + +function OnMsg.SystemActivate() + ClearDiagnosticMessageCache() + DiagnosticMessageActiveGed = false +end + +function GedGetCachedDiagnosticMessages() + local ret = {} + for obj, msg in pairs(DiagnosticMessageCache) do + if msg then + ret[tostring(obj)] = msg + end + end + return ret +end + +function UpdateDiagnosticMessages(objs) + -- Update children objects first, which are last on the list, so when an object is updated, + -- we "know" its warning is correct (unless a child's warning status changed in the meantime) + local time, updated = GetPreciseTicks(), false + for i = 1, #objs do + local obj = objs[i] + if GedIsValidObject(obj) and UpdateDiagnosticMessage(obj) then + GedObjectModified(obj, "warning") + updated = true + end + if GetPreciseTicks() - time > 50 then + Sleep(50) + if not DiagnosticMessageActiveGed then return end + time = GetPreciseTicks() + end + end + if updated then + ged_update_warnings(DiagnosticMessageActiveGed) + end +end + +if FirstLoad then + CreateRealTimeThread(function() + while true do + Sleep(77) + while DiagnosticMessageActiveGed do + if not DiagnosticMessageSuspended then + sprocall(UpdateDiagnosticMessages, DiagnosticMessageObjs) + end + Sleep(50) + end + WaitMsg("WakeupFullDiagnosticThread") + end + end) + -- Update Ged's bound objects (the ones currently in panels) with a greater frequency for better responsiveness + CreateRealTimeThread(function() + while true do + Sleep(33) + while DiagnosticMessageActiveGed do + if not DiagnosticMessageSuspended then + local objs = {} + for name, obj in pairs(DiagnosticMessageActiveGed.bound_objects) do + if name:ends_with("|warning") then + if IsKindOf(obj, "PropertyObject") then + obj:ForEachSubObject(function(subobj) objs[subobj] = true end) + end + objs[obj] = true + end + end + sprocall(UpdateDiagnosticMessages, table.keys(objs)) + end + Sleep(50) + end + WaitMsg("WakeupQuickDiagnosticThread") + end + end) +end diff --git a/CommonLua/RainStreaksScale.lua b/CommonLua/RainStreaksScale.lua new file mode 100644 index 0000000000000000000000000000000000000000..4ad4aa8268dd3294652873876f177c8da8a4144f --- /dev/null +++ b/CommonLua/RainStreaksScale.lua @@ -0,0 +1,249 @@ +if not FirstLoad then return end + +SetRainStreaksScale { + [0] = { + [10] = { + [10] = { 0.004535, 0.014777, 0.012512, 0.130630, 0.013893, 0.125165, 0.011809, 0.244907, 0.010722, 0.218252 }, + [30] = { 0.011450, 0.016406, 0.015855, 0.055476, 0.015024, 0.067772, 0.021120, 0.118653, 0.018705, 0.142495 }, + [50] = { 0.004249, 0.017267, 0.042737, 0.036384, 0.043433, 0.039413, 0.058746, 0.038396, 0.065664, 0.054761 }, + [70] = { 0.002484, 0.003707, 0.004456, 0.006006, 0.004805, 0.006021, 0.004263, 0.007299, 0.004665, 0.007037 }, + [90] = { 0.002403, 0.004809, 0.004978, 0.005211, 0.004855, 0.004936, 0.006266, 0.007787, 0.006973, 0.007911 }, + [110] = { 0.004843, 0.007565, 0.007675, 0.011109, 0.007726, 0.012165, 0.013179, 0.021546, 0.013247, 0.012964 }, + [130] = { 0.105644, 0.126661, 0.128746, 0.101296, 0.123779, 0.106198, 0.123470, 0.129170, 0.116610, 0.137528 }, + [150] = { 0.302834, 0.379777, 0.392745, 0.339152, 0.395508, 0.334227, 0.374641, 0.503066, 0.387906, 0.519618 }, + [170] = { 0.414521, 0.521799, 0.521648, 0.498219, 0.511921, 0.490866, 0.523137, 0.713744, 0.516829, 0.743649 }, + }, + [30] = { + [10] = { 0.009892, 0.013868, 0.034567, 0.025788, 0.034729, 0.036399, 0.030606, 0.017303, 0.051809, 0.030852 }, + [30] = { 0.018874, 0.027152, 0.031625, 0.023033, 0.038150, 0.024483, 0.029034, 0.021801, 0.037730, 0.016639 }, + [50] = { 0.002868, 0.004127, 0.133022, 0.013847, 0.123368, 0.012993, 0.122183, 0.015031, 0.126043, 0.015916 }, + [70] = { 0.002030, 0.002807, 0.065443, 0.002752, 0.069440, 0.002810, 0.081357, 0.002721, 0.076409, 0.002990 }, + [90] = { 0.002425, 0.003250, 0.003180, 0.011331, 0.002957, 0.011551, 0.003387, 0.006086, 0.002928, 0.005548 }, + [110] = { 0.003664, 0.004258, 0.004269, 0.009404, 0.003925, 0.009233, 0.004224, 0.009405, 0.004014, 0.008435 }, + [130] = { 0.038058, 0.040362, 0.035946, 0.072104, 0.038315, 0.078789, 0.037069, 0.077795, 0.042554, 0.073945 }, + [150] = { 0.124160, 0.122589, 0.121798, 0.201886, 0.122283, 0.214549, 0.118196, 0.192104, 0.122268, 0.209397 }, + [170] = { 0.185212, 0.181729, 0.194527, 0.420721, 0.191558, 0.437096, 0.199995, 0.373842, 0.192217, 0.386263 }, + }, + [50] = { + [10] = { 0.003520, 0.053502, 0.060764, 0.035197, 0.055078, 0.036764, 0.048231, 0.052671, 0.050826, 0.044863 }, + [30] = { 0.002254, 0.023290, 0.082858, 0.043008, 0.073780, 0.035838, 0.080650, 0.071433, 0.073493, 0.026725 }, + [50] = { 0.002181, 0.002203, 0.112864, 0.060140, 0.115635, 0.065531, 0.093277, 0.094123, 0.093125, 0.144290 }, + [70] = { 0.002397, 0.002369, 0.043241, 0.002518, 0.040455, 0.002656, 0.002540, 0.090915, 0.002443, 0.101604 }, + [90] = { 0.002598, 0.002547, 0.002748, 0.002939, 0.002599, 0.003395, 0.002733, 0.003774, 0.002659, 0.004583 }, + [110] = { 0.003277, 0.003176, 0.003265, 0.004301, 0.003160, 0.004517, 0.003833, 0.008354, 0.003140, 0.009214 }, + [130] = { 0.008558, 0.007646, 0.007622, 0.026437, 0.007633, 0.021560, 0.007622, 0.017570, 0.007632, 0.018037 }, + [150] = { 0.031062, 0.028428, 0.028428, 0.108300, 0.028751, 0.111013, 0.028428, 0.048661, 0.028699, 0.061490 }, + [170] = { 0.051063, 0.047597, 0.048824, 0.129541, 0.045247, 0.124975, 0.047804, 0.128904, 0.045053, 0.119087 }, + }, + [70] = { + [10] = { 0.002197, 0.002552, 0.002098, 0.200688, 0.002073, 0.102060, 0.002111, 0.163116, 0.002125, 0.165419 }, + [30] = { 0.002060, 0.002504, 0.002105, 0.166820, 0.002117, 0.144274, 0.005074, 0.143881, 0.004875, 0.205333 }, + [50] = { 0.001852, 0.002184, 0.002167, 0.163804, 0.002132, 0.212644, 0.003431, 0.244546, 0.004205, 0.315848 }, + [70] = { 0.002450, 0.002360, 0.002243, 0.154635, 0.002246, 0.148259, 0.002239, 0.348694, 0.002265, 0.368426 }, + [90] = { 0.002321, 0.002393, 0.002376, 0.074124, 0.002439, 0.126918, 0.002453, 0.439270, 0.002416, 0.489812 }, + [110] = { 0.002484, 0.002629, 0.002559, 0.150246, 0.002579, 0.140103, 0.002548, 0.493103, 0.002637, 0.509481 }, + [130] = { 0.002960, 0.002952, 0.002880, 0.294884, 0.002758, 0.332805, 0.002727, 0.455842, 0.002816, 0.431807 }, + [150] = { 0.003099, 0.003028, 0.002927, 0.387154, 0.002899, 0.397946, 0.002957, 0.261333, 0.002909, 0.148548 }, + [170] = { 0.004887, 0.004884, 0.006581, 0.414647, 0.003735, 0.431317, 0.006426, 0.148997, 0.003736, 0.080715 }, + }, + [90] = { + [170] = { 0.001969, 0.002159, 0.002325, 0.200211, 0.002288, 0.202137, 0.002289, 0.595331, 0.002311, 0.636097 }, + }, + }, + [20] = { + [10] = { + [10] = { 0.004560, 0.009516, 0.164805, 0.018627, 0.196011, 0.028676, 0.248786, 0.038232, 0.224573, 0.021488 }, + [30] = { 0.010159, 0.012287, 0.115013, 0.042757, 0.098673, 0.049110, 0.111692, 0.027507, 0.083201, 0.045279 }, + [50] = { 0.003104, 0.014393, 0.043588, 0.030821, 0.043359, 0.034908, 0.038817, 0.065215, 0.039638, 0.071582 }, + [70] = { 0.002422, 0.002684, 0.002425, 0.002501, 0.002472, 0.002472, 0.003657, 0.003922, 0.003753, 0.004403 }, + [90] = { 0.002623, 0.003222, 0.003305, 0.003205, 0.003160, 0.003380, 0.003211, 0.003272, 0.003176, 0.003405 }, + [110] = { 0.003763, 0.004330, 0.009260, 0.004723, 0.009811, 0.004949, 0.007811, 0.007711, 0.006693, 0.008180 }, + [130] = { 0.034812, 0.046847, 0.058091, 0.040333, 0.058469, 0.043510, 0.051450, 0.041017, 0.060839, 0.036327 }, + [150] = { 0.128734, 0.154235, 0.223825, 0.201115, 0.230286, 0.204362, 0.228627, 0.260628, 0.235527, 0.259058 }, + [170] = { 0.176381, 0.207951, 0.250408, 0.475250, 0.251065, 0.513306, 0.292828, 0.245980, 0.298641, 0.284681 }, + }, + [30] = { + [10] = { 0.004908, 0.006522, 0.232467, 0.017034, 0.232381, 0.015974, 0.311869, 0.155029, 0.314360, 0.159952 }, + [30] = { 0.005819, 0.005938, 0.102767, 0.093733, 0.106100, 0.098882, 0.100478, 0.079574, 0.114405, 0.084094 }, + [50] = { 0.014899, 0.041680, 0.039549, 0.058443, 0.035414, 0.061015, 0.057607, 0.058193, 0.060112, 0.047068 }, + [70] = { 0.002336, 0.002245, 0.002275, 0.061981, 0.002265, 0.065389, 0.012967, 0.072239, 0.012030, 0.074442 }, + [90] = { 0.002692, 0.002498, 0.002601, 0.004547, 0.002638, 0.010356, 0.003117, 0.006370, 0.002764, 0.009057 }, + [110] = { 0.002619, 0.003090, 0.003728, 0.006174, 0.003248, 0.006257, 0.003458, 0.043782, 0.003318, 0.039061 }, + [130] = { 0.005468, 0.005529, 0.004356, 0.009145, 0.005430, 0.008871, 0.007144, 0.007392, 0.004245, 0.007603 }, + [150] = { 0.026103, 0.025820, 0.025397, 0.025490, 0.026053, 0.040151, 0.034123, 0.025490, 0.032310, 0.045433 }, + [170] = { 0.046068, 0.049983, 0.055949, 0.045053, 0.057215, 0.059921, 0.075323, 0.045021, 0.067432, 0.090841 }, + }, + [50] = { + [10] = { 0.009786, 0.009825, 0.027929, 0.010146, 0.022229, 0.017015, 0.029196, 0.032481, 0.019721, 0.027217 }, + [30] = { 0.020061, 0.020574, 0.025002, 0.018259, 0.022739, 0.017190, 0.024991, 0.018364, 0.020248, 0.027052 }, + [50] = { 0.002306, 0.020122, 0.052106, 0.086258, 0.055497, 0.065196, 0.060843, 0.089846, 0.047607, 0.082197 }, + [70] = { 0.001768, 0.002132, 0.068193, 0.171000, 0.065793, 0.129058, 0.096327, 0.174146, 0.100576, 0.165012 }, + [90] = { 0.002300, 0.002263, 0.002404, 0.336315, 0.002355, 0.333671, 0.004965, 0.297991, 0.013553, 0.290678 }, + [110] = { 0.002418, 0.002708, 0.004850, 0.221670, 0.006551, 0.211424, 0.003252, 0.126737, 0.003093, 0.131554 }, + [130] = { 0.002744, 0.002956, 0.004728, 0.330757, 0.003910, 0.333265, 0.003880, 0.240447, 0.003173, 0.249650 }, + [150] = { 0.002728, 0.003530, 0.004000, 0.014955, 0.003218, 0.020018, 0.003495, 0.288309, 0.003219, 0.286791 }, + [170] = { 0.003746, 0.006135, 0.006757, 0.012717, 0.007560, 0.008698, 0.005292, 0.234228, 0.004337, 0.168555 }, + }, + [70] = { + [10] = { 0.003746, 0.015429, 0.031544, 0.061388, 0.031515, 0.057834, 0.031802, 0.063263, 0.030412, 0.058193 }, + [30] = { 0.003488, 0.008828, 0.042609, 0.053334, 0.042642, 0.049466, 0.039036, 0.055997, 0.039174, 0.048701 }, + [50] = { 0.002020, 0.002477, 0.058121, 0.131174, 0.054632, 0.086591, 0.067590, 0.130891, 0.065946, 0.096837 }, + [70] = { 0.002038, 0.002501, 0.053969, 0.119410, 0.054038, 0.158875, 0.070047, 0.110579, 0.068796, 0.129331 }, + [90] = { 0.002215, 0.002256, 0.002690, 0.235087, 0.002160, 0.207328, 0.002318, 0.231574, 0.005399, 0.186675 }, + [110] = { 0.002270, 0.002362, 0.002172, 0.252646, 0.002174, 0.225453, 0.002139, 0.217236, 0.002215, 0.184976 }, + [130] = { 0.001916, 0.002536, 0.002353, 0.423406, 0.002543, 0.473041, 0.002285, 0.239963, 0.002193, 0.306072 }, + [150] = { 0.002390, 0.002680, 0.002419, 0.465065, 0.002412, 0.411361, 0.002344, 0.419189, 0.002562, 0.482421 }, + [170] = { 0.002419, 0.002683, 0.002484, 0.480641, 0.002369, 0.454117, 0.002397, 0.459750, 0.002336, 0.507037 }, + }, + [90] = { + [170] = { 0.002213, 0.005478, 0.002263, 0.112524, 0.002001, 0.194047, 0.002939, 0.142841, 0.003178, 0.178164 }, + }, + }, + [40] = { + [10] = { + [10] = { 0.009871, 0.013761, 0.016671, 0.010353, 0.033428, 0.009276, 0.028834, 0.009742, 0.032260, 0.033552 }, + [30] = { 0.020678, 0.027870, 0.028633, 0.025110, 0.029418, 0.026947, 0.037517, 0.028846, 0.023011, 0.023128 }, + [50] = { 0.004227, 0.004543, 0.118416, 0.039294, 0.116560, 0.040435, 0.116751, 0.034533, 0.093319, 0.033711 }, + [70] = { 0.002537, 0.002350, 0.151334, 0.004190, 0.141969, 0.002927, 0.206026, 0.003209, 0.227307, 0.003800 }, + [90] = { 0.002886, 0.002917, 0.015143, 0.003269, 0.019436, 0.003646, 0.019016, 0.005691, 0.018579, 0.006254 }, + [110] = { 0.003406, 0.003677, 0.016742, 0.006445, 0.012077, 0.006833, 0.020962, 0.005710, 0.020344, 0.005708 }, + [130] = { 0.007399, 0.006989, 0.010682, 0.007079, 0.007431, 0.008013, 0.010746, 0.006992, 0.009766, 0.006965 }, + [150] = { 0.030037, 0.029106, 0.061781, 0.028801, 0.057708, 0.034830, 0.051716, 0.028634, 0.050868, 0.028152 }, + [170] = { 0.050927, 0.049470, 0.055874, 0.054662, 0.057490, 0.053966, 0.049372, 0.046996, 0.052516, 0.047838 }, + }, + [30] = { + [10] = { 0.004937, 0.005698, 0.251811, 0.095698, 0.255707, 0.088408, 0.206243, 0.296530, 0.197785, 0.286117 }, + [30] = { 0.005405, 0.005381, 0.130863, 0.069686, 0.098231, 0.080812, 0.119711, 0.034574, 0.191727, 0.077793 }, + [50] = { 0.026670, 0.064978, 0.061274, 0.053076, 0.057124, 0.047929, 0.038494, 0.030667, 0.046214, 0.033972 }, + [70] = { 0.003362, 0.103931, 0.154726, 0.066401, 0.135930, 0.080933, 0.139265, 0.097174, 0.147297, 0.082854 }, + [90] = { 0.002129, 0.002547, 0.230974, 0.067342, 0.228166, 0.072128, 0.200842, 0.078009, 0.196074, 0.081771 }, + [110] = { 0.002708, 0.002952, 0.056933, 0.006642, 0.034241, 0.006222, 0.152668, 0.005382, 0.141766, 0.005354 }, + [130] = { 0.002855, 0.003577, 0.007098, 0.003175, 0.006287, 0.002961, 0.008467, 0.004368, 0.010790, 0.004051 }, + [150] = { 0.002878, 0.004805, 0.078964, 0.003311, 0.068843, 0.003237, 0.037141, 0.004576, 0.015684, 0.004664 }, + [170] = { 0.003395, 0.023417, 0.034159, 0.004111, 0.039560, 0.007093, 0.018659, 0.005629, 0.026553, 0.007014 }, + }, + [50] = { + [10] = { 0.004926, 0.005168, 0.168028, 0.291751, 0.199056, 0.297255, 0.096364, 0.300297, 0.201309, 0.321338 }, + [30] = { 0.005042, 0.005063, 0.146624, 0.152632, 0.081620, 0.167753, 0.127889, 0.212360, 0.083575, 0.184509 }, + [50] = { 0.018634, 0.021686, 0.017353, 0.048035, 0.042371, 0.051059, 0.020563, 0.047676, 0.018026, 0.063956 }, + [70] = { 0.005102, 0.089790, 0.092165, 0.057073, 0.069729, 0.073443, 0.021514, 0.048173, 0.036105, 0.056660 }, + [90] = { 0.002154, 0.175390, 0.153648, 0.108821, 0.108063, 0.124238, 0.158631, 0.148642, 0.152848, 0.128168 }, + [110] = { 0.002214, 0.120867, 0.209819, 0.137242, 0.218735, 0.143433, 0.233293, 0.177733, 0.236917, 0.207296 }, + [130] = { 0.002292, 0.003592, 0.101968, 0.065518, 0.093798, 0.045953, 0.288490, 0.206340, 0.345239, 0.221889 }, + [150] = { 0.002450, 0.003937, 0.011389, 0.007044, 0.012401, 0.003189, 0.064892, 0.118749, 0.046465, 0.146005 }, + [170] = { 0.002070, 0.034543, 0.055893, 0.006546, 0.065603, 0.006998, 0.060285, 0.045627, 0.051213, 0.075981 }, + }, + [70] = { + [10] = { 0.010334, 0.012398, 0.012627, 0.105082, 0.015882, 0.107243, 0.011987, 0.162902, 0.016889, 0.097966 }, + [30] = { 0.013331, 0.015565, 0.031835, 0.111329, 0.023890, 0.115157, 0.030848, 0.136133, 0.025204, 0.129236 }, + [50] = { 0.019748, 0.026527, 0.021658, 0.087605, 0.022032, 0.082362, 0.023618, 0.128133, 0.023609, 0.143085 }, + [70] = { 0.012742, 0.057761, 0.039337, 0.050431, 0.046790, 0.057298, 0.025552, 0.107163, 0.024241, 0.162057 }, + [90] = { 0.002240, 0.101708, 0.086850, 0.053726, 0.072056, 0.101405, 0.023162, 0.059033, 0.026874, 0.103807 }, + [110] = { 0.002055, 0.143630, 0.048210, 0.064565, 0.041799, 0.103894, 0.100044, 0.073217, 0.125079, 0.076761 }, + [130] = { 0.001981, 0.183407, 0.002482, 0.138680, 0.002249, 0.079117, 0.162073, 0.127927, 0.175744, 0.079166 }, + [150] = { 0.002099, 0.189438, 0.005181, 0.132646, 0.004624, 0.083865, 0.189732, 0.121156, 0.177109, 0.206023 }, + [170] = { 0.002229, 0.187995, 0.010884, 0.134331, 0.010545, 0.114960, 0.179264, 0.119197, 0.177343, 0.238360 }, + }, + [90] = { + [170] = { 0.003426, 0.082624, 0.087553, 0.059160, 0.087354, 0.052085, 0.035339, 0.063762, 0.034373, 0.049437 }, + }, + }, + [60] = { + [10] = { + [10] = { 0.003578, 0.117763, 0.093450, 0.025419, 0.095649, 0.041967, 0.112423, 0.058952, 0.106238, 0.068160 }, + [30] = { 0.002669, 0.002602, 0.090816, 0.026078, 0.084716, 0.025780, 0.135090, 0.023534, 0.087582, 0.033345 }, + [50] = { 0.001917, 0.002450, 0.124990, 0.018763, 0.130526, 0.012430, 0.174319, 0.015484, 0.143780, 0.013465 }, + [70] = { 0.002594, 0.002344, 0.202079, 0.004041, 0.186902, 0.004624, 0.241517, 0.007278, 0.188204, 0.007610 }, + [90] = { 0.003006, 0.002938, 0.299921, 0.004253, 0.224567, 0.004887, 0.307696, 0.003903, 0.305072, 0.004525 }, + [110] = { 0.003154, 0.003352, 0.316434, 0.004025, 0.325729, 0.003345, 0.301829, 0.004007, 0.306174, 0.004001 }, + [130] = { 0.003169, 0.003768, 0.007125, 0.003976, 0.007018, 0.003693, 0.007064, 0.063606, 0.007407, 0.044504 }, + [150] = { 0.003320, 0.003704, 0.005765, 0.081200, 0.006641, 0.075512, 0.006009, 0.291761, 0.005941, 0.300362 }, + [170] = { 0.003366, 0.003412, 0.013466, 0.359989, 0.004698, 0.360033, 0.004448, 0.327057, 0.006007, 0.337578 }, + }, + [30] = { + [10] = { 0.009430, 0.008766, 0.031310, 0.015425, 0.036344, 0.027050, 0.040504, 0.019124, 0.040527, 0.039756 }, + [30] = { 0.019707, 0.020664, 0.056864, 0.016232, 0.050740, 0.029325, 0.055866, 0.015300, 0.044906, 0.036234 }, + [50] = { 0.018329, 0.035138, 0.101977, 0.031697, 0.063069, 0.031321, 0.089227, 0.031239, 0.109973, 0.026481 }, + [70] = { 0.003451, 0.089218, 0.083212, 0.056904, 0.144225, 0.053121, 0.110734, 0.078315, 0.144967, 0.085846 }, + [90] = { 0.001958, 0.085903, 0.211731, 0.058728, 0.210308, 0.055763, 0.223412, 0.102191, 0.159352, 0.100648 }, + [110] = { 0.002117, 0.002927, 0.297865, 0.082210, 0.278653, 0.080750, 0.260325, 0.139839, 0.220843, 0.136087 }, + [130] = { 0.002089, 0.003560, 0.308149, 0.041930, 0.289941, 0.042181, 0.366502, 0.188603, 0.312170, 0.166819 }, + [150] = { 0.002544, 0.003505, 0.003732, 0.022325, 0.005354, 0.018256, 0.142453, 0.294051, 0.136305, 0.264485 }, + [170] = { 0.002400, 0.003074, 0.024879, 0.024217, 0.027642, 0.026374, 0.023453, 0.577589, 0.019929, 0.536414 }, + }, + [50] = { + [10] = { 0.005071, 0.005609, 0.088304, 0.447553, 0.090436, 0.401310, 0.081885, 0.337496, 0.084227, 0.347107 }, + [30] = { 0.005046, 0.005930, 0.091372, 0.120790, 0.083976, 0.149732, 0.092404, 0.185650, 0.074278, 0.244212 }, + [50] = { 0.009542, 0.013627, 0.037894, 0.051177, 0.044550, 0.043340, 0.031536, 0.026021, 0.028734, 0.041971 }, + [70] = { 0.023064, 0.036200, 0.028498, 0.025795, 0.020165, 0.020570, 0.028790, 0.026472, 0.018198, 0.020643 }, + [90] = { 0.007826, 0.089438, 0.058421, 0.085872, 0.074070, 0.055206, 0.022830, 0.019533, 0.072770, 0.019878 }, + [110] = { 0.002593, 0.116219, 0.120338, 0.107828, 0.114548, 0.078604, 0.097916, 0.037804, 0.081663, 0.016684 }, + [130] = { 0.002143, 0.157795, 0.166421, 0.079790, 0.148481, 0.089574, 0.143235, 0.064781, 0.143546, 0.067725 }, + [150] = { 0.002120, 0.169938, 0.109876, 0.118375, 0.120899, 0.108351, 0.134355, 0.129384, 0.147023, 0.115477 }, + [170] = { 0.002052, 0.168642, 0.260588, 0.111142, 0.264297, 0.143906, 0.307150, 0.179252, 0.288306, 0.111958 }, + }, + [70] = { + [10] = { 0.005738, 0.007079, 0.008370, 0.271747, 0.011779, 0.322446, 0.008415, 0.362914, 0.007722, 0.365667 }, + [30] = { 0.004787, 0.007425, 0.008121, 0.194556, 0.007093, 0.177297, 0.006915, 0.127942, 0.008291, 0.133044 }, + [50] = { 0.005210, 0.009968, 0.008153, 0.037007, 0.007861, 0.138520, 0.005229, 0.095831, 0.007979, 0.136454 }, + [70] = { 0.005684, 0.012150, 0.005932, 0.039217, 0.007107, 0.029957, 0.006379, 0.089936, 0.007644, 0.040819 }, + [90] = { 0.019047, 0.016517, 0.016452, 0.040339, 0.015073, 0.014770, 0.016219, 0.030672, 0.015543, 0.033722 }, + [110] = { 0.019267, 0.028114, 0.017929, 0.016113, 0.020640, 0.017453, 0.015490, 0.016187, 0.023851, 0.041510 }, + [130] = { 0.013961, 0.046025, 0.024594, 0.016222, 0.023559, 0.013921, 0.024420, 0.018475, 0.036287, 0.022688 }, + [150] = { 0.007374, 0.068685, 0.039878, 0.016678, 0.035977, 0.012360, 0.027156, 0.016430, 0.024661, 0.022074 }, + [170] = { 0.003892, 0.069969, 0.065874, 0.025439, 0.056990, 0.018357, 0.042670, 0.020724, 0.042926, 0.019703 }, + }, + [90] = { + [170] = { 0.010521, 0.014424, 0.012910, 0.039350, 0.012845, 0.023746, 0.012274, 0.044856, 0.012174, 0.072366 }, + }, + }, + [80] = { + [10] = { + [10] = { 0.002041, 0.002476, 0.002100, 0.094776, 0.002317, 0.135046, 0.002131, 0.165419, 0.002271, 0.105200 }, + [30] = { 0.002279, 0.002349, 0.175250, 0.176004, 0.156418, 0.159252, 0.082404, 0.207167, 0.071967, 0.243518 }, + [50] = { 0.002411, 0.002501, 0.220372, 0.268515, 0.178611, 0.283616, 0.228769, 0.065250, 0.235944, 0.057167 }, + [70] = { 0.002264, 0.002735, 0.182511, 0.005781, 0.194422, 0.005559, 0.177981, 0.003908, 0.188455, 0.003589 }, + [90] = { 0.002823, 0.002935, 0.219231, 0.003930, 0.218236, 0.003657, 0.221514, 0.003918, 0.224101, 0.003617 }, + [110] = { 0.002613, 0.003146, 0.272810, 0.003860, 0.258491, 0.003420, 0.265095, 0.021120, 0.322322, 0.007610 }, + [130] = { 0.002146, 0.002950, 0.225646, 0.378030, 0.211132, 0.355975, 0.003592, 0.251355, 0.003419, 0.204946 }, + [150] = { 0.002068, 0.002796, 0.003119, 0.223709, 0.002914, 0.347037, 0.003333, 0.138739, 0.002746, 0.184558 }, + [170] = { 0.002457, 0.002726, 0.002304, 0.522847, 0.002389, 0.538394, 0.002493, 0.203530, 0.002297, 0.311985 }, + }, + [30] = { + [10] = { 0.003218, 0.035440, 0.043039, 0.049892, 0.038971, 0.055384, 0.036815, 0.049157, 0.038921, 0.055433 }, + [30] = { 0.002378, 0.038924, 0.075852, 0.088167, 0.077494, 0.052676, 0.070094, 0.081838, 0.065911, 0.057526 }, + [50] = { 0.002938, 0.052154, 0.050948, 0.077841, 0.105093, 0.121810, 0.105322, 0.088296, 0.086732, 0.118699 }, + [70] = { 0.003115, 0.080180, 0.069223, 0.103855, 0.128131, 0.084200, 0.167636, 0.081299, 0.094938, 0.070611 }, + [90] = { 0.002116, 0.078813, 0.084803, 0.007550, 0.164552, 0.007206, 0.128179, 0.117205, 0.176458, 0.088727 }, + [110] = { 0.002329, 0.092602, 0.177752, 0.212800, 0.157516, 0.187301, 0.196594, 0.194065, 0.173277, 0.198864 }, + [130] = { 0.002276, 0.070697, 0.178542, 0.154574, 0.186342, 0.177680, 0.169323, 0.126082, 0.167855, 0.165132 }, + [150] = { 0.001809, 0.057867, 0.142869, 0.174075, 0.136047, 0.144156, 0.141150, 0.142090, 0.137835, 0.164703 }, + [170] = { 0.001848, 0.053836, 0.002866, 0.178880, 0.002021, 0.114308, 0.060644, 0.174042, 0.058878, 0.108221 }, + }, + [50] = { + [10] = { 0.009334, 0.014952, 0.015078, 0.057072, 0.012228, 0.058016, 0.011248, 0.158103, 0.012301, 0.160338 }, + [30] = { 0.006786, 0.015701, 0.027412, 0.102667, 0.026835, 0.102262, 0.027047, 0.098829, 0.027640, 0.097512 }, + [50] = { 0.013372, 0.017513, 0.051360, 0.042303, 0.042304, 0.048439, 0.110456, 0.041059, 0.109078, 0.046851 }, + [70] = { 0.028191, 0.034781, 0.023150, 0.017862, 0.053955, 0.032122, 0.160396, 0.021829, 0.118339, 0.030414 }, + [90] = { 0.025131, 0.045014, 0.042092, 0.036304, 0.043029, 0.045537, 0.136531, 0.020213, 0.089171, 0.047220 }, + [110] = { 0.017091, 0.053897, 0.050798, 0.039707, 0.030660, 0.054415, 0.071442, 0.044684, 0.064438, 0.058738 }, + [130] = { 0.011163, 0.052426, 0.046508, 0.035538, 0.047058, 0.048361, 0.028554, 0.057174, 0.063010, 0.055211 }, + [150] = { 0.004922, 0.055588, 0.049765, 0.041266, 0.057180, 0.062325, 0.032767, 0.033097, 0.029641, 0.062678 }, + [170] = { 0.003455, 0.058100, 0.046326, 0.043538, 0.049643, 0.059842, 0.030882, 0.059422, 0.032092, 0.066378 }, + }, + [70] = { + [10] = { 0.005557, 0.010144, 0.011257, 0.039361, 0.011425, 0.066341, 0.015509, 0.088235, 0.011960, 0.091267 }, + [30] = { 0.004504, 0.012390, 0.017711, 0.082864, 0.018982, 0.057679, 0.016843, 0.066230, 0.019422, 0.081146 }, + [50] = { 0.004746, 0.012093, 0.021438, 0.041310, 0.026578, 0.046057, 0.030463, 0.050593, 0.029605, 0.052279 }, + [70] = { 0.004913, 0.010567, 0.015099, 0.012106, 0.016807, 0.011996, 0.034567, 0.014915, 0.028702, 0.010390 }, + [90] = { 0.004956, 0.010616, 0.012899, 0.012591, 0.008842, 0.007187, 0.022210, 0.011524, 0.034312, 0.007047 }, + [110] = { 0.005071, 0.011364, 0.010281, 0.011339, 0.009102, 0.007409, 0.013022, 0.010918, 0.026926, 0.007889 }, + [130] = { 0.005701, 0.012361, 0.011769, 0.009054, 0.009115, 0.008639, 0.009556, 0.010215, 0.011912, 0.009206 }, + [150] = { 0.007991, 0.014323, 0.009397, 0.008621, 0.007927, 0.009872, 0.008945, 0.010647, 0.009644, 0.011384 }, + [170] = { 0.009771, 0.014965, 0.010674, 0.009023, 0.010677, 0.011736, 0.010990, 0.011723, 0.010864, 0.011779 }, + }, + [90] = { + [170] = { 0.005924, 0.016348, 0.011056, 0.015849, 0.010972, 0.013523, 0.011296, 0.018953, 0.011288, 0.013080 }, + }, + }, +} \ No newline at end of file diff --git a/CommonLua/Random.lua b/CommonLua/Random.lua new file mode 100644 index 0000000000000000000000000000000000000000..38741e3f49c9881948313fa24f4115fb829430e5 --- /dev/null +++ b/CommonLua/Random.lua @@ -0,0 +1,76 @@ +GameVar("MapLoadRandom", function() return InitMapLoadRandom() end) +GameVar("InteractionSeeds", {}) +GameVar("InteractionSeed", function() return MapLoadRandom end) + +function InitMapLoadRandom() + if config.FixedMapLoadRandom then + return config.FixedMapLoadRandom + elseif Game and (Game.seed_text or "") ~= "" then + return xxhash(Game.seed_text) + elseif netInGame and Libs.Network == "sync" then + return bxor(netGameSeed, mapdata and mapdata.NetHash or 0) + else + return AsyncRand() + end +end + +function OnMsg.PreNewMap() + MapLoadRandom = InitMapLoadRandom() + ResetInteractionRand(0) +end + +function OnMsg.NewMapLoaded() + if Game then + DebugPrint("Game Seed: ", Game.seed_text, "\n") + end +end + +local BraidRandom = BraidRandom +local xxhash = xxhash + +function ResetInteractionRand(seed) + NetUpdateHash("ResetInteractionRand", seed) + InteractionSeeds = {} + InteractionSeed = xxhash(seed, MapLoadRandom) +end + +function InteractionRand(max, int_type, obj, target) + assert(type(max) ~= "string") + int_type = int_type or "none" + assert(type(int_type) == "string") + assert(not IsValid(obj) or obj:IsSyncObject() or IsHandleSync(obj.handle) or obj:GetGameFlags(const.gofPermanent) ~= 0) + if type(max) == "number" and max <= 1 then + return 0 + end + + local interaction_seeds = InteractionSeeds + assert(interaction_seeds) + if not interaction_seeds then + return 0 + end + + local interaction_seed = interaction_seeds[int_type] or xxhash(InteractionSeed, int_type) + local rand + rand, interaction_seed = BraidRandom(interaction_seed, max) + interaction_seeds[int_type] = interaction_seed + + NetUpdateHash("InteractionRand", rand, max, int_type, obj) + + return rand, interaction_seed +end + +function InteractionRandRange(min, max, int_type, ...) + return min + InteractionRand(max - min + 1, int_type, ...) +end + +function InteractionRandRange2(range, int_type, ...) + return range.from + InteractionRand(range.to - range.from + 1, int_type, ...) +end + +function OnMsg.NewMapLoaded() + DebugPrint("MapLoadRandom: ", MapLoadRandom, "\n") +end + +function InteractionRandCreate(int_type, obj, target) + return BraidRandomCreate(InteractionRand(nil, int_type, obj, target)) +end diff --git a/CommonLua/Reactions.lua b/CommonLua/Reactions.lua new file mode 100644 index 0000000000000000000000000000000000000000..3d4bed5960651365b4e4b3fd4fb47dfb8ed9e34c --- /dev/null +++ b/CommonLua/Reactions.lua @@ -0,0 +1,360 @@ +ReactionTargets = {} + +DefineClass.MsgDef = { + __parents = { "Preset" }, + properties = { + { id = "Params", editor = "text", default = "", buttons = {{ name = "Copy", func = "CopyHandler" }}, }, + { id = "Target", editor = "choice", default = "", items = function() return ReactionTargets end }, + { id = "Description", editor = "text", default = "" }, + }, + GlobalMap = "MsgDefs", + EditorMenubarName = "Msg defs", + EditorMenubar = "Editors.Engine", + EditorIcon = "CommonAssets/UI/Icons/message typing.png", +} + +function MsgDef:CopyHandler() + local handler = string.format("function OnMsg.%s(%s)\n\t\nend\n\n", self.id, self.Params) + CopyToClipboard(handler) +end + +function OnMsg.ClassesGenerate() + DefineModItemPreset("MsgDef", { EditorName = "Message definition", EditorSubmenu = "Other", Documentation = "Refer to Messages and Reactions documentation for more info." }) +end + + +DefineClass.Reaction = { + __parents = { "PropertyObject" }, + properties = { + { id = "Event", editor = "preset_id", default = "", preset_class = "MsgDef", + preset_filter = function(preset, obj) return preset.Target == obj.ReactionTarget end }, + { id = "Description", name = "Description", editor = "help", default = false, dont_save = true, read_only = true, + help = function (self) return self:GetHelp() end, }, + { id = "Handler", editor = "func", default = false, lines = 6, max_lines = 60, + name = function(self) return self.Event end, + params = function (self) return self:GetParams() end, }, + }, + ReactionTarget = "", + StoreAsTable = true, + EditorView = T(205999281210, "()"), +} + +function Reaction:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Event" and type(self.Handler) == "function" then + -- force reevaluation of the Handler's params when the event changes + GedSetProperty(ged, self, "Handler", GameToGedValue(self.Handler, self:GetPropertyMetadata("Handler"), self)) + end +end + +function Reaction:GetParams() + local def = MsgDefs[self.Event] + if not def then return "" end + local params = def.Params or "" + if params == "" then + return self.ReactionTarget == "" and "self" or "self, target" + end + return (self.ReactionTarget == "" and "self, " or "self, target, ") .. params +end + +function Reaction:GetHelp() + local def = MsgDefs[self.Event] + return def and def.Description or "" +end + + +----- ReactionScript + +DefineClass.ReactionScript = { + __parents = { "Reaction" }, + properties = { + { id = "Handler", editor = "script", default = false, lines = 6, max_lines = 60, + name = function(self) return self.Event end, + params = function (self) return self:GetParams() end, }, + }, +} + + +function DefineReactionsPreset(name, target, reactions_member, parent) + assert(name) + reactions_member = reactions_member or (name .. "_reactions") + ReactionTargets[#ReactionTargets + 1] = target + local ReactionClassName = name .. "Reaction" + DefineClass[ReactionClassName] = { + __parents = { "Reaction" }, + ReactionTarget = target, + } + DefineClass(name .. "ReactionScript", ReactionClassName, "ReactionScript") + DefineClass[name .. "ReactionsPreset"] = { + __parents = { parent or "Preset" }, + properties = { + { category = "Reactions", id = reactions_member, name = name .. " Reactions", default = false, + editor = "nested_list", base_class = ReactionClassName, auto_expand = true, inclusive = true }, + }, + ReactionTarget = target, + ReactionsMember = reactions_member, + EditorMenubarName = false, + } +end + + +----- ReactionObject + +DefineClass.ReactionObject = { + __parents = { "PropertyObject" }, + reaction_handlers = false, + reaction_handlers_in_use = 0, +} + +local move = table.move +local icopy = table.icopy +function ReactionObject:AddReactions(instance, list, insert_locations) + if #(list or "") == 0 then return end + local reaction_handlers_in_use = self.reaction_handlers_in_use + instance = instance or false + local reaction_handlers = self.reaction_handlers + if not reaction_handlers then + reaction_handlers = {} + self.reaction_handlers = reaction_handlers + end + local ModMsgBlacklist = config.Mods and ModMsgBlacklist or empty_table + for _, reaction in ipairs(list) do + local event_id = reaction.Event + local handler = reaction.Handler + if not ModMsgBlacklist[event_id] and handler then + local handlers = reaction_handlers[event_id] + if handlers then + if reaction_handlers_in_use > 0 then + handlers = icopy(handlers) + reaction_handlers[event_id] = handlers + end + local index = insert_locations and insert_locations[event_id] or #handlers + 1 + move(handlers, index, #handlers, index + 2) + handlers[index] = instance + handlers[index + 1] = handler + if insert_locations and insert_locations[event_id] then + insert_locations[event_id] = index + 2 + end + else + reaction_handlers[event_id] = { instance, handler } + end + end + end +end + +function ReactionObject:RemoveReactions(instance) + -- remove all handlers for this instance + instance = instance or false + local reaction_handlers = self.reaction_handlers + for event_id, handlers in pairs(reaction_handlers) do + local reaction_handlers_in_use = self.reaction_handlers_in_use + for i = #handlers - 1, 1, -2 do + if instance == handlers[i] then + if #handlers == 2 then + reaction_handlers[event_id] = nil + else + if reaction_handlers_in_use > 0 then + handlers = icopy(handlers) + reaction_handlers[event_id] = handlers + reaction_handlers_in_use = 0 + end + move(handlers, i + 2, #handlers + 2, i) + end + end + end + end +end + +-- to be used when the reactions list or handlers have changed +function ReactionObject:ReloadReactions(instance, list) + assert(self.reaction_handlers_in_use == 0) + local insert_locations + local reaction_handlers = self.reaction_handlers + for event_id, handlers in pairs(reaction_handlers) do + -- remove all handlers for this instance + for i = #handlers - 1, 1, -2 do + if instance == handlers[i] then + if #handlers == 2 then + reaction_handlers[event_id] = nil + else + if i ~= #handlers - 1 then + insert_locations = insert_locations or {} + insert_locations[event_id] = i + end + move(handlers, i + 2, #handlers + 2, i) + end + end + end + end + -- insert the new handlers at the appropriate places + self:AddReactions(instance, list, insert_locations) +end + +function ReactionObject:AddEventReaction(event_id, instance, handler) + local ModMsgBlacklist = config.Mods and ModMsgBlacklist or empty_table + if not handler or ModMsgBlacklist[event_id] then return end + local reaction_handlers = self.reaction_handlers + if not reaction_handlers then + reaction_handlers = {} + self.reaction_handlers = reaction_handlers + end + local handlers = reaction_handlers[event_id] + if handlers then + if self.reaction_handlers_in_use > 0 then + handlers = icopy(handlers) + reaction_handlers[event_id] = handlers + end + handlers[#handlers + 1] = instance + handlers[#handlers + 1] = handler + else + reaction_handlers[event_id] = { instance, handler } + end +end + +function ReactionObject:RemoveEventReactions(event_id, instance) + local reaction_handlers = self.reaction_handlers + local handlers = reaction_handlers and reaction_handlers[event_id] + local reaction_handlers_in_use = self.reaction_handlers_in_use + for i = #(handlers or "") - 1, 1, -2 do + if instance == handlers[i] then + if #handlers == 2 then + reaction_handlers[event_id] = nil + else + if reaction_handlers_in_use > 0 then + handlers = icopy(handlers) + reaction_handlers[event_id] = handlers + reaction_handlers_in_use = 0 + end + move(handlers, i + 2, #handlers + 2, i) + end + end + end +end + +local procall = procall +function ReactionObject:CallReactions(event_id, ...) + local reaction_handlers = self.reaction_handlers + local handlers = reaction_handlers and reaction_handlers[event_id] + if #(handlers or "") == 0 then return end + if #handlers > 2 then + self.reaction_handlers_in_use = self.reaction_handlers_in_use + 1 + for i = 1, #handlers - 2, 2 do + procall(handlers[i + 1], handlers[i], self, ...) + end + self.reaction_handlers_in_use = self.reaction_handlers_in_use - 1 + end + procall(handlers[#handlers], handlers[#handlers - 1], self, ...) +end + +function ReactionObject:CallReactions_And(event_id, ...) + local reaction_handlers = self.reaction_handlers + local handlers = reaction_handlers and reaction_handlers[event_id] + if #(handlers or "") == 0 then return true end + local result = true + if #handlers > 2 then + self.reaction_handlers_in_use = self.reaction_handlers_in_use + 1 + for i = 1, #handlers - 2, 2 do + local success, res = procall(handlers[i + 1], handlers[i], self, ...) + if success then + result = result and res + end + end + self.reaction_handlers_in_use = self.reaction_handlers_in_use - 1 + end + local success, res = procall(handlers[#handlers], handlers[#handlers - 1], self, ...) + if success then + result = result and res + end + return result +end + +function ReactionObject:CallReactions_Or(event_id, ...) + local reaction_handlers = self.reaction_handlers + local handlers = reaction_handlers and reaction_handlers[event_id] + if #(handlers or "") == 0 then return false end + local result = false + if #handlers > 2 then + self.reaction_handlers_in_use = self.reaction_handlers_in_use + 1 + for i = 1, #handlers - 2, 2 do + local success, res = procall(handlers[i + 1], handlers[i], self, ...) + if success then + result = result or res + end + end + self.reaction_handlers_in_use = self.reaction_handlers_in_use - 1 + end + local success, res = procall(handlers[#handlers], handlers[#handlers - 1], self, ...) + if success then + result = result or res + end + return result +end + +function ReactionObject:CallReactions_Modify(event_id, value, ...) + local reaction_handlers = self.reaction_handlers + local handlers = reaction_handlers and reaction_handlers[event_id] + if #(handlers or "") == 0 then return value end + if #handlers > 2 then + self.reaction_handlers_in_use = self.reaction_handlers_in_use + 1 + for i = 1, #handlers - 2, 2 do + local success, res = procall(handlers[i + 1], handlers[i], self, value, ...) + if success and res ~= nil then + value = res + end + end + self.reaction_handlers_in_use = self.reaction_handlers_in_use - 1 + end + local success, res = procall(handlers[#handlers], handlers[#handlers - 1], self, value, ...) + if success and res ~= nil then + value = res + end + return value +end + + +function ListCallReactions(list, event_id, ...) + for _, obj in ipairs(list) do + obj:CallReactions(event_id, ...) + end +end + +----- MsgReactions + +DefineReactionsPreset("Msg", "", "msg_reactions") -- DefineClass.MsgReactionsPreset + +-- MsgReactions is defined in cthreads.lua +local MsgReactions = MsgReactions +function ReloadMsgReactions() + table.clear(MsgReactions) + local list = {} + ClassDescendants("MsgReactionsPreset", function(classname, classdef, list) + list[#list + 1] = classdef.PresetClass or classname + end, list) + table.sort(list) + local ModMsgBlacklist = config.Mods and ModMsgBlacklist or empty_table + local last_preset + for i, preset_type in ipairs(list) do + if preset_type ~= last_preset then + last_preset = preset_type + ForEachPreset(preset_type, function(preset_instance) + for _, reaction in ipairs(preset_instance.msg_reactions or empty_table) do + local event_id = reaction.Event + local handler = reaction.Handler + if not ModMsgBlacklist[event_id] and handler then + local handlers = MsgReactions[event_id] + if handlers then + handlers[#handlers + 1] = preset_instance + handlers[#handlers + 1] = handler + else + MsgReactions[event_id] = { preset_instance, handler } + end + end + end + end) + end + end +end + +OnMsg.ModsReloaded = ReloadMsgReactions +OnMsg.DataLoaded = ReloadMsgReactions +OnMsg.PresetSave = ReloadMsgReactions +OnMsg.DataReloadDone = ReloadMsgReactions diff --git a/CommonLua/RichPresence.lua b/CommonLua/RichPresence.lua new file mode 100644 index 0000000000000000000000000000000000000000..526f949200fbcc75afdbf8e59b50878f59deaa07 --- /dev/null +++ b/CommonLua/RichPresence.lua @@ -0,0 +1,46 @@ +if not Platform.xbox then + return +end + +function GetRichPresenceData() -- game-specific - returns the current state from RichPresenceData + return false +end + +if FirstLoad then + g_RichPresenceData = false + g_RichPresenceThread = false +end + +CreateRealTimeThread(function() + local lastPresenceData = false + while true do + if g_RichPresenceData and Xbox.IsUserSigned() then + local presence_data = g_RichPresenceData + g_RichPresenceData = false + + if presence_data.xbox_id ~= lastPresenceData then + CreateRealTimeThread(AsyncXboxSetRichPresence, presence_data.xbox_id, presence_data.xbox_tokens) + lastPresenceData = presence_data.xbox_id + end + end + Sleep(7137) + end +end) + +UpdatePresenceInfo = empty_func + +function OnMsg.DataLoaded() + UpdatePresenceInfo = function() + g_RichPresenceData = GetRichPresenceData() or false + end +end + +function UpdatePresenceInfoDefer() + UpdatePresenceInfo() +end + +OnMsg.Start = UpdatePresenceInfoDefer +OnMsg.ChangeMapDone = UpdatePresenceInfoDefer +OnMsg.NewMapLoaded = UpdatePresenceInfoDefer +OnMsg.LoadGame = UpdatePresenceInfoDefer +OnMsg.GameStateChangedNotify = UpdatePresenceInfoDefer diff --git a/CommonLua/Savegame.lua b/CommonLua/Savegame.lua new file mode 100644 index 0000000000000000000000000000000000000000..fbb59eff282245798adf2cb42525b96ec15dc7aa --- /dev/null +++ b/CommonLua/Savegame.lua @@ -0,0 +1,1293 @@ +--[==[ + +A "savegame" is : + - a named piece of data (but note that savenames ARE NOT filenames -- use them only with the Savegame API) + - permanently stored in a platform-specific way + - to "client" code (i.e. user-supplied save/load callbacks) it looks like a folder + - transparently backed up (optional), so that a failed load will automatically use the backup if available + - has a metadata table with savename, displayname, timestamp. The game code can supply custom metadata as well + +Saving has three usage scenarios: + - Saved data with autogenerated names and optional "tag" to distinguish between different save types (implicitly never backed up) + - Saved data with known names (like account.dat) and back up + - Saved data with known names and no back up + + +Loading works both save types. If there is a backup and the "main" save can't be loaded, the backup is transparently loaded. +"Transparently" means that the caller will not know that this has happened. + +All save/load/delete/list calls are serialized - you can't make two at the same time, instead they wait for each other +Internally, saving and loading work from/to a preallocated memory buffer (see namespace MemorySavegame on the C-side) +_The save/load callbacks are presented with a folder (which is an in-memory hpk mounted at the SaveGame._MountPoint mountpoint, different every time) +The internal memory buffer can be reallocated by changing config.MemorySavegameSize + (Beware of fragmentation problems, it's better to increase the default g_nInMemorySaveGame in EngineConfig.cpp instead of tweaking this dynamically) + +------------ + +Public interface: + +error, savename = Savegame.WithTag (tag , displayname, save_callback, metadata) +error = Savegame.WithName (savename, displayname, save_callback, metadata) +error = Savegame.WithBackup(savename, displayname, save_callback, metadata) + +error = Savegame.Load (savename, load_callback) +error = Savegame.LoadWithBackup (savename, load_callback) +error = Savegame.Delete (savename) +error, list = Savegame.ListForTag(tag) -- tag may be "" meaning no tag, a specific tag, or an array of tags + + list = array of { displayname = "...", savename = "...", timestamp = N, corrupt = true|nil } tables + +error, count = Savegame.CountForTag(tag) -- tag may be "" meaning no tag, a specific tag, or an array of tags + +Developer tools: + +error = Savegame.Export(savename_from, filepath_to) +error = Savegame.Import(filename_from, savename_to) + +Callback interface: + +error = callback(folder) -- "folder" has a trailing slash + + +Helper functions (use them from within save/load callbacks) + +error, metadata = LoadMetadata(folder) +error = PersistGame(folder) +error = UnpersistGame(folder) + +------------ + +Implementation notes: +- The _Internal* functions do the work +- The _Platform* functions do platform-specific work +- The public interface functions are wrapped (see _Wrap) to disable concurrency + +--]==] + +sg_print = CreatePrint { + --"save", +} + +function PlayWithoutStorage() + return false +end + +function OnMsg.CanSaveGameQuery(query) + if GetMap() == "" then + query.no_map = true + end + if GameState.Tutorial and not GetDialog(config.BugReporterXTemplateID) and not Platform.developer then + query.tutorial = true + end + if IsEditorActive() then + query.editor_active = true + end + if PlayWithoutStorage() then + query.play_without_storage = true + end + if StoringSaveGame then + query.storing = true + end + if mapdata and not mapdata.GameLogic then + query.no_game_logic = true + end +end + +function CanSaveGame(request) + local query = {} + Msg("CanSaveGameQuery", query, request) + if not next(query) then + return "persist" + end + return nil, query +end + +StopAutosaveThread = empty_func + +-- A namespace. More functions in it are defined below, these are just the platform requisites +Savegame = { + -- Override these per platform + _PlatformSaveFromMemory = function(savename, displayname) assert(false, "Not implemented") return "Not implemented" end, + _PlatformLoadToMemory = function(savename) assert(false, "Not implemented") return "Not implemented" end, + _PlatformDelete = function(savename) assert(false, "Not implemented") return "Not implemented" end, + _PlatformListFileInfo = function() assert(false, "Not implemented") return "Not implemented" end, + _PlatformListFileNames = function() assert(false, "Not implemented") return "Not implemented" end, + -- See Savegame._DefaultCopy for a naive implementation, use it if there is no better alternative on the platform + _PlatformCopy = function(savename_from, savename_to) assert(false, "Not implemented") return "Not implemented" end, + -- Called before/after each savegame operation + _PlatformProlog = function() end, + _PlatformEpilog = function() end, + -- supply these two functions to enable loading from mounted disk files; otherwise, savegames are copied to memory first before loading + _PlatformMountToMemory = false, + _PlatformUnmountMemory = empty_func, + _CancelLoadToMemory = empty_func, + -- path of the currently mounted savegame, false if none + _MountPoint = false, + -- config + ScreenshotName = "screenshot.jpg", + ScreenshotWidth = 960, + ScreenshotHeight = 640, +} + +-----------------------------------[ Errors ]----------------------------------- + +-- Contexts: savegame | loadgame +-- Errors: "Disk Full", orbis1gb, "File is corrupt" + +--------------------------------[ SavegamesList ]----------------------------------- + +local AccountStorageSaveDelay = 5000 +if FirstLoad then + SavegamesList = { invalid = true, error = false } + SavingGame = false + StoringSaveGame = false + SavegameRunningThread = false +end + +function SavegamesList:Reset() + self.invalid = true + self.error = false + table.iclear(self) +end + +function SavegamesList:OnNew(metadata) + SavegamesList:OnDelete(metadata.savename) + table.insert(self, 1, metadata) + + if AccountStorage and metadata.savename ~= account_savename then + AccountStorage.savegameList = AccountStorage.savegameList or {} + table.insert(AccountStorage.savegameList, 1, metadata) + SaveAccountStorage(AccountStorageSaveDelay) + end +end + +function SavegamesList:OnDelete(savename) + local idx = table.find(self, "savename", savename) + if idx then + table.remove(self, idx) + end + + if AccountStorage and savename ~= account_savename and table.remove_entry(AccountStorage.savegameList or empty_table, "savename", savename) then + SaveAccountStorage(AccountStorageSaveDelay) + end +end + +function OnMsg.AccountStorageChanged() + if not AccountStorage or not AccountStorage.savegameList then + return + end + + SavegamesList:Reset() +end + +function SavegamesList:Refresh() + if not self.invalid then + return self.error + end + + -- Refresh is an internal function called in _InternalListForTag that is already Wrapped + assert(IsValidThread(SavegameRunningThread) and SavegameRunningThread == CurrentThread()) + + self:Reset() + + local useCache = AccountStorage and AccountStorage.savegameList + local platforminfoError, list = Savegame._PlatformListFileInfo() + + if platforminfoError then + self.error = platforminfoError; + return self.error + end + + local savegames = {} + local updateAccountStorage = useCache and #list < #AccountStorage.savegameList + + for i=1, #list do + local savelist_data = false + local error = false + local savename = list[i].savename + local timestamp = list[i].timestamp + + if useCache then + local idx = table.find(AccountStorage.savegameList, "savename", savename) + + if idx and + AccountStorage.savegameList[idx].os_timestamp and + AccountStorage.savegameList[idx].os_timestamp >= timestamp then + savelist_data = AccountStorage.savegameList[idx] + end + end + + if error or not savelist_data then + updateAccountStorage = true; + + error = Savegame._InternalLoad(savename, + function(folder) + local err, metadata = LoadMetadata(folder) + if err then return err end + if not metadata then return end + + if metadata.required_lua_revision and metadata.required_lua_revision > LuaRevision then + return "File is incompatible" + end + + metadata.savename = savename + metadata.os_timestamp = timestamp + metadata.loaded = true + savelist_data = metadata + end + ) + end + + if error or not savelist_data then + local name = T(857405002607, "Damaged savegame") + if error == "File is incompatible" then + name = T(365970916765, "Incompatible savegame") + end + savelist_data = { + corrupt = error ~= "File is incompatible", + incompatible = error == "File is incompatible", + savename = savename, + displayname = _InternalTranslate(name), + timestamp = os.time() + } + end + + savegames[1 + #savegames] = savelist_data + end + + if Platform.desktop then + table.sortby_field_descending(savegames, "timestamp") + else + table.sortby_field_descending(savegames, "savename") + end + + if updateAccountStorage then + AccountStorage.savegameList = savegames + SaveAccountStorage(AccountStorageSaveDelay) + end + + for i=1, #savegames do + self[i] = savegames[i] + end + + self.invalid = false + + return self.error +end + +function SavegamesList:Last() + return not self.invalid and self[1] +end + +function SavegamesList:GenerateFilename(tag) + if self.invalid then return end + local timestamp = os.time() + if tag and tag ~= "" then + return string.format("%010d.%s.sav", timestamp, tag) + else + return string.format("%010d.sav", timestamp) + end +end + +function OnMsg.DeviceChanged() + SavegamesList:Reset() +end + + +--------------------------------[ Internals ]----------------------------------- +function Savegame._UniqueName(displayname, tag, params) + local timestamp = os.time() + local tag = tag and tag ~= "" and "." .. tag or "" + if Platform.desktop then + local displayname = CanonizeSaveGameName(displayname) + local proposed_name = string.format("%s%s.%s", displayname, tag, "sav") + local force_overwrite = params and params.force_overwrite + if not force_overwrite and io.exists(GetPCSaveFolder() .. proposed_name) then + --a number must be added after the name + local error, files = AsyncListFiles(GetPCSaveFolder(), string.format("%s(*)%s.%s", displayname, tag, "sav"), "relative") + local pattern_name = EscapePatternMatchingMagicSymbols(displayname) + local pattern = pattern_name .. "%((%d+)%)" .. (tag ~= "" and "%" or "") .. tag .. "%.sav$" + local max_idx = 1 + for i = 1, #files do + local index = tonumber(string.match(files[i]:lower(), pattern:lower())) + max_idx = Max(index, max_idx) + end + return string.format("%s(%d)%s.%s", displayname, max_idx + 1, tag, "sav"), timestamp + else + return proposed_name, timestamp + end + else + return string.format("%010d%s.sav", timestamp, tag), timestamp + end +end + +function Savegame._BackupName(savename) + return savename .. ".bak" +end + +function Savegame._Wrap(func) + return function(...) + -- wait for other wrapped functions to finish + while IsValidThread(SavegameRunningThread) do + WaitMsg(SavegameRunningThread, 111) + end + local thread = CurrentThread() or false + SavegameRunningThread = thread + Savegame._PlatformProlog() + local results = pack_params(sprocall(func, ...)) + if not results[1] then + print("Savegame error:", results[2]) + end + Savegame._PlatformEpilog() + assert(SavegameRunningThread == thread) + if thread then Msg(thread) end + SavegameRunningThread = false + if results[1] then + return unpack_params(results, 2) + else + return "Failed" + end + end +end + +function Savegame._InternalSave(metadata, save_callback, params) + sg_print("Saving", metadata) + params = params or {} + local backup = params.backup + assert(metadata.savename ~= "") + assert(not backup or metadata.backupname ~= "") + assert(metadata.displayname ~= "") + Savegame.Unmount() + local error + if backup then + local backupname = Savegame._BackupName(metadata.savename) + error = Savegame._PlatformCopy(metadata.savename, backupname) + if error then + if error ~= "File Not Found" and error ~= "Path Not Found" then + IgnoreError(error, "SavedataSave/backup") + end + error = false + end + end + local mount_point + error, mount_point = MemorySavegameCreateEmpty() + if error then return error end + error = SaveMetadata(mount_point, metadata) + if error then return error end + error = save_callback(mount_point, metadata, params) + + if not error then + StoringSaveGame = true + CreateRealTimeThread(function() + Msg("StoreSaveGame", true) + local err = Savegame._PlatformSaveFromMemory(metadata.savename, metadata.displayname) + Msg("StoreSaveGame", false) + if err then + CreateErrorMessageBox(err, "savegame", nil, nil, {savename = T{129666099950, '""', name = Untranslated(metadata.savename)}, error_code = Untranslated(err)}) + end + StoringSaveGame = false + if not err then + metadata.loaded = true + SavegamesList:OnNew(metadata) + end + end) + end + + return error +end + +function Savegame.Unmount() + if Savegame._MountPoint then + Savegame._PlatformUnmountMemory() + Savegame._MountPoint = false + end +end + +function Savegame._InternalLoad(savename, load_callback, params) + Savegame.Unmount() + + local dir = GetPathDir(savename) + local err, mount_point + if dir ~= "" and io.exists(savename) then + err, mount_point = MountToMemory_Desktop(savename) + else + err, mount_point = (Savegame._PlatformMountToMemory or Savegame._PlatformLoadToMemory)(savename) + end + + if err then return err end + err = load_callback(mount_point, params) + if err then return err end + Savegame._MountPoint = mount_point +end + +function Savegame._InternalLoadWithBackup(savename, load_callback) + local error_original = Savegame._InternalLoad(savename, load_callback) + if not error_original then + return error_original + end + + -- Delete the damaged original. + -- This is important, otherwise we would clobber the backup with the damaged original + -- when we save a new version. + if error_original ~= "File Not Found" and error_original ~= "Path Not Found" then + -- User must be informed before deleting any save data. + WaitErrorMessage(error_original, "account load", nil, GetLoadingScreenDialog(), { savename = g_AccountStorageSaveName }) + + local error_delete = Savegame._PlatformDelete(savename) + if error_delete then + IgnoreError(error_delete, "SavedataLoad - delete original") + end + end + + -- Try to load from backup + savename = Savegame._BackupName(savename) + local error_backup = Savegame._InternalLoad(savename, load_callback) + if not error_backup then + return error_original, error_backup + end + + -- Delete the damaged backup. + -- This is important, so that we do not spam the user with the same corrupted save. + if error_backup ~= "File Not Found" and error_backup ~= "Path Not Found" then + -- User must be informed before deleting any save data. + WaitErrorMessage(error_backup, "account load backup", nil, GetLoadingScreenDialog(), { savename = g_AccountStorageSaveName }) + + local error_delete = Savegame._PlatformDelete(savename) + if error_delete then + IgnoreError(error_delete, "SavedataLoad - delete backup") + end + end + + return error_original, error_backup +end + +function Savegame._InternalDeleteWithBackup(savename) + Savegame.Unmount() + -- First delete the backup, otherwise, if we fail, a deleted main + valid backup means the backup may be used + local error_backup = Savegame._PlatformDelete(Savegame._BackupName(savename)) + if error_backup and error_backup ~= "File Not Found" and error_backup ~= "Path Not Found" then + return error_backup + end + local error = Savegame._PlatformDelete(savename) + if error then + return error + else + SavegamesList:OnDelete(savename) + end +end + +function Savegame._InternalListForTag(tag) + assert(tag) + + local error = SavegamesList:Refresh() + + if error then return error end + + if type(tag)=="string" then + local list = {} + for i = 1, #SavegamesList do + local match = { string.match(SavegamesList[i].savename, "^.*%.(%w+)%.sav$") } + if (match[1] and tag == match[1]) or (not match[1] and tag == "") then + table.insert(list, table.copy(SavegamesList[i], "deep")) + end + end + return nil, list + elseif type(tag)=="table" then + local list = {} + for i=1, #tag do + local error, taglist = Savegame._InternalListForTag(tag[i]) + if error then return error end + table.iappend(list, taglist) + end + -- coming from multiple sources, re-sort + table.sortby_field_descending( list, "savename" ) + return nil, list + else + assert(false, "Unsupported tag type") + end +end + +function Savegame._InternalCountForTag(tag) + assert(tag) + local error, files = Savegame._PlatformListFileNames() + if error then return error end + if type(tag)=="string" then + local count = 0 + for i = 1, #(files or "") do + local match = {string.match(files[i], "^.*%.(%w+)%.sav$")} + if (match[1] and tag == match[1]) or (not match[1] and tag == "") then + count = count + 1 + end + end + return nil, count + elseif type(tag)=="table" then + local count = 0 + for i=1, #tag do + local error, tag_count = Savegame._InternalCountForTag(tag[i]) + if error then return error end + count = count + tag_count + end + return nil, count + else + assert(false, "Unsupported tag type") + end +end + +function Savegame._DefaultCopy(savename_from, savename_to) + local error, mount_point = Savegame._PlatformLoadToMemory(savename_from) + if error then return error end + local metadata + error, metadata = LoadMetadata(mount_point) + if error then return error end + return Savegame._PlatformSaveFromMemory(savename_to, metadata.displayname) +end + +---------------------------------------------------------------------------------------- +--------------------------------[ Platform specific ]----------------------------------- +---------------------------------------------------------------------------------------- + +if Platform.desktop then + function GetSavePath(savename) + local dir = GetPathDir(savename) + if dir == "" then + savename = GetPCSaveFolder() .. savename + end + return savename + end + function Savegame._PlatformSaveFromMemory(savename, displayname) + return SaveFromMemory_Desktop(GetSavePath(savename), displayname) + end + Savegame._PlatformLoadToMemory = function(savename) + return LoadToMemory_Desktop(GetSavePath(savename)) + end + Savegame._PlatformDelete = function(savename) + return AsyncFileDelete(GetSavePath(savename)) + end + + Savegame._PlatformCopy = function(savename_from, savename_to) + return AsyncCopyFile(GetSavePath(savename_from), GetSavePath(savename_to), "raw") + end + + Savegame._PlatformMountToMemory = function(savename) + return MountToMemory_Desktop(GetSavePath(savename)) + end + Savegame._PlatformUnmountMemory = UnmountMemory_Desktop + + function Savegame._PlatformListFileInfo() + local error, files = AsyncListFiles(GetPCSaveFolder(), "*.sav", "relative,modified") + if error then return error end + local filesInfo = {} + for i=1,#files do + filesInfo[1+#filesInfo] = { + savename = files[i], + timestamp = files["modified"][i] + } + end + return nil, filesInfo + end + + function Savegame._PlatformListFileNames() + local error, files = AsyncListFiles(GetPCSaveFolder(), "*.sav", "relative") + if error then return error end + return nil, files + end + +end -- Platform.desktop + +--------------------------------[ PS4 ]----------------------------------- + +if Platform.playstation then + + function Savegame._PlatformListFileInfo() + local err, files = AsyncPlayStationSaveDataList() + if err then return err end + local filesInfo = {} + for i=1,#files do + filesInfo[1+#filesInfo] = { + savename = files[i][1], + timestamp = files[i][3], + } + end + return nil, filesInfo + end + + function Savegame._PlatformProlog() + while StoringSaveGame do + Sleep(1) + end + end + + function Savegame._PlatformListFileNames() + local err, files = AsyncPlayStationSaveDataList() + if err then return err end + + local file_names = {} + for i=1,#files do + table.insert(file_names, files[i][1]) + end + + return nil, file_names + end + + -- Before modifying read: https://p.siedev.net/forums/thread/241339/ + function Savegame._PlatformSaveFromMemory(savename, displayname) + local err, required_space = AsyncPlayStationSaveRequiredSize() + if err == "Disk Full" then + -- handle out-of-storage scenario with a system dialog and retry + AsyncPlayStationShowFreeSpaceDialog(required_space) + err, required_space = AsyncPlayStationSaveRequiredSize() + end + if err then return err end + + local err, total_size = AsyncPlayStationSaveDataTotalSize() + if err then return err end + + if total_size + required_space > const.PlayStationMaxSaveDataSizePerUser then + return "Save Storage Full" + end + + return AsyncPlayStationSaveFromMemory(savename, displayname, required_space) + end + + Savegame._PlatformLoadToMemory = AsyncPlayStationLoadToMemory + Savegame._PlatformDelete = AsyncPlayStationSaveDataDelete + Savegame._PlatformCopy = Savegame._DefaultCopy +end -- Platform.playstation + +--------------------------------[ Xbox ]----------------------------------- + +if Platform.xbox then + + function Savegame._PlatformSaveFromMemory(savename, displayname) + if not Xbox.IsUserSigned() then + assert("Tring to save game with no active player") + return "NoUser" + end + local err = Xbox.StoreSave(savename, displayname) + if err then + Xbox.DeleteSave(savename) + end + return err + end + + Savegame._PlatformLoadToMemory = function(savename) + if not Xbox.IsUserSigned() then + assert("Trying to load a game with no active player") + return "NoUser" + end + + return Xbox.MountReadContent(savename) + end + + Savegame._PlatformDelete = function(savename) + if not Xbox.IsUserSigned() then + assert("Trying to delete savegame with no active player") + return "NoUser" + end + + return Xbox.DeleteSave(savename) + end + + function Savegame._PlatformListFileInfo() + if not Xbox.IsUserSigned() then + assert("Trying to list savegames with no active player") + return "NoUser" + end + local err, files = Xbox.GetSaveList() + if err then return err end + local filesInfo = {} + for i=1,#files do + filesInfo[1+#filesInfo] = { + savename = files[i][1], + timestamp = files[i][3], + } + end + return nil, filesInfo + end + + function Savegame._PlatformListFileNames() + if not Xbox.IsUserSigned() then + assert("Trying to list savegames with no active player") + return "NoUser" + end + + local err, files = Xbox.GetSaveList() + if err then + return err + end + + local file_names = {} + for i=1,#files do + table.insert(file_names, files[i][1]) + end + + return nil, file_names + end + + Savegame._PlatformCopy = Savegame._DefaultCopy + +end --Platform.xbox + +if Platform.switch then + function Savegame._PlatformSaveFromMemory(savename, displayname) + local err = SaveFromMemory_Desktop("saves:/" .. savename, displayname) + Switch.CommitSaveData() + return err + end + Savegame._PlatformLoadToMemory = function(savename) + return LoadToMemory_Desktop("saves:/" .. savename) + end + Savegame._PlatformDelete = function(savename) + return AsyncFileDelete("saves:/" .. savename) + end + + Savegame._PlatformCopy = function(savename_from, savename_to) + return AsyncCopyFile("saves:/" .. savename_from, "saves:/" .. savename_to, "raw") + end + + function Savegame._PlatformListFileInfo() + local error, files = AsyncListFiles("saves:/", "*.sav", "relative,modified") + if error then return error end + local filesInfo = {} + for i=1,#files do + filesInfo[1+#filesInfo] = { + savename = files[i], + timestamp = files["modified"][i] + } + end + return nil, filesInfo + end + + function Savegame._PlatformListFileNames() + local error, files = AsyncListFiles("saves:/", "*.sav", "relative") + if error then return error end + return nil, files + end + +end -- Platform.switch + +-------------------------------------------------------------------- +--------------------------[ Helpers ]------------------------------- +-------------------------------------------------------------------- + +function AddSystemMetadata(metadata) + local required_revision = config.SavegameRequiredLuaRevision + if required_revision == -1 then + required_revision = LuaRevision + end + metadata.lua_revision = LuaRevision + metadata.assets_revision = AssetsRevision + metadata.required_lua_revision = required_revision + metadata.platform = Platform + metadata.real_time = RealTime() + FillDlcMetadata(metadata) +end + +function SaveMetadata(folder, metadata) + AddSystemMetadata(metadata) + return AsyncStringToFile(folder .. "savegame_metadata", TableToLuaCode(metadata)) +end +-- returns error, metadata +function LoadMetadata(folder) + local filename = folder .. "savegame_metadata" + return FileToLuaValue(filename, {}) +end + +function UnpersistGame(folder, metadata, params) + --@@@msg PreLoadGame, metadata - fired before a game is loaded. + Msg("PreLoadGame", metadata) + --@@msg LoadGameObjectsUnpersisted, metadata, version - fired when all objects are unpersisted + local err, version = EngineLoadGame(folder .. "persist", metadata) + if err then + if CurrentMapFolder == "" then + CurrentMapFolder = GetMap() -- make possible to unmount the map + end + if CurrentMap == "" then + CurrentMap = "preloaded map" -- make possible to unmount the map + end + DoneMap() + ChangeMap("") + return err + end + --@@@msg LoadGame, metadata, version - fired after a game has been loaded. + Msg("LoadGame", metadata, version, params) + FixupSavegame(metadata) + --@@@msg PostLoadGame, metadata, version - fired after LoadGame and with fixups. The loaded game is ready to run (e.g. UI can update). + Msg("PostLoadGame", metadata, version) +end + +function ReportPersistErrors() + for _, err in ipairs(__error_table__) do + print("Persist error:", err.error or "unknown") + print("Persist stack:") + for _, value in ipairs(err) do + local str = tostring(value) + if type(value) == "table" then + if value.class then + str = str .. " " .. ObjToStr(value) + else + str = str .. " #" .. table.count(value) + end + end + print(" ", str) + end + print() + end + --[[ + local last_entry = __error_table__[#__error_table__] + local err_obj = last_entry and last_entry[#last_entry] + if err_obj then + local refs = FindReferences(err_obj) + print("Last entry refs:") + for _, ref in ipairs(refs) do + print(" ", ref) + end + end + --]] +end + +function PersistGame(folder) + assert(CanSaveGame() == "persist") + collectgarbage("collect") + rawset(_G, "__error_table__", {}) + local filename = folder .. "persist" + local err = EngineSaveGame(filename) + ReportPersistErrors() + assert(#__error_table__ == 0, "Fatal persist errors. CALL A PROGRAMMER!. See __error_table__") + if not Platform.developer then + rawset(_G, "__error_table__", false) + end + return err +end + +--------------------------------------------------------------------------------------- +--------------------------------[ Public interface ]----------------------------------- +--------------------------------------------------------------------------------------- + +Savegame._WrappedSave = Savegame._Wrap(Savegame._InternalSave) + +function Savegame.WithTag(tag, displayname, save_callback, metadata, params) + params = params or {} + local savename, timestamp = Savegame._UniqueName(displayname, tag, params) + metadata = metadata or {} + assert(not metadata.savename and + not metadata.displayname and + not metadata.timestamp, "Do not fill reserved metadata fields") + + metadata.savename = savename + metadata.displayname = displayname + metadata.timestamp = timestamp + metadata.os_timestamp = timestamp + metadata.playtime = GetCurrentPlaytime() + + params.backup = false + + return Savegame._WrappedSave(metadata, save_callback, params), savename +end + +function Savegame.WithName(savename, displayname, save_callback, metadata, params) + params = params or {} + metadata = metadata or {} + assert(not metadata.savename and + not metadata.displayname and + not metadata.timestamp, "Do not fill reserved metadata fields") + + metadata.savename = savename + metadata.displayname = displayname + metadata.timestamp = os.time() + metadata.os_timestamp = os.time() + metadata.playtime = GetCurrentPlaytime() + + params.backup = false + + return Savegame._WrappedSave(metadata, save_callback, params) +end + +function Savegame.WithBackup(savename, displayname, save_callback, metadata, params) + params = params or {} + metadata = metadata or {} + assert(not metadata.savename and + not metadata.displayname and + not metadata.timestamp, "Do not fill reserved metadata fields") + + metadata.savename = savename + metadata.displayname = displayname + metadata.timestamp = os.time() + metadata.os_timestamp = os.time() + metadata.playtime = GetCurrentPlaytime() + + params.backup = true + + return Savegame._WrappedSave(metadata, save_callback, params) +end + +-- error = function(savename, load_callback) +Savegame.LoadWithBackup = Savegame._Wrap(Savegame._InternalLoadWithBackup) + +-- error = function(savename, load_callback) +Savegame.Load = Savegame._Wrap(Savegame._InternalLoad) + +-- error = function(savename) +Savegame.Delete = Savegame._Wrap(Savegame._InternalDeleteWithBackup) + +-- error, list = function(tag) +Savegame.ListForTag = Savegame._Wrap(Savegame._InternalListForTag) + +-- error, count = function(tag) +Savegame.CountForTag = Savegame._Wrap(Savegame._InternalCountForTag) + +-- function() +Savegame.CancelLoad = Savegame._CancelLoadToMemory + +-- on desktop platforms, metadata is always loaded entirely from the savegame, so metadata entries in list are full +-- on consoles, initial load of savegame list results in entries consisting only of savename and displayname, +-- so this needs to be called when you need the full metadata; it will be loaded if necessary + +function GetFullMetadata(metadata, reload) + if metadata.corrupt then + return "File is corrupt" + elseif metadata.incompatible then + return "File is incompatible" + elseif metadata.loaded and not reload then + -- full meta is already loaded + return + end + + local loaded_meta + -- keep savename from given metadata, because this is the actual filename + -- and might differ from the one stored in the savegame if the user has + -- renamed it manually outside of the game + local savename = metadata.savename + local err = Savegame.Load(metadata.savename, function(folder) + local load_err + load_err, loaded_meta = LoadMetadata(folder) + if load_err then return load_err end + if loaded_meta and loaded_meta.required_lua_revision and loaded_meta.required_lua_revision > LuaRevision then + return "File is incompatible" + end + end) + + if err then + metadata.incompatible = err == "File is incompatible" + metadata.corrupt = err ~= "File is incompatible" + return err + end + + for key, val in pairs(loaded_meta or empty_table) do + metadata[key] = val + end + metadata.savename = savename + metadata.loaded = true +end + +function DeleteGame(name) + local err = Savegame.Delete(name) + if not err then + Msg("SavegameDeleted", name) + end + return err +end + +function WaitCountSaveGames() + if PlayWithoutStorage() then + return 0 + end + local err, count = Savegame.CountForTag("savegame") + return not err and count or 0 +end + +function GameSpecificSaveCallback(folder, metadata, params) + assert(false, "override this callback in game-specific code") +end + +function GameSpecificLoadCallback(folder, metadata, params) + assert(false, "override this callback in game-specific code") +end + +function GameSpecificSaveCallbackBugReport(folder, metadata) + return PersistGame(folder) +end + +function DoSaveGame(display_name, params) + WaitChangeMapDone() + WaitSaveGameDone() + SavingGame = true + --@@@msg SaveGameStart - fired before the game is saved. + params = params or {} + Msg("SaveGameStart", params) + local metadata = GatherGameMetadata(params) + local autosave = params.autosave + if autosave then + metadata.autosave = autosave + end + local err, name + if params.savename then + err = Savegame.WithName(params.savename, display_name, GameSpecificSaveCallback, metadata, params) + name = params.savename + else + err, name = Savegame.WithTag("savegame", display_name, GameSpecificSaveCallback, metadata, params) + end + SavingGame = false + if not err and params.save_as_last then + LocalStorage.last_save = name + SaveLocalStorage() + end + Msg("SaveGameDone", name, autosave, err) + return err, name, metadata +end + +function WaitSaveGameDone() + if SavingGame then + WaitMsg("SaveGameDone") + end +end + +function SaveGame(display_name, params) + params = params or {} + assert(type(params) == "table") + local silent = params.silent + if not silent then + LoadingScreenOpen("idLoadingScreen", "save savegame") + end + + WaitRenderMode("ui") + local err, name, meta = DoSaveGame(display_name, params) + WaitRenderMode("scene") + + if not silent then + LoadingScreenClose("idLoadingScreen", "save savegame") + end + return err, name, meta +end + +function LoadGame(savename, params) + local st = GetPreciseTicks() + params = params or {} + assert(type(params) == "table") + LoadingScreenOpen("idLoadingScreen", "load savegame") + WaitRenderMode("ui", collectgarbage, "collect") -- make sure we clear unused memory so we have enough for to load the game + local err = Savegame.Load(savename, LoadMetadataCallback, params) + local loaded_map = GetMap() + if loaded_map ~= "" then + WaitRenderMode("scene", collectgarbage, "collect") -- free any leftovers from the load process itself (intermediate data, stale references, etc.) + else + collectgarbage("collect") -- free any leftovers from the load process itself (intermediate data, stale references, etc.) + end + if params.save_as_last and not err then + LocalStorage.last_save = savename + SaveLocalStorage() + end + LoadingScreenClose("idLoadingScreen", "load savegame") + if err then + print("LoadGame error:", err) + else + DebugPrint("Game loaded on map", CurrentMap, "in", GetPreciseTicks() - st, "ms\n") + end + return err +end + +function SaveGameBugReport(display_name, screenshot) + return DoSaveGame(display_name) +end + +function SaveGameBugReportPStr(display_name) + WaitChangeMapDone() + local err, mount_point + err, mount_point = MemorySavegameCreateEmpty() + if err then return err end + -- metadata + local metadata = GatherBugReportMetadata (display_name) + err = SaveMetadata(mount_point, metadata) + if err then return err end + -- screen shot + err = GameSpecificSaveCallbackBugReport(mount_point) + if err then return err end + -- + return err, MemorySaveGamePStr(0, MemorySaveGameSize()) +end + +function Savegame.Import(filepath_from, savename_to) + local err = LoadToMemory_Desktop(filepath_from) + if err then return err end + + local savename, _ = Savegame._UniqueName(savename_to, "savegame") + return Savegame._PlatformSaveFromMemory(savename, savename_to) +end + +function Savegame.Export(savename_from, filepath_to) + local filedir_to, _, _ = SplitPath(filepath_to) + local err = AsyncCreatePath(filedir_to) + if err then return err end + + err = MountPack("exported", filepath_to, "create,compress") + if err then return err end + + err = Savegame.LoadWithBackup(savename_from, + function(folder) + local err, files = AsyncListFiles(folder, "*", "relative") + if err then return err end + for i=1, #files do + err = AsyncCopyFile(folder..files[i], "exported/"..files[i], "raw") + if err then return err end + end + end) + UnmountByPath("exported") + return err +end + +function GetSavegameExportPath(savename) + local console_name = "console" + local saveDir = "AppData/ExternalSaves/" + if Platform.ps4 then + console_name = "ps4" + elseif Platform.ps5 then + console_name = "ps5" + saveDir = "ExportedSaves/" + elseif Platform.xbox_one_x then + console_name = "xbox_one_x" + saveDir = "TmpData/ExportedSaves/" + elseif Platform.xbox_one then + console_name = "xbox_one" + saveDir = "TmpData/ExportedSaves/" + elseif Platform.xbox_series_x then + console_name = "xbox_series_x" + saveDir = "TmpData/ExportedSaves/" + elseif Platform.xbox_series then + console_name = "xbox_series" + saveDir = "TmpData/ExportedSaves/" + end + local export_path = string.format(saveDir .. "exp_%s_%s", console_name, savename) + return savename, export_path +end + +--------------------------[ Developer tools ]------------------------------- + +if not Platform.cmdline and Platform.pc and Platform.developer then + + function RegisterSavFileHandler() + local path = ConvertToOSPath(GetExecName()) + if not path or not io.exists(path) then return end + local name = "hg-" .. const.ProjectName + local reg = string.format([=[reg add HKCU\Software\Classes\%s\shell\open\command /f /d "cmd /c start \"%s\" /d \"%s\" \"%s\" -save \"%%1\""]=], name, const.ProjectName, GetCWD(), path) + local err, code = AsyncExec(reg, "", true, true) + if not err and code == 0 then + reg = [=[reg add HKCU\Software\Classes\.sav\OpenWithProgids /f /v "]=] .. name .. [=["]=] + err, code = AsyncExec(reg, "", true, true) + end + end + + if FirstLoad and config.RegisterSavFileHandler then + CreateRealTimeThread(RegisterSavFileHandler) + end + +end -- not Platform.cmdline and Platform.pc and Platform.developer + +function SavegameImportHelper() + CreateRealTimeThread(function() + local saveDir = "AppData/ExternalSaves/" + if Platform.xbox then + saveDir = "TmpData/ExternalSaves/" + elseif Platform.ps5 then + saveDir = "ExternalSaves/" + end + local err, files = AsyncListFiles(saveDir) + if err or not next(files) then + WaitMessage(terminal.desktop, + T(182714092509, "Fail"), + Untranslated(string.format("Finding saves to import failed: %s", err or "None")), + T(1000136, "OK") + ) + return + end + + local successful_imports = 0 + local failed_imports = 0 + for _, file in ipairs(files) do + local _, name, ext = SplitPath(file) + local display_name = " " .. string.match(name, "(.+)%.") + local err = Savegame.Import(file, display_name) + if err then + failed_imports = failed_imports + 1 + WaitMessage(terminal.desktop, + Untranslated("Fail"), + Untranslated(string.format("Importing: %s\nFailed: %s", file, err)), + Untranslated("OK") + ) + else + successful_imports = successful_imports + 1 + if Platform.xbox then Sleep(1000) end + end + end + if successful_imports > 0 then + SavegamesList:Reset() + end + CreateMessageBox(nil, + Untranslated("Finished"), + Untranslated(string.format("Imported %d saves, %d failed.", successful_imports, failed_imports)), + Untranslated("OK") + ) + end) +end + +function SavegameExportHelper(savename) + local console_name = GetPlatformName() + local saveBaseDir = (Platform.xbox and "TmpData" or "AppData") + local export_path = string.format("%s/ExternalSaves/exp_%s_%s", saveBaseDir, console_name, savename) + local err = Savegame.Export(savename, export_path) + if not err then + CreateMessageBox(nil, Untranslated("Success"), Untranslated(string.format("%s exported as %s", savename, export_path)), Untranslated("OK")) + else + CreateMessageBox(nil, Untranslated("Error"), Untranslated(string.format("Error while exporting %s: %s", savename, err)), Untranslated("OK")) + end +end + +if Platform.developer then + + if FirstLoad then + DbgSaveDesync = false + end + + function DbgToggleSaveSyncTest() + DbgSaveDesync = not DbgSaveDesync + if not DbgSaveDesync then + DbgSyncTestStop() + end + print("Save Sync Test:", DbgSaveDesync) + end + + function OnMsg.LoadGame(meta) + DeleteThread(DbgSyncTestThread) + if not DbgSaveDesync or Libs.Network ~= "sync" then + return + end + DbgSyncTestStart(table.hash(meta)) + DbgSyncTestTrack() + end + + function ResaveGame(path, params) + if not IsRealTimeThread() then + CreateRealTimeThread(ResaveGame, path, params) + return + end + print("Loading save", path) + local err = LoadGame(path) + if err then + print("Loading error:", err) + return + end + print("Loading success! Saving...") + params = table.copy(params) + if params.on_load_callback then + params.on_load_callback(SavegameMeta) + end + params.savename = path + params.force_overwrite = true + local err, savepath = SaveGame(SavegameMeta.displayname, params) + if err then + print("Saving error:", err) + return + end + print("Saving success!") + end + +end -- Platform.developer diff --git a/CommonLua/SavegameFixup.lua b/CommonLua/SavegameFixup.lua new file mode 100644 index 0000000000000000000000000000000000000000..c958b05746a6309a8c872f398920135951baa0cb --- /dev/null +++ b/CommonLua/SavegameFixup.lua @@ -0,0 +1,46 @@ +--[==[ +Simple fixups - the fixup is a function which needs to be applied only once on an old savegame during OnMsg.LoadGame + +function SavegameFixups.(metadata, lua_revision) + ... -- the actual code that fixes up the savegame +end + +--]==] + +GameVar("AppliedSavegameFixups", function() + local applied = {} + for fixup in pairs(SavegameFixups) do + applied[fixup] = true + end + return applied +end) + +SavegameFixups = {} + +if FirstLoad then + ApplyingSavegameFixups = false +end + +function FixupSavegame(metadata) + SuspendPassEdits("SavegameFixups") + SuspendDesyncErrors("SavegameFixups") + rawset(_G, "AppliedSavegameFixups", rawget(_G, "AppliedSavegameFixups") or {}) + ApplyingSavegameFixups = true + local lua_revision = metadata and metadata.lua_revision or 0 + local start_time, count = GetPreciseTicks(), 0 + local applied = {} + for fixup, func in sorted_pairs(SavegameFixups) do + if not AppliedSavegameFixups[fixup] and type(func) == "function" then + procall(func, metadata, lua_revision) + count = count + 1 + applied[#applied + 1] = fixup + AppliedSavegameFixups[fixup] = true + end + end + ApplyingSavegameFixups = false + if count > 0 then + DebugPrint(string.format("Applied %d savegame fixup(s) in %d ms: %s\n", count, GetPreciseTicks() - start_time, table.concat(applied, ", "))) + end + ResumeDesyncErrors("SavegameFixups") + ResumePassEdits("SavegameFixups") +end diff --git a/CommonLua/SavegameMetadata.lua b/CommonLua/SavegameMetadata.lua new file mode 100644 index 0000000000000000000000000000000000000000..f81ba3270a491fc30f94adb684a8954c3209e749 --- /dev/null +++ b/CommonLua/SavegameMetadata.lua @@ -0,0 +1,267 @@ +GameVar("SavegameMeta", false) +MapVar("LoadedRealTime", false) +PersistableGlobals.SavegameMeta = false +PersistableGlobals.LoadedRealTime = false + +local modid_props = {} +function OnMsg.ClassesBuilt() + if not config.Mods then return end + for i, prop_meta in ipairs(ModDef.properties) do + if prop_meta.modid then + table.insert(modid_props, prop_meta.id) + end + end +end +function GetLoadedModsSavegameData() + if not config.Mods then return end + local active_mods = SavegameMeta and SavegameMeta.active_mods or {} + for _, mod in ipairs(ModsLoaded or empty_table) do + local idx = table.find(active_mods, "id", mod.id) or (#active_mods + 1) + local mod_info = { + id = mod.id, + title = mod.title, + version = mod.version, + lua_revision = mod.lua_revision, + saved_with_revision = mod.saved_with_revision, + source = mod.source, + } + for i, prop_id in ipairs(modid_props) do + mod_info[prop_id] = rawget(mod, prop_id) + end + active_mods[idx] = mod_info + end + return active_mods +end + +function GetAllLoadedModsAffectedResources() + if not config.Mods then return end + if not ModsAffectedResourcesCache or not ModsAffectedResourcesCache.valid then + FillModsAffectedResourcesCache() + end + + local affected_resources = {} + for _, class in pairs(ModsAffectedResourcesCache) do + if class ~= "valid" then + for _, affectedObj in ipairs(class) do + table.insert(affected_resources, affectedObj:GetResourceTextDescription()) + end + end + end + return affected_resources +end + +function GatherGameMetadata(params) + assert(LuaRevision and LuaRevision ~= 0, "LuaRevision should never be 0 at this point") + params = params or empty_table + local save_terrain_grid_delta = config.SaveTerrainGridDelta and not params.include_full_terrain + local map = RemapMapName(GetMapName()) + local mapdata = MapData[map] or mapdata + local metadata = { + map = map, + active_mods = GetLoadedModsSavegameData(), + mod_affected_resources = GetAllLoadedModsAffectedResources(), + BaseMapNetHash = save_terrain_grid_delta and mapdata.NetHash or nil, + TerrainHash = save_terrain_grid_delta and mapdata.TerrainHash or nil, + GameTime = GameTime(), + broken = SavegameMeta and SavegameMeta.broken or nil, + ignored_mods = SavegameMeta and SavegameMeta.ignored_mods or nil, + } + Msg("GatherGameMetadata", metadata) + config.BaseMapFolder = save_terrain_grid_delta and GetMapFolder(map) or "" + + return metadata +end + +function GetMissingMods(active_mods, missing_mods_list) + --check if any mods are missing or outdated + for _, mod in ipairs(active_mods or empty_table) do + --mod is a table, containing id, title, version and lua_revision or is just the id in older saves + local blacklistedReason = GetModBlacklistedReason(mod.id) + local local_mod = table.find_value(ModsLoaded, "id", mod.id or mod) or Mods[mod.id or mod] + --possible problems + local deprecated = not Platform.developer and blacklistedReason and blacklistedReason == "deprecate" --in dev we want to count deprecated as missing + local missing = not local_mod + local too_old = (mod.lua_revision or 9999999) < ModMinLuaRevision + local disabled = local_mod and not table.find(AccountStorage.LoadMods, mod.id or mod) + local old_local = local_mod and local_mod.version < (mod.version or 0) + + if not deprecated and (missing or too_old or disabled or old_local) then + missing_mods_list[#missing_mods_list + 1] = table.copy(mod) + end + end +end + +function LoadAnyway(err, alt_option) + DebugPrint("\nLoad anyway", ":", _InternalTranslate(err), "\n\n") + local default_load_anyway = config.DefaultLoadAnywayAnswer + if default_load_anyway ~= nil then + return default_load_anyway + end + local parent = GetLoadingScreenDialog() or terminal.desktop + local choice = WaitMultiChoiceQuestion(parent, T(1000599, "Warning"), err, nil, T(3686, "Load anyway"), T(1000246, "Cancel"), alt_option) + return choice ~= 2, choice == 3 +end + +function ValidateSaveMetadata(metadata, broken, missing_mods_list) + if metadata.dlcs then + local missing_dlc = false + local load_anyway_enabled = true + for _, dlc in ipairs(metadata.dlcs) do + if not IsDlcAvailable(dlc.id) then + missing_dlc = true + if Platform.developer then + local dlc_preset = FindPreset("DLCConfig", dlc.id) + load_anyway_enabled = load_anyway_enabled and not dlc_preset or dlc_preset.load_anyway + end + end + end + + if Platform.developer and missing_dlc and load_anyway_enabled then + if not LoadAnyway(T(1000849, "The game cannot be loaded because some required downloadable content is not installed.")) then + return "missing dlc" + else + broken = table.create_set(broken, "MissingDLC", true) + end + elseif missing_dlc then + WaitMessage(GetLoadingScreenDialog() or terminal.desktop, + T(1000599, "Warning"), + T(1000849, "The game cannot be loaded because some required downloadable content is not installed."), + T(1000136, "OK")) + return "missing dlc" + end + end + + if (metadata.lua_revision or 0) < config.SupportedSavegameLuaRevision then + if not LoadAnyway(T(3685, "This savegame is from an old version and may not function properly.")) then + return "old version" + end + broken = table.create_set(broken, "WrongLuaRevision", true) + end + + if not broken and metadata.broken then + if not LoadAnyway(T(1000851, "This savegame was loaded in the past with ignored errors. It may not function properly.")) then + return "saved broken" + end + end + + return GameSpecificValidateSaveMetadata(metadata, broken, missing_mods_list) +end + +--stub +function GameSpecificValidateSaveMetadata() +end + +function LoadMetadataCallback(folder, params) + local st = GetPreciseTicks() + local err, metadata = LoadMetadata(folder) + if err then return err end + + DebugPrint("Load Game:", + "\n\tlua_revision:", metadata.lua_revision, + "\n\tassets_revision:", metadata.assets_revision, + "\n") + if metadata.dlcs and #metadata.dlcs > 0 then + DebugPrint("\n\tdlcs:", table.concat(table.map(metadata.dlcs, "id"), ", "), "\n") + end + if metadata.active_mods and #metadata.active_mods > 0 then + DebugPrint("\n\tmods:", table.concat(table.map(metadata.active_mods, "id"), ", "), "\n") + end + + local broken, change_current_map + local map_name = RemapMapName(metadata.map) + config.BaseMapFolder = "" + if map_name and metadata.BaseMapNetHash then + local map_meta = MapData[map_name] + assert(map_meta) + local terrain_hash = metadata.TerrainHash + local requested_map_hash = terrain_hash or metadata.BaseMapNetHash + local map_hash = map_meta and (terrain_hash and map_meta.TerrainHash or map_meta.NetHash) + local different_map = requested_map_hash ~= map_hash + if different_map and config.TryRestoreMapVersionOnLoad then + for map_id, map_data in pairs(MapData) do + local map_data_hash = terrain_hash and map_data.TerrainHash or map_data.NetHash + if map_data_hash == requested_map_hash and (not config.CompatibilityMapTest or map_data.ForcePackOld) then + map_name = map_id + different_map = false + change_current_map = true + break + end + end + end + if different_map then + if not LoadAnyway(T(840159075107, "The game cannot be loaded because it requires a map that is not present or has a different version.")) then + return "different map" + end + broken = table.create_set(broken, "DifferentMap", true) + if not map_meta then + map_name = GetOrigMapName(map_name) + end + end + config.BaseMapFolder = GetMapFolder(map_name) + if CurrentMapFolder ~= "" then + UnmountByPath(CurrentMapFolder) + end + CurrentMapFolder = config.BaseMapFolder + local err = PreloadMap(map_name) + CurrentMapFolder = "" -- so that ChangeMap("") will not unmount the map we just mounted + if err then + return err + end + end + + local missing_mods_list = {} + local validate_error = ValidateSaveMetadata(metadata, broken, missing_mods_list) + if validate_error then + return validate_error + end + + err = GameSpecificLoadCallback(folder, metadata, params) + if err then return err end + + if change_current_map then + CurrentMap = map_name + CurrentMapFolder = GetMapFolder(map_name) + _G.mapdata = MapData[map_name] + end + + metadata.broken = metadata.broken or broken or false + if next(missing_mods_list) then + metadata.ignored_mods = metadata.ignored_mods or {} + missing_mods_list = table.filter(missing_mods_list, function(idx, mod) return not table.find(metadata.ignored_mods, "id", mod.id) end) + table.iappend(metadata.ignored_mods, missing_mods_list) + end + metadata.active_mods = GetLoadedModsSavegameData() + SavegameMeta = metadata + LoadedRealTime = RealTime() + DebugPrint("Game Loaded in", GetPreciseTicks() - st, "ms\n") + Msg("GameMetadataLoaded", metadata) +end + +function GetOrigRealTime() + local orig_real_time = LoadedRealTime and SavegameMeta and SavegameMeta.real_time + if not orig_real_time then + return RealTime() + end + return (RealTime() - LoadedRealTime) + orig_real_time +end + +MapVar("OrigLuaRev", function() + return LuaRevision +end) +MapVar("OrigAssetsRev", function() + return AssetsRevision +end) + +function OnMsg.BugReportStart(print_func) + local lua_revision = SavegameMeta and SavegameMeta.lua_revision + if lua_revision then + local supported_str = lua_revision >= config.SupportedSavegameLuaRevision and "/" or " (unsupported!) /" + print_func("Savegame Rev:", lua_revision, supported_str, SavegameMeta.assets_revision) + end + if OrigLuaRev and OrigLuaRev ~= LuaRevision then + print_func("Game Start Rev:", OrigLuaRev, OrigAssetsRev) + end + if SavegameMeta and type(SavegameMeta.broken) == "table" then + print_func("Savegame Errors:", table.concat(table.keys(SavegameMeta.broken, true), ',')) + end +end \ No newline at end of file diff --git a/CommonLua/SavegameScreenshot.lua b/CommonLua/SavegameScreenshot.lua new file mode 100644 index 0000000000000000000000000000000000000000..530de1d1f4d4ab5422c7fdb3eb8c052173058696 --- /dev/null +++ b/CommonLua/SavegameScreenshot.lua @@ -0,0 +1,77 @@ +if FirstLoad then + g_TempScreenshotFilePath = false + g_SaveScreenShotThread = false +end + +function GetSavegameScreenshotParams() + local screen_sz = UIL.GetScreenSize() + local screen_w, screen_h = screen_sz:x(), screen_sz:y() + local src = box(point20, screen_sz) + return MulDivRound(Savegame.ScreenshotHeight, src:sizex(), src:sizey()), Savegame.ScreenshotHeight, src +end + +function WaitCaptureCurrentScreenshot() + while IsValidThread(g_SaveScreenShotThread) do + WaitMsg("SaveScreenShotEnd") + end + g_SaveScreenShotThread = CreateRealTimeThread(function() + local _, file_path = WaitCaptureSavegameScreenshot(config.MemoryScreenshotSize and "memoryscreenshot/" or "AppData/") + g_TempScreenshotFilePath = file_path + if g_TempScreenshotFilePath then + ResourceManager.OnFileChanged(g_TempScreenshotFilePath) + end + WaitNextFrame(2) + Msg("SaveScreenShotEnd") + end) + while IsValidThread(g_SaveScreenShotThread) do + WaitMsg("SaveScreenShotEnd") -- Sent multiple times by the thread. + end +end + +if FirstLoad then +ScreenShotHiddenDialogs = {} +end + +function WaitCaptureSavegameScreenshot(path) + local width, height, src = GetSavegameScreenshotParams() + local _, filename, ext = SplitPath(Savegame.ScreenshotName) + local file_path = string.format("%s%s%dx%d%s", path, filename, width, height, ext) + table.change(hr, "Savegame_BackgroundBlur", { + EnablePostProcScreenBlur = 0 + }) + + -- if we're under a loading screen, take a screenshot of the scene without any UI + -- otherwise, hide just some dialogs + + table.iclear(ScreenShotHiddenDialogs) + local screenshotWithUI = config.ScreenshotsWithUI or false + if GetLoadingScreenDialog() then + screenshotWithUI = false + else + for dlg_id, dialog in pairs(Dialogs or empty_table) do + if dialog.HideInScreenshots then + dialog:SetVisible(false, true) + table.insert(ScreenShotHiddenDialogs, dialog) + end + end + end + + Msg("SaveScreenShotStart") + WaitNextFrame(2) + local err = WaitCaptureScreenshot(file_path, { + interface = screenshotWithUI, + width = width, height = height, + src = src + }) + + for dlg_id, dialog in ipairs(ScreenShotHiddenDialogs) do + dialog:SetVisible(true, true) + end + table.iclear(ScreenShotHiddenDialogs) + + if table.changed(hr, "Savegame_BackgroundBlur") then + table.restore(hr, "Savegame_BackgroundBlur") + end + Msg("SaveScreenShotEnd") + return err, file_path +end diff --git a/CommonLua/Selection.lua b/CommonLua/Selection.lua new file mode 100644 index 0000000000000000000000000000000000000000..3bf638a9e603017b095168b921cac24f57ac049a --- /dev/null +++ b/CommonLua/Selection.lua @@ -0,0 +1,320 @@ +MapVar("SelectedObj", false) +MapVar("Selection", {}) + +local find = table.find +local remove = table.remove +local IsValid = IsValid + +local function SelectionChange() + ObjModified(Selection) + Msg("SelectionChange") +end + +local function __selobj(obj, prev) + obj = IsValid(obj) and obj or false + prev = prev or SelectedObj + if prev ~= obj then + SelectedObj = obj + SetDebugObj(obj) -- make it available at the C side for debugging the selected object + --@@@msg SelectedObjChange,object, previous- fired when the user changes the selected object. + Msg("SelectedObjChange", obj, prev) + if SelectedObj == obj then + if prev then + PlayFX("SelectObj", "end", prev) + end + if obj then + PlayFX("SelectObj", "start", obj) + end + end + end +end + +local function __add(obj) + if not IsValid(obj) or find(Selection, obj) then + return + end + Selection[#Selection + 1] = obj + PlayFX("Select", "start", obj) + Msg("SelectionAdded", obj) + DelayedCall(0, SelectionChange) +end + +local function __remove(obj, idx) + idx = idx or find(Selection, obj) + if not idx then + return + end + remove(Selection, idx) + PlayFX("Select", "end", obj) + Msg("SelectionRemoved", obj) + DelayedCall(0, SelectionChange) +end + +function SelectionAdd(obj) + if IsValid(obj) then + __add(obj) + elseif type(obj) == "table" then + for i = 1, #obj do + __add(obj[i]) + end + end + SelectionValidate(SelectedObj) +end + +function SelectionRemove(obj) + __remove(obj) + if type(obj) == "table" then + for i = 1, #obj do + __remove(obj[i]) + end + end + SelectionValidate(SelectedObj) +end + +function IsInSelection(obj) + return obj == SelectedObj or find(Selection, obj) +end + +function SelectionSet(list, obj) + list = list or {} + assert(not IsValid(list), "SelectionSet requires an array of objects") + if type(list) ~= "table" then + return + end + for i = 1, #list do + __add(list[i]) + end + for i = #Selection, 1, -1 do + local obj = Selection[i] + if not find(list, obj) then + __remove(obj, i) + end + end + SelectionValidate(obj or SelectedObj) +end + +function SelectionValidate(obj) + if not Selection then return end + local Selection = Selection + for i = #Selection, 1, -1 do + if not IsValid(Selection[i]) then + __remove(Selection[i], i) + end + end + SelectionSubSel(obj or SelectedObj) +end + +function SelectionSubSel(obj) + obj = IsValid(obj) and find(Selection, obj) and obj or false + __selobj(obj or #Selection == 1 and Selection[1]) +end + +--[[@@@ +Select object in the game. Clear the current selection if no object is passed. +@function void Selection@SelectObj(object obj) +--]] + +function SelectObj(obj) + obj = IsValid(obj) and obj or false + for i = #Selection, 1, -1 do + local o = Selection[i] + if o ~= obj then + __remove(o, i) + end + end + local prev = SelectedObj --__add kills this + __add(obj) + __selobj(obj, prev) +end + +--[[@@@ +Select object in the game and points the camera towards it. +@function void Selection@ViewAndSelectObject(object obj) +--]] + +function ViewAndSelectObject(obj) + SelectObj(obj) + ViewObject(obj) +end + +--[[@@@ +Gets the parent or another associated selectable object or the object itself +@function object Selection@SelectionPropagate(object obj) +@param object obj +--]] + +function SelectionPropagate(obj) + local topmost = GetTopmostSelectionNode(obj) + local prev = topmost + while IsValid(topmost) do + topmost = topmost:SelectionPropagate() or topmost + if prev == topmost then + break + end + prev = topmost + end + return prev +end + +AutoResolveMethods.SelectionPropagate = "or" + +-- game-specific selection logic (lowest priority) +local sel_tbl = {} +local sel_idx = 0 +function SelectFromTerrainPoint(pt) + Msg("SelectFromTerrainPoint", pt, sel_tbl) + if #sel_tbl > 0 then + sel_idx = (sel_idx + 1) % #sel_tbl + local obj = sel_tbl[sel_idx + 1] + sel_tbl = {} + return obj + end +end + +--[[@@@ +Gets the object that would be selected on the current mouse cursor position by default. +Also returns the original selected object without selection propagation. +@function object, object Selection@SelectionMouseObj() +--]] +function SelectionMouseObj() + local solid, transparent = GetPreciseCursorObj() + local obj = transparent or solid or SelectFromTerrainPoint(GetTerrainCursor()) or GetTerrainCursorObjSel() + return SelectionPropagate(obj) +end + +--[[@@@ +Gets the object that would be selected on the current gamepad position by default. +Also returns the original selected object without selection propagation. +@function object, object Selection@SelectionGamepadObj() +--]] +function SelectionGamepadObj(gamepad_pos) + local gamepad_pos = gamepad_pos or UIL.GetScreenSize() / 2 + local obj = GetTerrainCursorObjSel(gamepad_pos) + + if obj then + return SelectionPropagate(obj) + end + + if config.GamepadSearchRadius then + local xpos = GetTerrainCursorXY(gamepad_pos) + if not xpos or xpos == InvalidPos() or not terrain.IsPointInBounds(xpos) then + return + end + + local obj = MapFindNearest(xpos, xpos, config.GamepadSearchRadius, "CObject", const.efSelectable) + if obj then + return SelectionPropagate(obj) + end + end +end + +--Determines the selection class of an object. +function GetSelectionClass(obj) + if not obj then return end + + if IsKindOf(obj, "PropertyObject") and obj:HasMember("SelectionClass") then + return obj.SelectionClass + else + --return obj.class + end +end + +function GatherObjectsOnScreen(obj, selection_class) + obj = obj or SelectedObj + if not IsValid(obj) then return end + + selection_class = selection_class or GetSelectionClass(obj) + if not selection_class then return end + + local result = GatherObjectsInScreenRect(point20, point(GetResolution()), selection_class) + if not find(result, obj) then + table.insert(result, obj) + end + + return result +end + +function ScreenRectToTerrainPoints(start_pt, end_pt) + local start_x, start_y = start_pt:xy() + local end_x, end_y = end_pt:xy() + + --screen space + local ss_left = Min(start_x, end_x) + local ss_right = Max(start_x, end_x) + local ss_top = Min(start_y, end_y) + local ss_bottom = Max(start_y, end_y) + + --world space + local top_left = GetTerrainCursorXY(ss_left, ss_top) + local top_right = GetTerrainCursorXY(ss_right, ss_top) + local bottom_left = GetTerrainCursorXY(ss_right, ss_bottom) + local bottom_right = GetTerrainCursorXY(ss_left, ss_bottom) + + return top_left, top_right, bottom_left, bottom_right +end + +function GatherObjectsInScreenRect(start_pos, end_pos, selection_class, max_step, enum_flags, filter_func) + enum_flags = enum_flags or const.efSelectable + + local rect = Extend(empty_box, ScreenRectToTerrainPoints(start_pos, end_pos)):grow(max_step or 0) + local screen_rect = boxdiag(start_pos, end_pos) + + local function filter(obj) + local _, pos = GameToScreen(obj) + if not screen_rect:Point2DInside(pos) then return false end + if not filter_func then return true end + return filter_func(obj) + end + + return MapGet(rect, selection_class or "Object", enum_flags, filter) or {} +end + +function GatherObjectsInRect(top_left, top_right, bottom_left, bottom_right, selection_class, enum_flags, filter_func) + enum_flags = enum_flags or const.efSelectable + + local left = Min(top_left:x(), top_right:x(), bottom_left:x(), bottom_right:x()) + local right = Max(top_left:x(), top_right:x(), bottom_left:x(), bottom_right:x()) + local top = Min(top_left:y(), top_right:y(), bottom_left:y(), bottom_right:y()) + local bottom = Max(top_left:y(), top_right:y(), bottom_left:y(), bottom_right:y()) + + local max_step = 12 * guim --PATH_EXEC_STEP + top = top - max_step + left = left - max_step + bottom = bottom + max_step + right = right + max_step + + local rect = box(left, top, right, bottom) + local function IsInsideTrapeze(pt) + return + IsInsideTriangle(pt, top_left, bottom_right, bottom_left) or + IsInsideTriangle(pt, top_left, bottom_right, top_right) + end + + local function filter(obj) + local pos = obj:GetVisualPos() + if pos:z() ~= terrain.GetHeight(pos:x(), pos:y()) then + local _, p = GameToScreen(pos) + pos = GetTerrainCursorXY(p) + end + if not IsInsideTrapeze(pos) then return false end + if filter_func then + return filter_func(obj) + end + return true + end + + return MapGet(rect, selection_class or "Object", enum_flags, filter) or {} +end + +function OnMsg.GatherFXActions(list) + list[#list + 1] = "Select" + list[#list + 1] = "SelectObj" +end + +function OnMsg.BugReportStart(print_func) + print_func("\nSelected Obj:", SelectedObj and ValueToStr(SelectedObj) or "false") + local code = GetObjRefCode(SelectedObj) + if code then + print_func("Paste in the console: SelectObj(", code, ")\n") + end +end diff --git a/CommonLua/SetpieceEditor.lua b/CommonLua/SetpieceEditor.lua new file mode 100644 index 0000000000000000000000000000000000000000..378be27b910de90e6d82d2ba8a31a286810e5cc1 --- /dev/null +++ b/CommonLua/SetpieceEditor.lua @@ -0,0 +1,108 @@ +if Platform.ged then return end + +if FirstLoad then + SetpieceDebugState = {} + SetpieceLastStatement = false + SetpieceSelectedStatement = false + SetpieceVariableRefs = {} +end + +-- Replace the GedEditorView functions of Setpiece statements to allow statements highlighting, depending on data +function OnMsg.ClassesPostprocess() + ClassDescendants("PrgStatement", function(name, class) + if class.StatementTag == "Setpiece" then + local old_fn = class.GetEditorView + class.GetEditorView = function(self) + local state = SetpieceDebugState[self] + local color_tag = + state == "running" and "" or + state == "completed" and "" or + not next(SetpieceDebugState) and + (SetpieceVariableRefs[self] and "" or + not next(SetpieceVariableRefs) and SetpieceSelectedStatement and SetpieceSelectedStatement.class == self.class and "") + or "" + return Untranslated(color_tag .. (old_fn and old_fn(self) or self.EditorView)) + end + end + end) +end + + +----- Highlight statements in Ged as they are being executed + +function OnMsg.OnPrgLine(lineinfo) + local setpiece = Setpieces[lineinfo.id] + local statement = TreeNodeByPath(setpiece, unpack_params(lineinfo)) + if statement then -- can mismatch if the preset and the generated code do not match + SetpieceLastStatement = statement + SetpieceDebugState[statement] = IsKindOf(statement, "PrgSetpieceCommand") and "running" or "completed" + end + ObjModified(setpiece) +end + +function OnMsg.SetpieceCommandCompleted(state, thread, statement) + SetpieceDebugState[statement] = "completed" + ObjModified(state.setpiece) +end + +function OnMsg.SetpieceEndExecution(setpiece) + setpiece:ForEachSubObject("PrgStatement", function(obj) + SetpieceDebugState[obj] = nil + end) + ObjModified(setpiece) +end + + +----- Highlight setpiece statements with a matching actor upon selection + +function OnMsg.GedNotify(obj, method, selected, ged) + if IsKindOf(obj, "PrgStatement") and obj.StatementTag == "Setpiece" and method == "OnEditorSelect" then + if not selected then + SetpieceSelectedStatement = false + SetpieceVariableRefs = {} + elseif SetpieceSelectedStatement ~= obj then + SetpieceSelectedStatement = obj + UpdateSetpieceVariableRefs() + end + end +end + +function OnMsg.GedPropertyEdited(ged_id, obj, prop_id, old_value) + if IsKindOf(obj, "PrgStatement") and obj:GetPropertyMetadata(prop_id).variable then + UpdateSetpieceVariableRefs() + end +end + +function OnMsg.GedNotify(obj, method, ...) + if IsKindOf(obj, "PrgStatement") and (method == "OnEditorDelete" or method == "OnAfterEditorNew") then + UpdateSetpieceVariableRefs() + end +end + +function UpdateSetpieceVariableRefs() + local statement = SetpieceSelectedStatement + if not statement then return end + + local variables = {} + for _, prop_meta in ipairs(statement:GetProperties()) do + if prop_meta.variable then + local var_name = statement:GetProperty(prop_meta.id) + if var_name ~= "" then + variables[var_name] = true + table.insert(variables, var_name) + end + end + end + + SetpieceVariableRefs = {} + local setpiece = GetParentTableOfKind(statement, "SetpiecePrg") + setpiece:ForEachSubObject("PrgStatement", function(statement) + for _, prop_meta in ipairs(statement:GetProperties()) do + if prop_meta.variable and variables[statement:GetProperty(prop_meta.id)] then + SetpieceVariableRefs[statement] = true + end + end + end) + + ObjModified(setpiece) +end diff --git a/CommonLua/SetpiecePrg.lua b/CommonLua/SetpiecePrg.lua new file mode 100644 index 0000000000000000000000000000000000000000..a868f5eaf91f8ffa62b0a2024c71b2154f3982f4 --- /dev/null +++ b/CommonLua/SetpiecePrg.lua @@ -0,0 +1,813 @@ +if Platform.ged then return end + +-- override in the project, called from a game-time thread +function OnSetpieceStarted(setpiece) end +function OnSetpieceEnded(setpiece) end + +-- low level function, starts the setpiece code without UI & without executing completion effects +function StartSetpiece(id, test_mode, seed, ...) + local setpiece = Setpieces[id] + assert(setpiece, string.format("Missing setpiece '%s'", id)) + assert(setpiece.Map == "" or setpiece.Map == CurrentMap, string.format("Wrong map for setpiece '%s' - must be '%s'", id, setpiece.Map)) + + local state = SetpieceState:new{ test_mode = test_mode, setpiece = setpiece } + CreateGameTimeThread(function(seed, state, ...) + local ok = sprocall(SetpiecePrgs[id], seed, state, ...) + if not ok then + print("Setpiece", id, "crashed!") + state.commands = {} -- force completion + Msg(state) + end + state:WaitCompletion() + RegisterSetpieceActors(state.real_actors, false) + Msg("SetpieceEndExecution", state.setpiece, state) + end, seed, state, ...) + return state +end + +function EndSetpiece(id) + local setpiece = Setpieces[id] + ExecuteEffectList(setpiece.Effects, setpiece, "setpiece") + OnSetpieceEnded(setpiece) + Msg("SetpieceDialogClosed") +end + +function SetpieceRecord(setpiece, root, prop_id, ged, btn_param) + local directory = setpiece.RecordDirectory + if not directory:ends_with("\\") and not directory:ends_with("/") then + directory = directory .. "/" + end + local finalPath = directory .. (setpiece.id or "UnnamedSetpiece") .. "-" .. RealTime() .. "/" + AsyncCreatePath(finalPath) + + CreateRealTimeThread(function() + local prev_video_preset = EngineOptions.VideoPreset + if setpiece.ForceMaxVideoSettings then + MapForEach(true, CObject.SetForcedLOD, 0) + ApplyVideoPreset("Ultra") + WaitNextFrame() + end + + -- Start recording when the Setpiece starts + Msg("RecordingReady") + WaitMsg("SetpieceStarted") + local setpiece = setpiece + local fname = finalPath .. setpiece.RecordFileName + CreateRealTimeThread(RecordMovie, fname, 0, setpiece.RecordFPS, nil, setpiece.RecordQuality, setpiece.RecordMotionBlur, function() return setpiece.setpiece_ended end) + + WaitMsg("RecordingStarted") + GedObjectModified(setpiece) + + WaitMsg("SetpieceEnding") + setpiece.setpiece_ended = true + Sleep(100) + setpiece.setpiece_ended = false + + if setpiece.ForceMaxVideoSettings then + MapForEach(true, CObject.SetForcedLOD, -1) + ApplyVideoPreset(prev_video_preset) + WaitNextFrame() + end + end) + + -- Play Setpiece + WaitMsg("RecordingReady") + setpiece:Test(ged) + ObjModified(setpiece) + GedObjectModified(setpiece) +end + + +----- SetpiecePrg + +DefineClass.SetpiecePrg = { + __parents = { "PrgPreset" }, + + properties = { + { category = "Map", id = "NotOnMap", editor = "help", default = "", + buttons = { { name = "Switch map", func = "SwitchMap" } }, + help = "Can't display markers - the setpiece map isn't loaded.", + no_edit = function(self) return self.Map == "" or self.Map == CurrentMap or IsChangingMap() end, + }, + { category = "Map", id = "Map", editor = "choice", default = "", items = function() return table.keys2(MapData, true, "") end, }, + { category = "Map", id = "PlaceMarkers", editor = "buttons", default = "", + buttons = function() return table.map(ClassLeafDescendantsList("SetpieceMarker"), function(class) + return { name = "Place " .. g_Classes[class].DisplayName, func = SetpieceMarkerPlaceButton, param = class } + end) end, + no_edit = function(self) return self.Map ~= "" and self.Map ~= CurrentMap end, + }, + { category = "Misc", id = "TakePlayerControl", name = "Take player control", editor = "bool", default = true, }, + { category = "Misc", id = "RestoreCamera", name = "Restore camera", editor = "bool", default = false, help = "Restore the camera to where it was after the setpiece finishes.", }, + { category = "Misc", id = "Effects", name = "Completion effects", editor = "nested_list", default = false, base_class = "Effect", all_descendants = true }, + { category = "Testing", id = "FastForward", name = "Fast forward to", editor = "number", default = 0, scale = "sec", + help = "Allows 'skipping' a part of the setpiece for testing purposes by playing a part of it on very high speed.", + dont_save = true, + }, + { category = "Testing", id = "TestSpeed", name = "Test speed", editor = "number", slider = true, min = 5, max = 200, step = 5, default = 100, + buttons = { { name = "Test", func = function(self, root, prop_id, ged) self:Test(ged) end } }, + dont_save = true, + }, + { category = "Recording", id = "ForceMaxVideoSettings", name = "Force max video settings", editor = "bool", default = true }, + { category = "Recording", id = "RecordFPS", name = "FPS", editor = "number", default = 30, }, + { category = "Recording", id = "RecordDirectory", name = "Directory", editor = "text", default = "AppData/Recordings/", }, + { category = "Recording", id = "RecordFileName", name = "File name", editor = "text", default = "setPieceRecording.png", }, + { category = "Recording", id = "RecordQuality", name = "Quality", editor = "choice", default = 64, + items = {{name = "Fastest", value = 1}, {name = "Fast", value = 4}, {name = "High", value = 64}} + }, + { category = "Recording", id = "RecordMotionBlur", name = "Motion Blur", editor = "choice", default = 50, + items = {{name = "No motion blur", value = 0}, {name = "Standard motion blur", value = 50}, {name = "Extra motion blur", value = 100}} + }, + { category = "Recording", id = "RecordButtons", editor = "buttons", default = "", + buttons = { + { name = "Record", func = SetpieceRecord, is_hidden = function(self) return IsSetpiecePlaying() or IsEditorActive() or #self == 0 or IsRecording() end }, + { + name = "Stop", + func = function(self) + CreateRealTimeThread(function() + self.setpiece_ended = true + Sleep(100) + SkipAnySetpieces() + end) + end, + is_hidden = function(self) return not IsRecording() end + } + }, + }, + }, + + EditorCustomActions = { + { Toolbar = "main", Name = "Test (Ctrl-T)", FuncName = "Test", Icon = "CommonAssets/UI/Ged/play.tga", Shortcut = "Ctrl-T", }, + { Toolbar = "main", Name = "Toggle black strips (Alt-T)", FuncName = "GedPrgPresetToggleStrips", Icon = "CommonAssets/UI/Ged/explorer.tga", Shortcut = "Alt-T", IsToggledFuncName = "GedPrgPresetBlackStripsVisible" }, + }, + + EditorMenubarName = "Setpieces", + EditorMenubar = "Scripting", + EditorShortcut = "Ctrl-Alt-S", + EditorMenubarSortKey = "3050", + EditorIcon = "CommonAssets/UI/Icons/film.png", + Documentation = "Creates a skippable cutscene that can be triggered during gameplay.\n\nSet-pieces are composed of commands, that are and are executed in parallel, unless their 'Wait completion' property is turned on.\n\nMost commands require named to be created beforehand.\n\nThe setpiece is associated with a specific game map, and Markers created on that map are used to designate locations on the map, as well as spawn setpiece actors or particle effects.", + + Params = { "TriggerUnits" }, + StatementTags = { "Basics", "Setpiece" }, + FuncTable = "SetpiecePrgs", + GlobalMap = "Setpieces", +} + +function SetpiecePrg:GetParamString() + return next(self.Params) and "state, " .. table.concat(self.Params, ", ") or "state" +end + +function SetpiecePrg:OnEditorNew() + self.Map = CurrentMap +end + +if FirstLoad then + g_LastSelectedSetpiece = false +end + +function SetpiecePrg:OnEditorSelect(selected, ged) + g_LastSelectedSetpiece = selected and self or g_LastSelectedSetpiece +end + +function OnMsg.ChangeMapDone() + if g_LastSelectedSetpiece then + g_LastSelectedSetpiece:EditorData().prop_cache = nil + ObjModified(g_LastSelectedSetpiece) -- update list of markers displayed via virtual properties + end +end + +function SetpiecePrg:SwitchMap(selected, prop_id, ged) + if not MapData[self.Map] then + ged:ShowMessage("Error", string.format("Can't find map '%s'", self.Map)) + return + end + CreateRealTimeThread(function() + ChangeMap(self.Map) + self:EditorData().prop_cache = nil + ObjModified(self) + end) +end + +function SetpiecePrg:GetProperties() + local props = self:EditorData().prop_cache + if not props then + props = table.copy(PropertyObject.GetProperties(self), "deep") + local markers + local referenced_markers_map = {} + + -- Gather all markers referenced in this setpiece + for idx, statement in ipairs(self) do + if statement.Marker then + referenced_markers_map[statement.Marker] = true + elseif statement.Waypoints then + for _, pos_marker in ipairs(statement.Waypoints) do + if pos_marker then + referenced_markers_map[pos_marker] = true + end + end + end + end + + if GetMap() ~= "" then + -- Get all markers that have a name and are referenced by this setpiece + markers = MapGet("map", "SetpieceMarker", function(obj) + return obj.Name and referenced_markers_map[obj.Name] + end) + end + + if markers then + local marker_props = {} + for _, marker in ipairs(markers) do + table.insert(marker_props, { + id = marker.Name ~= "" and marker.Name or "[Unnamed]", + editor = "text", + category = "Map", + default = marker.DisplayName, + read_only = true, + dont_save = true, + buttons = {{ name = "View", func = function(...) return SetpieceViewMarker(...) end, param = marker.Name }} + }) + end + table.sortby_field(marker_props, "id") + local start_idx = table.find(props, "id", "NotOnMap") + for i = 1, #marker_props do + table.insert(props, i + start_idx - 1, marker_props[i]) + end + end + + self:EditorData().prop_cache = props + end + return props +end + +function SetpiecePrg:OnPreSave(user_requested) + Msg("SetpieceEndExecution", self) +end + +function SetpiecePrg:SaveAll(...) + if IsEditorSaving() then + WaitMsg("SaveMapDone") + elseif EditorMapDirty then + XEditorSaveMap() + end + + -- do not trigger Lua reload, we will reload only the relevant generated files + SuspendFileSystemChanged("save_setpiece") + local saved_presets = Preset.SaveAll(self, ...) + for preset, path in pairs(saved_presets) do + dofile(preset:GetCompanionFilesList(path)[true]) + end + Sleep(250) -- give time to the file changed notification to arrive + FileSystemChangedFiles = false + ResumeFileSystemChanged("save_setpiece") +end + +function GedPrgPresetBlackStripsVisible() + return not not GetDialog("XMovieBlackBars") +end + +function GedPrgPresetToggleStrips(ged) + if GedPrgPresetBlackStripsVisible() then + CloseDialog("XMovieBlackBars") + else + OpenDialog("XMovieBlackBars") + end +end + +function OnMsg.GedClosing(ged_id) + local app = GedConnections[ged_id] + if app and app.context and app.context.PresetClass == "SetpiecePrg" then + CloseDialog("XMovieBlackBars") + end +end + +function SetpiecePrg:ChangeMap(ged) + if self.Map ~= "" and self.Map ~= CurrentMap then + local result + if ged then + result = ged:WaitQuestion("Change Map", "This setpiece is set on another map.\n\nChange the map and continue?", "Yes", "No") + else + result = WaitQuestion(terminal.desktop, T(946126153891, "Change Map"), T(354774024588, "This setpiece is set on another map.\n\nChange the map and continue?"), T(1138, "Yes"), T(1139, "No")) + end + if result ~= "ok" then + return false + end + ChangeMap(self.Map) + end + return true +end + +function SetpiecePrg:Test(ged) + local in_mod_editor = ged and ged.app_template == "ModEditor" + + if #self == 0 then + ged:ShowMessage("Warning", "The setpiece has no commands.") + return + end + + if IsEditorSaving() then + WaitMsg("SaveMapDone") + end + + if not self:ChangeMap(ged) then + return + end + + local dirty + ForEachPreset("SetpiecePrg", function(preset) + dirty = dirty or preset:IsDirty() + if dirty then + return "break" + end + end) + if dirty then + local result + if ged then + result = ged:WaitQuestion("Changes Not Saved", "Changes need to be saved for testing.\n\nSave and continue?", "Yes", "No") + else + result = WaitQuestion(terminal.desktop, T(610687239101, "Changes Not Saved"), T(565937963985, "Changes need to be saved for testing.\n\nSave and continue?"), T(1138, "Yes"), T(1139, "No")) + end + if result ~= "ok" then + return false + end + if in_mod_editor then + self:PostSave() + if self.mod:UpdateCode() then + ReloadLua() + end + else + self:SaveAll() -- will reload the generated Lua files + end + elseif EditorMapDirty then + XEditorSaveMap() + end + + local in_editor = IsEditorActive() + if in_editor then + -- keep the editor camera position when testing + local pos, lookat = GetCamera() + if in_mod_editor then + editor.StopModdingEditor() + else + EditorDeactivate() + end + local _, _, camtype = GetCamera() + _G["camera"..camtype].SetCamera(pos, lookat) + end + if not Game then + NewGame() + end + Resume() + + -- setup game speed + local speed = GetTimeFactor() + local fast_forward_time = self.FastForward + local test_factor = MulDivRound(speed, self.TestSpeed, 100) + local time_thread = CreateGameTimeThread(function() + WaitMsg("SetpieceStarted") + if fast_forward_time > 0 then + SetTimeFactor(const.MaxTimeFactor) + Sleep(fast_forward_time) + end + SetTimeFactor(test_factor) + end) + + if GedPrgPresetBlackStripsVisible() then + GedPrgPresetToggleStrips() + end + + DiagnosticMessageSuspended = true + local state + if self.TakePlayerControl then + local dlg = OpenDialog("XSetpieceDlg", false, { setpiece = self.id, testMode = true, }) + WaitMsg(dlg) + state = dlg.setpieceInstance + else -- invoke the same messages as the dialog, used for Setpiece recording + Msg("SetpieceStarted", self) + state = StartSetpiece(self.id, true, AsyncRand()) + WaitMsg("SetpieceEndExecution") + Msg("SetpieceEnding", self) + Msg("SetpieceEnded", self) + end + DiagnosticMessageSuspended = false + + DeleteThread(time_thread) + SetTimeFactor(speed) + + for _, actor in ipairs(state.real_actors or empty_table) do + if IsValid(actor) then actor:SetVisible(true) end + end + for _, actor in ipairs(state.test_actors or empty_table) do + if IsValid(actor) then actor:delete() end + end + RegisterSetpieceActors(state.test_actors, false) + + if in_editor and not in_mod_editor then + EditorActivate() + end +end + + +----- Markers + +DefineClass.SetpieceMarker = { + __parents = { "EditorMarker", "StripCObjectProperties", "StripComponentAttachProperties", }, + properties = { + { id = "Setpiece", editor = "preset_id", default = "", preset_class = "SetpiecePrg", read_only = true, }, + { id = "Name", editor = "text", default = "", }, + + -- save angle and axis in map + { id = "Angle", editor = "number", default = 0, no_edit = true, }, + { id = "Axis", editor = "point", default = axis_z, no_edit = true, }, + }, + editor_text_offset = point(0, 0, 320*guic), + editor_text_member = "Name", +} + +function SetpieceMarker:OnEditorSetProperty(prop_id, old_value, ged) + if prop_id == "Name" then + self:OnNameChanged(old_value, ged) + end +end + +function SetpieceMarker:OnNameChanged(old_name, ged) + local new_name = self:GenerateUniqueName(self.Name) + if self.Name ~= new_name then + self.Name = new_name + GedForceUpdateObject(self) + ObjModified(self) + end + self:EditorTextUpdate(true) + + self:UpdateSetpieceMarkersList("rename", old_name, ged) +end + +-- If reason is "delete" - the user has deleted the marker +-- If old_name is passed - the marker has been renamed and old_name is the previous name +-- ged is passed only on rename +function SetpieceMarker:UpdateSetpieceMarkersList(reason, old_name, ged) + -- The marker could have been renamed + local marker_name = old_name and old_name or self.Name + local referencing_setpieces = {} + local setpieces_str + + -- Invalidate the cache of all setpieces that reference this marker + for name, setpiece in pairs(Setpieces) do + for statement_idx, statement in ipairs(setpiece) do + if statement.Marker and statement.Marker == marker_name then + setpiece:EditorData().prop_cache = nil + ObjModified(setpiece) + + referencing_setpieces[setpiece.id] = statement_idx + setpieces_str = setpieces_str and (setpieces_str .. ", " .. setpiece.id) or setpiece.id + elseif statement.Waypoints then + for idx, pos_marker in ipairs(statement.Waypoints) do + if pos_marker and pos_marker == marker_name then + setpiece:EditorData().prop_cache = nil + ObjModified(setpiece) + + referencing_setpieces[setpiece.id] = statement_idx + setpieces_str = setpieces_str and (setpieces_str .. ", " .. setpiece.id) or setpiece.id + end + end + end + end + end + + if reason == "delete" or reason == "rename" then + -- otherwise, it is editor undo or applying a map patch + + -- If the marker was renamed, ask the user if we should renamed it in all referencing setpieces + if old_name and setpieces_str then + CreateRealTimeThread(function () + local message = string.format("Rename marker '%s' in all referencing setpieces: %s? \nYou have to save them manually.", marker_name, setpieces_str) + if ged and ged:WaitQuestion("Warning", message) == "ok" then + + for setpiece_id, statement_idx in pairs(referencing_setpieces) do + local setpiece = Setpieces[setpiece_id] + local statement = setpiece[statement_idx] + + if statement.Marker and statement.Marker == marker_name then + -- Rename + statement.Marker = self.Name + + setpiece:EditorData().prop_cache = nil + ObjModified(setpiece) + + elseif statement.Waypoints then + for idx, pos_marker in ipairs(statement.Waypoints) do + if pos_marker and pos_marker == marker_name then + -- Rename + statement.Waypoints[idx] = self.Name + + setpiece:EditorData().prop_cache = nil + ObjModified(setpiece) + end + end + end + end + + end + end) + elseif reason == "delete" and setpieces_str then + CreateRealTimeThread(function () + local message = string.format("Marker '%s' is referenced in setpieces: %s.\nDeleting it will create errors in those setpieces.", marker_name, setpieces_str) + WaitMessage(terminal.desktop, Untranslated("Warning"), Untranslated(message)) + end) + end + end +end + +SetpieceMarker.EditorCallbackPlace = SetpieceMarker.OnNameChanged +SetpieceMarker.EditorCallbackClone = SetpieceMarker.OnNameChanged + +function SetpieceMarker:EditorCallbackDelete(reason) + self:UpdateSetpieceMarkersList(reason or "delete") +end + +function SetpieceMarker:GenerateUniqueName(name) + local used_names = {} + MapForEach("map", "SetpieceMarker", function(obj) + if obj ~= self then used_names[obj.Name] = true end + end) + if not used_names[self.Name] then + return self.Name + end + + local new + local n = 0 + local id1, n1 = name:match("(.*)_(%d+)$") + if id1 and n1 then + name, n = id1, tonumber(n1) + end + repeat + n = n + 1 + new = string.format("%s_%02d", name, n) + until not used_names[new] + return new +end + +function SetpieceMarker:SetActorsPosOrient(actors, duration, speed_change, set_orient) + local ptCenter = GetWeightPos(actors) + if not ptCenter:IsValidZ() then + ptCenter = ptCenter:SetTerrainZ() + end + local base_angle = #actors > 0 and actors[1]:GetAngle() + for _, actor in ipairs(actors) do + local pos = actor:GetVisualPos() + local offset = Rotate(pos - ptCenter, self:GetAngle() - base_angle) + local dest = self:GetPos() + offset + local anim_duration = duration or actor:GetAnimDuration() + if not speed_change or speed_change == 0 then + actor:SetAcceleration(0) + else + local speed = MulDivRound(pos:Dist(dest), 1000, anim_duration) + local acc = actor:GetAccelerationAndFinalSpeed(dest, Max(0, speed - speed_change / 2), anim_duration) + actor:SetAcceleration(acc) + end + actor:SetPos(dest, anim_duration) + if set_orient then + actor:SetAxisAngle(self:GetAxis(), self:GetAngle() + actor:GetAngle() - base_angle, anim_duration) + end + end +end + +function SetpieceMarker:GetActorLocations(actors) + local pts = {} + local pos = self:GetPos() + local count = #actors + if count == 1 then + pts[1] = pos + elseif count > 1 then + local ptCenter = GetWeightPos(actors) + local angle = self:GetAngle() - actors[1]:GetAngle() + for i = 1, count do + pts[i] = pos + Rotate(actors[i]:GetVisualPos() - ptCenter, angle):SetZ(0) + end + end + return pts +end + +OnMsg.ValidateMap = ValidateGameObjectProperties("SetpieceMarker") + +DefineClass.SetpiecePosMarker = { + __parents = { "SetpieceMarker" }, + DisplayName = "Pos", +} + +DefineClass.SetpieceSpawnMarkerBase = { + __parents = { "SetpieceMarker" }, + DisplayName = "Pos", +} + +DefineClass.SetpieceParticleSpawnMarker = { + __parents = { "SetpieceSpawnMarkerBase" }, + properties = { + { id = "Particles", category = "Particles", default = "", editor = "combo", items = ParticlesComboItems, buttons = {{name = "Test", func = "TestParticles"}, {name = "Edit", func = "ActionEditParticles"}}}, + { id = "PartScale", category = "Particles", default = 100, editor = "number", slider = true, min = 50, max = 200, name = "Scale" }, + }, + DisplayName = "Particles", +} + +function SetpieceParticleSpawnMarker:SpawnObjects() + local obj = PlaceParticles(self.Particles) + obj:SetScale(self.PartScale) + return { obj } +end + +function TestParticles(ged, marker, prop_id) + local obj = PlaceParticles(marker.Particles) + if not obj then + return + end + + marker:Attach(obj) + marker:ChangeEntity("InvisibleObject") + obj:SetScale(marker.PartScale) + + CreateRealTimeThread(function(obj) + Sleep(1500) + if IsValid(obj) then + StopParticles(obj, true) + DoneObject(obj) + end + if IsValid(marker) then + marker:ChangeEntity(SetpieceParticleSpawnMarker.entity) + end + end, obj) +end + +-- you may redefine the class for your project; it needs to inherit "SetpieceSpawnMarkerBase", requires DisplayName and SpawnObjects() +DefineClass.SetpieceSpawnMarker = { + __parents = { "SetpieceSpawnMarkerBase" }, + properties = { + { id = "SpawnClass", name = "Spawn class", editor = "choice", default = "", + items = ClassDescendantsCombo("CObject", false, function(name, class) return IsValidEntity(class:GetEntity()) end), }, + { id = "_", editor = "help", default = false, help = "Spawned object properties:", }, + }, + DisplayName = "Spawn", + prop_cache = false, +} + +function SetpieceSpawnMarker:GetProperties() + local props = self.prop_cache + if not props then + props = table.copy(PropertyObject.GetProperties(self), "deep") + local class = g_Classes[self.SpawnClass] + if class then + local spawned_props = table.copy(class:GetProperties(), "deep") + for _, prop in ipairs(spawned_props) do + local idx = table.find(props, "id", prop.id) + if idx then table.remove(props, idx) end + end + table.iappend(props, spawned_props) + end + if self ~= SetpieceSpawnMarker then + self.prop_cache = props + end + end + return props +end + +function SetpieceSpawnMarker:SetSpawnClass(class) + self.SpawnClass = class + self.prop_cache = nil + + class = g_Classes[class] or SetpieceSpawnMarker + self:ChangeEntity(class:GetEntity()) + + self.editor_text_offset = point(0, 0, self:GetEntityBBox():maxz() + guim / 2) + DoneObject(self.editor_text_obj) + self:EditorTextUpdate(true) +end + +function SetpieceSpawnMarker:SpawnObjects() + return { self:Clone(self.SpawnClass) } +end + + +----- Utilities for marker properties, including a set of Place/View buttons + +function SetpieceMarkersCombo(class) + return function(self) + local setpiece = GetParentTableOfKind(self, "SetpiecePrg") + local markers = {""} + if GetMap() ~= "" then + MapForEach("map", class, function(obj, setpiece_id, markers) + if obj.Name then + markers[#markers + 1] = obj.Name + end + end, setpiece.id, markers) + end + table.sort(markers) + return markers + end +end + +function SetpieceMarkerByName(name, check) + if not name or name == "" then return false end + local marker = MapGetFirst("map", "SetpieceMarker", function(obj) return obj.Name == name end) + if (AreModdingToolsActive() or Platform.developer) and check and not marker then + CreateMessageBox(nil, Untranslated("Error"), Untranslated(string.format("Spawn marker '%s' is missing", name))) + end + return marker +end + +local function find_previous_prop_meta(obj, prop_id) + local prev + for _, prop_meta in ipairs(obj:GetProperties()) do + if prop_meta.id == prop_id then break end + prev = prop_meta + end + return prev +end + +function SetpieceMarkerPlaceButton(obj, root, prop_id, ged, btn_param) + -- handle adding a waypoint to the 'string_list' Waypoints prop from the buttons prop below Waypoints + local prop_meta = obj:GetPropertyMetadata(prop_id) + if prop_meta.editor == "buttons" then + prop_meta = find_previous_prop_meta(obj, prop_id) + prop_id = prop_meta.id + end + + local setpiece = GetParentTableOfKind(obj, "SetpiecePrg") or obj + if not setpiece:ChangeMap(ged) then + return + end + + local name = obj:HasMember("AssignTo") and obj.AssignTo or "" + if name == "" then + name = ged:WaitUserInput("Enter marker name") + if not name then return end + if obj:HasMember("AssignTo") and obj.AssignTo == "" then + obj.AssignTo = name + end + end + + local in_mod_editor = ged and ged.app_template == "ModEditor" + if in_mod_editor then + editor.StartModdingEditor(obj, obj.Map) + else + EditorActivate() + end + local editor_cursor_obj = XEditorStartPlaceObject(btn_param) + editor_cursor_obj.Name = name + editor_cursor_obj:OnNameChanged(false, ged) + + if not obj:IsKindOf("SetpiecePrg") then + if prop_meta.editor == "string_list" then + obj[prop_id] = obj[prop_id] or {} + table.insert(obj[prop_id], editor_cursor_obj.Name) + else + obj[prop_id] = editor_cursor_obj.Name + end + end + ObjModified(obj) +end + +function SetpieceViewMarker(obj, root, prop_id, ged, btn_param) + -- handle viewing a waypoints in the 'string_list' Waypoints prop + local marker_name = btn_param or obj[prop_id] + local prop_meta = obj:GetPropertyMetadata(prop_id) + if prop_meta.editor == "buttons" then + local waypoints_list = obj[find_previous_prop_meta(obj, prop_id).id] + marker_name = waypoints_list and waypoints_list[1] + end + + local setpiece = GetParentTableOfKind(obj, "SetpiecePrg") or obj + if not setpiece:ChangeMap(ged) then + return + end + + local marker = SetpieceMarkerByName(marker_name) + if marker then + local in_mod_editor = ged and ged.app_template == "ModEditor" + if in_mod_editor then + editor.StartModdingEditor(obj, obj.Map) + else + EditorActivate() + end + EditorActivate() + ViewObject(marker) + editor.AddToSel{marker} + else + ged:ShowMessage("Error", "Marker not found.") + end +end + +function SetpieceMarkerPropButtons(marker_class) + return { { + name = "Place", + func = SetpieceMarkerPlaceButton, + param = marker_class, + is_hidden = function(obj, prop_meta) return obj:IsKindOf("GedMultiSelectAdapter") or prop_meta.editor ~= "buttons" and obj[prop_meta.id] ~= "" end, + }, + { + name = "View", + func = SetpieceViewMarker, + } } +end + +function SetpieceCheckMap(obj) + return IsChangingMap() or GetParentTableOfKind(obj, "SetpiecePrg").Map ~= CurrentMap +end diff --git a/CommonLua/SetpieceStatements.lua b/CommonLua/SetpieceStatements.lua new file mode 100644 index 0000000000000000000000000000000000000000..9b635c94b372c118ef1de40314aa341afa2f5bfe --- /dev/null +++ b/CommonLua/SetpieceStatements.lua @@ -0,0 +1,1475 @@ +if Platform.ged then return end + +DefineClass.SetpieceState = { + __parents = { "InitDone" }, + test_mode = false, + setpiece = false, + + root_state = false, -- the main (root) set-piece; it will have all sub set-piece commands registered in its .commands member + skipping = false, + commands = false, + + test_actors = false, + real_actors = false, + rand = false, + lightmodel = false, + cameraDOFParams = false +} + +function SetpieceState:Init() + self.root_state = self.root_state or self + self.commands = {} + Msg("SetpieceStartExecution", self.setpiece) +end + +function SetpieceState:RegisterCommand(command, thread, checkpoint, skip_fn, class) + command.class = class + command.setpiece_state = self + command.thread = thread + command.checkpoint = checkpoint + command.skip_fn = skip_fn + command.completed = false + table.insert(self.commands, command) + if self ~= self.root_state then + table.insert(self.root_state.commands, command) + end +end + +function SetpieceState:SetSkipFn(skip_fn, thread) + local command = table.find_value(self.commands, "thread", thread or CurrentThread()) + assert(command, "Setpiece command that was supposed to be running not found") + command.skip_fn = skip_fn +end + +-- Function to check whether or not to continue to next command +function SetpieceState:IsCompleted(checkpoint) + if not checkpoint and self.skipping then return end + + local checkpoint_exists + for _, command in ipairs(self.commands) do + local match = command.checkpoint == checkpoint or not checkpoint + if match then + checkpoint_exists = true + if not command.completed then + return false + end + end + end + return checkpoint_exists +end + +function SetpieceState:Skip() + if not IsGameTimeThread() or not CanYield() then + CreateGameTimeThread(SetpieceState.Skip, self) + return + end + + if self.skipping then return end + self.skipping = true + + -- First, start fading out the scene, so we can run the skip logic behind a black screen + local dlg = GetDialog("XSetpieceDlg") + if dlg and self.root_state == self then + dlg:FadeOut(700) + dlg.skipping_setpiece = true + end + + Sleep(0) + SuspendCommandObjectInfiniteChangeDetection() + repeat + self.skipping = true + for _, command in ipairs(self.commands) do + if command.setpiece_state == self and command.started and not command.completed then + if command.thread ~= CurrentThread() then + if not IsKindOf(command, "PrgPlaySetpiece") then + -- Don't delete subsetpiece threads so they can skip their commands + DeleteThread(command.thread) + end + command.skip_fn() + end + command.completed = true + end + end + Msg(self.root_state) -- notify that the completion state of commands changed + Sleep(0) -- the thread running the setpiece will continue, and potentially start commands + self.skipping = false + until self:IsCompleted() + ResumeCommandObjectInfiniteChangeDetection() + Msg(self.root_state) +end + +function SetpieceState:WaitCompletion() + while not self:IsCompleted() do + WaitMsg(self.root_state, 300) + Sleep(0) -- give the next setpiece commands a chance to be started + end +end + +function OnMsg.SetpieceCommandCompleted(state, thread) + local command = table.find_value(state.root_state.commands, "thread", thread) + assert(command, "Setpiece command that was supposed to be running not found") + command.completed = true + if state.root_state:IsCompleted(command.checkpoint) then + Msg(state.root_state) + end +end + + +----- Actors + +MapVar("g_SetpieceActors", {}) + +function RegisterSetpieceActors(objects, value) + for _, actor in ipairs(objects or empty_table) do + if value and IsValid(actor) then + g_SetpieceActors[actor] = true + actor:SetVisible(true) + Msg("SetpieceActorRegistered", actor) + else + g_SetpieceActors[actor] = nil + Msg("SetpieceActorUnegistered", actor) + end + end +end + +function IsSetpieceActor(actor) + return g_SetpieceActors[actor] and true +end + +function SetpieceActorsCombo(obj) + return function() + local setpiece = GetParentTableOfKind(obj, "SetpiecePrg") + local items = {""} + table.iappend(items, setpiece.Params or empty_table) + setpiece:ForEachSubObject("PrgSetpieceAssignActor", function(obj) table.insert_unique(items, obj.AssignTo) end) + table.sort(items) + return items + end +end + + +DefineClass.PrgSetpieceAssignActor = { + __parents = { "PrgExec" }, + properties = { + { id = "AssignTo", name = "Actor(s)", editor = "combo", default = "", items = SetpieceActorsCombo, variable = true, }, + { id = "_marker_help", editor = "help", help = "Place a testing spawner ONLY for actors that are expected to come from another map into this one during gameplay.", }, + { id = "Marker", name = "Testing spawner", editor = "choice", default = "", + items = SetpieceMarkersCombo("SetpieceSpawnMarker"), buttons = SetpieceMarkerPropButtons("SetpieceSpawnMarker"), + no_validate = SetpieceCheckMap, + }, + }, + ExtraParams = { "state", "rand" }, + EditorSubmenu = "Actors", + StatementTag = "Setpiece", +} + +function PrgSetpieceAssignActor.FindObjects(state, Marker, ...) + -- implement code that returns the objects that correspond to the actor +end + +function PrgSetpieceAssignActor:GetError() + if self.Marker ~= "" and not SetpieceCheckMap(self) then + local marker = SetpieceMarkerByName(self.Marker) + if not marker then + return string.format("Testing spawner %s not found on the map.", self.Marker) + end + if marker:HasMember("UnitDataSpawnDefs") and (not marker.UnitDataSpawnDefs or #marker.UnitDataSpawnDefs < 1) then + return string.format("No UnitData Spawn Templates are defined for testing spawner %s.", self.Marker) + end + end +end + +function CanBeSetpieceActor(idx, obj) + return not IsKindOf(obj, "EditorObject") +end + +function PrgSetpieceAssignActor:Exec(state, rand, AssignTo, Marker, ...) + state.rand = rand + + local objects = self.FindObjects(state, Marker, ...) + objects = table.ifilter(objects, function(idx, obj) return CanBeSetpieceActor(idx, obj) and not table.find(AssignTo, obj) end) + if objects and next(objects) then + local real_actors = state.real_actors or {} + for _, actor in ipairs(objects) do + table.insert_unique(real_actors, actor) + end + state.real_actors = real_actors + RegisterSetpieceActors(objects, true) + end + + if state.test_mode then + objects = table.ifilter(objects, function(idx, obj) return not rawget(obj, "setpiece_impostor") end) + if self.class == "SetpieceSpawn" then + state.test_actors = table.iappend(state.test_actors or {}, objects) + elseif self.class ~= "SetpieceAssignFromExistingActor" then + -- if actors are missing, spawn them using the Testing spawner marker + local marker = SetpieceMarkerByName(Marker, "check") + if not objects or #objects == 0 then + objects = marker and marker:SpawnObjects() or {} + assert(not marker or #objects > 0, string.format("Test spawner for group '%s' failed to spawn objects.", AssignTo)) + else -- hide the real actors and play the set-piece with impostor copies + objects = table.map(objects, function(obj) + local impostor = obj:Clone() + rawset(impostor, "setpiece_impostor", true) + if obj:HasMember("GetDynamicData") then -- Zulu-specific logic + local data = {} + obj:GetDynamicData(data) + data.pos = nil -- the pos saved in the dynamic data causes the impostor to snap to a slab's center + impostor:SetDynamicData(data) + if IsKindOf(impostor, "Unit") then + impostor:SetTeam(obj.team) + end + rawset(impostor, "session_id", nil) + end + obj:SetVisible(false, "force") + return impostor + end) + if marker then + marker:SetActorsPosOrient(objects, 0, false, "set_orient") + end + end + state.test_actors = table.iappend(state.test_actors or {}, objects) + RegisterSetpieceActors(objects, true) + end + end + + return table.iappend(AssignTo or {}, objects) +end + + +DefineClass.SetpieceSpawn = { + __parents = { "PrgSetpieceAssignActor" }, + properties = { + { id = "_marker_help", editor = false, }, + { id = "Marker", name = "Spawner", editor = "choice", default = "", + items = SetpieceMarkersCombo("SetpieceSpawnMarker"), buttons = SetpieceMarkerPropButtons("SetpieceSpawnMarker"), + no_validate = SetpieceCheckMap, + }, + }, + EditorView = Untranslated("Actor(s) '' += spawn from marker ''"), + EditorName = "Spawn actor", +} + +function SetpieceSpawn.FindObjects(state, Marker, ...) + local marker = SetpieceMarkerByName(Marker, "check") + return marker and marker:SpawnObjects() or {} +end + + +DefineClass.SetpieceAssignFromParam = { + __parents = { "PrgSetpieceAssignActor" }, + properties = { + { id = "Parameter", editor = "choice", default = "", items = PrgVarsCombo, variable = true, }, + }, + EditorView = Untranslated("Actor(s) '' += parameter ''"), + EditorName = "Actor(s) from parameter", +} + +function SetpieceAssignFromParam.FindObjects(state, Marker, Parameter) + return Parameter +end + + +DefineClass.SetpieceSpawnParticles = { + __parents = { "PrgSetpieceAssignActor" }, + properties = { + { id = "_marker_help", editor = false, }, + { id = "Marker", name = "Spawner", editor = "choice", default = "", + items = SetpieceMarkersCombo("SetpieceParticleSpawnMarker"), buttons = SetpieceMarkerPropButtons("SetpieceParticleSpawnMarker"), + no_validate = SetpieceCheckMap, + }, + }, + EditorView = Untranslated("Spawn particle FX from marker ''"), + EditorName = "Spawn particles", + EditorSubmenu = "Commands", + StatementTag = "Setpiece", +} + +function SetpieceSpawnParticles:GetParticleFXName() + local marker = SetpieceMarkerByName(self.Marker, not "check") + return marker and marker.Particles or "?" +end + +function SetpieceSpawnParticles.FindObjects(state, Marker, ...) + local marker = SetpieceMarkerByName(Marker, "check") + return marker and marker:SpawnObjects() or {} +end + + +local function actor_groups_combo() + local items = table.keys2(Groups, "sorted", "", "===== Groups from map") + items[#items + 1] = "===== All units" + table.iappend(items, PresetsCombo("UnitDataCompositeDef")()) + return items +end + + +DefineClass.SetpieceAssignFromGroup = { + __parents = { "PrgSetpieceAssignActor" }, + properties = { + { id = "Group", editor = "choice", default = "", items = function() return actor_groups_combo() end, no_validate = SetpieceCheckMap, }, + { id = "Class", editor = "text", default = "Object", }, + { id = "PickOne", editor = "bool", name = "Pick random object", default = false, }, + }, + EditorView = Untranslated("Actor(s) '' += from group ''"), + EditorName = "Actor(s) from group", +} + +function SetpieceAssignFromGroup:GetUnitSpecifier() + return (self.PickOne and "random object" or "object") .. (self.Class ~= "Object" and " of class" .. self.Class or "") +end + +function SetpieceAssignFromGroup.FindObjects(state, Marker, Group, Class, PickOne) + local group = table.ifilter(Groups[Group] or empty_table, function(i, o) return o:IsKindOf(Class) end) + return PickOne and #group > 0 and { group[state.rand(#group) + 1] } or group +end + + +DefineClass.SetpieceAssignFromExistingActor = { + __parents = { "PrgSetpieceAssignActor" }, + properties = { + { id = "Actors", name = "From actor", editor = "choice", default = "", items = SetpieceActorsCombo, variable = true, }, + { id = "Class", editor = "text", default = "Object", }, + { id = "PickOne", editor = "bool", name = "Pick random object", default = false, }, + { id = "_marker_help", editor = false, }, + { id = "Marker", editor = false, }, + }, + EditorView = Untranslated("Actor(s) '' += from actor ''"), + EditorName = "Actor(s) from existing actor", +} + +function SetpieceAssignFromExistingActor:GetUnitSpecifier() + return (self.PickOne and "random object" or "object") .. (self.Class ~= "Object" and " of class " .. self.Class or "") +end + +function SetpieceAssignFromExistingActor.FindObjects(state, Actors, Class, PickOne) + local actors = table.ifilter(Actors or empty_table, function(i, o) return o:IsKindOf(Class) end) + return PickOne and #actors > 0 and { actors[state.rand(#actors) + 1] } or actors +end + + +DefineClass.SetpieceDespawn = { + __parents = { "PrgExec" }, + properties = { + { id = "Actors", name = "Actor(s)", editor = "combo", default = "", items = SetpieceActorsCombo, variable = true, }, + }, + EditorView = Untranslated("Despawn actor(s) ''"), + EditorName = "Despawn actor(s)", + EditorSubmenu = "Actors", + StatementTag = "Setpiece", +} + +function SetpieceDespawn:Exec(Actors) + for _, actor in ipairs(Actors or empty_table) do + if IsValid(actor) then actor:delete() end + end +end + + +----- PrgSetpieceCommand +-- +-- performs an command to be used in a set-piece, e.g. a unit walking to a point; defines the ExecThread and Skip methods + +DefineClass.PrgSetpieceCommand = { + __parents = { "PrgExec" }, + properties = { + { id = "Wait", name = "Wait completion", editor = "bool", default = true, }, + { id = "Checkpoint", name = "Checkpoint id", editor = "combo", default = "", + items = function(self) return PresetsPropCombo(GetParentTableOfKind(self, "SetpiecePrg"), "Checkpoint", "", "recursive") end, + no_edit = function(self) return self.Wait end, + }, + }, + ExtraParams = { "state", "rand" }, + EditorSubmenu = "Commands", + StatementTag = "Setpiece", +} + +function PrgSetpieceCommand:GetWaitCompletionPrefix() + return _InternalTranslate(self.DisabledPrefix, self, false) .. (self.Wait and "===== " or "") +end + +function PrgSetpieceCommand:GetCheckpointPrefix() + return self.Checkpoint ~= "" and string.format(" ", self.Checkpoint) or "" +end + +function PrgSetpieceCommand:GetEditorView() + return Untranslated(self:GetWaitCompletionPrefix()) .. self.EditorView +end + +function PrgSetpieceCommand.ExecThread(state, ...) + -- implement code that performs the command here (this method is run in a thread) +end + +function PrgSetpieceCommand.Skip(state, ...) + -- implement code that immediately brings the command's objects to their final states + -- alternatively, you can call state:SetSkipFn(function) from the ExecThread method to set/change the skip function +end + +function PrgSetpieceCommand:Exec(state, rand, Wait, Checkpoint, ...) + local command = {} + local params = pack_params(...) + local thread = CreateGameTimeThread(function(self, command, params, statement) + command.started = true + sprocall(self.ExecThread, state, unpack_params(params)) + Msg("SetpieceCommandCompleted", state, CurrentThread(), statement) + end, self, command, params, SetpieceLastStatement) + state.rand = rand + + local checkpoint = not Wait and Checkpoint ~= "" and Checkpoint or thread + state:RegisterCommand(command, thread, checkpoint, function() self.Skip(state, unpack_params(params)) end, self.class) + -- Check whether to continue to next command or not + while Wait and not state:IsCompleted(checkpoint) do + WaitMsg(state.root_state) + end +end + + +DefineClass.PrgPlaySetpiece = { + __parents = { "PrgSetpieceCommand", "PrgCallPrgBase" }, + properties = { + { id = "PrgClass", editor = false, default = "SetpiecePrg" }, + }, + EditorName = "Play sub-setpiece", + EditorSubmenu = "Setpiece", + EditorView = Untranslated("Play setpiece ''"), + StatementTag = "Setpiece", +} + +PrgPlaySetpiece.GenerateCode = PrgExec.GenerateCode +PrgPlaySetpiece.GetParamString = PrgExec.GetParamString + +function PrgPlaySetpiece.ExecThread(state, PrgGroup, Prg, ...) + local new_state = SetpieceState:new{ + root_state = state.root_state, + test_mode = state.test_mode, + setpiece = Setpieces[Prg] + } + + state:SetSkipFn(function() new_state:Skip() end) + sprocall(SetpiecePrgs[Prg], state.rand(), new_state, ...) + new_state:WaitCompletion() + Msg("SetpieceEndExecution", new_state.setpiece) +end + + +DefineClass.PrgForceStopSetpiece = { + __parents = { "PrgSetpieceCommand" }, + properties = { { id = "Wait", editor = false, default = false, } }, + EditorName = "Force stop", + EditorSubmenu = "Setpiece", + EditorView = Untranslated("Force stop current setpiece"), + StatementTag = "Setpiece", +} + +function PrgForceStopSetpiece.ExecThread(state, PrgGroup, Prg, ...) + state:Skip() +end + + +----- SetpieceWaitCheckpoint +-- +-- waits all currently started setpiece commands with the specified checkpoint id to complete + +DefineClass.SetpieceWaitCheckpoint = { + __parents = { "PrgSetpieceCommand" }, + properties = { + { id = "Wait", default = true, no_edit = true, }, + { id = "Checkpoint", default = "", no_edit = true, }, + { id = "WaitCheckpoint", name = "Checkpoint id", editor = "combo", default = "", + items = function(self) return PresetsPropCombo(GetParentTableOfKind(self, "SetpiecePrg"), "Checkpoint", "", "recursive") end, + }, + }, + EditorName = "Wait checkpoint", + EditorView = Untranslated("Wait checkpoint ''"), + EditorSubmenu = "Setpiece", + StatementTag = "Setpiece", +} + +function SetpieceWaitCheckpoint:Exec(state, rand, WaitCheckpoint) + PrgSetpieceCommand.Exec(self, state, rand, true, "", WaitCheckpoint) +end + +function SetpieceWaitCheckpoint.ExecThread(state, WaitCheckpoint) + -- Check if checkpoint is reached or invalid + while not state.root_state:IsCompleted(WaitCheckpoint) do + WaitMsg(state.root_state) + end +end + + +----- Commands + +DefineClass.SetpieceSleep = { + __parents = { "PrgSetpieceCommand" }, + properties = { + { id = "Time", name = "Sleep time (ms)", editor = "number", default = 0, }, + }, + EditorName = "Sleep (wait time)", + EditorView = Untranslated("Sleep ', '" .. marker) or (markers .. "'" .. marker) + end + markers = markers .. "'" + end + + return self:GetWaitCompletionPrefix() .. self:GetCheckpointPrefix() .. string.format("Actor(s) '%s' go to %s", actors, markers) +end + + +if FirstLoad then + SetpieceIdleAroundThreads = setmetatable({}, weak_keys_meta) +end + +DefineClass.SetpieceIdleAround = { + __parents = { "PrgSetpieceCommand" }, + properties = { + { id = "Actors", name = "Actor(s)", editor = "choice", default = "", items = SetpieceActorsCombo, variable = true, }, + { id = "MaxDistance", editor = "number", default = 5 * guim, scale = "m", }, + { id = "Time", editor = "number", scale = "sec", default = 20000 }, + { id = "RandomDelay", name = "Random delay (max)", editor = "number", scale = "sec", default = 2000 }, + { id = "PFClass", name = "Pathfinding class", editor = "choice", default = false, + items = function() return table.map(pathfind, function(pfclass) return pfclass.name end) end, + }, + { id = "WalkAnimation", name = "Walk animation", editor = "combo", default = "walk", items = { "walk", "run" }, }, + { id = "UseIdleAnim", name = "Use idle animation", editor = "bool", default = true, }, + { id = "IdleAnimTime", name = "Idle animation time", editor = "number", scale = "sec", default = 5000, }, + { id = "IdleSequence1", name = "Idle sequence 1", editor = "string_list", default = false, items = function() return UnitAnimationsCombo() end, }, + { id = "IdleSequence2", name = "Idle sequence 2", editor = "string_list", default = false, items = function() return UnitAnimationsCombo() end, }, + { id = "IdleSequence3", name = "Idle sequence 3", editor = "string_list", default = false, items = function() return UnitAnimationsCombo() end, }, + }, + EditorName = "Idle around", + EditorView = Untranslated("Actor(s) '' idle around their current position for
Camera IDImage error metricGround TruthDifferenceNew Image
",img.id,"", img.img_err, + "
\"