diff --git a/src/arkhamdb/DeckImporterMain.ttslua b/src/arkhamdb/DeckImporterMain.ttslua index 5d3c1343..79e6f2a8 100644 --- a/src/arkhamdb/DeckImporterMain.ttslua +++ b/src/arkhamdb/DeckImporterMain.ttslua @@ -202,8 +202,7 @@ end -- Returns the simple name of a card given its ID. This will find the card and strip any trailing -- SCED-specific suffixes such as (Taboo) or (Level) function getCardName(cardId) - local configuration = getConfiguration() - local allCardsBag = getObjectFromGUID(configuration.card_bag_guid) + local allCardsBag = getObjectFromGUID("15bb07") local card = allCardsBag.call("getCardById", { id = cardId }) if (card ~= nil) then local name = card.data.Nickname diff --git a/src/core/Global.ttslua b/src/core/Global.ttslua index ff4433c3..64389386 100644 --- a/src/core/Global.ttslua +++ b/src/core/Global.ttslua @@ -1,3 +1,5 @@ +local tokenManager = require("core/token/TokenManager") + --------------------------------------------------------- -- general setup --------------------------------------------------------- @@ -331,32 +333,11 @@ end -- token spawning --------------------------------------------------------- +-- DEPRECATED. Use TokenManager instead. +-- Spawns a single token. +---@param params Table. Array with arguments to the method. 1 = position, 2 = type, 3 = rotation function spawnToken(params) - local position = params[1] - local tokenType = params[2] - local rotation = params[3] or {0, 270, 0} - local tokenData = TOKEN_DATA[tokenType] - - if tokenData == nil then - error("no token data found for '" .. tokenType .. "'") - end - - local token = spawnObject({ - type = 'Custom_Token', - position = position, - rotation = rotation - }) - - token.setCustomObject({ - image = tokenData['image'], - thickness = 0.3, - merge_distance = 5, - stackable = true - }) - - token.use_snap_points = false - token.scale(tokenData['scale']) - return token + return tokenManager.spawnToken(params[1], params[2], params[3]) end --------------------------------------------------------- @@ -614,7 +595,7 @@ end -- Content Importing and XML functions --------------------------------------------------------- -local source_repo = 'https://raw.githubusercontent.com/seth-sced/loadable-objects/main' +local source_repo = 'https://raw.githubusercontent.com/chr1z93/loadable-objects/main' local library = nil local request_obj @@ -640,7 +621,7 @@ end function onClick_toggleUi(_, title) UI.hide('optionPanel') UI.hide('load_ui') - + -- when same button is clicked or close window button is pressed, don't open UI if UI.getValue('title') ~= title and title ~= 'Hidden' then UI.setValue('title', title) @@ -836,7 +817,7 @@ function applyOptionPanelChange(id, state) -- update master clue counter getObjectFromGUID("4a3aa4").setVar("useClickableCounters", state) - + -- option: Show token arranger elseif id == "showTokenArranger" then -- delete previously pulled out tokens @@ -847,7 +828,7 @@ function applyOptionPanelChange(id, state) -- option: Show clean up helper elseif id == "showCleanUpHelper" then optionPanel[id] = spawnOrRemoveHelper(state, "Clean Up Helper", {-68, 1.6, 35.5}) - + -- option: Show hand helper for each player elseif id == "showHandHelper" then optionPanel[id][1] = spawnOrRemoveHelper(state, "Hand Helper", {-50.84, 1.6, 7.02}, {0, 270, 0}, "White") @@ -928,7 +909,7 @@ function removeHelperObject(name) ["Chaos Bag Manager"] = "showChaosBagManager", ["jaqenZann's Navigation Overlay"] = "showNavigationOverlay" } - + local data = optionPanel[referenceTable[name]] -- if there is a GUID stored, remove that object diff --git a/src/core/MythosArea.ttslua b/src/core/MythosArea.ttslua index d124c7e0..69991407 100644 --- a/src/core/MythosArea.ttslua +++ b/src/core/MythosArea.ttslua @@ -1,4 +1,14 @@ local playAreaApi = require("core/PlayAreaApi") +local tokenSpawnTracker = require("core/token/TokenSpawnTrackerApi") + +local ENCOUNTER_DECK_AREA = { + upperLeft = { x = 0.9, z = 0.42 }, + lowerRight = { x = 0.86, z = 0.38 }, +} +local ENCOUNTER_DISCARD_AREA = { + upperLeft = { x = 1.62, z = 0.42 }, + lowerRight = { x = 1.58, z = 0.38 }, +} local currentScenario @@ -15,8 +25,7 @@ function onSave() }) end --- TTS event handler. Checks for a scenrio card, extracts the scenario name from the description, --- and fires it to the relevant listeners. +-- TTS event handler. Handles scenario name event triggering and encounter card token resets. function onCollisionEnter(collisionInfo) local object = collisionInfo.collision_object if object.getName() == "Scenario" then @@ -25,9 +34,38 @@ function onCollisionEnter(collisionInfo) fireScenarioChangedEvent() end end + + local localPos = self.positionToLocal(object.getPosition()) + if inArea(localPos, ENCOUNTER_DECK_AREA) or inArea(localPos, ENCOUNTER_DISCARD_AREA) then + tokenSpawnTracker.resetTokensSpawned(object.getGUID()) + end +end + +-- Listens for cards entering the encounter deck or encounter discard, and resets the spawn state +-- for the cards when they do. +function onObjectEnterContainer(container, object) + Wait.frames(function() resetTokensIfInDeckZone(container, object) end, 1) +end + +function resetTokensIfInDeckZone(container, object) + local localPos = self.positionToLocal(container.getPosition()) + if inArea(localPos, ENCOUNTER_DECK_AREA) or inArea(localPos, ENCOUNTER_DISCARD_AREA) then + tokenSpawnTracker.resetTokensSpawned(object.getGUID()) + end end function fireScenarioChangedEvent() - log("Firing") playAreaApi.onScenarioChanged(currentScenario) end + +-- Simple method to check if the given point is in a specified area. Local use only, +---@param point Vector. Point to check, only x and z values are relevant +---@param bounds Table. Defined area to see if the point is within. See MAIN_PLAY_AREA for sample +-- bounds definition. +---@return Boolean. True if the point is in the area defined by bounds +function inArea(point, bounds) + return (point.x < bounds.upperLeft.x + and point.x > bounds.lowerRight.x + and point.z < bounds.upperLeft.z + and point.z > bounds.lowerRight.z) +end diff --git a/src/core/PlayArea.ttslua b/src/core/PlayArea.ttslua index 5539fdbf..2921797c 100644 --- a/src/core/PlayArea.ttslua +++ b/src/core/PlayArea.ttslua @@ -1,3 +1,4 @@ +local tokenManager = require("core/token/TokenManager") --------------------------------------------------------- -- general setup --------------------------------------------------------- @@ -23,8 +24,6 @@ local SHIFT_EXCLUSION = { local INVESTIGATOR_COUNTER_GUID = "f182ee" local PLAY_AREA_ZONE_GUID = "a2f932" -local clueData = {} -spawnedLocationGUIDs = {} local currentScenario --------------------------------------------------------- @@ -33,7 +32,6 @@ local currentScenario function onSave() return JSON.encode({ - spawnedLocs = spawnedLocationGUIDs, currentScenario = currentScenario }) end @@ -41,21 +39,8 @@ end function onLoad(saveState) -- records locations we have spawned clues for local saveData = JSON.decode(saveState) or {} - spawnedLocationGUIDs = saveData.spawnedLocs or { } currentScenario = saveData.currentScenario - local TOKEN_DATA = Global.getTable('TOKEN_DATA') - clueData = { - thickness = 0.1, - stackable = true, - type = 2, - image = TOKEN_DATA.clue.image, - image_bottom = TOKEN_DATA.doom.image - } - - local dataHelper = getObjectFromGUID('708279') - LOCATIONS = dataHelper.getTable('LOCATIONS_DATA') - self.interactable = DEBUG Wait.time(function() COLLISION_ENABLED = true end, 1) end @@ -64,72 +49,13 @@ function log(message) if DEBUG then print(message) end end ---------------------------------------------------------- --- clue spawning ---------------------------------------------------------- - --- try the compound key then the name alone as default -function getLocation(object) - return LOCATIONS[object.getName() .. '_' .. object.getGUID()] or LOCATIONS[object.getName()] -end - --- Return the number of clues to spawn on this location -function getClueCount(object, isFaceDown, playerCount) - local details = getLocation(object) - if details == nil then - error('attempted to get clue for unexpected object: ' .. object.getName()) - end - - log(object.getName() .. ' : ' .. details['type'] .. ' : ' .. details['value'] .. ' : ' .. details['clueSide']) - if ((isFaceDown and details['clueSide'] == 'back') or (not isFaceDown and details['clueSide'] == 'front')) then - if details['type'] == 'fixed' then - return details['value'] - elseif details['type'] == 'perPlayer' then - return details['value'] * playerCount - end - error('unexpected location type: ' .. details['type']) - end - return 0 -end - -function spawnCluesAtLocation(clueCount, object) - if spawnedLocationGUIDs[object.getGUID()] ~= nil then - error('tried to spawn clue for already spawned location:' .. object.getName()) - end - - log('spawning clues for ' .. object.getName() .. '_' .. object.getGUID()) - log('player count is ' .. getInvestigatorCount() .. ', clue count is ' .. clueCount) - - -- mark this location as spawned, can't happen again - spawnedLocationGUIDs[object.getGUID()] = true - - -- spawn clues (starting top right, moving to the next row after 4 clues) - local pos = object.getPosition() - for i = 1, clueCount do - local row = math.floor(1 + (i - 1) / 4) - local column = (i - 1) % 4 - spawnClue({ pos.x + 1.5 - 0.55 * row, pos.y, pos.z - 0.825 + 0.55 * column }) - end -end - -function spawnClue(position) - local token = spawnObject({ - position = position, - rotation = { 3.88, -90, 0.24 }, - type = 'Custom_Tile' - }) - - token.setCustomObject(clueData) - token.scale { 0.25, 1, 0.25 } - token.use_snap_points = false -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) - custom_data_helper_guid = args[1] - local custom_data_helper = getObjectFromGUID(args[1]) - - for k, v in pairs(custom_data_helper.getTable("LOCATIONS_DATA")) do - LOCATIONS[k] = v + local customDataHelper = getObjectFromGUID(args[1]) + if customDataHelper ~= nil then + tokenManager.addLocationData(customDataHelper.getTable("LOCATIONS_DATA")) end end @@ -137,13 +63,23 @@ function onCollisionEnter(collision_info) if not COLLISION_ENABLED then return end -- check if we should spawn clues here and do so according to playercount - local object = collision_info.collision_object - if getLocation(object) ~= nil and spawnedLocationGUIDs[object.getGUID()] == nil then - local clueCount = getClueCount(object, object.is_face_down, getInvestigatorCount()) - if clueCount > 0 then spawnCluesAtLocation(clueCount, object) end + local card = collision_info.collision_object + if shouldSpawnTokens(card) then + tokenManager.spawnForCard(card) end end +function shouldSpawnTokens(card) + local metadata = JSON.decode(card.getGMNotes()) + if metadata == nil then + return tokenManager.hasLocationData(card) ~= nil + end + return metadata.type == "Location" + or metadata.type == "Enemy" + or metadata.type == "Treachery" + or metadata.weakness +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' diff --git a/src/core/token/TokenManager.ttslua b/src/core/token/TokenManager.ttslua new file mode 100644 index 00000000..cfaae2cd --- /dev/null +++ b/src/core/token/TokenManager.ttslua @@ -0,0 +1,446 @@ +do + local tokenSpawnTracker = require("core/token/TokenSpawnTrackerApi") + local playAreaApi = require("core/PlayAreaApi") + + local PLAYER_CARD_TOKEN_OFFSETS = { + [1] = { + Vector(0, 8, -0.2) + }, + [2] = { + Vector(0.4, 20, -0.2), + Vector(-0.4, 20, -0.2) + }, + [3] = { + Vector(0, 8, -0.9), + Vector(0.4, 8, -0.2), + Vector(-0.4, 8, -0.2) + }, + [4] = { + Vector(0.4, 10, -0.9), + Vector(-0.4, 10, -0.9), + Vector(0.4, 10, -0.2), + Vector(-0.4, 10, -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) + } + } + + local SOURCE_BAG_GUIDS = { + damage = "480bda", + horror = "c3ecf4", + resource = "9fadf9", + doom = "47ffc3", + clue = "31fa39", + } + + 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. 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 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 An offset for the z-value of this group of tokens + TokenManager.spawnTokenGroup = function(card, tokenType, tokenCount, shiftDown) + if tokenType == "damage" or tokenType == "horror" then + TokenManager.spawnCounterToken(card, tokenType, tokenCount, shiftDown) + else + TokenManager.spawnMultipleTokens(card, tokenType, tokenCount, shiftDown) + 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 Value to set the damage/horror to + ---@param shiftDown An offset for the z-value of this group of tokens + TokenManager.spawnCounterToken = function(card, tokenType, tokenValue, shiftDown) + if tokenCount < 1 or tokenCount > 50 then + return + end + local offsets = PLAYER_CARD_TOKEN_OFFSETS[1] + 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 + local pos = card.positionToWorld(offsets[1]) + pos.y = card.getPosition().y + 0.15 + TokenManager.spawnToken(pos, tokenType, card.getRotation(), function(spawned) + spawned.setState(tokenValue) + 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 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 An offset for the z-value of this group of tokens + TokenManager.spawnMultipleTokens = function(card, tokenType, tokenCount, shiftDown) + if tokenCount < 1 or tokenCount > 12 then + return + end + local offsets + if tokenType == "clue" then + offsets = internal.buildClueOffsets(card, tokenCount) + else + offsets = { } + 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 + -- 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') + end + + for i = 1, tokenCount do + TokenManager.spawnToken(offsets[i], tokenType, card.getRotation()) + 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 A callback function triggered after the new token is spawned + TokenManager.spawnToken = function(position, tokenType, rotation, callback) + if SOURCE_BAG_GUIDS[tokenType] == nil then + error("Unknown token type '" .. tokenType .. "'") + return + end + local sourceBag = getObjectFromGUID(SOURCE_BAG_GUIDS[tokenType]) + if sourceBag == nil then + error("No token source for '" .. tokenType .. "'") + return + end + -- All the source bags are infinite, so we just grab the first object + local tokenTemplate = sourceBag.getData().ContainedObjects[1] + -- Take ONLY the Y-value for rotation, so we don't flip the token coming out of the bag + local tokenRotation = rotation or { x = 0, y = 270, z = 0 } + tokenTemplate.Transform.rotY = tokenRotation.y + log("x=" .. tokenTemplate.Transform.rotX .. ",y=" .. tokenTemplate.Transform.rotY .. "z=" .. tokenTemplate.Transform.rotZ) + return spawnObjectData({ + data = tokenTemplate, + position = position, + callback_function = callback + }) + 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) + return internal.getLocationData(card) ~= nil + 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 + local type = nil + local token = nil + local tokenCount = 0 + -- Uses structure underwent a breaking change in 2.4.0, have to check to see if this is + -- a single entry or an array. This is ugly and duplicated, but impossible to replicate the + -- multi-spawn vs. single spawn otherwise. TODO: Clean this up when 2.4.0 has been out long + -- enough that saved decks don't have old data + if uses.count != nil then + type = cardMetadata.uses.type + token = cardMetadata.uses.token + tokenCount = cardMetadata.uses.count + if extraUses ~= nil and extraUses[type] ~= nil then + tokenCount = tokenCount + extraUses[type] + end + log("Spawning single use tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token) + TokenManager.spawnTokenGroup(card, token, tokenCount) + else + for i, useInfo in ipairs(uses) do + type = useInfo.type + token = useInfo.token + tokenCount = (useInfo.count or 0) + + (useInfo.countPerInvestigator or 0) * playAreaApi.getInvestigatorCount() + if extraUses ~= nil and extraUses[type] ~= nil then + tokenCount = tokenCount + extraUses[type] + end + log("Spawning use array tokens for "..card.getName()..'['..card.getDescription()..']: '..tokenCount.."x "..token) + -- Shift each spawned group after the first down so they don't pile on each other + TokenManager.spawnTokenGroup(card, token, tokenCount, (i - 1) * 0.6) + end + 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 * playAreaApi.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 + + return TokenManager + +end diff --git a/src/core/token/TokenSpawnTracker.ttslua b/src/core/token/TokenSpawnTracker.ttslua new file mode 100644 index 00000000..5bb0b3fb --- /dev/null +++ b/src/core/token/TokenSpawnTracker.ttslua @@ -0,0 +1,85 @@ +local spawnedCardGuids = { } + +local HAND_ZONES = { } +HAND_ZONES["c506bf"] = true -- White +HAND_ZONES["67ce9a"] = true -- Green +HAND_ZONES["cbc751"] = true -- Orange +HAND_ZONES["57c22c"] = true -- Red + +function onLoad(saveState) + if saveState ~= nil then + local saveTable = JSON.decode(saveState) or { } + spawnedCardGuids = saveTable.cards or { } + end + + createResetMenuItems() +end + +function onSave() + return JSON.encode({ + cards = spawnedCardGuids + }) +end + +function createResetMenuItems() + self.addContextMenuItem("Reset All", resetAll) + self.addContextMenuItem("Reset Locations", resetAllLocations) + self.addContextMenuItem("Reset Player Cards", resetAllAssetAndEvents) +end + +function hasSpawnedTokens(cardGuid) + return spawnedCardGuids[cardGuid] == true +end + +function markTokensSpawned(cardGuid) + spawnedCardGuids[cardGuid] = true +end + +function resetTokensSpawned(cardGuid) + spawnedCardGuids[cardGuid] = nil +end + +function resetAllAssetAndEvents() + local resetList = { } + for cardGuid, _ in pairs(spawnedCardGuids) do + local card = getObjectFromGUID(cardGuid) + if card ~= nil then + local cardMetadata = JSON.decode(card.getGMNotes()) or { } + -- Check this by type rather than the PlayerCard tag so we don't reset weaknesses + if cardMetadata.type == "Asset" or cardMetadata.type == "Event" then + resetList[cardGuid] = true + end + end + end + for cardGuid, _ in pairs(resetList) do + spawnedCardGuids[cardGuid] = nil + end +end + +function resetAllLocations() + local resetList = { } + for cardGuid, _ in pairs(spawnedCardGuids) do + local card = getObjectFromGUID(cardGuid) + if card ~= nil then + local cardMetadata = JSON.decode(card.getGMNotes()) or { } + -- Check this by type rather than the PlayerCard tag so we don't reset weaknesses + if cardMetadata.type == "Location" then + resetList[cardGuid] = true + end + end + end + for cardGuid, _ in pairs(resetList) do + spawnedCardGuids[cardGuid] = nil + end +end + +function resetAll() + spawnedCardGuids = { } +end + +-- Listener to reset card token spawns when they enter a hand. +function onObjectEnterZone(zone, enterObject) + if HAND_ZONES[zone.getGUID()] then + resetTokensSpawned(enterObject.getGUID()) + end +end diff --git a/src/core/token/TokenSpawnTrackerApi.ttslua b/src/core/token/TokenSpawnTrackerApi.ttslua new file mode 100644 index 00000000..bd9a559e --- /dev/null +++ b/src/core/token/TokenSpawnTrackerApi.ttslua @@ -0,0 +1,31 @@ +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 diff --git a/src/playermat/Playmat.ttslua b/src/playermat/Playmat.ttslua index e2216c84..64da5963 100644 --- a/src/playermat/Playmat.ttslua +++ b/src/playermat/Playmat.ttslua @@ -1,5 +1,7 @@ +local tokenManager = require("core/token/TokenManager") + -- set true to enable debug logging and show Physics.cast() -local DEBUG = false +local DEBUG = true -- we use this to turn off collision handling until onLoad() is complete local COLLISION_ENABLED = false @@ -36,6 +38,16 @@ local INVESTIGATOR_AREA = { z = -0.0805, } } +local THREAT_AREA = { + upperLeft = { + x = 1.53, + z = -0.34 + }, + lowerRight = { + x = -1.13, + z = -0.92, + } +} local PLAY_ZONE_ROTATION = self.getRotation() @@ -58,9 +70,6 @@ end function onLoad(save_state) self.interactable = DEBUG - DATA_HELPER = getObjectFromGUID('708279') - PLAYER_CARDS = DATA_HELPER.getTable('PLAYER_CARD_DATA') - PLAYER_CARD_TOKEN_OFFSETS = DATA_HELPER.getTable('PLAYER_CARD_TOKEN_OFFSETS') TRASHCAN = getObjectFromGUID(TRASHCAN_GUID) STAT_TRACKER = getObjectFromGUID(STAT_TRACKER_GUID) @@ -387,38 +396,6 @@ function shuffleDiscardIntoDeck() discardPile = nil end -function spawnTokenOn(object, offsets, tokenType) - local tokenPosition = object.positionToWorld(offsets) - spawnToken(tokenPosition, tokenType) -end - --- Spawn a group of tokens of the given type on the object --- @param object Object to spawn the tokens on --- @param tokenType Type of token to be spawned --- @param tokenCount Number of tokens to spawn --- @param shiftDown Amount to shift this group down to avoid spawning multiple token groups on --- top of each other. Negative values are allowed, and will move the group up instead. This is --- a static value and is unaware of how many tokens were spawned previously; callers should --- ensure the proper shift. -function spawnTokenGroup(object, tokenType, tokenCount, shiftDown) - if (tokenCount < 1 or tokenCount > 12) then return end - local offsets = PLAYER_CARD_TOKEN_OFFSETS[tokenCount] - if shiftDown ~= nil then - -- Copy the offsets to make sure we don't change the static values - local baseOffsets = offsets - offsets = { } - for i, baseOffset in ipairs(baseOffsets) do - offsets[i] = baseOffset - offsets[i][3] = offsets[i][3] + shiftDown - end - end - if offsets == nil then error("couldn't find offsets for " .. tokenCount .. ' tokens') end - - for i = 1, tokenCount do - spawnTokenOn(object, offsets[i], tokenType) - end -end - --------------------------------------------------------- -- playmat token spawning --------------------------------------------------------- @@ -457,133 +434,102 @@ function replenishTokens(card, count, replenish) local newCount = foundTokens + replenish if newCount > count then newCount = count end - spawnTokenGroup(card, "resource", newCount) + tokenManager.spawnTokenGroup(card, "resource", newCount) end -function getPlayerCardData(object) - return PLAYER_CARDS[object.getName()..':'..object.getDescription()] or PLAYER_CARDS[object.getName()] -end - -function shouldSpawnTokens(object) - -- don't spawn tokens if in doubt, this should only ever happen onLoad and prevents respawns - local spawned = DATA_HELPER.call('getSpawnedPlayerCardGuid', {object.getGUID()}) - local hasDataHelperData = getPlayerCardData(object) - local cardMetadata = JSON.decode(object.getGMNotes()) or {} - local hasUses = cardMetadata.uses ~= nil - return not spawned and (hasDataHelperData or hasUses) -end - -function markSpawned(object) - local saved = DATA_HELPER.call('setSpawnedPlayerCardGuid', {object.getGUID(), true}) - if not saved then error('attempt to mark player card spawned before data loaded') end -end - --- contains position and amount of boxes for the upgradesheets that change uses --- Alchemical Distillation, Damning Testimony, Living Ink and Hyperphysical Shotcaster -local customizationsTable = { - ["09040"] = {5, 2}, - ["09059"] = {2, 2}, - ["09079"] = {3, 2}, - ["09119"] = {6, 4} -} - -function spawnTokensFor(object) - local cardMetadata = JSON.decode(object.getGMNotes()) or {} - local type = nil - local token = nil - local tokenCount = 0 - if cardMetadata.uses ~= nil then - -- Uses structure underwent a breaking change in 2.4.0, have to check to see if this is - -- a single entry or an array. This is ugly and duplicated, but impossible to replicate the - -- multi-spawn vs. single spawn otherwise. TODO: Clean this up when 2.4.0 has been out long - -- enough that saved decks don't have old data - if cardMetadata.uses.count != nil then - type = cardMetadata.uses.type - token = cardMetadata.uses.token - tokenCount = cardMetadata.uses.count - if activeInvestigatorId == "03004" and type == "Charge" then tokenCount = tokenCount + 1 end - - log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) - spawnTokenGroup(object, token, tokenCount) - else - for i, useInfo in ipairs(cardMetadata.uses) do - type = useInfo.type - token = useInfo.token - tokenCount = useInfo.count - - -- additional uses for certain customizable cards (by checking the upgradesheets) - if customizationsTable[cardMetadata.id] ~= nil then - for _, obj in ipairs(searchArea(PLAY_ZONE_POSITION, PLAY_ZONE_SCALE)) do - local obj = obj.hit_object - if obj.tag == "Card" then - local notes = JSON.decode(obj.getGMNotes()) or {} - if notes.id == (cardMetadata.id .. "-c") then - local pos = customizationsTable[cardMetadata.id][1] - local boxes = customizationsTable[cardMetadata.id][2] - if obj.getVar("markedBoxes")[pos] == boxes then tokenCount = tokenCount + 2 end - break - end - end +function syncCustomizableMetadata(card) + local cardMetadata = JSON.decode(card.getGMNotes()) or { } + if cardMetadata ~= nil and cardMetadata.customizations ~= nil then + for _, obj in ipairs(searchArea(PLAY_ZONE_POSITION, PLAY_ZONE_SCALE)) do + local obj = obj.hit_object + local notes = JSON.decode(obj.getGMNotes()) or { } + if notes.id == (cardMetadata.id .. "-c") then + for i, customization in ipairs(cardMetadata.customizations) do + if obj.getVar("markedBoxes")[i] == customization.xp + and customization.replaces ~= nil + and customization.replaces.uses ~= nil then + cardMetadata.uses = customization.replaces.uses + card.setGMNotes(JSON.encode(cardMetadata)) end end - - -- additional charge for Akachi - if activeInvestigatorId == "03004" and type == "Charge" then tokenCount = tokenCount + 1 end - - log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) - -- Shift each spawned group after the first down so they don't pile on each other - spawnTokenGroup(object, token, tokenCount, (i - 1) * 0.6) - end - end - else - local data = getPlayerCardData(object) - token = data['tokenType'] - tokenCount = data['tokenCount'] - log("Spawning tokens for "..object.getName()..'['..object.getDescription()..']: '..tokenCount.."x "..token) - spawnTokenGroup(object, token, tokenCount) - end - markSpawned(object) -end - -function resetSpawnState() - local zone = getObjectFromGUID(zoneID) - if zone == nil then return end - - for _, object in ipairs(zone.getObjects()) do - if object.tag == "Card" then - unmarkSpawned(object.getGUID(), true) - elseif object.tag == "Deck" then - local cards = object.getObjects() - for _, v in ipairs(cards) do - unmarkSpawned(v.guid) end end end end -function unmarkSpawned(guid, force) - if not force and getObjectFromGUID(guid) ~= nil then return end - DATA_HELPER.call('setSpawnedPlayerCardGuid', {guid, false}) +function spawnTokensFor(object) + local extraUses = { } + if activeInvestigatorId == "03004" then + extraUses["Charge"] = 1 + end + + tokenManager.spawnForCard(object, extraUses) 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 + syncCustomizableMetadata(object) + if isInDeckZone(object) then + tokenManager.resetTokensSpawned(object) + elseif shouldSpawnTokens(object) then spawnTokensFor(object) end end +function shouldSpawnTokens(card) + if card.is_face_down then + return false + end + local localCardPos = self.positionToLocal(card.getPosition()) + local metadata = JSON.decode(card.getGMNotes()) + -- If no metadata we don't know the type, so only spawn in the main area + if metadata == nil then + return inArea(localCardPos, MAIN_PLAY_AREA) + end + -- Spawn tokens for assets and events on the main area, and all encounter types in the threat area + if inArea(localCardPos, MAIN_PLAY_AREA) + and (metadata.type == "Asset" + or metadata.type == "Event") then + return true + end + if inArea(localCardPos, THREAT_AREA) + and (metadata.type == "Treachery" + or metadata.type == "Enemy" + or metadata.weakness) then + return true + end + return false +end + +function onObjectEnterContainer(container, object) + Wait.frames(function() resetTokensIfInDeckZone(container, object) end, 1) +end + +function resetTokensIfInDeckZone(container, object) + if isInDeckZone(container) then + tokenManager.resetTokensSpawned(object) + end +end + +function isInDeckZone(checkObject) + local deckZone = getObjectFromGUID(zoneID) + if deckZone == nil then + return false + end + for _, obj in ipairs(deckZone.getObjects()) do + if obj == checkObject then + return true + end + end + + return false +end + --------------------------------------------------------- -- investigator ID grabbing and stat tracker --------------------------------------------------------- @@ -655,10 +601,6 @@ 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 - -- Sets this playermat's draw 1 button to visible ---@param visible Boolean. Whether the draw 1 button should be visible function showDrawButton(visible) @@ -680,7 +622,7 @@ function showDrawButton(visible) -- remove the "Draw 1" button else local buttons = self.getButtons() - for i = 1, #buttons do + for i = 1, #buttons do if buttons[i].label == "Draw 1" then self.removeButton(buttons[i].index) end @@ -721,7 +663,7 @@ function clickableClues(showCounter) local pos = self.positionToWorld({x = -1.12, y = 0.05, z = 0.7}) for i = 1, clueCount do pos.y = pos.y + 0.045 * i - spawnToken(pos, "clue") + TokenManager.spawnToken(pos, "clue", self.getRotation()) end end end @@ -802,9 +744,7 @@ end -- called by custom data helpers to add player card data ---@param args table Contains only one entry, the GUID of the custom data helper function updatePlayerCards(args) - local custom_data_helper = getObjectFromGUID(args[1]) - data_player_cards = custom_data_helper.getTable("PLAYER_CARD_DATA") - for k, v in pairs(data_player_cards) do - PLAYER_CARDS[k] = v - end + local customDataHelper = getObjectFromGUID(args[1]) + local playerCardData = customDataHelper.getTable("PLAYER_CARD_DATA") + tokenManager.addPlayerCardData(playerCardData) end