Одна из моих целей на 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   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!

Комментарии (0)