-- Bundled by luabundle {"version":"1.6.0"} local __bundle_require, __bundle_loaded, __bundle_register, __bundle_modules = (function(superRequire) local loadingPlaceholder = {[{}] = true} local register local modules = {} local require local loaded = {} register = function(name, body) if not modules[name] then modules[name] = body end end require = function(name) local loadedModule = loaded[name] if loadedModule then if loadedModule == loadingPlaceholder then return nil end else if not modules[name] then if not superRequire then local identifier = type(name) == 'string' and '\"' .. name .. '\"' or tostring(name) error('Tried to require ' .. identifier .. ', but no such module has been registered') else return superRequire(name) end end loaded[name] = loadingPlaceholder loadedModule = modules[name](require, loaded, register, modules) loaded[name] = loadedModule end return loadedModule end return require, loaded, register, modules end)(nil) __bundle_register("__root", function(require, _LOADED, __bundle_register, __bundle_modules) --------------------------------------------------------- -- specific setup (different for each playmat) --------------------------------------------------------- PLAYER_COLOR = "White" PLAY_ZONE_POSITION = { x = -54.5, y = 4, z = 21 } PLAY_ZONE_SCALE = { x = 36, y = 5.1, z = 14.5 } DRAW_DECK_POSITION = { x = -54.8, y = 2.5, z = 4.29 } DISCARD_PILE_POSITION = { x = -58.9, y = 4, z = 4.29 } TRASHCAN_GUID = "147e80" STAT_TRACKER_GUID = "e598c2" RESOURCE_COUNTER_GUID = "4406f0" require("playermat/Playmat") end) __bundle_register("playermat/Playmat", function(require, _LOADED, __bundle_register, __bundle_modules) -- 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 COLLISION_ENABLED = false -- position offsets relative to mat [x, y, z] local DRAWN_ENCOUNTER_CARD_OFFSET = {1.365, 0.5, -0.635} local DRAWN_CHAOS_TOKEN_OFFSET = {-1.55, 0.5, -0.58} local DISCARD_BUTTON_OFFSETS = { {-1.365, 0.1, -0.945}, {-0.91, 0.1, -0.945}, {-0.455, 0.1, -0.945}, {0, 0.1, -0.945}, {0.455, 0.1, -0.945}, {0.91, 0.1, -0.945} } local PLAY_ZONE_ROTATION = self.getRotation() local TRASHCAN local STAT_TRACKER local RESOURCE_COUNTER -- global variable so it can be reset by the Clean Up Helper activeInvestigatorId = "00000" function onSave() return JSON.encode({zoneID = zoneID, playerColor = PLAYER_COLOR, activeInvestigatorId = activeInvestigatorId}) end function onLoad(save_state) self.interactable = DEBUG DATA_HELPER = getObjectFromGUID('708279') PLAYER_CARDS = DATA_HELPER.getTable('PLAYER_CARD_DATA') PLAYER_CARD_TOKEN_OFFSETS = DATA_HELPER.getTable('PLAYER_CARD_TOKEN_OFFSETS') TRASHCAN = getObjectFromGUID(TRASHCAN_GUID) STAT_TRACKER = getObjectFromGUID(STAT_TRACKER_GUID) RESOURCE_COUNTER = getObjectFromGUID(RESOURCE_COUNTER_GUID) 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 }) local state = JSON.decode(save_state) if state ~= nil then zoneID = state.zoneID PLAYER_COLOR = state.playerColor activeInvestigatorId = state.activeInvestigatorId end if getObjectFromGUID(zoneID) == nil then spawnDeckZone() end COLLISION_ENABLED = true end --------------------------------------------------------- -- utility functions --------------------------------------------------------- function log(message) if DEBUG then print(message) end end -- send messages to player who clicked button if no seated player found function setMessageColor(color) messageColor = Player[PLAYER_COLOR].seated and PLAYER_COLOR or color end 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 = PLAY_ZONE_ROTATION, 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(DISCARD_PILE_POSITION, false, true) obj.setRotation(PLAY_ZONE_ROTATION) else obj.setPositionSmooth(discardPosition, false, true) obj.setRotation({0, -90, 0}) end -- 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(position, discardPosition, number) 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 = 800, height = 280, font_size = 180 }) end function findObjectsAtPosition(localPos) return Physics.cast({ origin = self.positionToWorld(localPos), direction = {0, 1, 0}, orientation = {0, PLAY_ZONE_ROTATION.y + 90, 0}, type = 3, size = {3.2, 1, 2}, max_distance = 0, debug = DEBUG }) end --------------------------------------------------------- -- Upkeep button --------------------------------------------------------- function doUpkeep(_, color, alt_click) setMessageColor(color) -- right-click binds to new player color if alt_click then PLAYER_COLOR = color printToColor("Upkeep button bound to " .. color, color) return end local forcedLearning = false -- unexhaust cards in play zone, flip action tokens and find forcedLearning 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 then local cardMetadata = JSON.decode(obj.getGMNotes()) or {} if not doNotReady(obj) and cardMetadata.type ~= "Investigator" then obj.setRotation(PLAY_ZONE_ROTATION) 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 if found -- flip summoned servitor mini-cards (To-Do: don't flip all of them) if activeInvestigatorId ~= nil then local miniId = string.match(activeInvestigatorId, "%d%d%d%d%d") .. "-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 RESOURCE_COUNTER.call("addOrSubtract") -- gain an additional resource for Jenny Barnes if string.match(activeInvestigatorId, "%d%d%d%d%d") == "02003" then RESOURCE_COUNTER.call("addOrSubtract") printToColor("Gaining 2 resources (Jenny)", messageColor) 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[PLAYER_COLOR].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 -- legacy function for "add draw 1 button" function doDrawOne(_, color) setMessageColor(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, PLAYER_COLOR) 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, PLAYER_COLOR) end function shuffleDiscardIntoDeck() if not discardPile.is_face_down then discardPile.flip() end discardPile.shuffle() discardPile.setPositionSmooth(DRAW_DECK_POSITION, false, false) drawDeck = discardPile discardPile = nil end function spawnTokenOn(object, offsets, tokenType) local tokenPosition = object.positionToWorld(offsets) spawnToken(tokenPosition, tokenType) end -- Spawn a group of tokens of the given type on the object -- @param object Object to spawn the tokens on -- @param tokenType Type of token to be spawned -- @param tokenCount Number of tokens to spawn -- @param shiftDown Amount to shift this group down to avoid spawning multiple token groups on -- top of each other. Negative values are allowed, and will move the group up instead. This is -- a static value and is unaware of how many tokens were spawned previously; callers should -- ensure the proper shift. function spawnTokenGroup(object, tokenType, tokenCount, shiftDown) if (tokenCount < 1 or tokenCount > 12) then return end local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] 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') end for i = 1, tokenCount do spawnTokenOn(object, offsets[i], tokenType) 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 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() 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 local newCount = foundTokens + replenish if newCount > count then newCount = count end spawnTokenGroup(card, "resource", newCount) end function getPlayerCardData(object) return PLAYER_CARDS[object.getName()..':'..object.getDescription()] or PLAYER_CARDS[object.getName()] end function shouldSpawnTokens(object) -- don't spawn tokens if in doubt, this should only ever happen onLoad and prevents respawns local spawned = DATA_HELPER.call('getSpawnedPlayerCardGuid', {object.getGUID()}) local hasDataHelperData = getPlayerCardData(object) local cardMetadata = JSON.decode(object.getGMNotes()) or {} local hasUses = cardMetadata.uses ~= nil return not spawned and (hasDataHelperData or hasUses) end function markSpawned(object) local saved = DATA_HELPER.call('setSpawnedPlayerCardGuid', {object.getGUID(), true}) if not saved then error('attempt to mark player card spawned before data loaded') end end -- contains position and amount of boxes for the upgradesheets that change uses -- Alchemical Distillation, Damning Testimony, Living Ink and Hyperphysical Shotcaster local customizationsTable = { ["09040"] = {5, 2}, ["09059"] = {2, 2}, ["09079"] = {3, 2}, ["09119"] = {6, 4} } function spawnTokensFor(object) local cardMetadata = JSON.decode(object.getGMNotes()) or {} local type = nil local token = nil local tokenCount = 0 if cardMetadata.uses ~= nil then -- 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 cardMetadata.uses.count != nil then type = cardMetadata.uses.type token = cardMetadata.uses.token tokenCount = cardMetadata.uses.count if activeInvestigatorId == "03004" and type == "Charge" then tokenCount = tokenCount + 1 end log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) spawnTokenGroup(object, token, tokenCount) else for i, useInfo in ipairs(cardMetadata.uses) do type = useInfo.type token = useInfo.token tokenCount = useInfo.count -- additional uses for certain customizable cards (by checking the upgradesheets) if customizationsTable[cardMetadata.id] ~= nil then for _, obj in ipairs(searchArea(PLAY_ZONE_POSITION, PLAY_ZONE_SCALE)) do local obj = obj.hit_object if obj.tag == "Card" then local notes = JSON.decode(obj.getGMNotes()) or {} if notes.id == (cardMetadata.id .. "-c") then local pos = customizationsTable[cardMetadata.id][1] local boxes = customizationsTable[cardMetadata.id][2] if obj.getVar("markedBoxes")[pos] == boxes then tokenCount = tokenCount + 2 end break end end end end -- additional charge for Akachi if activeInvestigatorId == "03004" and type == "Charge" then tokenCount = tokenCount + 1 end log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) -- Shift each spawned group after the first down so they don't pile on each other spawnTokenGroup(object, token, tokenCount, (i - 1) * 0.6) end end else local data = getPlayerCardData(object) token = data['tokenType'] tokenCount = data['tokenCount'] log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) spawnTokenGroup(object, token, tokenCount) end markSpawned(object) end function resetSpawnState() local zone = getObjectFromGUID(zoneID) if zone == nil then return end for _, object in ipairs(zone.getObjects()) do if object.tag == "Card" then unmarkSpawned(object.getGUID(), true) elseif object.tag == "Deck" then local cards = object.getObjects() for _, v in ipairs(cards) do unmarkSpawned(v.guid) end end end end function unmarkSpawned(guid, force) if not force and getObjectFromGUID(guid) ~= nil then return end DATA_HELPER.call('setSpawnedPlayerCardGuid', {guid, false}) end function onCollisionEnter(collision_info) if not COLLISION_ENABLED then return end Wait.time(resetSpawnState, 1) local object = collision_info.collision_object -- only continue for cards if object.name ~= "Card" and object.name ~= "CardCustom" then return end maybeUpdateActiveInvestigator(object) -- don't spawn tokens for cards in discard pile / threat area local localpos = self.positionToLocal(object.getPosition()) if localpos.x < -0.7 or localpos.z < -0.3 then log('Not spawning tokens, relative coordinates are x: ' .. localpos.x .. ' z: ' .. localpos.z) elseif not object.is_face_down and shouldSpawnTokens(object) then spawnTokensFor(object) end end --------------------------------------------------------- -- investigator ID grabbing and stat tracker --------------------------------------------------------- function maybeUpdateActiveInvestigator(card) local notes = JSON.decode(card.getGMNotes()) if notes ~= nil and notes.type == "Investigator" and notes.id ~= activeInvestigatorId then activeInvestigatorId = notes.id STAT_TRACKER.call("updateStats", {notes.willpowerIcons, notes.intellectIcons, notes.combatIcons, notes.agilityIcons}) -- change state of action tokens local search = searchArea(self.positionToWorld({-1.25, 0.05, -1.11}), {4, 1, 1}) local small_token = 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 small_token = obj else setObjectState(obj, state_table[notes.class]) end end end -- update the small token with special action for certain investigators -- Ursula Downs: Investigate action if activeInvestigatorId == "04002" then setObjectState(small_token, 8) -- Daisy Walker (only for normal front, not parallel): Tome action elseif activeInvestigatorId == "01002" or activeInvestigatorId == "01502" or activeInvestigatorId == "01002-pb" then setObjectState(small_token, 9) -- Tony Morgan: Engage/Fight action elseif activeInvestigatorId == "06003" then setObjectState(small_token, 10) -- Finn Edwards: Evade action elseif activeInvestigatorId == "04003" then setObjectState(small_token, 11) -- Bob Jenkins: Play Item action elseif activeInvestigatorId == "08016" then setObjectState(small_token, 14) else setObjectState(small_token, state_table[notes.class]) end 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", PLAYER_COLOR) end function spawnToken(position, tokenType) Global.call('spawnToken', {position, tokenType, PLAY_ZONE_ROTATION}) 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 custom_data_helper = getObjectFromGUID(args[1]) data_player_cards = custom_data_helper.getTable("PLAYER_CARD_DATA") for k, v in pairs(data_player_cards) do PLAYER_CARDS[k] = v end end end) return __bundle_require("__root")