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

5.15. Luau

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

Luau

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

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

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


1. Происхождение и философия 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), одновременно предоставляя профессионалам инструменты для построения надёжных систем.


2. Статическая типизация: от динамики к строгости без потерь

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

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

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

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

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

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

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

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

2.2. Система типов 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

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

2.3. Интеграция с 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]

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

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

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


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

4. Производительность: от интерпретатора к JIT-компиляции в условиях изоляции

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

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

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

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

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

4.2. JIT-компиляция: адаптивная оптимизация под нагрузку

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

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

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

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


5. Архитектура выполнения в Roblox: клиент, сервер и их взаимодействие

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

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

5.1. Модель данных: иерархия Instance

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

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

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

5.2. Событийная модель: RemoteEvent и RemoteFunction

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

  • RemoteEvent: односторонняя отправка данных (fire-and-forget).

    -- Server
    local event = Instance.new("RemoteEvent")
    event.Parent = ReplicatedStorage
    event.OnServerEvent:Connect(function(player, data)
    print(player.Name .. " sent: " .. data)
    end)

    -- Client
    local event = ReplicatedStorage:WaitForChild("RemoteEvent")
    event:FireServer("Hello from client!")
  • 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

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


6. Практический пример: визуальный эффект при прыжке игрока

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

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

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

-- JumpEffectClient.luau
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local player = Players.LocalPlayer
local character = player.Character or player.CharacterAdded:Wait()
local humanoid = character:WaitForChild("Humanoid")

-- Подготавливаем шаблон эффекта (загружен заранее в ReplicatedStorage)
local jumpEffectTemplate = ReplicatedStorage:WaitForChild("JumpEffect"):Clone()

-- Событие для запроса эффекта у сервера
local requestJumpEffect = ReplicatedStorage:WaitForChild("RequestJumpEffect")

humanoid.StateChanged:Connect(function(oldState, newState)
if newState == Enum.HumanoidStateType.Jumping then
-- Отправляем запрос серверу — не создаём эффект напрямую!
requestJumpEffect:FireServer()
end
end)

-- Сервер отвечает через другой RemoteEvent
local playJumpEffect = ReplicatedStorage:WaitForChild("PlayJumpEffect")
playJumpEffect.OnClientEvent:Connect(function(position: Vector3)
local effect = jumpEffectTemplate:Clone()
effect.Position = position
effect.Parent = workspace
effect:Emit(10)
task.delay(2, function() effect:Destroy() end)
end)

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

-- JumpEffectServer.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")

local requestJumpEffect = Instance.new("RemoteEvent")
requestJumpEffect.Name = "RequestJumpEffect"
requestJumpEffect.Parent = ReplicatedStorage

local playJumpEffect = Instance.new("RemoteEvent")
playJumpEffect.Name = "PlayJumpEffect"
playJumpEffect.Parent = ReplicatedStorage

requestJumpEffect.OnServerEvent:Connect(function(player: Player)
-- Валидация: жив ли персонаж? Делал ли он прыжок легально?
local character = player.Character
if not character then return end

local humanoid = character:FindFirstChild("Humanoid")
if not humanoid or humanoid:GetState() ~= Enum.HumanoidStateType.Jumping then
warn(player.Name .. " attempted invalid jump effect")
return
end

-- Определяем позицию: обычно — под центром масс
local rootPart = character:FindFirstChild("HumanoidRootPart")
if not rootPart then return end

local position = rootPart.Position - Vector3.new(0, 2, 0) -- чуть ниже ног
playJumpEffect:FireClient(player, position) -- только этому игроку
end)

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

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

7. Инструментарий разработки: от print() до Language Server Protocol

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

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

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

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

  • автодополнение с учётом типов и Roblox API;
  • переход к определению (Go to Definition);
  • поиск всех ссылок (Find All References);
  • переименование (Rename Symbol);
  • inline-проверку типов в реальном времени;
  • предупреждения о неиспользуемых переменных, неявных any, потенциальных nil-разыменованиях.

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

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

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


8. Практика: создание первой игры — «Сборщик монет»

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

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

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

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

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

-- ServerScriptService/CoinManager.luau
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerStorage = game:GetService("ServerStorage")

local COIN_TEMPLATE = ReplicatedStorage:WaitForChild("CoinTemplate")
local COIN_SPAWN_POINTS = ServerStorage:WaitForChild("CoinSpawns") -- Folder с Part'ами

local coinCount = 0
local coins = {}

local function spawnCoin(at: Vector3)
local coin = COIN_TEMPLATE:Clone()
coin.Position = at
coin.Anchored = true
coin.Parent = workspace

local touchedConn
touchedConn = coin.Touched:Connect(function(hit)
local character = hit:FindFirstAncestorOfClass("Model")
if not character then return end

local player = game.Players:GetPlayerFromCharacter(character)
if not player then return end

-- Валидация: не слишком ли часто?
if os.clock() - (coins[coin] or 0) < 0.5 then return end

coinCount += 1
coins[coin] = os.clock()
touchedConn:Disconnect()
coin:Destroy()

-- Уведомляем клиента
game.ReplicatedStorage.CoinCollected:FireClient(player, coinCount)
end)

coins[coin] = 0
end

-- Спавним по точкам
for _, point in COIN_SPAWN_POINTS:GetChildren() do
if point:IsA("BasePart") then
spawnCoin(point.Position)
end
end

8.3. Клиентский 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)

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

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

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

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


9. Продвинутая типизация: инференс, вариативность и ограничения generics

9.1. Механизм 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

Однако 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
  • Циклы не сужают типы. В отличие от 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
  • Утверждения типов (::) — последнее средство. Они отключают проверку, но не генерируют проверок в рантайме:

    local x: any = "text"
    local len = (x :: string).len -- ошибка: поле len нет у string
    -- Анализатор не проверяет содержимое после `::`

9.2. 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>

Ограничения:

  1. Нет bounded generics (ограничений типов). Нельзя написать:

    -- НЕ РАБОТАЕТ
    function add<T: number | string>(a: T, b: T): T

    Вместо этого используют 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
  2. Нет generic-модулей верхнего уровня. type с generics должны быть определены внутри scope (функция, таблица). Глобальные generic-типы ограничены по сложности.

  3. Инференс 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}
  4. Нет variance-модификации (covariance/contravariance). Luau рассматривает Box<Part> и Box<Instance> как несовместимые типы, даже при наследовании Part : Instance. Это безопасно, но требует ручного кастинга:

    local partBox: Box<Part> = createBox(Instance.new("Part"))
    local instBox: Box<Instance> = partBox :: any -- прямое присваивание невозможно

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


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

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

10.1. Signal: замена RBXScriptSignal

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

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

-- ModuleScript: Signal.luau
export type Connection = { Disconnect: () -> () }
export type Signal<T...> = {
Connect: (self: Signal<T...>, callback: (T...) -> ()) -> Connection,
Fire: (self: Signal<T...>, T...) -> (),
Once: (self: Signal<T...>, callback: (T...) -> ()) -> Connection,
}

type SignalImpl<T...> = {
_callbacks: { (T...) -> () },
_onceCallbacks: { (T...) -> () },
}

local Signal = {}
Signal.__index = Signal

function Signal.new<T...>(): Signal<T...>
return setmetatable({
_callbacks = {},
_onceCallbacks = {},
} :: SignalImpl<T...>, Signal)
end

function Signal:Connect<T...>(callback: (T...) -> ()): Connection
table.insert(self._callbacks, callback)
return {
Disconnect = function()
local index = table.find(self._callbacks, callback)
if index then table.remove(self._callbacks, index) end
end
}
end

function Signal:Once<T...>(callback: (T...) -> ()): Connection
table.insert(self._onceCallbacks, callback)
return {
Disconnect = function()
local index = table.find(self._onceCallbacks, callback)
if index then table.remove(self._onceCallbacks, index) end
end
}
end

function Signal:Fire<T...>(...: T...)
for _, cb in self._callbacks do
task.spawn(cb, ...) -- асинхронный вызов во избежание блокировки
end
for _, cb in self._onceCallbacks do
task.spawn(cb, ...)
end
self._onceCallbacks = {}
end

return Signal

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

  • 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())

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

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

Структура:

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

init.luauServerScriptService и StarterPlayerScripts):

-- init.luau
local Services = {}

-- Ленивая инициализация
function Services.GetService<T>(name: string): T
if not rawget(Services, name) then
local module = require(script.Parent.Services[name])
Services[name] = module.new()
if Services[name].Init then Services[name]:Init() end
end
return Services[name] :: T
end

-- Экспорт в глобальную область (только для внутреннего использования)
_G.Services = Services

-- Запуск
Services.GetService("PlayerService")

PlayerService.luau:

-- Services/PlayerService.luau
type PlayerData = { coins: number, level: number }

local PlayerService = {}
PlayerService.__index = PlayerService

function PlayerService.new()
local self = setmetatable({
_players = {} :: { [Player]: PlayerData },
}, PlayerService)
return self
end

function PlayerService:Init()
game.Players.PlayerAdded:Connect(function(player)
self._players[player] = { coins = 0, level = 1 }
-- Загрузка из DataStore — отдельный сервис
end)

game.Players.PlayerRemoving:Connect(function(player)
self._players[player] = nil
end)
end

function PlayerService:GetCoins(player: Player): number
local data = self._players[player]
return data and data.coins or 0
end

function PlayerService:AddCoins(player: Player, amount: number)
local data = self._players[player]
if data then
data.coins += amount
-- Оповещение GUI через RemoteEvent или Signal
end
end

return PlayerService

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

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

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

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

-- ModuleScript: Promise.luau
export type Promise<T> = {
andThen: <U>(self: Promise<T>, (T) -> U | Promise<U>) -> Promise<U>,
catch: (self: Promise<T>, (string) -> T) -> Promise<T>,
await: (self: Promise<T>) -> T,
}

local Promise = {}
Promise.__index = Promise

function Promise.new<T>(executor: (resolve: (T) -> (), reject: (string) -> ()) -> ())
local self = setmetatable({
_state = "pending" :: "pending" | "fulfilled" | "rejected",
_value = nil :: T?,
_reason = nil :: string?,
_onFulfilled = {} :: { (T) -> () },
_onRejected = {} :: { (string) -> () },
}, Promise)

local function resolve(value: T)
if self._state ~= "pending" then return end
self._state = "fulfilled"
self._value = value
for _, cb in self._onFulfilled do task.spawn(cb, value) end
end

local function reject(reason: string)
if self._state ~= "pending" then return end
self._state = "rejected"
self._reason = reason
for _, cb in self._onRejected do task.spawn(cb, reason) end
end

task.spawn(executor, resolve, reject)
return self
end

-- Реализация andThen, catch, await — опущена для краткости, но доступна в open-source библиотеках (например, NevermoreEngine)

-- Использование:
local p = Promise.new(function(resolve, reject)
task.delay(1, function()
resolve("done")
end)
end)

p:andThen(function(result)
print(result) -- "done"
return result .. " and processed"
end):andThen(print) -- "done and processed"

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


11. Интеграция с внешними инструментами: разработка вне Roblox Studio

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

11.1. Rojo: синхронизация файловой системы и DataModel

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

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

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

{
"name": "MyGame",
"tree": {
"$className": "DataModel",
"ServerScriptService": {
"$className": "ServerScriptService",
"main.lua": {
"$path": "src/server/main.luau"
}
},
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Modules": {
"$path": "src/shared"
}
}
}
}

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

11.2. Selene: линтер для Luau

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"

Selene понимает:

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

11.3. Tarmac и TestEZ: тестирование

  • Tarmac — фреймворк для unit- и интеграционного тестирования Luau-кода, запускаемый в headless-режиме Roblox.
  • TestEZ — BDD-фреймворк (аналог Jasmine), интегрируемый с Tarmac.

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

-- tests/PlayerService.spec.luau
local TestEZ = require(game.ReplicatedStorage.TestEZ)
local PlayerService = require(game.ReplicatedStorage.Modules.PlayerService)

TestEZ.describe("PlayerService", function()
TestEZ.it("starts with 0 coins", function()
local service = PlayerService.new()
local mockPlayer = { Name = "Test" } :: any
service:Init()
service._players[mockPlayer] = { coins = 0, level = 1 }

expect(service:GetCoins(mockPlayer)).to.equal(0)
end)
end)

Запуск через CLI:

rojo build project.project.json -o build.rbxlx
tarmac run build.rbxlx --test-filter="PlayerService"

11.4. CI/CD: сборка и деплой

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

name: Build & Test
on: [push]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: Roblox/setup-foreman@v2
- run: foreman install rojo selene tarmac
- run: rojo build default.project.json -o game.rbxlx
- run: selene src/
- run: tarmac run game.rbxlx --test-path=tests/
- run: rbx-cli publish --cookie "$ROBLOSECURITY" --place-id 123456789 game.rbxlx
env:
ROBLOSECURITY: ${{ secrets.ROBLOSECURITY }}

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

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