Итак, мы продолжаем писать игру Space Invaders на языке программирования Lua. Если вы пропустили предыдущие статьи, то мы настоятельно рекомендуем вам прочитать их. Прилагаем полный список всех уроков в этой серии:
- Настройка проекта.
- Реализация геймплея. Часть 1.
- Реализация геймплея. Часть 2.
- Заключение.
Игровой уровень
Итак, в прошлых частей серии мы реализовали меню с движущимися звездами и кнопкой startButton, по нажатию на которую начинается игра. Но вы увидите всего лишь черный экран, поскольку игровой уровень еще не написан нами. Давайте как раз займемся этим.
Шаг 1: Локальные переменные
Вставьте нижеприведенный код в файл gamelevel.lua перед ключевым словом return. Это локальные файлы, которые нам понадобятся для самой игры. Названия большинства переменных говорящие, но для тех, смысл которых понятен не сразу, есть комментарии:
local starFieldGenerator = require("starfieldgenerator")
local pulsatingText = require("pulsatingtext")
local physics = require("physics")
local gameData = require( "gamedata" )
physics.start()
local starGenerator -- объект класса starFieldGenerator
local player
local playerHeight = 125
local playerWidth = 94
local invaderSize = 32 -- высота и ширина космических захватчиков
local leftBounds = 30 -- отступ слева
local rightBounds = display.contentWidth - 30 -- отступ справа
local invaderHalfWidth = 16
local invaders = {} -- таблица, хранящая всех захватчиков
local invaderSpeed = 5
local playerBullets = {} -- таблица, хранящая все пули героя
local canFireBullet = true
local invadersWhoCanFire = {} -- таблица, хранящая всех захватчиков, которые могут стрелять
local invaderBullets = {}
local numberOfLives = 3
local playerIsInvincible = false
local rowOfInvadersWhoCanFire = 5
local invaderFireTimer -- таймер, используемый для стрельбы захватчиков
local gameIsOver = false;
local drawDebugButtons = {} -- временные кнопки, необходимые для перемещения героя в симуляторе
local enableBulletFireTimer -- таймер, используемый для стрельбы героя
Шаг 2: Добавление звезд
В игровой сцене, как и в главном меню, будут пролетать звезды. Чтобы добавить их, пропишите следующий код в gamelevel.lua:
function scene:create(event)
local group = self.view
starGenerator = starFieldGenerator.new(200,group,5)
end
Как и раньше, чтобы заставить звезды двигаться, мы напишем следующий код в методе scene:show:
function scene:show(event)
local phase = event.phase
local previousScene = composer.getSceneName( "previous" )
composer.removeScene(previousScene)
local group = self.view
if ( phase == "did" ) then
Runtime:addEventListener("enterFrame", starGenerator)
end
end
Мы удаляем предыдущую сцену и добавляем слушателя события enterFrame. Как уже говорилось в прошлой статье, если был добавлен слушатель, его где-то обязательно нужно удалять. Функция scene:hide отлично подходит для этого:
function scene:hide(event)
local phase = event.phase
local group = self.view
if ( phase == "will" ) then
Runtime:removeEventListener("enterFrame", starGenerator)
end
end
И не забываем добавить слушателей для create, show и hide методов:
scene:addEventListener( "create", scene )
scene:addEventListener( "show", scene )
scene:addEventListener( "hide", scene )
Если теперь запустить игру, то, нажав на кнопку startButton, вы увидите движущиеся звезды.
Шаг 3: Добавление игрока
Давайте теперь создадим героя игры и научим его двигаться. Для движения мы будем использовать акселерометр, но специально для симулятора сделаем альтернативный способ управления — кнопками. Добавьте следующий код в gamelevel.lua:
function setupPlayer()
local options = { width = playerWidth,height = playerHeight,numFrames = 2}
local playerSheet = graphics.newImageSheet( "player.png", options )
local sequenceData = {
{ start=1, count=2, time=300, loopCount=0 }
}
player = display.newSprite( playerSheet, sequenceData )
player.name = "player"
player.x=display.contentCenterX- playerWidth /2
player.y = display.contentHeight - playerHeight - 10
player:play()
scene.view:insert(player)
local physicsData = (require "shapedefs").physicsData(1.0)
physics.addBody( player, physicsData:get("ship"))
player.gravityScale = 0
end
Герой является объектом класса SpriteObject. По сути — это всего лишь картинка, но мы сможем анимировать ее. Для этого воспользуемся двумя изображениями: в одном двигатель включен, в другом — выключен.
При быстром переключении этих изображений мы сможем создать иллюзию работающего двигателя. Чередуя различные картинки, можно достичь хороших результатов. Для этого воспользуемся атласом изображений. Это одна большая картинка, состоящая из более маленьких.
Таблица options хранит высоту, ширину и количество кадров в анимации. Переменная playerSheet является объектом класса ImageSheet, который в качестве параметров принимает изображение и таблицу options.
Переменная sequenceData требуется для создания анимации. Она хранит:
- ключ start, который означает, с какого кадра начнется анимация;
- ключ count, обозначающий количество кадров;
- ключ time — время между кадрами;
- ключ loopCount — количество повторений анимации (0 — бесконечное).
Далее мы задаем спрайту название, координаты, вызываем метод play и вставляем его на сцену.
Для того, чтобы отлавливать коллизии, мы прибегнем к помощи встроенного в Corona физического движка, который использует возможности Box2D. Обработка столкновений по умолчанию использует метод ограничительной рамки. Этот метод хорошо работает только для прямоугольных и круглых объектов, но совсем не приемлем для сложных фигур. Посмотрите на изображение ниже, чтобы лучше понять суть проблемы.
Обратите внимание, что выстрел не коснулся корабля, а столкновение произошло, что не есть хорошо.
Чтобы преодолеть это ограничение, можно использовать параметры формы — множество координат вершин фигуры. Эти координаты довольно трудоемко получать вручную, но используя программу PhysicsEditor можно сократить время до 7-10 секунд.
Переменная physicsData по сути и есть файл, экспортируемый из программы PhysicsEditor. Мы вызываем метод физического движка addBody для того, чтобы передать границы физического тела. В результате при обработке столкновения будут учитываться фактические границы космического корабля.
И в самом конце инициализируем gravityScale нулем, так как не хотим использовать воздействие гравитации. А теперь вызываем метод setupPlayer в scene:create:
function scene:create(event)
local group = self.view
starGenerator = starFieldGenerator.new(100,group,5)
setupPlayer()
end
И если вы запустите игру, то увидите вот такую картину:
Шаг 4: Движение игрока
Как мы уже говорили, игрок будет управлять кораблем при помощи акселерометра. Добавьте следующий код в gamelevel.lua:
local function onAccelerate(event)
player.x = display.contentCenterX + (display.contentCenterX * (event.xGravity*2))
end
system.setAccelerometerInterval( 60 )
Runtime:addEventListener ("accelerometer", onAccelerate)
Метод onAccelerate будет вызываться каждый раз, когда значения акселерометра поменяются. Важно знать, что использование акселерометра сильно расходует заряд батареи, а потому — удаляйте слушатель события акселерометра, если он не используется.
Если вы тестируете игру на телефоне, то при наклоне устройства позиция корабля будет меняться. Но на симуляторе нет возможности протестировать работу акселерометра, поэтому добавим кнопки для управления кораблем
Шаг 5: Отладочные кнопки
Добавьте следующий код в gamelevel.lua, чтобы отрисовать отладочные кнопки управления космическим кораблем:
function drawDebugButtons()
local function movePlayer(event)
if(event.target.name == "left") then
player.x = player.x - 5
elseif(event.target.name == "right") then
player.x = player.x + 5
end
end
local left = display.newRect(60,700,50,50)
left.name = "left"
scene.view:insert(left)
local right = display.newRect(display.contentWidth-60,700,50,50)
right.name = "right"
scene.view:insert(right)
left:addEventListener("tap", movePlayer)
right:addEventListener("tap", movePlayer)
end
Этот код использует метод newRext объекта Display для рисования прямоугольников, на которые мы вешаем слушателей. По нажатию на эти «кнопки» будет вызываться метод movePlayer.
Стрельба
Шаг 1: Добавление и удаление выстрелов
Когда игрок нажмет на экран своего смартфона, корабль сделает один выстрел. Мы будем ограничивать частоту выстрелов с помощью простого таймера:
function firePlayerBullet()
if(canFireBullet == true) then
local tempBullet = display.newImage("laser.png", player.x, player.y - playerHeight/ 2)
tempBullet.name = "playerBullet"
scene.view:insert(tempBullet)
physics.addBody(tempBullet, "dynamic" )
tempBullet.gravityScale = 0
tempBullet.isBullet = true
tempBullet.isSensor = true
tempBullet:setLinearVelocity( 0,-400)
table.insert(playerBullets,tempBullet)
local laserSound = audio.loadSound( "laser.mp3" )
local laserChannel = audio.play( laserSound )
audio.dispose(laserChannel)
canFireBullet = false
else
return
end
local function enableBulletFire()
canFireBullet = true
end
timer.performWithDelay(750,enableBulletFire,1)
end
Для начала проверяется, может ли игрок стрелять. Если может, то мы создаем выстрел, задаем ему название (это потребуется позже для идентификации объекта) и крепим к нему динамическое физическое тело. В конце мы задаем линейное ускорение при помощи функции setLinearVelocity. Подробнее о свойствах и методах физического объекта вы можете узнать из официальной документации.
Далее мы загружаем и воспроизводим звук выстрела, присваиваем переменной canFireBullet значение false и запускаем таймер, по истечении которого переменная canFireBullet станет равной true.
Теперь осталось добавить слушателей:
function scene:show(event)
--SNIP--
if ( phase == "did" ) then
Runtime:addEventListener("enterFrame", starGenerator)
Runtime:addEventListener("tap", firePlayerBullet)
end
end
И не забудем удалить их:
function scene:hide(event)
if ( phase == "will" ) then
Runtime:removeEventListener("enterFrame", starGenerator)
Runtime:removeEventListener("tap", firePlayerBullet)
end
end
Если вы запустите игру и нажмете на экран, то создастся пуля, которая будет лететь вверх. Но есть одна проблема, которую нельзя увидеть при тестировании. Дело в том, что пули, улетев за верхнюю часть экрана, продолжают лететь в бесконечность. Это очень плохо для памяти. Давайте исправим эту ошибку.
Шаг 2: Удаление выстрелов
Всякий раз, когда создается пуля, она сохраняется в таблице playerBullets. Таким образом мы можем с легкостью ссылаться на каждую пулю и проверять ее свойства. Пробегаясь по всей таблице и проверяя y-координату пули, можно сделать вывод о том, улетела ли она за пределы экрана или нет. И в соответствии с этим, удалять ее или оставлять.
function checkPlayerBulletsOutOfBounds()
if(#playerBullets > 0)then
for i=#playerBullets,1,-1 do
if(playerBullets[i].y < 0) then
playerBullets[i]:removeSelf()
playerBullets[i] = nil
table.remove(playerBullets,i)
end
end
end
end
Важно отметить, что мы пробегаем по таблице в обратном порядке. В противном случае можно получить ошибку отсутствия элемента. Также важно: перед удалением объекта требуется присвоить ему значение nil.
Теперь нам нужно сообразить, где лучше всего использовать эту функцию. Проверять пули на выход за пределы экрана нужно всегда, а потому лучшим местом будет игровой цикл — функция, которая вызывается при отрисовке каждого кадра игры.
Шаг 3: Игровой цикл
Добавьте следующий код в gamelevel.lua:
function gameLoop()
checkPlayerBulletsOutOfBounds()
end
Мы должны вызывать метод gameLoop до тех пор, пока игра запущена. Легче всего это сделать при помощи слушателя события enterFrame:
function scene:show(event)
--SNIP--
if ( phase == "did" ) then
Runtime:addEventListener("enterFrame", gameLoop)
Runtime:addEventListener("enterFrame", starGenerator)
Runtime:addEventListener("tap", firePlayerBullet)
end
end
И, как обычно, не забываем удалять слушателя в методу scene:hide:
function scene:hide(event)
if ( phase == "will" ) then
Runtime:removeEventListener("enterFrame", gameLoop)
Runtime:removeEventListener("enterFrame", starGenerator)
Runtime:removeEventListener("tap", firePlayerBullet)
end
end
Захватчики
Шаг 1: Добавление захватчиков
В первом шаге мы добавим захватчиков на сцену:
function setupInvaders()
local xPositionStart =display.contentCenterX - invaderHalfWidth - (gameData.invaderNum *(invaderSize + 10))
local numberOfInvaders = gameData.invaderNum *2+1
for i = 1, gameData.rowsOfInvaders do
for j = 1, numberOfInvaders do
local tempInvader = display.newImage("invader1.png",xPositionStart + ((invaderSize+10)*(j-1)), i * 46 )
tempInvader.name = "invader"
if(i== gameData.rowsOfInvaders)then
table.insert(invadersWhoCanFire,tempInvader)
end
physics.addBody(tempInvader, "dynamic" )
tempInvader.gravityScale = 0
tempInvader.isSensor = true
scene.view:insert(tempInvader)
table.insert(invaders,tempInvader)
end
end
end
В зависимости от того, на каком уровне находится игрок, ряды будут содержать разное количество захватчиков. Переменная rowsOfInvaders отвечает за количество рядов с захватчиками, а invaderNum — за номер текущего уровня (но также используется и в некоторых расчетах).
Мы задаем начальную x-координату захватчиков, затем вычисляем общее их количество и создаем нужное количество врагов. Расчет количества происходит по формуле invaderNum * 2 + 1. При создании захватчиков мы проверяем текущий ряд. Если он последний, то создающийся захватчик вставляется в таблицу invadersWhoCanFire.
Шаг 2: Движение захватчиков
Чтобы заставить захватчиков перемещаться, мы будем использовать функцию gameLoop. Но для начала добавьте следующий код:
function moveInvaders()
local changeDirection = false
for i=1, #invaders do
invaders[i].x = invaders[i].x + invaderSpeed
if(invaders[i].x > rightBounds - invaderHalfWidth or invaders[i].x < leftBounds + invaderHalfWidth) then
changeDirection = true;
end
end
if(changeDirection == true)then
invaderSpeed = invaderSpeed*-1
for j = 1, #invaders do
invaders[j].y = invaders[j].y+ 46
end
changeDirection = false;
end
end
Мы в каждой итерации цикла изменяем x-координату всех захватчиков. Причем, если мы вышли за границу экрана (проверяем при помощи leftBounds и rightBounds), то переменной changeDirection присваиваем значение true.
А если же значение переменной changeDirection равно true, то мы умножаем invaderSpeed на -1 и смещаем каждого захватчика вниз.
Таким образом, функция moveInvaders будет вызываться в игровом цикле, т. е. в методе gameLoop:
function gameLoop()
checkPlayerBulletsOutOfBounds()
moveInvaders()
end
Обнаружение столкновений
Теперь у нас на сцене есть как герой, так и захватчики. Настало время проверять столкновение каждого из них с выстрелами. Делается это в методе onCollision:
function onCollision(event) local function removeInvaderAndPlayerBullet(event) local params = event.source.params local invaderIndex = table.indexOf(invaders,params.theInvader) local invadersPerRow = gameData.invaderNum *2+1 if(invaderIndex > invadersPerRow) then table.insert(invadersWhoCanFire, invaders[invaderIndex - invadersPerRow]) end params.theInvader.isVisible = false physics.removeBody( params.theInvader ) table.remove(invadersWhoCanFire,table.indexOf(invadersWhoCanFire,params.theInvader)) if(table.indexOf(playerBullets,params.thePlayerBullet)~=nil)then physics.removeBody(params.thePlayerBullet) table.remove(playerBullets,table.indexOf(playerBullets,params.thePlayerBullet)) display.remove(params.thePlayerBullet) params.thePlayerBullet = nil end end if ( event.phase == "began" ) then if(event.object1.name == "invader" and event.object2.name == "playerBullet")then local tm = timer.performWithDelay(10, removeInvaderAndPlayerBullet,1) tm.params = {theInvader = event.object1 , thePlayerBullet = event.object2} end if(event.object1.name == "playerBullet" and event.object2.name == "invader") then local tm = timer.performWithDelay(10, removeInvaderAndPlayerBullet,1) tm.params = {theInvader = event.object2 , thePlayerBullet = event.object1} end end end Здесь мы проверяем свойство name обоих объектов, которые столкнулись (так как мы не знаем заранее, что хранится в переменных event.object1 и event.object2, то рассматриваем обе ситуации). Если это invader и playerBullet, то с некоторой задержкой запускается функция removeInvaderAndPlayerBullet. Этот метод получает индекс захватчика, и если он взрывается, то мы удаляем его из сцены, присваиваем ему значение nil, а вышестоящий добавляем в таблицу invadersWhoCanFire. Также мы присваиваем переменной, указывающей на пулю героя, значение nil, удаляем ее из таблицы playerBullets и со сцены. Чтобы метод onCollision работал, нам следует добавить нового слушателя событий:
function scene:show(event)
local phase = event.phase
local previousScene = composer.getSceneName( "previous" )
composer.removeScene(previousScene)
local group = self.view
if ( phase == "did" ) then
Runtime:addEventListener("enterFrame", gameLoop)
Runtime:addEventListener("enterFrame", starGenerator)
Runtime:addEventListener("tap", firePlayerBullet)
Runtime:addEventListener( "collision", onCollision )
end
end
И не забываем удалить его в нужном месте:
function scene:hide(event)
if ( phase == "will" ) then
Runtime:removeEventListener("enterFrame", starGenerator)
Runtime:removeEventListener("tap", firePlayerBullet)
Runtime:removeEventListener("enterFrame", gameLoop)
Runtime:removeEventListener( "collision", onCollision )
end
end
Если вы сейчас запустите игру, то сможете выстрелить. Понаблюдайте за поведением пуль и захватчиков, убедитесь, что они исчезают с экрана.
Вывод
А на этом наша статья подходит к концу. В следующей части мы реализуем стрельбу захватчиков, смерть игрока, а также добавим новые уровни. Оставайтесь с нами!
Перевод статьи «Create a Space Invaders Game in Corona: Implementing Gameplay»
Наши тесты для вас:
— Тест на знание сленга веб-разработчиков.
— Кто вы во вселенной Звёздных Войн?
— А вы точно программист?