[Перевод] Создание игры на Lua и LÖVE — 4

image


Оглавление


  • Статья 1
    • Часть 1. Игровой цикл
    • Часть 2. Библиотеки
    • Часть 3. Комнаты и области
    • Часть 4. Упражнения
  • Статья 2
    • Часть 5. Основы игры
    • Часть 6. Основы класса Player
  • Статья 3
    • Часть 7. Параметры и атаки игрока
    • Часть 8. Враги
  • Статья 4
    • Часть 9. Режиссёр и игровой цикл
    • Часть 10. Практики написания кода
    • Часть 11. Пассивные навыки


12. More Passives

13. Skill Tree

14. Console

15. Final

Часть 9: режиссёр и игровой цикл


Введение


В этой части мы завершим реализацию основ всей игры с минимальным количеством контента. Мы изучим режиссёра (Director) — код, который будет управлять созданием врагов и ресурсов. Затем мы рассмотрим перезапуск игры после смерти игрока. И после этого мы займёмся простой системой очков, а также базовым UI, чтобы игрок мог знать о своих показателях.

Режиссёр


Режиссёр (Director) — это фрагмент кода, управляющий созданием врагов, атак и ресурсов в игре. Цель игры — как можно дольше выжить и набрать как можно больше очков. Трудность игры определяется постоянно увеличивающимся количеством и сложностью создаваемых врагов. Эта сложность будет полностью контролироваться кодом, который мы сейчас начнём писать.

Правила, которым будет следовать режиссёр, достаточно просты:

  1. Каждые 22 секунды сложность увеличивается;
  2. Длительность каждой сложности создания врагов будет основана на системе очков:
    • Каждая сложность (или раунд) имеет определённое количество очков, которые можно использовать;
    • Враги стоят некоторую постоянную сумму очков (чем сложнее враг, тем дороже он стоит);
    • Чем выше уровень сложности, тем больше очков есть у режиссёра;
    • Враги случайным образом выбираются для создания в течение длительности раунда, пока у режиссёра не кончатся очки.
  3. Каждые 16 секунд создаётся ресурс (HP, SP или Boost);
  4. Каждые 30 секунд создаётся атака.


Мы начнём с создания объекта Director, который будет являться обычным объектом (не тем, который наследуется от GameObject, а используемым в Area). В него мы поместим наш код:

Director = Object:extend()

function Director:new(stage)
    self.stage = stage
end

function Director:update(dt)
  
end


Создать объект и создать его экземпляр в комнате Stage мы можем следующим образом:

function Stage:new()
    ...
    self.director = Director(self)
end

function Stage:update(dt)
    self.director:update(dt)
    ...
end


Мы хотим, чтобы у объекта Director была ссылка на комнату Stage, поскольку нам нужно создавать врагов и ресурсы, а единственный способ сделать это — использовать stage.area. Директору также потребуется доступ к времени, поэтому ему нужно соответствующее обновление.

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

function Director:new(...)
    ...

    self.difficulty = 1
    self.round_duration = 22
    self.round_timer = 0
end

function Director:update(dt)
    self.round_timer = self.round_timer + dt
    if self.round_timer > self.round_duration then
        self.round_timer = 0
        self.difficulty = self.difficulty + 1
        self:setEnemySpawnsForThisRound()
    end
end


Таким образом, difficulty увеличивается через каждые 22 секунд в соответствии с правилом 1. Также мы можем вызвать функцию setEnemySpawnsForThisRound, которая будет выполнять правило 2.

Первая часть правила 2 заключается в том, что у каждой сложности есть определённое количество очков, которые можно тратить. Первое, что нам нужно — определиться, сколько уровней сложности мы хотим сделать в игре и то, как мы будем задавать эти точки: вручную или через какую-то формулу. Я решил выбрать второй вариант, чтобы игра была бесконечной и становилась всё сложнее и сложнее, пока игрок больше не сможет с ней справляться. Я решил, что в игре будет 1024 уровней сложности, потому что это достаточно большое число, которого вряд ли кто-то достигнет.

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

Назначение очков будет выполнять по следующей формуле:

  • На сложности 1 есть 16 очков;
  • Начиная со сложности 2 применяется следующая четырёхэтапная формула:
    • Сложность i имеет сумму очков сложности i-1 + 8
    • Сложность i+1 имеет сумму очков сложности i
    • Сложность i+2 имеет сумму очков сложности (i+1)/1.5
    • Сложность i+3 имеет сумму очков сложности (i+2)*2


В коде это выглядит следующим образом:

function Director:new(...)
    ...
  
    self.difficulty_to_points = {}
    self.difficulty_to_points[1] = 16
    for i = 2, 1024, 4 do
        self.difficulty_to_points[i] = self.difficulty_to_points[i-1] + 8
        self.difficulty_to_points[i+1] = self.difficulty_to_points[i]
        self.difficulty_to_points[i+2] = math.floor(self.difficulty_to_points[i+1]/1.5)
        self.difficulty_to_points[i+3] = math.floor(self.difficulty_to_points[i+2]*2)
    end
end


То есть, например, первые 14 уровней сложности будут иметь следующее количество очков:

Сложность - очки
1 - 16
2 - 24
3 - 24
4 - 16
5 - 32
6 - 40
7 - 40
8 - 26
9 - 56
10 - 64
11 - 64
12 - 42
13 - 84

То есть получается, что сначала есть определённый уровень очков, сохраняющийся в течение трёх раундов, затем он снижается на один раунд, а затем значительно повышается в следующем раунде, что становится новым плато, длящимся примерно три раунда, затем оно снова подскакивает в следующем раунде, который становится новым плато, длящимся примерно три раунда, а затем этот цикл повторяется бесконечно. Таким образом мы создаём интересный цикл «нормализация → расслабление → интенсификация», с которым можно экспериментировать.

Увеличение количества очков следует очень быстрому и жёсткому правилу, то есть, например, при сложности 40 у раунда будет примерно 400 очков. Так как враги стоят постоянное количество очков, а каждый раунд должен потратить все данные ему очки, то игра быстро становится перенасыщенной и в какой-то момент игроки больше не могут выиграть. Но это вполне нормально, потому что именно таков дизайн игры. Её цель — набрать максимальное количество очков в таких условиях.

Разобравшись с этим, мы можем попробовать реализовать вторую часть правила 2, то есть определиться со стоимостью каждого врага. Пока мы создали всего два вида врагов, поэтому это достаточно тривиально, но в одной из следующих частей мы вернёмся к этому, после того, как создадим больше врагов. Сейчас код может выглядеть так:

function Director:new(...)
    ...
    self.enemy_to_points = {
        ['Rock'] = 1,
        ['Shooter'] = 2,
    }
end


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

К последней части правила 2 относится реализация функции setEnemySpawnsForThisRound. Но прежде чем мы приступим к ней, я хочу познакомить вас с очень важной конструкцией, связанной с шансами и вероятностями. Мы будем использовать её на протяжении всей игры.

ChanceList


Допустим, мы хотим, чтобы X происходило 25% времени, Y происходило 25% времени, а Z — 50% времени. Обычным способом это можно реализовать функцией типа love.math.random — заставить её генерировать значение от 1 до 100, а затем проверять, где оказалось значение. Если оно меньше 25, то мы говорим, что произошло событие X, если от 25 до 50, то событие Y, а если больше 50, то событие Z.

Большая проблема при такой реализации заключается в том, что мы не можем гарантировать, что при выполнении love.math.random 100 раз X произойдёт ровно 25 раз. Если мы выполним её 10000 раз, то, возможно, вероятность будет приближаться к 25%, но часто нам нужно иметь больший контроль над ситуацией. Поэтому простым решением будет создание того, что я называю «списком изменений» (chanceList).

Список chanceList работает следующим образом: мы генерируем список со значениями от 1 до 100. Когда нам нужно получить случайное значение из этого списка, мы вызываем функцию next. Эта функция выдаст нам случайное значение из списка, допустим, 28. Это значит, что произойдёт событие Y. Разница в том, что при вызове функции мы также удаляем из списка выбранное случайное значение. По сути это означает, что 28 больше никогда больше не выпадет и событие Y имеет теперь немного меньшую вероятность, чем два других события. Чем чаще мы вызываем next, тем более пустым становится список, и когда он становится совершенно пустым, мы просто воссоздаём заново все 100 чисел.

Таким образом мы можем гарантировать, что событие X произойдёт ровно 25 раз, событие Y — тоже ровно 25, а событие Z — ровно 50 раз. Мы можем также сделать так, чтобы вместо генерирования 100 чисел функция генерировала 20. В таком случае событие X произойдёт 5 раз, Y — тоже 5 раз, а Z — 10 раз.

Интерфейс для этого принципа работает довольно простым способом:

events = chanceList({'X', 25}, {'Y', 25}, {'Z', 50})
for i = 1, 100 do
    print(events:next()) --> will print X 25 times, Y 25 times and Z 50 times
end


events = chanceList({'X', 5}, {'Y', 5}, {'Z', 10})
for i = 1, 20 do
    print(events:next()) --> will print X 5 times, Y 5 times and Z 10 times
end


events = chanceList({'X', 5}, {'Y', 5}, {'Z', 10})
for i = 1, 40 do
    print(events:next()) --> will print X 10 times, Y 10 times and Z 20 times
end


Мы создадим в utils.lua функцию chanceList и воспользуемся некоторыми из особенностей Lua, которые мы рассмотрели во второй части этого туториала.

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

function chanceList(...)
    return {
        next = function(self)

        end
    }
end


Здесь мы получаем все возможные определения значений и вероятностей как ... которые мы подробнее будем обрабатывать позже. Затем мы возвращаем таблицу, которая имеет функцию next. Эта функция получает в качестве единственного аргумента self, так как мы знаем, что вызов функции с помощью : передаёт как первый аргумент её саму. То есть внутри функции nextself ссылается на таблицу, которую возвращает chanceList.

Прежде чем определить то, что находится внутри функции next, мы можем определить несколько атрибутов, которые будет иметь эта функция. Первый — это сам chance_list, который будет содержать значения, возвращаемые функцией next:

function chanceList(...)
    return {
    	chance_list = {},
        next = function(self)

        end
    }
end


Изначально эта таблица пуста и будет заполнена в функции next. В нашем примере:

events = chanceList({'X', 3}, {'Y', 3}, {'Z', 4})


Атрибут chance_list будет выглядеть примерно так:

.chance_list = {'X', 'X', 'X', 'Y', 'Y', 'Y', 'Z', 'Z', 'Z', 'Z'}


Нам понадобится ещё один атрибут с названием chance_definitions, в котором будут храниться все значения и вероятности, передаваемые в функцию chanceList:

function chanceList(...)
    return {
    	chance_list = {},
    	chance_definitions = {...},
        next = function(self)

        end
    }
end


И это всё, что нам нужно. Теперь мы можем переходить к функции next. Нам нужны от этой функции два поведения: она должна возвращать случайное значение в соответствии с вероятностями, описанными в chance_definitions, а также восстанавливать внутренний chance_list, когда он достигнет нуля элементов. Предполагая, что список заполнен элементами, мы можем реализовать первое поведение следующим образом:

next = function(self)
    return table.remove(self.chance_list, love.math.random(1, #self.chance_list))
end


Мы просто выбираем случайный элемент внутри таблицы chance_list и возвращаем его. Благодаря внутренней структуре элементов удовлетворяются все ограничения.

А теперь самая важная часть — мы будем строить саму таблицу chance_list. Оказывается, мы можем использовать для построения списка тот же код, который будет использоваться для его опустошения. Это будет выглядеть так:

next = function(self)
    if #self.chance_list == 0 then
        for _, chance_definition in ipairs(self.chance_definitions) do
      	    for i = 1, chance_definition[2] do 
                table.insert(self.chance_list, chance_definition[1]) 
      	    end
    	end
    end
    return table.remove(self.chance_list, love.math.random(1, #self.chance_list))
end


Здесь мы сначала определяем, равен ли размер chance_list нулю. Это будет верно при первом вызове next, а также тогда, когда список опустеет после множества вызовов. Если это верно, то мы начинаем обходить таблицу chance_definitions, в которой содержатся таблицы, которые мы назовём chance_definition со значениями и вероятностями этих значений. То есть если мы вызвали функцию chanceList так:

events = chanceList({'X', 3}, {'Y', 3}, {'Z', 4})


То таблица chance_definitions выглядит так:

.chance_definitions = {{'X', 3}, {'Y', 3}, {'Z', 4}}


И когда мы обходим этот список, chance_definitions[1] ссылается на значение, а chance_definitions[2] ссылается на количество раз, когда значение встречается в chance_list. Зная это, для заполнения списка мы просто вставляем chance_definition[1] в chance_list chance_definition[2] раз. И так же мы поступаем для всех таблиц chance_definitions.

Если мы протестируем это, то увидим, что система работает:

events = chanceList({'X', 2}, {'Y', 2}, {'Z', 4})
for i = 1, 16 do
    print(events:next())
end


Режиссёр


Вернёмся к режиссёру: мы хотели реализовать вторую часть правила 2, которая связана с реализацией setEnemySpawnsForThisRound. Первое, что мы хотим сделать — определить вероятность создания каждого врага. У разных уровней сложности будут разные вероятности создания, и нам нужно будет задать хотя бы первые несколько сложностей вручную. Затем последующие сложности будут задаваться случайно, потому что у них будет так много очков, что игрок в любом случае будет слишком перегружен.

Итак, вот как будут выглядеть несколько первых уровней сложности:

function Director:new(...)
    ...
    self.enemy_spawn_chances = {
        [1] = chanceList({'Rock', 1}),
        [2] = chanceList({'Rock', 8}, {'Shooter', 4}),
        [3] = chanceList({'Rock', 8}, {'Shooter', 8}),
        [4] = chanceList({'Rock', 4}, {'Shooter', 8}),
    }
end


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

Для сложностей с 5 по 1024 мы просто будем задавать каждому врагу случайные вероятности:

function Director:new(...)
    ...
    for i = 5, 1024 do
        self.enemy_spawn_chances[i] = chanceList(
      	    {'Rock', love.math.random(2, 12)}, 
      	    {'Shooter', love.math.random(2, 12)}
    	)
    end
end


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

Теперь перейдём к функции setEnemySpawnsForThisRound. Первое, что мы сделаем — используем создание врагов в списке согласно таблице enemy_spawn_chances, пока у нас не закончатся очки для текущего уровня сложности. Это может выглядеть примерно так:

function Director:setEnemySpawnsForThisRound()
    local points = self.difficulty_to_points[self.difficulty]

    -- Find enemies
    local enemy_list = {}
    while points > 0 do
        local enemy = self.enemy_spawn_chances[self.difficulty]:next()
        points = points - self.enemy_to_points[enemy]
        table.insert(enemy_list, enemy)
    end
end


Таким образом, локальная таблица enemy_list будет заполнена строками Rock и Shooter в соответствии с вероятностями текущей сложности. Мы помещаем этот код внутрь цикла while, который останавливает выполнение, когда количество оставшихся точек достигает нуля.

После этого нам нужно решить, когда в интервале 22 секунд текущего раунда будет создаваться каждый из врагов внутри таблицы enemy_list. Это может выглядеть как-то так:

function Director:setEnemySpawnsForThisRound()
    ...
  
    -- Find enemies spawn times
    local enemy_spawn_times = {}
    for i = 1, #enemy_list do 
    	enemy_spawn_times[i] = random(0, self.round_duration) 
    end
    table.sort(enemy_spawn_times, function(a, b) return a < b end)
end


Здесь мы делаем так, чтобы каждому врагу в enemy_list назначалось случайное число в интервале от 0 и round_duration, хранящееся в таблице enemy_spawn_times. Мы отсортируем эту таблицу, чтобы значения располагались по порядку. То есть если наша таблица enemy_list выглядит так:

.enemy_list = {'Rock', 'Shooter', 'Rock'}


то таблица enemy_spawn_times будет выглядеть так:

.enemy_spawn_times = {2.5, 8.4, 14.8}


Это значит, что Rock будет создан через 2,5 секунды, Shooter будет создан через 8,4 секунды, и ещё один Rock будет создан через 14,8 секунды после начала раунда.

Наконец, нам нужно задать само создание врагов с помощью вызова timer:after:

function Director:setEnemySpawnsForThisRound()
    ...

    -- Set spawn enemy timer
    for i = 1, #enemy_spawn_times do
        self.timer:after(enemy_spawn_times[i], function()
            self.stage.area:addGameObject(enemy_list[i])
        end)
    end
end


И здесь всё довольно прямолинейно. Мы проходим по списку enemy_spawn_times и задаём создание врагов из enemy_list в соответствии с числами из первой таблицы. Последнее, что нужно сделать — один раз вызвать эту функцию при запуске игры:

function Director:new(...)
    ...
    self:setEnemySpawnsForThisRound()
end


Если мы этого не сделаем, то враги начнут создаваться только через 22 секунды. Мы можем также при запуске добавить создание ресурса атаки, чтобы игрок имел возможность заменить свою атаку, но это не обязательно. Как бы то ни было, если мы запустим код сейчас, то всё будет работать так, как задумано!

На этом моменте мы пока оставим режиссёра в покое, но вернёмся к нему в следующих статьях, когда добавим в игру больше контента!

Упражнения с режиссёром


116. (КОНТЕНТ) Реализуйте правило 3. Оно должно работать как правило 1, только вместо увеличивающейся сложности должен создаваться один из трёх указанных в списке ресурсов. Вероятности создания каждого из ресурсов должные соответствовать такому определению:

function Director:new(...)
    ...
    self.resource_spawn_chances = chanceList({'Boost', 28}, {'HP', 14}, {'SkillPoint', 58})
end


117. (КОНТЕНТ) Реализуйте правило 4. Оно должно работать как правило 1, только вместо увеличивающейся сложности должна создаваться случайная атака.

118. У цикла while, который занимается поиском создаваемых врагов, есть одна большая проблема: он может навечно застрять в бесконечном цикле. Представьте ситуацию, в которой осталось только одно очко, но врагов, стоящих одно очко (например, Rock), больше создавать нельзя, потому что текущий уровень сложности не создаёт Rock. Найдите общее решение этой проблемы, не изменяя цену врагов, количество очков в уровнях сложности и не полагаясь на то, что проблему решат вероятности создания врагов (например, заставив все уровни сложности всегда создавать врагов с малой стоимостью).

Игровой цикл


Теперь перейдём к игровому циклу. Здесь мы сделаем так, чтобы игрок мог играть снова и снова — когда игрок умирает, он заново начинает уровень. В готовой игре цикл будет немного другим, потому что после смерти игрок должен переходить в комнату Console, но так как у нас пока нет комнаты Console, мы просто перезапустим комнату Stage. Здесь удобно будет проверять проблемы с памятью, потому что мы будем перезапускать комнату Stage снова и снова.

Благодаря тому, как мы структурировали код, сделать это оказывается невероятно просто. Мы определим в классе Stage функцию finish, которая использует gotoRoom для переключения на другую комнату Stage. Эта функция выглядит так:

function Stage:finish()
    timer:after(1, function()
        gotoRoom('Stage')
    end)
end


gotoRoom займётся уничтожением предыдущего экземпляра Stage и созданием нового, чтобы нам не пришлось уничтожать объекты вручную. Единственное, о чём нам нужно позаботиться — задать для атрибута player в классе Stage значение nil в его функции destroy, в противном случае объект Player не будет удалён правильным образом.

Функцию finish можно вызывать из самого объекта Player, когда игрок умирает:

function Player:die()
    ...
    current_room:finish()
end


Мы знаем, что current_room — это глобальная переменная, содержащая текущую активную комнату, а при вызове функции die для игрока единственной активной комнатой будет Stage, поэтому всё сработает, как надо. Если мы запустим код, то он будет работать, как мы того ожидали. Если игрок умирает, то через 1 секунду запускается новая комната Stage и можно начинать игру заново.

Стоит заметить, что всё получалось так просто, потому что мы структурировали нашу игру в соответствии с принципом комнат и областей. Если бы мы структурировали всё иначе, то было бы гораздо сложнее, и из-за этого (по моему мнению) многие люди запутываются при создании игры в LÖVE. Мы можем структурировать системы так, как нам нужно, но легко сделать так, что некоторые аспекты, например, перезапуск игры, оказывается реализовать не так просто. Важно понимать роль, которую играет выбранная нами архитектура.

Счёт


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

function Stage:new()
    ...
    self.score = 0
end


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

  1. Подбирание ресурса боеприпасов добавляет к счёту 50 очков
  2. Подбирание ресурса ускорения добавляет к счёту 150 очков
  3. Подбирание ресурса очка навыка добавляет к счёту 250 очков
  4. Подбирание ресурса атаки добавляет к счёту 500 очков
  5. Уничтожение Rock добавляет к счёту 100 очков
  6. Уничтожение Shooter добавляет к счёту 150 очков


Правило 1 мы реализуем следующим образом 1:

function Player:addAmmo(amount)
    self.ammo = math.min(self.ammo + amount, self.max_ammo)
    current_room.score = current_room.score + 50
end


Мы переходим в самое очевидное место — туда, где происходит событие (в нашем случае это функция addAmmo), а затем просто добавляем сюда код, изменяющий счёт. Так же, как мы делали это для функции finish, здесь мы можем получить доступ к комнате Stage через current_room, потому что комната Stage единственная, которая может быть активна в этом случае.

Упражнения со счётом


119. (КОНТЕНТ) Реализуйте правила с 2 по 6. Они очень просты в реализации и очень похожи на то, которое я дал для примера.

UI


А теперь перейдём к интерфейсу пользователя (UI). В готовой игре он будет выглядеть так:

f0f5232246fa7cf657032231f0278b72.png


В верхнем левом углу указано количество доступных очков навыков, счёт показан в верхней правой части, а основные характеристики игрока — в верхней и нижней части экрана. Давайте начнём со счёта. Всё, чего мы здесь хотим — выводить число в верхний правый угол экрана. Это может выглядеть так:

function Stage:draw()
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  		
        love.graphics.setFont(self.font)

        -- Score
        love.graphics.setColor(default_color)
        love.graphics.print(self.score, gw - 20, 10, 0, 1, 1,
    	math.floor(self.font:getWidth(self.score)/2), self.font:getHeight()/2)
        love.graphics.setColor(255, 255, 255)
    love.graphics.setCanvas()
  
    ...
end


Мы хотим отрисовывать UI поверх всего остального, и это можно реализовать двумя способами. Мы можем или создать объект под названием UI и задать его атрибут depth так, чтобы он отрисовывался поверх всего, или просто можем отрисовывать поверх Area в холсте main_canvas, который использует комната Stage. Я решил выбрать второй способ, но сработают они оба.

В показанном выше коде мы использовали для задания шрифта love.graphics.setFont:

function Stage:new()
    ...
    self.font = fonts.m5x7_16
end


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

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


Теперь перейдём ко второй важной части UI, то есть к центральным элементам. Мы начнём со здоровья (HP). Нам нужно отрисовать три элемента: слово, обозначающее параметр (в нашем случае «HP»), полосу, показывающую заполненность параметра, и числа, показывающие ту же информацию, но в более точной форме.

Мы начнём с отрисовки полосы:

function Stage:draw()
    ...
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  
        -- HP
        local r, g, b = unpack(hp_color)
        local hp, max_hp = self.player.hp, self.player.max_hp
        love.graphics.setColor(r, g, b)
        love.graphics.rectangle('fill', gw/2 - 52, gh - 16, 48*(hp/max_hp), 4)
        love.graphics.setColor(r - 32, g - 32, b - 32)
        love.graphics.rectangle('line', gw/2 - 52, gh - 16, 48, 4)
	love.graphics.setCanvas()
end


Во-первых, мы будем рисовать этот прямоугольник в позиции gw/2 - 52, gh - 16, а его ширина будет равна 48. То есть обе полосы будут отрисовываться относительно центра экрана с небольшим зазором в 8 пикселей. Из этого мы можем также понять, что позиция полоски справа будет gw/2 + 4, gh - 16.

Эта полоса будет заполненным прямоугольником с цветом hp_color, а его контур — прямоугольником с цветом hp_color - 32. Так как мы не можем выполнять вычитание из таблицы, нам нужно разделить таблицу hp_color на отдельные компоненты и вычитать из каждого.

Единственная полоса, которая каким-либо образом будет изменяться — это заполненный прямоугольник, ширина которого будет меняться согласно соотношению hp/max_hp. Например, если hp/max_hp равно 1, то HP полная. Если 0,5, то hp имеет половину размера max_hp. Если 0,25, то ¼ от размера. И если мы умножим это соотношение на ширину, которую должна иметь полоса, то получим красивую визуализацию заполнения HP игрока. Если мы реализуем это, то игра будет выглядеть так:

d8dd792fc6348b09ba928f85177a5e14.gif


Здесь можно заметить, что когда игрок получает урон, полоса реагирует соответствующим образом.

Теперь аналогично тому. как мы отрисовали число очков, мы можем отрисовать текст HP:

function Stage:draw()
    ...
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  
        -- HP
        ...
        love.graphics.print('HP', gw/2 - 52 + 24, gh - 24, 0, 1, 1,
    	math.floor(self.font:getWidth('HP')/2), math.floor(self.font:getHeight()/2))
	love.graphics.setCanvas()
end


Здесь снова, аналогично тому, как мы делали для счёта, нам нужно, чтобы текст центрировался относительно gw/2 - 52 + 24, то есть относительно центра полосы, то есть нам нужно сместить его на ширину этого текста, набранного этим шрифтом (и это мы делаем с помощью функции getWidth).

Наконец, мы можем также достаточно просто отрисовать числа HP под полосой:

function Stage:draw()
    ...
    love.graphics.setCanvas(self.main_canvas)
    love.graphics.clear()
        ...
  
        -- HP
        ...
        love.graphics.print(hp .. '/' .. max_hp, gw/2 - 52 + 24, gh - 6, 0, 1, 1,
    	math.floor(self.font:getWidth(hp .. '/' .. max_hp)/2),
    	math.floor(self.font:getHeight()/2))
	love.graphics.setCanvas()
end


Здесь применим тот же принцип. Нам нужно, чтобы текст центрировался, поэтому мы должны сместить его на его ширину. БОльшая часть этих координат получена методом проб и ошибок, поэтому при желании вы можете попробовать другие расстояния.

Упражнения с UI


120. (КОНТЕНТ) Реализуйте UI для параметра Ammo. Позиция полосы равна gw/2 - 52, 16.

121. (КОНТЕНТ) Реализуйте UI для параметра Boost. Позиция полосы равна gw/2 + 4, 16.

122. (КОНТЕНТ) Реализуйте UI для параметра Cycle. Позиция полосы равна gw/2 + 4, gh - 16.

Конец


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

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

Часть 10: Практики написания кода


Введение


В этой части я расскажу о рекомендуемых практиках кодирования и о том, как они применимы или неприменимы к тому, что мы делаем в этой серии туториалов. Если вы читаете её с самого начала и сделали большинство упражнений (особенно те, которые помечены как «контент»), то вы, вероятно, столкнулись с решениями, вызывающими вопросы с точки зрения практик программирования: огромные цепочки if/elseif, глобальные функции, огромные функции, огромные классы, выполняющие кучу операций, копипастинг и повторяющийся код вместо правильного абстрагирования, и так далее.

Если вы уже имеете опыт программирования в другой области, то знаете, чего делать не стоит, поэтому в этой части я хотел более подробно объяснить некоторые из этих решений. В отличие от всех предыдущих частей эта будет очень категоричной и возможно ошибочной, поэтому вы без проблем можете пропустить её. Мы не будем рассматривать ничего, напрямую связанного с игрой, даже когда для контекста того, о чём говорю, я буду приводить примеры из создаваемой нами игры. В этой части мы поговорим о двух основных аспектах: глобальных переменных и абстракциях. Во-первых, мы обсудим, когда и где можно использовать глобальные переменные, во-вторых, более широко рассмотрим то, как и когда нужно или не нужно абстрагировать/обобщать.

Кроме того, если вы купили туториал, то в кодовую базу для этой статьи я добавил код, который ранее был помечен в упражнениях как «контент», а именно графику для всех кораблей игрока, все атаки, а также объекты для всех ресурсов, потому что я буду использовать их здесь, как примеры.

Глобальные переменные


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

Приведём простой пример — представьте, что у вас есть пара объектов, использующих общую глобальную переменную. Допустим, вы не используете других источников случайности в модулях, тогда выходные данные конкретного метода можно предсказать (а потому и протестировать), если нам известно состояние системы до выполнения метода.

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


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

И именно эту мысль я буду повторять на протяжении всей статьи, потому что я глубоко верю в неё: совет, который полезен командам из нескольких человек и в разработке ПО, которое будет поддерживаться в течение нескольких лет/десятилетий, не работает так же хорошо для разработчиков-одиночек инди-видеоигр. Когда вы пишете код в основном самостоятельно, то можете пойти на упрощения, которые непозволительны команде. А когда вы пишете видеоигры, то можете упрощать ещё сильнее, по сравнению с другими типами ПО, потому что игры обычно поддерживаются в течение короткого времени.

Эта разница в контекстах проявляется, когда дело доходит до глобальных переменных. По-моему, можно использовать глобальные переменные, когда ты знаешь, как и зачем их использовать. Мы хотим максимально воспользоваться их преимуществами и в то же время избежать их недостатков. И в этом смысле нам также нужно учитывать имеющиеся у нас преимуществва: во-первых, мы пишем код самостоятельно, во-вторых, мы пишем видеоигры.

Типы глобальных переменных


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

Тип 1


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

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

Тип 2


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

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

Тип 3


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

© Habrahabr.ru