Здравствуйте, сообщество Castle! Этот урок сопровождался прямой трансляцией. Смотрите видео здесь.

В рамках Хэллоуинской вечеринки в замке мы проводим целую серию обучающих трансляций. Смотрите полное расписание стримов здесь.

В этом уроке мы рассмотрим основы создания 3D-игры в Замке. Когда мы закончим, у нас будет игра-лабиринт, которая выглядит так:

Вы можете попробовать игру здесь.

В замковых играх используется библиотека под названием LÖVE 2D. Как следует из названия, LÖVE ориентирован на 2D-игры, поэтому для 3D-игр не так много встроенных инструментов. Мы по-прежнему можем создавать 3D-игры, но нам нужно проделать еще немного работы, чтобы начать! Это руководство даст вам инструменты для начала работы над собственной 3D-игрой в Castle.

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

Рендеринг в 3D

Для создания 3D-игры требуется гораздо больше шаблонного кода, чем для 2D-игры. В дополнение ко всему, что необходимо для 2D-игр, вам также необходимо хранить сетки, настраивать шейдеры и выполнять математические операции с матрицами. Castle и LÖVE не предоставляют ничего из этого из коробки, поэтому я рекомендую использовать дополнительную библиотеку и модифицировать ее по мере необходимости. Для этого урока я начал с этой библиотеки и немного изменил ее, чтобы она соответствовала моему варианту использования.

Вы можете увидеть первоначальный коммит, куда я импортировал эту библиотеку здесь. Этот шаг в основном заключается в импорте библиотеки и проверке того, что мы можем нарисовать что-то простое на экране. Я добавил вспомогательную функцию для рисования прямоугольника с 4 вершинами:

function rectColor(coords, color, scale)
    local model = Engine.newModel({ coords[1], coords[2], coords[4], coords[2], coords[3], coords[4] }, nil, nil, color, { 
        {"VertexPosition", "float", 3}, 
    }, scale)
    table.insert(Scene.modelList, model)
    return model
end

а затем вызовите это в love.load():

rectColor({
  {-1, -1, 1},
  {-1, 1, 1},
  {1, 1, 1},
  {1, -1, 1}
}, {1,0,0}, 1.0)

что дает нам это:

Добавление освещения

Одним из больших различий между 2D- и 3D-рендерингом является освещение. Во многих 2D-играх вообще не нужно учитывать освещение, но большинство 3D-игр будут казаться плоскими и безжизненными без освещения. В этом уроке мы собираемся реализовать модель освещения Фонга. Вы можете взглянуть на это руководство, если хотите узнать больше о математике.

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

Для этого урока я вручную добавил вектор нормалей для каждой вершины. Вот как я изменил свой вызов на rectColor.

-- the 4th and 5th number in each row represent the texture UV coordinates, which are unused for now
rectColor({
  {-1, -1, 1,  0,0,  0,0,1},
  {-1, 1, 1,   0,0,  0,0,1},
  {1, 1, 1,    0,0,  0,0,1},
  {1, -1, 1,   0,0,  0,0,1}
}, {1,0,0}, 1.0)

В engine.newModel вы можете видеть, что последние 3 числа представляют собой норму:

format = {
  {"VertexPosition", "float", 3},
  {"VertexTexCoord", "float", 2},
  {"VertexNormal", "float", 3},
}

Теперь, когда у нас есть нормали, нам просто нужно реализовать модель освещения Фонга в наших шейдерах. Взгляните на diff for this step, чтобы увидеть, как изменились шейдеры.

Добавление текстур

Теперь мы на самом деле приступаем к разработке игры! Я решил сделать жуткий лабиринт, поэтому нашел текстуру стены, текстуру земли и текстуру скайбокса. Движок, который я импортировал, когда начал проект, уже хорошо справлялся с поддержкой текстур, поэтому мне просто нужно было изменить мою функцию rectColor, чтобы она брала текстуру вместо цвета, а затем передавала UV-координаты вместе с моими позициями вершин и нормалью.

wallImage = love.graphics.newImage("assets/wall.png")
-- 1-3 are position, 4-5 are UV coordinates, 6-8 are the normal
rect({
  {-1, -1, 1,  0,1,  0,0,1},
  {-1, 1, 1,   0,0,  0,0,1},
  {1, 1, 1,    1,0,  0,0,1},
  {1, -1, 1,   1,1,  0,0,1}
}, wallImage, 1.0)

После того, как я добавил скайбокс, я решил, что скайбокс выглядит неправильно с освещением Фонга, поэтому я изменил фрагментный шейдер, чтобы скайбокс освещался только окружающим светом. Я сделал это, установив нормали скайбокса на {0, 0, 0}, а затем используя освещение Фонга во фрагментном шейдере, только если длина нормали больше 0.

Посмотреть все изменения для этого шага можно здесь.

Создание карты

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

-- x is a wall, s is start, e is end
Map =  [[
        e
xxxxxxxx xxx
xx x       x
xx xx  xxx x
xxxxxx   xxx
x    xxx   x
x xxxx   x x
x      x   x
xx xxxxxxxxx
  s
]]

Как только я выбрал этот формат, я добавил этот код для разбора строки:

local z = 0
for line in Map:gmatch("[^\r\n]+") do
  for x = 0, string.len(line) do
    local char = string.sub(line, x, x)
    if char == 'x' then
      box(x, z)
    elseif char == 's' then
      Engine.camera.pos.x = x + 0.5
      Engine.camera.pos.z = z + 0.5
    end
    -- no end yet
  end
  z = z + 1
end

Я также изменил свою функцию box, чтобы она принимала координаты x и z. Я использовал функцию setTransform, предоставляемую 3D-движком, для перемещения блоков.

local m1 = rect({
  {0, 0, 1,  0,1,  0,0,1},
  {0, 1, 1,  0,0,  0,0,1},
  {1, 1, 1,  1,0,  0,0,1},
  {1, 0, 1,  1,1,  0,0,1}
}, wallImage, 1.0)
... similar code for the other sides
local models = {m1, m2, m3, m4}
for k,v in pairs(models) do
  v:setTransform({x, 0, z})
end

Теперь у нас есть полная карта! См. код для этого шага.

Превратите это в настоящую игру

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

Поскольку все размещено в сетке, нам не нужно выполнять сложное обнаружение столкновений. Вот код для проверки наличия pos в ячейке (x, z):

function isInSquare(pos, x, z)
  -- this gives us some buffer so that the camera doesn't go partially into a wall
  local d = 0.1
  local camX = pos.x
  local camZ = pos.z
  return camX >= x - d and camX <= x + 1 + d and camZ >= z - d and camZ <= z + 1 + d
end

Теперь, когда у нас есть эта функция, нам просто нужно отслеживать, какие ячейки имеют стены, а затем проверять все эти ячейки, прежде чем двигаться. Я сделал новую таблицу и добавил table.insert(Blocks, {x, z}) в функцию box, а затем добавил дополнительную проверку перед перемещением камеры:

local canMove = true
for k,v in pairs(Blocks) do
  if isInSquare(newPos, v[1], v[2]) then
    canMove = false
  end
end

Теперь, когда вы не можете проходить сквозь стены, все, что осталось, — это сделать игру выигрышной. Я добавил еще один вызов isInSquare в конце love.update(), который проверяет, находитесь ли вы в конечном квадрате:

if isInSquare(Engine.camera.pos, EndPosition[1], EndPosition[2]) then
  WonGame = true
end

Если WonGame истинно, мы показываем текстовое наложение и позволяем перезапустить игру, нажав пробел.

Вот diff для этого шага.

Будущая работа

Вы можете расширить эту игру, добавив визуальные эффекты (туман? призраки? дождь?) или добавив несколько уровней. Вы даже можете добавить редактор уровней с помощью Post API!

Надеюсь, вам понравился этот урок, и вы сможете сделать что-нибудь интересное. Свяжитесь со мной в чате Castle или Discord, если у вас есть какие-либо вопросы.