--------------------------------------------------------- -- general setup (identical for each playmat) --------------------------------------------------------- -- 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() activeInvestigatorId = "00000" --------------------------------------------------------- -- general code (identical for each playmat) --------------------------------------------------------- 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') 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, 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.tag == "Card" and not obj.is_face_down and not doNotReady(obj) then local notes = JSON.decode(obj.getGMNotes()) or {} if notes.id == "08031" then forcedLearning = true elseif notes.type ~= "Investigator" then obj.setRotation(PLAY_ZONE_ROTATION) -- check for cards with 'replenish' in their metadata if notes.uses ~= nil then local count = notes.uses[1].count local replenish = notes.uses[1].replenish if count and replenish then replenishTokens(obj, count, replenish) end end end elseif obj.getDescription() == "Action Token" and obj.is_face_down then obj.flip() 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" then local notes = JSON.decode(obj.getGMNotes()) if obj.is_face_down and 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("add_subtract") -- gain an additional resource for Jenny Barnes if string.match(activeInvestigatorId, "%d%d%d%d%d") == "02003" then RESOURCE_COUNTER.call("add_subtract") printToColor("Gaining 2 resources (Jenny)", messageColor) end -- draw a card (with handling for Patrice and Forced Learning) if activeInvestigatorId == "06005" then local handSize = #Player[PLAYER_COLOR].getHandObjects() if handSize >= 5 then return end local cardsToDraw = 5 - handSize printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) drawCardsWithReshuffle(cardsToDraw) 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) if type(numCards) ~= "number" then numCards = 1 end 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 function spawnTokenGroup(object, tokenType, tokenCount) if (tokenCount < 1 or tokenCount > 12) then return end local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] 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 for _, useInfo in ipairs(cardMetadata.uses) do type = useInfo.type token = useInfo.token tokenCount = useInfo.count end -- 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) spawnTokenGroup(object, token, tokenCount) else local data = getPlayerCardData(object) if data == nil then error('attempt to spawn tokens for ' .. object.getName() .. ': no token data') end token = data['tokenType'] tokenCount = data['tokenCount'] log(object.getName() .. '[' .. object.getDescription() .. ']' .. ' : ' .. data['tokenType'] .. ' : ' .. 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.1, 0.05, -0.27}), {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 self.positionToLocal(obj.getPosition()).x > -0.95 then small_token = obj else objState(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 objState(small_token, 8) -- Daisy Walker (only for normal front, not parallel): Tome action elseif activeInvestigatorId == "01002" or activeInvestigatorId == "01002-r" or activeInvestigatorId == "01002-pb" then objState(small_token, 9) -- Tony Morgan: Engage/Fight action elseif activeInvestigatorId == "06003" then objState(small_token, 10) -- Finn Edwards: Evade action elseif activeInvestigatorId == "04003" then objState(small_token, 11) -- Bob Jenkins: Play Item action elseif activeInvestigatorId == "08016" then objState(small_token, 14) else objState(small_token, state_table[notes.class]) end end end function objState(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(_, _, isRightClick) Global.call("drawEncountercard", {self.positionToWorld(DRAWN_ENCOUNTER_CARD_OFFSET), self.getRotation(), isRightClick}) end function spawnToken(position, tokenType) Global.call('spawnToken', {position, tokenType, PLAY_ZONE_ROTATION}) end 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