-- 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/Playmat") end) __bundle_register("playermat/Playmat", 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") -- 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 -- 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 playmat -- 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 } -- 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) local slotData = {} local defaultSlotData = { -- 1st row "any", "any", "any", "Tarot", "Hand (left)", "Hand (right)", "Ally", -- 2nd row "any", "any", "any", "Accessory", "Arcane", "Arcane", "Body" } -- global variable so it can be reset by the Clean Up Helper activeInvestigatorId = "00000" local isDrawButtonVisible = false -- global variable to report "Dream-Enhancing Serum" status isDES = false -- table of type-object reference pairs of all owned objects local ownedObjects = {} local matColor = self.getMemo() function onSave() return JSON.encode({ playerColor = playerColor, activeInvestigatorId = activeInvestigatorId, isDrawButtonVisible = isDrawButtonVisible, slotData = slotData }) end function onLoad(savedData) if savedData and savedData ~= "" then local loadedData = JSON.decode(savedData) playerColor = loadedData.playerColor activeInvestigatorId = loadedData.activeInvestigatorId isDrawButtonVisible = loadedData.isDrawButtonVisible slotData = loadedData.slotData end 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 playmat and associated set aside zone. function searchAroundSelf(filter) 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 -- 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 playmat -- setAsideDirection accounts for the set aside zone being on the left or right, depending on the -- table position of the playmat local setAsideDirection = bounds.center.z > 0 and 1 or -1 local localCenter = self.positionToLocal(bounds.center) localCenter.x = localCenter.x + setAsideDirection * SEARCH_AROUND_SELF_X_BUFFER / 2 / self.getScale().x 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 -- put chaos tokens back into bag (e.g. Unrelenting) elseif tokenChecker.isChaosToken(obj) then chaosBagApi.returnChaosTokenToBag(obj) -- don't touch locked objects (like the table etc.) or specific objects (like key tokens) elseif not obj.getLock() and not obj.hasTag("DontDiscard") then 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 forcedLearning local forcedLearning = false local rot = self.getRotation() for _, obj in ipairs(searchAroundSelf()) do if obj.getDescription() == "Action Token" 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 false) 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 if cardMetadata.uses ~= nil 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 local handSize = #Player[playerColor].getHandObjects() if handSize < 5 then local cardsToDraw = 5 - handSize printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) drawCardsWithReshuffle(cardsToDraw) 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 = {} for i = 1, #hand do local notes = JSON.decode(hand[i].getGMNotes()) if notes ~= nil then if notes.hidden ~= true then table.insert(choices, i) end else table.insert(choices, i) end end if #choices == 0 then broadcastToColor("Hidden cards can't be randomly discarded.", messageColor, "Orange") return end -- get a random non-hidden card (from the "choices" table) local num = math.random(1, #choices) deckLib.placeOrMergeIntoDeck(hand[choices[num]], returnGlobalDiscardPosition(), self.getRotation()) local playerName = Player[playerColor].steam_name or playerColor broadcastToAll(playerName .. " randomly discarded card " .. choices[num] .. "/" .. #hand .. ".", "White") 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 --------------------------------------------------------- -- playmat 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 -- detect if "Dream-Enhancing Serum" is placed if object.getName() == "Dream-Enhancing Serum" then isDES = true end maybeUpdateActiveInvestigator(object) syncCustomizableMetadata(object) local localCardPos = self.positionToLocal(object.getPosition()) if inArea(localCardPos, DECK_DISCARD_AREA) then tokenManager.resetTokensSpawned(object) removeTokensFromObject(object) elseif shouldSpawnTokens(object) then spawnTokensFor(object) end end -- detect if "Dream-Enhancing Serum" is removed function onCollisionExit(collisionInfo) if collisionInfo.collision_object.getName() == "Dream-Enhancing Serum" then isDES = false 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 tokenManager.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) elseif obj.getGUID() ~= "4ee1f2" and -- table obj ~= self and obj.type ~= "Deck" and obj.type ~= "Card" and obj.memo ~= nil and obj.getLock() == false and obj.getDescription() ~= "Action Token" 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 class if notes ~= nil and notes.type == "Investigator" and notes.id ~= nil then if notes.id == activeInvestigatorId then return end class = notes.class activeInvestigatorId = notes.id ownedObjects.InvestigatorSkillTracker.call("updateStats", { notes.willpowerIcons, notes.intellectIcons, notes.combatIcons, notes.agilityIcons }) elseif activeInvestigatorId ~= "00000" then class = "Neutral" activeInvestigatorId = "00000" ownedObjects.InvestigatorSkillTracker.call("updateStats", { 1, 1, 1, 1 }) else return end -- change state of action tokens local search = searchArea(self.positionToWorld({ -1.1, 0.05, -0.27 }), { 4, 1, 1 }) local smallToken = nil local STATE_TABLE = { ["Guardian"] = 1, ["Seeker"] = 2, ["Rogue"] = 3, ["Mystic"] = 4, ["Survivor"] = 5, ["Neutral"] = 6 } for _, obj in ipairs(search) do if obj.getDescription() == "Action Token" and obj.getStateId() > 0 then if obj.getScale().x < 0.4 then smallToken = obj else setObjectState(obj, STATE_TABLE[class]) end end end -- update the small token with special action for certain investigators local SPECIAL_ACTIONS = { ["04002"] = 8, -- Ursula Downs ["01002"] = 9, -- Daisy Walker ["01502"] = 9, -- Daisy Walker ["01002-pb"] = 9, -- Daisy Walker ["06003"] = 10, -- Tony Morgan ["04003"] = 11, -- Finn Edwards ["08016"] = 14 -- Bob Jenkins } if smallToken ~= nil then setObjectState(smallToken, SPECIAL_ACTIONS[activeInvestigatorId] or STATE_TABLE[class]) end end function setObjectState(obj, stateId) if obj.getStateId() ~= stateId then obj.setState(stateId) 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 .. " playmat 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 playmat 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 -- 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 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) return 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) return Global.call("releaseAllSealedTokens", playerColor) end -- returns all drawn tokens to the chaos bag ChaosBagApi.returnChaosTokens = function() return 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) return 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 ChaosBagApi.returnChaosTokenToBag = function(token) return Global.call("returnChaosTokenToBag", token) 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) return 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 canTouch True if the bag is manipulated, false if it should be blocked. ChaosBagApi.canTouchChaosTokens = function() return Global.call("canTouchChaosTokens") end -- called by playermats (by the "Draw chaos token" button) ---@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 returnedToken? tts__Object Token to be replaced with newly drawn token ChaosBagApi.drawChaosToken = function(mat, drawAdditional, tokenType, guidToBeResolved, returnedToken) return Global.call("drawChaosToken", {mat = mat, drawAdditional = drawAdditional, tokenType = tokenType, guidToBeResolved = guidToBeResolved, returnedToken = returnedToken}) 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/MythosAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local MythosAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getMythosArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "MythosArea") end ---@return any: Table of chaos token metadata (if provided through scenario reference card) MythosAreaApi.returnTokenData = function() return getMythosArea().call("returnTokenData") end ---@return any: Object reference to the encounter deck MythosAreaApi.getEncounterDeck = function() return getMythosArea().call("getEncounterDeck") end -- draw an encounter card for the requesting mat to the first empty spot from the right ---@param matColor string Playermat that triggered this ---@param position tts__Vector Position for the encounter card MythosAreaApi.drawEncounterCard = function(matColor, position) getMythosArea().call("drawEncounterCard", { matColor = matColor, position = position }) end -- reshuffle the encounter deck MythosAreaApi.reshuffleEncounterDeck = function() getMythosArea().call("reshuffleEncounterDeck") end return MythosAreaApi end end) __bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getPlayArea() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "PlayArea") end local function getInvestigatorCounter() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "InvestigatorCounter") end -- Returns the current value of the investigator counter from the playmat ---@return number: Number of investigators currently set on the counter PlayAreaApi.getInvestigatorCount = function() return getInvestigatorCounter().getVar("val") end -- Updates the current value of the investigator counter from the playmat ---@param count number Number of investigators to set on the counter PlayAreaApi.setInvestigatorCount = function(count) getInvestigatorCounter().call("updateVal", count) end -- Move all contents on the play area (cards, tokens, etc) one slot in the given direction. Certain -- fixed objects will be ignored, as will anything the player has tagged with 'displacement_excluded' ---@param playerColor string Color of the player requesting the shift for messages PlayAreaApi.shiftContentsUp = function(playerColor) getPlayArea().call("shiftContentsUp", playerColor) end PlayAreaApi.shiftContentsDown = function(playerColor) getPlayArea().call("shiftContentsDown", playerColor) end PlayAreaApi.shiftContentsLeft = function(playerColor) getPlayArea().call("shiftContentsLeft", playerColor) end PlayAreaApi.shiftContentsRight = function(playerColor) getPlayArea().call("shiftContentsRight", playerColor) end ---@param state boolean This controls whether location connections should be drawn PlayAreaApi.setConnectionDrawState = function(state) getPlayArea().call("setConnectionDrawState", state) end ---@param color string Connection color to be used for location connections PlayAreaApi.setConnectionColor = function(color) getPlayArea().call("setConnectionColor", color) end -- Event to be called when the current scenario has changed ---@param scenarioName string Name of the new scenario PlayAreaApi.onScenarioChanged = function(scenarioName) getPlayArea().call("onScenarioChanged", scenarioName) end -- Sets this playmat's snap points to limit snapping to locations or not. -- If matchTypes is false, snap points will be reset to snap all cards. ---@param matchCardTypes boolean Whether snap points should only snap for the matching card types PlayAreaApi.setLimitSnapsByType = function(matchCardTypes) getPlayArea().call("setLimitSnapsByType", matchCardTypes) end -- Receiver for the Global tryObjectEnterContainer event. Used to clear vector lines from dragged -- cards before they're destroyed by entering the container PlayAreaApi.tryObjectEnterContainer = function(container, object) getPlayArea().call("tryObjectEnterContainer", { container = container, object = object }) end -- Counts the VP on locations in the play area PlayAreaApi.countVP = function() return getPlayArea().call("countVP") end -- Highlights all locations in the play area without metadata ---@param state boolean True if highlighting should be enabled PlayAreaApi.highlightMissingData = function(state) return getPlayArea().call("highlightMissingData", state) end -- Highlights all locations in the play area with VP ---@param state boolean True if highlighting should be enabled PlayAreaApi.highlightCountedVP = function(state) return getPlayArea().call("countVP", state) end -- Checks if an object is in the play area (returns true or false) PlayAreaApi.isInPlayArea = function(object) return getPlayArea().call("isInPlayArea", object) end -- Returns the current surface of the play area PlayAreaApi.getSurface = function() return getPlayArea().getCustomObject().image end -- Updates the surface of the play area PlayAreaApi.updateSurface = function(url) return getPlayArea().call("updateSurface", url) end -- Returns a deep copy of the currently tracked locations PlayAreaApi.getTrackedLocations = function() local t = {} for k, v in pairs(getPlayArea().call("getTrackedLocations")) do t[k] = v end return t end -- Called by Custom Data Helpers to push their location data into the Data Helper. This adds the -- data to the local token manager instance. ---@param args table Single-value array holding the GUID of the Custom Data Helper making the call PlayAreaApi.updateLocations = function(args) getPlayArea().call("updateLocations", args) end PlayAreaApi.getCustomDataHelper = function() return getPlayArea().getVar("customDataHelper") end return PlayAreaApi end end) __bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenSpawnTracker = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getSpawnTracker() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSpawnTracker") end TokenSpawnTracker.hasSpawnedTokens = function(cardGuid) return getSpawnTracker().call("hasSpawnedTokens", cardGuid) end TokenSpawnTracker.markTokensSpawned = function(cardGuid) return getSpawnTracker().call("markTokensSpawned", cardGuid) end TokenSpawnTracker.resetTokensSpawned = function(cardGuid) return getSpawnTracker().call("resetTokensSpawned", cardGuid) end TokenSpawnTracker.resetAllAssetAndEvents = function() return getSpawnTracker().call("resetAllAssetAndEvents") end TokenSpawnTracker.resetAllLocations = function() return getSpawnTracker().call("resetAllLocations") end TokenSpawnTracker.resetAll = function() return getSpawnTracker().call("resetAll") end return TokenSpawnTracker end end) __bundle_register("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/NavigationOverlayApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local NavigationOverlayApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getNOHandler() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "NavigationOverlayHandler") end -- copies the visibility for the Navigation overlay ---@param startColor string Color of the player to copy from ---@param targetColor string Color of the targeted player NavigationOverlayApi.copyVisibility = function(startColor, targetColor) getNOHandler().call("copyVisibility", { startColor = startColor, targetColor = targetColor }) end -- changes the Navigation Overlay view ("Full View" --> "Play Areas" --> "Closed" etc.) ---@param playerColor string Color of the player to update the visibility for NavigationOverlayApi.cycleVisibility = function(playerColor) getNOHandler().call("cycleVisibility", playerColor) end -- loads the specified camera for a player ---@param player tts__Player Player whose camera should be moved ---@param camera number|string If number: Index of the camera view to load | If string: Color of the playermat to swap to NavigationOverlayApi.loadCamera = function(player, camera) getNOHandler().call("loadCameraFromApi", { player = player, camera = camera }) end return NavigationOverlayApi end end) __bundle_register("core/token/TokenChecker", function(require, _LOADED, __bundle_register, __bundle_modules) do local CHAOS_TOKEN_NAMES = { ["Elder Sign"] = true, ["+1"] = true, ["0"] = true, ["-1"] = true, ["-2"] = true, ["-3"] = true, ["-4"] = true, ["-5"] = true, ["-6"] = true, ["-7"] = true, ["-8"] = true, ["Skull"] = true, ["Cultist"] = true, ["Tablet"] = true, ["Elder Thing"] = true, ["Auto-fail"] = true, ["Bless"] = true, ["Curse"] = true, ["Frost"] = true } local TokenChecker = {} -- returns true if the passed object is a chaos token (by name) TokenChecker.isChaosToken = function(obj) if obj.type == "Tile" and CHAOS_TOKEN_NAMES[obj.getName()] then return true else return false end end return TokenChecker end end) __bundle_register("core/token/TokenManager", function(require, _LOADED, __bundle_register, __bundle_modules) do local guidReferenceApi = require("core/GUIDReferenceApi") local optionPanelApi = require("core/OptionPanelApi") local playAreaApi = require("core/PlayAreaApi") local searchLib = require("util/SearchLib") local tokenSpawnTrackerApi = require("core/token/TokenSpawnTrackerApi") local PLAYER_CARD_TOKEN_OFFSETS = { [1] = { Vector(0, 3, -0.2) }, [2] = { Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [3] = { Vector(0, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [4] = { Vector(0.4, 3, -0.9), Vector(-0.4, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [5] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.4, 3, -0.2), Vector(-0.4, 3, -0.2) }, [6] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2) }, [7] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0, 3, 0.5) }, [8] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(-0.35, 3, 0.5), Vector(0.35, 3, 0.5) }, [9] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5) }, [10] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0, 3, 1.2) }, [11] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(-0.35, 3, 1.2), Vector(0.35, 3, 1.2) }, [12] = { Vector(0.7, 3, -0.9), Vector(0, 3, -0.9), Vector(-0.7, 3, -0.9), Vector(0.7, 3, -0.2), Vector(0, 3, -0.2), Vector(-0.7, 3, -0.2), Vector(0.7, 3, 0.5), Vector(0, 3, 0.5), Vector(-0.7, 3, 0.5), Vector(0.7, 3, 1.2), Vector(0, 3, 1.2), Vector(-0.7, 3, 1.2) } } -- stateIDs for the multi-stated resource tokens local stateTable = { ["resource"] = 1, ["ammo"] = 2, ["bounty"] = 3, ["charge"] = 4, ["evidence"] = 5, ["secret"] = 6, ["supply"] = 7, ["offering"] = 8 } -- Table of data extracted from the token source bag, keyed by the Memo on each token which -- should match the token type keys ("resource", "clue", etc) local tokenTemplates local playerCardData local locationData local TokenManager = {} local internal = {} -- Spawns tokens for the card. This function is built to just throw a card at it and let it do -- the work once a card has hit an area where it might spawn tokens. It will check to see if -- the card has already spawned, find appropriate data from either the uses metadata or the Data -- Helper, and spawn the tokens. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playmat should pass "Charge"=1 TokenManager.spawnForCard = function(card, extraUses) if tokenSpawnTrackerApi.hasSpawnedTokens(card.getGUID()) then return end local metadata = JSON.decode(card.getGMNotes()) if metadata ~= nil then internal.spawnTokensFromUses(card, extraUses) else internal.spawnTokensFromDataHelper(card) end end -- Spawns a set of tokens on the given card. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string Type of token to spawn, for example "damage", "horror" or "resource" ---@param tokenCount number How many tokens to spawn. For damage or horror this value will be set to the -- spawned state object rather than spawning multiple tokens ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown, subType) local optionPanel = optionPanelApi.getOptions() if tokenType == "damage" or tokenType == "horror" then TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "enabled" then TokenManager.spawnResourceCounterToken(card, tokenCount) elseif tokenType == "resource" and optionPanel["useResourceCounters"] == "custom" and tokenCount == 0 then TokenManager.spawnResourceCounterToken(card, tokenCount) else TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown, subType) end end -- Spawns a single counter token and sets the value to tokenValue. Used for damage and horror tokens. ---@param card tts__Object Card to spawn tokens on ---@param tokenType string type of token to spawn, valid values are "damage" and "horror". Other -- types should use spawnMultipleTokens() ---@param tokenValue number Value to set the damage/horror to TokenManager.spawnCounterToken = function(card, tokenType, tokenValue, shiftDown) if tokenValue < 1 or tokenValue > 50 then return end local pos = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[1][1] + Vector(0, 0, shiftDown)) local rot = card.getRotation() TokenManager.spawnToken(pos, tokenType, rot, function(spawned) -- token starts in state 1, so don't attempt to change it to avoid error if tokenValue ~= 1 then spawned.setState(tokenValue) end end) end TokenManager.spawnResourceCounterToken = function(card, tokenCount) local pos = card.positionToWorld(card.positionToLocal(card.getPosition()) + Vector(0, 0.2, -0.5)) local rot = card.getRotation() TokenManager.spawnToken(pos, "resourceCounter", rot, function(spawned) spawned.call("updateVal", tokenCount) end) end -- Spawns a number of tokens. ---@param tokenType string type of token to spawn, valid values are resource", "doom", or "clue". -- Other types should use spawnCounterToken() ---@param tokenCount number How many tokens to spawn ---@param shiftDown? number An offset for the z-value of this group of tokens ---@param subType? string Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown, subType) -- not checking the max at this point since clue offsets are calculated dynamically if tokenCount < 1 then return end local offsets = {} if tokenType == "clue" then offsets = internal.buildClueOffsets(card, tokenCount) else -- only up to 12 offset tables defined if tokenCount > 12 then return end for i = 1, tokenCount do offsets[i] = card.positionToWorld(PLAYER_CARD_TOKEN_OFFSETS[tokenCount][i]) -- Fix the y-position for the spawn, since positionToWorld considers rotation which can -- have bad results for face up/down differences offsets[i].y = card.getPosition().y + 0.15 end end if shiftDown ~= nil then -- Copy the offsets to make sure we don't change the static values local baseOffsets = offsets offsets = {} -- get a vector for the shifting (downwards local to the card) local shiftDownVector = Vector(0, 0, shiftDown):rotateOver("y", card.getRotation().y) for i, baseOffset in ipairs(baseOffsets) do offsets[i] = baseOffset + shiftDownVector end end if offsets == nil then error("couldn't find offsets for " .. tokenCount .. ' tokens') return end -- handling for not provided subtype (for example when spawning from custom data helpers) if subType == nil then subType = "" end -- this is used to load the correct state for additional resource tokens (e.g. "Ammo") local callback = nil local stateID = stateTable[string.lower(subType)] if tokenType == "resource" and stateID ~= nil and stateID ~= 1 then callback = function(spawned) spawned.setState(stateID) end end for i = 1, tokenCount do TokenManager.spawnToken(offsets[i], tokenType, card.getRotation(), callback) end end -- Spawns a single token at the given global position by copying it from the template bag. ---@param position tts__Vector Global position to spawn the token ---@param tokenType string type of token to spawn, valid values are "damage", "horror", -- "resource", "doom", or "clue" ---@param rotation tts__Vector Rotation to be used for the new token. Only the y-value will be used, -- x and z will use the default rotation from the source bag ---@param callback? function A callback function triggered after the new token is spawned TokenManager.spawnToken = function(position, tokenType, rotation, callback) internal.initTokenTemplates() local loadTokenType = tokenType if tokenType == "clue" or tokenType == "doom" then loadTokenType = "clueDoom" end if tokenTemplates[loadTokenType] == nil then error("Unknown token type '" .. tokenType .. "'") return end local tokenTemplate = tokenTemplates[loadTokenType] -- Take ONLY the Y-value for rotation, so we don't flip the token coming out of the bag local rot = Vector(tokenTemplate.Transform.rotX, 270, tokenTemplate.Transform.rotZ) if rotation ~= nil then rot.y = rotation.y end if tokenType == "doom" then rot.z = 180 end tokenTemplate.Nickname = "" return spawnObjectData({ data = tokenTemplate, position = position, rotation = rot, callback_function = callback }) end -- Checks a card for metadata to maybe replenish it ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) ---@param mat tts__Object The playmat the card is placed on (for rotation and casting) TokenManager.maybeReplenishCard = function(card, uses, mat) -- TODO: support for cards with multiple uses AND replenish (as of yet, no official card needs that) if uses[1].count and uses[1].replenish then internal.replenishTokens(card, uses, mat) end end -- Delegate function to the token spawn tracker. Exists to avoid circular dependencies in some -- callers. ---@param card tts__Object Card object to reset the tokens for TokenManager.resetTokensSpawned = function(card) tokenSpawnTrackerApi.resetTokensSpawned(card.getGUID()) end -- Pushes new player card data into the local copy of the Data Helper player data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addPlayerCardData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do playerCardData[k] = v end end -- Pushes new location data into the local copy of the Data Helper location data. ---@param dataTable table Key/Value pairs following the DataHelper style TokenManager.addLocationData = function(dataTable) internal.initDataHelperData() for k, v in pairs(dataTable) do locationData[k] = v end end -- Checks to see if the given card has location data in the DataHelper ---@param card tts__Object Card to check for data ---@return boolean: True if this card has data in the helper, false otherwise TokenManager.hasLocationData = function(card) internal.initDataHelperData() return internal.getLocationData(card) ~= nil end internal.initTokenTemplates = function() if tokenTemplates ~= nil then return end tokenTemplates = {} local tokenSource = guidReferenceApi.getObjectByOwnerAndType("Mythos", "TokenSource") for _, tokenTemplate in ipairs(tokenSource.getData().ContainedObjects) do local tokenName = tokenTemplate.Memo tokenTemplates[tokenName] = tokenTemplate end end -- Copies the data from the DataHelper. Will only happen once. internal.initDataHelperData = function() if playerCardData ~= nil then return end local dataHelper = guidReferenceApi.getObjectByOwnerAndType("Mythos", "DataHelper") playerCardData = dataHelper.getTable('PLAYER_CARD_DATA') locationData = dataHelper.getTable('LOCATIONS_DATA') end -- Spawn tokens for a card based on the uses metadata. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for ---@param extraUses table A table of = which will modify the number of tokens --- spawned for that type. e.g. Akachi's playmat should pass "Charge"=1 internal.spawnTokensFromUses = function(card, extraUses) local uses = internal.getUses(card) if uses == nil then return end -- go through tokens to spawn local tokenCount for i, useInfo in ipairs(uses) do tokenCount = (useInfo.count or 0) + (useInfo.countPerInvestigator or 0) * playAreaApi.getInvestigatorCount() if extraUses ~= nil and extraUses[useInfo.type] ~= nil then tokenCount = tokenCount + extraUses[useInfo.type] end -- Shift each spawned group after the first down so they don't pile on each other TokenManager.spawnTokenGroup(card, useInfo.token, tokenCount, (i - 1) * 0.8, useInfo.type) end tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a card based on the data helper data. This will consider the face up/down state -- of the card for both locations and standard cards. ---@param card tts__Object Card to maybe spawn tokens for internal.spawnTokensFromDataHelper = function(card) internal.initDataHelperData() local playerData = internal.getPlayerCardData(card) if playerData ~= nil then internal.spawnPlayerCardTokensFromDataHelper(card, playerData) end local locationData = internal.getLocationData(card) if locationData ~= nil then internal.spawnLocationTokensFromDataHelper(card, locationData) end end -- Spawn tokens for a player card using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param playerData table Player card data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnPlayerCardTokensFromDataHelper = function(card, playerData) local token = playerData.tokenType local tokenCount = playerData.tokenCount TokenManager.spawnTokenGroup(card, token, tokenCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a location using data retrieved from the Data Helper. ---@param card tts__Object Card to maybe spawn tokens for ---@param locationData table Location data structure retrieved from the DataHelper. Should be -- the right data for this card. internal.spawnLocationTokensFromDataHelper = function(card, locationData) local clueCount = internal.getClueCountFromData(card, locationData) if clueCount > 0 then TokenManager.spawnTokenGroup(card, "clue", clueCount) tokenSpawnTrackerApi.markTokensSpawned(card.getGUID()) end end internal.getPlayerCardData = function(card) return playerCardData[card.getName() .. ':' .. card.getDescription()] or playerCardData[card.getName()] end internal.getLocationData = function(card) return locationData[card.getName() .. '_' .. card.getGUID()] or locationData[card.getName()] end internal.getClueCountFromData = function(card, locationData) -- Return the number of clues to spawn on this location if locationData == nil then error('attempted to get clue for unexpected object: ' .. card.getName()) return 0 end if ((card.is_face_down and locationData.clueSide == 'back') or (not card.is_face_down and locationData.clueSide == 'front')) then if locationData.type == 'fixed' then return locationData.value elseif locationData.type == 'perPlayer' then return locationData.value * playAreaApi.getInvestigatorCount() end error('unexpected location type: ' .. locationData.type) end return 0 end -- Gets the right uses structure for this card, based on metadata and face up/down state ---@param card tts__Object Card to pull the uses from internal.getUses = function(card) local metadata = JSON.decode(card.getGMNotes()) or {} if metadata.type == "Location" then if card.is_face_down and metadata.locationBack ~= nil then return metadata.locationBack.uses elseif not card.is_face_down and metadata.locationFront ~= nil then return metadata.locationFront.uses end elseif not card.is_face_down then return metadata.uses end return nil end -- Dynamically create positions for clues on a card. ---@param card tts__Object Card the clues will be placed on ---@param count number How many clues? ---@return table: Array of global positions to spawn the clues at internal.buildClueOffsets = function(card, count) local cluePositions = {} for i = 1, count do local row = math.floor(1 + (i - 1) / 4) local column = (i - 1) % 4 local cluePos = card.positionToWorld(Vector(-0.825 + 0.55 * column, 0, -1.5 + 0.55 * row)) cluePos.y = cluePos.y + 0.05 table.insert(cluePositions, cluePos) end return cluePositions end ---@param card tts__Object Card object to be replenished ---@param uses table The already decoded metadata.uses (to avoid decoding again) ---@param mat tts__Object The playmat the card is placed on (for rotation and casting) internal.replenishTokens = function(card, uses, mat) local cardPos = card.getPosition() -- don't continue for cards on the deck (Norman) or in the discard pile if mat.positionToLocal(cardPos).x < -1 then return end -- get current amount of resource tokens on the card local clickableResourceCounter = nil local foundTokens = 0 for _, obj in ipairs(searchLib.onObject(card, "isTileOrToken")) do local memo = obj.getMemo() if (stateTable[memo] or 0) > 0 then foundTokens = foundTokens + math.abs(obj.getQuantity()) obj.destruct() elseif memo == "resourceCounter" then foundTokens = obj.getVar("val") clickableResourceCounter = obj break end end -- this is the theoretical new amount of uses (to be checked below) local newCount = foundTokens + uses[1].replenish -- if there are already more uses than the replenish amount, keep them if foundTokens > uses[1].count then newCount = foundTokens -- only replenish up until the replenish amount elseif newCount > uses[1].count then newCount = uses[1].count end -- update the clickable counter or spawn a group of tokens if clickableResourceCounter then clickableResourceCounter.call("updateVal", newCount) else TokenManager.spawnTokenGroup(card, uses[1].token, newCount, _, uses[1].type) end end return TokenManager end end) __bundle_register("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 ---@param obj tts__Object Object 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(obj, pos, rot, below) if obj == nil or pos == nil then return end -- search the new position for existing card/deck local searchResult = searchLib.atPosition(pos, "isCardOrDeck") -- get new position local offset = 0.5 local newPos = Vector(pos) + Vector(0, offset, 0) if #searchResult == 1 then local bounds = searchResult[1].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 -- allow moving the objects smoothly out of the hand obj.use_hands = false if rot then obj.setRotationSmooth(rot, false, true) end obj.setPositionSmooth(newPos, false, true) -- continue if the card stops smooth moving Wait.condition( function() obj.use_hands = true -- this avoids a TTS bug that merges unrelated cards that are not resting if #searchResult == 1 and searchResult[1] ~= obj then -- call this with avoiding errors (physics is sometimes too fast so the object doesn't exist for the put) pcall(function() searchResult[1].putObject(obj) end) end end, function() return not obj.isSmoothMoving() end, 3) end return DeckLib end end) __bundle_register("util/SearchLib", function(require, _LOADED, __bundle_register, __bundle_modules) do local SearchLib = {} local filterFunctions = { isActionToken = function(x) return x.getDescription() == "Action Token" end, isCard = function(x) return x.type == "Card" end, isDeck = function(x) return x.type == "Deck" end, isCardOrDeck = function(x) return x.type == "Card" or x.type == "Deck" end, isClue = function(x) return x.memo == "clueDoom" and x.is_face_down == false end, isTileOrToken = function(x) return x.type == "Tile" end } -- performs the actual search and returns a filtered list of object references ---@param pos tts__Vector Global position ---@param rot? tts__Vector Global rotation ---@param size table Size ---@param filter? string Name of the filter function ---@param direction? table Direction (positive is up) ---@param maxDistance? number Distance for the cast local function returnSearchResult(pos, rot, size, filter, direction, maxDistance) local filterFunc if filter then filterFunc = filterFunctions[filter] end local searchResult = Physics.cast({ origin = pos, direction = direction or { 0, 1, 0 }, orientation = rot or { 0, 0, 0 }, type = 3, size = size, max_distance = maxDistance or 0 }) -- filtering the result local objList = {} for _, v in ipairs(searchResult) do if not filter or filterFunc(v.hit_object) then table.insert(objList, v.hit_object) end end return objList end -- searches the specified area SearchLib.inArea = function(pos, rot, size, filter) return returnSearchResult(pos, rot, size, filter) end -- searches the area on an object SearchLib.onObject = function(obj, filter) pos = obj.getPosition() size = obj.getBounds().size:setAt("y", 1) return returnSearchResult(pos, _, size, filter) end -- searches the specified position (a single point) SearchLib.atPosition = function(pos, filter) size = { 0.1, 2, 0.1 } return returnSearchResult(pos, _, size, filter) end -- searches below the specified position (downwards until y = 0) SearchLib.belowPosition = function(pos, filter) direction = { 0, -1, 0 } maxDistance = pos.y return returnSearchResult(pos, _, size, filter, direction, maxDistance) end return SearchLib end end) __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) return __bundle_require("__root")