-- Bundled by luabundle {"version":"1.6.0"} local __bundle_require, __bundle_loaded, __bundle_register, __bundle_modules = (function(superRequire) local loadingPlaceholder = {[{}] = true} local register local modules = {} local require local loaded = {} register = function(name, body) if not modules[name] then modules[name] = body end end require = function(name) local loadedModule = loaded[name] if loadedModule then if loadedModule == loadingPlaceholder then return nil end else if not modules[name] then if not superRequire then local identifier = type(name) == 'string' and '\"' .. name .. '\"' or tostring(name) error('Tried to require ' .. identifier .. ', but no such module has been registered') else return superRequire(name) end end loaded[name] = loadingPlaceholder loadedModule = modules[name](require, loaded, register, modules) loaded[name] = loadedModule end return loadedModule end return require, loaded, register, modules end)(nil) __bundle_register("core/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("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 (optional) DeckLib.placeOrMergeIntoDeck = function(obj, pos, rot) 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 newPos local offset = 0.5 if #searchResult == 1 then local bounds = searchResult[1].getBounds() newPos = Vector(pos):setAt("y", bounds.center.y + bounds.size.y / 2 + offset) else newPos = Vector(pos) + Vector(0, offset, 0) 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("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("__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 -- x-Values for discard buttons local DISCARD_BUTTON_OFFSETS = {-1.365, -0.91, -0.455, 0, 0.455, 0.91} 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 position of draw and discard pile 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 } -- global position of encounter discard pile local ENCOUNTER_DISCARD_POSITION = { x = -3.85, y = 1.5, z = 10.38} -- global variable so it can be reset by the Clean Up Helper activeInvestigatorId = "00000" -- table of type-object reference pairs of all owned objects local ownedObjects = {} local matColor = self.getMemo() -- variable to track the status of the "Show Draw Button" option local isDrawButtonVisible = false -- global variable to report "Dream-Enhancing Serum" status isDES = false function onSave() return JSON.encode({ playerColor = playerColor, activeInvestigatorId = activeInvestigatorId, isDrawButtonVisible = isDrawButtonVisible }) end function onLoad(saveState) self.interactable = false -- get object references to owned objects ownedObjects = guidReferenceApi.getObjectsByOwner(matColor) -- button creation for i = 1, 6 do makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], 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 }) self.createButton({ label = "Upkeep", click_function = "doUpkeep", function_owner = self, position = {1.84, 0.1, -0.44}, scale = {0.12, 0.12, 0.12}, width = 800, height = 280, font_size = 180 }) -- save state loading local state = JSON.decode(saveState) if state ~= nil then playerColor = state.playerColor activeInvestigatorId = state.activeInvestigatorId isDrawButtonVisible = state.isDrawButtonVisible end showDrawButton(isDrawButtonVisible) 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 function doNotReady(card) return card.getVar("do_not_ready") or false end -- rounds a number to the specified amount of decimal places ---@param num number Initial value ---@param numDecimalPlaces number Amount of decimal places function round(num, numDecimalPlaces) local mult = 10^(numDecimalPlaces or 0) return math.floor(num * mult + 0.5) / mult 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.) elseif not obj.getLock() then ownedObjects.Trash.putObject(obj) end end end -- build a discard button to discard from searchPosition (number must be unique) function makeDiscardButton(xValue, number) local position = { xValue, 0.1, -0.94} local searchPosition = {-position[1], position[2], position[3] + 0.32} local handlerName = 'handler' .. number 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[1], position[2], position[3] + 0.6}, 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(color) doUpkeep(_, color) end function doUpkeep(_, clickedByColor, isRightClick) if isRightClick then changeColor(clickedByColor) return end -- send messages to player who clicked button if no seated player found messageColor = Player[playerColor].seated and playerColor or 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 doNotReady(obj) 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 if cardMetadata.id == "08031" then forcedLearning = true end if cardMetadata.uses ~= nil then tokenManager.maybeReplenishCard(obj, cardMetadata.uses, self) 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 -- function for "draw 1 button" (that can be added via option panel) function doDrawOne(_, color) -- send messages to player who clicked button if no seated player found messageColor = Player[playerColor].seated and playerColor or color drawCardsWithReshuffle(1) end -- draw X cards (shuffle discards if necessary) 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 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 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 broadcastToAll("Cannot discard from empty hand!", "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 broadcastToAll("Hidden cards can't be randomly discarded.", "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 --------------------------------------------------------- -- color related functions --------------------------------------------------------- -- changes the player color function changeColor(clickedByColor) local colorList = { "White", "Brown", "Red", "Orange", "Yellow", "Green", "Teal", "Blue", "Purple", "Pink" } -- 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 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 -- Allowed use of call(), no APIs for individual cards 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 --------------------------------------------------------- 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 specific 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 -- returns the resource counter amount ---@param type string Counter to target 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) mythosAreaApi.drawEncounterCard(self, isRightClick) end function returnGlobalDiscardPosition() return self.positionToWorld(DISCARD_PILE_POSITION) end -- Sets this playermat's draw 1 button to visible ---@param visible boolean Whether the draw 1 button should be visible function showDrawButton(visible) isDrawButtonVisible = visible -- create the "Draw 1" button if isDrawButtonVisible then self.createButton({ label = "Draw 1", click_function = "doDrawOne", function_owner = self, position = { 1.84, 0.1, -0.36 }, scale = { 0.12, 0.12, 0.12 }, width = 800, height = 280, font_size = 180 }) -- remove the "Draw 1" button 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 -- 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 -- 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 local snapPos = snap.position if inArea(snapPos, 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(snapPos, 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 ChaosBagApi.drawChaosToken = function(mat, drawAdditional, tokenType, guidToBeResolved) return Global.call("drawChaosToken", {mat = mat, drawAdditional = drawAdditional, tokenType = tokenType, guidToBeResolved = guidToBeResolved}) 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/GUIDReferenceApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local GUIDReferenceApi = {} local function getGuidHandler() return getObjectFromGUID("123456") end ---@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 return GUIDReferenceApi 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 ---@param mat tts__Object Playermat that triggered this ---@param alwaysFaceUp boolean Whether the card should be drawn face-up MythosAreaApi.drawEncounterCard = function(mat, alwaysFaceUp) getMythosArea().call("drawEncounterCard", {mat = mat, alwaysFaceUp = alwaysFaceUp}) end return MythosAreaApi end end) __bundle_register("core/NavigationOverlayApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local NavigationOverlayApi = {} local guidReferenceApi = require("core/GUIDReferenceApi") local function getNOHandler() return guidReferenceApi.getObjectByOwnerAndType("Mythos", "NavigationOverlayHandler") end -- copies the visibility for the Navigation overlay ---@param startColor string Color of the player to copy from ---@param targetColor string Color of the targeted player NavigationOverlayApi.copyVisibility = function(startColor, targetColor) getNOHandler().call("copyVisibility", { startColor = startColor, targetColor = targetColor }) end -- changes the Navigation Overlay view ("Full View" --> "Play Areas" --> "Closed" etc.) ---@param playerColor string Color of the player to update the visibility for NavigationOverlayApi.cycleVisibility = function(playerColor) getNOHandler().call("cycleVisibility", playerColor) end -- loads the specified camera for a player ---@param player tts__Player Player whose camera should be moved ---@param camera number|string If number: Index of the camera view to load | If string: Color of the playermat to swap to NavigationOverlayApi.loadCamera = function(player, camera) getNOHandler().call("loadCameraFromApi", { player = player, camera = camera }) end return NavigationOverlayApi end end) __bundle_register("core/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) spawned.setState(tokenValue) 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 pos = card.getPosition() local cluePositions = { } for i = 1, count do local row = math.floor(1 + (i - 1) / 4) local column = (i - 1) % 4 table.insert(cluePositions, Vector(pos.x + 1.5 - 0.55 * row, pos.y + 0.15, pos.z - 0.825 + 0.55 * column)) 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/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/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 PlayAreaApi.getSurface = function() return getPlayArea().getCustomObject().image end PlayAreaApi.updateSurface = function(url) return getPlayArea().call("updateSurface", url) 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) return __bundle_require("__root")