Создание прототипов игр на LÖVE

от автора

Одна из моих целей на 2025 год — создание завершённой игры. Завершённой, то есть её можно будет купить в Steam или App Store за $2,99 или около того. Я уже делал маленькие игры, но завершение и выпуск игры, вероятно, будет самым крупным моим проектом (если не считать блога).

В зимние каникулы я какое-то время писал прототипы игр на LÖVE — фреймворке для создания 2D-игр на Lua. Таким образом я хотел изучить инструменты разработки игр, подходящие к моему набору навыков, и определить свои сильные стороны, чтобы в 2025 году распоряжаться временем эффективно.

До работы над этими прототипами я написал примерно двести строк кода на Lua, но у меня не возникло никаких проблем в освоении нужного мне синтаксиса.

Оказалось, что API LÖVE простой и мощный. Одно из преимуществ использования фреймворка вместо игрового движка в том, что я могу показать вам полный пример всего в десяти строках кода (в отличие от игрового движка, где пришлось бы определять объекты сцены, прикреплять скрипты и так далее).

Показанный ниже пример позволяет игроку перемещать квадратик по экрану.

x = 100  -- обновляем состояние игры в каждом кадре ---@param dt - время с последнего обновления в секундах function love.update(dt)     if love.keyboard.isDown('space') then         x = x + 200 * dt     end end  -- отрисовываем экран в каждом кадре function love.draw()     love.graphics.setColor(1, 1, 1)     love.graphics.rectangle('fill', x, 100, 50, 50) end

Хотя мои прототипы гораздо более сложные, этот пример демонстрирует саму суть LÖVE.

Шахматный UI

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

UI популярных игроков в шахматы (chess.comlichess.org) невероятно хорошо продуман. Реализация шахматного UI может показаться простой задачей, но когда я начал разбирать переходы между состояниями, то осознал, насколько изящно всё согласуется. Особенно хорош UI анализа после партии на lichess.org.

Я хотел попрактиковаться на шахматных головоломках, но для начала мне нужно было организовать работу базового шахматного UI. Это и стало моей первой программой на LÖVE, занявшей примерно два часа.

Для перехвата ввода с мыши я использовал сочетание функций обратного вызова LÖVE (love.mousereleased для завершения перетаскивания, love.mousepressed для перемещения фигуры двумя щелчками).

Для рендеринга фигур в процессе перетаскивания я использовал love.mouse.getPosition().

local pieceImage = love.graphics.newImage("assets/chess_" .. piece.name .. ".png")  -- ..  -- отрисовываем перетаскиваемую фигуру в позиции курсора if piece.dragging then     local mouseX, mouseY = love.mouse.getPosition()      -- центрируем фигуру на курсоре     local floatingX = mouseX - (pieceImage:getWidth() * scale) / 2     local floatingY = mouseY - (pieceImage:getHeight() * scale) / 2      -- отрисовываем поднятую фигуру нужным цветом     if piece.color == "white" then         love.graphics.setColor(1, 1, 1)     else         love.graphics.setColor(0.2, 0.2, 0.2)     end     love.graphics.draw(pieceImage, floatingX, floatingY, 0, scale, scale) end

За многие годы я создавал UI при помощи множества разных библиотек. Самым близким к работе с LÖVE опытом был браузерный Canvas API. Я считаю, что LÖVE — лучшее решение для прототипирования произвольного UI при помощи кода. Я говорю произвольного, потому что если бы мне потребовалось что-то с полями ввода и кнопками, то не думаю, что LÖVE стал бы подходящим выбором.

Одна из причин того, что LÖVE оказывается таким мощным решением, заключается в простоте, с которой LLM генерируют и анализируют код, необходимый для написания прототипов на LÖVE. API понятен (или его можно изучить по очень коротким docstrings), а для остального кода требуется лишь простая математика UI.

Это совершенно не похоже на ситуацию с GDScript движка Godot, с которым LLM испытывают трудности. Думаю, это можно исправить при помощи fine-tuning, RAG (Retrieval-Augmented Generation) или few-shot prompting, но в изучение этого вопроса я не углублялся.

Ранее я не пользовался LLM в визуальных проектах, поэтому был удивлён, насколько близко claude-3.5-sonnet и gpt-4o оказались способны реализовывать мои промты (через Cursor).

Хотя программы на LÖVE открываются очень быстро, мне всё равно не хватает горячей перезагрузки, которая есть при работе над браузерными UI. В более крупном проекте я бы, вероятно, потратил какое-то время на создание отладочного режима и/или горячей перезагрузки конфигурации UI.

У меня возникли небольшие проблемы с отделением логики UI от логики приложения. Мне не кажется, что разделение вышло особо чистым, но с ним было удобно работать. В примере ниже видно, как я использую свой «API фигуры».

-- вызывается при нажатии кнопки мыши ---@param x - координата x мыши ---@param y - координата y мыши function love.mousepressed(x, y, button)     local result = xyToGame(x, y)      -- проверяем, нажали ли мы на допустимую клетку     if result.square then         for _, piece in ipairs(pieces) do              -- если мы нажали на фигуру и это допустимая клетка, то перемещаем её             if piece.clicked and piece:validSquare(result.square) then                 piece:move(result.square)                 return             end         end     end      -- проверяем, нажали ли мы на фигуру     if result.piece then         result.piece:click(x, y)         result.piece:drag()         return     end      -- в противном случае выполняем unclick всех фигур     for _, piece in ipairs(pieces) do         piece:unclick()     end end

UI карточной игры

Ещё один UI, о котором я недавно думал — это интерфейс игры Hearthstone, в которую я играл примерно год после её релиза. Это был мой первый опыт в состязательной карточной игре, и она доставила мне море удовольствия.

Похоже, сложность реализации карточных игр идеально сбалансирована. Основная часть работы заключается в планировании и гейм-дизайне. Сравните это, например, с 3D-играми, при разработке которых существенную долю времени нужно тратить на создание графики и игрового мира. По моим ощущениям, я бы смог написать MVP предварительно спланированной карточной игры примерно за месяц.

На прототип у меня ушло три часа.

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

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

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

При работе с чем-то простым наподобие полосок здоровья и маны LÖVE проявляет себя во всей красе.

local function drawResourceBar(x, y, currentValue, maxValue, color)      -- фон     love.graphics.setColor(0.2, 0.2, 0.2, 0.8)     love.graphics.rectangle("fill", x, y, Config.resources.barWidth, Config.resources.barHeight)          -- заливка     local fillWidth = (currentValue / maxValue) * Config.resources.barWidth     love.graphics.setColor(color[1], color[2], color[3], 0.8)     love.graphics.rectangle("fill", x, y, fillWidth, Config.resources.barHeight)          -- граница     love.graphics.setColor(0.3, 0.3, 0.3, 1)     love.graphics.setLineWidth(Config.resources.border)     love.graphics.rectangle("line", x, y, Config.resources.barWidth, Config.resources.barHeight)          -- текст значения     love.graphics.setColor(1, 1, 1)     local font = love.graphics.newFont(12)     love.graphics.setFont(font)     local text = string.format("%d/%d", currentValue, maxValue)     local textWidth = font:getWidth(text)     local textHeight = font:getHeight()     love.graphics.print(text,          x + Config.resources.barWidth/2 - textWidth/2,          y + Config.resources.barHeight/2 - textHeight/2     ) end  local function drawResourceBars(resources, isOpponent)     local margin = 20     local y = isOpponent and margin or                love.graphics.getHeight() - margin - Config.resources.barHeight * 2 - Config.resources.spacing          drawResourceBar(margin, y, resources.health, Config.resources.maxHealth, {0.8, 0.2, 0.2})     drawResourceBar(margin, y + Config.resources.barHeight + Config.resources.spacing,                     resources.mana, resources.maxMana, {0.2, 0.2, 0.8}) end

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

-- обновляем состояние игры в каждом кадре ---@param dt - время с последнего обновления в секундах function love.update(dt)          -- ..          -- обновляем анимации карт     for i = 1, #State.cards do         local card = State.cards[i]         if i == State.hoveredCard and not State.draggedCard then             updateCardAnimation(card, Config.cards.hoverRise, Config.cards.hoverScale, dt)         else             updateCardAnimation(card, 0, 1, dt)         end         updateCardDrag(card, dt)     end end  -- выполняем lerp карты в сторону нужных значений подъёма и масштаба local function updateCardAnimation(card, targetRise, targetScale, dt)     local speed = 10     card.currentRise = card.currentRise + (targetRise - card.currentRise) * dt * speed     card.currentScale = card.currentScale + (targetScale - card.currentScale) * dt * speed end  -- выполняем lerp вытащенных карт local function updateCardDrag(card, dt)     if not State.draggedCard then         local speed = 10         card.dragOffset.x = card.dragOffset.x + (0 - card.dragOffset.x) * dt * speed         card.dragOffset.y = card.dragOffset.y + (0 - card.dragOffset.y) * dt * speed     end end

Приведённый выше код анимирует карты, выполняя плавные переходы значений их подъёма/масштаба между целевыми значениями. Классический пример линейной интерполяции (lerping), при котором текущие значения постепенно движутся в сторону целевых значений в зависимости от прошедшего времени и множителя скорости.

Куда я буду двигаться дальше

После создания этих прототипов (а также нескольких других, о которых я здесь не рассказал) я вполне осознал, какие проекты будет продуктивно разрабатывать на LÖVE.

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

Мой нечёткий план проекта на 2025 год выглядит примерно так:

  • Создать дизайн игры в блокноте

  • Изготовить бумажную игру и поиграть в прототип с женой

  • Собрать базовый MVP (без графики)

  • Провести плейтестинг с друзьями

  • Выполнять итерации/дополнительный плейтестинг

  • Создать графику

  • ???

  • Выпустить игру

Не думаю, что код моего прототипа будет особо полезен, но я всё равно выложил его в open source!


ссылка на оригинал статьи https://habr.com/ru/articles/872616/