SCED/src/core/Global.ttslua
2024-07-08 18:12:34 +02:00

1901 lines
61 KiB
Plaintext

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