SteamDB

» » Урок 5. Колоды и карты

Урок 5. Колоды и карты

Введение

Колоды и мешки имеют особую механику в Tabletop Simulator. Я буду говорить только о колодах (а мешки и подобные контейнеры работают аналогично). Главная проблема в том, что карты в колоде не представлены объектами, с которыми можно взаимодействовать. То есть карты буквально исчезают при складывании в колоду, а потом появляются, когда кто-то берёт эти карты из колоды.

Проблема действительно существенная. Потому что даже если админ будет очень аккуратным и не будет удалять карты, то ничто не помешает любому игроку сложить две карты вместе - они станут колодой из двух карт и "исчезнут".

Здесь я попытаюсь придумать универсальное решение для этой проблемы. И так как проблема сложная, то и решения будут не простыми.

ОглавлениеУрок 1. Скприты Lua для TTS
Урок 2. Инструменты
Урок 3. Раскраска API для Notepad++
Урок 4. Отложенные задачи во времени
Урок 5. Колоды и карты

Проверка существования объекта (карты)

Это просто.
local obj = getObjectFromGUID(guid) Мы просто пытаемся получить ссылку на объект, используя его guid. Если получаем nil, то объекта с таким guid уже нет. Правда, нам нужно знать сам guid, но это не проблема.

Проблема в другом. ЛЮБОЙ вызов API функции будет стоить нам гораздо дороже, чем вызов своей локальной функции. Ещё можно упомянуть, что конструкции языка дешевле вызова любых функций (даже pairs), но это уже тонкости. Главное, что при передаче управления движку игры, мы несём большие накладные расходы.

Представь, если у тебя на столе 100-200 объектов, и ты проверяешь существование каждого из них каждый тик, то эти расходы многократно возрастают. И твой лимит в 16мс (а на самом деле 5-10мс) быстро исчерпывается и начинаются ЛАГИ.

Поэтому постоянно проверять так объекты нельзя.

СЛАВА БОГУ, что в игре предусмотрено событие на удаление объекта. Это просто счастье оптимизатора. Какой бы ни был скудный API у TTS, но хоть это в нём есть, и на том спасибо. Таким образом, после проверки мы можем быть точно уверены, что объект существует без дополнительных проверок. Выглядит это примерно так:
local obj function onload() obj = getObjectFromGUID('ca018a') end --Вызывается при удалении объекта. --Этот объект исчезнет в следующий фрейм, --а пока что с ним можно полноценно работать. function onObjectDestroyed(object) if object == obj then obj = nil --Ну, как минимум помечаем, что объекта уже нет. --Можно ещё что-нибудь сделать напоследок. end end Имея такой код, мы можем быть уверены в любом месте скрипта, что если obj ~= nil, то он точно "живой" и взаимодействие с ним не приведёт к ошибке и остановке скрипта.

На момент написания статьи существует баг, который заключается в том, что если клонировать объект, то guid тоже дублируется на некоторое время. Не будем ковырять этот баг, а сойдемся на том, что таких багов ещё полно, и нам всё равно придётся с ними жить и терпеть их, пока не исправят.

Проверка всех объектов

Здесь немного обратная задача. Мы можем не знать, какие объекты на столе и какие у них guid. Но можем получить список всех этих объектов, и, таким образом, узнать все их guid и запомнить.

Есть варианты. Во-первых, можно посмотреть вообще все объекты (кроме игроков, которые не совсем "объекты"):
local all = getAllObjects() for k,v in pairs(all) do print(v.name) end Выведет список всех типов объектов. В логе ты увидишь: "Card", "Card", "Card", "Deck", "Die_6", "Card", "Custom_Table" и т.п. Это все "живые" объекты, у которых есть координаты, положение в пространстве, цвет, эти параметры можно менять и т.д. Карты в колоде здесь, конечно же, не перечислены, т.к. они "спрятаны", т.е. для нас их просто нет.

Во-вторых, можно посмотреть объекты в какой-либо зоне. Есть специальные объекты, т.н. "скриптовые зоны" (Scripting Zone) - это зоны, сделанные специально для отслеживания других объектов в них. Они имеют свои события, когда какой-то объект входит в зону или покидает её. Но нам сейчас важно, что у зоны есть своя функция getObjects().
local MY_SCRIPT_ZONE_GUID = '2d8ca7' local zone = getObjectFromGUID(MY_SCRIPT_ZONE_GUID) --сработает внутри onload local all = zone:getObjects() for k,v in pairs(all) do print(v.name) end Узнать guid зоны легко - нужно кликнуть по ней правой кнопкой мыши, - и он скопируется в буфер обмена. Классического контекстного меню для зоны не предусмотрено, т.к. это просто область, без цвета и запаха. Если кликнуть левой кнопкой, то это удалит зону.

Зона "рук" игрока, к сожалению, не имеет guid (это чётко видно, если проанализировать json-файл сейва, там просто НЕТ информации о guid). Поэтому с зоной рук нельзя взаимодействовать, как с объектом. Однако для этого предусмотрена функция getHandObjects()
local player = Player["Green"] --Зелёный игрок local objs = player:getHandObjects() for i,c in ipairs(objs) do print("Hand #",i,": ",c.name) end Правда, это уже несколько выходит за пределы темы данной главы. В любом случае, прямо поверх зоны "рук" всегда можно нарисовать скриптовую зону и "палить" карты в руке со всеми удобствами.

Учти, правда, что объект считается в зоне, даже если он слегка касается зоны. Таким образом, карта может находиться сразу в двух зонах, даже если они не пересекаются. Потому что сама карта имеет размеры, и она может быть одним краем в одной зоне, а вторым - в другой. Проблема решается большим зазором между зонами, но это грубое решение.

Карты в колоде

Вот мы и добрались до проверки карт в колоде. Как ты понимаешь, это не полноценные карты-объекты, а, скорее, просто записи о них.
local deck = getObjectFromGUID('9f56da') --Ссылка на колоду local all = deck:getObjects() -- Все карты в колоде.
Например, если у нас колода из трех карт, то список карт (all) может оказаться таким:
{ { index = 1, nickname = '', description = '', guid = '9d642b', lua_script = '', }, { index = 2, nickname = '', description = '', guid = '8eb0d4', lua_script = '', }, { index = 3, nickname = '', description = '', guid = 'fc46f0', lua_script = '', }, } Что мы здесь видим? Это просто луа-таблица, самая обычная, а не массив ссылок на объекты типа userdata. Для каждой карты у нас короткая запись об индексе, имени, описании, идентификаторе и связанном скрипте. Очень мало информации. Но давай по-порядку.

1) Индекс. Бесполезная вещь. Индексы постоянно смещаются. И чаще всего ты будешь иметь дело с колодой, которая перемешана в начале игры, и в процессе игры может меняться различным образом.

2) nickname и description - это имя и описание. Если нажать правой кнопкой на карту, то внизу контестного меню (у админа) будут поля для ввода имени и описания. Эти поля можно использовать в качестве идентификатора карт. Это сносно решает проблему клонирования (новая карта будет иметь новый guid, поэтому скрипт не сможет понять, что это за карта, но если ориентироваться на описание, то оно будет скопировано в точности). Конечно, обычно в настолках клонирование запрещено, но хороший скрипт должен уметь реагировать на такую ситуацию. Например, админ может склонировать карту (по ошибке), а потом удалить оригинал - в результате будет либо неопознанная карта, либо опознанная, смотря как работает твой скрипт.

3) guid - самая ценная инфа. Хорошо, что этот guid не меняется. Таким образом, если карта "исчезла" в колоде, а потом снова "появилась" в качестве отдельной карты, то у неё будет тот же guid. А здесь ты можешь узнать этот guid, даже ни разу не имея ссылку на живой объект карты. Для нас здесь важно, что: а) guid не меняется и в целом уникален, б) никто, даже админ, не может менять guid объектов, даже случайно (разве что через клонирование, но это редкий случай).

4) lua_script - текст скрипта, который соответствует карте. У меня была идея хранить там информацию о карте (можно даже в не валидном виде, для нас это просто строка). Но это слишком смелая идея. Каждый раз, когда карта вводится в игру (из колоды), скрипт будет компилироваться заново. А если на столе 100 карт, то в окошке Scripting (если админ туда заглянет, или это будешь ты сам) будет куча мусора. Но вообще здесь можно много чего замутить при желании.

Кэш

Теперь мы подошли к самому интересному. К оптимизации. Мы знаем, что оверхед на вызовы функции движка довольно большой, а использование луа-переменных достаточно экономно. Таким образом, очевидно, что информацию о картах нужно кэшировать в переменных луа.

Это позволит довольно гибко из любого места программы узнавать, например, координаты карты, не вызывая :getPosition() и уж тем более не проверяя существование карты. Это будет возможно при ЛЮБОЙ, сколько угодно сложной логике скрипта.

Правда, кэшированные данные будут устаревать и их нужно будет обновлять. Это проблема. Но это отдельная проблема. Главное, что мы значительно выигрываем в скорости, и сможем программировать сложную логику без особой нагрузки, укладываясь в наши 10мс.

Сам кэшЧто может быть проще?
CACHE = {} Вот он наш кэш. Это таблица карт, где в качестве ключей разумнее всего использовать guid. Это согласуется даже с проблемой клонирования карт (ведь клоны - это полноценные отдельные карты, которые также нужно кэшировать отдельно).

Отслеживание картК идее отслеживания можно подступиться, используя, как минимум, два подхода:
  1. Если карта существует, то она есть в кэше, иначе (если nil), карты не существует. Это правило можно использовать в обе стороны.
    • Во-первых, если у нас есть guid и CACHE[guid]==nil, то карта удалена.
    • Во-вторых, если мы перебираем все карты в кэше, то мы точно ничего не упустим, потому что других карт (которые не в кэше) быть не может.
  2. Если карта существует, то структура в кэше имеет соответствующее свойство (например, active) - true. При таком подходе не предполагается очищать кэш вообще (это не влияет на скорость доступа к информации). А запись о карте, пусть даже удалённой, может содержать важную информацию - например, последняя позиция, где была замечена карта и т.п.

Оба подхода имеют свои достоинства и недостатки. Мне больше нравится второй подход, потому что редко возникает необходимость перебрать все карты. Обычно нужно перебрать карты в какой-то области или обладающие определённым условием. А так как данные о карте обновляются редко, то в этот самый момент можно проверять эти условия и заносить карту в отдельные таблицы (как раз для последующего перебора уже без проверки этих условий). То есть условия проверяются также редко, хотя перебор может быть частым (каждый тик, например).

В любом случае, нам нужна ГАРАНТИЯ, что если объект помечен живым, то он жив. Например, так:
local CACHE = {} local function UpdateCache(obj, guid) if not guid then return end local inst = CACHE[guid] if not inst then inst = { guid = guid, } CACHE[guid] = inst end inst.obj = obj inst.pos = obj:getPosition() inst.active = true return inst end function onObjectDestroyed(obj) local inst = UpdateCache(obj, obj.guid) if inst then inst.active = false end end function onObjectPickedUp( player_color, obj ) UpdateCache(obj, obj.guid) end function onObjectDropped( player_color, obj ) UpdateCache(obj, obj.guid) end Этот код действительно гарантирует, что объект жив, если active==true, но если присмотреться, то этот код не гарантирует, что все живые объекты содержатся в кэше и что у них active==true. Как минимум, мы забыли проверить все объекты в начале работы скрипта. Поправим это дело. Допишем:
function onload() for i,v in pairs(getAllObjects()) do UpdateCache(v,v.guid) end end Само собой разумеется, что если у тебя УЖЕ есть функция onload выше или ниже, то вторую создавать не нужно. А нужно просто дополнить существующую.

Но и это не даёт гарантию. Событие onObjectDestroyed вызывается при удалении, но нам не предоставили события для появления объекта. Поэтому приходится извращаться. И это оставляет дыры, которыми теоретически может воспользоваться игрок. А мы вынуждены искать эти дыры и ставить заплатки. И всё равно нет гарантии, что мы найдём и закроем все дыры. Завтра разработчики добавят новую фичу, и станет на одну дыру больше.

Например такая дыра. Игрок может не брать карту из колоды с помощью курсора, а навести курсор и нажать клавишу "1" - и одна карта прилетит ему в "руку", при этом не с генерируется никаких событий, которые можно было бы отследить.

Чтобы отследить такое появление карты, элегантнее всего нарисовать скриптовую зону поверх зоны "руки" и слушать событие появления карты в ней. Для всех входящих и выходящих карт вызывать UpdateCache. Хотя и здесь можно возразить, что супер ловкий админ во время полёта карты успеет навести курсор и дважды нажать "L" (Lock), после чего карта упадёт на стол и останется невидимой для скрипта, но это уже редкий случай.

Также можно натянуть одну большую зону на весь стол. По идее все появления карт будут вызывать событие "onObjectEnterScriptingZone".
function onObjectEnterScriptingZone(zone, obj) UpdateCache(obj, obj.guid) --Даже не важно, что за зона, хе-хе. end
Ой, постой-ка, ведь карта может лететь в руку целую секунду. И в конце полёта её координаты поменяются (которые могут быть нам важны). Нужно на всякий случай с отсрочкой ещё раз чекнуть карту после внезапного появления. Благо мы это уже умеем.
function onObjectEnterScriptingZone(zone, obj) local guid = obj.guid --Нужно запомнить, а то вдруг объект удалят через полсекунды. UpdateCache(obj, guid) DoTaskInTime(1,function() local check_again = getObjectFromGUID(guid) if check_again then UpdateCache(check_again, guid) end end) end (Если ТЕБЕ это надо, то делай также, иначе это просто лишняя нагрузка).

Кстати, эта реализация легко переделывается в первый подход. Нужно вместо inst.active=false делать CACHE[guid] = nil. Но это ты и сам догадаешься, как сделать, надеюсь.

Карты в "курсоре"

То есть в руке игрока. Но не в личной зоне "рук", где игрок держит все свои карты скрытыми от остальных. Курсор - то, чем игрок хватает карты.

Как понять, что карта находится в "курсоре"? Очень просто. Добавляем таблицу CACHE_CURSOR, где мы будет хранить все карты (да и вообще все объекты), которые игроки берут в курсор. А затем будем просто отслеживать события хватания и отпускания.
local CACHE_CURSOR = {} --> NEW!!! function onObjectDestroyed(obj) CACHE_CURSOR[obj.guid] = nil --> NEW!!! local inst = UpdateCache(obj, obj.guid) if inst then inst.active = false end end function onObjectPickedUp( player_color, obj ) CACHE_CURSOR[obj.guid] = player_color --> NEW!!! UpdateCache(obj, obj.guid) end function onObjectDropped( player_color, obj ) CACHE_CURSOR[obj.guid] = nil --> NEW!!! UpdateCache(obj, obj.guid) end Мы просто обновляем эту таблицу, когда происходит событие хватания чего-либо. Если же происходит событие отпускания или удаления, то вычеркиваем соответствующий объект.

В качестве значений может быть просто true, либо сам объект, либо цвет игрока, либо даже ссылка на CACHE[guid]. В данной реализации я сохраняю лишь цвет игрока, в чьей руке находится объект, потому что эту информацию нельзя получить другим способом (на момент написания статьи). Если нужен кэш или сам объект, то, зная guid, ссылаемся на CACHE[guid] или CACHE[guid].obj.

Как это использовать в твоей игре - дело твоё. Возможностей много. Можно просто проверить, не держит ли кто-нибудь конкретную карту в курсоре:
if CACHE_CURSOR[guid] == nil then .......... Или же можно сделать что-то со всеми взятыми картами. Может понадобится, например, обновить все координаты этих карт. Ведь подразумевается, что эти карты перемещают. Мы можем игнорировать их до того момента, когда они будут брошены, либо пристально следить за тем, куда они перемещаются. В первом случае мы везде используем условие, что карта не в курсоре. Во втором же случае каждый тик делаем примерно следующее:
for k,v in pairs(CACHE_CURSOR) do UpdateCache(CACHE[k].obj, k) end Мда, хоть и коротко, но не очень красиво. Но ты можешь приукрасить, как тебе нравится. Здесь я смело использую CACHE, потому что объект 100% существует. Если его удалят, то сразу сработает onObjectDestroyed, и объект исчезнет из обоих кэшей. То есть если объект в CACHE_CURSOR, то он точно живой. Предполагается, что в курсоре одновременно будут 1-2 карты, а остальные 100-200 карт будут лежать на столе, так что не страшно обновлять эти пару карт так часто.

Учти, что игрок может взять несколько карт одновременно. Даже несколько игроков могут держать одновременно по несколько карт каждый. Просто учти этой в своей логике, чтобы не было заблуждения, будто один игрок может держать максимум одну карту, - это не так.

Карты в различных зонах

Чтобы каждый раз не вычислять сложные условия, такие как зона, в которой находится карта, можно вычислять эти условия только в тот момент, когда они могут измениться.

Для зоны - это изменение координат. Координаты меняются только тогда, когда мы их считываем из объекта, то есть при обновлении информации о нём.
local function UpdateCache(obj, guid) if not guid then return end local inst = CACHE[guid] if not inst then inst = { guid = guid, } CACHE[guid] = inst end inst.obj = obj inst.pos = obj:getPosition() inst.active = true inst.zone = nil if inZone(ZONES.FIELD_1, inst.pos) then inst.zone = ZONES.FIELD_1 elseif inZone(ZONES.FIELD_2, inst.pos) then inst.zone = ZONES.FIELD_2 end return inst end В конце мы добавляем новой свойство - zone. Функцию inZone тебе придётся написать самостоятельно. Какие зоны будут у тебя в игре, тоже решать тебе. Зависит от того, что за игра.

Советую в качестве каждой зоны использовать таблицу, в которой перечислены все объекты, которые в ней находятся. Это потребует от тебя немного усовершенствовать этот скрипт. Пусть это будет домашним заданием. Надеюсь, ты справишься. Зато потом будет очень легко проверять, есть ли в зоне объект, а также перебирать все объекты в зоне.

Объекты по типам

Тип объекта - это его obj.name. Для карт - это "Card", для колоды это "Deck" или "DeckCustom", для фишки-пешки это "PlayerPawn" и т.д. Это не имя (nickname) и не описание (description) объекта. Не путай.

Полезно раскидать все объекты, которые ты отслеживаешь, по разным группам. Это полезно, например, если тебе нужно перебрать все пешки (PlayerPawn), которых в игре по идее не больше, чем игроков. Это лучше, чем перебирать все объекты на столе.

Для наших целей заведём ещё один кэш CACHE_TYPE. Как следует из названия, мы будем кэшировать объекты по типам. Ключ - тип объекта. Значение - ещё один кэш. Сложно, но удобно для использования.
local CACHE_TYPE = {} local function UpdateCache(obj, guid) if not guid then return end local inst = CACHE[guid] if not inst then inst = { guid = guid, type = obj.name, --Тип не меняется, поэтому получаем его лишь единожды. } CACHE[guid] = inst --Проверяем, есть ли такой тип вообще в кэше. Если нет, то создаём. if not CACHE_TYPE[inst.type] then CACHE_TYPE[inst.type] = {} end end inst.obj = obj inst.pos = obj:getPosition() inst.active = true --При каждом обновлении также кэшируем и по типу. CACHE_TYPE[inst.type][guid] = inst return inst end Кроме этого, нужно очищать кэш типов, если объект удаляется:
function onObjectDestroyed(obj) --CACHE_CURSOR[obj.guid] = nil local inst = UpdateCache(obj, obj.guid) if inst then inst.active = false CACHE_TYPE[inst.type][obj.guid] = nil -- !!! end end
Дальше можно использовать новые возможности, как угодно. Например, перебор пешек игроков:
for guid,pawn in pairs(CACHE_TYPE.PlayerPawn) do --Наши действия над пешкой. end
Или же можно использовать этот механизм, чтобы кэшировать только определённые объекты, а остальные (декоративные и пр.) - игнорировать. Для этого нужно заранее инициализировать CACHE_TYPE нужными типами, это и будет знаком, что тип поддерживается. Ещё понадобится изменить функцию UpdateCache, чтобы она работала только с поддерживаемыми типами, а не со всеми подряд. Надеюсь, ты справишься.

Переворачивание карты

Как определить, что карта перевёрнута?

Мы можем получить её пространственные углы поворота. По моему опыту, состояние перевёрнутости карты зависит только от угла Z. Так что мы можем легко сделать булево свойство opened или closed для карты. Мне больше нравится "opened".
.......... local rot = inst.obj:getRotation() inst.opened = (rot_z < 90 or rot_z > 270) ..........
Вот так относительно просто.

Я искал в официальном API свойство или функцию с названием типа getFlipStatus(), но ничего подобного не нашёл. Есть только функция :flip(), но она предназначена для переворачивания карты, а не для выяснения статуса перевёрнутости, увы.

Кэш и save/load

Как следует и определения кэша, его НЕ нужно сохранять. Он отражает только реальное положение дел. Он даже не относится напрямую к логике игры.

Да, от логики зависит, что именно мы будем кэшировать - только координаты или, может быть, ещё и углы поворота, цвет, "перевёрнутость". Сам кэш логически находится ближе к объектам в игре.

Зона объекта вычисляется строго на основе его координат, причем каждый раз при обновлении координат. Поэтому она тоже не подлежит сохранению в сейв. При следующей загрузке игры, кэш будет восстановлен вместе со всеми свойствами объектов, которые можно получить из них самих.

Таким образом, кэш сохранять НЕ нужно! Это железно.

Однако некоторые свойства ошибочно можно хранить в кэше, хотя они относятся к геймплею. Например, информация о владельце карты уже относится к логике игры. Её удобно хранить в структуре из кэша, которая представляет из себя отражение карты, но нужно помнить, что эту информацию нужно сохранять в сейв, потому что при загрузке игры не получится вычислить хозяина карты, когда карта лежит на столе. Например, кто-то только что сыграл карту, а затем игра была сохранена.

Давай действовать рационально и последовательно, без суеты. В таблице SAVED сделай ещё одну таблицу - для сохранения всей специфической информации о картах.
SAVED = { CARDS = {}, } В качестве ключа, как обычно, - guid. И для каждой карты можно сохранять различные свойства - owner_color и т.д. Заметь (в очередной раз), мы здесь можем использовать только простые данные (например, название цвета). Нельзя оставлять ссылку на таблицу игрока, в которой могут быть ссылки на функции и объекты.

Заключение

С каждым уроком примеров всё меньше, а чистой теории и алгоритмов всё больше. Я не уверен, стоит ли продолжать эту серию статей. Напиши в комментах, помогает ли это тебе, или ты уже сам прекрасно справляешься с изобретением новых алгоритмов для своих нужд. скачать dle 10.6фильмы бесплатно