-- Bundled by luabundle {"version":"1.6.0"} local __bundle_require, __bundle_loaded, __bundle_register, __bundle_modules = (function(superRequire) local loadingPlaceholder = {[{}] = true} local register local modules = {} local require local loaded = {} register = function(name, body) if not modules[name] then modules[name] = body end end require = function(name) local loadedModule = loaded[name] if loadedModule then if loadedModule == loadingPlaceholder then return nil end else if not modules[name] then if not superRequire then local identifier = type(name) == 'string' and '\"' .. name .. '\"' or tostring(name) error('Tried to require ' .. identifier .. ', but no such module has been registered') else return superRequire(name) end end loaded[name] = loadingPlaceholder loadedModule = modules[name](require, loaded, register, modules) loaded[name] = loadedModule end return loadedModule end return require, loaded, register, modules end)(nil) __bundle_register("accessories/TokenArrangerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenArrangerApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") -- local function to call the token arranger, if it is on the table ---@param functionName string Name of the function to cal ---@param argument? table Parameter to pass local function callIfExistent(functionName, argument) local tokenArranger = guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenArranger") if tokenArranger ~= nil then tokenArranger.call(functionName, argument) end end -- updates the token modifiers with the provided data ---@param fullData table Contains the chaos token metadata TokenArrangerApi.onTokenDataChanged = function(fullData) callIfExistent("onTokenDataChanged", fullData) end -- deletes already laid out tokens TokenArrangerApi.deleteCopiedTokens = function() callIfExistent("deleteCopiedTokens") end -- updates the laid out tokens TokenArrangerApi.layout = function() Wait.time(function() callIfExistent("layout") end, 0.1) end return TokenArrangerApi end end) __bundle_register("core/token/TokenManager", function(require, _LOADED, __bundle_register, __bundle_modules) do local guidReferenceApi = require("core/GUIDReferenceApi") local optionPanelApi = require("core/OptionPanelApi") local playAreaApi = require("core/PlayAreaApi") local searchLib = require("util/SearchLib") local tokenSpawnTrackerApi = require("core/token/TokenSpawnTrackerApi") local PLAYER_CARD_TOKEN_OFFSETS = { [1] = { Vector(0, 3, -0.2) }, [2] = { Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [3] = { Vector(0, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [4] = { Vector(0.4, 3, -0.9), Vector(-0.4, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [5] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [6] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2) }, [7] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0, 3, 0.5) }, [8] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(-0.35, 3, 0.5), Vector(0.35, 3, 0.5) }, [9] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5) }, [10] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0, 3, 1.2) }, [11] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(-0.35, 3, 1.2), Vector(0.35, 3, 1.2) }, [12] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0.7, 3, 1.2), Vector(0, 3, 1.2), Vector(-0.7, 3, 1.2) } } -- stateIDs for the multi-stated resource tokens local stateTable = { ["resource"] = 1, ["ammo"] = 2, ["bounty"] = 3, ["charge"] = 4, ["evidence"] = 5, ["secret"] = 6, ["supply"] = 7, ["offering"] = 8 } -- Table of data extracted from the token source bag, keyed by the Memo on each token which -- should match the token type keys ("resource", "clue", etc) local tokenTemplates local playerCardData local locationData local TokenManager = {} local internal = {} -- Spawns tokens for the card. This function is built to just throw a card at it and let it do -- the work once a card has hit an area where it might spawn tokens. It will check to see if -- the card has already spawned, find appropriate data from either the uses metadata or the Data -- Helper, and spawn the tokens. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playmat should pass "Charge"=1 TokenManager.spawnForCard = function(card, extraUses) if tokenSpawnTrackerApi.hasSpawnedTokens(card.getGUID()) then return end local metadata = JSON.decode(card.getGMNotes()) if metadata ~= nil then internal.spawnTokensFromUses(card, extraUses) else internal.spawnTokensFromDataHelper(card) end end -- Spawns a set of tokens on the given card. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string Type of token to spawn, for example "damage", "horror" or "resource" ---@param tokenCount number How many tokens to spawn. For damage or horror this value will be set to the -- spawned state object rather than spawning multiple tokens ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown, subType) local optionPanel = optionPanelApi.getOptions() if tokenType == "damage" or tokenType == "horror" then TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "enabled" then TokenManager.spawnResourceCounterToken(card, tokenCount) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "custom" and tokenCount == 0 then TokenManager.spawnResourceCounterToken(card, tokenCount) else TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown, subType) end end -- Spawns a single counter token and sets the value to tokenValue. Used for damage and horror tokens. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string type of token to spawn, valid values are "damage" and "horror". Other -- types should use spawnMultipleTokens() ---@param tokenValue number Value to set the damage/horror to TokenManager.spawnCounterToken = function(card, tokenType, tokenValue, shiftDown) if tokenValue < 1 or tokenValue > 50 then return end local pos = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[1][1] + Vector(0, 0, shiftDown)) local rot = card.getRotation() TokenManager.spawnToken(pos, tokenType, rot, function(spawned) -- token starts in state 1, so don't attempt to change it to avoid error if tokenValue ~= 1 then spawned.setState(tokenValue) end end) end TokenManager.spawnResourceCounterToken = function(card, tokenCount) local pos = card.positionToWorld(card.positionToLocal(card.getPosition()) + Vector(0, 0.2, -0.5)) local rot = card.getRotation() TokenManager.spawnToken(pos, "resourceCounter", rot, function(spawned) spawned.call("updateVal", tokenCount) end) end -- Spawns a number of tokens. ---@param tokenType string type of token to spawn, valid values are resource", "doom", or "clue". -- Other types should use spawnCounterToken() ---@param tokenCount number How many tokens to spawn ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown, subType) -- not checking the max at this point since clue offsets are calculated dynamically if tokenCount < 1 then return end local offsets = {} if tokenType == "clue" then offsets = internal.buildClueOffsets(card, tokenCount) else -- only up to 12 offset tables defined if tokenCount > 12 then return end for i = 1, tokenCount do offsets[i] = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[tokenCount][i]) -- Fix the y-position for the spawn, since positionToWorld considers rotation which can -- have bad results for face up/down differences offsets[i].y = card.getPosition().y + 0.15 end end if shiftDown ~= nil then -- Copy the offsets to make sure we don't change the static values local baseOffsets = offsets offsets = {} -- get a vector for the shifting (downwards local to the card) local shiftDownVector = Vector(0, 0, shiftDown):rotateOver("y", card.getRotation().y) for i, baseOffset in ipairs(baseOffsets) do offsets[i] = baseOffset + shiftDownVector end end if offsets == nil then error("couldn't find offsets for " .. tokenCount .. ' tokens') return end -- handling for not provided subtype (for example when spawning from custom data helpers) if subType == nil then subType = "" end -- this is used to load the correct state for additional resource tokens (e.g. "Ammo") local callback = nil local stateID = stateTable[string.lower(subType)] if tokenType == "resource" and stateID ~= nil and stateID ~= 1 then callback = function(spawned) spawned.setState(stateID) end end for i = 1, tokenCount do TokenManager.spawnToken(offsets[i], tokenType, card.getRotation(), callback) end end -- Spawns a single token at the given global position by copying it from the template bag. ---@param position tts__Vector Global position to spawn the token ---@param tokenType string type of token to spawn, valid values are "damage", "horror", -- "resource", "doom", or "clue" ---@param rotation tts__Vector Rotation to be used for the new token. Only the y-value will be used, -- x and z will use the default rotation from the source bag ---@param callback? function A callback function triggered after the new token is spawned TokenManager.spawnToken = function(position, tokenType, rotation, callback) internal.initTokenTemplates() local loadTokenType = tokenType if tokenType == "clue" or tokenType == "doom" then loadTokenType = "clueDoom" end if tokenTemplates[loadTokenType] == nil then error("Unknown token type '" .. tokenType .. "'") return end local tokenTemplate = tokenTemplates[loadTokenType] -- Take ONLY the Y-value for rotation, so we don't flip the token coming out of the bag local rot = Vector(tokenTemplate.Transform.rotX, 270, tokenTemplate.Transform.rotZ) if rotation ~= nil then rot.y = rotation.y end if tokenType == "doom" then rot.z = 180 end tokenTemplate.Nickname = "" return spawnObjectData({ data = tokenTemplate, position = position, rotation = rot, callback_function = callback }) end -- Checks a card for metadata to maybe replenish it ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) ---@param mat tts__Object The playmat the card is placed on (for rotation and casting) TokenManager.maybeReplenishCard = function(card, uses, mat) -- TODO: support for cards with multiple uses AND replenish (as of yet, no official card needs that) if uses[1].count and uses[1].replenish then internal.replenishTokens(card, uses, mat) end end -- Delegate function to the token spawn tracker. Exists to avoid circular dependencies in some -- callers. ---@param card tts__Object Card object to reset the tokens for TokenManager.resetTokensSpawned = function(card) tokenSpawnTrackerApi.resetTokensSpawned(card.getGUID()) end -- Pushes new player card data into the local copy of the Data Helper player data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addPlayerCardData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do playerCardData[k] = v end end -- Pushes new location data into the local copy of the Data Helper location data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addLocationData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do locationData[k] = v end end -- Checks to see if the given card has location data in the DataHelper ---@param card tts__Object Card to check for data ---@return boolean: True if this card has data in the helper, false otherwise TokenManager.hasLocationData = function(card) internal.initDataHelperData() return internal.getLocationData(card) ~= nil end internal.initTokenTemplates = function() if tokenTemplates ~= nil then return end tokenTemplates = {} local tokenSource = guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSource") for _, tokenTemplate in ipairs(tokenSource.getData().ContainedObjects) do local tokenName = tokenTemplate.Memo tokenTemplates[tokenName] = tokenTemplate end end -- Copies the data from the DataHelper. Will only happen once. internal.initDataHelperData = function() if playerCardData ~= nil then return end local dataHelper = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") playerCardData = dataHelper.getTable('PLAYER_CARD_DATA') locationData = dataHelper.getTable('LOCATIONS_DATA') end -- Spawn tokens for a card based on the uses metadata. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playmat should pass "Charge"=1 internal.spawnTokensFromUses = function(card, extraUses) local uses = internal.getUses(card) if uses == nil then return end -- go through tokens to spawn local tokenCount for i, useInfo in ipairs(uses) do tokenCount = (useInfo.count or 0) + (useInfo.countPerInvestigator or 0) * playAreaApi.getInvestigatorCount() if extraUses ~= nil and extraUses[useInfo.type] ~= nil then tokenCount = tokenCount + extraUses[useInfo.type] end -- Shift each spawned group after the first down so they don't pile on each other TokenManager.spawnTokenGroup(card, useInfo.token, tokenCount, (i - 1) * 0.8, useInfo.type) end tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a card based on the data helper data. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for internal.spawnTokensFromDataHelper = function(card) internal.initDataHelperData() local playerData = internal.getPlayerCardData(card) if playerData ~= nil then internal.spawnPlayerCardTokensFromDataHelper(card, playerData) end local locationData = internal.getLocationData(card) if locationData ~= nil then internal.spawnLocationTokensFromDataHelper(card, locationData) end end -- Spawn tokens for a player card using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param playerData table Player card data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnPlayerCardTokensFromDataHelper = function(card, playerData) local token = playerData.tokenType local tokenCount = playerData.tokenCount TokenManager.spawnTokenGroup(card, token, tokenCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a location using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param locationData table Location data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnLocationTokensFromDataHelper = function(card, locationData) local clueCount = internal.getClueCountFromData(card, locationData) if clueCount > 0 then TokenManager.spawnTokenGroup(card, "clue", clueCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end end internal.getPlayerCardData = function(card) return playerCardData[card.getName() .. ':' .. card.getDescription()] or playerCardData[card.getName()] end internal.getLocationData = function(card) return locationData[card.getName() .. '_' .. card.getGUID()] or locationData[card.getName()] end internal.getClueCountFromData = function(card, locationData) -- Return the number of clues to spawn on this location if locationData == nil then error('attempted to get clue for unexpected object: ' .. card.getName()) return 0 end if ((card.is_face_down and locationData.clueSide == 'back') or (not card.is_face_down and locationData.clueSide == 'front')) then if locationData.type == 'fixed' then return locationData.value elseif locationData.type == 'perPlayer' then return locationData.value * playAreaApi.getInvestigatorCount() end error('unexpected location type: ' .. locationData.type) end return 0 end -- Gets the right uses structure for this card, based on metadata and face up/down state ---@param card tts__Object Card to pull the uses from internal.getUses = function(card) local metadata = JSON.decode(card.getGMNotes()) or {} if metadata.type == "Location" then if card.is_face_down and metadata.locationBack ~= nil then return metadata.locationBack.uses elseif not card.is_face_down and metadata.locationFront ~= nil then return metadata.locationFront.uses end elseif not card.is_face_down then return metadata.uses end return nil end -- Dynamically create positions for clues on a card. ---@param card tts__Object Card the clues will be placed on ---@param count number How many clues? ---@return table: Array of global positions to spawn the clues at internal.buildClueOffsets = function(card, count) local cluePositions = {} for i = 1, count do local row = math.floor(1 + (i - 1) / 4) local column = (i - 1) % 4 local cluePos = card.positionToWorld(Vector(-0.825 + 0.55 * column, 0, -1.5 + 0.55 * row)) cluePos.y = cluePos.y + 0.05 table.insert(cluePositions, cluePos) end return cluePositions end ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) ---@param mat tts__Object The playmat the card is placed on (for rotation and casting) internal.replenishTokens = function(card, uses, mat) local cardPos = card.getPosition() -- don't continue for cards on the deck (Norman) or in the discard pile if mat.positionToLocal(cardPos).x < -1 then return end -- get current amount of resource tokens on the card local clickableResourceCounter = nil local foundTokens = 0 for _, obj in ipairs(searchLib.onObject(card, "isTileOrToken")) do local memo = obj.getMemo() if (stateTable[memo] or 0) > 0 then foundTokens = foundTokens + math.abs(obj.getQuantity()) obj.destruct() elseif memo == "resourceCounter" then foundTokens = obj.getVar("val") clickableResourceCounter = obj break end end -- this is the theoretical new amount of uses (to be checked below) local newCount = foundTokens + uses[1].replenish -- if there are already more uses than the replenish amount, keep them if foundTokens > uses[1].count then newCount = foundTokens -- only replenish up until the replenish amount elseif newCount > uses[1].count then newCount = uses[1].count end -- update the clickable counter or spawn a group of tokens if clickableResourceCounter then clickableResourceCounter.call("updateVal", newCount) else TokenManager.spawnTokenGroup(card, uses[1].token, newCount, _, uses[1].type) end end return TokenManager end end) __bundle_register("core/OptionPanelApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local OptionPanelApi = {} -- loads saved options ---@param options table Set a new state for the option table OptionPanelApi.loadSettings = function(options) return Global.call("loadSettings", options) end ---@return any: Table of option panel state OptionPanelApi.getOptions = function() return Global.getTable("optionPanel") end return OptionPanelApi end end) __bundle_register("chaosbag/BlessCurseManagerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local BlessCurseManagerApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getManager() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "BlessCurseManager") end -- removes all taken tokens and resets the counts BlessCurseManagerApi.removeTakenTokensAndReset = function() local BlessCurseManager = getManager() Wait.time(function() BlessCurseManager.call("removeTakenTokens", "Bless") end, 0.05) Wait.time(function() BlessCurseManager.call("removeTakenTokens", "Curse") end, 0.10) Wait.time(function() BlessCurseManager.call("doReset", "White") end, 0.15) end -- updates the internal count (called by cards that seal bless/curse tokens) ---@param type string Type of chaos token ("Bless" or "Curse") ---@param guid string GUID of the token BlessCurseManagerApi.sealedToken = function(type, guid) getManager().call("sealedToken", { type = type, guid = guid }) end -- updates the internal count (called by cards that seal bless/curse tokens) ---@param type string Type of chaos token ("Bless" or "Curse") ---@param guid string GUID of the token BlessCurseManagerApi.releasedToken = function(type, guid) getManager().call("releasedToken", { type = type, guid = guid }) end -- updates the internal count (called by cards that seal bless/curse tokens) ---@param type string Type of chaos token ("Bless" or "Curse") ---@param guid string GUID of the token BlessCurseManagerApi.returnedToken = function(type, guid) getManager().call("returnedToken", { type = type, guid = guid }) end -- broadcasts the current status for bless/curse tokens ---@param playerColor string Color of the player to show the broadcast to BlessCurseManagerApi.broadcastStatus = function(playerColor) getManager().call("broadcastStatus", playerColor) end -- removes all bless / curse tokens from the chaos bag and play ---@param playerColor string Color of the player to show the broadcast to BlessCurseManagerApi.removeAll = function(playerColor) getManager().call("doRemove", playerColor) end -- adds bless / curse sealing to the hovered card ---@param playerColor string Color of the player to show the broadcast to ---@param hoveredObject tts__Object Hovered object BlessCurseManagerApi.addBlurseSealingMenu = function(playerColor, hoveredObject) getManager().call("addMenuOptions", { playerColor = playerColor, hoveredObject = hoveredObject }) end return BlessCurseManagerApi end end) __bundle_register("core/GUIDReferenceApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local GUIDReferenceApi = {} local function getGuidHandler() return getObjectFromGUID("123456") end -- Returns the matching object ---@param owner string Parent object for this search ---@param type string Type of object to search for ---@return any: Object reference to the matching object GUIDReferenceApi.getObjectByOwnerAndType = function(owner, type) return getGuidHandler().call("getObjectByOwnerAndType", { owner = owner, type = type }) end -- Returns all matching objects as a table with references ---@param type string Type of object to search for ---@return table: List of object references to matching objects GUIDReferenceApi.getObjectsByType = function(type) return getGuidHandler().call("getObjectsByType", type) end -- Returns all matching objects as a table with references ---@param owner string Parent object for this search ---@return table: List of object references to matching objects GUIDReferenceApi.getObjectsByOwner = function(owner) return getGuidHandler().call("getObjectsByOwner", owner) end -- Sends new information to the reference handler to edit the main index ---@param owner string Parent of the object ---@param type string Type of the object ---@param guid string GUID of the object GUIDReferenceApi.editIndex = function(owner, type, guid) return getGuidHandler().call("editIndex", { owner = owner, type = type, guid = guid }) end -- Returns the owner of an object or the object it's located on ---@param object tts__GameObject Object for this search ---@return string: Parent of the object or object it's located on GUIDReferenceApi.getOwnerOfObject = function(object) return getGuidHandler().call("getOwnerOfObject", object) end return GUIDReferenceApi end end) __bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenSpawnTracker = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getSpawnTracker() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSpawnTracker") end TokenSpawnTracker.hasSpawnedTokens = function(cardGuid) return getSpawnTracker().call("hasSpawnedTokens", cardGuid) end TokenSpawnTracker.markTokensSpawned = function(cardGuid) return getSpawnTracker().call("markTokensSpawned", cardGuid) end TokenSpawnTracker.resetTokensSpawned = function(cardGuid) return getSpawnTracker().call("resetTokensSpawned", cardGuid) end TokenSpawnTracker.resetAllAssetAndEvents = function() return getSpawnTracker().call("resetAllAssetAndEvents") end TokenSpawnTracker.resetAllLocations = function() return getSpawnTracker().call("resetAllLocations") end TokenSpawnTracker.resetAll = function() return getSpawnTracker().call("resetAll") end return TokenSpawnTracker end end) __bundle_register("playermat/PlaymatApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlaymatApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local searchLib = require("util/SearchLib") -- Convenience function to look up a mat's object by color, or get all mats. ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@return table: Single-element if only single playmat is requested local function getMatForColor(matColor) if matColor == "All" then return guidReferenceApi.getObjectsByType("Playermat") else return { matColor = guidReferenceApi.getObjectByOwnerAndType(matColor, "Playermat") } end end -- Returns the color of the closest playmat ---@param startPos table Starting position to get the closest mat from PlaymatApi.getMatColorByPosition = function(startPos) local result, smallestDistance for matColor, mat in pairs(getMatForColor("All")) do local distance = Vector.between(startPos, mat.getPosition()):magnitude() if smallestDistance == nil or distance < smallestDistance then smallestDistance = distance result = matColor end end return result end -- Returns the color of the player's hand that is seated next to the playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getPlayerColor = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("playerColor") end end -- Returns the color of the playmat that owns the playercolor's hand ---@param handColor string Color of the playmat PlaymatApi.getMatColor = function(handColor) for matColor, mat in pairs(getMatForColor("All")) do local playerColor = mat.getVar("playerColor") if playerColor == handColor then return matColor end end end -- Returns if there is the card "Dream-Enhancing Serum" on the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.isDES = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("isDES") end end -- Performs a search of the deck area of the requested playmat and returns the result as table ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getDeckAreaObjects = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("getDeckAreaObjects") end end -- Flips the top card of the deck (useful after deck manipulation for Norman Withers) ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.flipTopCardFromDeck = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("flipTopCardFromDeck") end end -- Returns the position of the discard pile of the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getDiscardPosition = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("returnGlobalDiscardPosition") end end -- Returns the position of the draw pile of the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.getDrawPosition = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("returnGlobalDrawPosition") end end -- Transforms a local position into a global position ---@param localPos table Local position to be transformed ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.transformLocalPosition = function(localPos, matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.positionToWorld(localPos) end end -- Returns the rotation of the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.returnRotation = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getRotation() end end -- Returns a table with spawn data (position and rotation) for a helper object ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param helperName string Name of the helper object PlaymatApi.getHelperSpawnData = function(matColor, helperName) local resultTable = {} local localPositionTable = { ["Hand Helper"] = {0.05, 0, -1.182}, ["Search Assistant"] = {-0.3, 0, -1.182} } for color, mat in pairs(getMatForColor(matColor)) do resultTable[color] = { position = mat.positionToWorld(localPositionTable[helperName]), rotation = mat.getRotation() } end return resultTable end -- Triggers the Upkeep for the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param playerColor string Color of the calling player (for messages) PlaymatApi.doUpkeepFromHotkey = function(matColor, playerColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("doUpkeepFromHotkey", playerColor) end end -- Handles discarding for the requested playmat for the provided list of objects ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") ---@param objList table List of objects to discard PlaymatApi.discardListOfObjects = function(matColor, objList) for _, mat in pairs(getMatForColor(matColor)) do mat.call("discardListOfObjects", objList) end end -- Returns the active investigator id ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlaymatApi.returnInvestigatorId = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("activeInvestigatorId") end end -- Returns the position for encounter card drawing ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") ---@param stack boolean If true, returns the leftmost position instead of the first empty from the right PlaymatApi.getEncounterCardDrawPosition = function(matColor, stack) for _, mat in pairs(getMatForColor(matColor)) do return Vector(mat.call("getEncounterCardDrawPosition", stack)) end end -- Sets the requested playmat's snap points to limit snapping to matching card types or not. If -- matchTypes is true, the main card slot snap points will only snap assets, while the -- investigator area point will only snap Investigators. If matchTypes is false, snap points will -- be reset to snap all cards. ---@param matchCardTypes boolean Whether snap points should only snap for the matching card types ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.setLimitSnapsByType = function(matchCardTypes, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("setLimitSnapsByType", matchCardTypes) end end -- Sets the requested playmat's draw 1 button to visible ---@param isDrawButtonVisible boolean Whether the draw 1 button should be visible or not ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.showDrawButton = function(isDrawButtonVisible, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("showDrawButton", isDrawButtonVisible) end end -- Shows or hides the clickable clue counter for the requested playmat ---@param showCounter boolean Whether the clickable counter should be present or not ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.clickableClues = function(showCounter, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("clickableClues", showCounter) end end -- Removes all clues (to the trash for tokens and counters set to 0) for the requested playmat ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.removeClues = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("removeClues") end end -- Reports the clue count for the requested playmat ---@param useClickableCounters boolean Controls which type of counter is getting checked PlaymatApi.getClueCount = function(useClickableCounters, matColor) local count = 0 for _, mat in pairs(getMatForColor(matColor)) do count = count + mat.call("getClueCount", useClickableCounters) end return count end -- Updates the specified owned counter ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param type string Counter to target ---@param newValue number Value to set the counter to ---@param modifier number If newValue is not provided, the existing value will be adjusted by this modifier PlaymatApi.updateCounter = function(matColor, type, newValue, modifier) for _, mat in pairs(getMatForColor(matColor)) do mat.call("updateCounter", { type = type, newValue = newValue, modifier = modifier }) end end -- Triggers the draw function for the specified playmat ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param number number Amount of cards to draw PlaymatApi.drawCardsWithReshuffle = function(matColor, number) for _, mat in pairs(getMatForColor(matColor)) do mat.call("drawCardsWithReshuffle", number) end end -- Returns the resource counter amount ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") ---@param type string Counter to target PlaymatApi.getCounterValue = function(matColor, type) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("getCounterValue", type) end end -- Returns a list of mat colors that have an investigator placed PlaymatApi.getUsedMatColors = function() local localInvestigatorPosition = { x = -1.17, y = 1, z = -0.01 } local usedColors = {} for matColor, mat in pairs(getMatForColor("All")) do local searchPos = mat.positionToWorld(localInvestigatorPosition) local searchResult = searchLib.atPosition(searchPos, "isCardOrDeck") if #searchResult > 0 then table.insert(usedColors, matColor) end end return usedColors end -- Resets the specified skill tracker to "1, 1, 1, 1" ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.resetSkillTracker = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("resetSkillTracker") end end -- Finds all objects on the playmat and associated set aside zone and returns a table ---@param matColor string Color of the playmat - White, Orange, Green, Red or All ---@param filter string Name of the filte function (see util/SearchLib) PlaymatApi.searchAroundPlaymat = function(matColor, filter) local objList = {} for _, mat in pairs(getMatForColor(matColor)) do for _, obj in ipairs(mat.call("searchAroundSelf", filter)) do table.insert(objList, obj) end end return objList end -- Discard a non-hidden card from the corresponding player's hand ---@param matColor string Color of the playmat - White, Orange, Green, Red or All PlaymatApi.doDiscardOne = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("doDiscardOne") end end -- Triggers the metadata sync for all playmats PlaymatApi.syncAllCustomizableCards = function() for _, mat in pairs(getMatForColor("All")) do mat.call("syncAllCustomizableCards") end end return PlaymatApi end end) __bundle_register("core/Global", function(require, _LOADED, __bundle_register, __bundle_modules) 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 searchLib = require("util/SearchLib") 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 "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 chaosTokens = {} local chaosTokensLastMatGUID = nil -- chaos token stat tracking local tokenDrawingStats = { ["Overall"] = {} } local bagSearchers = {} local hideTitleSplashWaitFunctionId = nil -- online functionality related variables local MOD_VERSION = "3.8.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 tabIdTable = { tab1 = "campaigns", tab2 = "scenarios", tab3 = "fanmadeCampaigns", tab4 = "fanmadeScenarios", tab5 = "fanmadePlayerCards" } -- optionPanel data (intentionally not local!) 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/' } } --------------------------------------------------------- -- general code --------------------------------------------------------- -- saving state of optionPanel to restore later function onSave() local chaosTokensGUID = {} for _, obj in ipairs(chaosTokens) do if obj ~= nil then table.insert(chaosTokensGUID, obj.getGUID()) end end return JSON.encode({ optionPanel = optionPanel, acknowledgedUpgradeVersions = acknowledgedUpgradeVersions, chaosTokensLastMatGUID = chaosTokensLastMatGUID, chaosTokensGUID = chaosTokensGUID }) end function onLoad(savedData) if savedData and savedData ~= "" then local loadedData = JSON.decode(savedData) optionPanel = loadedData.optionPanel acknowledgedUpgradeVersions = loadedData.acknowledgedUpgradeVersions chaosTokensLastMatGUID = loadedData.chaosTokensLastMatGUID -- restore saved state for drawn chaos tokens for _, guid in ipairs(loadedData.chaosTokensGUID or {}) do table.insert(chaosTokens, getObjectFromGUID(guid)) end updateOptionPanelState() end for _, guid in ipairs(NOT_INTERACTABLE) do local obj = getObjectFromGUID(guid) if obj ~= nil then obj.interactable = false end end 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) local 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) local 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 -- TTS event for objects that enter zones -- used to detect the "token discard zones" beneath the hand zones function onObjectEnterZone(zone, enteringObj) if zone.getName() ~= "TokenDiscardZone" then return end if tokenChecker.isChaosToken(enteringObj) then return end if enteringObj.type == "Tile" and enteringObj.getMemo() and enteringObj.getLock() == false then local matcolor = playmatApi.getMatColorByPosition(enteringObj.getPosition()) local trash = guidReferenceApi.getObjectByOwnerAndType(matcolor, "Trash") trash.putObject(enteringObj) end end -- handle card drawing via number typing for multihanded gameplay -- (and additionally allow Norman Withers to draw multiple cards via number) function onObjectNumberTyped(hoveredObject, playerColor, number) -- only continue for decks or cards if hoveredObject.type ~= "Deck" and hoveredObject.type ~= "Card" then return end -- check whether the hovered object is part of a players draw objects for _, color in ipairs(playmatApi.getUsedMatColors()) do local deckAreaObjects = playmatApi.getDeckAreaObjects(color) if deckAreaObjects.topCard == hoveredObject or deckAreaObjects.draw == hoveredObject then playmatApi.drawCardsWithReshuffle(color, number) return true end end -- check if this is a card with states (and then change state instead of drawing it) local states = hoveredObject.getStates() if states ~= nil and #states > 0 then local stateId = hoveredObject.getStateId() if stateId ~= number and (#states + 1) >= number then hoveredObject.setState(number) return true end end end --------------------------------------------------------- -- chaos token drawing --------------------------------------------------------- -- checks scripting zone for chaos bag (also called by a lot of objects!) function findChaosBag() local chaosBagZone = guidReferenceApi.getObjectByOwnerAndType("Mythos", "ChaosBagZone") -- error handling: scripting zone not found if chaosBagZone == nil then printToAll("Zone for chaos bag detection couldn't be found.", "Red") return end for _, item in ipairs(chaosBagZone.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() local chaosBag = findChaosBag() for _, token in pairs(chaosTokens) do if token ~= nil then chaosBag.putObject(token) end end chaosTokens = {} end -- returns a single chaos token to the bag and calls respective functions function returnChaosTokenToBag(token) local name = token.getName() local guid = token.getGUID() local chaosBag = findChaosBag() chaosBag.putObject(token) tokenArrangerApi.layout() if name == "Bless" or name == "Curse" then blessCurseManagerApi.releasedToken(name, guid) end end function getTokenIndex(token) for i, obj in ipairs(chaosTokens) do if obj == token then return i end end 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 _, 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 -- converts the human readable name to the empty name that the bag uses function getChaosTokenName(tokenName) if tokenName == "Custom Token" then tokenName = "" end return tokenName end -- converts the empty name to the human readable name function getReadableTokenName(tokenName) if tokenName == "" then tokenName = "Custom Token" end return tokenName end -- called by playermats (by the "Draw chaos token" button) function drawChaosToken(params) if not canTouchChaosTokens() then return end local tokenOffset = { -1.55, 0.25, -0.58 } local matGUID = params.mat.getGUID() -- return token(s) on other playmat first if chaosTokensLastMatGUID ~= nil and chaosTokensLastMatGUID ~= matGUID and #chaosTokens ~= 0 then returnChaosTokens() chaosTokensLastMatGUID = nil return end chaosTokensLastMatGUID = matGUID -- if we have left clicked and have no tokens OR if we have right clicked if params.drawAdditional or #chaosTokens == 0 then local chaosBag = findChaosBag() if #chaosBag.getObjects() == 0 then return end chaosBag.shuffle() local indexOfReturnedToken local takeParameters = {} -- add the token to the list, compute new position based on list length if params.returnedToken then trackChaosToken(params.returnedToken.getName(), matGUID, true) indexOfReturnedToken = getTokenIndex(params.returnedToken) takeParameters.position = params.returnedToken.getPosition() if #chaosTokens > indexOfReturnedToken then takeParameters.rotation = params.mat.getRotation() + Vector(0, 0, -8) else takeParameters.rotation = params.returnedToken.getRotation() end returnChaosTokenToBag(params.returnedToken) else tokenOffset[1] = tokenOffset[1] + (0.17 * #chaosTokens) takeParameters.position = params.mat.positionToWorld(tokenOffset) takeParameters.rotation = params.mat.getRotation() end local token if params.guidToBeResolved then -- resolve a sealed token from a card token = getObjectFromGUID(params.guidToBeResolved) token.setPositionSmooth(takeParameters.position) local guid = token.getGUID() local tokenType = token.getName() if tokenType == "Bless" or tokenType == "Curse" then blessCurseManagerApi.releasedToken(tokenType, guid) end tokenArrangerApi.layout() else -- take a token from the bag, either specified or random if params.tokenType then for i, lookedForToken in ipairs(chaosBag.getObjects()) do if lookedForToken.nickname == params.tokenType then takeParameters.index = i - 1 end end end token = chaosBag.takeObject(takeParameters) end -- 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 "") trackChaosToken(name, matGUID) if params.returnedToken then chaosTokens[indexOfReturnedToken] = token else chaosTokens[#chaosTokens + 1] = token end 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, subtract) -- initialize tables if not tokenDrawingStats[matGUID] then tokenDrawingStats[matGUID] = {} end -- increase stats by 1 (or decrease if token is returned) local modifier = (subtract and -1 or 1) local tokenName = getReadableTokenName(tokenName) tokenDrawingStats["Overall"][tokenName] = (tokenDrawingStats["Overall"][tokenName] or 0) + modifier tokenDrawingStats[matGUID][tokenName] = (tokenDrawingStats[matGUID][tokenName] or 0) + modifier 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"] or 0 if playerSquidCount > maxSquid then squidKing = playerName maxSquid = playerSquidCount end end -- get the total count of drawn tokens for the player local totalCount = 0 for _, 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) -- print stats in order of the "ID_URL_MAP" for _, subtable in pairs(ID_URL_MAP) do local tokenName = subtable.name local value = personalStats[tokenName] if value and value ~= 0 then printToAll(tokenName .. ': ' .. tostring(value)) end end -- also print stats for custom tokens local customTokenName = getReadableTokenName("") local customTokenCount = personalStats[customTokenName] if customTokenCount and customTokenCount ~= 0 then printToAll(customTokenName .. ': ' .. tostring(customTokenCount)) 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() tokenDrawingStats = { ["Overall"] = {} } end --------------------------------------------------------- -- Difficulty selector script --------------------------------------------------------- -- called for button creation on the difficulty selectors ---@param args table Parameters for this function: -- object TTSObject Usually "self" -- 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 args table Parameters for this function: -- object object Usually "self" -- key string Name of the scenario -- 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_HELPER = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") 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 the currently drawn chaos tokens ---@api ChaosBagApi function getChaosTokensinPlay() return chaosTokens 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 = {} chaosTokensLastMatGUID = 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 -- returns all sealed tokens on cards to the chaos bag function releaseAllSealedTokens(playerColor) 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 "Custom URL" button in the playarea image gallery function onClick_customUrl(player) changeWindowVisibilityForColor(player.color, "playareaGallery") Wait.time(function() player.showInputDialog("Enter a custom URL for the playarea image", "", function(newURL) playAreaApi.updateSurface(newURL) end) end, 0.15) end -- click function for the download button in the preview window function onClick_download(player) local params = library[contentToShow][currentListItem] params.player = player placeholder_download(params) end -- the download button on the placeholder objects calls this to directly initiate a download ---@param params table contains url and guid of replacement object function placeholder_download(params) 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 params.player then changeWindowVisibilityForColor(params.player.color, "downloadWindow", false) end return 1 end local url = SOURCE_REPO .. '/' .. params.url requestObj = WebRequest.get(url, function(request) contentDownloadCallback(request, params) end) startLuaCoroutine(Global, 'downloadCoroutine') end -- spawns a bag that contains every object from the library function onClick_downloadAll(player) broadcastToAll("Download initiated - this will take a few minutes!") -- hide download window changeWindowVisibilityForColor(player.color, "downloadWindow", false) startLuaCoroutine(Global, "coroutineDownloadAll") end function coroutineDownloadAll() local JSON = [[ { "Name": "Bag", "Transform": { "posX": {{POSX}}, "posY": 2, "posZ": -95, "rotX": 0, "rotY": 270, "rotZ": 0, "scaleX": 1, "scaleY": 1, "scaleZ": 1 }, "Nickname": "{{NICKNAME}}", "Bag": { "Order": 0 }, "ContainedObjects": [ ]] local posx = -45.0 local downloadedItems = 0 local skippedItems = 0 -- loop through the library to add content for contentType, objectList in pairs(library) do broadcastToAll("Downloading " .. contentType .. "...") local contained = "" for _, params in ipairs(objectList) do local request = WebRequest.get(SOURCE_REPO .. '/' .. params.url, function() end) local start = os.time() while true do if request.is_done then contained = contained .. request.text .. "," downloadedItems = downloadedItems + 1 break -- time-out if item can't be loaded in 5s elseif request.is_error or (os.time() - start) > 5 then skippedItems = skippedItems + 1 break end coroutine.yield(0) end end local JSONCopy = JSON JSONCopy = JSONCopy .. contained .. "]}" JSONCopy = JSONCopy:gsub("{{POSX}}", posx) JSONCopy = JSONCopy:gsub("{{NICKNAME}}", contentType) spawnObjectJSON({ json = JSONCopy }) posx = posx + 3 end broadcastToAll(downloadedItems .. " objects downloaded.", "Green") broadcastToAll(skippedItems .. " objects had a time-out / error.", "Orange") return 1 end -- spawns a placeholder box for the selected object function onClick_spawnPlaceholder(player) -- get object references local item = library[contentToShow][currentListItem] local dummy = guidReferenceApi.getObjectByOwnerAndType("Mythos", "PlaceholderBoxDummy") -- error handling if not item.boxsize or item.boxsize == "" or not item.boxart or item.boxart == "" then print("Error loading object.") return end -- get data for placeholder local spawnPos = { -39.5, 2, -87 } local meshTable = { big = "https://raw.githubusercontent.com/RobMayer/TTSLibrary/master/advboxes/core_h_MSH.obj", small = "https://raw.githubusercontent.com/RobMayer/TTSLibrary/master/advboxes/tuckbox_h_MSH.obj", wide = "http://cloud-3.steamusercontent.com/ugc/2278324073260846176/33EFCAF30567F8756F665BE5A2A6502E9C61C7F7/" } local scaleTable = { big = { 1.00, 0.14, 1.00 }, small = { 2.21, 0.46, 2.42 }, wide = { 2.00, 0.11, 1.69 } } local placeholder = spawnObject({ type = "Custom_Model", position = spawnPos, rotation = { 0, 270, 0 }, scale = scaleTable[item.boxsize], }) placeholder.setCustomObject({ mesh = meshTable[item.boxsize], diffuse = item.boxart, material = 3 }) if item.boxsize == "big" then placeholder.addTag("LargeBox") end placeholder.setColorTint({ 1, 1, 1, 71 / 255 }) placeholder.setName(item.name) placeholder.setDescription("by " .. (item.author or "Unknown")) placeholder.setGMNotes(item.url) placeholder.setLuaScript(dummy.getLuaScript()) Player.getPlayers()[1].pingTable(spawnPos) -- hide download window changeWindowVisibilityForColor(player.color, "downloadWindow", false) end -- toggles the visibility of the respective UI ---@param player tts__Player Player that triggered this ---@param windowId string Name of the UI to toggle function onClick_toggleUi(player, windowId) if windowId == "Navigation Overlay" then navigationOverlayApi.cycleVisibility(player.color) return end -- hide the playAreaGallery if visible if windowId == "downloadWindow" then changeWindowVisibilityForColor(player.color, "playAreaGallery", false) -- hide the downloadWindow if visible elseif windowId == "playAreaGallery" then changeWindowVisibilityForColor(player.color, "downloadWindow", false) end changeWindowVisibilityForColor(player.color, windowId) end -- toggles the visibility of the specific window for the specified color ---@param color string Player color to toggle the visibility for ---@param windowId string ID of the XML element ---@param overrideState? boolean Forcefully sets the new visibility ---@return boolean visible Returns the new state of the visibility function changeWindowVisibilityForColor(color, windowId, overrideState) -- current state local colorString = UI.getAttribute(windowId, "visibility") or "" -- parse the visibility string local visible = false local viewers = {} for str in string.gmatch(colorString, "%a+") do table.insert(viewers, str) if str == color then visible = true end end -- add / remove the color as viewer if visible == true then removeValueFromTable(viewers, color) elseif visible == false then table.insert(viewers, color) end visible = not visible -- resolve override if overrideState == true and visible == false then table.insert(viewers, color) visible = true elseif overrideState == false and visible == true then removeValueFromTable(viewers, color) visible = false end -- construct new string local newColorString = "" for _, viewer in ipairs(viewers) do newColorString = newColorString .. viewer .. "|" end -- remove last delimiter newColorString = newColorString:sub(1, -2) -- update the visibility of the XML UI.setAttribute(windowId, "visibility", newColorString) UI.setAttribute(windowId, "active", newColorString ~= "") return visible end -- forwards the call to the onClick function function togglePlayAreaGallery(playerColor) changeWindowVisibilityForColor(playerColor, "playareaGallery") 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 updateGlobalXml(globalXml) -- select the first item Wait.time(onClick_select, 0.2) end -- this helper function updates the global XML while preserving the visibility of windows function updateGlobalXml(newXml) -- preserve visibility settings for these elements local windowIdList = { "playAreaGallery", "downloadWindow", "optionPanel" } -- get current state and update newXml for _, windowId in ipairs(windowIdList) do local element = getXmlTableElementById(newXml, windowId) element.attributes.active = UI.getAttribute(windowId, "active") element.attributes.visibility = UI.getAttribute(windowId, "visibility") end UI.setXmlTable(newXml) 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 position is undefined, get empty position if not spawnTable.position then spawnTable.rotation = { 0, 270, 0 } local pos = getValidSpawnPosition() if pos then spawnTable.position = pos else broadcastToAll("Please make space in the area below the tentacle stand in the upper middle of the table and try again.", "Red") return end end -- if spawned from menu, move the camera and/or ping the table if params.name then spawnTable["callback_function"] = function(obj) Wait.time(function() -- move camera if params.player then params.player.lookAt({ position = obj.getPosition(), pitch = 65, yaw = 90, distance = 65 }) end -- ping object local pingPlayer = params.player or Player.getPlayers()[1] pingPlayer.pingTable(obj.getPosition()) end, 0.1) end end if pcall(function() spawnObjectJSON(spawnTable) end) then print('Object loaded.') else print('Error loading object.') end end -- gets the first empty position to spawn a custom content object safely function getValidSpawnPosition() local potentialSpawnPositionX = { 65, 50, 35 } local potentialSpawnPositionY = 1.5 local potentialSpawnPositionZ = { 35, 21, 7, -7, -21, -35 } for i, posX in ipairs(potentialSpawnPositionX) do for j, posZ in ipairs(potentialSpawnPositionZ) do local pos = { x = posX, y = potentialSpawnPositionY, z = posZ, } if checkPositionForContentSpawn(pos) then return pos end end end return nil end -- checks whether something is in the specified position -- returns true if empty function checkPositionForContentSpawn(checkPos) local searchResult = searchLib.atPosition(checkPos) -- first hit is the table surface, additional hits means something is there return #searchResult == 1 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 --------------------------------------------------------- -- changes the UI state and the internal variable for the togglebuttons function onClick_toggleOption(_, _, id) local currentState = optionPanel[id] local newState = not currentState applyOptionPanelChange(id, newState) UI.setAttribute(id, "image", newState and "option-on" or "option-off") end -- color selection for playArea function onClick_playAreaConnectionColor(player, _, id) player.showColorDialog(optionPanel[id], function(color) applyOptionPanelChange(id, color) end) 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 -- called by the playermat removal selection dropdown function playermatRemovalSelected(player, selectedIndex, id) if selectedIndex == "0" then return end local matColorList = { "White", "Orange", "Green", "Red" } local matColor = matColorList[tonumber(selectedIndex)] local mat = guidReferenceApi.getObjectByOwnerAndType(matColor, "Playermat") if mat then -- confirmation dialog about deletion player.pingTable(mat.getPosition()) player.showConfirmDialog("Do you really want to remove " .. matColor .. "'s playermat and related objects? This can't be reversed.", function() removePlayermat(matColor) end) else -- info dialog that it is already deleted player.showInfoDialog(matColor .. "'s playermat has already been removed.") end -- set selected value back to first option UI.setAttribute(id, "value", 0) end -- removes a playermat and all related objects from play ---@param matColor string Color of the playermat to remove function removePlayermat(matColor) local matObjects = guidReferenceApi.getObjectsByOwner(matColor) if not matObjects.Playermat then return end -- remove action tokens local actionTokens = playmatApi.searchAroundPlaymat(matColor, "isActionToken") for _, obj in ipairs(actionTokens) do obj.destruct() end -- remove mat owned objects for _, obj in pairs(matObjects) do obj.destruct() 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 id == "playAreaConnectionColor" then UI.setAttribute(id, "color", "#" .. Color.new(optionValue):toHex()) elseif (type(optionValue) == "boolean" and optionValue) or (type(optionValue) == "string" and optionValue) or (type(optionValue) == "table" and #optionValue ~= 0) then UI.setAttribute(id, "image", "option-on") else UI.setAttribute(id, "image", "option-off") end end end -- handles the applying of option selections and calls the respective functions based on the id ---@param id string ID of the option that was selected or deselected ---@param state boolean|any State of the option (true = enabled) function applyOptionPanelChange(id, state) optionPanel[id] = state -- option: Snap tags if id == "useSnapTags" then playmatApi.setLimitSnapsByType(state, "All") -- option: Draw 1 button elseif id == "showDrawButton" then playmatApi.showDrawButton(state, "All") -- option: Clickable clue counters elseif id == "useClueClickers" then playmatApi.clickableClues(state, "All") -- update master clue counter local counter = guidReferenceApi.getObjectByOwnerAndType("Mythos", "MasterClueCounter") counter.setVar("useClickableCounters", state) -- option: Play area snap tags elseif id == "playAreaConnections" then playAreaApi.setConnectionDrawState(state) -- option: Play area connection color elseif id == "playAreaConnectionColor" then playAreaApi.setConnectionColor(state) UI.setAttribute(id, "color", "#" .. Color.new(state):toHex()) -- option: Play area snap tags elseif id == "playAreaSnapTags" then playAreaApi.setLimitSnapsByType(state) -- option: Show clean up helper elseif id == "showCleanUpHelper" then spawnOrRemoveHelper(state, "Clean Up Helper", { -66, 1.6, 46 }) -- option: Show hand helper for each player elseif id == "showHandHelper" then spawnOrRemoveHelperForPlayermats("Hand Helper", state) -- option: Show search assistant for each player elseif id == "showSearchAssistant" then spawnOrRemoveHelperForPlayermats("Search Assistant", state) -- option: Show attachment helper elseif id == "showAttachmentHelper" then spawnOrRemoveHelper(state, "Attachment Helper", { -62, 1.4, 0 }) -- option: Show CYOA campaign guides elseif id == "showCYOA" then spawnOrRemoveHelper(state, "CYOA Campaign Guides", { 39, 1.3, -20 }) -- option: Show displacement tool elseif id == "showDisplacementTool" then spawnOrRemoveHelper(state, "Displacement Tool", { -57, 1.6, 46 }) end end -- spawns or removes a helper object for all playermats ---@param helperName string Name of the helper object ---@param state boolean Contains the state of the option: true = spawn it, false = remove it function spawnOrRemoveHelperForPlayermats(helperName, state) for color, data in pairs(playmatApi.getHelperSpawnData("All", helperName)) do spawnOrRemoveHelper(state, helperName, data.position, data.rotation, color) 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 tts__Vector Position of the object (where it will spawn) ---@param rotation? tts__Vector Rotation of the object for spawning (default: {0, 270, 0}) ---@param owner? string Owner of the object (defaults to "Mythos") ---@return string|nil GUID GUID of the spawnedObj (or nil if object was removed) function spawnOrRemoveHelper(state, name, position, rotation, owner) if state then Player.getPlayers()[1].pingTable(position) local spawnedGUID = spawnHelperObject(name, position, rotation).getGUID() local cleanName = name:gsub("%s+", "") guidReferenceApi.editIndex(owner or "Mythos", cleanName, spawnedGUID) else 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 tts__Vector Desired position of the object ---@param rotation? tts__Vector Desired rotation of the object (defaults to object's rotation) function spawnHelperObject(name, position, rotation) local sourceBag = guidReferenceApi.getObjectByOwnerAndType("Mythos", "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) local cleanName = name:gsub("%s+", "") for _, obj in pairs(guidReferenceApi.getObjectsByType(cleanName)) do obj.destruct() end end -- loads saved options ---@param newOptions table Contains the new state for the option panel function loadSettings(newOptions) for id, state in pairs(newOptions) do if optionPanel[id] ~= state then optionPanel[id] = state applyOptionPanelChange(id, state) end end -- update XML UI state updateOptionPanelState() end -- loads the default options function onClick_defaultSettings() -- clean reset of variables optionPanel = { cardLanguage = "en", changePlayAreaImage = false, playAreaConnectionColor = { a = 1, b = 0.4, g = 0.4, r = 0.4 }, playAreaConnections = true, playAreaSnapTags = true, showAttachmentHelper = false, showCleanUpHelper = false, showCYOA = false, showDisplacementTool = false, showDrawButton = false, showHandHelper = false, showSearchAssistant = false, showTitleSplash = true, useClueClickers = false, useResourceCounters = "disabled", useSnapTags = true } -- applying changes for id, state in pairs(optionPanel) do applyOptionPanelChange(id, state) end -- 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 or newer version if convertVersionToNumber(MOD_VERSION) >= convertVersionToNumber(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 -- converts a version number to a string ---@param version string Version number, separated by dots (e.g. 3.3.1) function convertVersionToNumber(version) local major, minor, patch = string.match(version, "(%d+)%.(%d+)%.(%d+)") return major * 100 + minor * 10 + patch 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") end --------------------------------------------------------- -- Utility functions --------------------------------------------------------- function removeValueFromTable(t, val) for i, v in ipairs(t) do if v == val then table.remove(t, i) break end end end end) __bundle_register("core/MythosAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local MythosAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getMythosArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "MythosArea") end ---@return any: Table of chaos token metadata (if provided through scenario reference card) MythosAreaApi.returnTokenData = function() return getMythosArea().call("returnTokenData") end ---@return any: Object reference to the encounter deck MythosAreaApi.getEncounterDeck = function() return getMythosArea().call("getEncounterDeck") end -- draw an encounter card for the requesting mat to the first empty spot from the right ---@param matColor string Playermat that triggered this ---@param position tts__Vector Position for the encounter card MythosAreaApi.drawEncounterCard = function(matColor, position) getMythosArea().call("drawEncounterCard", { matColor = matColor, position = position }) end -- reshuffle the encounter deck MythosAreaApi.reshuffleEncounterDeck = function() getMythosArea().call("reshuffleEncounterDeck") end return MythosAreaApi end end) __bundle_register("core/NavigationOverlayApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local NavigationOverlayApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getNOHandler() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "NavigationOverlayHandler") end -- copies the visibility for the Navigation overlay ---@param startColor string Color of the player to copy from ---@param targetColor string Color of the targeted player NavigationOverlayApi.copyVisibility = function(startColor, targetColor) getNOHandler().call("copyVisibility", { startColor = startColor, targetColor = targetColor }) end -- changes the Navigation Overlay view ("Full View" --> "Play Areas" --> "Closed" etc.) ---@param playerColor string Color of the player to update the visibility for NavigationOverlayApi.cycleVisibility = function(playerColor) getNOHandler().call("cycleVisibility", playerColor) end -- loads the specified camera for a player ---@param player tts__Player Player whose camera should be moved ---@param camera number|string If number: Index of the camera view to load | If string: Color of the playermat to swap to NavigationOverlayApi.loadCamera = function(player, camera) getNOHandler().call("loadCameraFromApi", { player = player, camera = camera }) end return NavigationOverlayApi end end) __bundle_register("core/token/TokenChecker", function(require, _LOADED, __bundle_register, __bundle_modules) do local CHAOS_TOKEN_NAMES = { ["Elder Sign"] = true, ["+1"] = true, ["0"] = true, ["-1"] = true, ["-2"] = true, ["-3"] = true, ["-4"] = true, ["-5"] = true, ["-6"] = true, ["-7"] = true, ["-8"] = true, ["Skull"] = true, ["Cultist"] = true, ["Tablet"] = true, ["Elder Thing"] = true, ["Auto-fail"] = true, ["Bless"] = true, ["Curse"] = true, ["Frost"] = true } local TokenChecker = {} -- returns true if the passed object is a chaos token (by name) TokenChecker.isChaosToken = function(obj) if obj.type == "Tile" and CHAOS_TOKEN_NAMES[obj.getName()] then return true else return false end end return TokenChecker end end) __bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules) require("core/Global") end) __bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getPlayArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "PlayArea") end local function getInvestigatorCounter() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "InvestigatorCounter") end -- Returns the current value of the investigator counter from the playmat ---@return number: Number of investigators currently set on the counter PlayAreaApi.getInvestigatorCount = function() return getInvestigatorCounter().getVar("val") end -- Updates the current value of the investigator counter from the playmat ---@param count number Number of investigators to set on the counter PlayAreaApi.setInvestigatorCount = function(count) getInvestigatorCounter().call("updateVal", count) end -- Move all contents on the play area (cards, tokens, etc) one slot in the given direction. Certain -- fixed objects will be ignored, as will anything the player has tagged with 'displacement_excluded' ---@param playerColor string Color of the player requesting the shift for messages PlayAreaApi.shiftContentsUp = function(playerColor) getPlayArea().call("shiftContentsUp", playerColor) end PlayAreaApi.shiftContentsDown = function(playerColor) getPlayArea().call("shiftContentsDown", playerColor) end PlayAreaApi.shiftContentsLeft = function(playerColor) getPlayArea().call("shiftContentsLeft", playerColor) end PlayAreaApi.shiftContentsRight = function(playerColor) getPlayArea().call("shiftContentsRight", playerColor) end ---@param state boolean This controls whether location connections should be drawn PlayAreaApi.setConnectionDrawState = function(state) getPlayArea().call("setConnectionDrawState", state) end ---@param color string Connection color to be used for location connections PlayAreaApi.setConnectionColor = function(color) getPlayArea().call("setConnectionColor", color) end -- Event to be called when the current scenario has changed ---@param scenarioName string Name of the new scenario PlayAreaApi.onScenarioChanged = function(scenarioName) getPlayArea().call("onScenarioChanged", scenarioName) end -- Sets this playmat's snap points to limit snapping to locations or not. -- If matchTypes is false, snap points will be reset to snap all cards. ---@param matchCardTypes boolean Whether snap points should only snap for the matching card types PlayAreaApi.setLimitSnapsByType = function(matchCardTypes) getPlayArea().call("setLimitSnapsByType", matchCardTypes) end -- Receiver for the Global tryObjectEnterContainer event. Used to clear vector lines from dragged -- cards before they're destroyed by entering the container PlayAreaApi.tryObjectEnterContainer = function(container, object) getPlayArea().call("tryObjectEnterContainer", { container = container, object = object }) end -- Counts the VP on locations in the play area PlayAreaApi.countVP = function() return getPlayArea().call("countVP") end -- Highlights all locations in the play area without metadata ---@param state boolean True if highlighting should be enabled PlayAreaApi.highlightMissingData = function(state) return getPlayArea().call("highlightMissingData", state) end -- Highlights all locations in the play area with VP ---@param state boolean True if highlighting should be enabled PlayAreaApi.highlightCountedVP = function(state) return getPlayArea().call("countVP", state) end -- Checks if an object is in the play area (returns true or false) PlayAreaApi.isInPlayArea = function(object) return getPlayArea().call("isInPlayArea", object) end -- Returns the current surface of the play area PlayAreaApi.getSurface = function() return getPlayArea().getCustomObject().image end -- Updates the surface of the play area PlayAreaApi.updateSurface = function(url) return getPlayArea().call("updateSurface", url) end -- Returns a deep copy of the currently tracked locations PlayAreaApi.getTrackedLocations = function() local t = {} for k, v in pairs(getPlayArea().call("getTrackedLocations")) do t[k] = v end return t end -- Called by Custom Data Helpers to push their location data into the Data Helper. This adds the -- data to the local token manager instance. ---@param args table Single-value array holding the GUID of the Custom Data Helper making the call PlayAreaApi.updateLocations = function(args) getPlayArea().call("updateLocations", args) end PlayAreaApi.getCustomDataHelper = function() return getPlayArea().getVar("customDataHelper") end return PlayAreaApi end end) __bundle_register("core/SoundCubeApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local SoundCubeApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") -- this table links the name of a trigger effect to its index local soundIndices = { ["Vacuum"] = 0, ["Deep Bell"] = 1, ["Dark Souls"] = 2 } ---@param index number Index of the sound effect to play local function playTriggerEffect(index) local SoundCube = guidReferenceApi.getObjectByOwnerAndType("Mythos", "SoundCube") SoundCube.AssetBundle.playTriggerEffect(index) end -- plays the by name requested sound ---@param soundName string Name of the sound to play SoundCubeApi.playSoundByName = function(soundName) playTriggerEffect(soundIndices[soundName]) end return SoundCubeApi end end) __bundle_register("util/SearchLib", function(require, _LOADED, __bundle_register, __bundle_modules) do local SearchLib = {} local filterFunctions = { isActionToken = function(x) return x.getDescription() == "Action Token" end, isCard = function(x) return x.type == "Card" end, isDeck = function(x) return x.type == "Deck" end, isCardOrDeck = function(x) return x.type == "Card" or x.type == "Deck" end, isClue = function(x) return x.memo == "clueDoom" and x.is_face_down == false end, isTileOrToken = function(x) return x.type == "Tile" end } -- performs the actual search and returns a filtered list of object references ---@param pos tts__Vector Global position ---@param rot? tts__Vector Global rotation ---@param size table Size ---@param filter? string Name of the filter function ---@param direction? table Direction (positive is up) ---@param maxDistance? number Distance for the cast local function returnSearchResult(pos, rot, size, filter, direction, maxDistance) local filterFunc if filter then filterFunc = filterFunctions[filter] end local searchResult = Physics.cast({ origin = pos, direction = direction or { 0, 1, 0 }, orientation = rot or { 0, 0, 0 }, type = 3, size = size, max_distance = maxDistance or 0 }) -- filtering the result local objList = {} for _, v in ipairs(searchResult) do if not filter or filterFunc(v.hit_object) then table.insert(objList, v.hit_object) end end return objList end -- searches the specified area SearchLib.inArea = function(pos, rot, size, filter) return returnSearchResult(pos, rot, size, filter) end -- searches the area on an object SearchLib.onObject = function(obj, filter) pos = obj.getPosition() size = obj.getBounds().size:setAt("y", 1) return returnSearchResult(pos, _, size, filter) end -- searches the specified position (a single point) SearchLib.atPosition = function(pos, filter) size = { 0.1, 2, 0.1 } return returnSearchResult(pos, _, size, filter) end -- searches below the specified position (downwards until y = 0) SearchLib.belowPosition = function(pos, filter) direction = { 0, -1, 0 } maxDistance = pos.y return returnSearchResult(pos, _, size, filter, direction, maxDistance) end return SearchLib end end) return __bundle_require("__root")