--------------------------------------------------------- -- general setup --------------------------------------------------------- -- set true to enable debug logging local DEBUG = false -- Location connection directional options local BIDIRECTIONAL = 0 local ONE_WAY = 1 local INCOMING_ONE_WAY = 2 -- Connector draw parameters local CONNECTION_THICKNESS = 0.015 local DRAGGING_CONNECTION_THICKNESS = 0.15 local DRAGGING_CONNECTION_COLOR = { 0.8, 0.8, 0.8, 1 } local CONNECTION_COLOR = { 0.4, 0.4, 0.4, 1 } local DIRECTIONAL_ARROW_DISTANCE = 3.5 local ARROW_ARM_LENGTH = 0.9 local ARROW_ANGLE = 25 -- Height to draw the connector lines, places them just above the table and always below cards local CONNECTION_LINE_Y = 1.529 -- we use this to turn off collision handling until onLoad() is complete local collisionEnabled = false -- used for recreating the link to a custom data helper after image change customDataHelper = nil local SHIFT_OFFSETS = { left = { x = 0.00, y = 0, z = 7.67 }, right = { x = 0.00, y = 0, z = -7.67 }, up = { x = 6.54, y = 0, z = 0.00 }, down = { x = -6.54, y = 0, z = 0.00 } } local SHIFT_EXCLUSION = { ["b7b45b"] = true, ["f182ee"] = true, ["721ba2"] = true } local LOC_LINK_EXCLUDE_SCENARIOS = { ["The Witching Hour"] = true, } local tokenManager = require("core/token/TokenManager") local INVESTIGATOR_COUNTER_GUID = "f182ee" local PLAY_AREA_ZONE_GUID = "a2f932" local clueData = {} local spawnedLocationGUIDs = {} local locations = {} local locationConnections = {} local draggingGuids = {} local locationData local currentScenario --------------------------------------------------------- -- general code --------------------------------------------------------- function onSave() return JSON.encode({ trackedLocations = locations, currentScenario = currentScenario, }) end function onLoad(saveState) -- records locations we have spawned clues for local save = JSON.decode(saveState) or { } locations = save.trackedLocations or { } currentScenario = save.currentScenario self.interactable = DEBUG Wait.time(function() collisionEnabled = true end, 1) end function log(message) if DEBUG then print(message) end end -- Called by Custom Data Helpers to push their location data into the Data Helper. This adds the -- data to the local token manager instance. ---@param args Table Single-value array holding the GUID of the Custom Data Helper making the call function updateLocations(args) customDataHelper = getObjectFromGUID(args[1]) if customDataHelper ~= nil then tokenManager.addLocationData(customDataHelper.getTable("LOCATIONS_DATA")) end end function onCollisionEnter(collisionInfo) local obj = collisionInfo.collision_object local objType = obj.name -- only continue for cards if not collisionEnabled or (objType ~= "Card" and objType ~= "CardCustom") then return end -- check if we should spawn clues here and do so according to playercount local card = collisionInfo.collision_object if shouldSpawnTokens(card) then tokenManager.spawnForCard(card) end -- If this card was being dragged, clear the dragging connections. A multi-drag/drop may send -- the dropped card immediately into a deck, so this has to be done here if draggingGuids[card.getGUID()] ~= nil then card.setVectorLines(nil) draggingGuids[card.getGUID()] = nil end maybeTrackLocation(card) end function shouldSpawnTokens(card) local metadata = JSON.decode(card.getGMNotes()) if metadata == nil then return tokenManager.hasLocationData(card) end return metadata.type == "Location" or metadata.type == "Enemy" or metadata.type == "Treachery" or metadata.weakness end function onCollisionExit(collisionInfo) maybeUntrackLocation(collisionInfo.collision_object) end -- Destroyed objects don't trigger onCollisionExit(), so check on destruction to untrack as well function onObjectDestroy(object) maybeUntrackLocation(object) end function onObjectPickUp(player, object) -- only continue for cards local objType = object.name if objType ~= "Card" and objType ~= "CardCustom" then return end -- onCollisionExit USUALLY fires first, so we have to check the card to see if it's a location we -- should be tracking if showLocationLinks() and isInPlayArea(object) and object.getGMNotes() ~= nil and object.getGMNotes() ~= "" then local pickedUpGuid = object.getGUID() local metadata = JSON.decode(object.getGMNotes()) or { } if (metadata.type == "Location") then -- onCollisionExit sometimes comes 1 frame after onObjectPickUp (rather than before it or in -- the same frame). This causes a mismatch in the data between dragging the on-table, and -- that one frame draws connectors on the card which then show up as shadows for snap points. -- Waiting ensures we always do thing in the expected Exit->PickUp order Wait.frames(function() draggingGuids[pickedUpGuid] = metadata rebuildConnectionList() end, 2) end end end function onUpdate() -- Due to the frequence of onUpdate calls, ensure that we only process any changes to the -- connection list once, and only redraw once local needsConnectionRebuild = false local needsConnectionDraw = false for guid, _ in pairs(draggingGuids) do local obj = getObjectFromGUID(guid) if obj == nil or not isInPlayArea(obj) then draggingGuids[guid] = nil needsConnectionRebuild = true -- If object still exists then it's been dragged outside the area and needs to clear the -- lines attached to it if obj ~= nil then obj.setVectorLines(nil) end end -- Even if the last location left the play area, need one last draw to clear the lines needsConnectionDraw = true end if (needsConnectionRebuild) then rebuildConnectionList() end if needsConnectionDraw then drawDraggingConnections() end end -- Checks the given card and adds it to the list of locations tracked for connection purposes. -- A card will be added to the tracking if it is a location in the play area (based on centerpoint). ---@param card Object A card object, possibly a location. function maybeTrackLocation(card) -- Collision checks for any part of the card overlap, but our other tracking is centerpoint -- Ignore any collision where the centerpoint isn't in the area if showLocationLinks() and isInPlayArea(card) then local metadata = JSON.decode(card.getGMNotes()) or { } if metadata.type == "Location" then locations[card.getGUID()] = metadata rebuildConnectionList() drawBaseConnections() end end end -- Stop tracking a location for connection drawing. This should be called for both collision exit -- and destruction, as a destroyed object does not trigger collision exit. An object can also be -- deleted mid-drag, but the ordering for drag events means we can't clear those here and those will -- be cleared in the next onUpdate() cycle. ---@param card Object Card to (maybe) stop tracking function maybeUntrackLocation(card) -- Locked objects no longer collide (hence triggering an exit event) but are still in the play -- area. If the object is now locked, don't remove it. if locations[card.getGUID()] ~= nil and not card.locked then locations[card.getGUID()] = nil rebuildConnectionList() drawBaseConnections() end end -- Global event handler, delegated from Global. Clears any connection lines from dragged cards -- before they are destroyed by entering a deck. Removal of the card from the dragging list will -- be handled during the next onUpdate() call. function tryObjectEnterContainer(params) for draggedGuid, _ in pairs(draggingGuids) do local draggedObj = getObjectFromGUID(draggedGuid) if draggedObj ~= nil then draggedObj.setVectorLines(nil) end end end -- Builds a list of GUID to GUID connection information based on the currently tracked locations. -- This will update the connection information and store it in the locationConnections data member, -- but does not draw those connections. This should often be followed by a call to -- drawBaseConnections() function rebuildConnectionList() if not showLocationLinks() then locationConnections = { } return end local iconCardList = { } -- Build a list of cards with each icon as their location ID for cardId, metadata in pairs(draggingGuids) do buildLocListByIcon(cardId, iconCardList) end for cardId, metadata in pairs(locations) do buildLocListByIcon(cardId, iconCardList) end -- Pair up all the icons locationConnections = { } for cardId, metadata in pairs(draggingGuids) do buildConnection(cardId, iconCardList) end for cardId, metadata in pairs(locations) do if draggingGuids[cardId] == nil then buildConnection(cardId, iconCardList) end end end -- Extracts the card's icon string into a list of individual location icons ---@param cardID String GUID of the card to pull the icon data from ---@param iconCardList Table A table of icon->GUID list. Mutable, will be updated by this method function buildLocListByIcon(cardId, iconCardList) local card = getObjectFromGUID(cardId) local locData = getLocationData(card) if locData ~= nil and locData.icons ~= nil then for icon in string.gmatch(locData.icons, "%a+") do if iconCardList[icon] == nil then iconCardList[icon] = { } end table.insert(iconCardList[icon], card.getGUID()) end end end -- Builds the connections for the given cardID by finding matching icons and adding them to the -- Playarea's locationConnections table. ---@param cardId String GUID of the card to build the connections for ---@param iconCardList Table A table of icon->GUID List. Used to find matching icons for connections. function buildConnection(cardId, iconCardList) local card = getObjectFromGUID(cardId) local locData = getLocationData(card) if locData ~= nil and locData.connections ~= nil then locationConnections[card.getGUID()] = { } for icon in string.gmatch(locData.connections, "%a+") do if iconCardList[icon] ~= nil then for _, connectedGuid in ipairs(iconCardList[icon]) do -- If the reciprocal exists, convert it to BiDi, otherwise add as a one-way if locationConnections[connectedGuid] ~= nil and (locationConnections[connectedGuid][card.getGUID()] == ONE_WAY or locationConnections[connectedGuid][card.getGUID()] == BIDIRECTIONAL) then locationConnections[connectedGuid][card.getGUID()] = BIDIRECTIONAL locationConnections[card.getGUID()][connectedGuid] = nil else if locationConnections[connectedGuid] == nil then locationConnections[connectedGuid] = { } end locationConnections[card.getGUID()][connectedGuid] = ONE_WAY locationConnections[connectedGuid][card.getGUID()] = INCOMING_ONE_WAY end end end end end end -- Helper method to extract the location metadata from a card based on whether it's front or back -- is showing. ---@param card String Card object to extract data from ---@return. Table with either the locationFront or locationBack metadata structure, or nil if the -- metadata doesn't exist function getLocationData(card) if card == nil then return nil end if card.is_face_down then return JSON.decode(card.getGMNotes()).locationBack else return JSON.decode(card.getGMNotes()).locationFront end end -- Draws the lines for connections currently in locationConnections but not in draggingGuids. -- Constructed vectors will be set to the playmat function drawBaseConnections() if not showLocationLinks() then locationConnections = { } return end local cardConnectionLines = { } for originGuid, targetGuids in pairs(locationConnections) do -- Objects should reliably exist at this point, but since this can be called during onUpdate the -- object checks are conservative just to make sure. local origin = getObjectFromGUID(originGuid) if draggingGuids[originGuid] == nil and origin != nil then for targetGuid, direction in pairs(targetGuids) do local target = getObjectFromGUID(targetGuid) if draggingGuids[targetGuid] == nil and target != nil then -- Since we process the full list, we're guaranteed to hit any ONE_WAY connections later -- so we can ignore INCOMING_ONE_WAY if direction == BIDIRECTIONAL then addBidirectionalVector(origin, target, self, cardConnectionLines) elseif direction == ONE_WAY then addOneWayVector(origin, target, self, cardConnectionLines) end end end end end self.setVectorLines(cardConnectionLines) end -- Draws the lines for cards which are currently being dragged. function drawDraggingConnections() if not showLocationLinks() then return end local cardConnectionLines = { } local ownedVectors = { } for originGuid, _ in pairs(draggingGuids) do targetGuids = locationConnections[originGuid] -- Objects should reliably exist at this point, but since this can be called during onUpdate the -- object checks are conservative just to make sure. local origin = getObjectFromGUID(originGuid) if draggingGuids[originGuid] and origin ~= nil and targetGuids ~= nil then ownedVectors[originGuid] = { } for targetGuid, direction in pairs(targetGuids) do local target = getObjectFromGUID(targetGuid) if target != nil then if direction == BIDIRECTIONAL then addBidirectionalVector(origin, target, origin, ownedVectors[originGuid]) elseif direction == ONE_WAY then addOneWayVector(origin, target, origin, ownedVectors[originGuid]) elseif direction == INCOMING_ONE_WAY and not draggingGuids[targetGuid] then addOneWayVector(target, origin, origin, ownedVectors[originGuid]) end end end end end for ownerGuid, vectors in pairs(ownedVectors) do local card = getObjectFromGUID(ownerGuid) card.setVectorLines(vectors) end end -- Draws a bidirectional location connection between the two cards, adding the lines to do so to the -- given lines list. ---@param card1 Object One of the card objects to connect ---@param card2 Object The other card object to connect ---@param vectorOwner Object The object which these lines will be set to. Used for relative --- positioning and scaling, as well as highlighting connections during a drag operation ---@param lines Table List of vector line elements. Mutable, will be updated to add this connector function addBidirectionalVector(card1, card2, vectorOwner, lines) local cardPos1 = card1.getPosition() local cardPos2 = card2.getPosition() cardPos1.y = CONNECTION_LINE_Y cardPos2.y = CONNECTION_LINE_Y local pos1 = vectorOwner.positionToLocal(cardPos1) local pos2 = vectorOwner.positionToLocal(cardPos2) table.insert(lines, { points = { pos1, pos2 }, color = vectorOwner == self and CONNECTION_COLOR or DRAGGING_CONNECTION_COLOR, thickness = vectorOwner == self and CONNECTION_THICKNESS or DRAGGING_CONNECTION_THICKNESS, }) end -- Draws a one-way location connection between the two cards, adding the lines to do so to the -- given lines list. Arrows will point towards the target card. ---@param origin Object Origin card in the connection ---@param target Object Target card object to connect ---@param vectorOwner Object The object which these lines will be set to. Used for relative --- positioning and scaling, as well as highlighting connections during a drag operation ---@param lines Table List of vector line elements. Mutable, will be updated to add this connector function addOneWayVector(origin, target, vectorOwner, lines) -- Start with the BiDi then add the arrow lines to it addBidirectionalVector(origin, target, vectorOwner, lines) local originPos = origin.getPosition() local targetPos = target.getPosition() originPos.y = CONNECTION_LINE_Y targetPos.y = CONNECTION_LINE_Y -- Calculate card distance to be closer for horizontal positions than vertical, since cards are -- taller than they are wide local heading = Vector(originPos):sub(targetPos):heading("y") local distanceFromCard = DIRECTIONAL_ARROW_DISTANCE * 0.7 + DIRECTIONAL_ARROW_DISTANCE * 0.3 * math.abs(math.sin(math.rad(heading))) -- Calculate the three possible arrow positions. These are offset by half the arrow length to -- make them visually balanced by keeping the arrows centered, not tracking the point local midpoint = Vector(originPos):add(targetPos):scale(Vector(0.5, 0.5, 0.5)):moveTowards(targetPos, ARROW_ARM_LENGTH / 2) local closeToOrigin = Vector(originPos):moveTowards(targetPos, distanceFromCard + ARROW_ARM_LENGTH / 2) local closeToTarget = Vector(targetPos):moveTowards(originPos, distanceFromCard - ARROW_ARM_LENGTH / 2) if (originPos:distance(closeToOrigin) > originPos:distance(closeToTarget)) then addArrowLines(midpoint, originPos, vectorOwner, lines) else addArrowLines(closeToOrigin, originPos, vectorOwner, lines) addArrowLines(closeToTarget, originPos, vectorOwner, lines) end end -- Draws an arrowhead at the given position. ---@param arrowheadPosition Table Centerpoint of the arrowhead to draw (NOT the tip of the arrow) ---@param originPos Table Origin point of the connection, used to position the arrow arms ---@param vectorOwner Object The object which these lines will be set to. Used for relative --- positioning and scaling, as well as highlighting connections during a drag operation ---@param lines Table List of vector line elements. Mutable, will be updated to add this arrow function addArrowLines(arrowheadPos, originPos, vectorOwner, lines) local arrowArm1 = Vector(arrowheadPos):moveTowards(originPos, ARROW_ARM_LENGTH):sub(arrowheadPos):rotateOver("y", -1 * ARROW_ANGLE):add(arrowheadPos) local arrowArm2 = Vector(arrowheadPos):moveTowards(originPos, ARROW_ARM_LENGTH):sub(arrowheadPos):rotateOver("y", ARROW_ANGLE):add(arrowheadPos) local head = vectorOwner.positionToLocal(arrowheadPos) local arm1 = vectorOwner.positionToLocal(arrowArm1) local arm2 = vectorOwner.positionToLocal(arrowArm2) table.insert(lines, { points = { arm1, head, arm2}, color = vectorOwner == self and CONNECTION_COLOR or DRAGGING_CONNECTION_COLOR, thickness = vectorOwner == self and CONNECTION_THICKNESS or DRAGGING_CONNECTION_THICKNESS, }) end -- Move all contents on the play area (cards, tokens, etc) one slot in the given direction. Certain -- fixed objects will be ignored, as will anything the player has tagged with 'displacement_excluded' ---@param playerColor String Color of the player requesting the shift. Used solely to send an error --- message in the unlikely case that the scripting zone has been deleted function shiftContentsUp(playerColor) shiftContents(playerColor, "up") end function shiftContentsDown(playerColor) shiftContents(playerColor, "down") end function shiftContentsLeft(playerColor) shiftContents(playerColor, "left") end function shiftContentsRight(playerColor) shiftContents(playerColor, "right") end function shiftContents(playerColor, direction) local zone = getObjectFromGUID(PLAY_AREA_ZONE_GUID) if not zone then broadcastToColor("Scripting zone couldn't be found.", playerColor, "Red") return end for _, object in ipairs(zone.getObjects()) do if not (SHIFT_EXCLUSION[object.getGUID()] or object.hasTag("displacement_excluded")) then object.translate(SHIFT_OFFSETS[direction]) end end end -- Returns the current value of the investigator counter from the playmat ---@return. Number of investigators currently set on the counter function getInvestigatorCount() local investigatorCounter = getObjectFromGUID("f182ee") return investigatorCounter.getVar("val") end -- Check to see if the given object is within the bounds of the play area, based solely on the X and -- Z coordinates, ignoring height ---@param object Object Object to check ---@return. True if the object is inside the play area function isInPlayArea(object) local bounds = self.getBounds() local position = object.getPosition() -- Corners are arbitrary since it's all global - c1 goes down both axes, c2 goes up local c1 = { x = bounds.center.x - bounds.size.x / 2, z = bounds.center.z - bounds.size.z / 2} local c2 = { x = bounds.center.x + bounds.size.x / 2, z = bounds.center.z + bounds.size.z / 2} return position.x > c1.x and position.x < c2.x and position.z > c1.z and position.z < c2.z end -- Reset the play area's tracking of which cards have had tokens spawned. function resetSpawnedCards() spawnedLocationGUIDs = {} end function onScenarioChanged(scenarioName) currentScenario = scenarioName if not showLocationLinks() then broadcastToAll("Automatic location connections not available for this scenario") end end function showLocationLinks() return not LOC_LINK_EXCLUDE_SCENARIOS[currentScenario] 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. function setLimitSnapsByType(matchTypes) local snaps = self.getSnapPoints() for i, snap in ipairs(snaps) do local snapTags = snaps[i].tags if matchTypes then if snapTags == nil then snaps[i].tags = { "Location" } else table.insert(snaps[i].tags, "Location") end else snaps[i].tags = nil end end self.setSnapPoints(snaps) end -- rebuilds local snap points (could be useful in the future again) function buildSnaps() local upperleft = { x = 1.53, z = -1.09} local lowerright = {x = -1.53, z = 1.55} local snaps = {} -- creates 81 snap points, for uneven rows + columns it makes a rotation snap point for i = 1, 9 do for j = 1, 9 do local snap = {} snap.position = {} snap.position.x = round(upperleft.x - (upperleft.x - lowerright.x) * (i - 1) / 8, 3) snap.position.y = 0.1 snap.position.z = round(upperleft.z - (upperleft.z - lowerright.z) * (j - 1) / 8, 3) -- enable rotation snaps for uneven rows / columns if (i % 2 ~= 0) and (j % 2 ~= 0) then snap.rotation = {0, 0, 0} snap.rotation_snap = true end table.insert(snaps, snap) end end self.setSnapPoints(snaps) end -- utility function function round(num, numDecimalPlaces) local mult = 10^(numDecimalPlaces or 0) return math.floor(num * mult + 0.5) / mult end