
Одна из моих целей на 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.com, lichess.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!
Vad344
Ой, какая красота! Когда сын был маленьким, мы с ним "на Лёве" что-то кодили!
Классная статья.