Пишем Shoot 'em up на Alternativa3D - Часть 3: Прототип.

main2.png

Часть 3: Прототип.

CHAPTER 1 :: Chapter 2 :: Chapter 3

 

Устали от теории? Не волнуйтесь, больше ее не будет :). То, что так жаждали активные читатели свершилось - мы пишем полноценный игровой прототип. Здесь не будет динамического освещения, blooma'а и навороченных эффектов при взрыве. Зато можно будет летать, стрелять и убивать - джентельменский набор счастья.

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

Перед началом разбора кода лучше скачать готовый проект здесь.

Поиграть в прототип здесь.

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

3d scheme1.png

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

Запускаем Flash Develop.

Прототипирование.

Для написания прототипа (около четырех часов на все про все) мне хватило трех классов: Main, GlobVars и Player. В главном классе кода очень мало, всего 56 строк. Там происходит настройка рабочей области, инициализация движка, обработчик кадров и вызов функций других классов.

В спойлеры будут убраны коды и скриншоты (потому что форматирование кода не сохраняется и читать их не очень удобно). При копировании кода обращайте внимание на переносы строк. Когда исправите все появившиеся переносы строк, нажмите Ctrl+Shift+2 и FD самостоятельно отформатирует весь класс.

package
{
//импорт классов
import flash.display.Sprite;
import flash.display.Stage3D;
import flash.display.StageAlign;
import flash.display.StageQuality;
import flash.display.StageScaleMode;
import flash.events.Event;
import alternativa.engine3d.core.Camera3D;
import alternativa.engine3d.core.View;

//метаданные - настройка приложения
[SWF(width="600",height="600",backgroundColor="#000000",frameRate="60")]

/**
* Главный класс
* @author Verdana_hd
*/
public class Main extends Sprite
{
public var stage3d:Stage3D; //переменная для ссылки на основной stage3d
private var camera:Camera3D; //камера

public function Main()
{
if (stage)
init();
else
addEventListener(Event.ADDED_TO_STAGE, init);
}

private function init(event:Event = null):void
{
removeEventListener(Event.ADDED_TO_STAGE, init);

//настройки рабочей области
stage.align = StageAlign.TOP_LEFT;
stage.scaleMode = StageScaleMode.NO_SCALE;
stage.quality = StageQuality.HIGH;
stage.stageWidth = 600;
stage.stageHeight = 600;

camera = new Camera3D(0.1, 5000); //создаем камеру
camera.x = 0;
camera.y = 0;
camera.z = -800; //сдвигаем ее назад
Game.rootContainer.addChild(camera); //добавляем в главный контейнер

camera.view = new View(600, 600, false, 0x0, 1, 4); //создаем вьюпорт
addChild(camera.view); //добавляем вьюпорт к оглавному объекту
addChild(camera.diagram); //добавляем к главному объекту диаграмму для вывода отладочной информации

stage3d = stage.stage3Ds[0]; //запись ссылки в переменную
stage3d.addEventListener(Event.CONTEXT3D_CREATE, onContextCreate); //слушание события создания контекста
stage3d.requestContext3D(); //запуск создания контекста
}

private function onContextCreate(e:Event):void
{
stage3d.removeEventListener(Event.CONTEXT3D_CREATE, onContextCreate);

Game.init(this, stage); //вызываем инициализацию в классе с глобальными переменными, передаем объект главного класса для доступа в нему

addEventListener(Event.ENTER_FRAME, enterFrameHandler); //слушаем событие входа в кадр
}

private function enterFrameHandler(event:Event):void
{
camera.render(stage3d); //рендеринг
Game.playerObject.update(); //каждый кадр выполняем функцию обновления в объекте игрока
}
}
}
1_7161.png

Все что тут написано просто и понятно, разве что настройка среды может вызвать у вас вопросы. По ним легко найти ответ в онлайн-справочнике, достаточно выбрать нужный пакет и класс в меню слева (пути к класса смотри сверху в импорте). В функции onContextCreate есть обращение к статическим свойствам класса Game: атрибуту playerObject и методу init.

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

Разберем его подробно:

package
{
import alternativa.engine3d.core.Object3D;
import alternativa.engine3d.core.Resource;
import alternativa.engine3d.materials.FillMaterial;
import alternativa.engine3d.primitives.GeoSphere;
import flash.display.InteractiveObject;
import flash.events.TimerEvent;
import flash.geom.Vector3D;
import flash.text.TextField;
import flash.text.TextFieldAutoSize;
import flash.text.TextFormat;
import flash.utils.Timer;
/**
* Глобальные переменные
* @author Verdana_hd
*/
public final class Game
{
public static var mainObject:Main; //ссылка на объект главного класса
public static var playerObject:Player; //объект игрока
public static var tf:TextField; //текстовой поле
public static var rootContainer:Object3D = new Object3D(); //главный контейнерpublic static var enemyFlow:Vector.<Object3D> = new Vector.<Object3D>(); //вектор с врагамиpublic static var playerBulletFlow:Vector.<GeoSphere> = new Vector.<GeoSphere>(); //вектор с пулями игрока
public static var enemyBulletFlow:Vector.<Object3D> = new Vector.<Object3D>(); //вектор с пулями врагов
public static var enemySpawnTimer:Timer = new Timer(1000); //таймер для создания (спауна) врагов, с задержкой в 1с=1000мсpublic static function init(main:Main, stage:InteractiveObject):void //эта функция в классе выполняется первой (именно ее мы вызывали из главного класса)
{ //сдесь происходит предварительная инициализация всех объекто и загрузка всех необходимых ресурсов
mainObject = main; //записываем ссылку на главный объект, переданную параметром, в отдельный атрибут
playerObject = new Player(main, stage);/* загружаем ресурсы шаблонов в контекст
нам не нужно видеть сами шаблоны, поэтому мы не добавляем их в rootContainer и грузим отдельно */
Lib3D.enemyTemplate.geometry.upload(mainObject.stage3d.context3D); //загружаем шаблон врага
Lib3D.playerBulletTemplate.geometry.upload(mainObject.stage3d.context3D); //загружаем шаблон пули игрока
Lib3D.enemyBulletTemplate.geometry.upload(mainObject.stage3d.context3D); //загружаем шаблон пули врагов
Lib3D.playerTemplate.geometry.upload(mainObject.stage3d.context3D); //загружаем шаблон модели игрока
Lib3D.load();var myFormat:TextFormat = new TextFormat(); //создаем формат текста
myFormat.color = 0xFFFFFF;
myFormat.size = 20;
tf = new TextField(); //создаем текстовое поле
tf.x = 0; //задаем двумерный координаты на рабочей области
tf.y = 0;
tf.selectable = false; //делаем текст невыделяемым курсором
tf.autoSize = TextFieldAutoSize.LEFT; //авторазмерность по левому краю
tf.defaultTextFormat = myFormat; //устанавливаем формат
tf.text = "Игра."; //записываем базовый текст
mainObject.addChild(tf); //добавляем к главному объекту (он у нас класса Sprite) наше текстовое поле
//собираем и грузим все ресурсы главного контейнера
for each (var resource:Resource in rootContainer.getResources(true))
{
resource.upload(mainObject.stage3d.context3D);
}
enemySpawnTimer.start(); //запускаем таймер спауна врагов
enemySpawnTimer.addEventListener(TimerEvent.TIMER, spawn); //слушаем событие таймера, наступающее раз в 1000мс=1с, и спауним врагов
}
private static function spawn(e:TimerEvent = null):void
{
var enemy:Object3D = Lib3D.enemyTemplate.clone(); //создаем временную переменную и клонируем в нее шаблон врага (экономим ресурсы)
var n:Number = Math.random(); //создаем две случайных переменных
var i:Number = Math.random();
//систему распределения врагов при спауне смотрите в статье
if (i <= 1)
{
enemy.x = Math.sin(n) * 500;
enemy.y = Math.cos(n) * 500;
}
if (i <= 0.75)
{
enemy.x = Math.sin(n) * 500;
enemy.y = -Math.cos(n) * 500;
}
if (i <= 0.5)
{
enemy.x = -Math.sin(n) * 500;
enemy.y = Math.cos(n) * 500;
}
if (i <= 0.25)
{
enemy.x = -Math.sin(n) * 500;
enemy.y = -Math.cos(n) * 500;
}
enemy.z = 4000; //враги должны спауниться далеко
rootContainer.addChild(enemy); //добавляем врага в конейнер, чтобы он отображался
enemyFlow.push(enemy); //добавляем противника в поток врагов
}
public static function enemyActions():void //эта функция выполняется в каждом кадре - обновление противников
{
for (var i:uint = 0; i <= enemyFlow.length - 1; i++) //перебираем все объекты в потоке врагов
{
if (enemyFlow[i].z >= 0) //если противник не достиг конца цилиндра
{
enemyFlow[i].z -= 10; //сдвигаем противника на десять единиц ближе
var position:Vector3D = new Vector3D(enemyFlow[i].x, enemyFlow[i].y, enemyFlow[i].z); //записываем координаты врага в переменнуюfor (var j:uint = 0; j <= playerBulletFlow.length - 1; j++) //перебираем все пули в потоке пуль игрока
{
var bulPos:Vector3D = new Vector3D(playerBulletFlow[j].x, playerBulletFlow[j].y, playerBulletFlow[j].z); //записываем поток пуль в переменную
bulPos = bulPos.subtract(position); //отнимаем от вектора с позицией пули вектор с позицией врага
var deltaB:Number = Math.abs(bulPos.x) + Math.abs(bulPos.y) + Math.abs(bulPos.z); //складываем разницу координам по модулю
if (deltaB <= 50) //если разница координат меньше 40 (то есть если пуля находится внутри сферы радиуса 40 с центром в координатах врага)
{
rootContainer.removeChild(enemyFlow[i]); //удаляем врага из контейнера
enemyFlow.splice(i, 1); //удаляем врага из потока
rootContainer.removeChild(playerBulletFlow[j]); //удаляем пулю игрока из контейнера
playerBulletFlow.splice(j, 1); //удаляем пулю игрока из потока
}
}
var n:Number = Math.random(); //создаем рандомную переменную (диапазон у нее [0;1]) - обеспечиваем случайность выстрелов
if (n <= 0.01) //проверяем попадание числа в диапазон, обеспечиваем редкость выстрелом
{
var enemyBullet:Object3D = Lib3D.enemyBulletTemplate.clone(); //создаем пулю и клонируем в нее шаблон
enemyBullet.x = enemyFlow[i].x; //присваиваем пуле координаты стрелка
enemyBullet.y = enemyFlow[i].y;
enemyBullet.z = enemyFlow[i].z;
rootContainer.addChild(enemyBullet); //добавляем пулю в контейнер
enemyBulletFlow.push(enemyBullet); //добавляем пулю в потом вражеских пуль
}
}
else
{ //если враг достиг конца пути
rootContainer.removeChild(enemyFlow[i]); //удаляем его из контейнера
enemyFlow.splice(i, 1); //и из потока врагов
}
}
for (var h:uint = 0; h <= enemyBulletFlow.length - 1; h++) //перебираем все пули в потоке
{
if (enemyBulletFlow[h].z >= -800)
{
enemyBulletFlow[h].z -= 20; //приближаем к нам
}
else
{
rootContainer.removeChild(enemyBulletFlow[h]);
enemyBulletFlow.splice(h, 1);
}
}
}
public static function restart():void //функция перезапуска, которая выполняется по нажатию Enter после проигрыша
{
for each (var enemy:Object3D in enemyFlow) //перебираем всех врагов
{
rootContainer.removeChild(enemy); //удаляем из контейнера
}
for each (var enemyBul:Object3D in enemyBulletFlow) //перебираем все вражеские пули
{
rootContainer.removeChild(enemyBul); //удаляем из контейнера
}
for each (var bul:Object3D in playerBulletFlow) //перебираем все пули игрока
{
rootContainer.removeChild(bul); //удаляем из контейнера
}
enemyFlow = new Vector.<Object3D>(); //очищаем вектор фрагов
enemyBulletFlow = new Vector.<Object3D>(); //вражеских пуль
playerBulletFlow = new Vector.<GeoSphere>(); //и пуль игрока
playerObject.crack = false; //говорим, что игрок не убит
playerObject.stoped = false; //и снимаем с паузы
playerObject.enable(); //"включаем" игрока
tf.text = "Игра."; //заменяем текст
}
}
}

 2.png

3.png

4.png

5.png

 

Некоторые вещи кажутся очевидными, некоторые непонятны. Например система спауна врагов. Посмотрите на схему с цилиндром сверху. Знаете как я создал игрока? Просто добавил его в дочерний (по отношению к rootContainer) контейнер и сдвинул в локальных координатах на 500 единиц. В самой игре мы вращаем контейнер (координаты у него 0,0,0) с игроком вокруг своей оси, благодаря чему игрок движется по кругу с радиусом в 500 и постоянно повернут "верхней" стороной к центру. Удобно, не правда ли :) ? С врагами дело совсем другое. Вариант с контейнерами сразу отметаем, он тут не сработает. Как же тогда?..

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

circle_angles.png

Уловили зависимость абсциссы и ординаты от угла? Радиус равен единице.

 

  1. Создаем две случайные дробные переменные (i,n в диапазоне [0;1]).
  2. Смотрим, в каком диапазоне находится i (вложенные условия тут не нужны, достаточно воспользоваться последовательностью условий с сужающимся интервалом) - это выбор четверти на координатной плоскости (так как у нас изменяется только абсцисса и ордината, аппликатой можно пренебречь). 
  3. Определив в какой четверти будет находиться враг, устанавливаем ему координаты на основе второго случайного числа (используем синус и косинус, в зависимости от четверти ставим знак) и умножаем его на 500 - радиус нашего воображаемого цилиндра.

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

Слово "столкновения" не зря взято в кавычки. В Alternativa3D 8 есть класс Ellipsoid Collider специально для просчета столкновений, но при включенном antiAliasing у камеры многие вещи начинают глючить и он в том числе. Метод с расчетом разницы координаты в нашем случае (с небольшими объектами простой формы) удобнее и менее затратен в плане ресурсов.

Теперь класс Player

package
{
import alternativa.engine3d.core.Camera3D;
import alternativa.engine3d.core.Object3D;
import alternativa.engine3d.core.View;
import alternativa.engine3d.materials.FillMaterial;
import alternativa.engine3d.primitives.Box;
import flash.display.InteractiveObject;
import flash.display.Sprite;
import flash.display.Stage3D;
import flash.events.KeyboardEvent;
import flash.geom.Vector3D;
import flash.ui.Keyboard;
/**
* Игрок
* @author Verdana_hd
*/
public final class Player
{
public var container:Object3D = new Object3D(); //конейнер с моделью игрока
private var stage:InteractiveObject; //ссылка на stage
private var model:Object3D;
private var isLEFT:Boolean; //нажато ли "влево"
private var isRIGHT:Boolean; //нажато ли "вправо"
private var speed:uint = 2; //скорость вращения в градусах
private var shoot:Boolean = false; //выстрел
private var spacePressed:Boolean = false; //нажат ли пробел
private var bulSpeed:uint = 15; //скорость пули
private var hitballSize:uint = 40; //размер сферы попадания в игрокаpublic var crack:Boolean = false; //попали в игрока или нет
public var stoped:Boolean = false; //остановка игрока
public function Player(main:Sprite, eventSource:InteractiveObject) //конструктор, принимаем ссылки на главный класс и stage
{
model = Lib3D.playerTemplate.clone();
model.x = 0; //позиционируем модель
model.y = 500; //сдвигаем в локальных координатах
model.z = 0;
container.addChild(model); //добавляем в контейнер игрока
container.x = 0; //позиционируем контейнер
container.y = 0;
container.z = 0;
Game.rootContainer.addChild(container); //добавляем контейнер игрока к главному контейнеру
stage = eventSource; //записываем ссылку в атрибут
enable(); //включаем управление
}
public function enable():void //включение управления
{
if (stage.hasEventListener(KeyboardEvent.KEY_DOWN)) //проверяем если ли уже подписка на событие
{
stage.removeEventListener(KeyboardEvent.KEY_DOWN, stopedKeyDOWN); //удаляем подписку на нажатие клавиши в стоп-режиме
}
stage.addEventListener(KeyboardEvent.KEY_DOWN, onKeyDOWN); //подписываем на событие нажатия клавиши
stage.addEventListener(KeyboardEvent.KEY_UP, onKeyUP); //подписываем на событие отпуска клавиши
}
public function disable():void //отключение управления
{
isLEFT = false; //отключаем
isRIGHT = false;
shoot = false;
stage.removeEventListener(KeyboardEvent.KEY_DOWN, onKeyDOWN); //удаляем подписки
stage.removeEventListener(KeyboardEvent.KEY_UP, onKeyUP);
}
public function update():void //функция обновления, выполняется каждый кадр в главном объекте
{
action(); //обновления действий игрока
Game.enemyActions(); //обновление действий врагов
}
private function action():void
{
checkCollisions(); //проверяем "стокновения"
if (!crack) //если игрок не подбит
{
if (isLEFT) //если нажато влево
{
container.rotationZ += speed * Math.PI / 180; //поворачиваем контейнер (угол указывается в радианах, поэтому переводим из градусов)
}
else if (isRIGHT) //если нажато в право
{
container.rotationZ -= speed * Math.PI / 180; //аналогично
}
if (shoot) //если выстрел
{
shoot = false; //сообщаем что уже выстрелено
var bullet:Object3D = Lib3D.playerBulletTemplate.clone(); //создаем пулю, клонируем шаблон
var position:Vector3D = container.localToGlobal(new Vector3D(model.x, model.y, model.z)); //записываем координаты игрока, переводим их из локальных в глобальные
bullet.x = position.x; //позиционируем пулю
bullet.y = position.y;
bullet.z = position.z;
Game.rootContainer.addChild(bullet); //добавляем ее в главный контейнер
Game.playerBulletFlow.push(bullet); //добавляем ее в поток
}
}
else if (!stoped) //если игрок подбит и игра еще не остановлена
{
stoped = true; //говорим, что игра остановлена
disable(); //отключаем управление
Game.tf.text = "Убит. Нажмите Enter, чтобы начать заново"; //пишем
stage.addEventListener(KeyboardEvent.KEY_DOWN, stopedKeyDOWN); //подписываем на событие нажатия клавиши
}
for each (var bul:Object3D in Game.playerBulletFlow) //перебираем пули в потоке
{
if (bul.z <= 5000) //если пуля еще не улетела слишком далеко
{
bul.z += bulSpeed; //сдвигаем ее по направлению от игрока
}
}
}
private function checkCollisions():void //проверка "столкновений"
{
var position:Vector3D = container.localToGlobal(new Vector3D(model.x, model.y, model.z)); //берем глобальные координаты игрока
for each (var enemyBullet:Object3D in Game.enemyBulletFlow) //перебираем все пули врагов в потоке
{
var bulPos:Vector3D = new Vector3D(enemyBullet.x, enemyBullet.y, enemyBullet.z); //берем координаты пули
bulPos = bulPos.subtract(position); //отнимаем вектор с координатами пули от вектора с координатами игрока
var deltaB:Number = Math.abs(bulPos.x) + Math.abs(bulPos.y) + Math.abs(bulPos.z); //суммируем разность координат по модулю
if (deltaB <= hitballSize) //если разность меньше радиуса сферы "столкновения"
{
crack = true; //сообщаем, что игрок подбит
}
}
for each (var enemy:Object3D in Game.enemyFlow) //перебираем всех врагов в потоке
{//аналогично проверяем столкновение с врагами
var enemyPos:Vector3D = new Vector3D(enemy.x, enemy.y, enemy.z);
enemyPos = enemyPos.subtract(position);
var deltaE:Number = Math.abs(enemyPos.x) + Math.abs(enemyPos.y) + Math.abs(enemyPos.z);
if (deltaE <= hitballSize)
{
crack = true;
}
}
}
private function onKeyDOWN(e:KeyboardEvent):void //событие нажатия клавиши при включенном управлении
{
switch (e.keyCode) //смотрим какая клавиша нажата
{
case Keyboard.A:
isLEFT = true; //говорим, что влево
break;
case Keyboard.D:
isRIGHT = true; //говорим, что вправо
break;
case Keyboard.SPACE:
if (!spacePressed) //если пробел не зажат
{
spacePressed = true; //говорим, что пробел нажат
shoot = true; //говорим стрелять
}
break;
}
}
private function onKeyUP(e:KeyboardEvent):void //события отпуска клавиши, все наоборот
{
switch (e.keyCode)
{
case Keyboard.A:
isLEFT = false;
break;
case Keyboard.D:
isRIGHT = false;
break;
case Keyboard.SPACE:
spacePressed = false;
break;
}
}
private function stopedKeyDOWN(e:KeyboardEvent):void //событие нажатия, если игра остановлена (игрок побит)
{
if (e.keyCode == Keyboard.ENTER) //если нажат Enter
{
Game.restart(); //перезапускаем игру
}
}
}
}

6.png

7.png

8.png

9.png

10.png

На мой взгляд комментариев в классе достаточно, но все же я хотел бы объяснить вам некоторые свои решения:

Как видите, камера и вьюпорт создаются именно здесь. На самом деле я сделал это по инерции своего последнего проекта. Камеру стоит создавать в классе с игроком, только если вам нужно как-то управлять ею вместе с действиями игрока, ну а вьюпорт само собой создается там же где камера, т.к. является ее атрибутом. Нам же управление камерой вообще не нужно. Значит заносим в список как будущее структурное изменение.

Про вращение игрока по окружности уже писалось выше как предисловие к системе спауна врагов. Удобно, не правда ли?)

С функциями выключения и выключения управления все просто. Запомните лишь, что подписывать на клавиатурные события нужно stage нашего главного объекта. В данном случае стэйдж мы передаем через параметр в конструкторе и записываем в приватный атрибут.

Система поворота крайне проста, просто ин- или декрементируем величину угла поворота контейнера по оси аппликат и не забываем перевести градусы в радианы ("2" в радианах слишком много, поэтому удобнее работать с градусами).

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

Стоп-режим, для него понадобилось две булевых переменных: подбит ли игрок, остановлена ли игра. Одной не обойтись, а отправка собственных событий тут не оправдана. Потому две переменные.

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

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

Функции событий нажатия и отпускания клавиши простые, инструкция switch case проще некуда.

Скачать проект со всеми файлами можно здесь.

Поиграть в прототип здесь.

ВЫводы

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