SCED/src/core/Global.ttslua

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