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

https://github.com/SSYGEN/blog/issues/30
  • Перевод
  • Tutorial
image

Оглавление


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

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, так как мы знаем, что вызов функции с помощью : передаёт как первый аргумент её саму. То есть внутри функции next self ссылается на таблицу, которую возвращает 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). В готовой игре он будет выглядеть так:


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

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, то 1/4 от размера. И если мы умножим это соотношение на ширину, которую должна иметь полоса, то получим красивую визуализацию заполнения HP игрока. Если мы реализуем это, то игра будет выглядеть так:


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

Теперь аналогично тому. как мы отрисовали число очков, мы можем отрисовать текст 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


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

В нашей игре есть несколько таких переменных, но я думаю, что самой заметной будет current_room. Уже само её имя подразумевает некую неопределённость, поскольку текущая комната может быть объектом Stage, или объектом Console, или объектом SkillTree, или любым другим типом объекта Room. Я решил, что для нашей игры это будет вполне приемлемым снижением ясности.



Основной смысл подобного разделения глобальных переменных на типы заключается в том, чтобы разобраться в проблеме немного глубже и, так сказать, отделить зёрна от плевел. Наша продуктивность сильно бы пострадала, если бы мы отнеслись к этому слишком догматично и всеми силами избегали бы глобальных переменных. Избегать их всеми силами стоит командам и людям, годами занимающимися поддержкой ПО, но маловероятно, что переменная all_colors помешает нам в длительной перспективе. Пока мы отслеживаем переменные наподобие current_room и не позволяем им быть слишком множественными или слишком запутывающими (например, current_room изменяется только при вызове функции gotoRoom), мы сможем всё контролировать.

Когда вы видите или хотите использовать глобальную переменную, то для начала подумайте, какого типа она будет. Если это тип 1 или 2, то, скорее всего, она не вызовет проблем. Если она имеет тип 3, то важно подумать о том, когда и как часто её будут считывать и записывать. Если вы очень часто записываете в неё из случайных объектов во всей кодовой базе и считываете из случайных объектов по всей кодовой базе, то, вероятно, не стоит делать её глобальной. Если вы очень редко записываете в неё из очень маленького множества объектов, и считываете её из случайных объектов по всей кодовой базе, то это всё ещё не очень хорошо, но может быть допустимо. Смысл в том, чтобы размышлять о таких проблемах критически, а не просто следовать каким-то догматическим правилам.

Абстрагирование и копипастинг


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

local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = random(16, gh - 16)

И они одинаковы для всех объектов, которые должны создаваться слева или справа от экрана в случайной позиции по оси y. Кажется, эти три строки пока есть в начале примерно 6-7 объектов. Аргумент в пользу абстрагирования здесь заключается в том, что если эти строки повторяются в нескольких объектах, то мы должны их каким-то образом абстрагировать, чтобы объекты могли использовать эту абстракцию, а не повторяющиеся в кодовой базе строки. Мы можем реализовать эту абстракцию через наследование, компоненты, функцию или какой-то другой механизм. В нашем обсуждении все эти различные способы будут считаться одной темой, потому что все они демонстрируют одинаковые проблемы.

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

Абстрагирование


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

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

Однако у такого решения есть и свои затраты, которые чаще всего игнорируются, когда люди «продают» вам какое-то решение. Затраты становятся очевидными, когда мы хотим добавить какое-то новое поведение, которое похоже на старое, но не полностью. А в играх такое происходит часто.

Так что, например, допустим, что нам нужно добавить объекты, которые спаунятся точно посередине экрана. У нас есть два варианта: или изменить SpawnerComponent, чтобы он принимал это новое поведение, или создать новый компонент, реализующий это новое поведение. В нашем случае очевидным выбором будет изменение SpawnerComponent, но в более сложных примерах выбор может оказаться не таким уж очевидным. Смысл здесь в том, что поскольку нам нужно добавить новый код с учётом имеющегося кода (в нашем случае это SpawnerComponent), то для этого нам понадобится больше мысленных усилий с учётом того, что нам нужно решить где и куда стоит добавлять функционал вместо того, чтобы спокойно его добавить.

Копипастинг


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

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

local direction = table.random({-1, 1})
self.x = gw/2 + direction*(gw/2 + 48)
self.y = gh/2

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

Тогда возникает вопрос — если у обоих способов есть свои преимущества и недостатки, какой из них нужно использовать по умолчанию? Обычно люди говорят, что по умолчанию стоит использовать первый способ и мы не должны долго сохранять такой повторяющийся код, потому что это «плохо пахнет». Но на мой взгляд нужно делать наоборот. По умолчанию мы должны использовать повторяющийся код и абстрагировать его, только когда это абсолютно необходимо. И причина тому…

Частота и типы изменений


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

Непредсказуемые изменения


Непредсказуемые изменения — это такие изменения, которые фундаментальным образом изменяют поведение, гораздо сильнее, чем простые мелкие изменения. В примере с поведением спауна непредсказуемым изменением было бы, например, то, что вместо случайного создания врагов слева и справа от экрана они бы создавались на основании позиции, заданной алгоритмом процедурного генератора. Мы не можем предсказать фундаментальные изменения такого типа.

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

Предсказуемые изменения


Предсказуемые изменения — это изменения, которые модифицируют поведения незначительным и определённым образом. В показанном выше примере с поведением спауна предсказуемым изменением будет пример, в котором нам нужно создавать объекты точно посередине экрана по оси y. Это изменение меняет поведение спауна, но оно очень незначительно и не полностью разрушает основы работы поведения спауна.

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



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

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

Примеры


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

Движение влево/вправо


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

function Rock:new(area, x, y, opts)
    ...

    self.w, self.h = 8, 8
    self.collider = self.area.world:newPolygonCollider(createIrregularPolygon(8))
    self.collider:setPosition(self.x, self.y)
    self.collider:setObject(self)
    self.collider:setCollisionClass('Enemy')
    self.collider:setFixedRotation(false)
    self.v = -direction*random(20, 40)
    self.collider:setLinearVelocity(self.v, 0)
    self.collider:applyAngularImpulse(random(-100, 100))
  
  	...
end

function Rock:update(dt)
    ...

    self.collider:setLinearVelocity(self.v, 0) 
end

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

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

Графика кораблей игрока и следы за ними


Если вы сделали большинство упражнений, то в классе Player есть код, который выглядит примерно так:

GIF

По сути, это два огромных if/elseifs, один управляет графикой всевозможных кораблей, а другой — следами этих кораблей. Первое, что можно подумать, глядя на этот код — что его нужно ОЧИСТИТЬ. Но повторюсь, обязательно ли это? В отличие от предыдущих примеров, этот код не повторяется во множестве различных мест, то есть это просто куча последовательного кода.

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



Размер класса Player


Класс Player сейчас состоит примерно из 500 строк. В следующей части, где мы добавим пассивные навыки, он разрастётся примерно до 2000 с лишним строк. Когда вы увидите это, вашей естественной реакцией будет стремление сделать код более красивым и чистым. И здесь снова стоит задать себе вопрос — действительно ли необходимо это делать? В большинстве игр класс Player содержит бОльшую часть функционала и часто люди прикладывают огромные усилия к том, чтобы он не стал таким огромным классом, в котором происходит всё.

Но по тем же причинам, по которым я решил не абстрагировать графику и следы кораблей из предыдущего примера, на мой взгляд не имеет смысла абстрагировать все эти разные логические части, составляющие класс игрока. Поэтому вместо создания отдельных файлов для движения игрока, коллизий игрока, атак игрока и так далее, я считаю оптимальнее собрать всё это в один файл и получить класс Player из 2000 строк. Соотношение преимуществ и затрат нахождения всего в одном месте и без слоёв абстракций между элементами выше, чем соотношение преимуществ и затрат при правильном абстрагировании элементов (по моему мнению!).

Entity-Component-System


И наконец, самым популярным «мемом» для разработчиков-одиночек в последние годы был ECS. Думаю, с учётом вышесказанного вы уже понимаете, какова моя позиция по этому поводу, но я всё равно её объясню. Преимущества паттерна ECS чётко видны и, как мне кажется, все их понимают. Чего люди не понимают — так это его недостатков.

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

Более того, я считаю, что большинство инди-игр никогда не доходит до момента, когда архитектура ECS начинает себя оправдывать. Посмотрите на этот нарисованный мной научный график, и это станет вам понятно:


Смысл в том, что в начале «yolo-кодинг» (который я пропагандирую в этой части статьи) требует меньше усилий по сравнению с ECS. Со временем, в процессе развития проекта затраты на yolo-кодинг увеличиваются, а затраты на ECS снижаются. После чего наступает момент, когда ECS становится эффективнее, чем yolo-кодинг. Я считаю, что большинство инди-игр, за очень малым исключением (по крайней мере, на мой взгляд), никогда не достигнет этого пересечения двух линий.


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

КОНЕЦ


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

В то же время, если вы работаете в большой компании и создаёте ПО, которое должно поддерживаться в течение долгого времени, и привыкли к связанным с этим практиками и стилями программирования, то попытка написания кода дома для своей игры в другом, описанном мной стиле, может оказаться провальной. Поэтому вам также нужно учесть «естественную» для вас среду кодирования и то, насколько описанная мной естественная для инди-разработчиков игр среда программирования далека от вас, а также насколько хорошо вы можете переключаться между ними. Я хочу сказать, что нужно критически относиться к своим практикам программирования, к тому, насколько они подходят к конкретному контексту и насколько удобно вам ими пользоваться.

Часть 11: Пассивные навыки


Введение


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

В этой части статьи будет много упражнений, помеченных как «контент». Они работают следующим образом: сначала я покажу вам, как что-то делается, а затем дам кучу упражнений, в которых нужно сделать то же самое, но для других параметров. Например, я покажу, как реализовать множитель HP, то есть характеристику, которая умножает HP игрока на определённый процент, а затем в упражнениях попрошу вас реализовать множители Ammo и Boost. На самом деле всё будет немного сложнее, но в целом смысл таков.

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

Типы характеристик


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

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

  • Ресурсы — это HP, Boost и Ammo. Эти значения описываются переменной max_value, а также переменной current_value. В случае HP у нас есть максимальное здоровье, которое может иметь игрок, а также его текущее количество.
  • Множители характеристик — это множители, применяемые к различным значениям в игре. Когда игрок просматривает дерево, он может выбирать такие узлы, как «Увеличение скорости движения на 10%». После выбора и начала новой игры мы берём все выбранные игроком узлы, упаковываем их в эти значения множителей, а затем применяем в игре. То есть если игрок выбрал узлы, дающие увеличение скорости движения на 50%, то к переменной max_v будет применяться множитель скорости движения. Некая переменная mvspd_multiplier будет равна 1.5, а максимальная скорость умножится на 1.5 (то есть на 50%).
  • Вероятности — это шансы выполнения некоторого события. Игрок также сможет выбирать дополнительную вероятность выполнения некоторых событий в определённых условиях. Например, «Увеличенная на 5% вероятность очереди за цикл», то есть при завершении цикла (реализованный нами промежуток в 5 секунд) у игрока существует 5-процентная вероятность выпустить очередь из снарядов. Если игрок выберет множество таких узлов, то вероятность повышается и очереди будут происходить чаще.

Также в игре будет дополнительный тип узлов и дополнительная механика: важные узлы и временные бонусы.

  • Важные узлы — это узлы, каким-то образом изменяющие логику игры (хотя и не всегда). Например, у нас будет узел, заменяющий HP энергетическим щитом. Благодаря щиту игрок может выдерживать двойной урон, если он какое-то время не получает урона, то щит перезаряжается, кроме того, время неуязвимости становится вдвое меньше. Подобные узлы будут встречаться не так часто, как другие, но они могут быть очень мощными и комбинироваться интересными способами.
  • Временные бонусы — это временные увеличения параметров. Иногда игрок может получать временный бонус, который, например, на 4 секунды увеличивает скорость атаки на 50%.



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

function Player:new(...)
    ...
  
    -- Boost
    self.max_boost = 100
    self.boost = self.max_boost
    ...

    -- HP
    self.max_hp = 100
    self.hp = self.max_hp

    -- Ammo
    self.max_ammo = 100
    self.ammo = self.max_ammo
  
    ...
end

Значения кода движения должны выглядеть вот так:

function Player:new(...)
    ...
  	
    -- Movement
    self.r = -math.pi/2
    self.rv = 1.66*math.pi
    self.v = 0
    self.base_max_v = 100
    self.max_v = self.base_max_v
    self.a = 100
	
    ...
end

А значения цикла должны выглядеть так (ради целостности я переименовал все предыдущие ссылки со словом «tick» на «cycle»):

function Player:new(...)
    ...
  
    -- Cycle
    self.cycle_timer = 0
    self.cycle_cooldown = 5

    ...
end

Множитель HP


Итак, давайте начнём с множителя HP. В простейшем случае нам достаточно определить переменную hp_multiplier, которая изначально имеет значение 1, а затем применить все увеличения из дерева к этой переменной и в какой-то момент умножить её на max_hp. Давайте начнём с первого:

function Player:new(...)
    ...
  	
    -- Multipliers
    self.hp_multiplier = 1
end

Второе — нам нужно считать, что мы получаем увеличения HP из дерева. Для этого нам нужно решить, как будут передаваться эти увеличения и как они будут определяться. Здесь мне придётся немного сжульничать (потому что я уже написал эту игру) и сказать, что узлы дерева будут определяться в следующем формате:

tree[2] = {'HP', {'6% Increased HP', 'hp_multiplier', 0.06}}

Это означает, что узел 2 называется HP, имеет описание 6% Increased HP и влияет на переменную hp_multiplier на 0.06 (6%). Есть функция treeToPlayer, которая получает все 900 этих определений узлов и применяет их к объекту игрока. Важно также заметить, что имя переменной, используемая в определении узла, должно быть тем же именем, которое определено в объекте игрока, в противном случае ничего не заработает. Это очень тонко связанный и подверженный ошибкам способ, но как я сказал в предыдущей части, с такими вещами можно смириться, потому что мы пишем всё в одиночку.

Последний вопрос заключается в следующем: когда мы умножаем hp_multiplier на max_hp? Естественным выбором будет просто делать это в конструкторе, потому что именно в нём создаётся новый игрок, а новый игрок создаётся при создании новой комнаты Stage, что также происходит при начале новой игры. Однако мы сделаем это в самом конце конструктора, после того, как определены все ресурсы, множители и вероятности:

function Player:new(...)
    ...
  
    -- treeToPlayer(self)
    self:setStats()
end

Поэтому в функции setStats мы можем сделать следующее:

function Player:setStats()
    self.max_hp = self.max_hp*self.hp_multiplier
    self.hp = self.max_hp
end

То есть если мы, например, присвоим hp_multiplier значение 1.5 и запустим игру, то заметим, что у игрока будет 150 HP вместо 100.

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

123. (КОНТЕНТ) Реализуйте переменную ammo_multiplier.

124. (КОНТЕНТ) Реализуйте переменную boost_multiplier.

Простое HP


Теперь поговорим о простых характеристиках. Простые характеристики — это прямое увеличение какой-то характеристики, не основанное на процентах. Реализуем мы их для HP, определив переменную flat_hp, которая прибавляется к max_hp (до умножения на множитель):

function Player:new(...)
    ...
  	
    -- Flats
    self.flat_hp = 0
end

function Player:setStats()
    self.max_hp = (self.max_hp + self.flat_hp)*self.hp_multiplier
    self.hp = self.max_hp
end

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

tree[15] = {'Flat HP', {'+10 Max HP', 'flat_hp', 10}}

125. (КОНТЕНТ) Реализуйте переменную flat_ammo.

126. (КОНТЕНТ) Реализуйте переменную flat_boost.

127. (КОНТЕНТ) Реализуйте переменную ammo_gain, которая прибавляется к количеству получаемых боеприпасов, когда игрок подбирает ресурс. Соответствующим образом измените вычисления в функции addAmmo.

Самонаводящиеся снаряды


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

Функция самонаведения будет активироваться у снаряда при присвоении его атрибуту attack значения 'Homing'. Код, выполняющий самонаведение, будет таким же, что и код, использованный для ресурса Ammo:

function Projectile:update(dt)
    ...
    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))

    -- Homing
    if self.attack == 'Homing' then
    	-- Move towards target
        if self.target then
            local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized()
            local angle = math.atan2(self.target.y - self.y, self.target.x - self.x)
            local to_target_heading = Vector(math.cos(angle), math.sin(angle)):normalized()
            local final_heading = (projectile_heading + 0.1*to_target_heading):normalized()
            self.collider:setLinearVelocity(self.v*final_heading.x, self.v*final_heading.y)
        end
    end
end

Единственное, что будет отличаться — это определение переменной target. У объекта Ammo переменная target указывает на объект игрока, но в случае снаряда она будет указывать на ближайшего врага. Чтобы получить ближайшего врага, мы можем использовать функцию getAllGameObjectsThat, определённую в классе Area, и применить фильтр, который будет выбирать только объекты являющиеся врагами и расположенные достаточно близко. Для этого мы сначала должны определить, какие из объектов являются врагами, а какие объекты ими не являются. Простейший способ сделать это — создать глобальную таблицу enemies, в которой будет содержаться список строк с именами классов врагов. То есть в globals.lua мы можем добавить следующее определение:

enemies = {'Rock', 'Shooter'}

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

local targets = self.area:getAllGameObjectsThat(function(e)
    for _, enemy in ipairs(enemies) do
    	if e:is(_G[enemy]) then
            return true
        end
    end
end)

Мы используем строку _G[enemy] для доступа к определению класса текущей строки, которую проходим в цикле. То есть _G['Rock'] вернёт таблицу, содержащую определение класса Rock. Мы рассматривали это в нескольких частях туториала, поэтому вы должны уже понимать, почему это работает.

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

local targets = self.area:getAllGameObjectsThat(function(e)
    for _, enemy in ipairs(enemies) do
    	if e:is(_G[enemy]) and (distance(e.x, e.y, self.x, self.y) < 400) then
            return true
        end
    end
end)

distance — это функция, которую мы можем определить в utils.lua. Она возвращает расстояние между двумя позициями:

function distance(x1, y1, x2, y2)
    return math.sqrt((x1 - x2)*(x1 - x2) + (y1 - y2)*(y1 - y2))
end

И после этого враги должны будут находиться в списке targets. Затем единственное, что нам нужно — выбирать случайным образом одного из них и указывать его как target, к которому направляется снаряд:

self.target = table.remove(targets, love.math.random(1, #targets))

И всё это должно выглядеть вот так:

function Projectile:update(dt)
    ...

    self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))

    -- Homing
    if self.attack == 'Homing' then
        -- Acquire new target
        if not self.target then
            local targets = self.area:getAllGameObjectsThat(function(e)
                for _, enemy in ipairs(enemies) do
                    if e:is(_G[enemy]) and (distance(e.x, e.y, self.x, self.y) < 400) then
                        return true
                    end
                end
            end)
            self.target = table.remove(targets, love.math.random(1, #targets))
        end
        if self.target and self.target.dead then self.target = nil end

        -- Move towards target
        if self.target then
            local projectile_heading = Vector(self.collider:getLinearVelocity()):normalized()
            local angle = math.atan2(self.target.y - self.y, self.target.x - self.x)
            local to_target_heading = Vector(math.cos(angle), math.sin(angle)):normalized()
            local final_heading = (projectile_heading + 0.1*to_target_heading):normalized()
            self.collider:setLinearVelocity(self.v*final_heading.x, self.v*final_heading.y)
        end
    end
end

В конце блока, где мы получаем новую цель, есть дополнительная строка, в которой мы присваиваем self.target значение nil в случае, когда цель убита. Благодаря этому когда цель для снаряда перестаёт существовать, self.target присваивается значение nil и получается новая цель, потому что будет удовлетворяться условие not self.target, после чего весь процесс повторяется. Важно также упомянуть, что после получения цели мы не делаем больше никаких вычислений, поэтому нет особой нужны волноваться о скорости выполнения функции getAllGameObjectsThat, которая наивно обходит в цикле все живые объекты в игре.

Следующее, что нам нужно изменить — это поведение объекта снаряда, когда он не является самонаводящимся или когда отсутствует цель. Логично было бы использовать сначала setLinearVelocity для задания скорости снаряда, а затем использовать её повторно внутри цикла if self.attack == 'Homing', поскольку скорость будет изменяться только если снаряд на самом деле является самонаводящимся и если существует цель. Но по какой-то причине это приводит ко всевозможным проблемам, поэтому мы должны вызывать setLinearVelocity только один раз, то есть написать что-то вроде такого:

-- Homing
if self.attack == 'Homing' then
    ...
-- Normal movement
else self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r)) end

Это немного запутанней, чем предыдущая схема, зато это работает. И если мы протестируем всё это и создадим снаряд, атрибуту attack которого присвоено значение 'Homing', то это должно выглядеть так:


128. (КОНТЕНТ) Реализуйте атаку Homing. Её определение в таблице атак выглядит следующим образом:

attacks['Homing'] = {cooldown = 0.56, ammo = 4, abbreviation = 'H', color = skill_point_color}

А сама атака будет выглядеть так:


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

Вероятность стрельбы самонаводящимся снарядом при подборе боеприпасов


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

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

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

if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then
    -- launch homing projectile
end

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

function Player:generateChances()
    self.chances = {}
    for k, v in pairs(self) do
        if k:find('_chance') and type(v) == 'number' then
      
        end
    end
end

Здесь мы проходимся по всем парам «ключ-значение» внутри объекта игрока и возвращаем true, когда находим атрибут, содержащий в названии подстроку _chance, а также являющийся числом. Если оба эти условия истинны, то на основании нашего собственного решения это является переменной, относящейся к вероятности выполнения какого-то события. То есть нам достаточно создать затем chanceList и добавить его в таблицу chances:

function Player:generateChances()
    self.chances = {}
    for k, v in pairs(self) do
        if k:find('_chance') and type(v) == 'number' then
      	    self.chances[k] = chanceList({true, math.ceil(v)}, {false, 100-math.ceil(v)})
      	end
    end
end

Таким образом мы создадим chanceList из 100 значений, v из которых будут равны true, а 100-v — false. То есть если единственной переменной типа «вероятность», определённой в объекте игрока, была launch_homing_projectile_on_ammo_pickup_chance, и она имеет значение 5 (означающее 5-процентную вероятность совершения события), то в chanceList будет 5 значений true и 95 значений false, что даёт нам нужный результат.

И тогда если мы вызовем generateChances для конструктора игрока:

function Player:new(...)
    ...
  
    -- treeToPlayer(self)
    self:setStats()
    self:generateChances()
end

То всё замечательно заработает. Теперь мы можем определить переменную launch_homing_projectile_on_ammo_pickup_chance:

function Player:new(...)
    ...
  	
    -- Chances
    self.launch_homing_projectile_on_ammo_pickup_chance = 0
end

И если вы хотите протестировать работу этой системы «бросания кубика», то можно присвоить значение 50 и несколько раз вызвать :next(), чтобы посмотреть, что произойдёт.

Реализация выстрела будет происходить через функцию onAmmoPickup, которая вызывается при подборе ресурса Ammo:

function Player:update(dt)
    ...
    if self.collider:enter('Collectable') then
        ...
    
        if object:is(Ammo) then
            object:die()
            self:addAmmo(5)
            self:onAmmoPickup()
      	...
    end
end

И эта функция будет работать следующим образом:

function Player:onAmmoPickup()
    if self.chances.launch_homing_projectile_on_ammo_pickup_chance:next() then
        local d = 1.2*self.w
        self.area:addGameObject('Projectile', 
      	self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), 
      	{r = self.r, attack = 'Homing'})
        self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'})
    end
end

В результате всё это будет выглядеть так:


129. (КОНТЕНТ) Реализуйте пассивный навык regain_hp_on_ammo_pickup_chance. Количество восстанавливаемого HP будет равно 25. Оно должно добавляться с помощью функции addHP, которая прибавляет заданное количество HP к значению hp, проверяя, что оно не превышает max_hp. Кроме того, должен создаваться объект InfoText с текстом 'HP Regain!' и цветом hp_color.

130. (КОНТЕНТ) Реализуйте пассивный навык regain_hp_on_sp_pickup_chance. Количество восстанавливаемого HP будет равно 25. Оно должно добавляться с помощью функции addHP, которая прибавляет заданное количество HP к значению hp, проверяя, что оно не превышает max_hp. Должен также создаваться объект InfoText с текстом 'HP Regain!' и цветом hp_color. Кроме того, в класс Player нужно добавить функцию onSPPickup, и в ней должна выполняться вся работа (аналогично тому, как было с функцией onAmmoPickup).

Область ускорения


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

Множитель ASPD


Мы можем определить множитель ASPD просто как переменную aspd_multiplier и потом умножить эту переменную на время «передышки» между стрельбой:

function Player:new(...)
    ...
  	
    -- Multipliers
    self.aspd_multiplier = 1
end

function Player:update(dt)
    ...
  
    -- Shoot
    self.shoot_timer = self.shoot_timer + dt
    if self.shoot_timer > self.shoot_cooldown*self.aspd_multiplier then
        self.shoot_timer = 0
        self:shoot()
    end
end

Основная разница заключается в том, что для этого множителя меньшие значения лучше, чем бОльшие. Обычно если значение множителя равно 0.5, то он уменьшает вполовину тот параметр, к которому применяется. То есть для HP, скорости движения и почти всего остального это плохо. Однако для скорости атак меньшие значения лучше, и это легко объяснить показанным выше кодом. Поскольку мы применяем множитель к переменной shoot_cooldown, меньшие значения означают, что пауза будет короче, то есть игрок будет стрелять быстрее. Мы используем это знание при создании объекта HasteArea.

Область ускорения


Теперь, когда у нас есть множитель ASPD, мы можем вернуться к области. Здесь мы хотим создать круговую область, которая будет уменьшать aspd_multiplier на некую величину, пока в ней находится игрок. Чтобы достичь этого, мы создадим новый объект HasteArea, который будет управлять логикой проверки того, находится ли игрок внутри и задавать соответствующие значения. Базовая структура этого объекта выглядит так:

function HasteArea:new(...)
    ...
  
    self.r = random(64, 96)
    self.timer:after(4, function()
        self.timer:tween(0.25, self, {r = 0}, 'in-out-cubic', function() self.dead = true end)
    end)
end

function HasteArea:update(dt)
    ...
end

function HasteArea:draw()
    love.graphics.setColor(ammo_color)
    love.graphics.circle('line', self.x, self.y, self.r + random(-2, 2))
    love.graphics.setColor(default_color)
end

Для реализации логики применения эффекта нам нужно отслеживать попадание/уход игрока из области и изменять значение aspd_multiplier, когда это происходит. Сделать это можно примерно таким образом:

function HasteArea:update(dt)
    ...
  	
    local player = current_room.player
    if not player then return end
    local d = distance(self.x, self.y, player.x, player.y)
    if d < self.r and not player.inside_haste_area then -- Enter event
        player:enterHasteArea()
    elseif d >= self.r and player.inside_haste_area then -- Leave event
    	player:exitHasteArea()
    end
end

Мы используем переменную inside_haste_area для отслеживания того, находится ли игрок в области. Эта переменная принимает значение true внутри enterHasteArea и false внутри exitHasteArea, то есть эти функции будут вызываться только тогда, когда эти события будут происходит в объекте HasteArea. В классе Player обе функции будут просто применять необходимые модификации:

function Player:enterHasteArea()
    self.inside_haste_area = true
    self.pre_haste_aspd_multiplier = self.aspd_multiplier
    self.aspd_multiplier = self.aspd_multiplier/2
end

function Player:exitHasteArea()
    self.inside_haste_area = false
    self.aspd_multiplier = self.pre_haste_aspd_multiplier
    self.pre_haste_aspd_multiplier = nil
end

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

131. (КОНТЕНТ) Реализуйте пассивный навык spawn_haste_area_on_hp_pickup_chance. Объект InfoText должен создаваться с текстом 'Haste Area!'. Кроме того, к классу Player нужно добавить функцию onHPPickup.

132. (КОНТЕНТ) Реализуйте пассивный навык spawn_haste_area_on_sp_pickup_chance. Объект InfoText должен создаваться с текстом 'Haste Area!'.

Вероятность создания SP в цикле


Следующим эффектом будет spawn_sp_on_cycle_chance. Мы полностью знаем, как его реализовать. Часть «в цикле» ведёт себя очень похоже на «при подборе ресурса»; единственное отличие заключается в том, что мы будем вызывать функцию onCycle при выполнении нового цикла, а не при подборе ресурса. А часть «создание SP» — это просто создание нового ресурса SP, реализация которого нам известна.

Итак, для первой части нам нужно зайти в функцию cycle и вызвать onCycle:

function Player:cycle()
    ...
    self:onCycle()
end

Затем мы добавляем в Player переменную spawn_sp_on_cycle_chance:

function Player:new(...)
    ...
  	
    -- Chances
    self.spawn_sp_on_cycle_chance = 0
end

И таким образом мы также автоматически добавим новый chanceList, представляющий собой вероятность этой переменной. Благодаря этому мы можем добавить функционал, необходимый функции onCycle:

function Player:onCycle()
    if self.chances.spawn_sp_on_cycle_chance:next() then
        self.area:addGameObject('SkillPoint')
        self.area:addGameObject('InfoText', self.x, self.y, 
      	{text = 'SP Spawn!', color = skill_point_color})
    end
end

И это должно работать так, как задумано:


Вероятность выстрелить очередью при убийстве врага


Следующий навык — barrage_on_kill_chance. Единственное, что мы пока не знаем — это часть с «очередью». Срабатывание событий при убийстве аналогично предыдущему, за исключением того, что вместо вызова при выполнении цикла мы будем вызывать функцию игрока onKill при смерти врага.

Поэтому сначала мы добавим к Player переменную barrage_on_kill_chance:

function Player:new(...)
    ...
  	
    -- Chances
    self.barrage_on_kill_chance = 0
end

Затем мы создадим функцию onKill и будем вызывать её при смерти врага. Существует два подхода к вызову onKill при смерти игрока. Первый — просто вызывать функцию из функции die или hit каждого врага. Проблема здесь заключается в том, что при добавлении новых врагов нам придётся добавлять ко всем ним один и тот же код, вызывающий onKill. Второй вариант — вызов onKill при коллизии с врагом объекта Projectile. Здесь проблема заключается в том, что некоторые снаряды могут сталкиваться с врагами, но не убивать их (потому что у врагов больше HP или снаряд наносит меньше урона), поэтому нам нужно найти способ проверки того, мёртв ли враг на самом деле. Оказывается, выполнить эту проверку достаточно просто, поэтому я выберу этот способ:

function Projectile:update(dt)
    ...
  	
    if self.collider:enter('Enemy') then
        ...

        if object then
            object:hit(self.damage)
            self:die()
            if object.hp <= 0 then current_room.player:onKill() end
        end
    end
end

Единственное, что нам нужно делать после вызова функции hit врага — просто проверять, равно ли HP врага нулю. Если равно, то это означает, что он мёртв, и мы можем вызвать onKill.

Теперь перейдём к самой очереди. По умолчанию в коде будут выстреливаться 8 снарядов с промежутками между выстрелами в 0,05 секунды и разбросом в пределах от -math.pi/8 до +math.pi/8. Кроме того, снаряды очереди будут иметь атаку, которой обладает игрок. То есть если игрок стреляет самонаводящимися снарядами, то все снаряды очереди тоже будут самонаводящимися. В коде это можно записать так:

function Player:onKill()
    if self.chances.barrage_on_kill_chance:next() then
        for i = 1, 8 do
            self.timer:after((i-1)*0.05, function()
                local random_angle = random(-math.pi/8, math.pi/8)
                local d = 2.2*self.w
                self.area:addGameObject('Projectile', 
            	self.x + d*math.cos(self.r + random_angle), 
            	self.y + d*math.sin(self.r + random_angle), 
            	{r = self.r + random_angle, attack = self.attack})
            end)
        end
        self.area:addGameObject('InfoText', self.x, self.y, {text = 'Barrage!!!'})
    end
end

БОльшая часть кода достаточно проста. Единственное, что стоит упоминания — мы используем внутри цикла for after, чтобы разделить создание снарядов паузой в 0,05 секунды. Во всём остальном мы просто создаём снаряд с заданными ограничениями. Всё это должно выглядеть так:


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

133. (КОНТЕНТ) Реализуйте пассивный навык spawn_hp_on_cycle_chance.

134. (КОНТЕНТ) Реализуйте пассивный навык regain_hp_on_cycle_chance. Количество восстанавливаемого HP должно быть равно 25.

135. (КОНТЕНТ) Реализуйте пассивный навык regain_full_ammo_on_cycle_chance.

136. (КОНТЕНТ) Реализуйте пассивный навык change_attack_on_cycle_chance. Новая атака выбирается случайным образом.

137. (КОНТЕНТ) Реализуйте пассивный навык spawn_haste_area_on_cycle_chance.

138. (КОНТЕНТ) Реализуйте пассивный навык barrage_on_cycle_chance.

139. (КОНТЕНТ) Реализуйте пассивный навык launch_homing_projectile_on_cycle_chance.

140. (КОНТЕНТ) Реализуйте пассивный навык regain_ammo_on_kill_chance. Количество восстанавливаемых боеприпасов должно быть равно 20.

141. (КОНТЕНТ) Реализуйте пассивный навык launch_homing_projectile_on_kill_chance.

142. (КОНТЕНТ) Реализуйте пассивный навык regain_boost_on_kill_chance. Количество восстанавливаемого ускорения должно быть равно 40.

143. (КОНТЕНТ) Реализуйте пассивный навык spawn_boost_on_kill_chance.

Получение ускорения ASPD при убийстве


Мы уже реализовали похожий на «Ускорение ASPD» пассивный навык с помощью объекта HasteArea. Теперь мы хотим реализовать ещё один, в котором у нас будет вероятность получить увеличение скорости атак после убийства врага. Однако если мы попытаемся реализовать это точно так же, как предыдущее ускорение ASPD, то вскоре столкнёмся с проблемами. Чтобы освежить воспоминания, приведу пример того, как реализовано ускорение в HasteArea:

function HasteArea:update(dt)
    HasteArea.super.update(self, dt)

    local player = current_room.player
    if not player then return end
    local d = distance(self.x, self.y, player.x, player.y)
    if d < self.r and not player.inside_haste_area then player:enterHasteArea()
    elseif d >= self.r and player.inside_haste_area then player:exitHasteArea() end
end

Тогда enterHasteArea и exitHasteArea выглядят так:

function Player:enterHasteArea()
    self.inside_haste_area = true
    self.pre_haste_aspd_multiplier = self.aspd_multiplier
    self.aspd_multiplier = self.aspd_multiplier/2
end

function Player:exitHasteArea()
    self.inside_haste_area = false
    self.aspd_multiplier = self.pre_haste_aspd_multiplier
    self.pre_haste_aspd_multiplier = nil
end

Если бы мы попытались реализовать пассивный навык aspd_boost_on_kill_chance аналогичным образом, то это выглядело бы примерно так:

function Player:onKill()
    ...
    if self.chances.aspd_boost_on_kill_chance:next() then
        self.pre_boost_aspd_multiplier = self.aspd_multiplier
    	self.aspd_multiplier = self.aspd_multiplier/2
    	self.timer:after(4, function()
      	    self.aspd_multiplier = self.pre_boost_aspd_multiplier
            self.pre_boost_aspd_multiplier = nil
      	end)
    end
end

Здесь мы просто делаем то же самое, что и для ускорения в HasteArea. Мы сохраняем текущий множитель скорости атак, уменьшаем его вдвое, а затем через заданный промежуток времени (в нашем случае — 4 секунды) восстанавливаем исходное значение. Проблема в такой реализации возникает, когда мы хотим соединить действие этих бонусов.

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

А ещё представьте, что у игрока активно ускорение ASPD и он входит в область HasteArea. После завершения действия ускорения эффект HasteArea тоже обнулится, поскольку pre_boost_aspd_multiplier восстановит для aspd_multiplier значение, не учитывающее увеличение скорости атак HasteArea. Но что более важно, когда игрок будет покидать HasteArea, у него останется постоянно увеличенная скорость атак, потому что сохраняемая скорость атак при входе будет той, которая увеличена ускорением ASPD.

Поэтому решить эту проблему можно введением нескольких переменных:

function Player:new(...)
    ...
  	
    self.base_aspd_multiplier = 1
    self.aspd_multiplier = 1
    self.additional_aspd_multiplier = {}
end

Вместо одной переменной aspd_multiplier у нас будут base_aspd_multiplier и additional_aspd_multiplier. Переменная aspd_multiplier будет хранить текущий множитель с учётом всех ускорений. base_aspd_multiplier будет содержать исходный множитель учитывающий только процентное увеличение. То есть если мы получим из дерева увеличение скорости атак на 50%, оно будет применяться в конструкторе (в setStats) к base_aspd_multiplier. Тогда additional_aspd_multiplier будет содержать все прибавленные значения всех ускорений. То есть когда игрок будет находиться в HasteArea, мы будем добавлять в эту таблицу соответствующее значение, а затем в каждом кадре умножать её сумму на основание. Поэтому, например, функция обновления будет выглядеть следующим образом:

function Player:update(dt)
    ...
  	
    self.additional_aspd_multiplier = {}
    if self.inside_haste_area then table.insert(self.additional_aspd_multiplier, -0.5) end
    if self.aspd_boosting then table.insert(self.additional_aspd_multiplier, -0.5) end
    local aspd_sum = 0
    for _, aspd in ipairs(self.additional_aspd_multiplier) do
        aspd_sum = aspd_sum + aspd
    end
    self.aspd_multiplier = self.base_aspd_multiplier/(1 - aspd_sum)
end

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

Объект Stat выглядит так:

Stat = Object:extend()

function Stat:new(base)
    self.base = base

    self.additive = 0
    self.additives = {}
    self.value = self.base*(1 + self.additive)
end

function Stat:update(dt)
    for _, additive in ipairs(self.additives) do self.additive = self.additive + additive end

    if self.additive >= 0 then
        self.value = self.base*(1 + self.additive)
    else
        self.value = self.base/(1 - self.additive)
    end

    self.additive = 0
    self.additives = {}
end

function Stat:increase(percentage)
    table.insert(self.additives, percentage*0.01)
end

function Stat:decrease(percentage)
    table.insert(self.additives, -percentage*0.01)
end

И используем мы его для решения проблемы скорости атак следующим образом:

function Player:new(...)
    ...
  	
    self.aspd_multiplier = Stat(1)
end

function Player:update(dt)
    ...
  
    if self.inside_haste_area then self.aspd_multiplier:decrease(100) end
    if self.aspd_boosting then self.aspd_multiplier:decrease(100) end
    self.aspd_multiplier:update(dt)
  
    ...
end

Мы сможем получать доступ к множителю скорости атак в любой момент времени после вызова aspd_multiplier:update, обращаясь к aspd_multiplier.value, который будет возвращать верный результат в соответствии с основанием и всевозможными применёнными ускорениями. Поэтому мы должны изменить способ использования переменной aspd_multiplier:

function Player:update(dt)
    ...
  
    -- Shoot
    self.shoot_timer = self.shoot_timer + dt
    if self.shoot_timer > self.shoot_cooldown*self.aspd_multiplier.value then
        self.shoot_timer = 0
        self:shoot()
    end
end

Здесь мы просто заменяем self.shoot_cooldown*self.aspd_multiplier на self.shoot_cooldown*self.aspd_multiplier.value, потому что иначе ничего работать не будет. Кроме того, мы должны изменить здесь ещё кое-что. Способ работы переменной aspd_multiplier пока противоречит тому, как работают все остальные переменные игры. Когда мы говорим, что увеличили HP на 10%, то знаем, что hp_multiplier равна 1.1, но когда мы говорим, что увеличили на 10% ASPD, то aspd_multiplier на самом деле равна 0.9. Мы можем изменить это и сделать так, чтобы aspd_multiplier вела себя таким же образом, как и другие переменные, с выполнив вместо умножения деление shoot_cooldown:

if self.shoot_timer > self.shoot_cooldown/self.aspd_multiplier.value then

Таким образом, если мы получаем увеличение ASPD на 100%, то его значение будет равно 2 и мы вдвое снизим паузу между выстрелами, а именно этого мы и добивались. Кроме того, нам нужно изменить способ применения бонусов и вместо вызова для них decrease мы будем вызывать increase:

function Player:update(dt)
    ...
  
    if self.inside_haste_area then self.aspd_multiplier:increase(100) end
    if self.aspd_boosting then self.aspd_multiplier:increase(100) end
    self.aspd_multiplier:update(dt)
end

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

Как бы то ни было, таким способом мы можем запросто правильно реализовать «получение ускорения ASPD при убийстве»:

function Player:new(...)
    ...
  
    -- Chances
    self.gain_aspd_boost_on_kill_chance = 0
end

function Player:onKill()
    ...
  	
    if self.chances.gain_aspd_boost_on_kill_chance:next() then
        self.aspd_boosting = true
        self.timer:after(4, function() self.aspd_boosting = false end)
        self.area:addGameObject('InfoText', self.x, self.y, 
      	{text = 'ASPD Boost!', color = ammo_color})
    end
end

Также мы можем удалить функции enterHasteArea и exitHasteArea, а также немного изменить работу объекта HasteArea:

function HasteArea:update(dt)
    HasteArea.super.update(self, dt)

    local player = current_room.player
    if not player then return end
    local d = distance(self.x, self.y, player.x, player.y)
    if d < self.r then player.inside_haste_area = true
    elseif d >= self.r then player.inside_haste_area = false end
end

Вместо той сложной логики, которую мы использовали ранее, мы просто будем присваивать атрибуту inside_haste_area объекта Player значение true или false в зависимости, находится ли он в области, а затем благодаря тому, как мы реализовали объект Stat, применение ускорения скорости атак, полученных от HasteArea будет выполняться автоматически.

144. (КОНТЕНТ) Реализуйте пассивный навык mvspd_boost_on_cycle_chance. «Увеличение MVSPD» даёт игроку 50-процентное увеличение скорости движения на 4 секунды. Также реализуйте переменную mvspd_multiplier и выполняйте умножение на неё в соответствующем месте.

145. (КОНТЕНТ) Реализуйте пассивный навык pspd_boost_on_cycle_chance. «Увеличение PSPD» даёт созданным игроком снарядам 100-процентное увеличение скорости на 4 секунды. Кроме того, реализуйте переменную pspd_multiplier и выполняйте умножение на неё в соответствующем месте.

146. (КОНТЕНТ) Реализуйте пассивный навык pspd_inhibit_on_cycle_chance. «Уменьшение PSPD» даёт созданным игроком снарядам 50-процентное снижение скорости на 4 секунды.

Во время ускорения


Следующие пассивные навыки, которые мы будем реализовывать — это последние навыки типа «при вероятности события». Все рассмотренные нами ранее навыки были связаны с вероятностью выполнения чего-то при каком-то событии (при убийстве, в цикле, при подборе ресурса...) и следующие не будут отличаться, потому что они будут вероятностями выполнения чего-то во время ускорения (Boost) корабля.

В первую очередь мы реализуем launch_homing_projectile_while_boosting_chance. Он будет работать так: существует обычная вероятность выстрела самонаводящимся снарядом, и эта вероятность будет проверяться в интервале 0.2 секунды при выполнении ускорения (Boost). Это означает, что если мы будем ускоряться в течение 1 секунды, «кубики вероятности» будут брошены 5 раз.

Хорошим способом реализации этого будет определение двух новых функций: onBoostStart и onBoostEnd. Они будут активировать пассивный навык при начале ускорения и деактивировать его при завершении ускорения. Чтобы добавить эти две функции, нам нужно немного изменить код ускорения:

function Player:update(dt)
    ...
  
    -- Boost
    ...
    if self.boost_timer > self.boost_cooldown then self.can_boost = true end
    ...
    if input:pressed('up') and self.boost > 1 and self.can_boost then self:onBoostStart() end
    if input:released('up') then self:onBoostEnd() end
    if input:down('up') and self.boost > 1 and self.can_boost then 
        ...
        if self.boost <= 1 then
            self.boosting = false
            self.can_boost = false
            self.boost_timer = 0
            self:onBoostEnd()
        end
    end
    if input:pressed('down') and self.boost > 1 and self.can_boost then self:onBoostStart() end
    if input:released('down') then self:onBoostEnd() end
    if input:down('down') and self.boost > 1 and self.can_boost then 
        ...
        if self.boost <= 1 then
            self.boosting = false
            self.can_boost = false
            self.boost_timer = 0
            self:onBoostEnd()
        end
    end
    ...
end

Здесь мы добавляем input:pressed и input:released, которые возвращают true только при совершении этих событий, и благодаря этому мы можем быть уверены, что onBoostStart и onBoostEnd будут вызываться только при совершении этих событий. Также мы добавляем внутрь условной конструкции input:down onBoostEnd на тот случай, когда игрок не отпустил кнопку, но количество доступного ему ускорения заканчивается, а поэтому заканчивается и ускорение.

Теперь перейдём к части с launch_homing_projectile_while_boosting_chance:

function Player:new(...)
    ...
  
    -- Chances
    self.launch_homing_projectile_while_boosting_chance = 0
end

function Player:onBoostStart()
    self.timer:every('launch_homing_projectile_while_boosting_chance', 0.2, function()
        if self.chances.launch_homing_projectile_while_boosting_chance:next() then
            local d = 1.2*self.w
            self.area:addGameObject('Projectile', 
          	self.x + d*math.cos(self.r), self.y + d*math.sin(self.r), 
                {r = self.r, attack = 'Homing'})
            self.area:addGameObject('InfoText', self.x, self.y, {text = 'Homing Projectile!'})
        end
    end)
end

function Player:onBoostEnd()
    self.timer:cancel('launch_homing_projectile_while_boosting_chance')
end

Здесь при начале ускорения мы вызываем timer:every для проверки вероятности стрельбы самонаводящимся снарядом каждые 0,2 секунды, а затем, когда ускорение завершается, мы отменяем этот таймер. Вот как это выглядит, когда вероятность выполнения события равна 100%:


147. (КОНТЕНТ) Реализуйте переменную cycle_speed_multiplier. Эта переменная в зависимости от своего значения увеличивает или уменьшает скорость цикла. То есть, например, если cycle_speed_multiplier равна 2, а длительность цикла по умолчанию равна 5 секундам, то применение переменной приведёт к снижению длительности цикла до 2,5 секунды.

148. (КОНТЕНТ) Реализуйте пассивный навык increased_cycle_speed_while_boosting. Эта переменная должна иметь тип boolean и сигнализировать о том, должна ли увеличиваться скорость цикла, когда игрок выполняет ускорение. Ускорение должно быть увеличением множителя скорости цикла на 200%.

149. (КОНТЕНТ) Реализуйте пассивный навык invulnerability_while_boosting. Эта переменная имеет тип boolean и сигнализирует о том, должен ли игрок быть неуязвимым при ускорении. Воспользуйтесь уже существующим атрибутом invincible, который отвечает за неуязвимость игрока.

Увеличенная удача при ускорении


Последний тип пассивного навыка «при ускорении», который мы реализуем — это «увеличенная удача при ускорении». Прежде чем реализовать его, нам нужно реализовать параметр luck_multiplier. Удача — это один из основных параметров игры; она увеличивает вероятность выполнения желательных событий. Допустим, у нас есть 10-процентная вероятность выстрела самонаводящимся снарядом при убийстве. Если luck_multiplier равна 2, то эта вероятность становится 20-процентной.

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

function Player:generateChances()
    self.chances = {}
    for k, v in pairs(self) do
        if k:find('_chance') and type(v) == 'number' then
      	    self.chances[k] = chanceList(
            {true, math.ceil(v*self.luck_multiplier)}, 
            {false, 100-math.ceil(v*self.luck_multiplier)})
        end
    end
end

И здесь мы просто умножаем v на luck_multiplier и это должно работать именно так, как нужно. Благодаря этому мы можем реализовать пассивный навык increased_luck_while_boosting следующим образом:

function Player:onBoostStart()
    ...
    if self.increased_luck_while_boosting then 
    	self.luck_boosting = true
    	self.luck_multiplier = self.luck_multiplier*2
    	self:generateChances()
    end
end

function Player:onBoostEnd()
    ...
    if self.increased_luck_while_boosting and self.luck_boosting then
    	self.luck_boosting = false
    	self.luck_multiplier = self.luck_multiplier/2
    	self:generateChances()
    end
end

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

Кроме того важно, что при изменении множителя удачи мы также повторно вызываем generateChances, в противном случае наше увеличение удачи ни на что не повлияет. У такого решения есть свой недостаток — выполняется сброс всех списков, поэтому если какой-то список случайно выбрал серию неудачных «бросков», а затем был сброшен, то он снова может повторно выбрать серию неудачных «бросков» вместо того, чтобы использовать свойство chanceList, при котором с течением времени выбор менее удачных бросков становится всё менее вероятным. Но это очень несерьёзная проблема, которая лично меня не очень беспокоит.

Множитель вероятности создания HP


Теперь мы рассмотрим hp_spawn_chance_multiplier, который увеличивает вероятность того, что когда режиссёр создаёт новый ресурс, этим ресурсом будет HP. Если вы помните, как работает режиссёр, то эта реализация будет довольно простой:

function Player:new(...)
    ...
  	
    -- Multipliers
    self.hp_spawn_chance_multiplier = 1
end

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

В части 9 мы рассмотрели создание вероятностей спауна каждого из ресурсов. Эти вероятности хранятся в chanceList resource_spawn_chances, поэтому всё, что нам нужно — использовать hp_spawn_chance_multiplier для увеличения вероятностей того, что ресурс HP будет создан в соответствии с множителем.

Кроме того, здесь важно инициализировать в комнате Stage режиссёра после Player, поскольку Director зависит от имеющихся у Player переменных, в то время как Player вообще не зависит от Director.

150. (КОНТЕНТ) Реализуйте пассивный навык spawn_sp_chance_multiplier.

151. (КОНТЕНТ) Реализуйте пассивный навык spawn_boost_chance_multiplier.

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

152. (КОНТЕНТ) Реализуйте пассивный навык drop_double_ammo_chance. Когда враг умирает, должна быть вероятность того, что он создаст вместо одного два объекта Ammo.

153. (КОНТЕНТ) Реализуйте пассивный навык attack_twice_chance. Когда игрок атакует, то должна быть вероятность вызова функции shoot дважды.

154. (КОНТЕНТ) Реализуйте пассивный навык spawn_double_hp_chance. Когда режиссёр создаёт ресурс HP, должна быть вероятность того, что вместо одного он создаст два объекта HP.

155. (КОНТЕНТ) Реализуйте пассивный навык spawn_double_sp_chance Когда режиссёром создаётся ресурс SkillPoint, должна быть вероятность того, что он создаст вместо одного объекта SkillPoint два.

156. (КОНТЕНТ) Реализуйте пассивный навык gain_double_sp_chance. Когда игрок подбирает ресурс SkillPoint, должна быть вероятность того, что он получит два очка навыка вместо одного.

Частота создания врагов


enemy_spawn_rate_multiplier будет управлять тем, насколько быстро Director меняет уровни сложности. По умолчанию это происходит каждые 22 секунды, но если enemy_spawn_rate_multiplier равна 2, то это будет происходить каждые 11 секунд. Реализация этого тоже достаточно проста:

function Player:new(...)
    ...
  	
    -- Multipliers
    self.enemy_spawn_rate_multiplier = 1
end

function Director:update(dt)
    ...
  	
    -- Difficulty
    self.round_timer = self.round_timer + dt
    if self.round_timer > self.round_duration/self.stage.player.enemy_spawn_rate_multiplier then
        ...
    end
end

То есть здесь мы просто делим round_duration на enemy_spawn_rate_multiplier, чтобы получить нужную длительность раунда.

157. (КОНТЕНТ) Реализуйте пассивный навык resource_spawn_rate_multiplier.

158. (КОНТЕНТ) Реализуйте пассивный навык attack_spawn_rate_multiplier.

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

159. (КОНТЕНТ) Реализуйте пассивный навык turn_rate_multiplier. Этот пассивный навык увеличивает или уменьшает скорость поворота корабля игрока.

160. (КОНТЕНТ) Реализуйте пассивный навык boost_effectiveness_multiplier. Этот пассивный навык увеличивает или уменьшает эффективность ускорения. Это означает, что если переменная имеет значение 2, то ускорение будет работать в два раза быстрее или медленее.

161. (КОНТЕНТ) Реализуйте пассивный навык projectile_size_multiplier. Это пассивный навык, который увеличивает или уменьшает размер снарядов.

162. (КОНТЕНТ) Реализуйте пассивный навык boost_recharge_rate_multiplier. Это пассивный навык, который увеличивает или уменьшает скорость перезарядки ускорения.

163. (КОНТЕНТ) Реализуйте пассивный навык invulnerability_time_multiplier. Это пассивный навык, который увеличивает или уменьшает время неуязвимости игрока, когда ему наносится урон.

164. (КОНТЕНТ) Реализуйте пассивный навык ammo_consumption_multiplier. Этот пассивный навык увеличивает или уменьшает количество боеприпасов, потребляемых при всех атаках.

165. (КОНТЕНТ) Реализуйте пассивный навык size_multiplier. Этот пассивный навык увеличивает или уменьшает размер корабля игрока. Нужно учесть, что позиции всех следов всех кораблей, а также позиции снарядов должны изменяться соответствующим образом.

166. (КОНТЕНТ) Реализуйте пассивный навык stat_boost_duration_multiplier. Этот пассивный навык увеличивает или уменьшает длительность временных бонусов, даваемых игроку.

Пассивные навыки снарядов


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

Изменение на 90 градусов


Мы назовём этот пассивный навык projectile_ninety_degree_change. Он будет периодически изменять угол снаряда на 90 градусов. Это будет выглядеть так:


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

Простой способ заключается в том, что мы сделаем projectile_ninety_degree_change булевой переменной, которая будет влиять, когда имеет значение true. Поскольку мы собираемся применить этот эффект в классе Projectile, то у нас есть два варианта того, как можно считывать из него значение projectile_ninety_degree_change из Player: или передавать её в таблице opts при создании нового снаряда в функции shoot, или считывать её непосредственно из Player, осуществляя доступ через current_room.player. Я пойду по второму пути, потому что он проще и в нём нет серьёзных недостатков, за исключением того, что придётся заменить current_room.player на что-то другое, когда мы переместим часть этого кода в EnemyProjectile. Всё это будет выглядеть примерно таким образом:

function Player:new(...)
    ...
  	
    -- Booleans
    self.projectile_ninety_degree_change = false
end

function Projectile:new(...)
    ...

    if current_room.player.projectile_ninety_degree_change then

    end
end

Теперь нам нужно внутри условной конструкции конструктора Projectile каждый раз изменять угол снаряда на 90 градусов, но учитывать также его исходное направление. Первое, что мы можем сделать — случайным образом изменять угол или на 90, или на -90 градусов. Это будет выглядеть вот так:

function Projectile:new(...)
    ...

    if current_room.player.projectile_ninety_degree_change then
        self.timer:after(0.2, function()
      	    self.ninety_degree_direction = table.random({-1, 1})
            self.r = self.r + self.ninety_degree_direction*math.pi/2
      	end)
    end
end


Теперь нам нужно выяснить, как повернуть снаряд в другом направлении, а затем повернуть его обратно в другом, а затем снова, и так далее. Так как это периодическое действие, повторяемое бесконечно, то мы можем использовать timer:every:

function Projectile:new(...)
    ...
  	
    if current_room.player.projectile_ninety_degree_change then
        self.timer:after(0.2, function()
      	    self.ninety_degree_direction = table.random({-1, 1})
            self.r = self.r + self.ninety_degree_direction*math.pi/2
            self.timer:every('ninety_degree_first', 0.25, function()
                self.r = self.r - self.ninety_degree_direction*math.pi/2
                self.timer:after('ninety_degree_second', 0.1, function()
                    self.r = self.r - self.ninety_degree_direction*math.pi/2
                    self.ninety_degree_direction = -1*self.ninety_degree_direction
                end)
            end)
      	end)
    end
end

Сначала мы поворачиваем снаряд в противоположном от изначального поворота направлении, то есть теперь он направлен под исходным углом. Затем всего лишь через 0.1 секунды мы поворачиваем его снова в том же направлении, чтобы он был направлен в противоположном первому повороту направлении. Если сначала он при выстреле был направлен вправо, то происходит следующее: через 0.2 секунды он поворачивает вверх, через 0.25 секунды снова вправо, через 0.1 секунды — вниз, а затем через 0.25 секунды он повторяет процесс, поворачиваясь сначала вправо, потом вверх, потом вниз, и так далее.

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


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

168. (КОНТЕНТ) Реализуйте пассивный навык angle_change_frequency_multiplier. Этот навык увеличивает или уменьшает скорость изменения углов предыдущих двух пассивных навыков. Если angle_change_frequency_multiplier равен, например, 2, то вместо изменения углов через 0.25 и 0.1 секунды, они будут меняться через 0.125 и 0.05 секунды.

Волновые снаряды


Вместо прерывистого изменения угла снаряда мы можем делать это плавно с помощью функции timer:tween, получая таким образом эффект волнового снаряда:


Идея здесь почти такая же, что и в предыдущих примерах, только с использованием timer:tween:

function Projectile:new(...)
    ...
  	
    if current_room.player.wavy_projectiles then
        local direction = table.random({-1, 1})
        self.timer:tween(0.25, self, {r = self.r + direction*math.pi/8}, 'linear', function()
            self.timer:tween(0.25, self, {r = self.r - direction*math.pi/4}, 'linear')
        end)
        self.timer:every(0.75, function()
            self.timer:tween(0.25, self, {r = self.r + direction*math.pi/4}, 'linear',  function()
                self.timer:tween(0.5, self, {r = self.r - direction*math.pi/4}, 'linear')
            end)
        end)
    end
end

Из-за того, как работает timer:every, в коде он не начинает выполнять свои функции до завершения исходного времени,, поэтому мы сначала выполняем одну итерацию цикла вручную, а затем выполняется каждый цикл. В первой итерации мы также используем исходное значение math.pi/8 вместо math.pi/4, потому что мы хотим, чтобы снаряд колебался в два раза меньше от того, как нужно, потому что он изначально находится в среднем положении (так как был только что выстрелен Player), а не на одной из границ колебания.

169. (КОНТЕНТ) Реализуйте пассивный навык projectile_waviness_multiplier. Этот навык увеличивает или уменьшает целевой угол, который должен достичь снаряд при выполнении tween. Например, если If projectile_waviness_multiplier равен 2, то дуга его траектории будет в два раза больше обычной.

Ускорение и торможение снарядов


Теперь мы перейдём к нескольким пассивным навыкам, меняющим скорость снаряда. Первый — это «Быстро -> медленно», а второй «Медленно -> быстро», то есть снаряд начинает с высокой или низкой скорости, а затем переходит к низкой или высокой скорости. Вот как выглядит «Быстро -> медленно»:


Реализуем мы это довольно простым способом. Навык «Быстро -> медленно» реализуется быстрым tween скорости с удвоением начального значения, а затем через какое-то время снижением tween до половины начального значения. А для другого навыка мы просто сделаем обратную операцию.

function Projectile:new(...)
    ...
  	
    if current_room.player.fast_slow then
        local initial_v = self.v
        self.timer:tween('fast_slow_first', 0.2, self, {v = 2*initial_v}, 'in-out-cubic', function()
            self.timer:tween('fast_slow_second', 0.3, self, {v = initial_v/2}, 'linear')
        end)
    end

    if current_room.player.slow_fast then
        local initial_v = self.v
        self.timer:tween('slow_fast_first', 0.2, self, {v = initial_v/2}, 'in-out-cubic', function()
            self.timer:tween('slow_fast_second', 0.3, self, {v = 2*initial_v}, 'linear')
        end)
    end
end

170. (КОНТЕНТ) Реализуйте пассивный навык projectile_acceleration_multiplier. Этот навык управляет величиной ускорения при увеличении скорости от исходного значения.

171. (КОНТЕНТ) Реализуйте пассивный навык projectile_deceleration_multiplier. Этот навык управляет величиной торможения при уменьшении скорости от исходного значения.

Снаряды-щиты


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


Как вы видите, снаряды вращаются вокруг игрока и заимствуют его направление движения. Мы можем реализовать это с помощью параметрического уравнения окружности. В общем случае, если мы хотим, чтобы A вращалась вокруг B с неким радиусом R, то мы можем сделать что-то подобное:

Ax = Bx + R*math.cos(time)
Ay = By + R*math.sin(time)

Где time — это переменная, значение которой увеличивается со временем. Прежде чем приступать к реализации, давайте подготовим всё остальное. shield_projectile_chance будет переменной не boolean, а переменной типа «вероятность», то есть при каждом создании нового снаряда появится вероятность того, что он начнёт вращаться вокруг игрока.

function Player:new(...)
    ...
  	
    -- Chances
    self.shield_projectile_chance = 0
end

function Player:shoot()
    ...
    local shield = self.chances.shield_projectile_chance:next()
  	
    if self.attack == 'Neutral' then
        self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r), 
        self.y + 1.5*d*math.sin(self.r), {r = self.r, attack = self.attack, shield = shield})
	...
end

Здесь мы определяем переменную shield, для которой «бросается кубик» на то, должен ли этот снаряд вращаться вокруг игрока, после чего мы передаём её в таблицу opts вызова addGameObject. Здесь нам нужно повторить этот шаг для каждой из имеющихся атак. Поскольку в будущем у нас ещё будут подобные изменения, то мы можем сделать вместо этого нечто такое:

function Player:shoot()
    ...
  	
    local mods = {
        shield = self.chances.shield_projectile_chance:next()
    }

    if self.attack == 'Neutral' then
        self.area:addGameObject('Projectile', self.x + 1.5*d*math.cos(self.r), 
        self.y + 1.5*d*math.sin(self.r), table.merge({r = self.r, attack = self.attack}, mods))
        ...
end

Таким образом, в будущем нам достаточно будет добавлять всё в таблицу mods. Функция table.merge пока не определена, но исходя из того, как мы её здесь используем, вы можете догадаться, что она делает.

function table.merge(t1, t2)
    local new_table = {}
    for k, v in pairs(t2) do new_table[k] = v end
    for k, v in pairs(t1) do new_table[k] = v end
    return new_table
end

Она просто объединяет две таблицы с их значениями в новую, а затем возвращает её.

Теперь мы можем приступить к самой реализации функционала shield. Сначала мы хотим определить переменные, такие как радиус, скорость вращения и так далее. Пока я определю их следующим образом:

function Projectile:new(...)
    ...
  	
    if self.shield then
        self.orbit_distance = random(32, 64)
        self.orbit_speed = random(-6, 6)
        self.orbit_offset = random(0, 2*math.pi)
    end
end

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

function Projectile:update(dt)
    ...
  
    -- Shield
    if self.shield then
        local player = current_room.player
        self.collider:setPosition(
      	player.x + self.orbit_distance*math.cos(self.orbit_speed*time + self.orbit_offset),
      	player.y + self.orbit_distance*math.sin(self.orbit_speed*time + self.orbit_offset))
    end
  	
    ...
end

Важно вставить этот код после всех других вызовов, которые могут применяться для setLinearVelocity, в противном случае ничего не будет работать. Также нам нужно не забыть добавить глобальную переменную time и увеличивать её в каждом кадре на dt. Если мы сделаем всё правильно, то это будет выглядеть так:


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

function Projectile:new(...)
    ...
  	
    self.previous_x, self.previous_y = self.collider:getPosition()
end

function Projectile:update(dt)
    ...
  	
    -- Shield
    if self.shield then
        ...
        local x, y = self.collider:getPosition()
        local dx, dy = x - self.previous_x, y - self.previous_y
        self.r = Vector(dx, dy):angle()
    end

    ...
  
    -- At the very end of the update function
    self.previous_x, self.previous_y = self.collider:getPosition()
end

Таким образом мы задаём переменную r, в которой будет храниться угол снаряда, учитываемый при вращении. Поскольку мы используем setLinearVelocity и этот угол, то при отрисовке снаряда в Projectile:draw и использовании Vector(self.collider:getLinearVelocity()):angle()) для получения направления всё будет задаваться в соответствии с тем, как задана переменная r. И всё это будет выглядеть следующим образом:


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

function Projectile:new(...)
    ...
  	
    if self.shield then
        ...
    	self.invisible = true
    	self.timer:after(0.05, function() self.invisible = false end)
    end
end

function Projectile:draw()
    if self.invisible then return end
    ...
end

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

function Projectile:new(...)
    ...
  	
    if self.shield then
    	...
    	self.timer:after(6, function() self:die() end)
    end
end

Таким образом, через 6 секунд существования снаряды-щиты будут уничтожаться.

КОНЕЦ


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



Если вам понравится эта серия туториалов, то вы можете простимулировать меня к написанию чего-то подобного в будущем:


Купив туториал на itch.io, вы получите доступ к полному исходному коду игры, к ответам на упражения из частей 1-9, к коду, разбитому по частям туториала (код будет выглядеть так, как должен выглядеть в конце каждой части) и к ключу игры в Steam.
  • +19
  • 5,4k
  • 1
Поделиться публикацией

Похожие публикации

Комментарии 1
    +1
    Статьи ТАКОГО размера ну очень плохо читаются. Мне кажется, удобнее было бы разбить на части поменьше.

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

    Самое читаемое