-- 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("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) require("core/PlayArea") end) __bundle_register("core/PlayArea", function(require, _LOADED, __bundle_register, __bundle_modules) --------------------------------------------------------- -- 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, ["The Heart of Madness"] = 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 local missingData = {} local countedVP = {} --------------------------------------------------------- -- 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 if objType == "Deck" then table.insert(missingData, obj) end 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() if object.is_face_down then draggingGuids[pickedUpGuid] = metadata.locationBack else draggingGuids[pickedUpGuid] = metadata.locationFront end 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 isInPlayArea(card) then local metadata = JSON.decode(card.getGMNotes()) if metadata == nil then table.insert(missingData, card) else if metadata.type == "Location" then if card.is_face_down then locations[card.getGUID()] = metadata.locationBack else locations[card.getGUID()] = metadata.locationFront end -- only draw connection lines for not-excluded scenarios if showLocationLinks() then rebuildConnectionList() drawBaseConnections() end end 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, metadata) end for cardId, metadata in pairs(locations) do buildLocListByIcon(cardId, iconCardList, metadata) end -- Pair up all the icons locationConnections = { } for cardId, metadata in pairs(draggingGuids) do buildConnection(cardId, iconCardList, metadata) end for cardId, metadata in pairs(locations) do if draggingGuids[cardId] == nil then buildConnection(cardId, iconCardList, metadata) 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 ---@param locData Table A table containing the metadata for the card (for the correct side) function buildLocListByIcon(cardId, iconCardList, locData) 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], cardId) 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. ---@param locData Table A table containing the metadata for the card (for the correct side) function buildConnection(cardId, iconCardList, locData) if locData ~= nil and locData.connections ~= nil then locationConnections[cardId] = { } 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][cardId] == ONE_WAY or locationConnections[connectedGuid][cardId] == BIDIRECTIONAL) then locationConnections[connectedGuid][cardId] = BIDIRECTIONAL locationConnections[cardId][connectedGuid] = nil else if locationConnections[connectedGuid] == nil then locationConnections[connectedGuid] = { } end locationConnections[cardId][connectedGuid] = ONE_WAY locationConnections[connectedGuid][cardId] = INCOMING_ONE_WAY end end end end 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 Wait.time(drawBaseConnections, 0.1) 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 -- Updates the current value of the investigator counter from the playmat ---@param count Number of investigators to set on the counter function setInvestigatorCount(count) local investigatorCounter = getObjectFromGUID("f182ee") return investigatorCounter.call("updateVal", count) 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 -- count victory points on locations in play area ---@return. Returns the total amount of VP found in the play area function countVP() local totalVP = 0 for cardId, metadata in pairs(locations) do if metadata ~= nil then local cardVP = tonumber(metadata.victory) or 0 if cardVP ~= 0 and not cardHasClues(cardId) then totalVP = totalVP + cardVP if cardVP >0 then table.insert(countedVP, getObjectFromGUID(cardId)) end end end end return totalVP end -- checks if a card has clues on it, returns true if clues are on it ---@param cardId String GUID of the card to check for clues function cardHasClues(cardId) local card = getObjectFromGUID(cardId) for _, v in ipairs(searchOnObj(card)) do local obj = v.hit_object if obj.memo == "clueDoom" and obj.is_face_down == false then return true end end return false end -- searches on an object (by using its bounds) ---@param obj Object Object to search on function searchOnObj(obj) return Physics.cast({ direction = { 0, 1, 0 }, max_distance = 0.5, type = 3, size = obj.getBounds().size, origin = obj.getPosition() }) end -- highlights all locations in the play area without metadata ---@param state Boolean True if highlighting should be enabled function highlightMissingData(state) for i, obj in pairs(missingData) do if obj ~= nil then if state then obj.highlightOff("Red") else obj.highlightOn("Red") end else missingData[i] = nil end end end -- highlights all locations in the play area with VP ---@param state Boolean True if highlighting should be enabled function highlightCountedVP(state) for i, obj in pairs(countedVP) do if obj ~= nil then if state then obj.highlightOff("Green") else obj.highlightOn("Green") end else countedVP[i] = nil end end 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 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) } } -- stateIDs for the multi-stated resource tokens local stateTable = { ["resource"] = 1, ["ammo"] = 2, ["bounty"] = 3, ["charge"] = 4, ["evidence"] = 5, ["secret"] = 6, ["supply"] = 7 } -- 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 ---@param subType Number Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown, subType) 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, subType) 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 ---@param subType Number Subtype of token to spawn. This will only differ from the tokenName for resource tokens TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown, subType) 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 -- handling for not provided subtype (for example when spawning from custom data helpers) if subType == nil then subType = "" end -- this is used to load the correct state for additional resource tokens (e.g. "Ammo") local callback = nil local stateID = stateTable[string.lower(subType)] if tokenType == "resource" and stateID ~= nil and stateID ~= 1 then callback = function(spawned) spawned.setState(stateID) end end for i = 1, tokenCount do TokenManager.spawnToken(offsets[i], tokenType, card.getRotation(), callback) 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 -- Checks a card for metadata to maybe replenish it ---@param card Object Card object to be replenished ---@param uses Table The already decoded metadata.uses (to avoid decoding again) ---@param mat Object The playmat the card is placed on (for rotation and casting) TokenManager.maybeReplenishCard = function(card, uses, mat) -- TODO: support for cards with multiple uses AND replenish (as of yet, no official card needs that) if uses[1].count and uses[1].replenish then internal.replenishTokens(card, uses, mat) end 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 -- go through tokens to spawn local type, token, tokenCount 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 -- 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, type) 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 row = math.floor(1 + (i - 1) / 4) local column = (i - 1) % 4 table.insert(cluePositions, Vector(pos.x + 1.5 - 0.55 * row, pos.y + 0.15, pos.z - 0.825 + 0.55 * column)) end return cluePositions end ---@param card Object Card object to be replenished ---@param uses Table The already decoded metadata.uses (to avoid decoding again) ---@param mat Object The playmat the card is placed on (for rotation and casting) internal.replenishTokens = function(card, uses, mat) local cardPos = card.getPosition() -- don't continue for cards on the deck (Norman) or in the discard pile if mat.positionToLocal(cardPos).x < -1 then return end -- get current amount of resource tokens on the card local search = internal.searchOnCard(cardPos, card.getRotation()) local clickableResourceCounter = nil local foundTokens = 0 for _, obj in ipairs(search) do local obj = obj.hit_object local memo = obj.getMemo() if (stateTable[memo] or 0) > 0 then foundTokens = foundTokens + math.abs(obj.getQuantity()) obj.destruct() elseif memo == "resourceCounter" then foundTokens = obj.getVar("val") clickableResourceCounter = obj break end end -- this is the theoretical new amount of uses (to be checked below) local newCount = foundTokens + uses[1].replenish -- if there are already more uses than the replenish amount, keep them if foundTokens > uses[1].count then newCount = foundTokens -- only replenish up until the replenish amount elseif newCount > uses[1].count then newCount = uses[1].count end -- update the clickable counter or spawn a group of tokens if clickableResourceCounter then clickableResourceCounter.call("updateVal", newCount) else TokenManager.spawnTokenGroup(card, uses[1].token, newCount, _, uses[1].type) end end -- searches on a card (standard size) and returns the result ---@param position Table Position of the card ---@param rotation Table Rotation of the card internal.searchOnCard = function(position, rotation) return Physics.cast({ origin = position, direction = {0, 1, 0}, orientation = rotation, type = 3, size = { 2.5, 0.5, 3.5 }, max_distance = 1, debug = false }) end return TokenManager end end) __bundle_register("core/PlayAreaApi", function(require, _LOADED, __bundle_register, __bundle_modules) do local PlayAreaApi = { } local PLAY_AREA_GUID = "721ba2" local IMAGE_SWAPPER = "b7b45b" -- 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 -- Updates the current value of the investigator counter from the playmat ---@param count Number of investigators to set on the counter PlayAreaApi.setInvestigatorCount = function(count) return getObjectFromGUID(PLAY_AREA_GUID).call("setInvestigatorCount", count) 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 PlayAreaApi.getSurface = function() return getObjectFromGUID(PLAY_AREA_GUID).getCustomObject().image end PlayAreaApi.updateSurface = function(url) return getObjectFromGUID(IMAGE_SWAPPER).call("updateSurface", url) end return PlayAreaApi end end) return __bundle_require("__root")