第1课:用于TTS的Lua脚本

0 点赞
Tabletop Simulator
转载

他们在《桌面模拟器》中有多少种。 简介

假设你已经懂Lua编程语言了。不过要是不懂也没关系,反正我喜欢把所有东西都讲得非常透彻。不管怎样,建议你去看看(哪怕只是翻一翻也好)《Programming in Lua》这本书。 对于那些不擅长搜索的人,我给个提示。在谷歌上搜索: Программирование на языке LUA 2014 PDF RUS rutracker 这里我们要讲的是Lua如何集成到《Tabletop Simulator》中,如何使用它以及如何与之共处。当然,如果你懂英语,可以直接查阅官方文档[berserk-games.com],然后关闭这篇文章并忘记它。 本系列文章目录 第1课:TTS的Lua脚本 第2课:工具 第3课:Notepad++的API语法高亮 第4课:定时延迟任务 第5课:牌组与卡牌 在开始之前……

需要弄清楚脚本在游戏中保存在哪个位置。通常来说,脚本只能应用于已加载(从存档中)的游戏。因此,在开始编写程序之前,需要先加载游戏。而要加载游戏,就必须先保存游戏。 1) 保存游戏。 2) 加载游戏。 很棒,对吧?现在你已经准备好了。不过我希望你能体会到这个过程,所以我们来分析一些特殊情况。

假设你已将脚本应用到当前游戏中。之后你做了一些修改,并将游戏以其他名称保存(以免损坏原有的存档)。现在你想要修改脚本,于是进行了修改并应用到当前游戏,天真地以为一切正常。但意外发生了!脚本被应用到了之前的存档,并且该存档立即被加载。很棒,对吧?不,一点也不棒。问题在于最后加载的是之前的存档,而当前存档从未被加载过,所以脚本会应用到最后加载的那个存档上。因此,保存之后的所有更改都会丢失。顺便说一下,旧脚本也会丢失,会被新脚本覆盖。 另一个问题是,当你应用脚本时,它会保存到之前(已加载的)存档中,但其他更改会丢失!假设你修改了桌子,移动了元素,创建了新元素,然后编写了脚本并按下了那个“保存并运行”(SAVE&PLAY)按钮。结果游戏将脚本保存到存档中并立即加载,但你所做的其他所有更改都丢失了。不过之后你会习惯的,甚至会觉得这很方便。

还有一点小提示。脚本功能需要在游戏选项中启用。通常情况下,它们默认就是开启的。所以不清楚你的脚本功能是如何被关闭的,但有必要提醒你注意这一细节,以防万一。通常就是因为这类小问题,才会出现“完全没反应!求助!”之类的评论。 最后一个建议:在记事本++之类的其他编辑器中编写脚本,然后直接复制到游戏里。这样更可靠(而且对于有经验的程序员来说也更习惯)。 好了,现在你应该确实准备好了。可以开始了。 编写一个简单的脚本 嗯,你懂的,这就是“Hello World”。它看起来是这样的: print("Hello World")

点击“Scripting”按钮。左侧会出现一些不明按钮,右侧是编写脚本的工作区域,顶部则是“SAVE&PLAY”(保存并重启)按钮。将我们新的脚本复制到那里,然后点击该按钮。你可以删除或保留旧文本。好了,完成了吗? 现在高兴一下吧——脚本生效了!屏幕中央出现的绿色消息“Loading complete.”(加载完成。)会通知你加载成功。

如果脚本存在语法错误,它将无法运行,并且错误消息会以红色显示在屏幕上。此外,许多消息会在日志中重复记录——这是我们用于调试和获取反馈的工具,以便了解脚本中实际发生的情况以及其运行是否正常。

日志在哪里?左下角有一个包含聊天和日志的窗口。如果你不小心按了F11并完全隐藏了界面,只需再按一次F11,界面就会重新显示,你就能在底部看到聊天日志。“Game”标签页就是事件日志。各种游戏相关信息都会在这里显示,你的脚本执行情况也会记录在这里。右侧图片中是我点击“SAVE&LOAD”按钮后的日志:1) 游戏提示已将脚本保存到存档中。它在我这里叫“test_script”,很简单。2) 关于存档的信息以及存档已被修改的提示。3) 我们的脚本运行成功,日志中已记录“Hello World”字符串。4) 游戏再次提醒,其他所有更改均已失效。 今后你编写脚本时,需通过print函数来反馈其运行情况。遗憾的是,这几乎是在《桌面模拟器》中调试脚本的唯一方法,所以你得做好通过这个窗口与脚本交互的心理准备。 全局空间与Global 不要混淆! 顺便说一下,从这里开始就会有难度了,所以想偷懒的人可以离开了,再见。 我们通常认为,全局空间是存储所有全局变量的地方。它也被称为_G。在这里,它同样存在。我们的脚本正是在全局空间中运行,游戏已在该空间为我们提供了所有必要的函数。游戏也会在同一空间中查找我们的特定函数,并在某些事件发生时调用这些函数(如果找到的话)。

但开发者们更进一步——他们决定为游戏中的每个物体创建独立的全局空间。这可真是个转折!这有什么特别之处呢?只有在创建/初始化物体时才会出现卡顿,还有大规模的事件处理器检查,除此之外就只是几个独立的脚本而已。 我们先试着摸索一下,然后再进行调整。在桌子上创建一个普通方块,然后再次保存游戏。其实可以是任何物体,都没关系。右键呼出Lua编辑器。如你所见,现在我们有两个脚本了!直接输入print(2)然后重启。好了,完成了吗?

现在我们的日志中有两条记录——来自主脚本的“Hello world”和来自第二个脚本的“2”。 问题的关键在于每个脚本都有自己的全局空间_G,它们之间完全不互通。如果在一个脚本中写入x=5,那么在另一个脚本中x仍然是nil。 在大多数情况下,我们需要让脚本能够相互通信。当然,这并非总是迫切需要,但对于重要功能而言,这是必不可少的。还有一种方法是制作一个大型全局脚本,而不向单个对象添加脚本。但这样还是不太方便。既然有这样的功能,就应该充分利用它。顺便说一下,如果你不打算公开,并且担心玩家(你的访客)会窃取脚本,那么最好只制作一个全局脚本(因为任何玩家都可以将任何对象添加到自己的库中,包括附加在该对象上的脚本、美术资源、模型等)。 总之,开发者用一种独特的方式(通过单一位置)解决了这个问题。他们只是提供了一个不同脚本之间“通信”的接口。其原理是数据被直接复制。如果涉及到表格,那么表格也会连同所有嵌套内容一起被完整复制。不会传递表格的引用,表格确实是被复制的。如果需要修改表格,步骤如下: 1) 通过API复制表格 2) 修改一个或多个元素 3) 将表格复制回原位置覆盖,甚至其地址也会改变。真是意想不到的结果! 游戏中的每个脚本(及其全局命名空间)对应一个对象。从脚本中访问该对象可通过关键字“self”实现。此外,这并非处理函数的局部变量,而是全局变量,随时随地都可访问,甚至从脚本的第一行开始就能访问。 创建后,对象不会立即初始化,这需要一定时间。不过,“外部”可以访问其部分属性。当对象完全初始化后,此时会检查脚本是否存在——在这个阶段,脚本只是一段 Lua 语言的程序文本字符串。如果存在脚本,它会被编译并立即执行。这就是为什么 self 变量以及其他函数从一开始就可以使用。 不过,全局脚本没有对应的对象。开发者转而设计了一个Global变量(类型为userdata,这点毋庸置疑)。通过该变量,任何其他脚本都能借助Global:getVar()、Global:setVar()、Global:getTable()、Global:setTable()等调用,访问全局脚本的命名空间。 请勿将Global(游戏主对象)与单个脚本的全局命名空间“global”(即_G)相混淆。“Global”这个名称也用于脚本列表中的全局脚本,并且无法重命名(不过总体来说是合理的)。 getObjectFromGUID 嗯,我们好像一下子切入得太快了。得放慢点节奏。让我们回到基础,从简单的例子开始。我们来把那个红色的立方体涂成黄色吧。代码只需要写在全局脚本里,其他内容可以以后再处理。要查看你自己方块的GUID,请右键点击该方块,在“Scripting”(脚本)选项中会显示其GUID,你可以将其复制到剪贴板。原因是我创建了我的方块,你也创建了你的方块,我们的GUID是不同的。如果我保存游戏并将我的存档发送给你,GUID才会相同,但现在你需要自己去查看。 function onload() local cub = getObjectFromGUID('ca018a') ---> 替换为你的GUID cub:setColorTint{1,1,0} --rgb end

Ого! Куб перекрасился в жёлтый. Сам!! Конечно, можно перекрасить его руками, но это не интересно. Здесь ты применил функцию getObjectFromGUID, которая принимает на вход GUID (строку), а на выход даёт объект типа userdata, с которым можно взаимодействовать. Например, уже здесь мы можем получить доступ к пространству имён кубика - cub:getVar(), cub:getTable() и т.д. Можно использовать функции изменения позиции, цвета, поворота, ускорения, размера, менять имя, подсветку и всё, что доступно через официальный API игры. Документация есть на официальном сайте, мы к ней ещё вернёмся. setColorTint - функция изменения цвета. Она есть у всех объектов. И почти для всех работает, но присутствует вообще у всех. На вход она принимает таблицу с цветами RGB (красный-R, зелёный-G, синий-B). Ты же прочёл ту книгу, которую я советовал в начале? Молодец. Тогда ты уже знаешь, что если функция принимает лишь один аргумент и это таблица, то круглые скобки не нужны (для красоты). Но для пущей понятности и привычности можно написать и так: local color = {1,1,0} cub:setColorTint(color) Цикл игры и функция update А слабó перекрашивать куб каждую секунду то в красный, то в жёлтый? Ну, мы же не лыком шиты, это-то как раз легко: --При создании нового объекта, ему присваивается новый GUID. local CUBE_GUID = 'ca018a' --Идентификатор нашего куба. local cube --Будущая ссылка на объект. --Функция вызывается после загрузки игры и всех объектов. function onload() cube = getObjectFromGUID(CUBE_GUID) --Получаем ссылку на объект. end local COL_RED = {1,0,0} --Красный. local COL_YELLOW = {1,1,0} --Жёлтый. local last_time = 0 --Время перекрашивания. local is_color_red = true --Красный ли куб? --Функция вызывается каждый тик. Потом оптимизируем, а сейчас лень. function update() local time_now = os.clock() if (time_now - last_time < 1) then --Прошло меньше 1 секунды. return --Завершаем работу функции. end local color = is_color_red and COL_YELLOW or COL_RED --Новый цвет cube:setColorTint(color) --Применяем цвет к нашему кубику. is_color_red = not is_color_red --Цвет поменялся. last_time = time_now --Запоминаем время изменения. end Сразу учись программировать красиво. Без красоты нет понимания! Нужны отступы[ru.wikipedia.org], комментарии "для тупых" (да, именно такие я пишу для себя), правильное именование имён, функций[ru.wikipedia.org] и т.д. Погоди-ка, ты всё ещё не умеешь программировать на Луа? Если так, то вынужден огорчить, - дальше без основ уже никак. Начинается креатив, создание чего-то нового, а не просто копипаст. Так что, пожалуйста, прочти ТУ САМУЮ книгу. И на этом моменте я перестаю напоминать тебе об этом. Будем считать, что ты уже профи. Итак, здесь есть два ключевых места - это функция onload и функция update. Это как бы "точки входа", потому что эти функции вызывает игра. Они обязаны быть в глобальном пространстве, так что не вздумай поставить перед ними слово "local", иначе они сразу станут невидимыми для игры и просто не сработают никогда. ОптимизацияА вот переменные, константы и вспомогательные функции можно смело делать локальными. Это "невидимое" локальное пространство на самом деле работает чуть шустрее, чем глобальное. Хотя в целом это экономия на спичках, конечно же. Переменную cube мы теперь выносим на уровень выше, чтобы она была доступна в обеих функциях. В onload она инициализируется, а в update - используется. Цвета COL_RED и COL_YELLOW мы тоже делаем отдельными переменными, убивая сразу двух зайцев. Во-первых, так банально красивее - безликие цифры и скобки сразу приобретают смысл. Во-вторых, создание таблицы - дорогое удовольствие в Луа, и лучше сделать это единожды, а затем просто использовать ссылку на таблицу. Также на заметку - старайся делать степень вложенности как можно меньше. Чем меньше, тем красивее и понятней. updateО, это самая коварная функция, и её желательно не использовать. Но если надо, то нужно свести вычисления к минимуму. Это сейчас всё тип-топ, а когда у тебя будет сотня-другая объектов на столе, начнутся тормоза. Ведь функция вызывается каждый тик. Тик - условная единица времени в игре, обычно равная одному кадру. Как ты знаешь, хороший FPS имеет значение не ниже 60. А это значит, что на каждый тик приходится 16мс (миллисекунд). За это время нужно вычислить все скрипты и отрендерить новый кадр в Tabletop Simulator. И проблема в том, что мы не знаем, сколько времени занимает рендеринг. Более того, он тоже зависит от количества объектов и детализации моделек. А ещё время зависит от мощности компа. Поэтому, в идеале не следует использовать эту функцию вообще! Иначе её нужно оптимизировать максимально. Хорошо, если работу скрипта можно "размазать" по нескольким тикам. Иначе остаётся лишь маленький хитрый приём: local my_counter = 0 function update() my_counter = my_counter + 1 if my_counter < 50 then return --Пропуск 50 тиков. end my_counter = 0 --Сброс счётчика тиков. --Далее основная работа функции. end Это не полноценная разгрузка. Но так хотя бы при перегрузе лаг будет лишь каждые 50 кадров, подёргивание будет наблюдаться сносное, да и то только у хоста. Конечно, тебе ещё далеко до такого перегруза. Но сразу бери на заметку, что архитектуру программы лучше сразу продумывать хорошо, а не переделывать по 100 раз. К слову, перегруза и проседания FPS на слабых компах можно добиться вообще без скриптов. Сохранение и загрузка данных скрипта Шутки кончились. Это уже серьёзно. Работает это так: при сохранении игра ищет функцию onSave, и если находит, то вызывает и требует с неё строку для сохранения в файл игры. А при загрузке из сейва игра передаёт эту строку (если она есть) в функцию onload. Вот так относительно просто можно сохранять и загружать данные. Благо разработчики предусмотрительно оставили нам доступ к библиотеке JSON, чтобы запаковать в строку любую таблицу. Правда, там не должно быть ссылок на функции, userdata и прочие непотребства (было бы странно, если бы адреса удачно сохранялись и потом загружались). local SAVED = {} Сразу рекомендую сделать специальную таблицу и назвать её, например, SAVED, - и туда уже заносить то, что нужно сохранить. Здесь возможны два подхода: 1) постоянно обновлять данные в таблице SAVED, либо 2) формировать таблицу SAVED заново при каждом сохранении. Всё зависит от того, что чаще. Автосохранения происходят примерно раз в минуту. А вообще в целом без разницы, какой подход применить. Но решать тебе. Здесь я просто оставлю готовую шпору для копипаста. Лишнее нужно удалить. local SAVED = { Red = { name = 'Красный игрок', player_cube = 'ca018a', }, Blue = { name = 'Синий игрок', player_cube = 'f6626f', }, Green = { name = 'Зелёный игрок', player_cube = '74f5ae', }, } --Функция восполняет недостающие поля в только что загруженном сейве. --Ориентируется по базовому сейву, так что много перебирать не придётся. --Она полезна, когда добавляются новые поля в скрипт, а в старом сейве их нет. local function FixSave(basic,tbl) for k,v in pairs(basic) do if type(tbl[k]) ~= type(v) then if v ~= nil then --допустимо "false", например. print("Wrong type [",tostring(k),"]: ",type(tbl[k]),', ', type(v)) tbl[k] = v --Как правило, это замена nil на таблицу (но не наоборот!) end elseif type(v) == "table" then --типы равные, так что второе тоже таблица. FixSave(v,tbl[k]) --Рекурсия end end end local function InitGame() --Здесь инициализируем игру. cube = getObjectFromGUID(CUBE_GUID) --Например, таким образом. end --Срабатывает при загрузке и сигнализирует о начале работы скрипта. function onload(save_state) --Облом случается в начале игры и при Ctrl+Z на начало игры. if save_state == nil or save_state == '' then InitGame() --Сейва нет. Но return end local BASIC_SAVE = SAVED --Сохраняем ссылку на изначальную структуру сейва. SAVED = JSON.decode(save_state) FixSave(BASIC_SAVE, SAVED) --Вытаскиваем нужные временные данные. last_time = SAVED.last_time --Далее всё как обычно. InitGame() end function onSave() --Подготавливаем SAVED. Дописываем туда нужные данные. SAVED.last_time = last_time --Потом просто возвращаем строку. return JSON.encode(SAVED) end Документация и API Что ж, основные тонкие моменты мы прошли. Всё остальное - дело техники, опыта, терпения и желания, а ещё не повредит базовое знание английского языка. Важные странички в официальной документации всего две: 1) Базовый API[berserk-games.com] Здесь перечислены функции API, доступные из глобального пространства игры. Также здесь перечислены некоторые обработчики событий (их не много, к сожалению). Например, ты можешь отслеживать момент, когда один объект касается другого, создав специальную функцию onCollisionEnter, которая будет вызвана в этот самый момент. Почитай описания функций, там всё более-менее очевидно. Если в описании сказано "this Object", то пользоваться функцией можно только в скрипте, привязанном к объекту, иначе можно также и в глобальном скрипте. Однако ты не можешь, например, отслеживать изменение положение объекта или внезапный переворот карты. Для таких невозможных вещей приходится использовать функцию update - и в цикле проверять нужные свойства нужных объектов.

2) 对象 API(Object)[berserk-games.com] 此处列举了游戏中几乎每个对象的属性和函数。数量非常多,但仍然不够。专用对象具有额外的若干函数,这些函数会单独进行说明。 就是这样。只要在浏览器的单独标签页中打开这两个链接,并且懂Lua语言,你就几乎可以随心所欲地创建真正的脚本了。接下来将是纯粹的创意发挥,充分展现你掌握编程技能的艺术(即编写简洁、清晰、美观且优化的代码,以便自己能在其中快速定位、维护、开发并在必要时进行完善)。 如果遇到复杂或非标准问题,还有官方论坛[www.berserk-games.com]。在那里,虽然可能不会立即得到回复,但他们应该能提供帮助。不过,一个有能力的人应该能够仅依靠文档和自己的实验来解决所有问题,而在论坛上只需要报告那些明显的漏洞。祝你在《桌面模拟器》的事业中一切顺利!