Tabletop Simulator

Tabletop Simulator

Not enough ratings
Урок 4. Отложенные задачи во времени
By star
Создание функции DoTaskInTime() и многое другое.
   
Award
Favorite
Favorited
Unfavorite
Введение
Итак, что у нас есть из lua-инструментов для времени в Tabletop Simulator?

Во-первых, это функции времени:
os.time() - это локальное unix-время с точностью до секунды.
os.clock() - время в секундах с точностью до миллисекунд с начала работы программы.
os.date() - для форматирования даты и времени в виде строки или таблицы.
os.difftime() - просто для вычисления разницы во времени.

Во-вторых, это функции для создания сопрограмм. В TTS функционал ограничен:
startLuaCoroutine(fn_owner, fn_name) - создать сопрограмму.
coroutine.yield(0) - ждать один фрейм (тик, кадр).
Вот и всё, что здесь умеют сопрограммы, никаких супер-пупер возможностей, которые обычно есть в Луа.

В-третьих в TTS есть готовый глобальный класс Timer, предназначенный как раз для того, чтобы выполнить определённую функцию, спустя какое-то время.

Я расскажу тебе про все эти подходы. Но, кроме того, я хочу провести тебя по пути создания собственного инструмента на основе os.clock(). Ведь если ты программист, то ты уже можешь идти и делать свой инструмент без лишних вопросов. Но если нет, то тебе, должно быть, интересно, КАК этот инструмент создаётся с нуля. А создаётся он постепенно, не сразу, проходя через несколько этапов. Каждый этап - это законченный рабочий инструмент. Мы идём от простого к сложному. И на каждом следующем этапе этот инструмент становится чуть более совершенным.

Оглавление
Урок 1. Скприты Lua для TTS
Урок 2. Инструменты
Урок 3. Раскраска API для Notepad++
Урок 4. Отложенные задачи во времени
Урок 5. Колоды и карты
Способ через сопрограммы
Сначала попробуем выполнить какой-либо код через 5 секунд на основе сопрограмм.
function onload() time_finish = os.clock() + 5 --Прибавляем к текущему времени 5 секунд. startLuaCoroutine(Global, "co") end function co() while os.clock() < time_finish do --Ждём, пока время придёт. coroutine.yield(0) --Пропустить фрейм. end print("Прошло 5 секунд.") return 1 end

Это минимальный код, который необходим, чтобы отложить какую-либо задачу во времени. При этом:
  • startLuaCoroutine может не сработать (например, за пределами onload) и вернуть false
  • coroutine.yield(0) пропускает ровно 1 тик, не больше и не меньше, других вариантов нет. Функция coroutine.yield должна вызываться с параметром 0 и только так, это пропуск одного фрейма.
  • Нельзя передавать параметры основной программе. Разве что через глобальные переменные.
  • Нельзя передавать параметры сопрограмме. Опять же - только через глобальные переменные.
  • Функция co обязана возвращать 1 (такое, вот, правило придумали).
  • В целом решение не элегантно.
Способ через "update"
Функция update тоже вызывается каждый фрейм и не имеет дополнительных ограничений (то есть ничем не хуже предыдущего варианта). Лично мне больше нравится update.
local time_finish = os.clock() + 5 local done --Функция вызывается каждый тик. function update() if os.clock() < time_finish do --Ждём, пока время придёт. return --Пропустить фрейм. end if not done then print("Прошло 5 секунд.") done = true end end
Здесь мы точно также пропускаем по фрейму, пока время не окажется нужным. Дополнительно здесь приходится помечать, что задача выполнена, потому что мы не можем остановить функцию update, да и не нужно этого делать.

Теоретически можно попробовать написать _G.update = nil, но обычно функция update нужна также и для других целей. Кроме того, мы хотим отложить не одну задачу на старте игры, а иметь более гибкий инструмент, не так ли? Так что просто немного усовершенствуем этот способ. Напишем обёртку вокруг update и os.clock в виде небольшого API.

В качестве желаемых функций мы хотим пока что просто:
  • DoTaskInTime(time_in_seconds, fn) - выполнить функцию через некоторое время.
local time_now = os.clock() local TASKS = {} local function DoTaskInTime(seconds, fn) --seconds здесь - через какое время выполнить задачу. local task = { fn = fn, act_time = time_now + seconds, } table.insert(TASKS,task) end --Функция вызывается каждый тик. Потом оптимизируем, а сейчас лень. function update() time_now = os.clock() --в секундах с точностью до миллисекунд. for i=#TASKS,1,-1 do local task = TASKS[ i ] if time_now > task.act_time then --Если время подошло. task.fn() --Выполняем функцию. table.remove(TASKS,i) --Удаляем задачу. end end end
Интересное и простое решение. Теперь у нас есть функция для откладывания задач. Можно легко проверить этот инструмент, просто добавив в конец такой код:
DoTaskInTime(5, function() print("Через 5 секунд") end)
Такая строчка очень в духе программирования на Луа - короткая и понятная. Если у тебя есть хоть небольшой опыт, то ты понимаешь, насколько это удобно. В итоге мы можем откладывать несколько задач одновременно с разными интервалами, и у нас нет накладных расходов на передачу управления движку Tabletop Simulator и всяким там якобы сопрограммам. Обращение к os.clock (системная функция, однако) происходит лишь единожды каждый тик, а дальше полученное значение используется для проверки времени всех задач. И если мы захотим "буксовать" каждые 50 тиков, то эта оптимизация коснется также и отложенных задач.

Но нам и этого мало, не так ли? :)
Периодические задачи
До этого мы хотели выполнять лишь одноразовые задачи. А что если мы хотим, чтобы задача выполнялась каждые N секунд? Тогда нам нужно что-то вроде:
DoPeriodicTask(5, function() print("Каждые 5 секунд.") end)
Предыдущий инструмент не годится. Придётся его улучшить.
local time_now = os.clock() local TASKS = {} local function DoPeriodicTask(period, fn) local task = { period = period, fn = fn, act_time = time_now + period, } TASKS[task] = task return task end local function DoTaskInTime(seconds, fn) --seconds здесь - через какое время выполнить задачу. local task = { --period = period, fn = fn, act_time = time_now + seconds, } --table.insert(TASKS,task) TASKS[task] = task return task end local function CancelTask(task) if task then TASKS[task] = nil end end function update() time_now = os.clock() --в секундах с точностью до миллисекунд. for k,task in pairs(TASKS) do if time_now > task.act_time then --Время пришло. if task.period then --Повторяющаяся... task.act_time = time_now + task.period --Снова заводим таймер на то же время. else --Одноразовая... TASKS[k] = nil end task.fn() --Саму функцию не забываем выполнять. end end end
Что ж, это уже довольно элегантное решение, хоть и громоздкое. Но мы же не будем вспоминать о том, как всё сложно. Вместо этого мы скопируем в начало скрипта и забудем, а в самом скрипте будем использовать лишь сами функции:
  • DoTaskInTime(seconds, fn)
  • task = DoPeriodicTask(seconds, fn)
  • CancelTask(task)

Хитрый приём - мы храним задачи в массиве, но в качестве ключей используем сами же задачи. В принципе, в качестве значений можно использовать просто true, например. Так мы в полную силу используем всю мощь таблиц Луа, а именно - быстрый поиск по ключу. Это всё нужно для быстрого удаления задач.

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

Можно пойти дальше. Например, усовершенствовать функции так, чтобы они передавали аргументы в отложенную функцию. Например так:
DoTaskInTime(5, function(a,b) print(a+b) end, 6, 7)
Типа должно вывести число 13 через 5 секунд. Здесь я уже не буду рассказывать, как улучшить наш инструмент, но это очень просто. Так что если тебе это нужно позарез - дерзай (это уже будет третий этап разработки своего инструмента, и это не предел).
Способ через "Timer"
Давай-ка сначала я вкратце сравню этот способ с тем, который мы реализовали выше на основе os.clock:

Особенности (отличия)
  • В качестве идентификаторов используются строки. И эти строки уникальны в пределах всей игры.

Плюсы
  • Способ уже реализован и есть в самой игре. То есть не нужно изобретать велосипед со своими функциями откладывания задач.
  • Не нужно хорошо знать Луа. То есть это вариант "для ленивых".
  • Игра контролирует вызовы функций, и в целом за нас решает, когда ей удобнее передать управление. (Это является плюсом только при условии, что игра оптимизирована, но это не совсем так).
  • Тот факт, что идентификаторы уникальны во всей игре в целом, можно использовать, чтобы отменять таймеры, созданные в другом объекте (в другом скрипте). (Обычно это не требуется, к тому же в целом это можно заменить вызовом obj:call(), но всё же это "плюс").

Минусы
  • У нас НЕТ контроля над тем, что происходит.
    • Игра решает, когда передать управление. Если в игре есть или появятся баги таймера (вероятность высока), то мы их ощутим в полной мере.
    • Если у нас много задач, то игра теоретически может впихнуть их в один тик, и мы никак не можем на это повлиять и даже просто узнать об этом.
  • Вероятность багов действительно высока, потому что разработчики не стесняются выпускать сырые фичи. Для сравнения, вероятность багов в языке Луа практически равна нулю, поэтому способ через os.clock() ГОРАЗДО надёжней.
  • Таймеры работают лишь после(в) onload, даже если возвращают true как результат после создания таймера. Для сравнения, os.clock - системная функция и вообще не связана с движком игры, доступна и работает с первых строк, и никогда не может давать осечки.
  • Вызываемую функцию приходится объявлять как глобальную. Это ограничение, поэтому минус. Лично для меня (и других ценителей языка Луа) это серьёзный удар по удобству - можно забыть о решениях в одну строку типа такой:
    DoTaskInTime(5,function() print("5 seconds") end)
  • Небольшие накладные расходы (оверхед) при передаче управления игре и обратно.
  • Нужно следить за уникальностью идентификаторов задач. Ведь в случае с os.clock() мы использовали ссылки на сами задачи, которые являются адресами в памяти и обязаны быть уникальными, как ни крути. Здесь же нужно придумывать строковые идентификаторы самим и следить за их уникальностью.
  • Составные строковые идентификаторы добавляют еще чуть больше накладных расходов, т.к. операция конкатенации (т.е. склеивания строк) - довольно дорогое удовольствие в Луа. Чтобы понять это, надо знать, как работают строки в Луа. Если коротко, то сравнение сколь угодно больших строк занимает минимум времени, а создание новых строк - очень много.
    Разработчики рекомендуют обеспечивать уникальность с помощью guid:
    "mytimer_" .. guid
  • Передача параметров осуществляется в отдельной таблице, что тоже как бы добавляет лишней нагрузки.
  • *** На момент написания статьи существует баг[www.berserk-games.com], из-за которого полезность таймеров вообще под вопросом. Они больше представляют опасность для новичка, чем полезность.
  • *** Также ранее упоминалось[www.berserk-games.com], что таймеры не уничтожаются при Ctrl+Z или загрузке другой игры, что добавляет трудностей и усложняет нам жизнь. Сколько ещё таких багов есть или будет? Оно нам надо?

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

Однако лично я против таймеров не из-за оверхеда, а из-за высокой вероятности багов, дополнительных тонкостей для TTS, отсутствия контроля. Если тебе всё ещё интересно, то вот простой пример использования таймера:
function onload() local params = { --Параметры таймера. identifier = "test123", --Уникальный идентификатор. function_name = "myTimer", --Глобальная функция для вызова движком игры. delay = 5, --Задержка 5 секунд. repetitions = 2, --Повторить 2 раза. } Timer:create(params) --Создание таймера. end function myTimer() --Функция будетвызвана игрой. print("5 seconds twice") end
Что здесь ещё можно сказать про Timer? Это глобальный объект. У тебя есть полная документация к нему[berserk-games.com]. Также у тебя есть руки. Так что вперёд и с песней!
Какой способ лучше? Выводы
Timer - готовое решение, "для ленивых", но полно сюрпризов и неожиданностей, не смотря на кажущуюся внешнюю простоту.

Сопрограммы - не имеют особых достоинств, но в них также приличное кол-во сложностей и потенциальных (и существующих) багов.

Вариант с собственной реализацией через os.clock() - быстро и хорошо работает, но требует навыков программирования и понимания, что происходит. Новичку сложновато.

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

То есть теперь ты можешь использовать либо минимальную реализацию через os.clock - она же самая простая и самая быстрая. Либо сделать свой вариант с блэкджеком и фичами. Даже самая сложная и богатая реализация через os.clock на чистом Луа будет быстрее и надёжней, чем сопрограммы TTS или таймеры TTS.
Сохранение и загрузка задач
Помним о том, что сохранению важно уделять внимание. Ведь админ может сохранить игру, чтобы продолжить её завтра. Также админ может нажать Ctrl+Z. Как быть с отложенными задачами?

Здесь есть два варианта для каждой задачи:
  1. Задача не сохраняется вообще, потому что это не нужно.
  2. Задачу нужно сохранить в момент сохранения и снова запустить после загрузки.

Первый вариант
Наиболее удобен и прост. Задача просто перестаёт быть актуальной после загрузки из сейва.

Например, нужно проверить какой-то параметр некоторого объекта через 5 секунд. А после загрузки мы на всякий случай проверяем все объекты в игре. Таким образом, задача перестаёт быть актуальной. (Во время игры постоянно проверять все объекты может быть накладно, так что на помощь приходят отложенные задачи для некоторых объектов, которые трогали или которые нужно мониторить).

Если надо, эту задачу можно снова создать в onload или позже. Просто нужно понимать, что в любой момент игра может быть прервана с тем, чтобы продолжиться потом. А также понимать, что именно будет происходить с твоими данными и твоей логикой в скрипте в случае save/load.

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

Например, это может быть удаление объекта с тем, чтобы заспаунить (создать) его через 5 секунд, или чтобы создать другой объект с другими параметрами. Здесь уже важно запомнить факт того, что у нас должок - мы ДОЛЖНЫ заспаунить объект. Если в эти самые 5 секунд произойдет сохранение игры, то информация о "долге" должна попасть в сейв. (Вообще пример притянут за уши, и такие вещи лучше делать одновременно, чтобы не было "долгов").

В каком виде сохранять задачу, если очень нужно? Очень просто: вычисляем время, которое осталось, и сохраняем именно его. При загрузке восстанавливаем задачу и оставшееся время. Это простая математика, я даже не буду изобретать код. Если даже такой код действительно понадобится, то он будет довольно специфичным, и тебе вполне по силам его написать.

Главное, помнить о том, что целостность игры должна быть восстановлена при загрузке из сейва. Какую-то информацию можно восстановить по расположению объектов на столе, их количеству и свойствам. Но информацию, которая хранится исключительно в переменных Луа, мы вынуждены сами передавать в сейв в момент сохранения.
FAQ
Ремарка
Дорогой друг! Знаю, я тебе уже надоел с этими напоминаниями. Но большинство вопросов действительно отпадут, если выучить и хорошо знать язык Луа.

Q: Как "отменить" функцию update?
A: Убрать ссылку на неё из глобального пространства скрипта. Пример:
function update() print(123) --Сработает лишь единожды. update = nil end
12 Comments
star  [author] 10 Apr, 2021 @ 2:15am 
Гайд устарел, если что.
https://api.tabletopsimulator.com/wait/
star  [author] 25 Jan, 2017 @ 8:18pm 
Добавил про таймеры. :horns::tick:
star  [author] 24 Jan, 2017 @ 2:09pm 
В шапке ("Введение") есть аннотация, где сказано, что "я хочу провести тебя по пути создания собственного инструмента". Именно это и делает руководство. Про таймеры я забыл, каюсь, но сути руководства это почти не меняет. Спасибо за поправки.
star  [author] 24 Jan, 2017 @ 2:07pm 
В примере из руководства функция update каждый тик получает текущее время и проверяет все задачи на то, не пора ли их выполнить. Если задач нет, то и проверок нет. А нагрузку на эти проверки можно не считать, она крайне мала по сравнению с самими задачами, в которых будут вызовы к движку.

Можно немного изменить скрипт, сделав так, чтобы выполнялась максимум одна задача за тик. А можно ли такое сделать для таймеров?
star  [author] 24 Jan, 2017 @ 2:02pm 
Функция update вызывается каждый тик, но что именно будет делать - зависит от программиста. Если функция не делает ничего, то нагрузка нулевая. Можно, например, каждый первый тик делать что-то одно, каждый второй что-то другое, размазывая вычисления во времени, а следующие 20 тиков просто пропустить, ничего не делая.

Функция подобная "update" есть практический в любой игре (реального времени). Это основной цикл игры. Зачастую нет смысла отказываться от этой функции.
Dr. KTO 24 Jan, 2017 @ 6:09am 
Если это руководство сделано с целью ознакомления с луа, то конечно вопросов никаких. Но все же мне кажется это стоит отметить в шапке, потому что найдутся люди, которые вопримут это так же как и я, и будет не очень хорошо, если они будут усложнять себе жизнь использованием этих функций.
Dr. KTO 24 Jan, 2017 @ 6:09am 
Только она после этого вызывается заново, и лично я не в курсе, как прекратить это процесс. Про влияние на фпс я ничего не говорил, потому что и сам никаких измерений не производил. Я говорил про то, что частота выполнения приведенных в этом руководстве функций (кроме последнего примера) зависит от фпса хоста. Таймер банально удобнее, если разобраться с ним.
star  [author] 24 Jan, 2017 @ 4:27am 
Цель руководства - знакомство с языком Луа и с тем, как Луа связан с TTS, возможности и ограничения. Я рассчитываю на широкую аудиторию - от профи до тех, кому лень читать справку по языку. Поэтому есть и простые примеры, и сложные. Руководство проводит читателя через ЭТАПЫ написания велоспипеда, а не презентует сам велосипед (это лишь побочный эффект). Про таймеры я добавлю позже как третий способ.
star  [author] 24 Jan, 2017 @ 4:17am 
Что значит завершать функцию update? Она завершается, когда подходит к своему end или к return в теле функции. И никто не заставляет делать все вычисления в 1 тик. Кроме того, мне не известно, как таймеры сделаны в Tabletop Simulator - может там еще больше влияния на фпс. Меньше контроля над тем, что происходит - это точно.
Dr. KTO 24 Jan, 2017 @ 4:10am 
Если я правильно понимаю цель написания руководств, то они пишутся для того, чтобы научить новичка. Зачем учить новичка использовать "велосипед", время выполнения которого зависит от фпса хоста и уж тем более фукнцию update, которую непонятно как потом завершать, когда есть готовый таймер?