-- 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("playermat/Playmat", function(require, _LOADED, __bundle_register, __bundle_modules) local tokenManager = require("core/token/TokenManager") local tokenChecker = require("core/token/TokenChecker") -- set true to enable debug logging and show Physics.cast() local DEBUG = false -- we use this to turn off collision handling until onLoad() is complete local collisionEnabled = false -- position offsets relative to mat [x, y, z] local DRAWN_ENCOUNTER_CARD_OFFSET = {1.365, 0.5, -0.625} local DRAWN_CHAOS_TOKEN_OFFSET = {-1.55, 0.25, -0.58} -- x-Values for discard buttons local DISCARD_BUTTON_OFFSETS = {-1.365, -0.91, -0.455, 0, 0.455, 0.91} -- defined areas for the function "inArea()"" 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 DRAW_DECK_POSITION = { x = -1.82, y = 1, z = 0 } local DISCARD_PILE_POSITION = { x = -1.82, y = 1.5, z = 0.61 } local TRASHCAN local STAT_TRACKER local RESOURCE_COUNTER -- 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 function onSave() return JSON.encode({ zoneID = zoneID, playerColor = playerColor, activeInvestigatorId = activeInvestigatorId, isDrawButtonVisible = isDrawButtonVisible }) end function onLoad(save_state) self.interactable = DEBUG TRASHCAN = getObjectFromGUID(TRASHCAN_GUID) STAT_TRACKER = getObjectFromGUID(STAT_TRACKER_GUID) RESOURCE_COUNTER = getObjectFromGUID(RESOURCE_COUNTER_GUID) -- button creation for i = 1, 6 do makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], {-3.85, 3, 10.38}, 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(save_state) if state ~= nil then zoneID = state.zoneID playerColor = state.playerColor activeInvestigatorId = state.activeInvestigatorId isDrawButtonVisible = state.isDrawButtonVisible end showDrawButton(isDrawButtonVisible) if getObjectFromGUID(zoneID) == nil then spawnDeckZone() end collisionEnabled = true math.randomseed(os.time()) end --------------------------------------------------------- -- utility functions --------------------------------------------------------- function spawnDeckZone() spawnObject({ position = self.positionToWorld({-1.4, 0, 0.3 }), scale = {3, 5, 8 }, type = 'ScriptingTrigger', callback = function (zone) zoneID = zone.getGUID() end, callback_owner = self, rotation = self.getRotation() }) end function searchArea(origin, size) return Physics.cast({ origin = origin, direction = {0, 1, 0}, orientation = self.getRotation(), type = 3, size = size, max_distance = 1, debug = DEBUG }) end function doNotReady(card) return card.getVar("do_not_ready") or false end --------------------------------------------------------- -- Discard buttons --------------------------------------------------------- -- builds a function that discards things in searchPosition to discardPosition -- stuff on the card/deck will be put into the local trashcan function makeDiscardHandlerFor(searchPosition, discardPosition) return function () for _, hitObj in ipairs(findObjectsAtPosition(searchPosition)) do local obj = hitObj.hit_object if obj.tag == "Deck" or obj.tag == "Card" then if obj.hasTag("PlayerCard") then obj.setPositionSmooth(self.positionToWorld(DISCARD_PILE_POSITION), false, true) obj.setRotation(self.getRotation()) else obj.setPositionSmooth(discardPosition, false, true) obj.setRotation({0, -90, 0}) end -- put chaos tokens back into bag (e.g. Unrelenting) elseif tokenChecker.isChaosToken(obj) then local chaosBag = Global.call("findChaosBag") chaosBag.putObject(obj) -- don't touch the table or this playmat itself elseif obj.guid ~= "4ee1f2" and obj ~= self then TRASHCAN.putObject(obj) end end end end -- build a discard button to discard from searchPosition to discardPosition (number must be unique) function makeDiscardButton(xValue, discardPosition, number) local position = { xValue, 0.1, -0.94} local searchPosition = {-position[1], position[2], position[3] + 0.32} local handler = makeDiscardHandlerFor(searchPosition, discardPosition) local handlerName = 'handler' .. number self.setVar(handlerName, handler) 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 function findObjectsAtPosition(localPos) return Physics.cast({ origin = self.positionToWorld(localPos), direction = {0, 1, 0}, orientation = {0, self.getRotation().y + 90, 0}, type = 3, size = {3.2, 1, 2}, max_distance = 0, debug = DEBUG }) end --------------------------------------------------------- -- Upkeep button --------------------------------------------------------- -- calls the Upkeep function with correct parameter function doUpkeepFromHotkey(color) doUpkeep(_, color) end function doUpkeep(_, clickedByColor, isRightClick) -- right-click allow color changing 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 for _, v in ipairs(searchArea(PLAY_ZONE_POSITION, PLAY_ZONE_SCALE)) do local obj = v.hit_object if obj.getDescription() == "Action Token" and obj.is_face_down then obj.flip() elseif obj.tag == "Card" and not obj.is_face_down and not inArea(self.positionToLocal(obj.getPosition()), INVESTIGATOR_AREA) then local cardMetadata = JSON.decode(obj.getGMNotes()) or {} if not doNotReady(obj) then obj.setRotation(self.getRotation()) end if cardMetadata.id == "08031" then forcedLearning = true end if cardMetadata.uses ~= nil then -- check for cards with 'replenish' in their metadata local count local replenish -- Uses structure underwent a breaking change in 2.4.0, have to check to see if this is -- a single entry or an array. TODO: Clean this up when 2.4.0 has been out long -- enough that saved decks don't have old data if cardMetadata.uses.count ~= nil then count = cardMetadata.uses.count replenish = cardMetadata.uses.replenish else count = cardMetadata.uses[1].count replenish = cardMetadata.uses[1].replenish end if count and replenish then replenishTokens(obj, count, replenish) 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) if activeInvestigatorId ~= nil then local miniId = string.match(activeInvestigatorId, ".....") .. "-m" for _, obj in ipairs(getObjects()) do if obj.tag == "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 end -- gain a resource (or two if playing Jenny Barnes) if string.match(activeInvestigatorId, "%d%d%d%d%d") == "02003" then gainResources(2) printToColor("Gaining 2 resources (Jenny)", messageColor) else gainResources(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) else drawCardsWithReshuffle(1) end end -- adds the specified amount of resources to the resource counter function gainResources(amount) local count = RESOURCE_COUNTER.getVar("val") local add = tonumber(amount) or 0 RESOURCE_COUNTER.call("updateVal", count + add) 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) getDrawDiscardDecks() -- Norman Withers handling if string.match(activeInvestigatorId, "%d%d%d%d%d") == "08004" then local harbinger = false if topCard ~= nil and topCard.getName() == "The Harbinger" then harbinger = true elseif drawDeck ~= nil and not drawDeck.is_face_down then local cards = drawDeck.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 if topCard ~= nil then topCard.deal(numCards, playerColor) numCards = numCards - 1 if numCards == 0 then return end end end local deckSize = 1 if drawDeck == nil then deckSize = 0 elseif drawDeck.tag == "Deck" then deckSize = #drawDeck.getObjects() end if deckSize >= numCards then drawCards(numCards) return end drawCards(deckSize) if discardPile ~= nil then shuffleDiscardIntoDeck() Wait.time(|| drawCards(numCards - deckSize), 1) end printToColor("Take 1 horror (drawing card from empty deck)", messageColor) end -- get the draw deck and discard pile objects function getDrawDiscardDecks() drawDeck = nil discardPile = nil topCard = nil local zone = getObjectFromGUID(zoneID) if zone == nil then return end for _, object in ipairs(zone.getObjects()) do if object.tag == "Deck" or object.tag == "Card" then if self.positionToLocal(object.getPosition()).z > 0.5 then discardPile = object -- Norman Withers handling elseif string.match(activeInvestigatorId, "%d%d%d%d%d") == "08004" and object.tag == "Card" and not object.is_face_down then topCard = object else drawDeck = object end end end end function drawCards(numCards) if drawDeck == nil then return end drawDeck.deal(numCards, playerColor) end function shuffleDiscardIntoDeck() if not discardPile.is_face_down then discardPile.flip() end discardPile.shuffle() discardPile.setPositionSmooth(self.positionToWorld(DRAW_DECK_POSITION), false, false) drawDeck = discardPile discardPile = nil 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) hand[choices[num]].setPosition(returnGlobalDiscardPosition()) broadcastToAll(playerColor .. " 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) local HAND_ZONE_GUIDS = { "a70eee", -- White "5fe087", -- Orange "0285cc", -- Green "be2f17" -- Red } local index local startPos = self.getPosition() -- get respective hand zone by position if startPos.x < -42 then if startPos.z > 0 then index = 1 else index = 2 end else if startPos.z > 0 then index = 3 else index = 4 end end -- update the color of the hand zone local handZone = getObjectFromGUID(HAND_ZONE_GUIDS[index]) handZone.setValue(color) -- if the seated player clicked this, reseat him to the new color if clickedByColor == playerColor then Player[playerColor].changeColor(color) end -- update the internal variable playerColor = color end) end --------------------------------------------------------- -- playmat token spawning --------------------------------------------------------- -- replenish Tokens for specific cards (like 'Physical Training (4)') function replenishTokens(card, count, replenish) local cardPos = card.getPosition() -- don't continue for cards on your deck (Norman) or in your discard pile if self.positionToLocal(cardPos).x < -1 then return end -- get current amount of resource tokens on the card local search = searchArea(cardPos, { 2.5, 0.5, 3.5 }) local clickableResourceCounter = nil local foundTokens = 0 for _, obj in ipairs(search) do local obj = obj.hit_object if obj.getCustomObject().image == "http://cloud-3.steamusercontent.com/ugc/1758068501357192910/11DDDC7EF621320962FDCF3AE3211D5EDC3D1573/" then foundTokens = foundTokens + math.abs(obj.getQuantity()) obj.destruct() elseif obj.getMemo() == "resourceCounter" then foundTokens = obj.getVar("val") clickableResourceCounter = obj break end end -- handling Runic Axe upgrade sheet for additional replenish if card.getName() == "Runic Axe" then for _, v in ipairs(searchArea(PLAY_ZONE_POSITION, PLAY_ZONE_SCALE)) do local obj = v.hit_object if obj.tag == "Card" then local notes = JSON.decode(obj.getGMNotes()) or {} if notes ~= nil and notes.id == "09022-c" then if obj.getVar("markedBoxes")[7] == 3 then replenish = 2 end break end end end end -- this is the theoretical new amount of uses (to be checked below) local newCount = foundTokens + replenish -- if there are already more uses than the replenish amount, keep them if foundTokens > count then newCount = foundTokens -- only replenish up until the replenish amount elseif newCount > count then newCount = count end -- update the clickable counter or spawn a group of tokens if clickableResourceCounter then clickableResourceCounter.call("updateVal", newCount) else tokenManager.spawnTokenGroup(card, "resource", newCount) end end function syncCustomizableMetadata(card) local cardMetadata = JSON.decode(card.getGMNotes()) or { } if cardMetadata ~= nil and cardMetadata.customizations ~= nil then for _, collision in ipairs(searchArea(PLAY_ZONE_POSITION, PLAY_ZONE_SCALE)) do local obj = collision.hit_object if obj.name == "Card" or obj.name == "CardCustom" then local notes = JSON.decode(obj.getGMNotes()) or { } if notes.id == (cardMetadata.id .. "-c") then for i, customization in ipairs(cardMetadata.customizations) do if obj.getVar("markedBoxes")[i] == customization.xp and customization.replaces ~= nil and customization.replaces.uses ~= nil then cardMetadata.uses = customization.replaces.uses card.setGMNotes(JSON.encode(cardMetadata)) end 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(collision_info) local object = collision_info.collision_object -- detect if "Dream-Enhancing Serum" is placed if object.getName() == "Dream-Enhancing Serum" then isDES = true end -- only continue if loading is completed if not collisionEnabled then return end -- only continue for cards if object.name ~= "Card" and object.name ~= "CardCustom" then return end maybeUpdateActiveInvestigator(object) syncCustomizableMetadata(object) if isInDeckZone(object) then tokenManager.resetTokensSpawned(object) removeTokensFromObject(object) elseif shouldSpawnTokens(object) then spawnTokensFor(object) end end -- detect if "Dream-Enhancing Serum" is removed function onCollisionExit(collision_info) if collision_info.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) Wait.frames(function() resetTokensIfInDeckZone(container, object) end, 1) end function resetTokensIfInDeckZone(container, object) if isInDeckZone(container) then tokenManager.resetTokensSpawned(object) removeTokensFromObject(container) end end -- checks if an object is in this mats deckzone function isInDeckZone(checkObject) local deckZone = getObjectFromGUID(zoneID) if deckZone == nil then return false end for _, obj in ipairs(deckZone.getObjects()) do if obj == checkObject then return true end end return false end -- removes tokens from the provided card/deck function removeTokensFromObject(object) for _, v in ipairs(searchArea(object.getPosition(), { 3, 1, 4 })) do local obj = v.hit_object if 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" and not tokenChecker.isChaosToken(obj) then TRASHCAN.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 STAT_TRACKER.call("updateStats", {notes.willpowerIcons, notes.intellectIcons, notes.combatIcons, notes.agilityIcons}) elseif activeInvestigatorId ~= "00000" then class = "Neutral" activeInvestigatorId = "00000" STAT_TRACKER.call("updateStats", {1, 1, 1, 1}) else return end -- change state of action tokens local search = searchArea(self.positionToWorld({-1.25, 0.05, -1.11}), {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 local obj = obj.hit_object 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 --------------------------------------------------------- -- calls to 'Global' / functions for calls from outside --------------------------------------------------------- function drawChaosTokenButton(_, _, isRightClick) Global.call("drawChaosToken", {self, DRAWN_CHAOS_TOKEN_OFFSET, isRightClick}) end function drawEncountercard(object, player, isRightClick) Global.call("drawEncountercard", playerColor) 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 -- Spawns / destroys a clickable clue counter for this playmat with the correct amount of clues ---@param showCounter Boolean Whether the clickable clue counter should be present function clickableClues(showCounter) local CLUE_COUNTER = getObjectFromGUID(CLUE_COUNTER_GUID) local CLUE_CLICKER = getObjectFromGUID(CLUE_CLICKER_GUID) local clickerPos = CLUE_CLICKER.getPosition() local clueCount = 0 if showCounter then -- current clue count clueCount = CLUE_COUNTER.getVar("exposedValue") -- remove clues CLUE_COUNTER.call("removeAllClues") -- set value for clue clickers CLUE_CLICKER.call("updateVal", clueCount) -- move clue counters up clickerPos.y = 1.52 CLUE_CLICKER.setPosition(clickerPos) else -- current clue count clueCount = CLUE_CLICKER.getVar("val") -- move clue counters down clickerPos.y = 1.3 CLUE_CLICKER.setPosition(clickerPos) -- 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() local CLUE_COUNTER = getObjectFromGUID(CLUE_COUNTER_GUID) local CLUE_CLICKER = getObjectFromGUID(CLUE_CLICKER_GUID) CLUE_COUNTER.call("removeAllClues") CLUE_CLICKER.call("updateVal", 0) end -- reports the clue count ---@param useClickableCounters Boolean Controls which type of counter is getting checked function getClueCount(useClickableCounters) local count = 0 if useClickableCounters then local CLUE_CLICKER = getObjectFromGUID(CLUE_CLICKER_GUID) count = tonumber(CLUE_CLICKER.getVar("val")) else local CLUE_COUNTER = getObjectFromGUID(CLUE_COUNTER_GUID) count = tonumber(CLUE_COUNTER.getVar("exposedValue")) end return count 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 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("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 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 tokenSpawnTracker = require("core/token/TokenSpawnTrackerApi") local playArea = require("core/PlayAreaApi") 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) } } -- Source for tokens local TOKEN_SOURCE_GUID = "124381" -- 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 DATA_HELPER_GUID = "708279" 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 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 tokenSpawnTracker.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 Object Card to spawn tokens on ---@param tokenType String type of token to spawn, valid values are "damage", "horror", -- "resource", "doom", or "clue" ---@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 TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown) local optionPanel = Global.getTable("optionPanel") if tokenType == "damage" or tokenType == "horror" then TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) elseif tokenType == "resource" and optionPanel["useResourceCounters"] then TokenManager.spawnResourceCounterToken(card, tokenCount) else TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown) end end -- Spawns a single counter token and sets the value to tokenValue. Used for damage and horror -- tokens. ---@param card 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 TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown) if tokenCount < 1 or tokenCount > 12 then return end local offsets = {} if tokenType == "clue" then offsets = internal.buildClueOffsets(card, tokenCount) else 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 = { } for i, baseOffset in ipairs(baseOffsets) do offsets[i] = baseOffset offsets[i][3] = offsets[i][3] + shiftDown end end if offsets == nil then error("couldn't find offsets for " .. tokenCount .. ' tokens') return end for i = 1, tokenCount do TokenManager.spawnToken(offsets[i], tokenType, card.getRotation()) end end -- Spawns a single token at the given global position by copying it from the template bag. ---@param position 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 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 -- Delegate function to the token spawn tracker. Exists to avoid circular dependencies in some -- callers. ---@param card Object Card object to reset the tokens for TokenManager.resetTokensSpawned = function(card) tokenSpawnTracker.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 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 = getObjectFromGUID(TOKEN_SOURCE_GUID) 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 = getObjectFromGUID(DATA_HELPER_GUID) 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 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 local type = nil local token = nil local tokenCount = 0 -- Uses structure underwent a breaking change in 2.4.0, have to check to see if this is -- a single entry or an array. This is ugly and duplicated, but impossible to replicate the -- multi-spawn vs. single spawn otherwise. TODO: Clean this up when 2.4.0 has been out long -- enough that saved decks don't have old data if uses.count != nil then type = cardMetadata.uses.type token = cardMetadata.uses.token tokenCount = cardMetadata.uses.count if extraUses ~= nil and extraUses[type] ~= nil then tokenCount = tokenCount + extraUses[type] end log("Spawning single use tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token) TokenManager.spawnTokenGroup(card, token, tokenCount) else for i, useInfo in ipairs(uses) do type = useInfo.type token = useInfo.token tokenCount = (useInfo.count or 0) + (useInfo.countPerInvestigator or 0) * playArea.getInvestigatorCount() if extraUses ~= nil and extraUses[type] ~= nil then tokenCount = tokenCount + extraUses[type] end log("Spawning use array tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token) -- Shift each spawned group after the first down so they don't pile on each other TokenManager.spawnTokenGroup(card, token, tokenCount, (i - 1) * 0.8) end end tokenSpawnTracker.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 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 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) token = playerData.tokenType tokenCount = playerData.tokenCount log("Spawning data helper tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token) TokenManager.spawnTokenGroup(card, token, tokenCount) tokenSpawnTracker.markTokensSpawned(card.getGUID()) end -- Spawn tokens for a location using data retrieved from the Data Helper. ---@param card Object Card to maybe spawn tokens for ---@param playerData 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) tokenSpawnTracker.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 log(card.getName() .. ' : ' .. locationData.type .. ' : ' .. locationData.value .. ' : ' .. locationData.clueSide) 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 * playArea.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 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 Object Card the clues will be placed on ---@param count Integer 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 column = math.floor((i - 1) / 4) local row = (i - 1) % 4 table.insert(cluePositions, Vector(pos.x - 1 + 0.55 * row, pos.y, pos.z - 1.4 - 0.55 * column)) end return cluePositions end return TokenManager end end) __bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = { } local PLAY_AREA_GUID = "721ba2" -- Returns the current value of the investigator counter from the playmat ---@return Integer. Number of investigators currently set on the counter PlayAreaApi.getInvestigatorCount = function() return getObjectFromGUID(PLAY_AREA_GUID).call("getInvestigatorCount") 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 Color of the player requesting the shift. Used solely to send an error --- message in the unlikely case that the scripting zone has been deleted PlayAreaApi.shiftContentsUp = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsUp", playerColor) end PlayAreaApi.shiftContentsDown = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsDown", playerColor) end PlayAreaApi.shiftContentsLeft = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsLeft", playerColor) end PlayAreaApi.shiftContentsRight = function(playerColor) return getObjectFromGUID(PLAY_AREA_GUID).call("shiftContentsRight", playerColor) end -- Reset the play area's tracking of which cards have had tokens spawned. PlayAreaApi.resetSpawnedCards = function() return getObjectFromGUID(PLAY_AREA_GUID).call("resetSpawnedCards") end -- Event to be called when the current scenario has changed. ---@param scenarioName Name of the new scenario PlayAreaApi.onScenarioChanged = function(scenarioName) getObjectFromGUID(PLAY_AREA_GUID).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 matchTypes Boolean Whether snap points should only snap for the matching card types. PlayAreaApi.setLimitSnapsByType = function(matchCardTypes) getObjectFromGUID(PLAY_AREA_GUID).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) getObjectFromGUID(PLAY_AREA_GUID).call("tryObjectEnterContainer", { container = container, object = object }) end -- counts the VP on locations in the play area PlayAreaApi.countVP = function() return getObjectFromGUID(PLAY_AREA_GUID).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 getObjectFromGUID(PLAY_AREA_GUID).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 getObjectFromGUID(PLAY_AREA_GUID).call("highlightCountedVP", state) end -- Checks if an object is in the play area (returns true or false) PlayAreaApi.isInPlayArea = function(object) return getObjectFromGUID(PLAY_AREA_GUID).call("isInPlayArea", object) end return PlayAreaApi end end) __bundle_register("core/token/TokenSpawnTrackerApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local TokenSpawnTracker = { } local SPAWN_TRACKER_GUID = "e3ffc9" TokenSpawnTracker.hasSpawnedTokens = function(cardGuid) return getObjectFromGUID(SPAWN_TRACKER_GUID).call("hasSpawnedTokens", cardGuid) end TokenSpawnTracker.markTokensSpawned = function(cardGuid) return getObjectFromGUID(SPAWN_TRACKER_GUID).call("markTokensSpawned", cardGuid) end TokenSpawnTracker.resetTokensSpawned = function(cardGuid) return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetTokensSpawned", cardGuid) end TokenSpawnTracker.resetAllAssetAndEvents = function() return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAllAssetAndEvents") end TokenSpawnTracker.resetAllLocations = function() return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAllLocations") end TokenSpawnTracker.resetAll = function() return getObjectFromGUID(SPAWN_TRACKER_GUID).call("resetAll") end return TokenSpawnTracker end end) __bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules) --------------------------------------------------------- -- specific setup (different for each playmat) --------------------------------------------------------- PLAY_ZONE_POSITION = { x = -26.5, y = 4, z = -26.5 } PLAY_ZONE_SCALE = { x = 32, y = 5, z = 14 } TRASHCAN_GUID = "4b8594" STAT_TRACKER_GUID = "e74881" RESOURCE_COUNTER_GUID = "a4b60d" CLUE_COUNTER_GUID = "37be78" CLUE_CLICKER_GUID = "4111de" require("playermat/Playmat") end) return __bundle_require("__root")