-- set true to enable debug logging DEBUG = false -- we use this to turn off collision handling (for clue spawning) -- until after load is complete (probably a better way to do this) COLLISION_ENABLED = false -- position offsets, adjust these to reposition things relative to mat [x,y,z] DRAWN_ENCOUNTER_CARD_OFFSET = {1.365, 0.5, -0.635} DRAWN_CHAOS_TOKEN_OFFSET = {-1.55, 0.5, -0.58} DISCARD_BUTTON_OFFSETS = { {-0.98, 0.2, -0.35}, {-0.525, 0.2, -0.35}, {-0.07, 0.2, -0.35}, {0.39, 0.2, -0.35}, {0.84, 0.2, -0.35}, } -- draw deck and discard zone DECK_POSITION = { x=-1.4, y=0, z=0.3 } DECK_ZONE_SCALE = { x=3, y=5, z=8 } DRAW_DECK_POSITION = { x=-26.46, y=1.67, z=-39.15 } -- play zone PLAYER_COLOR = "Red" PLAY_ZONE_POSITION = { x=-25, y=4, z=-27 } PLAY_ZONE_ROTATION = { x=0, y=270, z=0 } PLAY_ZONE_SCALE = { x=30, y=5, z=15 } RESOURCE_COUNTER_GUID = "a4b60d" -- the position of the global discard pile -- TODO: delegate to global for any auto discard actions DISCARD_POSITION = {-3.85, 3, 10.38} -- DISCARD PILE POSITION DISCARD_PILE_POSITION = { -18.79, 4, -30.84 } local activeInvestigatorId = nil local willpowerTokenGuid = "8b6743" local intellectTokenGuid = "b4ef12" local combatTokenGuid = "93a48b" local agilityTokenGuid = "2c3f8d" function log(message) if DEBUG then print(message) end end -- builds a function that discards things in searchPostion to discardPostition function makeDiscardHandlerFor(searchPosition, discardPosition) return function (_) local discardItemList = findObjectsAtPosition(searchPosition) for _, obj in ipairs(discardItemList) do obj.setPositionSmooth(discardPosition, false, true) obj.setRotation({0, -90, 0}) end end end -- build a discard button at position to discard from searchPosition to discardPosition -- number must be unique function makeDiscardButton(position, searchPosition, discardPosition, number) 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 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') -- positions of encounter card slots local encounterSlots = { {1.365, 0, -0.7}, {0.91, 0, -0.7}, {0.455, 0, -0.7}, {0, 0, -0.7}, {-0.455, 0, -0.7}, {-0.91, 0, -0.7}, } local i = 1 while i <= 6 do makeDiscardButton(DISCARD_BUTTON_OFFSETS[i], encounterSlots[i], DISCARD_POSITION, i) i = i + 1 end self.createButton({ label = " ", click_function = "drawEncountercard", function_owner = self, position = {-1.88,0,-0.7}, rotation = {0,-15,0}, width = 170, height = 255, font_size = 50 }) self.createButton({ label=" ", click_function = "drawChaostokenButton", function_owner = self, position = {1.84,0.0,-0.74}, rotation = {0,-45,0}, width = 125, height = 125, font_size = 50 }) 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 if state.playerColor ~= nil then PLAYER_COLOR = state.playerColor end if state.zoneID ~= nil then zoneID = state.zoneID Wait.time(checkDeckZoneExists, 30) else spawnDeckZone() end else spawnDeckZone() end COLLISION_ENABLED = true end function onSave() return JSON.encode({ zoneID=zoneID, playerColor=PLAYER_COLOR }) end function setMessageColor(color) -- send messages to player who clicked button if no seated player found messageColor = Player[PLAYER_COLOR].seated and PLAYER_COLOR or color 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 i,object in ipairs(zone.getObjects()) do if object.tag == "Deck" or object.tag == "Card" then local relativePos = self.positionToLocal(object.getPosition()) if relativePos.z > 0.5 then discardPile = object else if investigator == "Norman Withers" and object.tag == "Card" and not object.is_face_down then topCard = object else drawDeck = object end end end end end function doUpkeep(obj, color, alt_click) -- right-click binds to new player color if alt_click then PLAYER_COLOR = color printToColor("Upkeep button bound to " .. color, color) return end setMessageColor(color) -- unexhaust cards in play zone local objs = Physics.cast({ origin = PLAY_ZONE_POSITION, direction = { x=0, y=1, z=0 }, type = 3, size = PLAY_ZONE_SCALE, orientation = PLAY_ZONE_ROTATION }) local y = PLAY_ZONE_ROTATION.y investigator = nil local miniId = nil local forcedLearning = false for i,v in ipairs(objs) do local obj = v.hit_object local props = obj.getCustomObject() or {} if obj.tag == "Card" and not obj.is_face_down and not doNotReady(obj) then local notes = JSON.decode(obj.getGMNotes()) or {} local name = obj.getName() if notes.type == "Investigator" and notes.id ~= nil then miniId = string.match(notes.id, "%d%d%d%d%d%d-") .. "-m" end if notes.type == "Investigator" or props.unique_back then if string.match(name, "Jenny Barnes") ~= nil then investigator = "Jenny Barnes" elseif name == "Patrice Hathaway" then investigator = name elseif string.match(name, "Norman Withers") ~= nil then investigator = "Norman Withers" end elseif name == "Forced Learning" then forcedLearning = true else local r = obj.getRotation() if (r.y - y > 10) or (y - r.y > 10) then obj.setRotation(PLAY_ZONE_ROTATION) end end elseif obj.getDescription() == "Action Token" then if obj.is_face_down then obj.flip() end end end -- flip investigator mini-card if found if miniId ~= nil then objs = getObjects() for i, obj in ipairs(objs) do if obj.tag == "Card" then local notes = JSON.decode(obj.getGMNotes()) if notes ~= nil and notes.type == "Minicard" and notes.id == miniId then if obj.is_face_down then obj.flip() end goto done end end end end ::done:: -- flip summoned servitor mini-cards (To-Do: don't flip all of them) for i, obj in ipairs(getObjects()) do if obj.tag == "Card" then local notes = JSON.decode(obj.getGMNotes()) if notes ~= nil and notes.type == "Minicard" and notes.id == "09080-m" then if obj.is_face_down then obj.flip() end end end end -- gain resource getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") if investigator == "Jenny Barnes" then getObjectFromGUID(RESOURCE_COUNTER_GUID).call("add_subtract") printToColor("Gaining 2 resources (Jenny)", messageColor) end -- special draw for Patrice Hathaway (shuffle discards if necessary) if investigator == "Patrice Hathaway" then patriceDraw() return end -- special draw for Forced Learning if forcedLearning then forcedLearningDraw() return end drawCardsWithReshuffle(1) end function doDrawOne(obj, color) setMessageColor(color) drawCardsWithReshuffle(1) end function doNotReady(card) if card.getVar("do_not_ready") == true then return true else return false end end -- draw X cards (shuffle discards if necessary) function drawCardsWithReshuffle(numCards) if type(numCards) ~= "number" then numCards = 1 end getDrawDiscardDecks() if investigator == "Norman Withers" then local harbinger = false if topCard ~= nil and topCard.getName() == "The Harbinger" then harbinger = true else if drawDeck ~= nil and not drawDeck.is_face_down then local cards = drawDeck.getObjects() local bottomCard = cards[#cards] if bottomCard.name == "The Harbinger" then harbinger = true end end end if harbinger then printToColor("The Harbinger is on top of your deck, not drawing cards", messageColor) return -1 end if topCard ~= nil then topCard.deal(numCards, PLAYER_COLOR) numCards = numCards - 1 if numCards == 0 then return end end end local deckSize if drawDeck == nil then deckSize = 0 elseif drawDeck.tag == "Deck" then deckSize = #drawDeck.getObjects() else deckSize = 1 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 function drawCards(numCards) if drawDeck == nil then return end drawDeck.deal(numCards, PLAYER_COLOR) end function shuffleDiscardIntoDeck() discardPile.flip() discardPile.shuffle() discardPile.setPositionSmooth(DRAW_DECK_POSITION, false, false) drawDeck = discardPile discardPile = nil end function patriceDraw() local handSize = #Player[PLAYER_COLOR].getHandObjects() if handSize >= 5 then return end local cardsToDraw = 5 - handSize printToColor("Drawing " .. cardsToDraw .. " cards (Patrice)", messageColor) drawCardsWithReshuffle(cardsToDraw) end function forcedLearningDraw() printToColor("Drawing 2 cards, discard 1 (Forced Learning)", messageColor) drawCardsWithReshuffle(2) end function checkDeckZoneExists() if getObjectFromGUID(zoneID) ~= nil then return end spawnDeckZone() end function spawnDeckZone() local pos = self.positionToWorld(DECK_POSITION) local zoneProps = { position = pos, scale = DECK_ZONE_SCALE, type = 'ScriptingTrigger', callback = 'zoneCallback', callback_owner = self, rotation = self.getRotation() } spawnObject(zoneProps) end function zoneCallback(zone) zoneID = zone.getGUID() end function findObjectsAtPosition(localPos) local globalPos = self.positionToWorld(localPos) local objList = Physics.cast({ origin=globalPos, --Where the cast takes place direction={0,1,0}, --Which direction it moves (up is shown) type=2, --Type. 2 is "sphere" size={2,2,2}, --How large that sphere is max_distance=1, --How far it moves. Just a little bit debug=false --If it displays the sphere when casting. }) local decksAndCards = {} for _, obj in ipairs(objList) do if obj.hit_object.tag == "Deck" or obj.hit_object.tag == "Card" then table.insert(decksAndCards, obj.hit_object) end end return decksAndCards 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 local i = 0 while i < tokenCount do local offset = offsets[i + 1] spawnTokenOn(object, offset, tokenType) i = i + 1 end end function buildPlayerCardKey(object) return object.getName() .. ':' .. object.getDescription() end function getPlayerCardData(object) return PLAYER_CARDS[buildPlayerCardKey(object)] or PLAYER_CARDS[object.getName()] end function shouldSpawnTokens(object) -- we assume we shouldn't spawn tokens if in doubt, this should -- only ever happen on load and in that case 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 function spawnTokensFor(object) local cardMetadata = JSON.decode(object.getGMNotes()) or {} local token = nil local type = nil local tokenCount = 0 if (cardMetadata.uses ~= nil) then for i, useInfo in ipairs(cardMetadata.uses) do token = useInfo.token type = useInfo.type tokenCount = useInfo.count if (activeInvestigatorId == "03004" and useInfo.type == "Charge") then tokenCount = tokenCount + 1 end log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) spawnTokenGroup(object, token, tokenCount) end 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 i,object in ipairs(zone.getObjects()) do if object.tag == "Card" then local guid = object.getGUID() if guid ~= nil then unmarkSpawned(guid, true) end elseif object.tag == "Deck" then local cards = object.getObjects() if (cards ~= nil) then for i,v in ipairs(cards) do if v.guid ~= nil then unmarkSpawned(v.guid) end end 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 (collision_info.collision_object.name == "Card") then maybeUpdateActiveInvestigator(collision_info.collision_object) end if not COLLISION_ENABLED then return end local object = collision_info.collision_object Wait.time(resetSpawnState, 1) -- anything to the left of this is legal to spawn local discardSpawnBoundary = self.positionToWorld({-1.2, 0, 0}) local boundaryLocalToCard = object.positionToLocal(discardSpawnBoundary) if boundaryLocalToCard.x > 0 then log('not checking for token spawn, boundary relative is ' .. boundaryLocalToCard.x) return end if not object.is_face_down and shouldSpawnTokens(object) then spawnTokensFor(object) end end function maybeUpdateActiveInvestigator(card) local cardMetadata = JSON.decode(card.getGMNotes()) or {} if (cardMetadata.type == "Investigator") then activeInvestigatorId = cardMetadata.id updateStatToken(willpowerTokenGuid, cardMetadata.willpowerIcons) updateStatToken(intellectTokenGuid, cardMetadata.intellectIcons) updateStatToken(combatTokenGuid, cardMetadata.combatIcons) updateStatToken(agilityTokenGuid, cardMetadata.agilityIcons) end end function updateStatToken(tokenGuid, val) local statToken = getObjectFromGUID(tokenGuid) if (statToken == nil) then return end statToken.call("reset_val") for i = 1, val do statToken.call("add_subtract", { alt_click = false }) end end -- functions delegated to Global function drawChaostokenButton(object, player, isRightClick) -- local toPosition = self.positionToWorld(DRAWN_CHAOS_TOKEN_OFFSET) 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}) 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