Пишем Space Invaders при помощи Corona. Реализация геймплея. Часть 2

Итак, мы продолжаем писать игру Space Invaders на языке программирования Lua. Если вы пропустили предыдущие статьи, то мы настоятельно рекомендуем вам прочитать их. Прилагаем полный список всех уроков в этой серии:

  1. Настройка проекта.
  2. Реализация геймплея. Часть 1.
  3. Реализация геймплея. Часть 2.
  4. Заключение.

Игровой уровень

Итак, в прошлых частей серии мы реализовали меню с движущимися звездами и кнопкой 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. Обработка столкновений по умолчанию использует метод ограничительной рамки. Этот метод хорошо работает только для прямоугольных и круглых объектов, но совсем не приемлем для сложных фигур. Посмотрите на изображение ниже, чтобы лучше понять суть проблемы.

Collision1

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

Чтобы преодолеть это ограничение, можно использовать параметры формы — множество координат вершин фигуры. Эти координаты довольно трудоемко получать вручную, но используя программу PhysicsEditor можно сократить время до 7-10 секунд.

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

Collision2

И в самом конце инициализируем gravityScale нулем, так как не хотим использовать воздействие гравитации. А теперь вызываем метод setupPlayer в scene:create:

function scene:create(event)
    local group = self.view
    starGenerator =  starFieldGenerator.new(100,group,5)
    setupPlayer()
end

И если вы запустите игру, то увидите вот такую картину:

player_thruster

Шаг 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»