Stage
, Console
и SkillTree
.1920x1080
. Для этой игры я выберу 480x270
, потому что это 1920x1080
, делённое на 4. Чтобы изменить размер игры на это значение, нам нужно использовать файл conf.lua
, который, как я объяснял в предыдущей части, является файлом конфигурации, определяющим параметры проекта LOVE по умолчанию, в том числе и разрешение окна, в котором запускается игры.gw
и gh
, соответствующие ширине и высоте базового разрешения, и переменные sx
и sy
, соответствующие масштабу, применённому к этому базовому разрешению. Файл conf.lua
должен находиться в той же папке, что и файл main.lua
, и при этом выглядеть вот так:gw = 480
gh = 270
sx = 1
sy = 1
function love.conf(t)
t.identity = nil -- Имя папки сохранения (строка)
t.version = "0.10.2" -- Версия LOVE, для которой сделана эта игра (строка)
t.console = false -- Подключение консоли (boolean, только в Windows)
t.window.title = "BYTEPATH" -- Заголовок окна (строка)
t.window.icon = nil -- Путь к файлу изображения, используемого как значок окна (строка)
t.window.width = gw -- Ширина окна (число)
t.window.height = gh -- Высота окна (число)
t.window.borderless = false -- Удаление всего визуального оформления границ окна (boolean)
t.window.resizable = true -- Разрешаем пользователю изменять размер окна (boolean)
t.window.minwidth = 1 -- Минимальная ширина окна при возможности его изменения (число)
t.window.minheight = 1 -- Минимальная высота окна при возможности его изменения (число)
t.window.fullscreen = false -- Включение полноэкранного режима (boolean)
t.window.fullscreentype = "exclusive" -- Стандартный полный экран или режим рабочего стола для полного экрана (строка)
t.window.vsync = true -- Включение вертикальной синхронизации (boolean)
t.window.fsaa = 0 -- Число сэмплов при мультисэмпловом антиалиасинге (число)
t.window.display = 1 -- Индекс монитора, в котором должно отображаться окно (число)
t.window.highdpi = false -- Включение режима высокого dpi для окна на дисплее Retina (boolean)
t.window.srgb = false -- Включение гамма-коррекции sRGB при отрисовке на экране (boolean)
t.window.x = nil -- Координата x позиции окна на указанном дисплее (число)
t.window.y = nil -- Координата y позиции окна на указанном дисплее (число)
t.modules.audio = true -- Включение аудиомодуля (boolean)
t.modules.event = true -- Включение модуля событий (boolean)
t.modules.graphics = true -- Включение модуля графики (boolean)
t.modules.image = true -- Включение модуля изображений (boolean)
t.modules.joystick = true -- Включение модуля джойстика (boolean)
t.modules.keyboard = true -- Включение модуля клавиатуры (boolean)
t.modules.math = true -- Включение модуля математики (boolean)
t.modules.mouse = true -- Включение модуля мыши (boolean)
t.modules.physics = true -- Включение модуля физики (boolean)
t.modules.sound = true -- Включение модуля звука (boolean)
t.modules.system = true -- Включение модуля системы (boolean)
t.modules.timer = true -- Включение модуля таймера (boolean), при его отключении 0 delta time в love.update будет иметь значение 0
t.modules.window = true -- Включение модуля окон (boolean)
t.modules.thread = true -- Включение модуля потоков (boolean)
end
gw/2, gh/2
) сейчас, вот так:love.window.setMode
, например, с шириной 3*gw
и высотой 3*gh
, то получите примерно следующее:gw/2
и gh/2
больше не является центром экрана при его увеличении в три раза. Мы хотим иметь возможность отрисовать маленький круг при базовом разрешении 480x270
, чтобы при увеличении экрана до размера обычного монитора круг тоже масштабировался пропорционально (и пикселизированно), а его позиция тоже пропорционально оставалась той же. Простейший способ решения этой задачи — использование Canvas
, который также называется в других движках буфером кадра (framebuffer) или целевым рендером (render target). Сначала мы создадим холст (canvas) с базовым разрешением в конструкторе класса Stage
:function Stage:new()
self.area = Area(self)
self.main_canvas = love.graphics.newCanvas(gw, gh)
end
480x270
, на котором можно выполнять отрисовку:function Stage:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
love.graphics.circle('line', gw/2, gh/2, 50)
self.area:draw()
love.graphics.setCanvas()
end
love.graphics.setCanvas
, который перенаправит все операции отрисовки на текущий заданный холст. Затем мы вызываем love.graphics.clear
, который очищает содержимое этого холста в текущем кадре, потому что оно также было отрисовано в предыдущем кадре, а в каждом кадре мы хотим отрисовывать всё с нуля. Потом, нарисовав всё, что нам нужно, мы повторно используем setCanvas
, но на этот раз ничего не передавая, чтобы наш целевой холст больше не был текущим и перенаправление операций отрисовки больше не выполнялось.function Stage:draw()
love.graphics.setCanvas(self.main_canvas)
love.graphics.clear()
love.graphics.circle('line', gw/2, gh/2, 50)
self.area:draw()
love.graphics.setCanvas()
love.graphics.setColor(255, 255, 255, 255)
love.graphics.setBlendMode('alpha', 'premultiplied')
love.graphics.draw(self.main_canvas, 0, 0, 0, sx, sy)
love.graphics.setBlendMode('alpha')
end
love.graphics.draw
для отрисовки холста на экране, а затем также обёртываем его вызовами love.graphics.setBlendMode
, которые согласно странице Canvas из LOVE wiki используются для предотвращения неправильного смешения (blending). Если запустить программу сейчас, то вы увидите отрисованный круг.sx
и sy
. Пока эти переменные имеют значение 1, но если изменить их значения, например, на 3, то произойдёт следующее:480x270
, теперь находится посередине холста 1440x810
. Так как сам экран имеет размер 480x270
, мы не можем увидеть Canvas целиком, потому что он больше экрана. Чтобы исправить это, мы можем создать в main.lua
функцию resize
, изменяющую при своём вызове sx
, sy
, а также размер самого экрана:function resize(s)
love.window.setMode(s*gw, s*gh)
sx, sy = s, s
end
resize(3)
в love.load
, должно произойти следующее:'linear'
. Так как мы хотим, чтобы игра имела пикселизированный внешний вид, мы должны изменить значение на 'nearest'
. Вызов love.graphics.setDefaultFilter
с аргументом 'nearest'
в начале love.load
должен устранить проблему. Ещё один аспект — нам нужно присвоить LineStyle значение 'rough'
. Поскольку по умолчанию оно имеет значение 'smooth'
, примитивы LOVE будут отрисовываться с алиасингом, а это не подходит для создания пиксельного стиля. Если сделать всё это и запустить код снова, то экран должен выглядеть вот так:x, y
должна быть равна gw/2, gh/2
, и вне зависимости от конечного разрешения объект всегда будет находиться в центре экрана. Это значительно упрощает процесс: значит, нам нужно только один раз беспокоиться о том, как выглядит игра и как распределены объекты на экране.1920x1080
. Базовое разрешение нашей игры отлично масштабируется до него. Но вторым по популярности разрешением является 1366x768
. 480x270
не масштабируется до него. Какие вы можете предложить варианты для работы с нестандартными разрешениями при переключении игры в полноэкранный режим?camera.lua
в папку библиотеки hump (и перезапишите имеющуюся версию camera.lua
), а затем добавьте require модуля камеры в main.lua
. Поместите файл Shake.lua
в папку objects
.function random(min, max)
local min, max = min or 0, max or 1
return (min > max and (love.math.random()*(min - max) + max)) or (love.math.random()*(max - min) + min)
end
Shake.lua
. После определения этой функции в utils.lua
попробуйте сделать нечто подобное:function love.load()
...
camera = Camera()
input:bind('f3', function() camera:shake(4, 60, 1) end)
...
end
function love.update(dt)
...
camera:update(dt)
...
end
Stage
:function love.load()
...
camera = Camera()
input:bind('f3', function() camera:shake(4, 60, 1) end)
...
end
function love.update(dt)
...
camera:update(dt)
...
end
f3
экран начнёт трястись:dt
. И это будет выглядеть вот так:function Stage:update(dt)
camera.smoother = Camera.smooth.damped(5)
camera:lockPosition(dt, gw/2, gh/2)
self.area:update(dt)
end
damped
со значением 5
. Эти параметры я вывел путём проб и ошибок, но в целом это позволяет камере фокусироваться на целевой точке плавным и приятным способом. Я поместил этот код внутрь комнаты Stage потому, что мы сейчас работаем с комнатой Stage, а в этой комнате камера всегда должна будет центрироваться на середине экрана и никогда не двигаться (кроме моментов тряски экрана). В результате мы получаем следующее:objects
новый файл с названием Player.lua
, который будет выглядеть следующим образом:Player = GameObject:extend()
function Player:new(area, x, y, opts)
Player.super.new(self, area, x, y, opts)
end
function Player:update(dt)
Player.super.update(self, dt)
end
function Player:draw()
end
GameObject
и иметь одинаковую структуру конструктора, функций update и draw. Теперь мы можем создать экземпляр этого объекта Player в комнате Stage следующим образом:function Stage:new()
...
self.area:addGameObject('Player', gw/2, gh/2)
end
Area
, мы можем просто отрисовать в его позиции круг:function Player:draw()
love.graphics.circle('line', self.x, self.y, 25)
end
addGameObject
возвращает созданный объект, поэтому мы можем хранить ссылку на игрока внутри self.player
Stage, и при необходимости включать событие смерти объекта Player привязанной клавишей:function Stage:new()
...
self.player = self.area:addGameObject('Player', gw/2, gh/2)
input:bind('f3', function() self.player.dead = true end)
end
f3
объект Player должен умирать, то есть круг должен переставать отрисовываться. Это происходит в результате того, как мы настроили код объекта Area
в предыдущей части. Также важно заметить, что если мы решим хранить ссылки, возвращаемые addGameObject
таким образом, то если мы не зададим переменную, в которой хранится ссылка на nil
, то этот объект никогда не будет удаляться. Кроме того, важно не забывать присваивать ссылкам значения nil
(в нашем случае строкой self.player = nil
), если нужно, чтобы объект на самом деле удалялся из памяти (помимо того, что его атрибуту присваивается dead
значение true).windfield
и добавить её require в файл main.lua
. Согласно её документации, в ней есть две основных концепции — World
и Collider
. World — это физический мир, в котором происходит симуляция, а Collider — это физический объект, симулируемый внутри этого мира. То есть нашей игре будет нужно подобие физического мира, а игрок будет коллайдером внутри этого мира.Area
, добавив вызов addPhysicsWorld
:function Area:addPhysicsWorld()
self.world = Physics.newWorld(0, 0, true)
end
.world
области, содержащий физический мир. Также нам нужно обновлять этот мир (и при необходимости отрисовывать его в целях отладки), если он существует:function Area:update(dt)
if self.world then self.world:update(dt) end
for i = #self.game_objects, 1, -1 do
...
end
end
function Area:draw()
if self.world then self.world:draw() end
for _, game_object in ipairs(self.game_objects) do game_object:draw() end
end
addPhysicsWorld
, а не просто добавили его в конструктор Area потому, что мы не хотим, чтобы у всех областей были физические миры. Например, комната Console тоже будет использовать объект для управления своими сущностями, но к этой Area прикреплять физический мир не нужно. Поэтому благодаря вызову одной функции мы делаем его необязательным. Мы можем создать экземпляр физического мира в Area комнаты Stage следующим образом:function Stage:new()
self.area = Area(self)
self.area:addPhysicsWorld()
...
end
function Player:new(area, x, y, opts)
Player.super.new(self, area, x, y, opts)
self.x, self.y = x, y
self.w, self.h = 12, 12
self.collider = self.area.world:newCircleCollider(self.x, self.y, self.w)
self.collider:setObject(self)
end
GameObject
имеют одинаковый конструктор, в котором они получают ссылку на объект Area
, которому принадлежат.w
и h
определили его ширину и высоту равными 12. Далее мы добавляем новый CircleCollider
с радиусом, равным ширине. Пока не очень логично создавать коллайдер в виде круга, если мы определили ширину и высоту, но это пригодится в будущем, потому что когда мы добавим разные типы кораблей, то визуально все корабли будут иметь разную ширину и высоту, но физически коллайдер всегда будет кругом, чтобы все корабли имели одинаковые шансы и обладали предсказуемым для игрока поведением.setObject
, которая привязывает объект Player к только что созданному Collider. Это полезно потому, что при столкновении двух коллайдеров мы можем получать информацию с точки зрения коллайдеров, а не объектов. Например, если Player сталкивается с Projectile, у нас будет два коллайдера, представляющих Player и Projectile, но у нас может и не быть самих объектов. setObject
(и getObject
) позволяет нам задавать и извлекать объект, к которому принадлежит Collider.function Player:draw()
love.graphics.circle('line', self.x, self.y, self.w)
end
.newWorld
и что происходит, если задать ему значение false? Есть ли преимущества задания значения true/false? Какие?function Player:new(area, x, y, opts)
Player.super.new(self, area, x, y, opts)
...
self.r = -math.pi/2
self.rv = 1.66*math.pi
self.v = 0
self.max_v = 100
self.a = 100
end
r
как угол, под которым движется игрок. Сначала он имеет значение -math.pi/2
, то есть указывает вверх. Углы в LOVE указываются по часовой стрелке, то есть math.pi/2
— это вниз, а -math.pi/2
— вверх (а 0 — это вправо). Переменная rv
представляет собой скорость изменения угла при нажатии игроком «влево» или «вправо». Затем у нас есть v
, обозначающая скорость игрока, и max_v
, обозначающая максимальную скорость игрока. Последний атрибут — это a
, представляющий собой ускорение игрока. Все значения получены методом проб и ошибок.function Player:update(dt)
Player.super.update(self, dt)
if input:down('left') then self.r = self.r - self.rv*dt end
if input:down('right') then self.r = self.r + self.rv*dt end
self.v = math.min(self.v + self.a*dt, self.max_v)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end
main.lua
(так как мы используем для всего глобальный объект Input):function love.load()
...
input:bind('left', 'left')
input:bind('right', 'right')
...
end
r
, соответствующий углу игрока, изменяется на 1.66*math.pi
радиан в соответствующем направлении. Ещё важно здесь заметить, что это значение умножается на dt
, то есть это значение управляется на посекундной основе. То есть скорость изменения угла измеряется в 1.66*math.pi
радиан в секунду. Это результат того, как работает игровой цикл, разобранный нами в первой части туториала.v
. Он немного более сложный, но если вы делали это на других языках, то он должен быть вам знаком. Исходное вычисление имеет вид self.v = self.v + self.a*dt
, то есть мы просто увеличиваем скорость на величину ускорения. В этом случае мы увеличиваем её на 100 в секунду. Но мы также определили атрибут max_v
, который должен ограничивать максимально допустимую скорость. Если мы не ограничим её, то self.v = self.v + self.a*dt
будет увеличивать v
бесконечно, и наш игрок превратится в Соника. А нам этого не нужно! Один из способов предотвратить это заключается в следующем:function Player:update(dt)
...
self.v = self.v + self.a*dt
if self.v >= self.max_v then
self.v = self.max_v
end
...
end
v
становится больше max_v
, то мы ограничиваем его этим значением, а не превышаем его. Ещё один краткий способ записи этого заключается в использовании функции math.min
, которая возвращает минимальное значение среди всех переданных ей аргументов. В нашем случае мы передаём результат self.v + self.a*dt
и self.max_v
, то есть если результат сложения будет больше max_v
, то math.min
вернёт max_v
, так как оно меньше суммы. Это очень распространённый и полезный паттерн в Lua (да и в других языках программирования тоже).setLinearVelocity
задаём скорость Collider по x и y равной атрибуту v
, умноженному на соответствующую величину в зависимости от угла объекта. В общем случае, когда мы хотим переместить что-то в каком-то направлении, и у нас есть для этого угол, то стоит использовать cos
для перемещения по оси x и sin
для движения по оси y. Это тоже очень распространённый паттерн в разработке 2D-игр. Я не буду объяснять этого, предположив, что вы разобрались с этим в школе (если это не так, то поищите в Google основы тригонометрии).GameObject
, и оно довольно простое. Так как мы используем физический движок, то у нас в каких-то переменных хранятся два представления, например, скорость и позиция. Мы получаем позицию и скорость игрока с помощью атрибутов x, y
и v
, а позицию и скорость Collider — с помощью getPosition
и getLinearVelocity
. Логично будет синхронизировать эти два представления, и один из способов добиться этого автоматически — изменив родительский класс всех игровых объектов:function GameObject:update(dt)
if self.timer then self.timer:update(dt) end
if self.collider then self.x, self.y = self.collider:getPosition() end
end
collider
,x
и y
будут установлены в позицию этого коллайдера. И когда позиция коллайдера изменяется, представление этой позиции в самом объекте тоже будет меняться соответствующим образом.world:draw()
отрисовывается Collider. На самом деле мы хотим отрисовывать не только коллайдеры, поэтому логично будет закомментировать эту строку и отрисовывать непосредственно объект Player:function Player:draw()
love.graphics.circle('line', self.x, self.y, self.w)
end
function Player:draw()
love.graphics.circle('line', self.x, self.y, self.w)
love.graphics.line(self.x, self.y, self.x + 2*self.w*math.cos(self.r), self.y + 2*self.w*math.sin(self.r))
end
B
, находящуюся в distance
единицах от позиции A
, такую, что позиция B
находится под определённым углом angle
относительно позиции A
, то паттерн будет примерно таким: bx = ax + distance*math.cos(angle)
и by = ay + distance*math.sin(angle)
. Такое очень часто применяется при разработке 2D-игр (по крайней мере, мне так кажется) и интуитивное понимание этого паттерна будет вам полезно.math.pi/2
math.pi/4
3*math.pi/4
-5*math.pi/6
0
11*math.pi/12
-math.pi/6
-math.pi/2 + math.pi/4
3*math.pi/4 + math.pi/3
math.pi
a
? Как будет выглядеть функция update игрока, если бы его не существовало? Есть ли вообще преимущества у его существования?(x, y)
точки B
из позиции A
, если используемый угол будет равен -math.pi/4
, а расстояние — 100
.(x, y)
точки C
из позиции B
, если используемый угол равен math.pi/4
, а расстояние равно 50
. Позиции A
и B
, а также расстояние и угол между ними остаются теми же, что и в предыдущем упражнении.A
до некоторой точки C
и допустимо использовать только множество промежуточных точек, которых можно достичь через углы и расстояния?getAngle
. Почему бы не синхронизировать и его через атрибут r
?a
, то при выполнении присвоения a = nil
сборщик мусора поймёт, что на таблицу, на которую ссылались, больше никто не ссылается, поэтому её можно удалить из памяти в будущем цикле сборки мусора. Проблема возникает, когда на один объект ссылаются несколько раз и вы забываете убрать ссылки из всех этих точек.addGameObject
, то объект добавляется в список .game_objects
. Это считается одной ссылкой, указывающей на этот объект. Однако также в этой функции возвращается сам объект. Поэтому ранее мы делали что-то наподобие self.player = self.area:addGameObject('Player', ...)
, то есть кроме хранения ссылки на объект в списке внутри объекта Area мы также храним ссылку на него в переменной self.player
. То есть когда мы говорим self.player.dead
и объект Player удаляется из списка игровых объектов в объекте Area, он всё равно не может быть удалён из памяти, потому что на него по-прежнему указывает self.player
. То есть в этом случае чтобы действительно удалить объект Player из памяти, нам нужно и присвоить dead
значение true, и затем выполнить self.player = nil
.setObject
, в которую мы передаём объект, чтобы Collider хранил ссылку на него. Если объект умирает, будет ли он удалён из памяти? Нет, поскольку Collider всё ещё хранит на него ссылку. Та же проблема, только в других условиях. Один из способов решения проблемы заключается в явном удалении объектов с помощью создаваемой для них функции destroy
, которая будет заниматься устранением ссылок.function GameObject:destroy()
self.timer:destroy()
if self.collider then self.collider:destroy() end
self.collider = nil
end
destroy
. Эта функция вызывает функции destroy
объекта EnhancedTimer, а также функцию коллайдера (Collider). Эти функции выполняют разыменование элементов, которые пользователь вероятно захочет удалить из памяти. Например, в Collider:destroy
одним из действий является вызов self:setObject(nil)
: так как мы хотим уничтожить этот объект, то нам не нужно, чтобы Collider больше хранил на него ссылку.function Area:update(dt)
if self.world then self.world:update(dt) end
for i = #self.game_objects, 1, -1 do
local game_object = self.game_objects[i]
game_object:update(dt)
if game_object.dead then
game_object:destroy()
table.remove(self.game_objects, i)
end
end
end
dead
объекта имеет значение true, то кроме удаления из списка игровых объектов мы также вызываем его функцию destroy, которая избавляется от ссылок на него. Мы можем расширить эту концепцию и осознать, что физический мир сам по себе имеет World:destroy, и мы можем использовать его при уничтожении объекта Area:function Area:destroy()
for i = #self.game_objects, 1, -1 do
local game_object = self.game_objects[i]
game_object:destroy()
table.remove(self.game_objects, i)
end
self.game_objects = {}
if self.world then
self.world:destroy()
self.world = nil
end
end
function Stage:destroy()
self.area:destroy()
self.area = nil
end
gotoRoom
:function gotoRoom(room_type, ...)
if current_room and current_room.destroy then current_room:destroy() end
current_room = _G[room_type](...)
end
current_room
существующей переменной, и содержит ли она атрибут destroy
(по сути, мы запрашиваем, содержит ли она настоящую комнату); если это так, то мы вызываем функцию destroy. А затем мы выполняем переход к целевой комнате.NewGameObject = GameObject:extend()
function NewGameObject:new(area, x, y, opts)
NewGameObject.super.new(self, area, x, y, opts)
end
function NewGameObject:update(dt)
NewGameObject.super.update(self, dt)
end
function NewGameObject:draw()
end
function NewGameObject:destroy()
NewGameObject.super.destroy(self)
end
function count_all(f)
local seen = {}
local count_table
count_table = function(t)
if seen[t] then return end
f(t)
seen[t] = true
for k,v in pairs(t) do
if type(v) == "table" then
count_table(v)
elseif type(v) == "userdata" then
f(v)
end
end
end
count_table(_G)
end
function type_count()
local counts = {}
local enumerate = function (o)
local t = type_name(o)
counts[t] = (counts[t] or 0) + 1
end
count_all(enumerate)
return counts
end
global_type_table = nil
function type_name(o)
if global_type_table == nil then
global_type_table = {}
for k,v in pairs(_G) do
global_type_table[v] = k
end
global_type_table[0] = "table"
end
return global_type_table[getmetatable(o) or 0] or "Unknown"
end
main.lua
, а затем добавим внутрь love.load
следующее:function love.load()
...
input:bind('f1', function()
print("Before collection: " .. collectgarbage("count")/1024)
collectgarbage()
print("After collection: " .. collectgarbage("count")/1024)
print("Object count: ")
local counts = type_count()
for k, v in pairs(counts) do print(k, v) end
print("-------------------------------------")
end)
...
end
f1
, он показывает количество памяти до и после цикла сборки мусора, а также выводит типы объектов, находящихся в памяти. Это полезно, потому что сейчас мы можем, например, создать новую комнату Stage с объектами внутри, удалить её, а затем убедиться, что память осталась такой же (или почти такой же, хе-хе), как и до создания Stage. Если она осталась такой же, то утечки памяти отсутствуют, а если нет, то значит, у нас есть проблемы и необходимо искать их источники.f2
к созданию и активации новой комнаты Stage с помощью вызова gotoRoom
.f3
к уничтожению текущей комнаты.f1
. После этого понажимайте несколько раз клавиши f2
и f3
, чтобы создать и уничтожить новые комнаты. Теперь снова проверьте количество занятой памяти, несколько раз нажав f1
. Осталось ли количество памяти таким, как было раньше, или его стало больше?function Stage:new()
...
for i = 1, 100 do
self.area:addGameObject('Player', gw/2 + random(-4, 4), gh/2 + random(-4, 4))
end
end
n
секунд атака срабатывает и запускается автоматически. В конце концов у нас получится 16 типов атак, но большинство из них будет связано с выстреливанием снарядов в направлении взгляда корабля игрока. Например, это атака наводящимися ракетами:n
секунд. n
— это число, изменяющееся в зависимости от атаки, но по умолчанию оно имеет значение 0.24
. Это можно легко реализовать с помощью библиотеки таймера, о которой мы рассказывали в предыдущих частях:function Player:new()
...
self.timer:every(0.24, function()
self:shoot()
end)
end
shoot
каждые 0,24 секунды, а внутри этой функции мы расположим код, который будет заниматься созданием объекта снаряда.ShootEffect
(теперь вы уже должны знать, как это делается). Этот эффект должен быть простым квадратом, остающимся на экране краткий промежуток времени рядом с позицией, в которой был создан снаряд. Простейший способ достичь этого — использовать нечто подобное:function Player:shoot()
self.area:addGameObject('ShootEffect', self.x + 1.2*self.w*math.cos(self.r),
self.y + 1.2*self.w*math.sin(self.r))
end
function ShootEffect:new(...)
...
self.w = 8
self.timer:tween(0.1, self, {w = 0}, 'in-out-cubic', function() self.dead = true end)
end
function ShootEffect:draw()
love.graphics.setColor(default_color)
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
end
tween
. Пока у нас есть одна проблема: позиция эффекта статична и не следует за игроком. Это кажется незначительной деталью, потому что длительность эффекта мала, но попробуйте изменить её до 0,5 секунд или больше, и вы увидите, что я имею в виду.function Player:shoot()
local d = 1.2*self.w
self.area:addGameObject('ShootEffect', self.x + d*math.cos(self.r),
self.y + d*math.sin(self.r), {player = self, d = d})
end
function ShootEffect:update(dt)
ShootEffect.super.update(self, dt)
if self.player then
self.x = self.player.x + self.d*math.cos(self.player.r)
self.y = self.player.y + self.d*math.sin(self.player.r)
end
end
function ShootEffect:draw()
pushRotate(self.x, self.y, self.player.r + math.pi/4)
love.graphics.setColor(default_color)
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
love.graphics.pop()
end
player
объекта ShootEffect через таблицу opts
в функции игрока shoot присваивается значение self
. Это значит, что доступ к ссылке на объект Player можно получить через self.player
в объекте ShootEffect. В общем случае мы передаём ссылки на объекты друг другу таким образом, потому что обычно объекты создаются из функции других объектов, то есть передавая self
, мы получаем то, что нам нужно. Кроме того, мы назначаем атрибуту d
значение расстояния, на котором эффект должен появиться от центра объекта Player. Это тоже реализуется с помощью таблицы opts
.if self.player then
), потому что если это не так, то произойдёт ошибка. Также очень часто в процессе дальнейшего создания игры будут возникать случаи, когда на сущности будут ссылаться откуда-то ещё, и мы будем пытаться получить доступ к их значениям, но поскольку они уже умерли, эти значения уже не будут заданы, и мы получим ошибку. Важно не забывать об этом, ссылаясь на сущности внутри друг друга таким образом.pushRotate
, которая выглядит так:function pushRotate(x, y, r)
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(r or 0)
love.graphics.translate(-x, -y)
end
r
вокруг точки x, y
, пока мы не вызовем love.graphics.pop
. То есть в этом примере у нас есть квадрат и мы поворачиваем его вокруг его центра на угол игрока плюс на 45 градусов (pi/4 радиан). Ради полноты можно показать другую версию этой функции, также содержащую и масштабирование:function pushRotateScale(x, y, r, sx, sy)
love.graphics.push()
love.graphics.translate(x, y)
love.graphics.rotate(r or 0)
love.graphics.scale(sx or 1, sy or sx or 1)
love.graphics.translate(-x, -y)
end
self.attack_speed
, который каждые 5 секунд изменяется на случайное значение в интервале от 1 до 2:function Player:new(...)
...
self.attack_speed = 1
self.timer:every(5, function() self.attack_speed = random(1, 2) end)
self.timer:every(0.24, function() self:shoot() end)
0.24/self.attack_speed
секунд? Учтите, что простое изменение в вызове every
, вызывающем функцию shoot, не сработает.pushRotate
поверните игрока вокруг его центра на 180 градусов. Это должно выглядеть вот так:pushRotate
поверните на 90 градусов линию, указывающую в направлении движения игрока, вокруг её центра. Это должно выглядеть следующим образом:pushRotate
поверните на 90 градусов линию, указывающую в направлении движения игрока, вокруг центра игрока. Это должно выглядеть так:pushRotate
поверните объект ShootEffect вокруг центра игрока на 90 градусов (сверх того, что он уже поворачивается относительно направления игрока). Это должно выглядеть следующим образом:function Player:shoot()
...
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})
end
d
, которая была определена ранее, а затем передаём угол игрока как атрибут r
. Заметьте, что в отличие от объекта ShootEffect, объект Projectile при создании не требует ничего, кроме угла игрока, поэтому нам не нужно передавать игрока как ссылку.function Projectile:new(area, x, y, opts)
Projectile.super.new(self, area, x, y, opts)
self.s = opts.s or 2.5
self.v = opts.v or 200
self.collider = self.area.world:newCircleCollider(self.x, self.y, self.s)
self.collider:setObject(self)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end
s
представляет собой радиус коллайдера, он не обозначен как r
, потому что эту переменную мы уже используем для угла движения. В общем случае для задания размеров объектов я буду использовать переменные w
, h
, r
или s
. Первые две — когда объект является прямоугольником, а последние две — когда он круг. В случаях, когда переменная r
уже использована для направления (как в этом случае) в качестве радиуса будет использоваться s
. Эти атрибуты в основном используются для визуализации, потому что чаще всего эти объекты уже имеют коллайдер, выполняющий всю работу, связанную с коллизиями.opts.attribute or default_value
. Благодаря тому, как or
работает в Lua, мы можем использовать эту конструкцию в качестве быстрого способа передачи следующего:if opts.attribute then
self.attribute = opts.attribute
else
self.attribute = default_value
end
self.s
ему будет присвоено значение opts.s
, если оно определено, в противном случае ему присваивается значение 2.5
. То же самое относится к self.v
. Наконец, мы задаём скорость снаряда с помощью setLinearVelocity
, указывая начальную скорость снаряда и угол, передаваемый из Player. Здесь используется тот же подход, что и при движении Player, поэтому вы должны уже в этом разбираться.function Projectile:update(dt)
Projectile.super.update(self, dt)
self.collider:setLinearVelocity(self.v*math.cos(self.r), self.v*math.sin(self.r))
end
function Projectile:draw()
love.graphics.setColor(default_color)
love.graphics.circle('line', self.x, self.y, self.s)
end
function Projectile:update(dt)
...
if self.x < 0 then self:die() end
if self.y < 0 then self:die() end
if self.x > gw then self:die() end
if self.y > gh then self:die() end
end
gw/2, gh/2
, то есть верхний левый угол находится в 0, 0
, а нижний правый — в gw, gh
. И нам достаточно лишь добавить несколько условных конструкций в функцию update снаряда, проверяющих его позицию, и если он находится за границами, то мы должны вызвать функцию die
.function Player:update(dt)
...
if self.x < 0 then self:die() end
if self.y < 0 then self:die() end
if self.x > gw then self:die() end
if self.y > gh then self:die() end
end
die
. Она очень проста и в сущности единственное, что она делает — присваивает атрибуту dead
сущности значение true, а затем создаёт визуальные эффекты. Для снаряда создаваемый эффект будет называться ProjectileDeathEffect
; как и в случае с ShootEffect, это будет квадрат, остающийся на экране небольшой промежуток времени, а затем исчезающий, но с некоторыми отличиями. Основное отличие в том, что ProjectileDeathEffect будет какое-то время мерцать, затем переходить к своему обычному цвету и исчезать. Это создаёт лёгкий, но интересный эффект хлопка. Так что конструктор будет выглядеть следующим образом:function ProjectileDeathEffect:new(area, x, y, opts)
ProjectileDeathEffect.super.new(self, area, x, y, opts)
self.first = true
self.timer:after(0.1, function()
self.first = false
self.second = true
self.timer:after(0.15, function()
self.second = false
self.dead = true
end)
end)
end
first
и second
, которые будут обозначать то, на каком этапе находится эффект. Если он на первом этапе, то у него будет белый цвет, а на втором он примет свой настоящий цвет. После завершения второго этапа эффект «умирает», что выполняется присвоением dead
значения true. Всё это происходит в течение 0,25 секунды (0,1 + 0,15) то есть это очень недолговечный и быстрый эффект. Эффект будет отрисовываться способом, очень похожим на способ отрисовки ShootEffect:function ProjectileDeathEffect:draw()
if self.first then love.graphics.setColor(default_color)
elseif self.second then love.graphics.setColor(self.color) end
love.graphics.rectangle('fill', self.x - self.w/2, self.y - self.w/2, self.w, self.w)
end
die
в объекте Projectile:function Projectile:die()
self.dead = true
self.area:addGameObject('ProjectileDeathEffect', self.x, self.y,
{color = hp_color, w = 3*self.s})
end
globals.lua
и выглядят так:default_color = {222, 222, 222}
background_color = {16, 16, 16}
ammo_color = {123, 200, 164}
boost_color = {76, 195, 217}
hp_color = {241, 103, 69}
skill_point_color = {255, 198, 93}
hp_color
(красный), чтобы показать, как выглядит эффект, но в будущем правильно будет использовать цвет объекта снаряда. Разные типы атак будут иметь разные цвета, поэтому эффект смерти тоже будет иметь разные цвета, зависящие от атаки. Сейчас эффект выглядит следующим образом:die
объекта Projectile и присваивать атрибуту dead
значение true, когда игрок достигает границ экрана. Сделав это, можно добавить к смерти визуальные эффекты. Основным спецэффектом при смерти игрока будет пучок частиц, называемый ExplodeParticle
, немного похожий на взрыв. В общем случае частицы будут линиями, двигающимися под случайными углами из начальной позиции и медленно уменьшающимися в длине. Реализовать это можно примерно таким образом:function ExplodeParticle:new(area, x, y, opts)
ExplodeParticle.super.new(self, area, x, y, opts)
self.color = opts.color or default_color
self.r = random(0, 2*math.pi)
self.s = opts.s or random(2, 3)
self.v = opts.v or random(75, 150)
self.line_width = 2
self.timer:tween(opts.d or random(0.3, 0.5), self, {s = 0, v = 0, line_width = 0},
'linear', function() self.dead = true end)
end
function ExplodeParticle:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setLineWidth(self.line_width)
love.graphics.setColor(self.color)
love.graphics.line(self.x - self.s, self.y, self.x + self.s, self.y)
love.graphics.setColor(255, 255, 255)
love.graphics.setLineWidth(1)
love.graphics.pop()
end
s
— это на самом деле половина размера линии, а не полный размер. Мы также используем love.graphics.setLineWidth
, чтобы линия сначала была жирной и становилась тоньше с течением времени.die
:function Player:die()
self.dead = true
for i = 1, love.math.random(8, 12) do
self.area:addGameObject('ExplodeParticle', self.x, self.y)
end
end
die
Player, потому что на границе экрана мы не сможем увидеть эффект полностью:function Player:new(...)
...
input:bind('f4', function() self:die() end)
end
slow_amount
в love.load
и присвоить ей начальное значение 1. Эту переменную мы будем использовать для умножения дельты, передаваемой во всех функции update. Поэтому когда нам нужно будет замедлить время на 50%, мы присвоим slow_amount
значение 0,5. Выполнение этого умножения выглядит так:function love.update(dt)
timer:update(dt*slow_amount)
camera:update(dt*slow_amount)
if current_room then current_room:update(dt*slow_amount) end
end
function slow(amount, duration)
slow_amount = amount
timer:tween('slow', duration, _G, {slow_amount = 1}, 'in-out-cubic')
end
slow(0.5, 1)
будет означать, что игра сначала замедлится до 50% скорости, а затем через 1 секунду вернётся к полной скорости. Здесь важно заметить, что в функции tween используется строка 'slow'
. Как рассказано в предыдущих частях, это значит, что при вызове функции slow, когда всё ещё действует tween другой функции slow, то эта предыдущая tween будет отменена, а новая tween продолжится, что предотвратит выполнение двух функций tween с одной переменной одновременно.slow(0.15, 1)
во время смерти игрока, то получим следующее::shake
, поэтому мы можем добавить следующее:function Player:die()
...
camera:shake(6, 60, 0.4)
...
end
flash(n)
, экран будет мерцать фоновым цветом в течение n кадров. Один из способов реализации такой возможности заключается в определении глобальной переменной flash_frames
в love.load
, которая изначально равна nil. Когда flash_frames
равна nil, это значит, что эффект неактивен, а когда не равна nil, то он активен. Функция мерцания выглядит так:function flash(frames)
flash_frames = frames
end
love.draw
:function love.draw()
if current_room then current_room:draw() end
if flash_frames then
flash_frames = flash_frames - 1
if flash_frames == -1 then flash_frames = nil end
end
if flash_frames then
love.graphics.setColor(background_color)
love.graphics.rectangle('fill', 0, 0, sx*gw, sy*gh)
love.graphics.setColor(255, 255, 255)
end
end
flash_frames
на 1, и затем, когда она достигает -1
мы присваиваем ей nil, потому что эффект завершён. А когда эффект не завершён, мы просто отрисовываем большой прямоугольник с цветом background_color
, закрывающий целый экран. Добавление этого в функцию die
выглядит так:function Player:die()
self.dead = true
flash(4)
camera:shake(6, 60, 0.4)
slow(0.15, 1)
for i = 1, love.math.random(8, 12) do
self.area:addGameObject('ExplodeParticle', self.x, self.y)
end
end
first
и second
, и с использованием только нового атрибута current_color
?flash
так, чтобы она получала длительность в секундах, а не кадрах. Какая из них лучше, или это просто дело вкуса? Может ли таймер использовать для измерения длительности кадры вместо секунд?tick
вызывалась каждые 5 секунд:function Player:new(...)
...
self.timer:every(5, function() self:tick() end)
end
TickEffect
, срабатывающий при каждом такте. Этот эффект похож на эффект refresh в Downwell (см. видео про Downwell выше), это большой прямоугольник, ненадолго накладывающийся на игрока. Он выглядит следующим образом:function Player:tick()
self.area:addGameObject('TickEffect', self.x, self.y, {parent = self})
end
function TickEffect:update(dt)
...
if self.parent then self.x, self.y = self.parent.x, self.parent.y end
end
function TickEffect:new(area, x, y, opts)
TickEffect.super.new(self, area, x, y, opts)
self.w, self.h = 48, 32
self.timer:tween(0.13, self, {h = 0}, 'in-out-cubic', function() self.dead = true end)
end
y_offset
, который вычитается из позиции y
объекта TickEffect:function TickEffect:new(...)
...
self.y_offset = 0
self.timer:tween(0.13, self, {h = 0, y_offset = 32}, 'in-out-cubic',
function() self.dead = true end)
end
function TickEffect:update(dt)
...
if self.parent then self.x, self.y = self.parent.x, self.parent.y - self.y_offset end
end
max_v
, задающий максимальную скорость, с которой может двигаться игрок. Мы хотим сделать так, чтобы при нажатии «вверх»/«вниз» это значение менялось и становилось больше/меньше. Проблема здесь в том, что после отпускания клавиши нам нужно вернуться к обычному значению. Поэтому нам нужна ещё одна переменная, хранящая базовое значение и другая, содержащая текущее значение.base_max_v
, содержащий начальное/базовое значение максимальной скорости, а обычный атрибут max_v
будет содержать текущую максимальную скорость, к которой применены все возможные модификаторы (например, ускорение).function Player:new(...)
...
self.base_max_v = 100
self.max_v = self.base_max_v
end
function Player:update(dt)
...
self.max_v = self.base_max_v
if input:down('up') then self.max_v = 1.5*self.base_max_v end
if input:down('down') then self.max_v = 0.5*self.base_max_v end
end
max_v
значение base_max_v
, а затем проверяем, нажаты ли клавиши «вверх»/«вниз», и соответствующим образом меняем max_v
. Важно заметить — это значит, что вызов setLinearVelocity
, использующий max_v
, должен происходить после этого, в противном случае всё развалится на части!TrailParticle
, который будет просто кругом определённого радиуса, в течение определённого времени уменьшаемый функцией tween:function TrailParticle:new(area, x, y, opts)
TrailParticle.super.new(self, area, x, y, opts)
self.r = opts.r or random(4, 6)
self.timer:tween(opts.d or random(0.3, 0.5), self, {r = 0}, 'linear',
function() self.dead = true end)
end
'in-out-cubic'
вместо 'linear'
, будут придавать следу разную форму. Я использовал linear, потому что он мне кажется наиболее красивым, но вы можете выбрать другой. Функция draw просто отрисовывает круг соответствующего цвета и радиуса, используя атрибут r
.function Player:new(...)
...
self.trail_color = skill_point_color
self.timer:every(0.01, function()
self.area:addGameObject('TrailParticle',
self.x - self.w*math.cos(self.r), self.y - self.h*math.sin(self.r),
{parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color})
end)
skill_point_color
(жёлтый).boosting
. С помощью этого атрибута мы сможем узнать, когда происходит ускорение и соответствующим образом изменять цвет, на который ссылается trail_color
:function Player:update(dt)
...
self.max_v = self.base_max_v
self.boosting = false
if input:down('up') then
self.boosting = true
self.max_v = 1.5*self.base_max_v
end
if input:down('down') then
self.boosting = true
self.max_v = 0.5*self.base_max_v
end
self.trail_color = skill_point_color
if self.boosting then self.trail_color = boost_color end
end
trail_color
на boost_color
(синий).function Player:new(...)
...
self.ship = 'Fighter'
self.polygons = {}
if self.ship == 'Fighter' then
self.polygons[1] = {
...
}
self.polygons[2] = {
...
}
self.polygons[3] = {
...
}
end
end
function Player:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setColor(default_color)
-- здесь отрисовываем многоугольники
love.graphics.pop()
end
function Player:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setColor(default_color)
for _, polygon in ipairs(self.polygons) do
-- здесь отрисовываем каждый многоугольник
end
love.graphics.pop()
end
function Player:draw()
pushRotate(self.x, self.y, self.r)
love.graphics.setColor(default_color)
for _, polygon in ipairs(self.polygons) do
local points = fn.map(polygon, function(k, v)
if k % 2 == 1 then
return self.x + v + random(-1, 1)
else
return self.y + v + random(-1, 1)
end
end)
love.graphics.polygon('line', points)
end
love.graphics.pop()
end
0, 0
. Это значит, что каждый многоугольник пока не знает, в какой позиции мира он находится.fn.map
обходит каждый элемент в таблице и применяет к нему функцию. В этом случае функция проверят индекс на чётность. Если он нечётный, то обозначает компонент x, а если чётный — то компонент y. То есть в каждом из этих случаев мы просто прибавляем к вершине позицию x или y игрока, а также случайное число в интервале от -1 до 1, чтобы корабль выглядел немного более нечётким и интересным. Затем, наконец, вызывается love.graphics.polygon
для отрисовки всех этих точек.self.polygons[1] = {
self.w, 0, -- 1
self.w/2, -self.w/2, -- 2
-self.w/2, -self.w/2, -- 3
-self.w, 0, -- 4
-self.w/2, self.w/2, -- 5
self.w/2, self.w/2, -- 6
}
self.polygons[2] = {
self.w/2, -self.w/2, -- 7
0, -self.w, -- 8
-self.w - self.w/2, -self.w, -- 9
-3*self.w/4, -self.w/4, -- 10
-self.w/2, -self.w/2, -- 11
}
self.polygons[3] = {
self.w/2, self.w/2, -- 12
-self.w/2, self.w/2, -- 13
-3*self.w/4, self.w/4, -- 14
-self.w - self.w/2, self.w, -- 15
0, self.w, -- 16
}
self.w, 0
. Следующая немного левее и выше первой, то есть её координаты self.w/2, -self.w/2
, и так далее.function Player:new(...)
...
self.timer:every(0.01, function()
if self.ship == 'Fighter' then
self.area:addGameObject('TrailParticle',
self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r - math.pi/2),
self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r - math.pi/2),
{parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color})
self.area:addGameObject('TrailParticle',
self.x - 0.9*self.w*math.cos(self.r) + 0.2*self.w*math.cos(self.r + math.pi/2),
self.y - 0.9*self.w*math.sin(self.r) + 0.2*self.w*math.sin(self.r + math.pi/2),
{parent = self, r = random(2, 4), d = random(0.15, 0.25), color = self.trail_color})
end
end)
end
0.9*self.w
), но каждая сдвинута на небольшое расстояние (0.2*self.w
) вдоль оси, противоположной движению игрока.elseif self.ship == 'ShipName' then
и к определению многоугольников, и к определению следа. Вот как выглядят корабли, созданные мной (но вы, разумеется, можете творить сами и выдумывать собственный дизайн):К сожалению, не доступен сервер mySQL