local blessCurseManagerApi = require("chaosbag/BlessCurseManagerApi") local guidReferenceApi = require("core/GUIDReferenceApi") local mythosAreaApi = require("core/MythosAreaApi") local navigationOverlayApi = require("core/NavigationOverlayApi") local playAreaApi = require("core/PlayAreaApi") local playmatApi = require("playermat/PlaymatApi") local soundCubeApi = require("core/SoundCubeApi") local tokenArrangerApi = require("accessories/TokenArrangerApi") local tokenChecker = require("core/token/TokenChecker") local tokenManager = require("core/token/TokenManager") --------------------------------------------------------- -- general setup --------------------------------------------------------- ENCOUNTER_DECK_POS = { -3.93, 1, 5.76 } ENCOUNTER_DECK_DISCARD_POSITION = { -3.85, 1, 10.38 } -- GUIDs that will not be interactable (e.g. parts of the table) local NOT_INTERACTABLE = { "6161b4", -- Decoration-Map "721ba2", -- PlayArea "9f334f", -- MythosArea "463022", -- Panel behind tentacle stand "f182ee", -- InvestigatorCount "7bff34", -- Tentacle stand "8646eb", -- horizontal border left "75937e", -- horizontal border right "612072", -- vertical border left "975c39", -- vertical border right } local guidHandler, DATA_HELPER -- global variable for access chaosTokens = {} local chaosTokensLastMat = nil local bagSearchers = {} local MAT_COLORS = { "White", "Orange", "Green", "Red" } local hideTitleSplashWaitFunctionId = nil -- online functionality related variables local MOD_VERSION = "3.3.0" local SOURCE_REPO = 'https://raw.githubusercontent.com/chr1z93/loadable-objects/main' local library, requestObj, modMeta local acknowledgedUpgradeVersions = {} local contentToShow = "campaigns" local currentListItem = 1 local xmlVisibility = { downloadWindow = false, optionPanel = false, updateNotification = false } local tabIdTable = { tab1 = "campaigns", tab2 = "scenarios", tab3 = "fanmadeCampaigns", tab4 = "fanmadeScenarios", tab5 = "fanmadePlayerCards" } -- optionPanel data optionPanel = {} local LANGUAGES = { { code = "zh_CN", name = "简体中文" }, { code = "zh_TW", name = "繁體中文" }, { code = "de", name = "Deutsch" }, { code = "en", name = "English" }, { code = "es", name = "Español" }, { code = "fr", name = "Français" }, { code = "it", name = "Italiano" } } local RESOURCE_OPTIONS = { "enabled", "custom", "disabled" } --------------------------------------------------------- -- data for tokens --------------------------------------------------------- TOKEN_DATA = { damage = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357115146/903D11AAE7BD5C254C8DC136E9202EE516289DEA/", scale = {0.17, 0.17, 0.17}}, horror = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357163535/6D9E0756503664D65BDB384656AC6D4BD713F5FC/", scale = {0.17, 0.17, 0.17}}, resource = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357192910/11DDDC7EF621320962FDCF3AE3211D5EDC3D1573/", scale = {0.17, 0.17, 0.17}}, doom = {image = "https://i.imgur.com/EoL7yaZ.png", scale = {0.17, 0.17, 0.17}}, clue = {image = "http://cloud-3.steamusercontent.com/ugc/1758068501357164917/1D06F1DC4D6888B6F57124BD2AFE20D0B0DA15A8/", scale = {0.15, 0.15, 0.15}} } ID_URL_MAP = { ['blue'] = {name = "Elder Sign", url = 'https://i.imgur.com/nEmqjmj.png'}, ['p1'] = {name = "+1", url = 'https://i.imgur.com/uIx8jbY.png'}, ['0'] = {name = "0", url = 'https://i.imgur.com/btEtVfd.png'}, ['m1'] = {name = "-1", url = 'https://i.imgur.com/w3XbrCC.png'}, ['m2'] = {name = "-2", url = 'https://i.imgur.com/bfTg2hb.png'}, ['m3'] = {name = "-3", url = 'https://i.imgur.com/yfs8gHq.png'}, ['m4'] = {name = "-4", url = 'https://i.imgur.com/qrgGQRD.png'}, ['m5'] = {name = "-5", url = 'https://i.imgur.com/3Ym1IeG.png'}, ['m6'] = {name = "-6", url = 'https://i.imgur.com/c9qdSzS.png'}, ['m7'] = {name = "-7", url = 'https://i.imgur.com/4WRD42n.png'}, ['m8'] = {name = "-8", url = 'https://i.imgur.com/9t3rPTQ.png'}, ['skull'] = {name = "Skull", url = 'https://i.imgur.com/stbBxtx.png'}, ['cultist'] = {name = "Cultist", url = 'https://i.imgur.com/VzhJJaH.png'}, ['tablet'] = {name = "Tablet", url = 'https://i.imgur.com/1plY463.png'}, ['elder'] = {name = "Elder Thing", url = 'https://i.imgur.com/ttnspKt.png'}, ['red'] = {name = "Auto-fail", url = 'https://i.imgur.com/lns4fhz.png'}, ['bless'] = {name = "Bless", url = 'http://cloud-3.steamusercontent.com/ugc/1655601092778627699/339FB716CB25CA6025C338F13AFDFD9AC6FA8356/'}, ['curse'] = {name = "Curse", url = 'http://cloud-3.steamusercontent.com/ugc/1655601092778636039/2A25BD38E8C44701D80DD96BF0121DA21843672E/'}, ['frost'] = {name = "Frost", url = 'http://cloud-3.steamusercontent.com/ugc/1858293462583104677/195F93C063A8881B805CE2FD4767A9718B27B6AE/'} } --------------------------------------------------------- -- data for chaos token stat tracker --------------------------------------------------------- local tokenDrawingStats = { ["Overall"] = {}, ["8b081b"] = {}, ["bd0ff4"] = {}, ["383d8b"] = {}, ["0840d5"] = {} } --------------------------------------------------------- -- general code --------------------------------------------------------- -- saving state of optionPanel to restore later function onSave() return JSON.encode({ optionPanel = optionPanel, acknowledgedUpgradeVersions = acknowledgedUpgradeVersions }) end function onLoad(savedData) if savedData then loadedData = JSON.decode(savedData) optionPanel = loadedData.optionPanel acknowledgedUpgradeVersions = loadedData.acknowledgedUpgradeVersions updateOptionPanelState() else print("Saved state could not be found!") end for _, guid in ipairs(NOT_INTERACTABLE) do local obj = getObjectFromGUID(guid) if obj ~= nil then obj.interactable = false end end DATA_HELPER = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") resetChaosTokenStatTracker() getModVersion() math.randomseed(os.time()) -- initialization of loadable objects library (delay to let Navigation Overlay build) Wait.time(function() WebRequest.get(SOURCE_REPO .. '/library.json', libraryDownloadCallback) end, 1) end -- Event hook for any object search. When chaos tokens are manipulated while the chaos bag -- container is being searched, a TTS bug can cause tokens to duplicate or vanish. We lock the -- chaos bag during search operations to avoid this. function onObjectSearchStart(object, playerColor) chaosbag = findChaosBag() if object == chaosbag then bagSearchers[playerColor] = true end end -- Event hook for any object search. When chaos tokens are manipulated while the chaos bag -- container is being searched, a TTS bug can cause tokens to duplicate or vanish. We lock the -- chaos bag during search operations to avoid this. function onObjectSearchEnd(object, playerColor) chaosbag = findChaosBag() if object == chaosbag then bagSearchers[playerColor] = nil end end -- Pass object enter container events to the PlayArea to clear vector lines from dragged cards. -- This requires the try method as cards won't exist any more after they enter a deck, so the lines -- can't be cleared. function tryObjectEnterContainer(container, object) playAreaApi.tryObjectEnterContainer(container, object) return true end --------------------------------------------------------- -- chaos token drawing --------------------------------------------------------- -- checks scripting zone for chaos bag (also called by a lot of objects!) function findChaosBag() local chaosbag_zone = getObjectFromGUID("83ef06") -- error handling: scripting zone not found if chaosbag_zone == nil then printToAll("Zone for chaos bag detection couldn't be found.", "Red") return end for _, item in ipairs(chaosbag_zone.getObjects()) do if item.getDescription() == "Chaos Bag" then return item end end -- error handling: chaos bag not found printToAll("Chaos bag couldn't be found.", "Red") end function returnChaosTokens() for _, token in pairs(chaosTokens) do if token ~= nil then chaosbag.putObject(token) end end chaosTokens = {} end -- Checks to see if the chaos bag can be manipulated. If a player is searching the bag when tokens -- are drawn or replaced a TTS bug can cause those tokens to vanish. Any functions which change the -- contents of the bag should check this method before doing so. -- This method will broadcast a message to all players if the bag is being searched. ---@return Boolean. True if the bag is manipulated, false if it should be blocked. function canTouchChaosTokens() for color, searching in pairs(bagSearchers) do if searching then broadcastToAll("Someone is searching the chaos bag, can't touch the tokens.", "Red") return false end end return true end -- called by playermats (by the "Draw chaos token" button) function drawChaosToken(params) if not canTouchChaosTokens() then return end local mat = params[1] local tokenOffset = params[2] local isRightClick = params[3] chaosbag = findChaosBag() -- return token(s) on other playmat first if chaosTokensLastMat ~= nil and chaosTokensLastMat ~= mat and #chaosTokens ~= 0 then returnChaosTokens() chaosTokensLastMat = nil return end chaosTokensLastMat = mat -- if we have left clicked and have no tokens OR if we have right clicked if isRightClick or #chaosTokens == 0 then if #chaosbag.getObjects() == 0 then return end chaosbag.shuffle() -- add the token to the list, compute new position based on list length tokenOffset[1] = tokenOffset[1] + (0.17 * #chaosTokens) local token = chaosbag.takeObject({ index = 0, position = mat.positionToWorld(tokenOffset), rotation = mat.getRotation() }) -- get data for token description local name = token.getName() local tokenData = mythosAreaApi.returnTokenData().tokenData or {} local specificData = tokenData[name] or {} token.setDescription(specificData.description or "") -- track the chaos token (for stat tracker and future returning) trackChaosToken(name, mat.getGUID()) chaosTokens[#chaosTokens + 1] = token return else returnChaosTokens() end end --------------------------------------------------------- -- token spawning --------------------------------------------------------- -- DEPRECATED. Use TokenManager instead. -- Spawns a single token. ---@param params Table. Array with arguments to the method. 1 = position, 2 = type, 3 = rotation function spawnToken(params) return tokenManager.spawnToken(params[1], params[2], params[3]) end --------------------------------------------------------- -- chaos token stat tracker --------------------------------------------------------- function trackChaosToken(tokenName, matGUID) tokenDrawingStats["Overall"][tokenName] = (tokenDrawingStats["Overall"][tokenName] or 0) + 1 tokenDrawingStats[matGUID][tokenName] = (tokenDrawingStats[matGUID][tokenName] or 0) + 1 end -- Left-click: print stats, Right-click: reset stats function handleStatTrackerClick(_, _, isRightClick) if isRightClick then resetChaosTokenStatTracker() else local squidKing = "Nobody" local maxSquid = 0 local foundAnyStats = false for key, personalStats in pairs(tokenDrawingStats) do local playerColor, playerName if key == "Overall" then playerColor = "White" playerName = "Overall" else -- get mat color local matColor = playmatApi.getMatColorByPosition(getObjectFromGUID(key).getPosition()) playerColor = playmatApi.getPlayerColor(matColor) playerName = Player[playerColor].steam_name or playerColor local playerSquidCount = personalStats["Auto-fail"] if playerSquidCount > maxSquid then squidKing = playerName maxSquid = playerSquidCount end end -- get the total count of drawn tokens for the player local totalCount = 0 for tokenName, value in pairs(personalStats) do totalCount = totalCount + value end -- only print the personal stats if any tokens were drawn if totalCount > 0 then foundAnyStats = true printToAll("------------------------------") printToAll(playerName .. " Stats", playerColor) for tokenName, value in pairs(personalStats) do if value ~= 0 then printToAll(tokenName .. ': ' .. tostring(value)) end end printToAll('Total: ' .. tostring(totalCount)) end end -- detect if any player drew tokens if foundAnyStats then printToAll("------------------------------") printToAll(squidKing .. " is an auto-fail magnet.", { 255, 0, 0 }) else printToAll("No tokens have been drawn yet.", "Yellow") end end end -- resets the count for each token to 0 function resetChaosTokenStatTracker() for key, _ in pairs(tokenDrawingStats) do tokenDrawingStats[key] = {} for _, token in pairs(ID_URL_MAP) do tokenDrawingStats[key][token.name] = 0 end end end --------------------------------------------------------- -- Difficulty selector script --------------------------------------------------------- -- called for button creation on the difficulty selectors ---@param object object Usually "self" ---@param key string Name of the scenario function createSetupButtons(args) local data = getDataValue('modeData', args.key) if data ~= nil then local buttonParameters = {} buttonParameters.function_owner = args.object buttonParameters.position = { 0, 0.1, -0.15 } buttonParameters.scale = { 0.47, 1, 0.47 } buttonParameters.height = 200 buttonParameters.width = 1150 buttonParameters.color = { 0.87, 0.8, 0.7 } if data.easy ~= nil then buttonParameters.label = "Easy" buttonParameters.click_function = "easyClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.normal ~= nil then buttonParameters.label = "Standard" buttonParameters.click_function = "normalClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.hard ~= nil then buttonParameters.label = "Hard" buttonParameters.click_function = "hardClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.expert ~= nil then buttonParameters.label = "Expert" buttonParameters.click_function = "expertClick" args.object.createButton(buttonParameters) buttonParameters.position[3] = buttonParameters.position[3] + 0.20 end if data.standalone ~= nil then buttonParameters.label = "Standalone" buttonParameters.click_function = "standaloneClick" args.object.createButton(buttonParameters) end end end -- called for adding chaos tokens ---@param object object Usually "self" ---@param key string Name of the scenario ---@param mode string difficulty (e.g. "hard" or "expert") function fillContainer(args) local data = getDataValue('modeData', args.key) if data == nil then return end local value = data[args.mode] if value == nil or value.token == nil then return end local tokenList = {} for _, tokenId in ipairs(value.token) do table.insert(tokenList, tokenId) end if value.append ~= nil then for _, tokenId in ipairs(value.append) do table.insert(tokenList, tokenId) end end -- randomly choose tokens for specific Carcosa scenarios in standalone if value.random then local n = #value.random if n > 0 then for _, tokenId in ipairs(value.random[math.random(1, n)]) do table.insert(tokenList, tokenId) end end end setChaosBagState(tokenList) if value.message then broadcastToAll(value.message) end if value.warning then broadcastToAll(value.warning, { 1, 0.5, 0.5 }) end end function getDataValue(storage, key) local data = DATA_HELPER.getTable(storage) if data ~= nil then local value = data[key] if value ~= nil then local res = {} for m, v in pairs(value) do res[m] = v if res[m].parent ~= nil then local parentData = getDataValue(storage, res[m].parent) if parentData ~= nil and parentData[m] ~= nil and parentData[m].token ~= nil then res[m].token = parentData[m].token end res[m].parent = nil end end return res end end end function createChaosTokenNameLookupTable() local namesToIds = {} for k, v in pairs(ID_URL_MAP) do namesToIds[v.name] = k end return namesToIds end -- returns a Table List of chaos token ids in the current chaos bag ---@api chaosbag/ChaosBagApi function getChaosBagState() local tokens = {} local invertedTable = createChaosTokenNameLookupTable() local chaosbag = findChaosBag() for _, v in ipairs(chaosbag.getObjects()) do local id = invertedTable[v.name] if id then table.insert(tokens, id) else printToAll(v.name .. " token not recognized. Will not be recorded.", "Yellow") end end return tokens end -- respawns the chaos bag with a new state of tokens ---@param tokenList Table List of chaos token ids ---@api chaosbag/ChaosBagApi function setChaosBagState(tokenList) if not canTouchChaosTokens() then return end local chaosbag = findChaosBag() local chaosbagData = chaosbag.getData() local reserveData = getObjectFromGUID("106418").getData() local tokenCache = {} local containedObjects = {} -- create a temporary copy of the data for each chaos token for _, objData in ipairs(reserveData.ContainedObjects) do tokenCache[objData.Nickname] = objData end -- iterate over tokenlist and insert specified tokens into new table for _, tokenId in ipairs(tokenList) do local tokenName = ID_URL_MAP[tokenId].name table.insert(containedObjects, tokenCache[tokenName]) end -- overwrite chaos bag content and respawn it chaosbagData.ContainedObjects = containedObjects chaosbag.destruct() spawnObjectData({ data = chaosbagData }) -- remove tokens that are still in play for _, token in pairs(chaosTokens) do if token ~= nil then token.destruct() end end chaosTokens = {} chaosTokensLastMat = nil -- reset bless / curse manager blessCurseManagerApi.removeTakenTokensAndReset() printToAll("Chaos bag set to chosen difficulty.", "Green") end -- spawns the specified chaos token and puts it into the chaos bag ---@param id String ID of the chaos token function spawnChaosToken(id) if not canTouchChaosTokens() then return end id = id:lower() local chaosbag = findChaosBag() local url = ID_URL_MAP[id].url or "" if url ~= "" then return spawnObject({ type = 'Custom_Tile', position = { 0.49, 3, 0 }, scale = { 0.81, 1.0, 0.81 }, rotation = { 0, 270, 0 }, callback_function = function(obj) obj.setName(ID_URL_MAP[id].name) chaosbag.putObject(obj) tokenArrangerApi.layout() end }).setCustomObject({ type = 2, image = url, thickness = 0.1 }) end end -- removes the specified chaos token from the chaos bag ---@param id String ID of the chaos token function removeChaosToken(id) if not canTouchChaosTokens() then return end local tokens = {} local chaosbag = findChaosBag() local name = ID_URL_MAP[id].name for _, v in ipairs(chaosbag.getObjects()) do if v.name == name then table.insert(tokens, v.guid) end end -- error handling: no matching token found if #tokens == 0 then printToAll("No " .. name .. " tokens in the chaos bag.", "Yellow") return end chaosbag.takeObject({ guid = tokens[1], smooth = false, callback_function = function(obj) obj.destruct() tokenArrangerApi.layout() end }) printToAll("Removing " .. name .. " token (in bag: " .. #tokens - 1 .. ")", "White") end -- empty the chaos bag function emptyChaosBag() if not canTouchChaosTokens() then return end local chaosbag = findChaosBag() for _, object in ipairs(chaosbag.getObjects()) do chaosbag.takeObject({ callback_function = function(item) item.destruct() end }) end end -- returns all sealed tokens on cards to the chaos bag function releaseAllSealedTokens(playerColor) local chaosbag = findChaosBag() for _, obj in ipairs(getObjectsWithTag("CardThatSeals")) do obj.call("releaseAllTokens", playerColor) end end --------------------------------------------------------- -- Content Importing and XML functions --------------------------------------------------------- -- forwards the requested content type to the update function and sets highlight to clicked tab ---@param tabId String Id of the clicked tab function onClick_tab(_, _, tabId) for listId, listContent in pairs(tabIdTable) do if listId == tabId then UI.setClass(listId, 'downloadTab activeTab') contentToShow = listContent else UI.setClass(listId, 'downloadTab') end end currentListItem = 1 updateDownloadItemList() end -- click function for the items in the download window -- updates backgroundcolor for row panel and fontcolor for list item function onClick_select(_, _, identificationKey) UI.setAttribute("panel" .. currentListItem, "color", "clear") UI.setAttribute(contentToShow .. "_" .. currentListItem, "color", "white") -- parses the identification key (contentToShow_currentListItem) if identificationKey then contentToShow = nil currentListItem = nil for str in string.gmatch(identificationKey, "([^_]+)") do if not contentToShow then -- grab the first part to know the content type contentToShow = str else -- get the index currentListItem = tonumber(str) break end end end UI.setAttribute("panel" .. currentListItem, "color", "grey") UI.setAttribute(contentToShow .. "_" .. currentListItem, "color", "black") updatePreviewWindow() end -- click function for the download button in the preview window function onClick_download() placeholder_download(library[contentToShow][currentListItem]) end -- the download button on the placeholder objects calls this to directly initiate a download ---@param param Table contains url and guid of replacement object function placeholder_download(params) local url = SOURCE_REPO .. '/' .. params.url requestObj = WebRequest.get(url, function (request) contentDownloadCallback(request, params) end) startLuaCoroutine(Global, 'downloadCoroutine') end function downloadCoroutine() -- show progress bar UI.setAttribute('download_progress', 'active', true) -- update progress bar while requestObj do UI.setAttribute('download_progress', 'percentage', requestObj.download_progress * 100) coroutine.yield(0) end UI.setAttribute('download_progress', 'percentage', 100) -- wait 30 frames for i = 1, 30 do coroutine.yield(0) end -- hide progress bar UI.setAttribute('download_progress', 'active', false) -- hide download window if xmlVisibility.downloadWindow then xmlVisibility.downloadWindow = false UI.hide('downloadWindow') end return 1 end -- toggles the visibility of the respective UI ---@param player LuaPlayer Player that triggered this ---@param title String Name of the UI to toggle function onClick_toggleUi(player, title) if title == "Navigation Overlay" then navigationOverlayApi.cycleVisibility(player.color) return end if xmlVisibility[title] then -- small delay to allow button click sounds to play Wait.time(function() UI.hide(title) end, 0.1) else UI.show(title) end xmlVisibility[title] = not xmlVisibility[title] end -- updates the preview window function updatePreviewWindow() local item = library[contentToShow][currentListItem] local tempImage = "http://cloud-3.steamusercontent.com/ugc/2115061845788345842/2CD6ABC551555CCF58F9D0DDB7620197BA398B06/" -- set default image if not defined if item.boxsize == nil or item.boxsize == "" or item.boxart == nil or item.boxart == "" then item.boxsize = "big" item.boxart = "http://cloud-3.steamusercontent.com/ugc/762723517667628371/18438B0A0045038A7099648AA3346DFCAA267C66/" end UI.setValue("previewTitle", item.name) UI.setValue("previewAuthor", "by " .. (item.author or "- Author not found -")) UI.setValue("previewDescription", item.description or "- Description not found -") -- update mask according to size (hardcoded values to align image in mask) local maskData = {} if item.boxsize == "big" then maskData = { image = "box-cover-mask-big", width = "870", height = "435", offsetXY = "154 60" } elseif item.boxsize == "small" then maskData = { image = "box-cover-mask-small", width = "792", height = "594", offsetXY = "135 13" } elseif item.boxsize == "wide" then maskData = { image = "box-cover-mask-wide", width = "756", height = "630", offsetXY = "-190 -70" } end -- loading empty image as placeholder until real image is loaded UI.setAttribute("previewArtImage", "image", tempImage) -- insert the image itself UI.setAttribute("previewArtImage", "image", item.boxart) UI.setAttributes("previewArtMask", maskData) end -- formats the json response from the webrequest into a key-value lua table -- strips the prefix from the community content items function formatLibrary(json_response) library = {} library["campaigns"] = json_response.campaigns library["scenarios"] = json_response.scenarios library["extras"] = json_response.extras library["fanmadeCampaigns"] = {} library["fanmadeScenarios"] = {} library["fanmadePlayerCards"] = {} for _, item in ipairs(json_response.community) do local identifier = nil for str in string.gmatch(item.name, "([^:]+)") do if not identifier then -- grab the first part to know the content type identifier = str else -- update the name without the content type item.name = str break end end if identifier == "Fan Investigators" then table.insert(library["fanmadePlayerCards"], item) elseif identifier == "Fan Campaign" then table.insert(library["fanmadeCampaigns"], item) elseif identifier == "Fan Scenario" then table.insert(library["fanmadeScenarios"], item) end end end -- updates the window content to the requested content function updateDownloadItemList() if not library then return end -- addition of list items according to library file local globalXml = UI.getXmlTable() local contentList = getXmlTableElementById(globalXml, 'contentList') contentList.children = {} for i, v in ipairs(library[contentToShow]) do table.insert(contentList.children, { tag = "Panel", attributes = { id = "panel" .. i }, children = { tag = 'Text', value = v.name, attributes = { id = contentToShow .. "_" .. i, onClick = 'onClick_select', alignment = 'MiddleLeft' } } }) end contentList.attributes.height = #contentList.children * 27 UI.setXmlTable(globalXml) -- select the first item Wait.time(onClick_select, 0.2) end -- called after the webrequest of downloading an item -- deletes the placeholder and spawns the downloaded item function contentDownloadCallback(request, params) requestObj = nil -- error handling if request.is_error or request.response_code ~= 200 then print('Error: ' .. request.error) return end -- initiate content spawning local spawnTable = { json = request.text } if params.replace then local replacedObject = getObjectFromGUID(params.replace) if replacedObject then spawnTable.position = replacedObject.getPosition() spawnTable.rotation = replacedObject.getRotation() spawnTable.scale = replacedObject.getScale() destroyObject(replacedObject) end end -- if spawned from menu, ping the position if params.name then spawnTable["callback_function"] = function(obj) Player.getPlayers()[1].pingTable(obj.getPosition()) end end if pcall(function() spawnObjectJSON(spawnTable) end) then print('Object loaded.') else print('Error loading object.') end end -- downloading of the library file function libraryDownloadCallback(request) if request.is_error or request.response_code ~= 200 then print('error: ' .. request.error) return end local json_response = nil if pcall(function () json_response = JSON.decode(request.text) end) then formatLibrary(json_response) updateDownloadItemList() else print('error parsing downloaded library') end end -- loops through an XML table and returns the specified object ---@param ui Table XmlTable (get this via getXmlTable) ---@param id String Id of the object to return function getXmlTableElementById(ui, id) for _, obj in ipairs(ui) do if obj.attributes and obj.attributes.id and obj.attributes.id == id then return obj end if obj.children then local result = getXmlTableElementById(obj.children, id) if result then return result end end end return nil end --------------------------------------------------------- -- Option Panel related functionality --------------------------------------------------------- -- called by toggling an option function onClick_toggleOption(_, id) local state = self.UI.getAttribute(id, "isOn") -- flip state (and handle stupid "False" value) if state == "False" then state = true else state = false end self.UI.setAttribute(id, "isOn", state) applyOptionPanelChange(id, state) end -- called by the language selection dropdown function languageSelected(_, selectedIndex, id) optionPanel[id] = LANGUAGES[tonumber(selectedIndex) + 1].code end -- returns the ID (position in the table) for a provided language code function returnLanguageId(code) for index, tbl in ipairs(LANGUAGES) do if tbl.code == code then return index end end end -- called by the resource counter selection dropdown function resourceCounterSelected(_, selectedIndex, id) optionPanel[id] = RESOURCE_OPTIONS[tonumber(selectedIndex) + 1] end -- returns the ID for the provided option name function returnResourceCounterId(name) for index, optionName in ipairs(RESOURCE_OPTIONS) do if optionName == name then return index end end end -- sets the option panel to the correct state (corresponding to 'optionPanel') function updateOptionPanelState() for id, optionValue in pairs(optionPanel) do if id == "cardLanguage" and type(optionValue) == "string" then local dropdownId = returnLanguageId(optionValue) - 1 UI.setAttribute(id, "value", dropdownId) elseif id == "useResourceCounters" and type(optionValue) == "string" then local dropdownId = returnResourceCounterId(optionValue) - 1 UI.setAttribute(id, "value", dropdownId) elseif (type(optionValue) == "boolean" and optionValue) or (type(optionValue) == "string" and optionValue) or (type(optionValue) == "table" and #optionValue ~= 0) then UI.setAttribute(id, "isOn", true) else UI.setAttribute(id, "isOn", "False") end end end -- handles the applying of option selections and calls the respective functions based ---@param id String ID of the option that was selected or deselected ---@param state Boolean State of the option (true = enabled) function applyOptionPanelChange(id, state) -- option: Snap tags if id == "useSnapTags" then playmatApi.setLimitSnapsByType(state, "All") optionPanel[id] = state -- option: Draw 1 button elseif id == "showDrawButton" then playmatApi.showDrawButton(state, "All") optionPanel[id] = state -- option: Clickable clue counters elseif id == "useClueClickers" then playmatApi.clickableClues(state, "All") optionPanel[id] = state -- update master clue counter local counter = guidHandler.call("getObjectByOwnerAndType", { owner = "Mythos", type = "MasterClueCounter" }) counter.setVar("useClickableCounters", state) -- option: Play area snap tags elseif id == "playAreaSnapTags" then playAreaApi.setLimitSnapsByType(state) optionPanel[id] = state -- option: Show Title on placing scenarios elseif id == "showTitleSplash" then optionPanel[id] = state -- option: Show clean up helper elseif id == "showCleanUpHelper" then optionPanel[id] = spawnOrRemoveHelper(state, "Clean Up Helper", {-66, 1.6, 46}) -- option: Show hand helper for each player elseif id == "showHandHelper" then for i, color in ipairs(MAT_COLORS) do local pos = playmatApi.transformLocalPosition({0.05, 0, -1.182}, color) local rot = playmatApi.returnRotation(color) optionPanel[id][i] = spawnOrRemoveHelper(state, "Hand Helper", pos, rot) end -- option: Show search assistant for each player elseif id == "showSearchAssistant" then for i, color in ipairs(MAT_COLORS) do local pos = playmatApi.transformLocalPosition({-0.3, 0, -1.182}, color) local rot = playmatApi.returnRotation(color) optionPanel[id][i] = spawnOrRemoveHelper(state, "Search Assistant", pos, rot) end -- option: Show attachment helper elseif id == "showAttachmentHelper" then optionPanel[id] = spawnOrRemoveHelper(state, "Attachment Helper", {-62, 1.4, 0}) -- option: Show CYOA campaign guides elseif id == "showCYOA" then optionPanel[id] = spawnOrRemoveHelper(state, "CYOA Campaign Guides", {39, 1.3, -20}) -- option: Show custom playmat images elseif id == "showCustomPlaymatImages" then optionPanel[id] = spawnOrRemoveHelper(state, "Custom Playmat Images", {67.5, 1.6, 37}) -- option: Show displacement tool elseif id == "showDisplacementTool" then optionPanel[id] = spawnOrRemoveHelper(state, "Displacement Tool", {-57, 1.6, 46}) end end -- handler for spawn / remove functions of helper objects ---@param state Boolean Contains the state of the option: true = spawn it, false = remove it ---@param name String Name of the helper object ---@param position Vector Position of the object (where it will spawn) ---@param rotation Vector Rotation of the object for spawning (default: {0, 270, 0}) ---@return. GUID of the spawnedObj (or nil if object was removed) function spawnOrRemoveHelper(state, name, position, rotation) if (type(state) == "table" and #state == 0) then return removeHelperObject(name) elseif state then Player.getPlayers()[1].pingTable(position) return spawnHelperObject(name, position, rotation).getGUID() else return removeHelperObject(name) end end -- copies the specified tool (by name) from the option panel source bag ---@param name String Name of the object that should be copied ---@param position Table Desired position of the object ---@param rotation Table Desired rotation of the object (defaults to object's rotation) function spawnHelperObject(name, position, rotation) local sourceBag = guidHandler.call("getObjectByOwnerAndType", { owner = "Mythos", type = "OptionPanelSource" }) -- error handling for missing sourceBag if not sourceBag then broadcastToAll("Option panel source bag could not be found!", "Red") return end local spawnTable = { position = position } -- only overrride rotation if there is one provided (object's rotation used instead) if rotation then spawnTable.rotation = rotation end for _, obj in ipairs(sourceBag.getData().ContainedObjects) do if obj["Nickname"] == name then spawnTable.data = obj spawnTable.callback_function = function(spawnedObj) Wait.time(function() spawnedObj.setLock(true) end, 2) end return spawnObjectData(spawnTable) end end end -- removes the specified tool (by name) ---@param name String Object that should be removed function removeHelperObject(name) -- links objects name to the respective option name (to grab the GUID for removal) local referenceTable = { ["Clean Up Helper"] = "showCleanUpHelper", ["Hand Helper"] = "showHandHelper", ["Search Assistant"] = "showSearchAssistant", ["Displacement Tool"] = "showDisplacementTool", ["Custom Playmat Images"] = "showCustomPlaymatImages", ["Attachment Helper"] = "showAttachmentHelper", ["CYOA Campaign Guides"] = "showCYOA" } local data = optionPanel[referenceTable[name]] -- if there is a GUID stored, remove that object if type(data) == "string" then local obj = getObjectFromGUID(data) if obj then obj.destruct() end -- if it is a table (e.g. for the "Hand Helper", remove all of them) elseif type(data) == "table" then for _, guid in pairs(data) do local obj = getObjectFromGUID(guid) if obj then obj.destruct() end end end end -- loads saved options function loadSettings(newOptions) optionPanel = newOptions updateOptionPanelState() for id, state in pairs(optionPanel) do applyOptionPanelChange(id, state) end end -- loads the default options function onClick_defaultSettings() for id, _ in pairs(optionPanel) do local state = false -- override for settings that are enabled by default if id == "useSnapTags" or id == "showTitleSplash" then state = true end applyOptionPanelChange(id, state) end -- clean reset of variables optionPanel = { cardLanguage = "en", playAreaSnapTags = true, showAttachmentHelper = false, showCleanUpHelper = false, showCustomPlaymatImages = false, showCYOA = false, showDisplacementTool = false, showDrawButton = false, showHandHelper = {}, showSearchAssistant = {}, showTitleSplash = true, useClueClickers = false, useResourceCounters = "disabled", useSnapTags = true } -- update UI updateOptionPanelState() end -- splash scenario title on setup function titleSplash(scenarioName) if optionPanel['showTitleSplash'] then -- if there's any ongoing title being displayed, hide it and cancel the waiting function if hideTitleSplashWaitFunctionId then Wait.stop(hideTitleSplashWaitFunctionId) hideTitleSplashWaitFunctionId = nil UI.setAttribute('title_splash', 'active', false) end -- display scenario name and set a 4 seconds (2 seconds animation and 2 seconds on screen) -- wait timer to hide the scenario name UI.setValue('title_splash_text', scenarioName) UI.show('title_splash') hideTitleSplashWaitFunctionId = Wait.time(function() UI.hide('title_splash') hideTitleSplashWaitFunctionId = nil end, 4) soundCubeApi.playSoundByName("Deep Bell") end end --------------------------------------------------------- -- Update notification related functionality --------------------------------------------------------- -- grabs the latest mod version and release notes from GitHub (called onLoad()) function getModVersion() WebRequest.get(SOURCE_REPO .. '/modversion.json', compareVersion) end -- compares the modversion with GitHub and possibly shows the update notification function compareVersion(request) if request.is_error then log(request.error) return end -- global variable to make it accessible for other functions modMeta = JSON.decode(request.text) -- stop here if on latest version if MOD_VERSION == modMeta["latestVersion"] then return end -- stop here if "don't show again" was clicked for this version before if acknowledgedUpgradeVersions[modMeta["latestVersion"]] then return end updateNotificationLoading() -- delay to avoid lagging during onLoad() Wait.time(function() UI.show("FinnIcon") end, 1) end -- updates the XML update notification based on the mod metadata function updateNotificationLoading() -- grab data local highlights = modMeta["releaseHighlights"] -- concatenate the release highlights local highlightText = "• " .. highlights[1] for i, entry in pairs(highlights) do if i ~= 1 then highlightText = highlightText .. "\n• " .. entry end end -- update the XML UI UI.setValue("notificationHeader", "New version available: " .. modMeta["latestVersion"]) UI.setValue("releaseHighlightText", highlightText) UI.setAttribute("highlightRow", "preferredHeight", 20*#highlights) UI.setAttribute("updateNotification", "height", 20*#highlights + 125) end -- close / don't show again buttons on the update notification function onClick_notification(_, parameter) if parameter == "dontShowAgain" then -- this variable tracks if "don't show again" was pressed for a version acknowledgedUpgradeVersions[modMeta["latestVersion"]] = true end UI.hide("FinnIcon") UI.hide("updateNotification") xmlVisibility["updateNotification"] = false end