Stormworks: Build and Rescue

Stormworks: Build and Rescue

评价数不足
Архетиктура приложений и кооперативная многозадачность в swLUA
由 hostbanani 制作
Это руководство предназначено для тех, кто хочет создавать сложные приложения на Lua, но сталкивается с ограничениями языка. В нем я опишу способы использования планировщика задач для упрощения кода и создания модульных решений, избавив вас от необходимости строить интерфейсы с бесконечными конструкциями if, then, elseif и так далее.
   
奖励
收藏
已收藏
取消收藏
Начало
Поскольку данное руководство предназначено для опытных пользователей языка программирования, я не буду детально разбирать основы его конструкций. Если какие-то моменты остаются непонятными, рекомендую использовать поисковик для их изучения.

Перед началом чтения у вас могут возникнуть некоторые вопросы, и я постараюсь на них ответить:

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

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

  • Как понять, что скрипт достаточно сложный, чтобы применить эти принципы?
    Если вы начинаете теряться в коде, который превращается в труднопонимаемое «спагетти», или если вам нужно получать данные от нескольких источников, это хороший сигнал, что стоит использовать более структурированные (модульные) подходы.

Если ваш скрипт выполняет только простые математические операции, то, возможно, вам и не нужно применять эти методы. Но если задачи становятся более сложными (разветвленными), это может существенно улучшить управление кодом.
Понятие задача
В данном руководстве все улучшения структуры кода будут связаны с разбиением кода на отдельные задачи.

Что такое задача?
Задача — это логическая или графическая единица работы, выполнение и управление которой берет на себя менеджер задач.

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

Менеджеры задач можно разделить на три основные типа в зависимости от способа вызова задач:

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

  • Менеджер стека
    Выполняет только последнюю поставленную задачу. Такой подход особенно полезен для интерфейсов с переходами между окнами. Например, открывая новое окно, предыдущее окно остается "свернутым" на предыдущем уровне, и после завершения текущего окна вы возвращаетесь обратно.

  • Менеджер FIFO
    Обрабатывает первую задачу из списка. Этот тип менеджера удобен для случаев, когда нужно передать несколько пакетов данных через одну шину. Задачи добавляются в очередь, и, по мере завершения передачи каждого пакета, автоматически начинает выполняться следующая задача.
Кооперативный менеджер
Этот менеджер задач позволяет ставить перед вашим скриптом задачи, которые он будет выполнять на постоянной основе.

Вот пример реализации такого менеджера:

function makeManager() local tasks = {list = {}} -- создаем список задач function tasks.create(self, tick, draw) -- создаем метод для добавления задач, отдельно логическую и графическую части tick, draw = tick or function() end, draw or function() end -- если логическая или графическая часть не передана, заменяем на заглушку table.insert(self.list, {tick = tick, draw = draw}) -- добавляем обе части задачи в список end function tasks.run(self, flag) -- создаем метод выполнения задач -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" for id, task in pairs(self.list) do -- перебираем все задачи -- если задача возвращает true, удаляем её if task[flag]() then self.list[id] = nil end end end return tasks end tasks = makeManager() function onTick() tasks:run() end -- выполняем логические задачи function onDraw() tasks:run(1) end -- выполняем графические задачи

В данном случае этот код ничего не делает, а только является основой для вашей программы. Давайте теперь попробуем добавить функционал. После предыдущего куска кода добавим следующее:

-- объявляем несколько глобальных переменных tsX, tsY, tsW, tsH, tsBool = 0, 0, 0, 0, false count = 0 -- создаем задачу для получения информации от дисплея tasks:create(function() tsX = input.getNumber(3) tsY = input.getNumber(4) tsW = input.getNumber(1) tsH = input.getNumber(2) tsBool = input.getBool(1) end) -- добавим функцию для создания кнопок function addButton(text, event, x, y, w, h) event = event or function() end local textFunction = text if type(text) ~= "function" then textFunction = function() return text end end return function() if tsBool and tsX >= x and tsX <= x + w and tsY >= y and tsY <= y + h then event() -- вызываем событие, если кнопка нажата end end, function() screen.drawRect(x, y, w, h) -- рисуем кнопку screen.drawTextBox(x, y, w, h, textFunction(), 0, 0) -- добавляем текст end end -- Создаем 3 отдельные кнопки в планировщике задач. tasks:create( addButton( function()return count end, nil, 2, 2, 91, 10) ) tasks:create( addButton( "-", function() count = count - 1 end, 2, 15, 43, 10) ) tasks:create( addButton( "+", function() count = count + 1 end, 50, 15, 43, 10) )
Преимущество такого подхода в модульности и масштабируемости, а также в исключении конфликтов между частями приложения.

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

function addButton(text, event, x, y, w, h) event = event or function() end local textFunction, oldValue = text, true if type(text) ~= "function" then textFunction = function() return text end end return function() if oldValue and tsBool and tsX >= x and tsX <= x + w and tsY >= y and tsY <= y + h then event() -- вызываем событие, если кнопка нажата end oldValue = not tsBool end, function() screen.drawRect(x, y, w, h) -- рисуем кнопку screen.drawTextBox(x, y, w, h, textFunction(), 0, 0) -- добавляем текст end end

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

Теперь представим, что при нажатии на + нам нужно создать задачу, которая будет увеличивать счет на протяжении 1 секунды. Для этого нужно просто изменить событие нажатия на кнопку. Сделаем так:

function addCounter(mod, number) return function() count = count + mod number = number - 1 return number <= 0 end end tasks:create( addButton( "-", function() tasks:create(addCounter(-1, 60)) end, 2, 15, 43, 10) ) tasks:create( addButton( "+", function() tasks:create(addCounter(1, 60)) end, 50, 15, 43, 10) )
Вот таким образом вы можете параллельно выполнять несколько процессов и обеспечивать их взаимодействие на примере простого счетчика.Также вы могли пронаблюдать простоту модификации и поддержки такого кода.

Для дополнительного пояснения добавлю показатель числа активных задач:
Видим число 4. Давайте убедимся и посчитаем сколько должно быть.
1 - задача обновляющая информацию от экрана
2 - циферблат
3, 4 - кнопки + и -
Как видим все сходится. Давайте теперь попробуем пару раз нажать на + :
Задач стало 6 потому что мы создали 2 задачи добавляющие значение на счетчик и через время они пропадут и задач снова станет 4
Менеджер стека
Теперь рассмотрим использование стека процессов. Программисты часто стремятся к простоте и повторному использованию кода, поэтому нет смысла создавать новый менеджер ради небольшого изменения функционала. Если задуматься, вся разница между текущими методами заключается лишь в способе вызова процессов. Поэтому разумно расширить уже существующий менеджер, добавив функцию обработки стека.
Основной код менеджера:
function makeManager() -- создаем список задач local tasks = {list = {}} -- создаем метод для добавления задач, отдельно логическую и графическую части function tasks.create(self, tick, draw) -- если логическая или графическая часть не передана, заменяем на заглушку tick, draw = tick or function() end, draw or function() end -- добавляем обе части задачи в список table.insert(self.list, {tick = tick, draw = draw}) end -- создаем метод выполнения задач списком function tasks.runList(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- перебираем все задачи for id, task in pairs(self.list) do -- если задача возвращает true, удаляем её if task[flag]() then self.list[id] = nil end end end -- создаем метод выполнения задач стэком function tasks.runStack(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- если задача возвращает true, удаляем её if self.list[#self.list][flag]() then self.list[#self.list] = nil end end return tasks end tasks = makeManager() -- выполняем логические задачи function onTick() tasks:runList() end -- выполняем графические задачи function onDraw() tasks:runList(1) end -- объявляем несколько глобальных переменных tsX, tsY, tsW, tsH, tsBool = 0, 0, 0, 0, false -- создаем задачу для получения информации от дисплея tasks:create(function() tsX = input.getNumber(3) tsY = input.getNumber(4) tsW = input.getNumber(1) tsH = input.getNumber(2) tsBool = input.getBool(1) end)

При этом заметьте что я не заставил основу программы выполнятся стэком. Мы создадим задачу которая будет вызывать этот планировщик задач внутри задачи программы как в прошлом примере.
создадим простое окно используя кнопки из прошлого раздела:
function makeManager() -- создаем список задач local tasks = {list = {}} -- создаем метод для добавления задач, отдельно логическую и графическую части function tasks.create(self, tick, draw) -- если логическая или графическая часть не передана, заменяем на заглушку tick, draw = tick or function() end, draw or function() end -- добавляем обе части задачи в список table.insert(self.list, {tick = tick, draw = draw}) end -- создаем метод выполнения задач списком function tasks.runList(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- перебираем все задачи for id, task in pairs(self.list) do -- если задача возвращает true, удаляем её if task[flag]() then self.list[id] = nil end end end -- создаем метод выполнения задач стэком function tasks.runStack(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- если задача возвращает true, удаляем её if #self.list > 0 and self.list[#self.list][flag]() then self.list[#self.list] = nil end end return tasks end tasks = makeManager() -- выполняем логические задачи function onTick() tasks:runList() end -- выполняем графические задачи function onDraw() tasks:runList(1) end -- объявляем несколько глобальных переменных tsX, tsY, tsW, tsH, tsBool, push = 0, 0, 0, 0, false, false data = {0, 1, 2, 3, 4} -- создаем задачу для получения информации от дисплея tasks:create(function() tsX = input.getNumber(3) tsY = input.getNumber(4) tsW = input.getNumber(1) tsH = input.getNumber(2) push = not tsBool and input.getBool(1) tsBool = input.getBool(1) end) function makeGraph() stack = makeManager() -- создадим функцию для создания кнопок function addButton(text, event, x, y, w, h) event = event or function() end local textFunction = text if type(text) ~= "function" then textFunction = function() return text end end return function() if push and tsX >= x and tsX <= x + w and tsY >= y and tsY <= y + h then event() -- вызываем событие, если кнопка нажата end end, function() screen.drawRect(x, y, w, h) -- рисуем кнопку screen.drawTextBox(x, y, w, h, textFunction(), 0, 0) -- добавляем текст end end -- создадим главное окно function makeMainWindow() local tasks = makeManager() for id = 1, 5 do tasks:create( addButton( function()return data[id] end, nil, 2, 11*id-9, 91, 9 ) ) end return function() tasks:runList() end, function() tasks:runList(1) end end stack:create(makeMainWindow()) return function() stack:runStack() end, function() stack:runStack(1) end end tasks:create(makeGraph())
Почему такая структура удобна? Если внимательно рассмотреть данный пример, можно заметить, что это задача в кооперативном менеджере, вызывающая стек, который, в свою очередь, запускает другой кооперативный менеджер.

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

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

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

function makeManager() -- создаем список задач local tasks = {list = {}} -- создаем метод для добавления задач, отдельно логическую и графическую части function tasks.create(self, tick, draw) -- если логическая или графическая часть не передана, заменяем на заглушку tick, draw = tick or function() end, draw or function() end -- добавляем обе части задачи в список table.insert(self.list, {tick = tick, draw = draw}) end -- создаем метод выполнения задач списком function tasks.runList(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- перебираем все задачи for id, task in pairs(self.list) do -- если задача возвращает true, удаляем её if task[flag]() then self.list[id] = nil end end end -- создаем метод выполнения задач стэком function tasks.runStack(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- если задача возвращает true, удаляем её if #self.list > 0 and self.list[#self.list][flag]() then self.list[#self.list] = nil end end return tasks end tasks = makeManager() -- выполняем логические задачи function onTick() tasks:runList() end -- выполняем графические задачи function onDraw() tasks:runList(1) end -- объявляем несколько глобальных переменных tsX, tsY, tsW, tsH, tsBool, push = 0, 0, 0, 0, false, false data = {0, 1, 2, 3, 4, 5, 6} -- создаем задачу для получения информации от дисплея tasks:create(function() tsX = input.getNumber(3) tsY = input.getNumber(4) tsW = input.getNumber(1) tsH = input.getNumber(2) push = not tsBool and input.getBool(1) tsBool = input.getBool(1) end) function makeGraph() stack = makeManager() -- создадим функцию для создания кнопок function addButton(text, event, x, y, w, h) event = event or function() end local textFunction = text if type(text) ~= "function" then textFunction = function() return text end end return function() if push and tsX >= x and tsX <= x + w and tsY >= y and tsY <= y + h then event() -- вызываем событие, если кнопка нажата end end, function() screen.drawRect(x, y, w, h) -- рисуем кнопку screen.drawTextBox(x, y, w, h, textFunction(), 0, 0) -- добавляем текст end end -- создадим окно изменения значения function makeCountWindow(n) local tasks = makeManager() local exit = false tasks:create( addButton( function()return data[n] end, nil, 2, 2, 91, 9 ) ) tasks:create( addButton( "-", function() data[n] = data[n] - 1 end, 2, 15, 43, 10 ) ) tasks:create( addButton( "+", function() data[n] = data[n] + 1 end, 50, 15, 43, 10 ) ) tasks:create( addButton( "exit", function() exit = true end, 2, 28, 91, 9 ) ) return function() tasks:runList() return exit end, function() tasks:runList(1) end end -- создадим главное окно function makeMainWindow() local tasks = makeManager() for id = 1, 7 do tasks:create( addButton( function()return data[id] end, function() stack:create(makeCountWindow(id)) end, 2, 9*id-7, 91, 7 ) ) end return function() tasks:runList() end, function() tasks:runList(1) end end stack:create(makeMainWindow()) return function() stack:runStack() end, function() stack:runStack(1) end end tasks:create(makeGraph())
Примерно это видно на итоговых скриншотах:

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

Данный код вместе с комментариями занимает 3894 символа, но при оптимизации его объем можно сократить до 1298 символов, сохраняя его функциональность. Хотя это незначительно больше, чем минимально возможный размер другими способами, данный подход имеет свои преимущества:
  1. Удобство поддержки:

    Код остается легко читаемым благодаря сохранению структуры и комментариев.
    Понятное разделение логики и графических элементов упрощает модификацию.

  2. Масштабируемость:

    Благодаря хорошо продуманной архитектуре кода, добавление новых функций (например, вложенных окон или сложных интерфейсов) минимально увеличивает его объем.
    Уже описаны все основные элементы экрана, что позволяет расширять функционал без значительного усложнения.

  3. Гибкость:

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

Менеджер FIFO
Данный вид менеджера работает по принципу FIFO (First In, First Out), то есть "первый пришёл — первый вышел". Этот принцип широко применяется в задачах, связанных с передачей данных между устройствами.

Предположим, что у нас есть устройство, которое передаёт сообщения через один цифровой канал и ожидает подтверждения от получателя. Для управления такой передачей сообщений создадим менеджер FIFO.

function tasks.runFifo(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- если задача возвращает true, удаляем её if #self.list > 0 and self.list[1][flag]() then table.remove(self.list, 1) end end

Этот метод похож на предыдущий, но, в отличие от стека, он вызывает не последний, а первый элемент из списка.

Далее создадим несколько переменных с сообщениями. Например:

messages = {"hello, world!", "i love swLUA", "I'm tired", "ok let's go"}

Теперь напишем задачи для каждого из сообщений, чтобы они отправляли данные и ждали подтверждения.

function makeManager() -- создаем список задач local tasks = {list = {}} -- создаем метод для добавления задач, отдельно логическую и графическую части function tasks.create(self, tick, draw) -- если логическая или графическая часть не передана, заменяем на заглушку tick, draw = tick or function() end, draw or function() end -- добавляем обе части задачи в список -- из - за некорректной работы table.insert после замены элемента пришлось добавить проверку if self.list[1] == nil then self.list[1] = {tick = tick, draw = draw} return end table.insert(self.list, {tick = tick, draw = draw}) end -- создаем метод выполнения задач списком function tasks.runList(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- перебираем все задачи for id, task in pairs(self.list) do -- если задача возвращает true, удаляем её if task[flag]() then self.list[id] = nil end end end -- создаем метод выполнения задач стэком function tasks.runStack(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- если задача возвращает true, удаляем её if #self.list > 0 and self.list[#self.list][flag]() then self.list[#self.list] = nil end end -- создаем метод выполнения задач стэком function tasks.runStack(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- если задача возвращает true, удаляем её if #self.list > 0 and self.list[#self.list][flag]() then self.list[#self.list] = nil end end -- создаем метод выполнения задач FIFO function tasks.runFifo(self, flag) -- определяем, какой обработчик вызывать: логический (tick) или графический (draw) flag = flag and "draw" or "tick" -- вызываем задачу if #self.list <= 0 then return end --если список пуст останавливаем arg1, arg2 = self.list[1][flag]() -- если задача возвращает true, удаляем её if arg1 == true then table.remove(self.list, 1) elseif type(arg1 or arg2) == "function" then -- проверяет из ближайшей не пустой ячейки является ли она функцией -- если да то удаляем первую задачу и вносим новую на основе аргументов self.list[1] = nil tasks:create(arg1, arg2) end end return tasks end tasks = makeManager() -- выполняем логические задачи function onTick() tasks:runFifo() end -- выполняем графические задачи function onDraw() tasks:runFifo(1) end -- константы messages = {"hello, world!", "i love swLUA", "I'm tired", "ok let's go"} WAIT_TIME = 30 function makeSender(text) -- делаем копию нужного текста чтобы иметь экземпляр изначального. local textSend = text return function() if #textSend > 0 then output.setNumber(1, string.byte(textSend:sub(1, 1))) textSend = textSend:sub(2, -1) else -- после полной передачи запускаем процесс ожидания подтверждения output.setNumber(1, 0) local time = 0 -- создаем таймер для функции return function() time = time + 1 if input.getBool(1) then return true elseif time >= WAIT_TIME then -- если за n тиков подтверждения нет запускаем попытку отправки заново -- снова заменяем процесс. return makeSender(text) end end end end -- графика нам тут не нужна, вторую функцию не передаем, менеджер заменит ее на заглушку. end for id, message in pairs(messages) do tasks:create(makeSender(message)) end

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

Была добавлена возможность замены текущей задачи, чтобы поддерживать повторные попытки отправки сообщений в случае сбоя.

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

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

  1. Структурированное управление задачами
    Менеджер задач позволяет разделить логику приложения на небольшие, независимые задачи. Это упрощает чтение, тестирование и модификацию кода.
  2. Повторное использование кода
    Задачи могут быть универсальными, благодаря возможности передавать функции в качестве параметров. Например, задача отправки сообщения в очереди может использоваться многократно с разными данными и разными алгоритмами отправки.
  3. Управление асинхронными процессами
    Менеджер эффективно решает проблему координации асинхронных операций. можно повторять или модифицировать процесс выполнения на основе текущего состояния.
  4. Инкапсуляция логики
    Все задачи выполняются через методы менеджера, что изолирует детали реализации и позволяет изменять внутреннюю логику без необходимости переписывать весь код приложения.
  5. Гибкость выполнения задач
    Поддержка различных стратегий выполнения (FIFO, LIFO) делает менеджер универсальным.
  6. Простое масштабирование
    Добавление новых задач не требует изменения существующего кода. Это позволяет масштабировать приложение без увеличения сложности структуры.

Недостатки

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

Вывод

Менеджер задач является мощным инструментом для управления процессами в приложениях, особенно в сценариях, где требуется обработка очередей, стеков или выполнение задач по списку. Он упрощает структуру кода, улучшает читаемость и предоставляет возможности масштабирования. Однако необходимо учитывать ограничения, связанные с его потенциальными "дырами" в данных и усложнением управления при росте числа задач.
bye bye
Добавляйте в избранное, если информация оказалась для вас полезной, чтобы не потерять её. Делитесь своими проектами, использующими данный метод, в комментариях!
Один из крупнейших моих проектов, основанных на многозадачности, можно найти:ТУТ.

Удачи в ваших начинаниях!
2 条留言
hostbanani  [作者] 2024 年 11 月 29 日 上午 9:59 
Sorry. Writing something like that in English is too hard for me.:lunar2019smilingpig:
ultimateboscar 2024 年 11 月 28 日 下午 8:07 
cool, but im not russian круто, но я не русский