-- Bundled by luabundle {"version":"1.6.0"} local __bundle_require, __bundle_loaded, __bundle_register, __bundle_modules = (function(superRequire) local loadingPlaceholder = {[{}] = true} local register local modules = {} local require local loaded = {} register = function(name, body) if not modules[name] then modules[name] = body end end require = function(name) local loadedModule = loaded[name] if loadedModule then if loadedModule == loadingPlaceholder then return nil end else if not modules[name] then if not superRequire then local identifier = type(name) == 'string' and '\"' .. name .. '\"' or tostring(name) error('Tried to require ' .. identifier .. ', but no such module has been registered') else return superRequire(name) end end loaded[name] = loadingPlaceholder loadedModule = modules[name](require, loaded, register, modules) loaded[name] = loadedModule end return loadedModule end return require, loaded, register, modules end)(nil) __bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules) require("playermat/Playermat") end) __bundle_register("core/NavigationOverlayApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local NavigationOverlayApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getNOHandler() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "NavigationOverlayHandler") end -- copies the visibility for the Navigation overlay ---@param startColor string Color of the player to copy from ---@param targetColor string Color of the targeted player NavigationOverlayApi.copyVisibility = function(startColor, targetColor) getNOHandler().call("copyVisibility", { startColor = startColor, targetColor = targetColor }) end -- changes the Navigation Overlay view ("Full View" --> "Play Areas" --> "Closed" etc.) ---@param playerColor string Color of the player to update the visibility for NavigationOverlayApi.cycleVisibility = function(playerColor) getNOHandler().call("cycleVisibility", playerColor) end -- loads the specified camera for a player ---@param player tts__Player Player whose camera should be moved ---@param camera number|string If number: Index of the camera view to load | If string: Color of the playermat to swap to NavigationOverlayApi.loadCamera = function(player, camera) getNOHandler().call("loadCameraFromApi", { player = player, camera = camera }) end return NavigationOverlayApi end end) __bundle_register("util/SearchLib", function(require, _LOADED, __bundle_register, __bundle_modules) do local SearchLib = {} local filterFunctions = { isCard = function(x) return x.type == "Card" end, isDeck = function(x) return x.type == "Deck" end, isCardOrDeck = function(x) return x.type == "Card" or x.type == "Deck" end, isClue = function(x) return x.memo == "clueDoom" and x.is_face_down == false end, isTileOrToken = function(x) return x.type == "Tile" end, isUniversalToken = function(x) return x.getMemo() == "universalActionAbility" end, } -- performs the actual search and returns a filtered list of object references ---@param pos tts__Vector Global position ---@param rot? tts__Vector Global rotation ---@param size table Size ---@param filter? string Name of the filter function ---@param direction? table Direction (positive is up) ---@param maxDistance? number Distance for the cast local function returnSearchResult(pos, rot, size, filter, direction, maxDistance) local filterFunc if filter then filterFunc = filterFunctions[filter] end local searchResult = Physics.cast({ origin = pos, direction = direction or { 0, 1, 0 }, orientation = rot or { 0, 0, 0 }, type = 3, size = size, max_distance = maxDistance or 0 }) -- filter the result for matching objects local objList = {} for _, v in ipairs(searchResult) do if not filter or filterFunc(v.hit_object) then table.insert(objList, v.hit_object) end end return objList end -- searches the specified area SearchLib.inArea = function(pos, rot, size, filter) return returnSearchResult(pos, rot, size, filter) end -- searches the area on an object SearchLib.onObject = function(obj, filter) local pos = obj.getPosition() local size = obj.getBounds().size:setAt("y", 1) return returnSearchResult(pos, _, size, filter) end -- searches the specified position (a single point) SearchLib.atPosition = function(pos, filter) local size = { 0.1, 2, 0.1 } return returnSearchResult(pos, _, size, filter) end -- searches below the specified position (downwards until y = 0) SearchLib.belowPosition = function(pos, filter) local size = { 0.1, 2, 0.1 } local direction = { 0, -1, 0 } local maxDistance = pos.y return returnSearchResult(pos, _, size, filter, direction, maxDistance) end return SearchLib end end) __bundle_register("chaosbag/ChaosBagApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local ChaosBagApi = {} -- respawns the chaos bag with a new state of tokens ---@param tokenList table List of chaos token ids ChaosBagApi.setChaosBagState = function(tokenList) Global.call("setChaosBagState", tokenList) end -- returns a Table List of chaos token ids in the current chaos bag -- requires copying the data into a new table because TTS is weird about handling table return values in Global ChaosBagApi.getChaosBagState = function() local chaosBagContentsCatcher = Global.call("getChaosBagState") local chaosBagContents = {} for _, v in ipairs(chaosBagContentsCatcher) do table.insert(chaosBagContents, v) end return chaosBagContents end -- checks scripting zone for chaos bag (also called by a lot of objects!) ChaosBagApi.findChaosBag = function() return Global.call("findChaosBag") end -- returns a table of object references to the tokens in play (does not include sealed tokens!) ChaosBagApi.getTokensInPlay = function() return Global.call("getChaosTokensinPlay") end -- returns all sealed tokens on cards to the chaos bag ---@param playerColor string Color of the player to show the broadcast to ChaosBagApi.releaseAllSealedTokens = function(playerColor) Global.call("releaseAllSealedTokens", playerColor) end -- returns all drawn tokens to the chaos bag ChaosBagApi.returnChaosTokens = function() Global.call("returnChaosTokens") end -- removes the specified chaos token from the chaos bag ---@param id string ID of the chaos token ChaosBagApi.removeChaosToken = function(id) Global.call("removeChaosToken", id) end -- returns a chaos token to the bag and calls all relevant functions ---@param token tts__Object Chaos token to return ---@param fromBag boolean whether or not the token to return was in the middle of being drawn (true) or elsewhere (false) ChaosBagApi.returnChaosTokenToBag = function(token, fromBag) Global.call("returnChaosTokenToBag", { token = token, fromBag = fromBag }) end -- spawns the specified chaos token and puts it into the chaos bag ---@param id string ID of the chaos token ChaosBagApi.spawnChaosToken = function(id) Global.call("spawnChaosToken", id) 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 any: True if the bag is manipulated, false if it should be blocked. ChaosBagApi.canTouchChaosTokens = function() return Global.call("canTouchChaosTokens") end -- draws a chaos token to a playermat ---@param mat tts__Object Playermat that triggered this ---@param drawAdditional boolean Controls whether additional tokens should be drawn ---@param tokenType? string Name of token (e.g. "Bless") to be drawn from the bag ---@param guidToBeResolved? string GUID of the sealed token to be resolved instead of drawing a token from the bag ---@param takeParameters? table Position and rotation of the location where the new token should be drawn to, usually to replace a returned token ---@return tts__Object: Object reference to the token that was drawn ChaosBagApi.drawChaosToken = function(mat, drawAdditional, tokenType, guidToBeResolved, takeParameters) return Global.call("drawChaosToken", { mat = mat, drawAdditional = drawAdditional, tokenType = tokenType, guidToBeResolved = guidToBeResolved, takeParameters = takeParameters }) end -- returns a Table List of chaos token ids in the current chaos bag -- requires copying the data into a new table because TTS is weird about handling table return values in Global ChaosBagApi.getIdUrlMap = function() return Global.getTable("ID_URL_MAP") end return ChaosBagApi end end) __bundle_register("core/token/TokenChecker", function(require, _LOADED, __bundle_register, __bundle_modules) do local CHAOS_TOKEN_NAMES = { ["Elder Sign"] = true, ["+1"] = true, ["0"] = true, ["-1"] = true, ["-2"] = true, ["-3"] = true, ["-4"] = true, ["-5"] = true, ["-6"] = true, ["-7"] = true, ["-8"] = true, ["Skull"] = true, ["Cultist"] = true, ["Tablet"] = true, ["Elder Thing"] = true, ["Auto-fail"] = true, ["Bless"] = true, ["Curse"] = true, ["Frost"] = true } local TokenChecker = {} -- returns true if the passed object is a chaos token (by name) TokenChecker.isChaosToken = function(obj) if obj.type == "Tile" and CHAOS_TOKEN_NAMES[obj.getName()] then return true else return false end end return TokenChecker end end) __bundle_register("core/OptionPanelApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local OptionPanelApi = {} -- loads saved options ---@param options table Set a new state for the option table OptionPanelApi.loadSettings = function(options) return Global.call("loadSettings", options) end ---@return any: Table of option panel state OptionPanelApi.getOptions = function() return Global.getTable("optionPanel") end return OptionPanelApi end end) __bundle_register("playermat/Playermat", function(require, _LOADED, __bundle_register, __bundle_modules) local chaosBagApi = require("chaosbag/ChaosBagApi") local deckLib = require("util/DeckLib") local guidReferenceApi = require("core/GUIDReferenceApi") local mythosAreaApi = require("core/MythosAreaApi") local navigationOverlayApi = require("core/NavigationOverlayApi") local searchLib = require("util/SearchLib") local tokenChecker = require("core/token/TokenChecker") local tokenManager = require("core/token/TokenManager") local tokenSpawnTrackerApi = require("core/token/TokenSpawnTrackerApi") -- we use this to turn off collision handling until onLoad() is complete local collisionEnabled = false local currentlyEditingSlots = false -- x-Values for discard buttons local DISCARD_BUTTON_X_START = -1.365 local DISCARD_BUTTON_X_OFFSET = 0.455 local SEARCH_AROUND_SELF_X_BUFFER = 8 local SEARCH_AROUND_SELF_Z_BUFFER = 1.75 -- defined areas for object searching local MAIN_PLAY_AREA = { upperLeft = { x = 1.98, z = 0.736 }, lowerRight = { x = -0.79, z = -0.39 } } local INVESTIGATOR_AREA = { upperLeft = { x = -1.084, z = 0.06517 }, lowerRight = { x = -1.258, z = -0.0805 } } local THREAT_AREA = { upperLeft = { x = 1.53, z = -0.34 }, lowerRight = { x = -1.13, z = -0.92 } } local DECK_DISCARD_AREA = { upperLeft = { x = -1.62, z = 0.855 }, lowerRight = { x = -2.02, z = -0.245 }, center = { x = -1.82, y = 0.5, z = 0.305 }, size = { x = 0.4, y = 3, z = 1.1 } } -- local positions local DRAW_DECK_POSITION = { x = -1.82, y = 0.1, z = 0 } local DISCARD_PILE_POSITION = { x = -1.82, y = 0.1, z = 0.61 } local DRAWN_ENCOUNTER_POSITION = { x = 1.365, y = 0.5, z = -0.625 } -- global position of encounter discard pile local ENCOUNTER_DISCARD_POSITION = { x = -3.85, y = 1.5, z = 10.38 } -- used for the buttons on the right side of the playermat -- starts off with the data for the "Upkeep" button and will then be changed local buttonParameters = { label = "Upkeep", click_function = "doUpkeep", tooltip = "Right-click to change color", function_owner = self, position = { x = 1.82, y = 0.1, z = -0.45 }, scale = { 0.12, 0.12, 0.12 }, width = 1000, height = 280, font_size = 180 } -- table of texture URLs local nameToTexture = { Guardian = "http://cloud-3.steamusercontent.com/ugc/2501268517241599869/179119CA88170D9F5C87CD00D267E6F9F397D2F7/", Mystic = "http://cloud-3.steamusercontent.com/ugc/2501268517241600113/F6473F92B3435C32A685BB4DC2A88C2504DDAC4F/", Neutral = "http://cloud-3.steamusercontent.com/ugc/2462982115659543571/5D778EA4BC682DAE97E8F59A991BCF8CB3979B04/", Rogue = "http://cloud-3.steamusercontent.com/ugc/2501268517241600395/00CFAFC13D7B6EACC147D22A40AF9FBBFFAF3136/", Seeker = "http://cloud-3.steamusercontent.com/ugc/2501268517241600579/92DEB412D8D3A9C26D1795CEA0335480409C3E4B/", Survivor = "http://cloud-3.steamusercontent.com/ugc/2501268517241600848/CEB685E9C8A4A3C18A4B677A519B49423B54E886/" } -- translation table for slot names to characters for special font local slotNameToChar = { ["any"] = "", ["Accessory"] = "C", ["Ally"] = "E", ["Arcane"] = "G", ["Body"] = "K", ["Hand (right)"] = "M", ["Hand (left)"] = "M", ["Hand x2"] = "N", ["Tarot"] = "A" } -- slot symbol for the respective slot (from top left to bottom right) - intentionally global! slotData = {} local defaultSlotData = { -- 1st row "any", "any", "any", "Tarot", "Hand (left)", "Hand (right)", "Ally", -- 2nd row "any", "any", "any", "Accessory", "Arcane", "Arcane", "Body" } -- global variables for access activeInvestigatorClass = "Neutral" activeInvestigatorId = "00000" hasDES = false local isClassTextureEnabled = true local isDrawButtonVisible = false -- table of type-object reference pairs of all owned objects local ownedObjects = {} local matColor = self.getMemo() function onSave() return JSON.encode({ activeInvestigatorClass = activeInvestigatorClass, activeInvestigatorId = activeInvestigatorId, isClassTextureEnabled = isClassTextureEnabled, isDrawButtonVisible = isDrawButtonVisible, playerColor = playerColor, slotData = slotData }) end function onLoad(savedData) if savedData and savedData ~= "" then local loadedData = JSON.decode(savedData) activeInvestigatorClass = loadedData.activeInvestigatorClass activeInvestigatorId = loadedData.activeInvestigatorId isClassTextureEnabled = loadedData.isClassTextureEnabled isDrawButtonVisible = loadedData.isDrawButtonVisible playerColor = loadedData.playerColor slotData = loadedData.slotData end updateMessageColor(playerColor) self.interactable = false -- get object references to owned objects ownedObjects = guidReferenceApi.getObjectsByOwner(matColor) -- discard button creation for i = 1, 6 do makeDiscardButton(i) end self.createButton({ click_function = "drawEncounterCard", function_owner = self, position = { -1.84, 0, -0.65 }, rotation = { 0, 80, 0 }, width = 265, height = 190 }) self.createButton({ click_function = "drawChaosTokenButton", function_owner = self, position = { 1.85, 0, -0.74 }, rotation = { 0, -45, 0 }, width = 135, height = 135 }) -- Upkeep button: can use the default parameters for this self.createButton(buttonParameters) -- Slot editing button: modified default data buttonParameters.label = "Edit Slots" buttonParameters.click_function = "toggleSlotEditing" buttonParameters.tooltip = "Right-click to reset slot symbols" buttonParameters.position.z = 0.92 self.createButton(buttonParameters) showDrawButton(isDrawButtonVisible) redrawSlotSymbols() math.randomseed(os.time()) Wait.time(function() collisionEnabled = true end, 0.1) end --------------------------------------------------------- -- utility functions --------------------------------------------------------- -- searches an area and optionally filters the result function searchArea(origin, size, filter) return searchLib.inArea(origin, self.getRotation(), size, filter) end -- finds all objects on the playermat and associated set aside zone. function searchAroundSelf(filter) local scale = self.getScale() local bounds = self.getBoundsNormalized() -- Increase the width to cover the set aside zone bounds.size.x = bounds.size.x + SEARCH_AROUND_SELF_X_BUFFER bounds.size.y = 1 bounds.size.z = bounds.size.z + SEARCH_AROUND_SELF_Z_BUFFER -- 'setAsideDirection' accounts for the set aside zone being on the left or right, -- depending on the table position of the playermat local setAsideDirection = bounds.center.z > 0 and 1 or -1 -- Since the cast is centered on the position, shift left or right to keep -- the non-set aside edge of the cast at the edge of the playermat local localCenter = self.positionToLocal(bounds.center) localCenter.x = localCenter.x + setAsideDirection * SEARCH_AROUND_SELF_X_BUFFER / 2 / scale.x localCenter.z = localCenter.z - SEARCH_AROUND_SELF_Z_BUFFER / 2 / scale.z return searchArea(self.positionToWorld(localCenter), bounds.size, filter) end -- searches the area around the draw deck and discard pile function searchDeckAndDiscardArea(filter) local pos = self.positionToWorld(DECK_DISCARD_AREA.center) local scale = self.getScale() local size = { x = DECK_DISCARD_AREA.size.x * scale.x, y = DECK_DISCARD_AREA.size.y, z = DECK_DISCARD_AREA.size.z * scale.z } return searchArea(pos, size, filter) end -- rounds a number to the specified amount of decimal places ---@param num number Initial value ---@param numDecimalPlaces number Amount of decimal places ---@return number: rounded number function round(num, numDecimalPlaces) local mult = 10 ^ (numDecimalPlaces or 0) return math.floor(num * mult + 0.5) / mult end -- edits the label of a button ---@param oldLabel string Old label of the button ---@param newLabel string New label of the button function editButtonLabel(oldLabel, newLabel) local buttons = self.getButtons() for i = 1, #buttons do if buttons[i].label == oldLabel then self.editButton({ index = buttons[i].index, label = newLabel }) end end end -- updates the internal "messageColor" which is used for print/broadcast statements if no player is seated ---@param clickedByColor string Colorstring of player who clicked a button function updateMessageColor(clickedByColor) messageColor = Player[playerColor].seated and playerColor or clickedByColor end --------------------------------------------------------- -- Discard buttons --------------------------------------------------------- -- handles discarding for a list of objects ---@param objList table List of objects to discard function discardListOfObjects(objList) for _, obj in ipairs(objList) do if obj.type == "Card" or obj.type == "Deck" then if obj.hasTag("PlayerCard") then deckLib.placeOrMergeIntoDeck(obj, returnGlobalDiscardPosition(), self.getRotation()) else deckLib.placeOrMergeIntoDeck(obj, ENCOUNTER_DISCARD_POSITION, { x = 0, y = -90, z = 0 }) end elseif tokenChecker.isChaosToken(obj) then -- put chaos tokens back into bag (e.g. Unrelenting) chaosBagApi.returnChaosTokenToBag(obj, false) elseif not obj.getLock() and not obj.hasTag("DontDiscard") then -- don't touch locked objects (like the table etc.) or specific objects (like key tokens) ownedObjects.Trash.putObject(obj) end end end -- build a discard button to discard from searchPosition ---@param id number Index of the discard button (from left to right, must be unique) function makeDiscardButton(id) local xValue = DISCARD_BUTTON_X_START + (id - 1) * DISCARD_BUTTON_X_OFFSET local position = { xValue, 0.1, -0.94 } local searchPosition = { -position[1], position[2], position[3] + 0.32 } local handlerName = 'handler' .. id self.setVar(handlerName, function() local cardSizeSearch = { 2, 1, 3.2 } local globalSearchPosition = self.positionToWorld(searchPosition) local searchResult = searchArea(globalSearchPosition, cardSizeSearch) return discardListOfObjects(searchResult) end) self.createButton({ label = "Discard", click_function = handlerName, function_owner = self, position = position, scale = { 0.12, 0.12, 0.12 }, width = 900, height = 350, font_size = 220 }) end --------------------------------------------------------- -- Upkeep button --------------------------------------------------------- -- calls the Upkeep function with correct parameter function doUpkeepFromHotkey(clickedByColor) doUpkeep(_, clickedByColor) end function doUpkeep(_, clickedByColor, isRightClick) if isRightClick then changeColor(clickedByColor) return end updateMessageColor(clickedByColor) -- unexhaust cards in play zone, flip action tokens and find Forced Learning / Dream-Enhancing Serum checkForDES() local forcedLearning = false local rot = self.getRotation() for _, obj in ipairs(searchAroundSelf()) do if obj.hasTag("Temporary") == true then discardListOfObjects({ obj }) elseif obj.hasTag("UniversalToken") == true and obj.is_face_down then obj.flip() elseif obj.type == "Card" and not inArea(self.positionToLocal(obj.getPosition()), INVESTIGATOR_AREA) then local cardMetadata = JSON.decode(obj.getGMNotes()) or {} if not (obj.getVar("do_not_ready") or obj.hasTag("DoNotReady")) then local cardRotation = round(obj.getRotation().y, 0) - rot.y local yRotDiff = 0 if cardRotation < 0 then cardRotation = cardRotation + 360 end -- rotate cards to the next multiple of 90° towards 0° if cardRotation > 90 and cardRotation <= 180 then yRotDiff = 90 elseif cardRotation < 270 and cardRotation > 180 then yRotDiff = 270 end -- set correct rotation for face-down cards rot.z = obj.is_face_down and 180 or 0 obj.setRotation({ rot.x, rot.y + yRotDiff, rot.z }) end -- detect Forced Learning to handle card drawing accordingly if cardMetadata.id == "08031" then forcedLearning = true end -- maybe replenish uses on certain cards (don't continue for cards on the deck (Norman) or in the discard pile) if cardMetadata.uses ~= nil and self.positionToLocal(obj.getPosition()).x > -1 then tokenManager.maybeReplenishCard(obj, cardMetadata.uses, self) end elseif obj.type == "Deck" and forcedLearning == false then -- check decks for forced learning for _, deepObj in ipairs(obj.getObjects()) do local cardMetadata = JSON.decode(deepObj.gm_notes) or {} if cardMetadata.id == "08031" then forcedLearning = true end end end end -- flip investigator mini-card and summoned servitor mini-card -- (all characters allowed to account for custom IDs - e.g. 'Z0000' for TTS Zoop generated IDs) local miniId = string.match(activeInvestigatorId, ".....") .. "-m" for _, obj in ipairs(getObjects()) do if obj.type == "Card" and obj.is_face_down then local notes = JSON.decode(obj.getGMNotes()) if notes ~= nil and notes.type == "Minicard" and (notes.id == miniId or notes.id == "09080-m") then obj.flip() end end end -- gain a resource (or two if playing Jenny Barnes) if string.match(activeInvestigatorId, "%d%d%d%d%d") == "02003" then updateCounter({ type = "ResourceCounter", modifier = 2 }) printToColor("Gaining 2 resources (Jenny)", messageColor) else updateCounter({ type = "ResourceCounter", modifier = 1 }) end -- draw a card (with handling for Patrice and Forced Learning) if activeInvestigatorId == "06005" then if forcedLearning then printToColor("Wow, did you really take 'Versatile' to play Patrice with 'Forced Learning'?" .. " Choose which draw replacement effect takes priority and draw cards accordingly.", messageColor) else -- discards all non-weakness and non-hidden cards from hand first local handCards = Player[playerColor].getHandObjects() local cardsToDiscard = {} for _, card in ipairs(handCards) do local md = JSON.decode(card.getGMNotes()) if card.type == "Card" and md ~= nil and (not md.weakness and not md.hidden and md.id ~= "52020") then table.insert(cardsToDiscard, card) end end -- perform discarding 1 by 1 local pos = returnGlobalDiscardPosition() deckLib.placeOrMergeIntoDeck(cardsToDiscard, pos, self.getRotation()) -- draw up to 5 cards local cardsToDraw = 5 - #handCards + #cardsToDiscard if cardsToDraw > 0 then printToColor("Discarding " .. #cardsToDiscard .. " and drawing " .. cardsToDraw .. " card(s). (Patrice)", messageColor) -- add some time if there are any cards to discard local k = 0 if #cardsToDiscard > 0 then k = 0.8 + (#cardsToDiscard * 0.1) end Wait.time(function() drawCardsWithReshuffle(cardsToDraw) end, k) end end elseif forcedLearning then printToColor("Drawing 2 cards, discard 1 (Forced Learning)", messageColor) drawCardsWithReshuffle(2) elseif activeInvestigatorId == "89001" then printToColor("Drawing 2 cards (Subject 5U-21)", messageColor) drawCardsWithReshuffle(2) else drawCardsWithReshuffle(1) end end -- click function for "draw 1 button" (that can be added via option panel) function doDrawOne(_, clickedByColor) updateMessageColor(clickedByColor) drawCardsWithReshuffle(1) end -- draws the specified amount of cards (and shuffles the discard if necessary) ---@param numCards number Number of cards to draw function drawCardsWithReshuffle(numCards) local deckAreaObjects = getDeckAreaObjects() -- Norman Withers handling local harbinger = false if deckAreaObjects.topCard and deckAreaObjects.topCard.getName() == "The Harbinger" then harbinger = true elseif deckAreaObjects.draw and not deckAreaObjects.draw.is_face_down then local cards = deckAreaObjects.draw.getObjects() if cards[#cards].name == "The Harbinger" then harbinger = true end end if harbinger then printToColor("The Harbinger is on top of your deck, not drawing cards", messageColor) return end local topCardDetected = false if deckAreaObjects.topCard ~= nil then deckAreaObjects.topCard.deal(1, playerColor) topCardDetected = true numCards = numCards - 1 if numCards == 0 then flipTopCardFromDeck() return end end local deckSize = 1 if deckAreaObjects.draw == nil then deckSize = 0 elseif deckAreaObjects.draw.type == "Deck" then deckSize = #deckAreaObjects.draw.getObjects() end if deckSize >= numCards then drawCards(numCards) -- flip top card again for Norman if topCardDetected and string.match(activeInvestigatorId, "%d%d%d%d%d") == "08004" then flipTopCardFromDeck() end else drawCards(deckSize) if deckAreaObjects.discard ~= nil then shuffleDiscardIntoDeck() Wait.time(function() drawCards(numCards - deckSize) -- flip top card again for Norman if topCardDetected and string.match(activeInvestigatorId, "%d%d%d%d%d") == "08004" then flipTopCardFromDeck() end end, 1) end printToColor("Take 1 horror (drawing card from empty deck)", messageColor) end end -- get the draw deck and discard pile objects and returns the references ---@return table: string-indexed table with references to the found objects function getDeckAreaObjects() local deckAreaObjects = {} for _, object in ipairs(searchDeckAndDiscardArea("isCardOrDeck")) do if self.positionToLocal(object.getPosition()).z > 0.5 then deckAreaObjects.discard = object -- Norman Withers handling elseif object.type == "Card" and not object.is_face_down then deckAreaObjects.topCard = object else deckAreaObjects.draw = object end end return deckAreaObjects end -- draws the specified number of cards (reshuffling of discard pile is handled separately) ---@param numCards number Number of cards to draw function drawCards(numCards) local deckAreaObjects = getDeckAreaObjects() if deckAreaObjects.draw then deckAreaObjects.draw.deal(numCards, playerColor) end end function shuffleDiscardIntoDeck() local deckAreaObjects = getDeckAreaObjects() if not deckAreaObjects.discard.is_face_down then deckAreaObjects.discard.flip() end deckAreaObjects.discard.shuffle() deckAreaObjects.discard.setPositionSmooth(self.positionToWorld(DRAW_DECK_POSITION), false, false) end -- utility function for Norman Withers to flip the top card to the revealed side function flipTopCardFromDeck() Wait.time(function() local deckAreaObjects = getDeckAreaObjects() if deckAreaObjects.topCard then elseif deckAreaObjects.draw then if deckAreaObjects.draw.type == "Card" then deckAreaObjects.draw.flip() else -- get bounds to know the height of the deck local bounds = deckAreaObjects.draw.getBounds() local pos = bounds.center + Vector(0, bounds.size.y / 2 + 0.2, 0) deckAreaObjects.draw.takeObject({ position = pos, flip = true }) end end end, 0.1) end -- discard a random non-hidden card from hand function doDiscardOne() local hand = Player[playerColor].getHandObjects() if #hand == 0 then broadcastToColor("Cannot discard from empty hand!", messageColor, "Red") else local choices = {} local hiddenCards = {} local missingMetadataCards = {} for i, handObj in ipairs(hand) do if handObj.type == "Card" then -- get a name for the card or use the index if unnamed local name = handObj.getName() if name == "" then name = "Card " .. i end -- check card for metadata local md = JSON.decode(handObj.getGMNotes()) if md == nil then table.insert(missingMetadataCards, name) elseif md.hidden or md.id == "52020" then table.insert(hiddenCards, name) else table.insert(choices, i) end end end -- print message with hidden cards if #hiddenCards > 0 then local cardList = concatenateListOfStrings(hiddenCards) printToColor("Excluded (hidden): " .. cardList, messageColor) end -- print message with missing metadata cards if #missingMetadataCards > 0 then local cardList = concatenateListOfStrings(missingMetadataCards) printToColor("Excluded (missing data): " .. cardList, messageColor) end if #choices == 0 then broadcastToColor("Didn't find any eligible cards for random discarding.", messageColor, "Orange") return end -- get a random eligible card (from the "choices" table) local num = math.random(1, #choices) deckLib.placeOrMergeIntoDeck(hand[choices[num]], returnGlobalDiscardPosition(), self.getRotation()) broadcastToAll(getColoredName(playerColor) .. " randomly discarded card " .. choices[num] .. "/" .. #hand .. ".", "White") end end function concatenateListOfStrings(list) local cardList for _, cardName in ipairs(list) do if not cardList then cardList = "" else cardList = cardList .. ", " end cardList = cardList .. cardName end return cardList end -- checks if DES is present function checkForDES() hasDES = false for _, obj in ipairs(searchAroundSelf()) do if obj.type == "Card" then local cardMetadata = JSON.decode(obj.getGMNotes()) or {} -- position is used to exclude deck / discard local cardPos = self.positionToLocal(obj.getPosition()) if cardMetadata.id == "06159" and cardPos.x > -1 then hasDES = true break end end end end --------------------------------------------------------- -- slot symbol displaying --------------------------------------------------------- -- this will redraw the XML for the slot symbols based on the slotData table function redrawSlotSymbols() local xml = {} local snapId = 0 -- use the snap point positions in the main play area for positions for _, snap in ipairs(self.getSnapPoints()) do if inArea(snap.position, MAIN_PLAY_AREA) then snapId = snapId + 1 local slotName = slotData[snapId] -- conversion from regular coordinates to XML local x = snap.position.x * 100 local y = snap.position.z * 100 -- XML for a single slot (panel with text in the special font) local slotXML = { tag = "Panel", attributes = { id = "slotPanel" .. snapId, scale = "0.1 0.1 1", width = "175", height = "175", position = x .. " " .. y .. " -11" }, children = { { tag = "Text", attributes = { id = "slot" .. snapId, rotation = getSlotRotation(slotName), fontSize = "145", font = "font_arkhamicons", color = "#414141CB", text = slotNameToChar[slotName] } } } } table.insert(xml, slotXML) end end self.UI.setXmlTable(xml) end -- toggle the "slot editing mode" function toggleSlotEditing(_, clickedByColor, isRightClick) if isRightClick then resetSlotSymbols() return end updateMessageColor(clickedByColor) -- toggle internal variable currentlyEditingSlots = not currentlyEditingSlots if currentlyEditingSlots then editButtonLabel("Edit Slots", "Stop editing") broadcastToColor("Click on a slot symbol (or an empty slot) to edit it.", messageColor, "Orange") addClickFunctionToSlots() else editButtonLabel("Stop editing", "Edit Slots") redrawSlotSymbols() end end -- click function for slot symbols during the "slot editing mode" function slotClickfunction(player, _, id) local slotIndex = id:gsub("slotPanel", "") slotIndex = tonumber(slotIndex) -- make a list of the table keys as options for the dialog box local slotNames = {} for slotName, _ in pairs(slotNameToChar) do table.insert(slotNames, slotName) end -- prompt player to choose symbol player.showOptionsDialog("Choose Slot Symbol", slotNames, slotData[slotIndex], function(chosenSlotName) slotData[slotIndex] = chosenSlotName -- update slot symbol self.UI.setAttribute("slot" .. slotIndex, "text", slotNameToChar[chosenSlotName]) -- update slot rotation self.UI.setAttribute("slot" .. slotIndex, "rotation", getSlotRotation(chosenSlotName)) end ) end -- helper function to rotate the left hand function getSlotRotation(slotName) if slotName == "Hand (left)" then return "0 180 180" else return "0 0 180" end end -- reset the slot symbols by making a deep copy of the default data and redrawing function resetSlotSymbols() slotData = {} for _, slotName in ipairs(defaultSlotData) do table.insert(slotData, slotName) end redrawSlotSymbols() -- need to re-add the click functions if currently in edit mode if currentlyEditingSlots then addClickFunctionToSlots() end end -- enables the click functions for editing function addClickFunctionToSlots() for i = 1, #slotData do self.UI.setAttribute("slotPanel" .. i, "onClick", "slotClickfunction") end end --------------------------------------------------------- -- color related functions --------------------------------------------------------- -- changes the player color function changeColor(clickedByColor) local colorList = Player.getColors() -- remove existing colors from the list of choices for _, existingColor in ipairs(Player.getAvailableColors()) do for i, newColor in ipairs(colorList) do if existingColor == newColor or newColor == "Black" or newColor == "Grey" then table.remove(colorList, i) end end end -- show the option dialog for color selection to the player that triggered this Player[clickedByColor].showOptionsDialog("Select a new color:", colorList, _, function(color) -- update the color of the hand zone local handZone = ownedObjects.HandZone handZone.setValue(color) -- if the seated player clicked this, reseat him to the new color if clickedByColor == playerColor then navigationOverlayApi.copyVisibility(playerColor, color) Player[playerColor].changeColor(color) end -- update the internal variable playerColor = color end) end --------------------------------------------------------- -- playermat token spawning --------------------------------------------------------- -- Finds all customizable cards in this play area and updates their metadata based on the selections -- on the matching upgrade sheet. -- This method is theoretically O(n^2), and should be used sparingly. In practice it will only be -- called when a checkbox is added or removed in-game (which should be rare), and is bounded by the -- number of customizable cards in play. function syncAllCustomizableCards() for _, card in ipairs(searchAroundSelf("isCard")) do syncCustomizableMetadata(card) end end function syncCustomizableMetadata(card) local cardMetadata = JSON.decode(card.getGMNotes()) or {} if cardMetadata == nil or cardMetadata.customizations == nil then return end for _, upgradeSheet in ipairs(searchAroundSelf("isCard")) do local upgradeSheetMetadata = JSON.decode(upgradeSheet.getGMNotes()) or {} if upgradeSheetMetadata.id == (cardMetadata.id .. "-c") then for i, customization in ipairs(cardMetadata.customizations) do if customization.replaces ~= nil and customization.replaces.uses ~= nil then if upgradeSheet.call("isUpgradeActive", i) then cardMetadata.uses = customization.replaces.uses card.setGMNotes(JSON.encode(cardMetadata)) else -- TODO: Get the original metadata to restore it... maybe. This should only be -- necessary in the very unlikely case that a user un-checks a previously-full upgrade -- row while the card is in play. It will be much easier once the AllPlayerCardsApi is -- in place, so defer until it is end end end end end end function spawnTokensFor(object) local extraUses = {} if activeInvestigatorId == "03004" then extraUses["Charge"] = 1 end tokenManager.spawnForCard(object, extraUses) end function onCollisionEnter(collisionInfo) local object = collisionInfo.collision_object -- only continue if loading is completed if not collisionEnabled then return end -- only continue for cards if object.type ~= "Card" then return end maybeUpdateActiveInvestigator(object) syncCustomizableMetadata(object) local localCardPos = self.positionToLocal(object.getPosition()) if inArea(localCardPos, DECK_DISCARD_AREA) then tokenSpawnTrackerApi.resetTokensSpawned(object) removeTokensFromObject(object) elseif shouldSpawnTokens(object) then spawnTokensFor(object) end end -- checks if tokens should be spawned for the provided card function shouldSpawnTokens(card) if card.is_face_down then return false end local localCardPos = self.positionToLocal(card.getPosition()) local metadata = JSON.decode(card.getGMNotes()) -- If no metadata we don't know the type, so only spawn in the main area if metadata == nil then return inArea(localCardPos, MAIN_PLAY_AREA) end -- Spawn tokens for assets and events on the main area if inArea(localCardPos, MAIN_PLAY_AREA) and (metadata.type == "Asset" or metadata.type == "Event") then return true end -- Spawn tokens for all encounter types in the threat area if inArea(localCardPos, THREAT_AREA) and (metadata.type == "Treachery" or metadata.type == "Enemy" or metadata.weakness) then return true end return false end function onObjectEnterContainer(container, object) if object.type ~= "Card" then return end local localCardPos = self.positionToLocal(object.getPosition()) if inArea(localCardPos, DECK_DISCARD_AREA) then tokenSpawnTrackerApi.resetTokensSpawned(object) removeTokensFromObject(object) end end -- removes tokens from the provided card/deck function removeTokensFromObject(object) if object.hasTag("CardThatSeals") then local func = object.getVar("resetSealedTokens") -- check if function exists (it won't for older custom content) if func ~= nil then object.call("resetSealedTokens") end end for _, obj in ipairs(searchLib.onObject(object)) do if tokenChecker.isChaosToken(obj) then chaosBagApi.returnChaosTokenToBag(obj, false) elseif obj.getGUID() ~= "4ee1f2" and -- table obj ~= self and obj.type ~= "Deck" and obj.type ~= "Card" and obj.memo ~= nil and obj.getLock() == false then ownedObjects.Trash.putObject(obj) end end end --------------------------------------------------------- -- investigator ID grabbing and skill tracker --------------------------------------------------------- -- updates the internal investigator id and action tokens if an investigator card is detected ---@param card tts__Object Card that might be an investigator function maybeUpdateActiveInvestigator(card) if not inArea(self.positionToLocal(card.getPosition()), INVESTIGATOR_AREA) then return end local notes = JSON.decode(card.getGMNotes()) local extraToken if notes ~= nil and notes.type == "Investigator" and notes.id ~= nil then if notes.id == activeInvestigatorId then return end activeInvestigatorClass = notes.class activeInvestigatorId = notes.id extraToken = notes.extraToken ownedObjects.InvestigatorSkillTracker.call("updateStats", { notes.willpowerIcons, notes.intellectIcons, notes.combatIcons, notes.agilityIcons }) updateTexture() elseif activeInvestigatorId ~= "00000" then activeInvestigatorClass = "Neutral" activeInvestigatorId = "00000" ownedObjects.InvestigatorSkillTracker.call("updateStats", { 1, 1, 1, 1 }) updateTexture() else return end -- set proper scale for investigators local cardData = card.getData() if cardData["SidewaysCard"] == true then -- 115% for easier readability card.setScale({ 1.15, 1, 1.15 }) else -- Zoop-exported investigators are horizontal cards and TTS scales them differently card.setScale({ 0.8214, 1, 0.8214 }) end -- remove old action tokens for _, obj in ipairs(searchAroundSelf("isUniversalToken")) do obj.destruct() end -- spawn three regular action tokens (investigator specific one in the bottom spot) for i = 1, 3 do local pos = self.positionToWorld(Vector(-1.54 + i * 0.17, 0, -0.28)):add(Vector(0, 0.2, 0)) tokenManager.spawnToken(pos, "universalActionAbility", self.getRotation(), function(spawned) spawned.call("updateClassAndSymbol", { class = activeInvestigatorClass, symbol = activeInvestigatorClass }) end) end -- spawn additional token (maybe specific for investigator) if extraToken and extraToken ~= "None" then -- local positions local tokenSpawnPos = { action = { Vector(-0.86, 0, -0.28), -- left of the regular three actions Vector(-1.54, 0, -0.28), -- right of the regular three actions }, ability = { Vector(-1, 0, 0.118), -- bottom left corner of the investigator card Vector(-1, 0, -0.118), -- top left corner of the investigator card } } -- spawn tokens (split string by "|") local count = { action = 0, ability = 0 } for str in string.gmatch(extraToken, "([^|]+)") do local type = "action" if str == "FreeTrigger" or str == "Reaction" then type = "ability" end count[type] = count[type] + 1 if count[type] > 2 then printToColor("More than two extra tokens of the same type are not supported.", playerColor) else local localSpawnPos = tokenSpawnPos[type][count[type]] local globalSpawnPos = self.positionToWorld(localSpawnPos):add(Vector(0, 0.2, 0)) tokenManager.spawnToken(globalSpawnPos, "universalActionAbility", self.getRotation(), function(spawned) spawned.call("updateClassAndSymbol", { class = activeInvestigatorClass, symbol = str }) end) end end end end -- updates the texture of the playermat ---@param overrideName? string Force a specific texture function updateTexture(overrideName) local name = "Neutral" -- use class specific texture if enabled if isClassTextureEnabled then name = activeInvestigatorClass end -- get new texture URL local newUrl = nameToTexture[name] -- override name if valid if nameToTexture[overrideName] then newUrl = nameToTexture[overrideName] end -- apply texture local customInfo = self.getCustomObject() if customInfo.image ~= newUrl then -- temporarily lock objects so they don't fall through the mat local objectsToUnlock = {} for _, obj in ipairs(searchAroundSelf()) do if not obj.getLock() then obj.setLock(true) table.insert(objectsToUnlock, obj) end end self.script_state = onSave() customInfo.image = newUrl self.setCustomObject(customInfo) local reloadedMat = self.reload() -- unlock objects when mat is reloaded Wait.condition(function() for _, obj in ipairs(objectsToUnlock) do obj.setLock(false) end end, function() return reloadedMat.loading_custom == false end) end end --------------------------------------------------------- -- manipulation of owned objects --------------------------------------------------------- -- updates the specified owned counter ---@param param table Contains the information to update: --- type: String Counter to target --- newValue: Number Value to set the counter to --- modifier: Number If newValue is not provided, the existing value will be adjusted by this modifier function updateCounter(param) local counter = ownedObjects[param.type] if counter ~= nil then counter.call("updateVal", param.newValue or (counter.getVar("val") + param.modifier)) else printToAll(param.type .. " for " .. matColor .. " could not be found.", "Yellow") end end -- get the value the specified owned counter ---@param type string Counter to target ---@return number: Counter value function getCounterValue(type) return ownedObjects[type].getVar("val") end -- set investigator skill tracker to "1, 1, 1, 1" function resetSkillTracker() local obj = ownedObjects.InvestigatorSkillTracker if obj ~= nil then obj.call("updateStats", { 1, 1, 1, 1 }) else printToAll("Skill tracker for " .. matColor .. " playermat could not be found.", "Yellow") end end --------------------------------------------------------- -- calls to 'Global' / functions for calls from outside --------------------------------------------------------- function drawChaosTokenButton(_, _, isRightClick) chaosBagApi.drawChaosToken(self, isRightClick) end function drawEncounterCard(_, _, isRightClick) local drawPos = getEncounterCardDrawPosition(not isRightClick) mythosAreaApi.drawEncounterCard(matColor, drawPos) end function returnGlobalDiscardPosition() return self.positionToWorld(DISCARD_PILE_POSITION) end function returnGlobalDrawPosition() return self.positionToWorld(DRAW_DECK_POSITION) end -- returns the position for encounter card drawing ---@param stack boolean If true, returns the leftmost position instead of the first empty from the right function getEncounterCardDrawPosition(stack) local drawPos = self.positionToWorld(DRAWN_ENCOUNTER_POSITION) -- maybe override position with first empty slot in threat area (right to left) if not stack then local searchPos = Vector(-0.91, 0.5, -0.625) for i = 1, 5 do local globalSearchPos = self.positionToWorld(searchPos) local searchResult = searchLib.atPosition(globalSearchPos, "isCardOrDeck") if #searchResult == 0 then drawPos = globalSearchPos break else searchPos.x = searchPos.x + 0.455 end end end return drawPos end -- creates / removes the draw 1 button ---@param visible boolean Whether the draw 1 button should be visible function showDrawButton(visible) isDrawButtonVisible = visible if isDrawButtonVisible then -- Draw 1 button: modified default data buttonParameters.label = "Draw 1" buttonParameters.click_function = "doDrawOne" buttonParameters.tooltip = "" buttonParameters.position.z = -0.35 self.createButton(buttonParameters) else local buttons = self.getButtons() for i = 1, #buttons do if buttons[i].label == "Draw 1" then self.removeButton(buttons[i].index) end end end end -- shows / hides a clickable clue counter for this playermat and sets the correct amount of clues ---@param showCounter boolean Whether the clickable clue counter should be visible function clickableClues(showCounter) local clickerPos = ownedObjects.ClickableClueCounter.getPosition() local clueCount = 0 -- move clue counters local modY = showCounter and 0.525 or -0.525 ownedObjects.ClickableClueCounter.setPosition(clickerPos + Vector(0, modY, 0)) if showCounter then -- get current clue count clueCount = ownedObjects.ClueCounter.getVar("exposedValue") -- remove clues ownedObjects.ClueCounter.call("removeAllClues", ownedObjects.Trash) -- set value for clue clickers ownedObjects.ClickableClueCounter.call("updateVal", clueCount) else -- get current clue count clueCount = ownedObjects.ClickableClueCounter.getVar("val") -- spawn clues local pos = self.positionToWorld({ x = -1.12, y = 0.05, z = 0.7 }) for i = 1, clueCount do pos.y = pos.y + 0.045 * i tokenManager.spawnToken(pos, "clue", self.getRotation()) end end end -- Toggles the use of class textures ---@param state boolean Whether the class texture should be used or not function useClassTexture(state) if state == isClassTextureEnabled then return end isClassTextureEnabled = state updateTexture() end -- removes all clues (moving tokens to the trash and setting counters to 0) function removeClues() ownedObjects.ClueCounter.call("removeAllClues", ownedObjects.Trash) ownedObjects.ClickableClueCounter.call("updateVal", 0) end -- reports the clue count ---@param useClickableCounters boolean Controls which type of counter is getting checked function getClueCount(useClickableCounters) if useClickableCounters then return ownedObjects.ClickableClueCounter.getVar("val") else return ownedObjects.ClueCounter.getVar("exposedValue") end end -- Sets this playermat's snap points to limit snapping to matching card types or not. If matchTypes -- is true, the main card slot snap points will only snap assets, while the investigator area point -- will only snap Investigators. If matchTypes is false, snap points will be reset to snap all cards. ---@param matchTypes boolean Whether snap points should only snap for the matching card types. function setLimitSnapsByType(matchTypes) local snaps = self.getSnapPoints() for i, snap in ipairs(snaps) do if inArea(snap.position, MAIN_PLAY_AREA) then local snapTags = snaps[i].tags if matchTypes then if snapTags == nil then snaps[i].tags = { "Asset" } else table.insert(snaps[i].tags, "Asset") end else snaps[i].tags = nil end end if inArea(snap.position, INVESTIGATOR_AREA) then local snapTags = snaps[i].tags if matchTypes then if snapTags == nil then snaps[i].tags = { "Investigator" } else table.insert(snaps[i].tags, "Investigator") end else snaps[i].tags = nil end end end self.setSnapPoints(snaps) end -- Simple method to check if the given point is in a specified area. Local use only ---@param point tts__Vector Point to check, only x and z values are relevant ---@param bounds table Defined area to see if the point is within. See MAIN_PLAY_AREA for sample bounds definition. ---@return boolean: True if the point is in the area defined by bounds function inArea(point, bounds) return (point.x < bounds.upperLeft.x and point.x > bounds.lowerRight.x and point.z < bounds.upperLeft.z and point.z > bounds.lowerRight.z) end -- called by custom data helpers to add player card data ---@param args table Contains only one entry, the GUID of the custom data helper function updatePlayerCards(args) local customDataHelper = getObjectFromGUID(args[1]) local playerCardData = customDataHelper.getTable("PLAYER_CARD_DATA") tokenManager.addPlayerCardData(playerCardData) end -- returns the colored steam name or color function getColoredName(playerColor) local displayName = playerColor if Player[playerColor].steam_name then displayName = Player[playerColor].steam_name end -- add bb-code return "[" .. Color.fromString(playerColor):toHex() .. "]" .. displayName .. "[-]" end end) __bundle_register("core/MythosAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local MythosAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getMythosArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "MythosArea") end ---@return any: Table of chaos token metadata (if provided through scenario reference card) MythosAreaApi.returnTokenData = function() return getMythosArea().call("returnTokenData") end ---@return any: Object reference to the encounter deck MythosAreaApi.getEncounterDeck = function() return getMythosArea().call("getEncounterDeck") end -- draw an encounter card for the requesting mat to the first empty spot from the right ---@param matColor string Playermat that triggered this ---@param position tts__Vector Position for the encounter card MythosAreaApi.drawEncounterCard = function(matColor, position) getMythosArea().call("drawEncounterCard", { matColor = matColor, position = position }) end -- reshuffle the encounter deck MythosAreaApi.reshuffleEncounterDeck = function() getMythosArea().call("reshuffleEncounterDeck") end return MythosAreaApi end end) __bundle_register("util/DeckLib", function(require, _LOADED, __bundle_register, __bundle_modules) do local DeckLib = {} local searchLib = require("util/SearchLib") -- places a card/deck at a position or merges into an existing deck below ---@param objOrTable tts__Object|table Object or table of objects to move ---@param pos table New position for the object ---@param rot? table New rotation for the object ---@param below? boolean Should the object be placed below an existing deck? DeckLib.placeOrMergeIntoDeck = function(objOrTable, pos, rot, below) if objOrTable == nil or pos == nil then return end -- handle 'objOrTable' parameter local objects = {} if type(objOrTable) == "table" then objects = objOrTable else table.insert(objects, objOrTable) end -- search the new position for existing card/deck local searchResult = searchLib.atPosition(pos, "isCardOrDeck") local targetObj -- get new position local offset = 0.5 local newPos = Vector(pos) + Vector(0, offset, 0) if #searchResult == 1 then targetObj = searchResult[1] local bounds = targetObj.getBounds() if below then newPos = Vector(pos):setAt("y", bounds.center.y - bounds.size.y / 2) else newPos = Vector(pos):setAt("y", bounds.center.y + bounds.size.y / 2 + offset) end end -- process objects in reverse order for i = #objects, 1, -1 do local obj = objects[i] -- add a 0.1 delay for each object (for animation purposes) Wait.time(function() -- allow moving smoothly out of hand and temporarily lock it obj.setLock(true) obj.use_hands = false if rot then obj.setRotationSmooth(rot, false, true) end obj.setPositionSmooth(newPos, false, true) -- wait for object to finish movement (or 2 seconds) Wait.condition( function() -- revert toggles obj.setLock(false) obj.use_hands = true -- use putObject to avoid a TTS bug that merges unrelated cards that are not resting if #searchResult == 1 and targetObj ~= obj and not targetObj.isDestroyed() and not obj.isDestroyed() then targetObj = targetObj.putObject(obj) else targetObj = obj end end, -- check state of the object (make sure it's not moving) function() return obj.isDestroyed() or not obj.isSmoothMoving() end, 2) end, (#objects- i) * 0.1) end end return DeckLib end end) __bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getPlayArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "PlayArea") end local function getInvestigatorCounter() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "InvestigatorCounter") end -- Returns the current value of the investigator counter from the playermat ---@return number: Number of investigators currently set on the counter PlayAreaApi.getInvestigatorCount = function() return getInvestigatorCounter().getVar("val") end -- Updates the current value of the investigator counter from the playermat ---@param count number Number of investigators to set on the counter PlayAreaApi.setInvestigatorCount = function(count) getInvestigatorCounter().call("updateVal", count) end -- Move all contents on the play area (cards, tokens, etc) one slot in the given direction. Certain -- fixed objects will be ignored, as will anything the player has tagged with 'displacement_excluded' ---@param playerColor string Color of the player requesting the shift for messages PlayAreaApi.shiftContentsUp = function(playerColor) getPlayArea().call("shiftContentsUp", playerColor) end PlayAreaApi.shiftContentsDown = function(playerColor) getPlayArea().call("shiftContentsDown", playerColor) end PlayAreaApi.shiftContentsLeft = function(playerColor) getPlayArea().call("shiftContentsLeft", playerColor) end PlayAreaApi.shiftContentsRight = function(playerColor) getPlayArea().call("shiftContentsRight", playerColor) end ---@param state boolean This controls whether location connections should be drawn PlayAreaApi.setConnectionDrawState = function(state) getPlayArea().call("setConnectionDrawState", state) end ---@param color string Connection color to be used for location connections PlayAreaApi.setConnectionColor = function(color) getPlayArea().call("setConnectionColor", color) end -- Event to be called when the current scenario has changed ---@param scenarioName string Name of the new scenario PlayAreaApi.onScenarioChanged = function(scenarioName) getPlayArea().call("onScenarioChanged", scenarioName) end -- Sets this playermat's snap points to limit snapping to locations or not. -- If matchTypes is false, snap points will be reset to snap all cards. ---@param matchCardTypes boolean Whether snap points should only snap for the matching card types PlayAreaApi.setLimitSnapsByType = function(matchCardTypes) getPlayArea().call("setLimitSnapsByType", matchCardTypes) end -- Receiver for the Global tryObjectEnterContainer event. Used to clear vector lines from dragged -- cards before they're destroyed by entering the container PlayAreaApi.tryObjectEnterContainer = function(container, object) getPlayArea().call("tryObjectEnterContainer", { container = container, object = object }) end -- Counts the VP on locations in the play area PlayAreaApi.countVP = function() return getPlayArea().call("countVP") end -- Highlights all locations in the play area without metadata ---@param state boolean True if highlighting should be enabled PlayAreaApi.highlightMissingData = function(state) return getPlayArea().call("highlightMissingData", state) end -- Highlights all locations in the play area with VP ---@param state boolean True if highlighting should be enabled PlayAreaApi.highlightCountedVP = function(state) return getPlayArea().call("countVP", state) end -- Checks if an object is in the play area (returns true or false) PlayAreaApi.isInPlayArea = function(object) return getPlayArea().call("isInPlayArea", object) end -- Returns the current surface of the play area PlayAreaApi.getSurface = function() return getPlayArea().getCustomObject().image end -- Updates the surface of the play area PlayAreaApi.updateSurface = function(url) return getPlayArea().call("updateSurface", url) end -- Returns a deep copy of the currently tracked locations PlayAreaApi.getTrackedLocations = function() local t = {} for k, v in pairs(getPlayArea().call("getTrackedLocations", {})) do t[k] = v end return t end -- Called by Custom Data Helpers to push their location data into the Data Helper. This adds the -- data to the local token manager instance. ---@param args table Single-value array holding the GUID of the Custom Data Helper making the call PlayAreaApi.updateLocations = function(args) getPlayArea().call("updateLocations", args) end PlayAreaApi.getCustomDataHelper = function() return getPlayArea().getVar("customDataHelper") end return PlayAreaApi end end) __bundle_register("core/GUIDReferenceApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local GUIDReferenceApi = {} local function getGuidHandler() return getObjectFromGUID("123456") end -- Returns the matching object ---@param owner string Parent object for this search ---@param type string Type of object to search for ---@return any: Object reference to the matching object GUIDReferenceApi.getObjectByOwnerAndType = function(owner, type) return getGuidHandler().call("getObjectByOwnerAndType", { owner = owner, type = type }) end -- Returns all matching objects as a table with references ---@param type string Type of object to search for ---@return table: List of object references to matching objects GUIDReferenceApi.getObjectsByType = function(type) return getGuidHandler().call("getObjectsByType", type) end -- Returns all matching objects as a table with references ---@param owner string Parent object for this search ---@return table: List of object references to matching objects GUIDReferenceApi.getObjectsByOwner = function(owner) return getGuidHandler().call("getObjectsByOwner", owner) end -- Sends new information to the reference handler to edit the main index ---@param owner string Parent of the object ---@param type string Type of the object ---@param guid string GUID of the object GUIDReferenceApi.editIndex = function(owner, type, guid) return getGuidHandler().call("editIndex", { owner = owner, type = type, guid = guid }) end -- Returns the owner of an object or the object it's located on ---@param object tts__GameObject Object for this search ---@return string: Parent of the object or object it's located on GUIDReferenceApi.getOwnerOfObject = function(object) return getGuidHandler().call("getOwnerOfObject", object) end return GUIDReferenceApi end end) __bundle_register("core/token/TokenManager", function(require, _LOADED, __bundle_register, __bundle_modules) do local guidReferenceApi = require("core/GUIDReferenceApi") local optionPanelApi = require("core/OptionPanelApi") local playAreaApi = require("core/PlayAreaApi") local playermatApi = require("playermat/PlayermatApi") local searchLib = require("util/SearchLib") local tokenSpawnTrackerApi = require("core/token/TokenSpawnTrackerApi") local PLAYER_CARD_TOKEN_OFFSETS = { [1] = { Vector(0, 3, -0.2) }, [2] = { Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [3] = { Vector(0, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [4] = { Vector(0.4, 3, -0.9), Vector(-0.4, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [5] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [6] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2) }, [7] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0, 3, 0.5) }, [8] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(-0.35, 3, 0.5), Vector(0.35, 3, 0.5) }, [9] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5) }, [10] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0, 3, 1.2) }, [11] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(-0.35, 3, 1.2), Vector(0.35, 3, 1.2) }, [12] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0.7, 3, 1.2), Vector(0, 3, 1.2), Vector(-0.7, 3, 1.2) } } -- stateIDs for the multi-stated resource tokens local stateTable = { ["resource"] = 1, ["ammo"] = 2, ["bounty"] = 3, ["charge"] = 4, ["evidence"] = 5, ["secret"] = 6, ["supply"] = 7, ["offering"] = 8 } -- Table of data extracted from the token source bag, keyed by the Memo on each token which -- should match the token type keys ("resource", "clue", etc) local tokenTemplates local playerCardData local locationData local TokenManager = {} local internal = {} -- Spawns tokens for the card. This function is built to just throw a card at it and let it do -- the work once a card has hit an area where it might spawn tokens. It will check to see if -- the card has already spawned, find appropriate data from either the uses metadata or the Data -- Helper, and spawn the tokens. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playermat should pass "Charge"=1 TokenManager.spawnForCard = function(card, extraUses) if tokenSpawnTrackerApi.hasSpawnedTokens(card.getGUID()) then return end local metadata = JSON.decode(card.getGMNotes()) if metadata ~= nil then internal.spawnTokensFromUses(card, extraUses) else internal.spawnTokensFromDataHelper(card) end end -- Spawns a set of tokens on the given card. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string Type of token to spawn (template needs to be in source bag) ---@param tokenCount number How many tokens to spawn. For damage or horror this value will be set to the -- spawned state object rather than spawning multiple tokens ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown, subType) local optionPanel = optionPanelApi.getOptions() if tokenType == "damage" or tokenType == "horror" then TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "enabled" then TokenManager.spawnResourceCounterToken(card, tokenCount) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "custom" and tokenCount == 0 then TokenManager.spawnResourceCounterToken(card, tokenCount) else TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown, subType) end end -- Spawns a single counter token and sets the value to tokenValue. Used for damage and horror tokens. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string Type of token to spawn (template needs to be in source bag) ---@param tokenValue number Value to set the damage/horror to TokenManager.spawnCounterToken = function(card, tokenType, tokenValue, shiftDown) if tokenValue < 1 or tokenValue > 50 then return end local pos = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[1][1] + Vector(0, 0, shiftDown)) local rot = card.getRotation() TokenManager.spawnToken(pos, tokenType, rot, function(spawned) -- token starts in state 1, so don't attempt to change it to avoid error if tokenValue ~= 1 then spawned.setState(tokenValue) end end) end TokenManager.spawnResourceCounterToken = function(card, tokenCount) local pos = card.positionToWorld(card.positionToLocal(card.getPosition()) + Vector(0, 0.2, -0.5)) local rot = card.getRotation() TokenManager.spawnToken(pos, "resourceCounter", rot, function(spawned) spawned.call("updateVal", tokenCount) end) end -- Spawns a number of tokens. ---@param tokenType string Type of token to spawn (template needs to be in source bag) ---@param tokenCount number How many tokens to spawn ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource or action tokens TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown, subType) -- not checking the max at this point since clue offsets are calculated dynamically if tokenCount < 1 then return end local offsets = {} if tokenType == "clue" then offsets = internal.buildClueOffsets(card, tokenCount) else -- only up to 12 offset tables defined if tokenCount > 12 then printToAll("Attempting to spawn " .. tokenCount .. " tokens. Spawning clickable counter instead.") TokenManager.spawnResourceCounterToken(card, tokenCount) return end for i = 1, tokenCount do offsets[i] = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[tokenCount][i]) -- Fix the y-position for the spawn, since positionToWorld considers rotation which can -- have bad results for face up/down differences offsets[i].y = card.getPosition().y + 0.15 end end if shiftDown ~= nil then -- Copy the offsets to make sure we don't change the static values local baseOffsets = offsets offsets = {} -- get a vector for the shifting (downwards local to the card) local shiftDownVector = Vector(0, 0, shiftDown):rotateOver("y", card.getRotation().y) for i, baseOffset in ipairs(baseOffsets) do offsets[i] = baseOffset + shiftDownVector end end if offsets == nil then error("couldn't find offsets for " .. tokenCount .. ' tokens') return end -- this is used to load the correct state for additional resource tokens (e.g. "Ammo") local callback = nil local stateID = stateTable[string.lower(subType or "")] if tokenType == "resource" and stateID ~= nil and stateID ~= 1 then callback = function(spawned) spawned.setState(stateID) end elseif tokenType == "universalActionAbility" then local matColor = playermatApi.getMatColorByPosition(card.getPosition()) local class = playermatApi.returnInvestigatorClass(matColor) callback = function(spawned) spawned.call("updateClassAndSymbol", { class = class, symbol = subType or class }) end end for i = 1, tokenCount do TokenManager.spawnToken(offsets[i], tokenType, card.getRotation(), callback) end end -- Spawns a single token at the given global position by copying it from the template bag. ---@param position tts__Vector Global position to spawn the token ---@param tokenType string Type of token to spawn (template needs to be in source bag) ---@param rotation tts__Vector Rotation to be used for the new token. Only the y-value will be used, -- x and z will use the default rotation from the source bag ---@param callback? function A callback function triggered after the new token is spawned TokenManager.spawnToken = function(position, tokenType, rotation, callback) internal.initTokenTemplates() local loadTokenType = tokenType if tokenType == "clue" or tokenType == "doom" then loadTokenType = "clueDoom" end if tokenTemplates[loadTokenType] == nil then error("Unknown token type '" .. tokenType .. "'") return end local tokenTemplate = tokenTemplates[loadTokenType] -- Take ONLY the Y-value for rotation, so we don't flip the token coming out of the bag local rot = Vector(tokenTemplate.Transform.rotX, 270, tokenTemplate.Transform.rotZ) if rotation ~= nil then rot.y = rotation.y end if tokenType == "doom" then rot.z = 180 end tokenTemplate.Nickname = "" return spawnObjectData({ data = tokenTemplate, position = position, rotation = rot, callback_function = callback }) end -- Checks a card for metadata to maybe replenish it ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) TokenManager.maybeReplenishCard = function(card, uses) -- TODO: support for cards with multiple uses AND replenish (as of yet, no official card needs that) if uses[1].count and uses[1].replenish then internal.replenishTokens(card, uses) end end -- Pushes new player card data into the local copy of the Data Helper player data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addPlayerCardData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do playerCardData[k] = v end end -- Pushes new location data into the local copy of the Data Helper location data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addLocationData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do locationData[k] = v end end -- Checks to see if the given card has location data in the DataHelper ---@param card tts__Object Card to check for data ---@return boolean: True if this card has data in the helper, false otherwise TokenManager.hasLocationData = function(card) internal.initDataHelperData() return internal.getLocationData(card) ~= nil end internal.initTokenTemplates = function() if tokenTemplates ~= nil then return end tokenTemplates = {} local tokenSource = guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSource") for _, tokenTemplate in ipairs(tokenSource.getData().ContainedObjects) do local tokenName = tokenTemplate.Memo tokenTemplates[tokenName] = tokenTemplate end end -- Copies the data from the DataHelper. Will only happen once. internal.initDataHelperData = function() if playerCardData ~= nil then return end local dataHelper = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") playerCardData = dataHelper.getTable('PLAYER_CARD_DATA') locationData = dataHelper.getTable('LOCATIONS_DATA') end -- Spawn tokens for a card based on the uses metadata. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playermat should pass "Charge"=1 internal.spawnTokensFromUses = function(card, extraUses) local uses = internal.getUses(card) if uses == nil then return end -- go through tokens to spawn local tokenCount for i, useInfo in ipairs(uses) do tokenCount = (useInfo.count or 0) + (useInfo.countPerInvestigator or 0) * playAreaApi.getInvestigatorCount() if extraUses ~= nil and extraUses[useInfo.type] ~= nil then tokenCount = tokenCount + extraUses[useInfo.type] end -- Shift each spawned group after the first down so they don't pile on each other TokenManager.spawnTokenGroup(card, useInfo.token, tokenCount, (i - 1) * 0.8, useInfo.type) end tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a card based on the data helper data. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for internal.spawnTokensFromDataHelper = function(card) internal.initDataHelperData() local playerData = internal.getPlayerCardData(card) if playerData ~= nil then internal.spawnPlayerCardTokensFromDataHelper(card, playerData) end local locationData = internal.getLocationData(card) if locationData ~= nil then internal.spawnLocationTokensFromDataHelper(card, locationData) end end -- Spawn tokens for a player card using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param playerData table Player card data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnPlayerCardTokensFromDataHelper = function(card, playerData) local token = playerData.tokenType local tokenCount = playerData.tokenCount TokenManager.spawnTokenGroup(card, token, tokenCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a location using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param locationData table Location data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnLocationTokensFromDataHelper = function(card, locationData) local clueCount = internal.getClueCountFromData(card, locationData) if clueCount > 0 then TokenManager.spawnTokenGroup(card, "clue", clueCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end end internal.getPlayerCardData = function(card) return playerCardData[card.getName() .. ':' .. card.getDescription()] or playerCardData[card.getName()] end internal.getLocationData = function(card) return locationData[card.getName() .. '_' .. card.getGUID()] or locationData[card.getName()] end internal.getClueCountFromData = function(card, locationData) -- Return the number of clues to spawn on this location if locationData == nil then error('attempted to get clue for unexpected object: ' .. card.getName()) return 0 end if ((card.is_face_down and locationData.clueSide == 'back') or (not card.is_face_down and locationData.clueSide == 'front')) then if locationData.type == 'fixed' then return locationData.value elseif locationData.type == 'perPlayer' then return locationData.value * playAreaApi.getInvestigatorCount() end error('unexpected location type: ' .. locationData.type) end return 0 end -- Gets the right uses structure for this card, based on metadata and face up/down state ---@param card tts__Object Card to pull the uses from internal.getUses = function(card) local metadata = JSON.decode(card.getGMNotes()) or {} if metadata.type == "Location" then if card.is_face_down and metadata.locationBack ~= nil then return metadata.locationBack.uses elseif not card.is_face_down and metadata.locationFront ~= nil then return metadata.locationFront.uses end elseif not card.is_face_down then return metadata.uses end return nil end -- Dynamically create positions for clues on a card. ---@param card tts__Object Card the clues will be placed on ---@param count number How many clues? ---@return table: Array of global positions to spawn the clues at internal.buildClueOffsets = function(card, count) local cluePositions = {} for i = 1, count do local row = math.floor(1 + (i - 1) / 4) local column = (i - 1) % 4 local cluePos = card.positionToWorld(Vector(-0.825 + 0.55 * column, 0, -1.5 + 0.55 * row)) cluePos.y = cluePos.y + 0.05 table.insert(cluePositions, cluePos) end return cluePositions end ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) internal.replenishTokens = function(card, uses) -- get current amount of matching resource tokens on the card local clickableResourceCounter = nil local foundTokens = 0 local searchType = string.lower(uses[1].type) for _, obj in ipairs(searchLib.onObject(card, "isTileOrToken")) do local memo = obj.getMemo() if searchType == memo then foundTokens = foundTokens + math.abs(obj.getQuantity()) obj.destruct() elseif memo == "resourceCounter" then foundTokens = obj.getVar("val") clickableResourceCounter = obj break end end -- this is the theoretical new amount of uses (to be checked below) local newCount = foundTokens + uses[1].replenish -- if there are already more uses than the replenish amount, keep them if foundTokens > uses[1].count then newCount = foundTokens -- only replenish up until the replenish amount elseif newCount > uses[1].count then newCount = uses[1].count end -- update the clickable counter or spawn a group of tokens if clickableResourceCounter then clickableResourceCounter.call("updateVal", newCount) else TokenManager.spawnTokenGroup(card, uses[1].token, newCount, _, uses[1].type) end end return TokenManager end end) __bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenSpawnTracker = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getSpawnTracker() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSpawnTracker") end TokenSpawnTracker.hasSpawnedTokens = function(cardGuid) return getSpawnTracker().call("hasSpawnedTokens", cardGuid) end TokenSpawnTracker.markTokensSpawned = function(cardGuid) return getSpawnTracker().call("markTokensSpawned", cardGuid) end TokenSpawnTracker.resetTokensSpawned = function(card) return getSpawnTracker().call("resetTokensSpawned", card) end TokenSpawnTracker.resetAllAssetAndEvents = function() return getSpawnTracker().call("resetAllAssetAndEvents") end TokenSpawnTracker.resetAllLocations = function() return getSpawnTracker().call("resetAllLocations") end TokenSpawnTracker.resetAll = function() return getSpawnTracker().call("resetAll") end return TokenSpawnTracker end end) __bundle_register("playermat/PlayermatApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayermatApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local searchLib = require("util/SearchLib") local localInvestigatorPosition = { x = -1.17, y = 1, z = -0.01 } -- Convenience function to look up a mat's object by color, or get all mats. ---@param matColor string Color of the playermat - White, Orange, Green, Red or All ---@return table: Single-element if only single playermat is requested local function getMatForColor(matColor) if matColor == "All" then return guidReferenceApi.getObjectsByType("Playermat") else return { matColor = guidReferenceApi.getObjectByOwnerAndType(matColor, "Playermat") } end end -- Returns the color of the closest playermat ---@param startPos table Starting position to get the closest mat from PlayermatApi.getMatColorByPosition = function(startPos) local result, smallestDistance for matColor, mat in pairs(getMatForColor("All")) do local distance = Vector.between(startPos, mat.getPosition()):magnitude() if smallestDistance == nil or distance < smallestDistance then smallestDistance = distance result = matColor end end return result end -- Returns the color of the player's hand that is seated next to the playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.getPlayerColor = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("playerColor") end end -- Returns the color of the playermat that owns the playercolor's hand ---@param handColor string Color of the playermat PlayermatApi.getMatColor = function(handColor) for matColor, mat in pairs(getMatForColor("All")) do local playerColor = mat.getVar("playerColor") if playerColor == handColor then return matColor end end end -- Instructs a playermat to check for DES ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.checkForDES = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("checkForDES") end end -- Returns if there is the card "Dream-Enhancing Serum" on the requested playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") ---@return boolean: whether DES is present on the playermat PlayermatApi.hasDES = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("hasDES") end end -- gets the slot data for the playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.getSlotData = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getTable("slotData") end end -- sets the slot data for the playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") ---@param newSlotData table New slot data for the playermat PlayermatApi.loadSlotData = function(matColor, newSlotData) for _, mat in pairs(getMatForColor(matColor)) do mat.setTable("slotData", newSlotData) mat.call("redrawSlotSymbols") return end end -- Performs a search of the deck area of the requested playermat and returns the result as table ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.getDeckAreaObjects = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("getDeckAreaObjects") end end -- Flips the top card of the deck (useful after deck manipulation for Norman Withers) ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.flipTopCardFromDeck = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("flipTopCardFromDeck") end end -- Returns the position of the discard pile of the requested playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.getDiscardPosition = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("returnGlobalDiscardPosition") end end -- Returns the position of the draw pile of the requested playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.getDrawPosition = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("returnGlobalDrawPosition") end end -- Transforms a local position into a global position ---@param localPos table Local position to be transformed ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.transformLocalPosition = function(localPos, matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.positionToWorld(localPos) end end -- Returns the rotation of the requested playermat ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.returnRotation = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getRotation() end end -- Returns a table with spawn data (position and rotation) for a helper object ---@param matColor string Color of the playermat - White, Orange, Green, Red or All ---@param helperName string Name of the helper object PlayermatApi.getHelperSpawnData = function(matColor, helperName) local resultTable = {} local localPositionTable = { ["Hand Helper"] = {0.05, 0, -1.182}, ["Search Assistant"] = {-0.3, 0, -1.182} } for color, mat in pairs(getMatForColor(matColor)) do resultTable[color] = { position = mat.positionToWorld(localPositionTable[helperName]), rotation = mat.getRotation() } end return resultTable end -- Triggers the Upkeep for the requested playermat ---@param matColor string Color of the playermat - White, Orange, Green, Red or All ---@param playerColor string Color of the calling player (for messages) PlayermatApi.doUpkeepFromHotkey = function(matColor, playerColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("doUpkeepFromHotkey", playerColor) end end -- Handles discarding for the requested playermat for the provided list of objects ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") ---@param objList table List of objects to discard PlayermatApi.discardListOfObjects = function(matColor, objList) for _, mat in pairs(getMatForColor(matColor)) do mat.call("discardListOfObjects", objList) end end -- Returns the active investigator id ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.returnInvestigatorId = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("activeInvestigatorId") end end -- Returns the class of the active investigator ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") PlayermatApi.returnInvestigatorClass = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do return mat.getVar("activeInvestigatorClass") end end -- Returns the position for encounter card drawing ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") ---@param stack boolean If true, returns the leftmost position instead of the first empty from the right PlayermatApi.getEncounterCardDrawPosition = function(matColor, stack) for _, mat in pairs(getMatForColor(matColor)) do return Vector(mat.call("getEncounterCardDrawPosition", stack)) end end -- Sets the requested playermat's snap points to limit snapping to matching card types or not. If -- matchTypes is true, the main card slot snap points will only snap assets, while the -- investigator area point will only snap Investigators. If matchTypes is false, snap points will -- be reset to snap all cards. ---@param matchCardTypes boolean Whether snap points should only snap for the matching card types ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.setLimitSnapsByType = function(matchCardTypes, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("setLimitSnapsByType", matchCardTypes) end end -- Sets the requested playermat's draw 1 button to visible ---@param isDrawButtonVisible boolean Whether the draw 1 button should be visible or not ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.showDrawButton = function(isDrawButtonVisible, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("showDrawButton", isDrawButtonVisible) end end -- Shows or hides the clickable clue counter for the requested playermat ---@param showCounter boolean Whether the clickable counter should be present or not ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.clickableClues = function(showCounter, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("clickableClues", showCounter) end end -- Toggles the use of class textures for the requested playermat ---@param state boolean Whether the class texture should be used or not ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.useClassTexture = function(state, matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("useClassTexture", state) end end -- Removes all clues (to the trash for tokens and counters set to 0) for the requested playermat ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.removeClues = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("removeClues") end end -- Reports the clue count for the requested playermat ---@param useClickableCounters boolean Controls which type of counter is getting checked PlayermatApi.getClueCount = function(useClickableCounters, matColor) local count = 0 for _, mat in pairs(getMatForColor(matColor)) do count = count + mat.call("getClueCount", useClickableCounters) end return count end -- Updates the specified owned counter ---@param matColor string Color of the playermat - White, Orange, Green, Red or All ---@param type string Counter to target ---@param newValue number Value to set the counter to ---@param modifier number If newValue is not provided, the existing value will be adjusted by this modifier PlayermatApi.updateCounter = function(matColor, type, newValue, modifier) for _, mat in pairs(getMatForColor(matColor)) do mat.call("updateCounter", { type = type, newValue = newValue, modifier = modifier }) end end -- Triggers the draw function for the specified playermat ---@param matColor string Color of the playermat - White, Orange, Green, Red or All ---@param number number Amount of cards to draw PlayermatApi.drawCardsWithReshuffle = function(matColor, number) for _, mat in pairs(getMatForColor(matColor)) do mat.call("drawCardsWithReshuffle", number) end end -- Returns the resource counter amount ---@param matColor string Color of the playermat - White, Orange, Green or Red (does not support "All") ---@param type string Counter to target PlayermatApi.getCounterValue = function(matColor, type) for _, mat in pairs(getMatForColor(matColor)) do return mat.call("getCounterValue", type) end end -- Returns a list of mat colors that have an investigator placed PlayermatApi.getUsedMatColors = function() local usedColors = {} for matColor, mat in pairs(getMatForColor("All")) do local searchPos = mat.positionToWorld(localInvestigatorPosition) local searchResult = searchLib.atPosition(searchPos, "isCardOrDeck") if #searchResult > 0 then table.insert(usedColors, matColor) end end return usedColors end -- Returns investigator name ---@param matColor string Color of the playmat - White, Orange, Green or Red (does not support "All") PlayermatApi.getInvestigatorName = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do local searchPos = mat.positionToWorld(localInvestigatorPosition) local searchResult = searchLib.atPosition(searchPos, "isCardOrDeck") if #searchResult == 1 then return searchResult[1].getName() end end return "" end -- Resets the specified skill tracker to "1, 1, 1, 1" ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.resetSkillTracker = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("resetSkillTracker") end end -- Redraws the XML for the slot symbols based on the slotData table ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.redrawSlotSymbols = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("redrawSlotSymbols") end end -- Finds all objects on the playermat and associated set aside zone and returns a table ---@param matColor string Color of the playermat - White, Orange, Green, Red or All ---@param filter string Name of the filte function (see util/SearchLib) PlayermatApi.searchAroundPlayermat = function(matColor, filter) local objList = {} for _, mat in pairs(getMatForColor(matColor)) do for _, obj in ipairs(mat.call("searchAroundSelf", filter)) do table.insert(objList, obj) end end return objList end -- Discard a non-hidden card from the corresponding player's hand ---@param matColor string Color of the playermat - White, Orange, Green, Red or All PlayermatApi.doDiscardOne = function(matColor) for _, mat in pairs(getMatForColor(matColor)) do mat.call("doDiscardOne") end end -- Triggers the metadata sync for all playermats PlayermatApi.syncAllCustomizableCards = function() for _, mat in pairs(getMatForColor("All")) do mat.call("syncAllCustomizableCards") end end return PlayermatApi end end) return __bundle_require("__root")