ah_sce_unpacked/unpacked.ttslua
2024-07-27 21:47:52 -04:00

3382 lines
115 KiB
Plaintext

-- 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("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("playermat/PlayermatApi", function(require, _LOADED, __bundle_register, __bundle_modules)
do
local PlayermatApi = {}
local guidReferenceApi = require("core/GUIDReferenceApi")
local searchLib = require("util/SearchLib")
local localInvestigatorPosition = { x = -1.17, y = 1, z = -0.01 }
-- Convenience function to look up a mat's object by color, or get all mats.
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
---@return table: Single-element if only single playermat 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 playermat
---@param startPos table Starting position to get the closest mat from
PlayermatApi.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 playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.getPlayerColor = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
return mat.getVar("playerColor")
end
end
-- Returns the color of the playermat that owns the playercolor's hand
---@param handColor string Color of the playermat
PlayermatApi.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
-- Instructs a playermat to check for DES
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
PlayermatApi.checkForDES = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("checkForDES")
end
end
-- Returns if there is the card "Dream-Enhancing Serum" on the requested playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
---@return boolean: whether DES is present on the playermat
PlayermatApi.hasDES = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
return mat.getVar("hasDES")
end
end
-- gets the slot data for the playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.getSlotData = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
return mat.getTable("slotData")
end
end
-- sets the slot data for the playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
---@param newSlotData table New slot data for the playermat
PlayermatApi.loadSlotData = function(matColor, newSlotData)
for _, mat in pairs(getMatForColor(matColor)) do
mat.setTable("slotData", newSlotData)
mat.call("redrawSlotSymbols")
return
end
end
-- Performs a search of the deck area of the requested playermat and returns the result as table
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.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 playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.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 playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.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 playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.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 playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.transformLocalPosition = function(localPos, matColor)
for _, mat in pairs(getMatForColor(matColor)) do
return mat.positionToWorld(localPos)
end
end
-- Returns the rotation of the requested playermat
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.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 playermat - White, Orange, Green, Red or All
---@param helperName string Name of the helper object
PlayermatApi.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 playermat
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
---@param playerColor string Color of the calling player (for messages)
PlayermatApi.doUpkeepFromHotkey = function(matColor, playerColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("doUpkeepFromHotkey", playerColor)
end
end
-- Handles discarding for the requested playermat for the provided list of objects
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
---@param objList table List of objects to discard
PlayermatApi.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 playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.returnInvestigatorId = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
return mat.getVar("activeInvestigatorId")
end
end
-- Returns the class of the active investigator
---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All")
PlayermatApi.returnInvestigatorClass = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
return mat.getVar("activeInvestigatorClass")
end
end
-- Returns the position for encounter card drawing
---@param matColor string Color of the playermat - 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
PlayermatApi.getEncounterCardDrawPosition = function(matColor, stack)
for _, mat in pairs(getMatForColor(matColor)) do
return Vector(mat.call("getEncounterCardDrawPosition", stack))
end
end
-- Sets the requested playermat'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 playermat - White, Orange, Green, Red or All
PlayermatApi.setLimitSnapsByType = function(matchCardTypes, matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("setLimitSnapsByType", matchCardTypes)
end
end
-- Sets the requested playermat'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 playermat - White, Orange, Green, Red or All
PlayermatApi.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 playermat
---@param showCounter boolean Whether the clickable counter should be present or not
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
PlayermatApi.clickableClues = function(showCounter, matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("clickableClues", showCounter)
end
end
-- Toggles the use of class textures for the requested playermat
---@param state boolean Whether the class texture should be used or not
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
PlayermatApi.useClassTexture = function(state, matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("useClassTexture", state)
end
end
-- Removes all clues (to the trash for tokens and counters set to 0) for the requested playermat
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
PlayermatApi.removeClues = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("removeClues")
end
end
-- Reports the clue count for the requested playermat
---@param useClickableCounters boolean Controls which type of counter is getting checked
PlayermatApi.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 playermat - 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
PlayermatApi.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 playermat
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
---@param number number Amount of cards to draw
PlayermatApi.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 playermat - White, Orange, Green or Red (does not support "All")
---@param type string Counter to target
PlayermatApi.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
PlayermatApi.getUsedMatColors = function()
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
-- Returns investigator name
---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All")
PlayermatApi.getInvestigatorName = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
local searchPos = mat.positionToWorld(localInvestigatorPosition)
local searchResult = searchLib.atPosition(searchPos, "isCardOrDeck")
if #searchResult == 1 then
return searchResult[1].getName()
end
end
return ""
end
-- Resets the specified skill tracker to "1, 1, 1, 1"
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
PlayermatApi.resetSkillTracker = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("resetSkillTracker")
end
end
-- Redraws the XML for the slot symbols based on the slotData table
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
PlayermatApi.redrawSlotSymbols = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("redrawSlotSymbols")
end
end
-- Finds all objects on the playermat and associated set aside zone and returns a table
---@param matColor string Color of the playermat - White, Orange, Green, Red or All
---@param filter string Name of the filte function (see util/SearchLib)
PlayermatApi.searchAroundPlayermat = 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 playermat - White, Orange, Green, Red or All
PlayermatApi.doDiscardOne = function(matColor)
for _, mat in pairs(getMatForColor(matColor)) do
mat.call("doDiscardOne")
end
end
-- Triggers the metadata sync for all playermats
PlayermatApi.syncAllCustomizableCards = function()
for _, mat in pairs(getMatForColor("All")) do
mat.call("syncAllCustomizableCards")
end
end
return PlayermatApi
end
end)
__bundle_register("util/SearchLib", function(require, _LOADED, __bundle_register, __bundle_modules)
do
local SearchLib = {}
local filterFunctions = {
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,
isUniversalToken = function(x) return x.getMemo() == "universalActionAbility" 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
})
-- filter the result for matching objects
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)
local pos = obj.getPosition()
local 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)
local 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)
local size = { 0.1, 2, 0.1 }
local direction = { 0, -1, 0 }
local maxDistance = pos.y
return returnSearchResult(pos, _, size, filter, direction, maxDistance)
end
return SearchLib
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 playermatApi = require("playermat/PlayermatApi")
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.9.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
-- provides a random seed (from 1 to 999) to be used by "linked" objects like the action tokens
function getRandomSeed()
return math.random(999)
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
Player[playerColor].clearSelectedObjects()
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)
-- stop mini cards from forming decks
if object.hasTag("Minicard") and container.hasTag("Minicard") then
return false
end
playAreaApi.tryObjectEnterContainer(container, object)
return true
end
-- TTS event for objects that enter zones
function onObjectEnterZone(zone, object)
-- detect the "token discard zones" beneath the hand zones
if zone.getName() == "TokenDiscardZone" and
not tokenChecker.isChaosToken(object) and
object.type == "Tile" and
object.getMemo() and
not object.getLock() then
local matcolor = playermatApi.getMatColorByPosition(object.getPosition())
local trash = guidReferenceApi.getObjectByOwnerAndType(matcolor, "Trash")
trash.putObject(object)
elseif zone.type == "Hand" and object.type == "Card" then
-- make sure the card is face-up
if object.is_face_down then
object.flip()
end
-- disable any helpers on the card
if object.hasTag("CardWithHelper") then
object.call("setHelperState", false)
end
-- maybe reset data about sealed tokens (if that function exists)
if object.hasTag("CardThatSeals") then
local func = object.getVar("resetSealedTokens")
if func ~= nil then
object.call("resetSealedTokens")
end
end
end
end
-- TTS event for objects that leave zones
function onObjectLeaveZone(zone, object)
-- 1 frame delay to avoid error messages when exiting the game
Wait.frames(
function()
-- end here if one of the objects doesn't exist
if zone.isDestroyed() or object.isDestroyed() then return end
-- resync the state of the helper on the card with the option panel
if zone.type == "Hand" and object.hasTag("CardWithHelper") then
object.call("syncDisplayWithOptionPanel")
end
end, 1)
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 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
-- check whether the hovered object is part of a players draw objects
for _, color in ipairs(playermatApi.getUsedMatColors()) do
local deckAreaObjects = playermatApi.getDeckAreaObjects(color)
if deckAreaObjects.topCard == hoveredObject or deckAreaObjects.draw == hoveredObject then
playermatApi.drawCardsWithReshuffle(color, number)
return true
end
end
end
-- TTS event, used to redraw the playermat slot symbols after a small delay to account for the custom font loading
function onPlayerConnect()
Wait.time(function() playermatApi.redrawSlotSymbols("All") end, 0.2)
end
-- disable delete action (only applies to promoted players) and discard objects instead
function onPlayerAction(player, action, targets)
if action == Player.Action.Delete and not player.admin then
for _, target in ipairs(targets) do
local matColor = playermatApi.getMatColorByPosition(target.getPosition())
local trash = guidReferenceApi.getObjectByOwnerAndType(matColor, "Trash")
trash.putObject(target)
end
return false
end
return true
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
-- returns all chaos tokens to the bag
function returnChaosTokens()
local chaosBag = findChaosBag()
for _, token in pairs(chaosTokens) do
if token ~= nil then chaosBag.putObject(token) end
end
chaosTokens = {}
isTokenXMLActive = false
end
-- returns a single chaos token to the bag and calls respective functions
function returnChaosTokenToBag(params)
local name = params.token.getName()
local chaosBag = findChaosBag()
chaosBag.putObject(params.token)
tokenArrangerApi.layout()
if name == "Bless" or name == "Curse" then
blessCurseManagerApi.releasedToken(name, params.token.getGUID(), params.fromBag)
end
end
-- returns the index of a token in the chaosTokens table
function getTokenIndex(token)
for i, obj in ipairs(chaosTokens) do
if obj == token then
return i
end
end
end
-- starts a redraw effect and displays buttons for a choice if needed
function activeRedrawEffect(params)
redrawData = params
if isTokenXMLActive == true then
broadcastToAll("Clear already active buttons first, then try again", "Red")
return
end
if #chaosTokens == 0 then
broadcastToAll("No tokens found in play area", "Red")
return
end
-- nil handling
redrawData.VALID_TOKENS = redrawData.VALID_TOKENS or {}
redrawData.INVALID_TOKENS = redrawData.INVALID_TOKENS or {}
-- determine if only some tokens are able to be returned to the bag
local matchingTokensInPlay = {}
for _, token in ipairs(chaosTokens) do
local tokenName = getReadableTokenName(token.getName())
-- allow valid tokens or not invalid tokens, also allow any token if both lists empty
if (redrawData.VALID_TOKENS[tokenName] ~= nil and isTableEmpty(redrawData.INVALID_TOKENS)) or
(isTableEmpty(redrawData.VALID_TOKENS) and not redrawData.INVALID_TOKENS[tokenName]) or
(isTableEmpty(redrawData.VALID_TOKENS) and isTableEmpty(redrawData.INVALID_TOKENS)) then
table.insert(matchingTokensInPlay, token)
end
end
-- proceed according to number of matching tokens
if #matchingTokensInPlay == 0 then
broadcastToAll("No eligible token found in play area", "Red")
elseif #matchingTokensInPlay == 1 then
returnAndRedraw(_, matchingTokensInPlay[1].getGUID())
else
-- draw XML to allow choosing the token to return to bag
isTokenXMLActive = true
for _, token in ipairs(matchingTokensInPlay) do
token.UI.setXmlTable({
{
tag = "VerticalLayout",
attributes = {
height = 275,
width = 275,
padding = "0 0 20 25",
scale = "0.4 0.4 1",
rotation = "0 0 180",
position = "0 0 -15",
color = "rgba(0,0,0,0.7)",
onClick = "Global/returnAndRedraw(" .. token.getGUID() .. ")",
},
children = {
{
tag = "Text",
attributes = {
fontSize = "100",
font = "font_teutonic-arkham",
color = "#ffffff",
text = "Redraw"
}
},
{
tag = "Text",
attributes = {
fontSize = "125",
font = "font_arkhamicons",
color = "#ffffff",
text = "u"
}
}
}
}
})
end
end
end
-- returns a chaos token to the chaos bag and redraws another
function returnAndRedraw(_, tokenGUID)
local returnedToken = getObjectFromGUID(tokenGUID)
local tokenName = returnedToken.getName()
local indexOfReturnedToken = getTokenIndex(returnedToken)
local matColor = playermatApi.getMatColorByPosition(returnedToken.getPosition())
local mat = guidReferenceApi.getObjectByOwnerAndType(matColor, "Playermat")
local takeParameters = {
position = returnedToken.getPosition(),
rotation = returnedToken.getRotation()
}
if #chaosTokens > indexOfReturnedToken then
takeParameters.rotation = takeParameters.rotation + Vector(0, 0, -8)
end
-- perform the actual token replacing
trackChaosToken(tokenName, mat.getGUID(), true)
local params = {token = returnedToken, fromBag = true}
returnChaosTokenToBag(params)
chaosTokens[indexOfReturnedToken] = drawChaosToken({
mat = mat,
drawAdditional = true,
tokenType = redrawData.DRAW_SPECIFIC_TOKEN, -- currently only used for Nkosi Mabati
takeParameters = takeParameters
})
-- remove these tokens from the bag
if redrawData.RETURN_TO_POOL then
-- let the bless/curse manager handle these
if tokenName == "Bless" or tokenName == "Curse" then
blessCurseManagerApi.removeToken(tokenName)
else
local invertedTable = createChaosTokenNameLookupTable()
removeChaosToken(invertedTable[tokenName])
end
end
-- remove XML from tokens in play
isTokenXMLActive = false
for _, token in ipairs(chaosTokens) do
token.UI.setXml("")
end
redrawData = {}
-- return a reference to the freshly drawn token
return chaosTokens[indexOfReturnedToken]
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 matGUID = params.mat.getGUID()
-- return token(s) on other playermat 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()
-- add the token to the list, compute new position based on list length
local tokenOffset = Vector(-1.55 + 0.17 * #chaosTokens, 0.25, -0.58)
local takeParameters = params.takeParameters or {}
takeParameters.position = takeParameters.position or params.mat.positionToWorld(tokenOffset)
takeParameters.rotation = takeParameters.rotation or params.mat.getRotation()
local token
if params.guidToBeResolved then
-- resolve a sealed token from a card
token = getObjectFromGUID(params.guidToBeResolved)
token.setPositionSmooth(takeParameters.position)
tokenArrangerApi.layout()
local tokenName = token.getName()
if tokenName == "Bless" or tokenName == "Curse" then
blessCurseManagerApi.releasedToken(tokenName, token.getGUID())
end
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 not params.takeParameters then
table.insert(chaosTokens, token)
end
return token
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)
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
local matColor = playermatApi.getMatColorByPosition(getObjectFromGUID(key).getPosition())
playerColor = playermatApi.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 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 = playermatApi.searchAroundPlayermat(matColor, "isUniversalToken")
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
playermatApi.setLimitSnapsByType(state, "All")
-- option: Draw 1 button
elseif id == "showDrawButton" then
playermatApi.showDrawButton(state, "All")
-- option: Use class texture
elseif id == "useClassTexture" then
playermatApi.useClassTexture(state, "All")
-- option: Clickable clue counters
elseif id == "useClueClickers" then
playermatApi.clickableClues(state, "All")
-- update master clue counter
local counter = guidReferenceApi.getObjectByOwnerAndType("Mythos", "MasterClueCounter")
counter.setVar("useClickableCounters", state)
-- option: Enable card helpers
elseif id == "enableCardHelpers" then
toggleCardHelpers(state)
-- option: Play area connection drawing
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.53, 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.53, 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(playermatApi.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 _, objData in ipairs(sourceBag.getData().ContainedObjects) do
if objData["Nickname"] == name then
objData["Locked"] = true
spawnTable.data = objData
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,
enableCardHelpers = true,
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,
useClassTexture = 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
-- instructs all card helpers to update their visibility
function toggleCardHelpers(state)
for _, obj in ipairs(getObjectsWithTag("CardWithHelper")) do
obj.call("setHelperState", state)
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
---------------------------------------------------------
-- removes a value from a table
function removeValueFromTable(t, val)
for i, v in ipairs(t) do
if v == val then
table.remove(t, i)
break
end
end
end
-- checks if a table is empty
function isTableEmpty(tbl)
if next(tbl) == nil then
return true
else
return false
end
end
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 playermat
---@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 playermat
---@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 playermat'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("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
---@param fromBag? boolean Whether or not token was just drawn from the chaos bag
BlessCurseManagerApi.releasedToken = function(type, guid, fromBag)
getManager().call("releasedToken", { type = type, guid = guid, fromBag = fromBag })
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
-- adds bless / curse to the chaos bag
---@param type string Type of chaos token ("Bless" or "Curse")
BlessCurseManagerApi.addToken = function(type)
getManager().call("addToken", type)
end
-- removes bless / curse from the chaos bag
---@param type string Type of chaos token ("Bless" or "Curse")
BlessCurseManagerApi.removeToken = function(type)
getManager().call("removeToken", type)
end
BlessCurseManagerApi.getBlessCurseInBag = function()
return getManager().call("getBlessCurseInBag", {})
end
return BlessCurseManagerApi
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/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("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(card)
return getSpawnTracker().call("resetTokensSpawned", card)
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("__root", function(require, _LOADED, __bundle_register, __bundle_modules)
require("core/Global")
end)
__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/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("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("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 playermatApi = require("playermat/PlayermatApi")
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 <use type>=<count> which will modify the number of tokens
--- spawned for that type. e.g. Akachi's playermat 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 (template needs to be in source bag)
---@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 (template needs to be in source bag)
---@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 (template needs to be in source bag)
---@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 or action 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
printToAll("Attempting to spawn " .. tokenCount .. " tokens. Spawning clickable counter instead.")
TokenManager.spawnResourceCounterToken(card, tokenCount)
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
-- this is used to load the correct state for additional resource tokens (e.g. "Ammo")
local callback = nil
local stateID = stateTable[string.lower(subType or "")]
if tokenType == "resource" and stateID ~= nil and stateID ~= 1 then
callback = function(spawned) spawned.setState(stateID) end
elseif tokenType == "universalActionAbility" then
local matColor = playermatApi.getMatColorByPosition(card.getPosition())
local class = playermatApi.returnInvestigatorClass(matColor)
callback = function(spawned) spawned.call("updateClassAndSymbol", { class = class, symbol = subType or class }) 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 (template needs to be in source bag)
---@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)
TokenManager.maybeReplenishCard = function(card, uses)
-- 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)
end
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 <use type>=<count> which will modify the number of tokens
--- spawned for that type. e.g. Akachi's playermat 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)
internal.replenishTokens = function(card, uses)
-- get current amount of matching resource tokens on the card
local clickableResourceCounter = nil
local foundTokens = 0
local searchType = string.lower(uses[1].type)
for _, obj in ipairs(searchLib.onObject(card, "isTileOrToken")) do
local memo = obj.getMemo()
if searchType == memo 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)
return __bundle_require("__root")