Перейти к основному содержимому

Luau - типизированный диалект Lua от Roblox

Разработчику Архитектору

Luau

Что такое Luau?

Luau — это диалект языка программирования Lua, разработанный компанией Roblox Corporation как усовершенствованная, промышленно-ориентированная версия Lua 5.1, адаптированная под нужды платформы Roblox. Хотя синтаксически и семантически Luau остаётся совместимым с Lua 5.1 на уровне базовой лексики и исполнения, он вносит существенные изменения в модель типизации, инструментарий времени разработки и производительность. Эти изменения отвечают на вызовы, с которыми сталкиваются разработчики крупных интерактивных миров — необходимость в статической проверке ошибок, поддержка командной работы, масштабируемость кодовой базы и гарантии безопасности при разделении логики между клиентом и сервером.

Luau — это архитектурное расширение, затрагивающее всю цепочку разработки: от редактирования кода в среде Roblox Studio до исполнения в виртуальной машине на клиенте и сервере. В отличие от многих других форков Lua (например, MoonScript, Fennel или Typed Lua, последний из которых оказал влияние на ранние версии Luau), Luau развивается не как независимый проект с открытым исходным кодом, а как внутренняя технология, глубоко интегрированная в экосистему Roblox, и одновременно — как open-source реализация: с 2020 года Roblox Corporation публикует исходный код компилятора и статического анализатора Luau под лицензией MIT на GitHub. Это позволяет изучать язык как инструмент для создания игр и как интересный кейс эволюции динамически типизированного языка в сторону гибридной типизации без ущерба для производительности и доступности.

Ниже мы подробно рассмотрим как лингвистические, так и системные особенности Luau, а также их проекцию на архитектуру приложений в среде Roblox.


Происхождение и философия Luau

Lua 5.1, выпущенный в 2006 году, остаётся одним из самых компактных и гибких скриптовых языков, идеально подходящим для встраивания в приложения. Его минимализм, наличие корутин, таблиц как универсальной структуры данных и гибкой метапрограммирования через метатаблицы сделали его популярным в игровой индустрии: от World of Warcraft до Factorio. Однако по мере роста сложности проектов, разрабатываемых на платформе Roblox (которая к середине 2010-х перешла от конструктора мини-игр к полноценной платформе для создания persistent-вселенных), выяснилось, что чисто динамическая природа Lua создаёт серьёзные проблемы:

  • отсутствие возможности выявлять опечатки в именах полей или вызовах методов на этапе написания кода;
  • невозможность построения точных контрактов интерфейсов между модулями;
  • сложность поддержки кода в командах из нескольких разработчиков;
  • отсутствие инструментов автодополнения и рефакторинга, сравнимых с теми, что доступны для TypeScript или C#.

Первоначально Roblox использовал Lua 5.1 с некоторыми внутренними расширениями, включая собственную библиотеку для работы с объектной моделью (Roblox Instance API). Однако с 2019 года началась постепенная замена интерпретатора Lua на собственную виртуальную машину, а в 2020 — появление первого прототипа статической типизации. В 2021 году был представлен Luau Type Checker и Luau Language Server, а в 2022 — официальное объявление о переходе всей платформы на Luau.

Ключевой философский принцип Luau — ненавязчивая статика (gradual typing). Это означает:

  • код без аннотаций типов остаётся валидным и исполняется точно так же, как в Lua 5.1;
  • аннотации не влияют на семантику во время выполнения — они обрабатываются только на этапе статического анализа;
  • разработчик может постепенно добавлять типы, начиная с критически важных модулей, не переписывая всю кодовую базу.

Такой подход позволяет сохранить низкий порог входа для новичков (в том числе детей и подростков, составляющих значительную долю аудитории Roblox), одновременно предоставляя профессионалам инструменты для построения надёжных систем.


Статическая типизация

Синтаксис аннотаций типов

Основной способ указания типа — аннотация после двоеточия, как в TypeScript:

local playerName: string = "Guest"
local health: number = 100
local isAlive: boolean = true

Разбор:

  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Аннотации могут быть указаны:

  • для локальных переменных;
  • для параметров функций;
  • для возвращаемых значений (в том числе множественных);
  • для полей таблиц (включая классы, построенные на основе метатаблиц).

Пример функции с полной типизацией:

type Vector3 = { x: number, y: number, z: number }

function calculateDistance(a: Vector3, b: Vector3): number
local dx = a.x - b.x
local dy = a.y - b.y
local dz = a.z - b.z
return math.sqrt(dx*dx + dy*dy + dz*dz)
end

Разбор:

  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • Вызовы из стандартных библиотек math — готовые функции языка, не нужно писать их с нуля.
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.
  • Фигурные скобки {} создают таблицу: можно задать поля key = value и/или элементы-массив с индексами с 1.
  • Ключевые вызовы в фрагменте: math.sqrt().
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Обратите внимание: type — это директива для анализатора. Она не создаёт объектов, не генерирует проверок в рантайме. Это чисто статическая конструкция.


Система типов Luau

Система типов Luau включает:

  • Примитивные типыnil, boolean, number, string, thread, userdata (представлен как Instance и его подтипы — см. ниже).
  • Составные типы:
    • table<K, V> — параметризованный тип таблицы;
    • { field1 — T1, field2: T2, ... } — структурные типы ("анонимные интерфейсы");
    • (T1, T2) -> T3 — тип функции;
  • Специальные типы:
    • any — "выключатель" типизации, эквивалент unknown в TypeScript по строгости (но на практике ближе к any, так как разрешает любые операции);
    • never — невыполнимый тип (например, для функции, всегда вызывающей error);
    • ?T (или T?) — сокращение для T | nil, nilable-тип;
  • Union-типы: string | number, Part | Model | nil;
  • Generics (универсальные типы), введённые в 2023 году:
type LinkedList<T> = { value: T, next: LinkedList<T>? }

function map<T, U>(list: LinkedList<T>, f: (T) -> U): LinkedList<U>
if list == nil then return nil end
return { value = f(list.value), next = map(list.next, f) }
end

Разбор:

  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.
  • Фигурные скобки {} создают таблицу: можно задать поля key = value и/или элементы-массив с индексами с 1.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Генерики поддерживаются как на уровне type, так и на уровне функций. Важно — инференс (выведение типов) в Luau мощный — если параметры функции типизированы, а возвращаемое значение не указано явно, анализатор попытается его вывести. Однако для рекурсивных функций и сложных полиморфных случаев явная аннотация возвращаемого типа обязательна.


Интеграция с Roblox Instance API

Одна из самых значимых особенностей Luau — глубокая интеграция со структурой объектов Roblox. В платформе все сущности (персонажи, части мира, скрипты) представлены как экземпляры класса Instance, иерархия которого задаётся статически (например, Part — BasePart — Instance, Script : LuaSourceContainer : Instance, Player : Instance). Luau знает эту иерархию и позволяет использовать имена классов как типы:

local part: Part = Instance.new("Part")
local player: Player = game.Players:GetPlayers()[1]

Разбор:

  • Код выполняется построчно: каждая инструкция использует значения, созданные строками выше.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Более того, при доступе к свойствам и методам через точку или двоеточие IDE (через Language Server) предоставляет точную подсказку:

part.Size = Vector3.new(5, 5, 5) -- ok
part.Health = 100 -- ошибка: Part не имеет свойства Health

Разбор:

  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Это достигается за счёт встроенного type definition для всей Roblox API, поддерживаемого командой Roblox и обновляемого с каждой версией движка. Таким образом, типизация охватывает взаимодействие с платформой.


Совместимость с Lua 5.1 и отклонения

Luau сохраняет почти полную обратную совместимость с Lua 5.1 на уровне синтаксиса и семантики выполнения. Можно скопировать любой валидный Lua 5.1-скрипт — он будет работать в Roblox без изменений (если не использует ограниченные API, например, os.execute или io.*).

Однако есть важные оговорки:

  1. Нет поддержки goto и меток. Это сознательное решение, вызванное сложностью интеграции с типовой системой и JIT-компиляцией.
  2. Ограниченная поддержка замыканий в for-циклах. В Lua 5.1 переменная цикла захватывается по ссылке, что приводит к известной ошибке:
local funcs = {}
for i = 1, 3 do
funcs[i] = function() print(i) end
end
funcs[1]() -- печатает 4

Разбор:

  • Цикл for повторяет тело: в числовой форме перебирает диапазон, в generic — элементы через итератор.

  • Ключевые вызовы в фрагменте: print().

  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.

  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

    В Luau это поведение изменено: переменная цикла захватывается по значению, как в Lua 5.2+. Это не нарушает совместимость на уровне валидности кода, но изменяет семантику.

  1. Отсутствие debug.* API, кроме ограниченных функций вроде debug.traceback. Безопасность платформы требует изоляции скриптов.
  2. Другие запрещённые глобальныеloadstring, dofile, package.*, os.*, io.*. Доступны только print, warn, assert, error, math.*, string.*, table.*, utf8.* и некоторые другие.
  3. Синтаксические расширения (типы, type, export type) не являются валидным Lua 5.1. Это означает, что код с аннотациями не будет работать в стандартной Lua-среде без транспиляции. Однако транспилятор Luau (в составе компилятора) автоматически удаляет аннотации перед генерацией байткода — в рантайме их нет.

Производительность

Любая скриптовая платформа, ориентированная на реальное время (real-time interactivity), сталкивается с фундаментальным противоречием: гибкость динамического языка и предсказуемая производительность. Ранние версии Roblox использовали интерпретатор LuaJIT 2.0, но его интеграция оказалась небезопасной и несовместимой с архитектурой sandbox’а. Поэтому Roblox разработал собственный виртуальный процессор (Luau VM) с последующим внедрением JIT-компилятора, оптимизированного под специфику платформы.


Архитектура Luau VM

Luau VM — это стек-машина с 32-битным байткодом, построенная по образцу Lua 5.1 VM, но с рядом изменений:

  • расширенный набор инструкций — более 80 против 38 в Lua 5.1, включая специализированные для работы с таблицами и вызовами методов (GETIMPORT, FASTCALLn);
  • поддержка "быстрых путей" для частых операций — например, прямой доступ к полям через смещения, если структура таблицы стабильна;
  • inline-кэширование вызовов функций и метаметодов.

Байткод генерируется статическим компилятором Luau, который включает в себя лексический, синтаксический анализ и анализ потока типов (type flow analysis). Это позволяет, например, устранять избыточные проверки nil или объединять последовательные операции на этапе компиляции.


JIT-компиляция

JIT в Luau активируется автоматически для "горячих" функций — тех, которые вызываются часто (порог настраивается, по умолчанию ~100 вызовов). Он транслирует байткод в нативный x64-код (на Windows/macOS) или ARM64 (на iOS/Android) с учётом профиля выполнения:

  • Инлайнинг: маленькие функции встраиваются непосредственно в вызывающий код.
  • Деоптимизация по demand — если во время выполнения встречается значение другого типа (например, ожидался number, пришёл string), JIT-код деоптимизируется и возвращается к интерпретируемому режиму для этой ветви — без краха приложения. Это критически важно для gradual typing.
  • Оптимизация замыканий — переменные, захваченные в замыкании, могут быть размещены в регистрах, а не в куче, если анализ показывает, что их жизненный цикл ограничен.
  • Устранение границ проверок (bounds check elimination) для индексов таблиц при стабильных шаблонах доступа.

Важно: JIT работает только на стороне клиента. Серверные скрипты (в облаке Roblox Cloud) исполняются в режиме интерпретации с агрессивной оптимизацией байткода, но без генерации нативного кода — из соображений детерминизма и воспроизводимости (все серверы должны выдавать одинаковый результат при одинаковом входе).

Это разделение — сознательный дизайн: клиент может жертвовать детерминизмом ради плавности анимации и отклика, тогда как сервер обязан обеспечивать согласованность состояния мира для всех участников.


Архитектура выполнения в Roblox

В отличие от классических приложений, где клиент и сервер — разные процессы с чётким разделением ответственности, в Roblox граница проходит через один и тот же скрипт. Разработчик управляет контекстом выполнения, задаваемым типом скрипта:

Тип скриптаКонтекстОписание
ScriptСерверВыполняется только на сервере. Имеет полный доступ к экземплярам, может изменять состояние мира для всех.
LocalScriptКлиентВыполняется только на клиенте конкретного игрока. Имеет доступ к PlayerGui, может читать (но не писать) большинство свойств серверных объектов.
ModuleScriptЛюбойКонтейнер для кода, импортируемого (require()) другими скриптами. Может содержать и клиентскую, и серверную логику — в зависимости от того, кто его вызывает.

Модель данных

Вся виртуальная вселенная Roblox представлена в виде древовидной структуры объектов, наследующих от Instance. Корень — game, псевдоним для DataModel. Наиболее важные ветви:

  • game.Workspace — физическое пространство мира — части, модели, персонажи. Доступен и клиенту, и серверу.
  • game.Players — коллекция всех игроков. Сервер может изменять состав (например, банить), клиент видит только себя и других (только чтение).
  • game.ReplicatedStorage — область, синхронизируемая от сервера к клиентам. Используется для хранения общих ресурсов — модулей, ассетов, шаблонов персонажей. Изменения здесь реплицируются автоматически.
  • game.ReplicatedFirst — аналогично, но репликация происходит до загрузки игрока (для критически важных ресурсов).
  • game.ServerScriptService, game.ServerStorage, game.StarterPlayer — серверные/клиентские контейнеры, недоступные на противоположной стороне.

Репликация — ключевой механизм синхронизации. Не все изменения транслируются: только свойства, помеченные как Replicated (внутренний флаг API). Например, изменение Part.Position реплицируется, а изменение локальной переменной в скрипте — нет.


Событийная модель

Прямой вызов кода на другой стороне невозможен. Взаимодействие осуществляется через события:

  • RemoteEvent: односторонняя отправка данных (fire-and-forget).
-- Server
local event = Instance.new("RemoteEvent")
event.Parent = ReplicatedStorage
event.OnServerEvent:Connect(function(player, payload)
print(player.Name .. " sent: " .. payload)
end)

-- Client
local event = ReplicatedStorage:WaitForChild("RemoteEvent")
event:FireServer("Hello from client!")

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).

  • События Roblox связывают клиент и сервер: прямого вызова чужого скрипта нет, только обмен сообщениями.

  • Ключевые вызовы в фрагменте: print().

  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.

  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

  • RemoteFunction: двухсторонний вызов с возвратом значения (синхронный на клиенте, асинхронный на сервере).

-- Server
local func = Instance.new("RemoteFunction")
func.Parent = ReplicatedStorage
func.OnServerInvoke = function(player, x, y)
return x * y
end

-- Client
local result = func:InvokeServer(6, 7) -- result = 42

Разбор:

  • return завершает функцию и может вернуть несколько значений через запятую.
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Важно: RemoteFunction:InvokeServer() блокирует клиентский поток до ответа. Злоупотребление приводит к "подвисанию" клиента. Рекомендуется использовать RemoteEvent + коллбэки для асинхронных сценариев.


Пример — визуальный эффект при прыжке игрока

Рассмотрим реализацию эффекта частиц, появляющегося под ногами игрока в момент прыжка. Это требует:

  • обнаружения события прыжка (клиент);
  • проверки валидности (сервер);
  • создания частиц (клиент, но по команде сервера или с его разрешения).

Клиентская часть (LocalScript в StarterPlayerScripts)

Код ITЗагрузка примера кода…

Разбор:

  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • События Roblox связывают клиент и сервер: прямого вызова чужого скрипта нет, только обмен сообщениями.
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Серверная часть (Script в ServerScriptService)

Код ITЗагрузка примера кода…

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • События Roblox связывают клиент и сервер: прямого вызова чужого скрипта нет, только обмен сообщениями.
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Архитектурные замечания

  • Безопасность: клиент не создаёт эффект напрямую — только по разрешению сервера. Это предотвращает читерство (например, спам частицами для маскировки).
  • Эффективность: шаблон JumpEffectParticleEmitter, загруженный один раз в ReplicatedStorage. Его клонирование дешевле, чем создание через Instance.new("ParticleEmitter").
  • Изоляция — каждый клиент создаёт эффект локально — другие игроки не получают данные о частицах (если не требуется синхронизация, например, для взрывов), что экономит трафик.

Инструментарий разработки

Отладка в среде

Roblox Studio предоставляет:

  • Output — лог print(), warn(), error().
  • Developer Console (Ctrl+F9) — во время игры позволяет:
    • просматривать стек вызовов;
    • устанавливать точки останова (breakpoints) в скриптах;
    • инспектировать переменные в текущем фрейме;
    • выполнять произвольный код в контексте (REPL-подобный режим).

Однако print() остаётся основным инструментом из-за простоты. Для структурированного логгирования рекомендуется обёртка:

local Logger = {}
Logger.Level = "INFO" -- DEBUG, INFO, WARN, ERROR

function Logger.debug(msg: string)
if Logger.Level == "DEBUG" then print("[DEBUG] " .. msg) end
end

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Ключевые вызовы в фрагменте: print().
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Luau LSP и статический анализ

В 2022 году Roblox выпустил Luau Language Server — реализацию Language Server Protocol (LSP), интегрируемую в VS Code, Sublime Text, Neovim и др. Он предоставляет:

  • автодополнение с учётом типов и Roblox API;
  • переход к определению (Go to Definition);
Go to Definition

Разбор:

  • Команда или конфигурация окружения: выполняется вне интерпретатора Lua.

  • В фрагменте 1 строк(и); читайте сверху вниз как последовательность шагов.

  • поиск всех ссылок (Find All References);

Find All References

Разбор:

  • Команда или конфигурация окружения: выполняется вне интерпретатора Lua.

  • В фрагменте 1 строк(и); читайте сверху вниз как последовательность шагов.

  • переименование (Rename Symbol);

  • inline-проверку типов в реальном времени;

  • предупреждения о неиспользуемых переменных, неявных any, потенциальных nil-разыменованиях.

Анализатор также поддерживает strict mode (включается через --!strict в начале файла), который:

  • запрещает необъявленные глобальные переменные;
  • требует аннотации для function, local, параметров;
  • интерпретирует nil как отдельный тип (без автоматического объединения).

Это приближает Luau к "промышленному" уровню TypeScript.


Практика на Roblox — учебный маршрут

Полноценный проект с DataStore, этапами и магазином — в практикуме "обби". Studio и первый Place — Roblox Studio — первая игра и настройки Place. В примерах ниже используйте task.spawn / task.wait вместо устаревших spawn / wait.

УстаревшееЗамена в Luau
spawn(function() … end)task.spawn(function() … end)
wait(n)task.wait(n)
delay(n, fn)task.delay(n, fn)

Практика — создание первой игры — "Сборщик монет"

Краткий мини-проект для закрепления RemoteEvent и GUI. Для полного учебного цикла (чекпоинты, DataStore, магазин) откройте практикум "обби".

Для закрепления теории рассмотрим пошаговое создание мини-игры, демонстрирующей ключевые концепции:

  1. Цель — игрок управляет персонажем, собирает монеты (Part с TouchInterest), счёт отображается на экране.
  2. Архитектура:
    • сервер — спавн монет, хранение счёта, валидация сбора;
    • клиент: отображение GUI, анимация сбора;
    • модуль: общая логика (например, CoinSpawner).

Создание монеты

  • В ReplicatedStorage создаём PartCoinTemplate:
    • BrickColor = BrickColor.new("Bright yellow")
    • Shape = Enum.PartType.Ball, Size = Vector3.new(1,1,1)
    • Добавляем ClickDetector или используем Touched (осторожно: Touched срабатывает при любом контакте, включая стены).

Серверный скрипт спавна

Код ITЗагрузка примера кода…

Разбор:

  • Одинарные и двойные кавычки эквивалентны: выбирайте тот вид, где меньше экранирования внутри текста.
  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.
  • Цикл for повторяет тело: в числовой форме перебирает диапазон, в generic — элементы через итератор.
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • Ключевые вызовы в фрагменте: os.clock().
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Клиентский GUI

  • В StarterGui создаём ScreenGuiTextLabel (CoinCounter).
  • LocalScript в StarterPlayerScripts:
-- LocalScript/CoinUI.luau
local Players = game:GetService("Players")
local player = Players.LocalPlayer

local coinCounter = player:WaitForChild("PlayerGui"):WaitForChild("CoinCounter")

game.ReplicatedStorage.CoinCollected.OnClientEvent:Connect(function(count: number)
coinCounter.Text = "Монет: " .. tostring(count)
end)

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Ключевые вызовы в фрагменте: tostring().
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Обучение через ограничения

Эта простая игра уже включает:

  • работу с иерархией Instance;
  • событийную модель (Touched);
  • клиент-серверное взаимодействие;
  • валидацию входных данных;
  • репликацию состояния через RemoteEvent;
  • работу с GUI.

Дальнейшее развитие — добавить таймер, уровни, сохранение прогресса через DataStoreService, звуки, анимации.


Продвинутая типизация

Механизм inference и его границы

Luau использует flow-sensitive type inference (чувствительный к потоку управления вывод типов), что означает: тип переменной может меняться в зависимости от ветвления.

local x = math.random() > 0.5 and "hello" or 123
-- x: string | number

if typeof(x) == "string" then
print(x:upper()) -- ok: в этой ветке x: string
else
print(x + 1) -- ok: x: number
end

Разбор:

  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • Вызовы из стандартных библиотек math — готовые функции языка, не нужно писать их с нуля.
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Ключевые вызовы в фрагменте: math.random(), print().
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Однако inference ограничен:

  • Нет полиморфного рекурсивного вывода. Для рекурсивных функций аннотация возвращаемого типа обязательна:
-- ОШИБКА: Cannot infer type for recursive function
function factorial(n)
if n <= 1 then return 1 end
return n * factorial(n - 1)
end

-- Правильно:
function factorial(n: number): number
if n <= 1 then return 1 end
return n * factorial(n - 1)
end

Разбор:

  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.

  • Цикл for повторяет тело: в числовой форме перебирает диапазон, в generic — элементы через итератор.

  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.

  • return завершает функцию и может вернуть несколько значений через запятую.

  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.

  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.

  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.

  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

  • Циклы не сужают типы. В отличие от TypeScript, Luau не отслеживает изменения в циклах:

local items: {string | number} = {"a", 1, "b", 2}
for _, v in items do
if typeof(v) == "string" then
-- v здесь всё ещё string | number
-- приходится использовать утверждение:
local s = v :: string
print(s:upper())
end
end

Разбор:

  • Цикл for повторяет тело: в числовой форме перебирает диапазон, в generic — элементы через итератор.

  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.

  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.

  • Фигурные скобки {} создают таблицу: можно задать поля key = value и/или элементы-массив с индексами с 1.

  • Ключевые вызовы в фрагменте: print().

  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.

  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

  • Утверждения типов (::) — последнее средство. Они отключают проверку, но не генерируют проверок в рантайме:

local x: any = "text"
local _ = (x :: number) + 1 -- анализатор может не поймать ошибку после `::`
-- Предпочитайте `typeof(x) == "string"` вместо слепого cast

Разбор:

  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Generics

Luau поддерживает generics с 2023 года, но с оговорками. Синтаксис:

type Box<T> = { value: T }

function createBox<T>(value: T): Box<T>
return { value = value }
end

local strBox = createBox("hello") -- Box<string>
local numBox = createBox(42) -- Box<number>

Разбор:

  • return завершает функцию и может вернуть несколько значений через запятую.
  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.
  • Фигурные скобки {} создают таблицу: можно задать поля key = value и/или элементы-массив с индексами с 1.
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Ограничения

  1. Нет bounded generics (ограничений типов). Нельзя написать:
-- НЕ РАБОТАЕТ
function add<T: number | string>(a: T, b: T): T

Разбор:

  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.

  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

    Вместо этого используют union-типы в параметрах:

function add(a: number | string, b: number | string): number | string
if typeof(a) == "number" and typeof(b) == "number" then
return a + b
elseif typeof(a) == "string" and typeof(b) == "string" then
return a .. b
else
error("Invalid types")
end
end

Разбор:

  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.
  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Ключевые вызовы в фрагменте: error().
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.
  1. Нет generic-модулей верхнего уровня. type с generics должны быть определены внутри scope (функция, таблица). Глобальные generic-типы ограничены по сложности.

  2. Инференс generics в вызовах — частичный. Если хотя бы один параметр аннотирован, тип выводится:

function map<T, U>(list: {T}, f: (T) -> U): {U} ... end
local strings = map({1,2,3}, function(x) return tostring(x) end) -- {string}

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • return завершает функцию и может вернуть несколько значений через запятую.
  • Фигурные скобки {} создают таблицу: можно задать поля key = value и/или элементы-массив с индексами с 1.
  • Ключевые вызовы в фрагменте: tostring().
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.
  1. Нет variance-модификации (covariance/contravariance). Luau рассматривает Box<Part> и Box<Instance> как несовместимые типы, даже при наследовании Part : Instance. Это безопасно, но требует ручного кастинга:
local partBox: Box<Part> = createBox(Instance.new("Part"))
local instBox: Box<Instance> = partBox :: any -- прямое присваивание невозможно

Разбор:

  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Эти ограничения — компромисс между выразительностью и производительностью анализа. В крупных проектах это компенсируется использованием фабрик и интерфейсов.


Паттерны проектирования в экосистеме Luau

Roblox-разработка породила собственные идиомы, адаптированные под ограничения платформы — отсутствие true-множественного наследования, необходимость изоляции клиент/сервер, и высокая стоимость создания Instance.


Signal

Встроенная система событий Roblox (BindableEvent, RemoteEvent) неудобна для внутренней логики — они — Instance, их создание дорого, и они не поддерживают типизацию параметров.

Поэтому повсеместно используется паттерн Signal — pure-Lua реализация события:

Код ITЗагрузка примера кода…

Разбор:

  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.
  • Синтаксис obj:method() передаёт obj первым аргументом (self) — удобный стиль для методов таблиц.
  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • Цикл for повторяет тело: в числовой форме перебирает диапазон, в generic — элементы через итератор.
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • setmetatable связывает таблицу с метатаблицей; поля __… задают перехват операций (индекс, арифметика, вызов).
  • Вызовы из стандартных библиотек table — готовые функции языка, не нужно писать их с нуля.
  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.

Преимущества:

  • 100% Lua, нет Instance;
  • полная типизация параметров и возвращаемых значений;
  • поддержка variadic generics;
  • совместимость с Luau LSP (автодополнение в Connect работает корректно).

Использование:

local Signal = require(script.Parent.Signal)

local playerDied = Signal.new<Player, number>() -- Player, время смерти (сек)

playerDied:Connect(function(player, time)
print(player.Name .. " died at " .. time)
end)

playerDied:Fire(somePlayer, os.clock())

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • require("имя") загружает модуль один раз и возвращает его публичную таблицу из кэша package.loaded.
  • Ключевые вызовы в фрагменте: os.clock(), print(), require().
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Service Locator и модульная архитектура

Крупные проекты (>100 скриптов) структурируются по принципу сервисов — одиночных (Singleton) модулей, управляемых глобальным локатором.

Структура:

src/
├── Services/
│ ├── PlayerService.luau
│ ├── EconomyService.luau
│ └── ...
├── Shared/
│ └── Types.luau
└── init.luau -- точка входа

Разбор:

  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • Комментарии после -- поясняют ожидаемый результат; при запуске интерпретатор их игнорирует.
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

init.luauServerScriptService и StarterPlayerScripts):

Код ITЗагрузка примера кода…

Разбор:

  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • require("имя") загружает модуль один раз и возвращает его публичную таблицу из кэша package.loaded.
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Ключевые вызовы в фрагменте: rawget(), require().
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

PlayerService.luau:

Код ITЗагрузка примера кода…

Разбор:

  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.
  • Синтаксис obj:method() передаёт obj первым аргументом (self) — удобный стиль для методов таблиц.
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • setmetatable связывает таблицу с метатаблицей; поля __… задают перехват операций (индекс, арифметика, вызов).
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.
  • События Roblox связывают клиент и сервер: прямого вызова чужого скрипта нет, только обмен сообщениями.

Преимущества:

  • Чёткое разделение ответственности;
  • Легко mock’ать в тестах;
  • Возможность lazy-загрузки.

Promise-подобные конструкции (без async/await)

Luau не имеет async/await, но поддерживает корутины через task.spawn, task.delay, task.wait. Для композиции асинхронных операций используется паттерн Promise, реализованный в стиле Lua:

Код ITЗагрузка примера кода…

Разбор:

  • function объявляет функцию; end закрывает тело. Имя после function становится ссылкой на вызываемый объект.
  • Оператор .. склеивает строки (и автоматически приводит числа к строке при конкатенации).
  • Цикл for повторяет тело: в числовой форме перебирает диапазон, в generic — элементы через итератор.
  • if … then … end выбирает ветку по truthiness: ложными считаются только nil и false.
  • Логика записывается словами and/or/not, а не символами &&/||/! как в C-подобных языках.
  • return завершает функцию и может вернуть несколько значений через запятую.
  • setmetatable связывает таблицу с метатаблицей; поля __… задают перехват операций (индекс, арифметика, вызов).
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • type Имя = { … } в Luau объявляет именованный тип для статической проверки.

Важно: такие реализации не эквивалентны JS-Promise по семантике (не имеют микрозадач), но решают задачу управления асинхронным потоком.


Интеграция с внешними инструментами

Roblox Studio — удобная среда, но непригодна для командной работы, CI/CD и тестирования. Поэтому развиты инструменты для внешней разработки.


Rojo

Rojo — open-source утилита, которая:

  • монтирует иерархию Instance Roblox как файловую систему;
  • позволяет писать скрипты в .luau-файлах, а не в окне Studio;
  • поддерживает сборку проектов (default.project.json);
  • интегрируется с Git, CI, редакторами.

Пример default.project.json:

Код ITЗагрузка примера кода…

Разбор:

  • Конфигурационный файл: описывает структуру проекта, а не исполняемый Lua-код.
  • В фрагменте 18 строк(и); читайте сверху вниз как последовательность шагов.

Rojo запускается в watch-режиме: любое изменение в src/ мгновенно отражается в запущенном Roblox Studio.


Selene

Selene — официальный линтер от Roblox, заменяющий устаревший StyLua.

Конфигурация (selene.toml):

std = "roblox+testez"

[config]
unused_variable = "allow" # в учебных проектах — allow, в продакшене — "deny"
global_usage = "allow" # для _G, game и т.д.

[rules]
roblox_unsafe_call = "error" # запрет os.time(), warn()
unused_variable = "warn"

Разбор:

  • Ключевые вызовы в фрагменте: os.time().
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Selene понимает:

  • Roblox API;
  • аннотации Luau;
  • особенности task и coroutine;
  • common pitfalls (например, захват переменной цикла).

TestEZ и запуск тестов

  • TestEZ — BDD-фреймворк (аналог Jasmine) для unit-тестов Luau в Roblox.
  • run-in-roblox / lemur / CI-плагины — запуск тестов вне Studio (зависит от настройки проекта).
  • Tarmac — отдельный инструмент для ассетов (текстуры, звуки), не для unit-тестов; не путать с TestEZ.

Пример теста:

Код ITЗагрузка примера кода…

Разбор:

  • require("имя") загружает модуль один раз и возвращает его публичную таблицу из кэша package.loaded.
  • Двоеточие после имени — аннотация типа Luau; на выполнение в рантайме не влияет, помогает анализатору и IDE.
  • Фигурные скобки {} создают таблицу: можно задать поля key = value и/или элементы-массив с индексами с 1.
  • Ключевые вызовы в фрагменте: require().
  • При переносе в свой проект сохраните порядок шагов и проверьте результат через print или отладчик.

Запуск через CLI (типичная связка Rojo + TestEZ; точная команда зависит от runner в проекте):

rojo build default.project.json -o build.rbxlx
# далее — test runner вашей команды (run-in-roblox, foreman и т.д.)

Разбор:

  • Команда или конфигурация окружения: выполняется вне интерпретатора Lua.
  • В фрагменте 2 строк(и); читайте сверху вниз как последовательность шагов.

CI/CD

Типичный pipeline в GitHub Actions:

Код ITЗагрузка примера кода…

Разбор:

  • Команда или конфигурация окружения: выполняется вне интерпретатора Lua.
  • В фрагменте 15 строк(и); читайте сверху вниз как последовательность шагов.

Это позволяет:

  • гарантировать отсутствие regression’ов;
  • форсировать стандарты кода;
  • автоматически деплоить в Roblox после успешного merge в main.