Как написать игру башенки

Время на прочтение
19 мин

Количество просмотров 61K

Жанр Tower Defence один из самых популярных жанров игр на iOS. Причиной тому веселое времяпрепровождение, сдобренное постройкой башен для создания последней точки обороны против орд монстров, пытающихся эту самую оборону прорвать.
Представляю вашему вниманию перевод статьи, написанной Pablo Ruiz, и найденной мной на сайте raywenderlich.com.
В этом уроке будет показано, как создать игру Tower Defense с нуля, с помощью Cocos2D.
В процессе вы выучите следующее:

  • Как создавать волны врагов и настраивать время их появления.
  • Как заставить этих врагов двигаться по заданным точкам(waypoint’ам).
  • Как строить башни на специально отведенных местах на карте.
  • Как заставить башни стрелять во врагов.
  • Как визуально представить waypoint’ы и радиусы атак башен.

В конце урока, у вас будет свой фрэймворк для создания игр такого типа, который вы сможете расширять, добавляя новые типы врагов, башен и карт.
Для понимания этого урока, вам нужно базовое понимание Cocos2D. Если вы новичок, то можете взглянуть на более легкие уроки на сайте Рэя.

Вид с Башни из Слоновой Кости

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

image

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

Подготовка ресурсов

Для вас уже подготовлен стартовый проект, содержащий в себе пустой шаблон Cocos2D и большинство ресурсов, которые мы будем использовать в ходе урока.
Стартовый проект содержит базовый шаблон Cocos2D 1.1, который предоставляет собой работающее приложение содержащее HelloWorldLayer и строку по середине экрана. Мы не будем использовать HelloWorldLayer, потому что создадим собственный интерфейс, и все же данный класс приводится для того, чтобы вы были уверены, что на данном этапе все работает.
Откройте проект в Xcode, скомпилируйте и запустите его. Текст «Hello World» удален из проекта, поэтому вы просто должны получить черный экран и не получить ошибок в процессе запуска.
Взгляните на структуру проекта. Внутри папки TowerDefense вы найдете:

  • Все классы использованные в игре
  • Папка с библиотекой Cocos2D
  • Папка ресурсов с необходимыми графикой и звуками

Теперь вы можете начать настраивать карту и создавать башни!

Установка башен

Вначале, добавьте фоновую картинку на сцену. Откройте класс HelloWorldLayer.m и добавьте следующие строчки кода внутрь условия «if» в методе «init»:

// 1 - инициализация
self.isTouchEnabled = YES;
CGSize wins = [CCDirector sharedDirector].winSize;
// 2 - назначение фона    
CCSprite * background = [CCSprite spriteWithFile:@"Bg.png"];
[self addChild:background];
[background setPosition:ccp(wins.width/2,wins.height/2)];

Первая строчка в разделе #1 позволяет слою принимать события касания. Остальная часть кода в разделе #2 добавляет фоновый спрайт на нашу сцену.
Фоновая картинка помогает увидеть, где игрок может ставить башни. Теперь вам нужно задать несколько точек на экране, касаясь которых игрок сможет строить башни.
Чтобы сохранить смысл наших действий, все точки будут находиться в файле «.plist», в котором их можно будет легко изменить. TowerPosition.plist находится в папке Resources и уже содержит несколько позиций для постройки башен внутри.
Рассмотрите этот файл, вы найдете массив словарей, которые содержат всего два ключа: «x» и «y». Каждый словарь представляет собой позицию башни по ее координатам на экране. Теперь вам нужно прописать этот файл и установить базы для башен на карте.
Откройте HelloWorldLayer.h и добавьте следующую переменную (внутрь фигурных скобок после строчки interface):

NSMutableArray * towerBases;

Внесите следующие изменения в HelloWorldLayer.m:

Код

//Добавьте новый метод над "init"
-(void)loadTowerPositions
{
    NSString* plistPath = [[NSBundle mainBundle] pathForResource:@"TowersPosition" ofType:@"plist"];
    NSArray * towerPositions = [NSArray arrayWithContentsOfFile:plistPath];
    towerBases = [[NSMutableArray alloc] initWithCapacity:10];
 
    for(NSDictionary * towerPos in towerPositions)
    {
        CCSprite * towerBase = [CCSprite spriteWithFile:@"open_spot.png"];
        [self addChild:towerBase];
        [towerBase setPosition:ccp([[towerPos objectForKey:@"x"] intValue],[[towerPos objectForKey:@"y"] intValue])];
        [towerBases addObject:towerBase];
    }
 
}
 
//Внутри init, вызовите этот метод после раздела #2
// 3 - Загрузка позиции башен
[self loadTowerPositions];
 
//В dealloc, освободите память нового массива (перед вызовом super)
[towerBases release];

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

image

Теперь эти базы готовы, давайте построим несколько башен!
Вначале, откройте HelloWorldLayer.h и добавьте следующую строчку после закрытия фигурных скобок:

@property (nonatomic,retain) NSMutableArray *towers;

Синтезируйте переменную башен в HelloWorldLayer.m ниже строчки @implementation:

@synthesize towers;

Затем, создайте новый класс, который будет представлять башни. Добавьте новый файл из шаблона iOSCocoa TouchObjective-C. Назовите класс Tower, и сделайте его подклассом CCNode.
Замените содержимое Tower.h следующим:

Код

#import "cocos2d.h"
#import "HelloWorldLayer.h"
 
#define kTOWER_COST 300
 
@class HelloWorldLayer, Enemy;
 
@interface Tower: CCNode {
    int attackRange;
    int damage;
    float fireRate;
}
 
@property (nonatomic,assign) HelloWorldLayer *theGame;
@property (nonatomic,assign) CCSprite *mySprite;
 
+(id)nodeWithTheGame:(HelloWorldLayer*)_game location:(CGPoint)location;
-(id)initWithTheGame:(HelloWorldLayer *)_game location:(CGPoint)location;
 
@end

Затем замените содержимое Tower.m этим:

Код

#import "Tower.h"
 
@implementation Tower
 
@synthesize mySprite,theGame;
 
+(id) nodeWithTheGame:(HelloWorldLayer*)_game location:(CGPoint)location
{
    return [[[self alloc] initWithTheGame:_game location:location] autorelease];
}
 
-(id) initWithTheGame:(HelloWorldLayer *)_game location:(CGPoint)location
{
	if( (self=[super init])) {
 
		theGame = _game;
        	attackRange = 70;
       		damage = 10;
        	fireRate = 1;
 
        	mySprite = [CCSprite spriteWithFile:@"tower.png"];
		[self addChild:mySprite];
 
        	[mySprite setPosition:location];
 
        	[theGame addChild:self];
 
        	[self scheduleUpdate];
 
	}
 
	return self;
}
 
-(void)update:(ccTime)dt
{
 
}
 
-(void)draw
{
    glColor4f(255, 255, 255, 255);
    ccDrawCircle(mySprite.position, attackRange, 360, 30, false);
    [super draw];
}
 
-(void)dealloc
{
	[super dealloc];
}
@end

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

  • attackRange: определяет расстояние с которого башни могут атаковать врагов
  • damage: определяет сколько урона башня наносит врагам
  • fireRate: определяет сколько времени нужно башни на перезарядку

С помощью этих трех переменных, вы можете создать огромное множество видов башен с разными типами атак. И, наконец, код содержит метод отрисовки радиуса атаки башни для удобства тестирования.
Настало время позволить игроку добавлять башни!
Откройте HelloWorldLayer.m и внесите следующие изменения:

Код

//В верхушке файла:
#import "Tower.h"
 
//Внутриdealloc: 
[towers release];
 
//После метода dealloc добавьте следующие методы:
-(BOOL)canBuyTower
{
    return YES;
}
 
- (void)ccTouchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
 
	for( UITouch *touch in touches ) {
		CGPoint location = [touch locationInView: [touch view]];
 
		location = [[CCDirector sharedDirector] convertToGL: location];
 
       	 	for(CCSprite * tb in towerBases)
        	{
			 if([self canBuyTower] && CGRectContainsPoint([tb boundingBox],location) && !tb.userData)
			{
				 //Мы потратим наше золото позже.
 
                		Tower * tower = [Tower nodeWithTheGame:self location:tb.position];
                		[towers addObject:tower];
               			 tb.userData = tower;
			}
		}
	}
}

ccTouchesBegan: фиксирует касания экрана. Затем код производит итерацию по массиву towerBases и проверяет, содержит ли какая-нибудь из баз башен точку касания.
Но до того, как башни могут быть сделаны, вы должны проверить две вещи:

  1. Может ли игрок позволить такое удовольствие? Метод canBuyTower проверит, имеет ли пользователь достаточно золота, чтобы купить башню. Хотя, на данный момент, у игрока все золото форта Нокс и метод будет возвращать всегда YES.
  2. Нарушает ли игрок правила постройки? Если tb.UserData задана, тогда на том месте уже стоит башня и вы не можете построить новую.

Если все условия выполнены, создается новая башня, устанавливается на базу и добавляется в массив башен.
Скомпилируйте и запустите игру. Коснитесь любой базы, и вы увидите, что добавляется башня с белым кругом вокруг нее, показывающим радиус ее атаки.

image
Но к чему все это вооружение без «плохих парней», давайте позовем их на вечеринку!

Политика игры: Враги, Волны и Вэйпоинты

До того как создадим врагов, давайте проложим дорогу для них. Враги будут следовать по маршруту из вэйпоинтов, которые по сути являются точками, соединенными между собой и определяющими путь движения врагов в вашем мире. Враги будут появляться на первом вэйпоинте, будут искать следующий в списке, двигаться к нему, и так будет повторяться, пока они не достигнут последнего — вашей базы! Если это произойдет — вы понесете урон.
Создайте список вэйпоинтов путем создания нового файла из iOSCocoa TouchObjective-C шаблона классов. Назовите класс Waypoint и сделайте его подклассом CCNode.
Замените содержимое Waypoint.h следующим:

Код

#import "cocos2d.h"
#import "HelloWorldLayer.h"
 
@interface Waypoint: CCNode {
    HelloWorldLayer *theGame;
}
 
@property (nonatomic,readwrite) CGPoint myPosition;
@property (nonatomic,assign) Waypoint *nextWaypoint;
 
+(id)nodeWithTheGame:(HelloWorldLayer*)_game location:(CGPoint)location;
-(id)initWithTheGame:(HelloWorldLayer *)_game location:(CGPoint)location;
 
@end

Далее замените содержимое Waypoint.m:

Код

#import "Waypoint.h"
 
@implementation Waypoint
 
@synthesize myPosition, nextWaypoint;
 
+(id)nodeWithTheGame:(HelloWorldLayer*)_game location:(CGPoint)location
{
    return [[[self alloc] initWithTheGame:_game location:location] autorelease];
}
 
-(id)initWithTheGame:(HelloWorldLayer *)_game location:(CGPoint)location
{
	if( (self=[super init])) {
 
		theGame = _game;
 
        [self setPosition:CGPointZero];
        myPosition = location;
 
        [theGame addChild:self];
 
	}
 
	return self;
}
 
-(void)draw
{
    glColor4f(0, 255, 0, 255);
    ccDrawCircle(myPosition, 6, 360, 30, false);
    ccDrawCircle(myPosition, 2, 360, 30, false);
 
    if(nextWaypoint)
        ccDrawLine(myPosition, nextWaypoint.myPosition);
 
    [super draw];   
}
 
-(void)dealloc
{
    [super dealloc];
}
 
@end

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

Код

@property (nonatomic,retain) NSMutableArray *waypoints;

Добавьте следующий код в HelloWorldLayer.m:

//В самом верху файла:
#import "Waypoint.h"
 
// Добавьте synthesise
@synthesize waypoints;
 
//Добавьте следующий метод над init
-(void)addWaypoints
{
    waypoints = [[NSMutableArray alloc] init];
 
    Waypoint * waypoint1 = [Waypoint nodeWithTheGame:self location:ccp(420,35)];
    [waypoints addObject:waypoint1];
 
    Waypoint * waypoint2 = [Waypoint nodeWithTheGame:self location:ccp(35,35)];
    [waypoints addObject:waypoint2];
    waypoint2.nextWaypoint =waypoint1;
 
    Waypoint * waypoint3 = [Waypoint nodeWithTheGame:self location:ccp(35,130)];
    [waypoints addObject:waypoint3];
    waypoint3.nextWaypoint =waypoint2;
 
    Waypoint * waypoint4 = [Waypoint nodeWithTheGame:self location:ccp(445,130)];
    [waypoints addObject:waypoint4];
    waypoint4.nextWaypoint =waypoint3;
 
    Waypoint * waypoint5 = [Waypoint nodeWithTheGame:self location:ccp(445,220)];
    [waypoints addObject:waypoint5];
     waypoint5.nextWaypoint =waypoint4;
 
    Waypoint * waypoint6 = [Waypoint nodeWithTheGame:self location:ccp(-40,220)];
    [waypoints addObject:waypoint6];
     waypoint6.nextWaypoint =waypoint5;
 
}
 
// В конце метода init:
// 4 - Add waypoints
[self addWaypoints];
 
//Внутри dealloc
[waypoints release];

Скомпилируйте и запустите игру:

image

На карте шесть вэйпоинтов, по этому пути будут следовать враги. Перед тем как вы дадите друзьям проиграть в игре, вам нужно добавить несколько вспомогательных методов.
Для начала добавьте описание методов в заголовочный файл, чтобы другие классы могли использовать эти методы без предупреждений от компилятора.
Откройте HelloWorldLayer.h и добавьте следующие описания методов перед строчкой » end»:

Код

-(BOOL)circle:(CGPoint)circlePoint withRadius:(float)radius collisionWithCircle:(CGPoint)circlePointTwo collisionCircleRadius:(float)radiusTwo;
void ccFillPoly(CGPoint *poli, int points, BOOL closePolygon);

Далее откройте HelloWorldLayer.m и добавьте следующие строки в конец файла (перед end):

void ccFillPoly( CGPoint *poli, int points, BOOL closePolygon ) {
    // Обычные состояния GL: GL_TEXTURE_2D, GL_VERTEX_ARRAY, GL_COLOR_ARRAY, GL_TEXTURE_COORD_ARRAY
    // Необходимые состояния: GL_VERTEX_ARRAY,
    // Ненужные состояния: GL_TEXTURE_2D, GL_TEXTURE_COORD_ARRAY, GL_COLOR_ARRAY
    glDisable(GL_TEXTURE_2D);
    glDisableClientState(GL_TEXTURE_COORD_ARRAY);
    glDisableClientState(GL_COLOR_ARRAY);
 
    glVertexPointer(2, GL_FLOAT, 0, poli);
    if( closePolygon )
        glDrawArrays(GL_TRIANGLE_FAN, 0, points);
    else
        glDrawArrays(GL_LINE_STRIP, 0, points);
 
    // restore default state
    glEnableClientState(GL_COLOR_ARRAY);
    glEnableClientState(GL_TEXTURE_COORD_ARRAY);
    glEnable(GL_TEXTURE_2D);
}
 
-(BOOL)circle:(CGPoint) circlePoint withRadius:(float) radius collisionWithCircle:(CGPoint) circlePointTwo collisionCircleRadius:(float) radiusTwo {
    float xdif = circlePoint.x - circlePointTwo.x;
    float ydif = circlePoint.y - circlePointTwo.y;
 
    float distance = sqrt(xdif*xdif+ydif*ydif);
 
    if(distance <= radius+radiusTwo) 
        return YES;
 
    return NO;
}

Метод collisionWithCircle поможет нам выяснить, когда два круга сталкиваются или пересекаются. Это поможет понять, достиг ли враг вэйпоинта, а также выявить врагов, находящихся в радиусе атаки башни.
Метод ccFillPoly рисует заполненные полигоны с помощью OpenGL. В Cocos2d вы можете рисовать только незаполненные полигоны. Метод ccFillPoly будет использоваться для рисования полосок здоровья врагов.
Пришло время выпустить врагов.
Откройте HelloWorldLayer.h и добавьте следующий код:

Код

// Добавьте эти переменные
int wave;
CCLabelBMFont *ui_wave_lbl;
 
// Там где описываются property
@property (nonatomic,retain) NSMutableArray *enemies;

Внесите следующие изменения в HelloWorldLayer.m:

// Synthesize enemies
@synthesize enemies;
 
// В dealloc
[enemies release];

Пришло время создать класс, который будет содержать всю информацию о врагах и управлять их передвижением по экрану. Создайте новый файл iOSCocoa TouchObjective-C. Назовите класс Enemy и сделайте его подклассом CCNode.
Замените содержимое Enemy.h:

Код

#import "cocos2d.h"
#import "HelloWorldLayer.h"
#import "GameConfig.h"
 
@class HelloWorldLayer, Waypoint, Tower;
 
@interface Enemy: CCNode {
    CGPoint myPosition;
    int maxHp;
    int currentHp;
    float walkingSpeed;
    Waypoint *destinationWaypoint;
    BOOL active;
}
 
@property (nonatomic,assign) HelloWorldLayer *theGame;
@property (nonatomic,assign) CCSprite *mySprite;
 
+(id)nodeWithTheGame:(HelloWorldLayer*)_game;
-(id)initWithTheGame:(HelloWorldLayer *)_game;
-(void)doActivate;
-(void)getRemoved;
 
@end

Теперь содержимое Enemy.m:

Код

#import "Enemy.h"
#import "Tower.h"
#import "Waypoint.h"
#import "SimpleAudioEngine.h"
 
#define HEALTH_BAR_WIDTH 20
#define HEALTH_BAR_ORIGIN -10
 
@implementation Enemy
 
@synthesize mySprite, theGame;
 
+(id)nodeWithTheGame:(HelloWorldLayer*)_game {
    return [[[self alloc] initWithTheGame:_game] autorelease];
}
 
-(id)initWithTheGame:(HelloWorldLayer *)_game {
	if ((self=[super init])) {
 
		theGame = _game;
        maxHp = 40;
        currentHp = maxHp;
 
        active = NO;
 
        walkingSpeed = 0.5;
 
        mySprite = [CCSprite spriteWithFile:@"enemy.png"];
		[self addChild:mySprite];
 
        Waypoint * waypoint = (Waypoint *)[theGame.waypoints objectAtIndex:([theGame.waypoints count]-1)];
 
        destinationWaypoint = waypoint.nextWaypoint;
 
        CGPoint pos = waypoint.myPosition;
        myPosition = pos;
 
        [mySprite setPosition:pos];
 
        [theGame addChild:self];
 
        [self scheduleUpdate];
 
	}
 
	return self;
}
 
-(void)doActivate
{
    active = YES;
}
 
-(void)update:(ccTime)dt
{
    if(!active)return;
 
    if([theGame circle:myPosition withRadius:1 collisionWithCircle:destinationWaypoint.myPosition collisionCircleRadius:1])
    {
        if(destinationWaypoint.nextWaypoint)
        {
            destinationWaypoint = destinationWaypoint.nextWaypoint;
        }else
        {
            //Достигнут конец пути. Нанесение повреждений игроку
            [theGame getHpDamage];
            [self getRemoved];
        }
    }
 
    CGPoint targetPoint = destinationWaypoint.myPosition;
    float movementSpeed = walkingSpeed;
 
    CGPoint normalized = ccpNormalize(ccp(targetPoint.x-myPosition.x,targetPoint.y-myPosition.y));
    mySprite.rotation = CC_RADIANS_TO_DEGREES(atan2(normalized.y,-normalized.x));
 
    myPosition = ccp(myPosition.x+normalized.x * movementSpeed,myPosition.y+normalized.y * movementSpeed);
 
   [mySprite setPosition:myPosition];
 
 
}
 
-(void)getRemoved
{
    [self.parent removeChild:self cleanup:YES];
    [theGame.enemies removeObject:self];
 
    //Сообщите игре что мы убили врага и можно проверить если можно запустить новую волну
    [theGame enemyGotKilled];
}
 
-(void)draw
{
    glColor4f(255, 0, 0, 255);
    CGPoint healthBarBack[] = {ccp(mySprite.position.x -10,mySprite.position.y+16),ccp(mySprite.position.x+10,mySprite.position.y+16),ccp(mySprite.position.x+10,mySprite.position.y+14),ccp(mySprite.position.x-10,mySprite.position.y+14)};
    ccFillPoly(healthBarBack, 4, YES);
 
    glColor4f(0, 255, 0, 255);
    CGPoint healthBar[] = {ccp(mySprite.position.x + HEALTH_BAR_ORIGIN,mySprite.position.y+16),ccp(mySprite.position.x+HEALTH_BAR_ORIGIN+(float)(currentHp * HEALTH_BAR_WIDTH) / maxHp,mySprite.position.y+16),ccp(mySprite.position.x+HEALTH_BAR_ORIGIN+(float)(currentHp * HEALTH_BAR_WIDTH) / maxHp,mySprite.position.y+14),ccp(mySprite.position.x+HEALTH_BAR_ORIGIN,mySprite.position.y+14)};
    ccFillPoly(healthBar, 4, YES);
}
 
-(void)dealloc
{
	[super dealloc];
}
 
@end

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

  • maxHP: определяет сколько ударов может выдержать враг
  • walkingSpeed: определяет как быстро двигается враг
  • mySprite: хранит визуальной представление врага(спрайт)
  • destinationWaypoint: хранит ссылку на следующий выэйпоинт

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

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

Наконец, метод отрисовки создает полоску здоровья над спрайтом. Вначале он рисует красный фон, затем покрывает его зеленым, согласно нынешнему количеству HP врага.
Класс врагов готов, вы можете показать их на экране!
Откройте HelloWorldLayer.h и добавьте следующее описание метода:

-(void)enemyGotKilled;

Переключитесь на HelloWorldLayer.m и опишите метод:

Код

//Вверху файла:
#import "Enemy.h"
 
//до метода init:
-(BOOL)loadWave {
    NSString* plistPath = [[NSBundle mainBundle] pathForResource:@"Waves" ofType:@"plist"];
    NSArray * waveData = [NSArray arrayWithContentsOfFile:plistPath];
 
    if(wave >= [waveData count])
    {
        return NO;
    }
 
    NSArray * currentWaveData =[NSArray arrayWithArray:[waveData objectAtIndex:wave]];
 
    for(NSDictionary * enemyData in currentWaveData)
    {
        Enemy * enemy = [Enemy nodeWithTheGame:self];
        [enemies addObject:enemy];
        [enemy schedule:@selector(doActivate) interval:[[enemyData objectForKey:@"spawnTime"]floatValue]];
    }
 
    wave++;
    [ui_wave_lbl setString:[NSString stringWithFormat:@"WAVE: %d",wave]];
 
    return YES;
 
}
 
-(void)enemyGotKilled {
    if ([enemies count]<=0) //If there are no more enemies.
    {
        if(![self loadWave])
        {
            NSLog(@"You win!");
            [[CCDirector sharedDirector] replaceScene:[CCTransitionSplitCols transitionWithDuration:1 scene:[HelloWorldLayer scene]]];
        }
    }
}
 
// вконце метода init:
// 5 - добавляем врагов
enemies = [[NSMutableArray alloc] init];
[self loadWave];
// 6 - создаем надпись про номер волны
ui_wave_lbl = [CCLabelBMFont labelWithString:[NSString stringWithFormat:@"WAVE: %d",wave] fntFile:@"font_red_14.fnt"];
[self addChild:ui_wave_lbl z:10];
[ui_wave_lbl setPosition:ccp(400,wins.height-12)];
[ui_wave_lbl setAnchorPoint:ccp(0,0.5)];

Все выше написанное достойно объяснения. Самая важная часть- метод LoadWave; он считывает данные из Waves.plist.
Взгляните на Waves.plist, и вы заметите, что он содержит 3 массива. Каждый из них представляет собой волну, которая является группой врагов, появляющихся вместе. Первый массив содержит шесть словарей. Каждый словарь представляет собой врага. В этом уроке, словарь хранит только время появления врага, но может содержать в себе тип врага, его способности, здоровье и т.д.
Метод loadWave проверяет, должна ли появиться следующая волна, создает соответствующих врагов, основываясь на информации о волне, и расписывает их время появления соответственно.
Метод enemyGotKilled проверяет количество врагов на экране, если их нет, вызывает следующую волну. Позже этот же метод будет использован для проверки победы игрока.
Скомпилируйте и запустите игру сейчас. Враги двигаются к вашей базе!

image

Атака башен

Башни на месте? Проверьте. Враги двигаются? Перепроверьте! Пришло время завалить этих наглецов.
Каждая башня проверяет есть ли враг внутри радиуса ее поражения. Если да, башня начинает стрелять по нему, пока не произойдет одно из двух: враг выйдет за пределы досягаемости, или враг будет уничтожен. Тогда башня начнет искать новую жертву.
Начнем защищать базу!
Откройте Tower.h и внесите следующие изменения:

Код

// добавьте несколько переменных
BOOL attacking;
Enemy *chosenEnemy;
 
// добавьте описание методов
-(void)targetKilled;

Теперь в Tower.m:

// импортируйте хэдер Enemy класса
#import "Enemy.h"
 
// Добавьте  следующие методы над init:
-(void)attackEnemy
{
    [self schedule:@selector(shootWeapon) interval:fireRate];
}
 
-(void)chosenEnemyForAttack:(Enemy *)enemy
{
    chosenEnemy = nil;
    chosenEnemy = enemy;
    [self attackEnemy];
    [enemy getAttacked:self];
}
 
-(void)shootWeapon
{
    CCSprite * bullet = [CCSprite spriteWithFile:@"bullet.png"];
    [theGame addChild:bullet];
    [bullet setPosition:mySprite.position];
    [bullet runAction:[CCSequence actions:[CCMoveTo actionWithDuration:0.1 position:chosenEnemy.mySprite.position],[CCCallFunc actionWithTarget:self selector:@selector(damageEnemy)],[CCCallFuncN actionWithTarget:self selector:@selector(removeBullet:)], nil]];
 
 
}
 
-(void)removeBullet:(CCSprite *)bullet
{
    [bullet.parent removeChild:bullet cleanup:YES];
}
 
-(void)damageEnemy
{
    [chosenEnemy getDamaged:damage];
}
 
-(void)targetKilled
{
    if(chosenEnemy)
        chosenEnemy =nil;
 
    [self unschedule:@selector(shootWeapon)];
}
 
-(void)lostSightOfEnemy
{
    [chosenEnemy gotLostSight:self];
    if(chosenEnemy)
        chosenEnemy =nil; 
 
    [self unschedule:@selector(shootWeapon)];
}

Наконец, замените пустой метод update на:

Код

-(void)update:(ccTime)dt {
    if (chosenEnemy){
 
        //Мы заставляем ее повернуться к выбранному врагу
        CGPoint normalized = ccpNormalize(ccp(chosenEnemy.mySprite.position.x-mySprite.position.x,chosenEnemy.mySprite.position.y-mySprite.position.y));
        mySprite.rotation = CC_RADIANS_TO_DEGREES(atan2(normalized.y,-normalized.x))+90;
 
        if(![theGame circle:mySprite.position withRadius:attackRange collisionWithCircle:chosenEnemy.mySprite.position collisionCircleRadius:1])
        {
            [self lostSightOfEnemy];
        }
    } else {
        for(Enemy * enemy in theGame.enemies)
        {
            if([theGame circle:mySprite.position withRadius:attackRange collisionWithCircle:enemy.mySprite.position collisionCircleRadius:1])
            {
                [self chosenEnemyForAttack:enemy];
                break;
            }
        }
    }
}

Кода много- это так. Плюс, вы наверно заметили увеличивающиеся количество предупреждений от компилятора по мере добавления кода. Во-первых, разберемся с предупреждениями, добавив несколько недостающих бит.
Откройте Enemy.h и внесите следующие изменения:

Код

//добавьте переменную
NSMutableArray *attackedBy;
 
// добавьте описание методов
-(void)getAttacked:(Tower *)attacker;
-(void)gotLostSight:(Tower *)attacker;
-(void)getDamaged:(int)damage;

Теперь в Enemy.m:

Код

//Добавьте следующее в начало метода initWithTheGame: (внутри условия "if")
attackedBy = [[NSMutableArray alloc] initWithCapacity:5];
 
// Замените содержимое  getRemoved:
-(void)getRemoved
{
    for(Tower * attacker in attackedBy)
    {
        [attacker targetKilled];
    }
 
    [self.parent removeChild:self cleanup:YES];
    [theGame.enemies removeObject:self];
 
    //Сообщите игре, что мы убили врага и можно проверить, нужно ли выслать новую волну
    [theGame enemyGotKilled];
}
 
// Добавьте следующие методы в конец файла
-(void)getAttacked:(Tower *)attacker
{
    [attackedBy addObject:attacker];
}
 
-(void)gotLostSight:(Tower *)attacker
{
    [attackedBy removeObject:attacker];
}
 
-(void)getDamaged:(int)damage
{
    currentHp -=damage;
    if(currentHp <=0)
    {
        [self getRemoved];
    }
}

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

image

Осталось добавить всего несколько деталей, чтобы получить готовую версию tower defense. Неплохо бы добавить звуковые эффекты, научить базу получать урон от врагов и ограничить количество золота игрока.

Финальные штрихи

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

Код

int playerHp;
CCLabelBMFont *ui_hp_lbl;
BOOL gameEnded;

playerHp показывает сколько жизней у игрока, а CCLabelBMFont это надпись, которая демонстрирует это количество. gameEnded задается, когда игра закончена! Также добавьте следующие описания методов:

-(void)getHpDamage;
-(void)doGameOver;

Теперь откройте HelloWorldLayer.m и внесите следующие изменения:

// В конце метода init:
// 7 - жизни игрока
playerHp = 5;
ui_hp_lbl = [CCLabelBMFont labelWithString:[NSString stringWithFormat:@"HP: %d",playerHp] fntFile:@"font_red_14.fnt"];
[self addChild:ui_hp_lbl z:10];
[ui_hp_lbl setPosition:ccp(35,wins.height-12)];
 
// Добавьте следующие методы в конец файла:
-(void)getHpDamage {
    playerHp--;
    [ui_hp_lbl setString:[NSString stringWithFormat:@"HP: %d",playerHp]];
    if (playerHp <=0) {
        [self doGameOver];
    }
}
 
-(void)doGameOver {
    if (!gameEnded) {
        gameEnded = YES;
        [[CCDirector sharedDirector] replaceScene:[CCTransitionRotoZoom transitionWithDuration:1 scene:[HelloWorldLayer scene]]];
    }
}

Это добавляет метод, который уменьшает количество жизней игрока, обновляет надпись и проверяет, закончились ли жизни у игрока. Если да, игре конец!
Метод getHpDamage вызывается, когда враг достигает базы. Вы ранее уже добавили его в update метод в Enemy.m.
Скомпилируйте и запустите игру. Позвольте врагам дойти до вашей базы. Вы должны увидеть, как количество ваших жизней уменьшается, пока игра не оканчивается.

image

Пришло время урезать бюджет!
Множество игр внедряют опцию “zero-sum”, предоставляя игроку ограниченное количество ресурсов на постройку в начале игры. Ваша игра будет иметь нечто похожее, но в упрощенном виде.
Откройте HelloWorldLayer.h и добавьте следующие переменные:

int playerGold;
CCLabelBMFont *ui_gold_lbl;

Также как с жизнями, была добавлена переменная количества золота(playerGold) и надпись для его отображения(ui_gold_lbl). Также добавьте описание нового метода:

-(void)awardGold:(int)gold;

Теперь откройте HelloWorldLayer.m и сделайте следующее:

Код

//Добавьте метод перед init:
-(void)awardGold:(int)gold {
    playerGold += gold;
    [ui_gold_lbl setString:[NSString stringWithFormat:@"GOLD: %d",playerGold]];
}
 
// В конце init:
// 8 - Золото
playerGold = 1000;        
ui_gold_lbl = [CCLabelBMFont labelWithString:[NSString stringWithFormat:@"GOLD: %d",playerGold] fntFile:@"font_red_14.fnt"];
[self addChild:ui_gold_lbl z:10];
[ui_gold_lbl setPosition:ccp(135,wins.height-12)];
[ui_gold_lbl setAnchorPoint:ccp(0,0.5)];
 
//Замените метод canBuyTower:
-(BOOL)canBuyTower {
    if (playerGold - kTOWER_COST >=0)
        return YES;
    return NO;
}
 
// Внутри ccTouchesBegan, добавьте следующие строки внутрь условия "if", там где вы писали, что золото будет потрачено позже:
playerGold -= kTOWER_COST;
[ui_gold_lbl setString:[NSString stringWithFormat:@"GOLD: %d",playerGold]];

Новый код вверху проверяет, хватает ли золота каждый раз, как игрок строит башню. Если хватает, башня строится, цена башни вычитается из количества золота игрока. Нужно также награждать игрока за убийство врагов.
Добавьте следующую строчку в метод getDamaged:(внутри условия “if”) в Enemy.m:

[theGame awardGold:200];

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

image

Наконец добавим немного музыки!
Откройте HelloWorldLayer.m:

Код

//В верхушке файла:
#import "SimpleAudioEngine.h"
 
//В начале метода init: (внутри условия 'if')
[[SimpleAudioEngine sharedEngine] playBackgroundMusic:@"8bitDungeonLevel.mp3" loop:YES];
 
//Внутри метода ccTouchesBegan, перед созданием нового объекта Tower:
[[SimpleAudioEngine sharedEngine] playEffect:@"tower_place.wav"];
 
//В начале getHpDamage
[[SimpleAudioEngine sharedEngine] playEffect:@"life_lose.wav"];

Теперь в Enemy.m:

Код

//Вверху файла:
#import "SimpleAudioEngine.h"
 
//В начале метода getDamaged:
[[SimpleAudioEngine sharedEngine] playEffect:@"laser_shoot.wav"];

Все! Вы закончили! Запустите игру, как вам ретро звуки?!
Сэмпл проект со всем что мы сделали выше: TowerDefenseFinished.zip

[Первая и вторая части туториала]

  • Размещаем на поле башни.
  • Целимся во врагов при помощи физики.
  • Отслеживаем их, пока это возможно.
  • Стреляем в них лазерным лучом.

Это третья часть серии туториалов о создании простой игры жанра tower defense. В ней рассмотрено создание башен, прицеливание и стрельба во врагов.

Туториал создавался в Unity 2018.3.0f2.

Зададим врагам жару.

Создание башни

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

Содержимое тайла

Башни — это ещё один тип содержимого тайла, поэтому добавим для них запись в GameTileContent.

public enum GameTileContentType {
	Empty, Destination, Wall, SpawnPoint, Tower€
}

В этом туториале мы будем поддерживать только один тип башен, что можно реализовать, предоставив GameTileContentFactory одну ссылку на префаб башни, экземпляр которого также можно будет создавать через Get.

	[SerializeField]
	GameTileContent towerPrefab = default;

	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			…
			case GameTileContentType.Tower€: return Get(towerPrefab);
		}
		…
	}

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

using UnityEngine;

public class Tower : GameTileContent {}

Можно сделать так, чтобы префаб башни имел собственный компонент, изменив тип поля фабрики на Tower. Так как класс по-прежнему считается GameTileContent, больше ничего менять не нужно.

	Tower towerPrefab = default;

Префаб

Создадим префаб для башни. Можно начать с дублирования префаба стены и замены его компонента GameTileContent на компонент Tower, а после изменить его тип на Tower. Чтобы башня соответствовала по размеру стенам, сохраните куб стены как основание башни. Затем поместите ещё один куб поверх него. Я задал ему масштаб 0.5. Поставьте на него ещё один куб, обозначающий турель, эта часть будет целиться и стрелять во врагов.

Три куба, образующих башню.

Турель будет поворачиваться, и поскольку она имеет коллайдер, её будет отслеживать физический движок. Но нам не нужно быть такими точными, потому что мы используем коллайдеры башен только для выбора ячеек. Это вполне можно делать приблизительно. Удалите коллайдер куба турели и измените коллайдер куба башни, чтобы он покрывал оба куба.

Коллайдер куба башни.

Башня будет стрелять лазерным лучом. Его можно визуализировать разными способами, но мы просто используем полупрозрачный куб, который растянем для образования луча. У каждой башни должен быть собственный луч, поэтому добавим его к префабу башни. Расположим ег внутри турели, чтобы по умолчанию он был скрыт, и придадим ему меньший масштаб, например 0.2. Сделаем его дочерним элементом корня префаба, а не куба турели.

laser beam

hierarchy

Скрытый куб лазерного луча.

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

color

no reflections

Материал лазерного луча.

Проверьте, чтобы у лазерного луча не было коллайдера, а также отключите у него отбрасывание и получение теней.

Лазерный луч не взаимодействует с тенями.

Завершив создание префаба башни, добавим его в фабрику.

Фабрика с башней.

Размещение башен

Добавлять и убирать башни мы будем при помощи ещё одного метода переключения. Можно просто продублировать GameBoard.ToggleWall, изменив название метода и тип содержимого.

	public void ToggleTower (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Tower€) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower€);
			if (!FindPaths()) {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
		}
	}

В Game.HandleTouch при зажатии клавиши shift переключаться будут не стены, а башни.

	void HandleTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			if (Input.GetKey(KeyCode.LeftShift)) {
				board.ToggleTower(tile);
			}
			else {
				board.ToggleWall(tile);
			}
		}
	}

Башни на поле.

Блокирование пути

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

	public bool BlocksPath =>
		Type == GameTileContentType.Wall || Type == GameTileContentType.Tower€;

Используем это свойство в GameTile.GrowPathTo вместо проверки типа содержимого.

	GameTile GrowPathTo (GameTile neighbor, Direction direction) {
		…
		return
			//neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
			neighbor.Content.BlocksPath ? null : neighbor;
	}

Теперь путь блокируют и стены, и башни.

Заменяем стены

Скорее всего, игрок будет часто заменять стены башнями. Ему будет неудобно сначала убирать стену, и к тому же в этот временно появившийся промежуток смогут проникать враги. Можно реализовать прямую замену, заставив GameBoard.ToggleTower проверять, находится ли в данный момент на тайле стена. Если да, то нужно сразу заменить её на башню. В таком случае нам не придётся искать другие пути, потому что тайл по-прежнему их блокирует.

	public void ToggleTower (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Tower) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			…
		}
		else if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower);
		}
	}

Целимся во врагов

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

Точка прицеливания

Для обнаружения целей мы будем использовать физический движок. Как и в случае с коллайдером башни, нам не нужно, чтобы коллайдер врага обязательно совпадал с его формой. Можно выбрать простейший коллайдер, то есть сферу. После обнаружения врага мы будем использовать позицию игрового объекта с присоединённым к нему коллайдером как точку для прицеливания.

Мы не можем прикрепить коллайдер к корневому объекту врага, потому что он не всегда совпадает с позицией модели и заставит башню целиться в землю. То есть нужно разместить коллайдер где-то на модели. Физический движок даст нам ссылку на этот объект, которую мы сможем использовать для прицеливания, но нам ещё нужен будет и доступ к компоненту Enemy корневого объекта. Чтобы упростить задачу, давайте создадим компонент TargetPoint. Дадим ему свойство для приватного задания и публичного получения компонента Enemy, и ещё одно свойство для получения его позиции в мире.

using UnityEngine;

public class TargetPoint : MonoBehaviour {

	public Enemy Enemy€ { get; private set; }

	public Vector3 Position => transform.position;
}

Дадим ему метод Awake, задающий ссылку на его компонент Enemy. Перейти непосредственно к корневому объекту можно при помощи transform.root. Если компонент Enemy не существует, то тогда мы совершили ошибку при создании врага, поэтому давайте добавим для этого утверждение.

	void Awake () {
		Enemy€ = transform.root.GetComponent<Enemy>();
		Debug.Assert(Enemy€ != null, "Target point without Enemy root!", this);
	}

Кроме того, коллайдер должен быть прикреплён к тому же игровому объекту, к которому прикреплён TargetPoint.

		Debug.Assert(Enemy€ != null, "Target point without Enemy root!", this);
		Debug.Assert(
			GetComponent<SphereCollider>() != null,
			"Target point without sphere collider!", this
		);

Добавим к кубу префаба врага компонент и коллайдер. Это заставит башни целиться в центр куба. Используем сферический коллайдер с радиусом 0.25. Куб имеет масштаб 0.5, поэтому истинный радиус коллайдера будет равен 0.125. Благодаря этому враг должен будет визуально пересечь круг дальности башни, и только спустя какое-то время становиться настоящей цель. На размер коллайдера также влияет случайный масштаб врага, поэтому его размер в игре тоже будет немного варьироваться.

inspector

Враг с точкой для прицеливания и коллайдером на кубе.

Слой врагов

Башни волнуют только враги, и они не целятся ни во что другое, поэтому мы поместим всех врагов в отдельный слой. Воспользуемся слоем 9. Измените его название на Enemy в окне Layers & Tags, которое можно открыть через опцию Edit Layers в раскрывающемся меню Layers в правом верхнем углу редактора.

Слой 9 будет использоваться для врагов.

Этот слой нужен только для распознавания врагов, а не для физических взаимодействий. Давайте укажем на это, отключив их в Layer Collision Matrix, которая находится в панели Physics параметров проекта.

Матрица коллизий слоёв.

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

Враг на нужном слое.

Давайте добавим утверждение о том, что TargetPoint действительно находится на нужном слое.

	void Awake () {
		…
		Debug.Assert(gameObject.layer == 9, "Target point on wrong layer!", this);
	}

Кроме того, действия игрока должны игнорировать вражеские коллайдеры. Это можно реализовать, добавив аргумент маски слоя к Physics.Raycast в GameBoard.GetTile. У этого метода есть форма, получающая в качестве дополнительных аргументов расстояние до луча и маску слоя. Передадим ему максимальное расстояние и маску слоя по умолчанию, то есть 1.

	public GameTile GetTile (Ray ray) {
		if (Physics.Raycast(ray, out RaycastHit hit, float.MaxValue, 1)) {
			…
		}
		return null;
	}

Разве маска слоя не должна равняться 0?

Индекс слоя по умолчанию равен нулю, но мы передаём маску слоя. Маска меняет отдельные биты целого числа на 1, если слой нужно включить. В данном случае нужно задать только первый бит, то есть самый младший, а значит, 20, что равняется 1.

Обновление содержимого тайлов

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

	public virtual void GameUpdate () {}

Заставим Tower переопределить его, пусть пока просто выводит в консоль, что ищет цель.

	public override void GameUpdate () {
		Debug.Log("Searching for target...");
	}

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

	List<GameTileContent> updatingContent = new List<GameTileContent>();
	
	…
	
	public void GameUpdate () {
		for (int i = 0; i < updatingContent.Count; i++) {
			updatingContent[i].GameUpdate();
		}
	}

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

	public void ToggleTower (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Tower) {
			updatingContent.Remove(tile.Content);
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower);
			//if (!FindPaths()) {
			if (FindPaths()) {
				updatingContent.Add(tile.Content);
			}
			else {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
		}
		else if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(GameTileContentType.Tower);
			updatingContent.Add(tile.Content);
		}
	}

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

	void Update () {
		…
		enemies.GameUpdate();
		board.GameUpdate();
	}

Дальность прицеливания

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

	[SerializeField, Range(1.5f, 10.5f)]
	float targetingRange = 1.5f;

Дальность прицеливания 2.5.

Давайте визуализируем дальность при помощи гизмо. Нам не нужно видеть её постоянно, поэтому создадим метод OnDrawGizmosSelected, вызываемый только для выбранных объектов. Отрисуем жёлтый каркас сферы с радиусом, равным дальности и центрированный относительно башни. Расположим его немного выше земли, чтобы его всегда было чётко видно.

	void OnDrawGizmosSelected () {
		Gizmos.color = Color.yellow;
		Vector3 position = transform.localPosition;
		position.y += 0.01f;
		Gizmos.DrawWireSphere(position, targetingRange);
	}

Гизмо дальности прицеливания.

Теперь мы можем видеть, кто из врагов является доступной целью для каждой из башен. Но выбирать башни в окне сцены неудобно, потому что нам приходится выбирать один из дочерних кубов, а затем переключаться на корневой объект башни. Другие типы содержимого тайлов тоже страдают от той же проблемы. Мы можем принудительно выбирать в окне сцены корень содержания тайла, добавив в GameTileContent атрибут SelectionBase.

[SelectionBase]
public class GameTileContent : MonoBehaviour { … }

Захват цели

Добавим классу Tower поле TargetPoint, чтобы он мог отслеживать свою захваченную цель. Затем изменим GameUpdate, чтобы он вызывал новый метод AquireTarget, возвращающий информацию о том, нашёл ли он цель. При обнаружении он будет выводить сообщение в консоль.

	TargetPoint target;

	public override void GameUpdate () {
		if (AcquireTarget()) {
			Debug.Log("Acquired target!");
		}
	}

В AcquireTarget получим все доступные цели, вызвав Physics.OverlapSphere с позицией башни и дальностью в качестве аргументов. Результатом будет массив Collider, содержащий все коллайдеры, находящиеся в контакте со сферой. Если длина массива положительна, то существует хотя бы одна точка прицеливания, и мы просто выбираем первую. Возьмём её компонент TargetPoint, который должен всегда существовать, присвоим его полю target и сообщим об успехе. В противном случае очистим target и сообщим о неудаче.

	bool AcquireTarget () {
		Collider[] targets = Physics.OverlapSphere(
			transform.localPosition, targetingRange
		);
		if (targets.Length > 0) {
			target = targets[0].GetComponent<TargetPoint>();
			Debug.Assert(target != null, "Targeted non-enemy!", targets[0]);
			return true;
		}
		target = null;
		return false;
	}

Мы гарантированно получим правильные точки прицеливания, если будем учитывать коллайдеры только на слое врагов. Это слой 9, поэтому передадим соответствующую маску слоя.

	const int enemyLayerMask = 1 << 9;

	…

	bool AcquireTarget () {
		Collider[] targets = Physics.OverlapSphere(
			transform.localPosition, targetingRange, enemyLayerMask
		);
		…
	}

Как работает эта битовая маска?

Так как слой врагов имеет индекс 9, десятый бит битовой маски должен иметь значение 1. Этому соответствует целое число 29, то есть 512. Но такая запись битовой маски неинтуитивна. Мы можем также записать двоичный литерал, например 0b10_0000_0000, но тогда нам придётся считать нули. В данном случае наиболее удобной записью будет использование оператора сдвига влево <<, сдвигающего биты влево. что соответствует числу в степени двойки.

Можно визуализировать захваченную цель, отрисовав линию-гизмо между позициями башни и цели.

	void OnDrawGizmosSelected () {
		…
		if (target != null) {
			Gizmos.DrawLine(position, target.Position);
		}
	}

Визуализация целей.

Почему бы не использовать методы наподобие OnTriggerEnter?

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

Фиксация на цели

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

	bool TrackTarget () {
		if (target == null) {
			return false;
		}
		return true;
	}

Вызовем этот метод в GameUpdate и только при возврате false будем вызывать AcquireTarget. Если метод вернул true, то у нас есть цель. Это можно сделать, поместив оба вызова методов в проверку if с оператором OR, потому что если первый операнд вернёт true, то второй не будет проверяться, и вызов будет пропущен. Оператор AND действует схожим образом.

	public override void GameUpdate () {
		if (TrackTarget() || AcquireTarget()) {
			Debug.Log("Locked on target!");
		}
	}

Отслеживание целей.

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

Чтобы отслеживать цели только когда они находятся в пределах дальности, TrackTarget должен отслеживать расстояние между башней и целью. Если она превысит величину дальности, то цель нужно сбрасывать и возвращать false. Для этой проверки можно использовать метод Vector3.Distance.

	bool TrackTarget () {
		if (target == null) {
			return false;
		}
		Vector3 a = transform.localPosition;
		Vector3 b = target.Position;
		if (Vector3.Distance(a, b) > targetingRange) {
			target = null;
			return false;
		}
		return true;
	}

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

		if (Vector3.Distance(a, b) > targetingRange + 0.125f) { … }

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

	public float Scale { get; private set; }

	…

	public void Initialize (float scale, float speed, float pathOffset) {
		Scale = scale;
		…
	}

Теперь мы можем проверять в Tower.TrackTarget правильную дальность.

		if (Vector3.Distance(a, b) > targetingRange + 0.125f * target.Enemy€.Scale) { … }

Синхронизируем физику

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

Неправильное прицеливание.

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

Можно включить мгновенную синхронизацию, выполняемую при изменении преобразований объекта, присвоив Physics.autoSyncTransforms значение true. Но по умолчанию оно отключено, потому что гораздо эффективнее синхронизировать всё вместе и при необходимости. В нашем случае синхронизация требуется только при обновлении состояния башен. Мы можем выполнять её, вызывая Physics.SyncTransforms между обновлениями врагов и поля в Game.Update.

	void Update () {
		…
		enemies.GameUpdate();
		Physics.SyncTransforms();
		board.GameUpdate();
	}

Игнорируем высоту

По сути, наш игровой процесс происходит в 2D. Поэтому давайте изменим Tower так, чтобы при прицеливании и отслеживании он учитывал только координаты X и Z. Физический движок работает в 3D-пространстве, но по сути мы можем выполнять проверку AcquireTarget в 2D: растянем сферу вверх, чтобы она покрывала все коллайдеры, вне зависимости от их позиции по вертикали. Это можно сделать, использовав вместо сферы капсулу, вторая точка которой будет в нескольких единицах над землёй (допустим, в трёх).

	bool AcquireTarget () {
		Vector3 a = transform.localPosition;
		Vector3 b = a;
		b.y += 3f;
		Collider[] targets = Physics.OverlapCapsule(
			a, b, targetingRange, enemyLayerMask
		);
		…
	}

Разве нельзя использовать физический 2D-движок?

Проблема в том, что наша игра проходит в плоскости XZ, а физический 2D-движок работает в плоскости XY. Мы можем заставить его работать, или изменив ориентацию всей игры, или создав отдельное 2D-представление только для физики. Но легче просто использовать 3D-физику.

Нужно также изменить TrackTarget. Конечно, мы можем использовать 2D-векторы и Vector2.Distance, но давайте проведём вычисления самостоятельно и вместо этого будем сравнивать квадраты расстояний, этого будет достаточно. Так мы избавимся от операции вычисления квадратного корня.

	bool TrackTarget () {
		if (target == null) {
			return false;
		}
		Vector3 a = transform.localPosition;
		Vector3 b = target.Position;
		float x = a.x - b.x;
		float z = a.z - b.z;
		float r = targetingRange + 0.125f * target.Enemy€.Scale;
		if (x * x + z * z > r * r) {
			target = null;
			return false;
		}
		return true;
	}

Как работают эти математические вычисления?

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

Избегаем выделения памяти

Недостаток использования Physics.OverlapCapsule заключается в том, что на каждый вызов он выделяет новый массив. Этого можно избежать, выделив массив один раз и вызывая альтернативный метод OverlapCapsuleNonAlloc с массивом в качестве дополнительного аргумента. Длина передаваемого массива определяет количество получаемых результатов. Все потенциальные цели за пределами массива отбрасываются. Мы всё равно будем использовать только первый элемент, поэтому нам хватит массива длиной 1.

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

	static Collider[] targetsBuffer = new Collider[1];

	…

	bool AcquireTarget () {
		Vector3 a = transform.localPosition;
		Vector3 b = a;
		b.y += 2f;
		int hits = Physics.OverlapCapsuleNonAlloc(
			a, b, targetingRange, targetsBuffer, enemyLayerMask
		);
		if (hits > 0) {
			target = targetsBuffer[0].GetComponent<TargetPoint>();
			Debug.Assert(target != null, "Targeted non-enemy!", targetsBuffer[0]);
			return true;
		}
		target = null;
		return false;
	}

Стреляем во врагов

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

Прицеливаемся турелью

Чтобы направить турель на цель, классу Tower нужно иметь ссылку на компонент Transform турели. Добавим для этого поле конфигурации и подключим его в префабе башни.

	[SerializeField]
	Transform turret = default;

Присоединённая турель.

Если в GameUpdate есть настоящая цель, то мы должны стрелять в неё. Поместим код стрельбы в отдельный метод. Заставим его вращать турель в сторону цели, вызывая его метод Transform.LookAt с точкой прицеливания в качестве аргумента.

	public override void GameUpdate () {
		if (TrackTarget() || AcquireTarget()) {
			//Debug.Log("Locked on target!");
			Shoot();
		}
	}

	void Shoot () {
		Vector3 point = target.Position;
		turret.LookAt(point);
	}

Просто целимся.

Стреляем лазером

Для позиционирования лазерного луча классу Tower тоже нужна ссылка на него.

	[SerializeField]
	Transform turret = default, laserBeam = default;

Подключили лазерный луч.

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

	void Shoot () {
		Vector3 point = target.Position;
		turret.LookAt(point);
		laserBeam.localRotation = turret.localRotation;
	}

Во-вторых мы отмасштабируем лазерный луч так, чтобы его длина была равна расстоянию между локальной точкой начала координат турели и точкой прицеливания. Мы масштабируем его по оси Z, то есть локальной оси, направленной в сторону цели. Чтобы сохранить исходный масштаб по XY, запишем исходный масштаб при пробуждении (Awake) турели.

	Vector3 laserBeamScale;

	void Awake () {
		laserBeamScale = laserBeam.localScale;
	}

	…

	void Shoot () {
		Vector3 point = target.Position;
		turret.LookAt(point);
		laserBeam.localRotation = turret.localRotation;

		float d = Vector3.Distance(turret.position, point);
		laserBeamScale.z = d;
		laserBeam.localScale = laserBeamScale;
	}

В-третьих, расположим лазерный луч посередине между турелью и точкой прицеливания.

		laserBeam.localScale = laserBeamScale;
		laserBeam.localPosition =
			turret.localPosition + 0.5f * d * laserBeam.forward;

Стрельба лазерными лучами.

Разве нельзя сделать лазерный луч дочерним элементом турели?

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

Это работает, пока турель фиксирована на цели. Но когда цели нет, лазер остаётся активным. Мы можем отключить отображение лазера, присвоив его масштабу в GameUpdate значение 0.

	public override void GameUpdate () {
		if (TrackTarget() || AcquireTarget()) {
			Shoot();
		}
		else {
			laserBeam.localScale = Vector3.zero;
		}
	}

Простаивающие башни не стреляют.

Здоровье врагов

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

	float Health { get; set; }
	
	…

	public void Initialize (float scale, float speed, float pathOffset) {
		…
		Health = 100f * scale;
	}

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

	public void ApplyDamage (float damage) {
		Debug.Assert(damage >= 0f, "Negative damage applied.");
		Health -= damage;
	}

Мы не будем мгновенно избавляться от врага, как только его здоровье достигнет нуля. Проверку исчерпания здоровья и уничтожение врага будет выполняться в начале GameUpdate.

	public bool GameUpdate () {
		if (Health <= 0f) {
			OriginFactory.Reclaim(this);
			return false;
		}

		…
	}

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

Урон в секунду

Теперь нам нужно определить, сколько урона будет наносить лазер. Для этого добавим к Tower поле конфигурации. Так как лазерный луч наносит непрерывный урон, мы выразим его как урон в секунду (damage per second). В Shoot приложим его к компоненту Enemy цели с умножением на дельту времени.

	[SerializeField, Range(1f, 100f)]
	float damagePerSecond = 10f;

	…
	
	void Shoot () {
		…

		target.Enemy.ApplyDamage(damagePerSecond * Time.deltaTime);
	}

inspector

Урон каждой башни — 20 единиц в секунду.

Прицеливание случайным образом

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

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

	static Collider[] targetsBuffer = new Collider[100];

Теперь вместо выбора первой потенциальной цели мы будем выбирать из массива случайный элемент.

	bool AcquireTarget () {
		…
		if (hits > 0) {
			target =
				targetsBuffer[Random.Range(0, hits)].GetComponent<TargetPoint>();
			…
		}
		target = null;
		return false;
	}

Случайное прицеливание.

Можно ли использовать и другие критерии выбора целей?

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

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

Привет, разработчики Flash! В этой серии уроков мы рассмотрим процесс разработки очень простой игры Tower Defense. В этой первой части серии мы узнаем, как разместить турели на игровом поле, дать им возможность нацеливаться на объект (в данном случае на мышь) и заставлять их стрелять частицами.


Окончательный результат предварительного просмотра

Как только мы закончим этот урок, у нас будет следующее:

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


Шаг 1: Что такое игра Tower Defense?

Определение Википедии хорошо подводит итог:

Цель игр защиты башни – попытаться остановить врагов от пересечения карты, строя башни, которые стреляют в них, когда они проходят.

По сути, это то, что мы будем развивать в этой серии уроков. Помните, что в этом уроке мы называем башни башенками.

Cursed Treasure – отличный пример TD игры, если вы все еще не уверены.


Шаг 2: Настройка – IDE

Прежде чем приступить к разработке игры, нам нужно настроить проект в нашей IDE. Я буду использовать FlashDevelop здесь. Если вы хотите прочитать о том, как настроить проект в Flash Develop, прочитайте Шаги 1 и 2 этого руководства или это полное руководство по FlashDevelop .

Теперь у вас должен быть главный класс Main.as со следующим кодом:

01

02

03

04

05

06

07

08

09

10

11

package

{

    import flash.display.Sprite;

    public class Main extends Sprite

    {

        public function Main():void

        {

        }

    }

}


Шаг 3: Понимание элементов игры

Для этой части наша игра будет иметь следующие игровые элементы:

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

Все вышеупомянутые элементы будут созданы в Main.as кроме турелей, которые будут отдельным классом турели.

Давайте начнем кодировать сейчас!


Шаг 4: Создание заполнителей

Сначала мы создадим функцию с именем createTurretPlaceholder() которая создаст и вернет нам заполнитель-спрайт. Добавьте следующее в Main класс:

01

02

03

04

05

06

07

08

09

10

11

private function createTurretPlaceholder():Sprite {

    var placeholder:Sprite = new Sprite();

    // draw the graphics

    var g:Graphics = placeholder.graphics;

    g.beginFill(0xCE7822);

    g.drawCircle(0, 0, 20);

    g.endFill();

    return placeholder;

}

Эта функция просто создает placeholder переменной Sprite . Затем, используя API-интерфейс рисования Actionscript, мы создаем графику, которая представляет собой простой круг. Наконец он возвращает этот спрайт.


Шаг 5: Добавление некоторых заполнителей

Теперь мы создадим три заполнителя, используя предыдущую функцию, и разместим их в разных местах поля. Добавьте следующий код в конструктор Main() :

1

var placeholder1:Sprite = createTurretPlaceholder();

В приведенном выше заявлении мы создаем переменную placeholder1 типа Sprite которая получает заполнитель из вышеупомянутой функции createTurretPlaceholder() .

Мы позиционируем заполнитель на поле.

А затем мы добавляем заполнитель на сцену.

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

01

02

03

04

05

06

07

08

09

10

11

12

13

14

public function Main() {

    var placeholder1:Sprite = createTurretPlaceholder();

    placeholder1.x = 200;

    var placeholder2:Sprite = createTurretPlaceholder();

    placeholder2.x = 60;

    var placeholder3:Sprite = createTurretPlaceholder();

    placeholder3.x = 350;

    addChild(placeholder1);

    addChild(placeholder2);

    addChild(placeholder3);

}


Шаг 6: Башенка – Создание класса

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

Перейдите к созданию нового класса Turret , производного от Sprite , в файле с именем Turret.as . Он должен иметь следующий основной код:

01

02

03

04

05

06

07

08

09

10

11

12

13

package

{

    import flash.display.Sprite;

    public class Turret extends Sprite

    {

        public function Turret()

        {

        }

    }

}


Шаг 7: Башенка – Графика

Теперь, когда у нас есть базовая структура класса Turret , следующий шаг – дать башне немного графики. Для этого мы создаем новую функцию draw() в классе. Поэтому поместите следующую функцию чуть ниже конструктора:

1

2

3

4

5

6

7

8

private function draw():void {

    var g:Graphics = this.graphics;

    g.beginFill(0xD7D700);

    g.drawCircle(0, 0, 20);

    g.beginFill(0x800000);

    g.drawRect(0, -5, 25, 10);

    g.endFill();

}

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

1

2

3

4

public function Turret()

{

    draw();

}


Шаг 8: Создание Ghost Turret

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

Для начала нам нужна турель-призрак. Идем дальше и объявляем переменную для него в классе Main .

1

private var ghost_turret:Turret;

Теперь создайте новую турель в конструкторе Main() :

1

ghost_turret = new Turret();

Дайте призрачной башне некоторые свойства, чтобы она выглядела как призрак:

1

2

3

ghost_turret.alpha = 0.5;

ghost_turret.mouseEnabled = false;

ghost_turret.visible = false;

В приведенном выше коде мы уменьшаем непрозрачность башни до половины (0,5) и устанавливаем для свойства mouseEnabled башни значение false чтобы призрачная башня не получала никаких событий мыши. Почему? Мы увидим это позже. И поскольку турель-призрак по умолчанию будет невидима, мы ее скрываем.

Наконец, добавьте турель в список отображения:

Ваш Main конструктор должен выглядеть примерно так:

01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

17

18

19

20

21

public function Main() {

    var placeholder1:Sprite = createTurretPlaceholder();

    placeholder1.x = 200;

    var placeholder2:Sprite = createTurretPlaceholder();

    placeholder2.x = 60;

    var placeholder3:Sprite = createTurretPlaceholder();

    placeholder3.x = 350;

    addChild(placeholder1);

    addChild(placeholder2);

    addChild(placeholder3);

    ghost_turret = new Turret();

    ghost_turret.alpha = 0.5;

    ghost_turret.mouseEnabled = false;

    ghost_turret.visible = false;

    addChild(ghost_turret);

}

Если вы запустите фильм сейчас (Ctrl + Enter), все, что вы увидите, это три заполнителя на сцене. Скучно, а? Давайте добавим немного интерактивности.


Шаг 9: Показ / скрытие башни-призрака

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

01

02

03

04

05

06

07

08

09

10

11

12

13

private function createTurretPlaceholder():Sprite {

    var placeholder:Sprite = new Sprite();

    // draw the graphics

    var g:Graphics = placeholder.graphics;

    g.beginFill(0xCE7822);

    g.drawCircle(0, 0, 20);

    g.endFill();

    placeholder.addEventListener(MouseEvent.MOUSE_OVER, showGhostTurret, false, 0, true);

    placeholder.addEventListener(MouseEvent.MOUSE_OUT, hideGhostTurret, false, 0, true);

    return placeholder;

}

Код присоединяет слушателей к MOUSE_OVER и MOUSE_OUT .

Далее мы определяем две функции-обработчики для одного и того же. Добавьте следующие две функции ниже функции createTurretPlaceholder() :

01

02

03

04

05

06

07

08

09

10

private function showGhostTurret(e:MouseEvent = null):void {

    var target_placeholder:Sprite = e.currentTarget as Sprite;

    ghost_turret.x = target_placeholder.x;

    ghost_turret.y = target_placeholder.y;

    ghost_turret.visible = true;

}

private function hideGhostTurret(e:MouseEvent = null):void {

    ghost_turret.visible = false;

}

hideGhostTurret() просто скрывает турель-призрак, но что происходит в функции showGhostTurret ? Посмотрим.

1

var target_placeholder:Sprite = e.currentTarget as Sprite;

Мы получаем ссылку на местозаполнитель, в котором присутствует мышь, используя MouseEvent currentTarget MouseEvent , типизированное для Sprite .

1

2

3

ghost_turret.x = target_placeholder.x;

ghost_turret.y = target_placeholder.y;

ghost_turret.visible = true;

Это просто … мы помещаем призрачную башню в кординаты заполнителя и делаем ее видимой. Запустите фильм, и вы должны увидеть призрачную башню при наведении курсора на заполнители. Ницца!


Шаг 10: Развертывание турелей

Наша следующая цель – развернуть турель при нажатии на заполнитель. Для этого нам нужен слушатель CLICK на заполнителе. Но перед этим нам нужна переменная Array которая будет содержать все наши турели, поэтому мы можем ссылаться на них в любое время позже. Сделать один в Main классе.

1

private var turrets:Array = [];

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

1

2

3

placeholder.addEventListener(MouseEvent.MOUSE_OVER, showGhostTurret, false, 0, true);

placeholder.addEventListener(MouseEvent.MOUSE_OUT, hideGhostTurret, false, 0, true);

placeholder.addEventListener(MouseEvent.CLICK, addTurret, false, 0, true);

addTurret() функцию-обработчик addTurret() функцией hideGhostTurret() :

1

2

private function addTurret(e:MouseEvent):void {

}

Теперь давайте напишем код для функции. Добавьте следующий код в addTurret() .

1

var target_placeholder:Sprite = e.currentTarget as Sprite;

Сначала мы получаем ссылку на заполнитель, по которому щелкали, как мы делали в функции showGhostTurret() .

1

var turret:Turret = new Turret();

Мы создаем новую башню в переменной с именем turret .

1

2

turret.x = target_placeholder.x;

turret.y = target_placeholder.y;

Далее мы позиционируем turret по координатам target_placeholder .

1

2

addChild(turret);

turrets.push(turret);

Когда турель создана, мы добавляем ее на сцену и помещаем в массив.

Ваша функция addTurret должна выглядеть примерно так:

1

2

3

4

5

6

7

8

private function addTurret(e:MouseEvent):void {

    var target_placeholder:Sprite = e.currentTarget as Sprite;

    var turret:Turret = new Turret();

    turret.x = target_placeholder.x;

    turret.y = target_placeholder.y;

    addChild(turret);

    turrets.push(turret);

}

Здесь нужно отметить одну вещь. Помните, мы установили свойство mouseEnabled башни-призрака в false ? Если бы мы этого не сделали, то призрачная башня между заполнителем и мышью захватила бы событие щелчка, тем самым предотвратив попадание события в заполнитель. В результате прослушиватель CLICK прикрепленный к заполнителю, не будет вызван.

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

Ну, вот и все, что нам нужно для размещения наших турелей. Попробуйте запустить фильм, и вы сможете развернуть турели на заполнителях.


Шаг 11: заставляем турель двигаться

Для этого урока мы заставим турель вращаться лицом к мыши. Мы будем хранить функциональность вращения в отдельном методе класса Turret . Этот метод будет называться update() .

Давайте добавим этот метод в Turret сейчас:

1

2

3

4

public function update():void {

    var angle:Number = Math.atan2(stage.mouseY – this.y, stage.mouseX – this.x) / Math.PI * 180;

    this.rotation = angle;

}

Все, что мы делаем в этой функции – это вычисляем угол наклона мыши от башни и устанавливаем для нее угол наклона башни. (Посмотрите Trigonometry для разработчиков Flash, если вы не уверены, как это работает.)

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


Шаг 12: игровой цикл

Что за игровая петля? Это функция, которая прикрепляется к событию ENTER_FRAME поэтому она ENTER_FRAME в каждом кадре. Он обновляет все элементы в игре … в нашем случае Turrets. Подробнее в этой статье .

Создайте функцию с именем gameLoop() под конструктором Main() которая будет прослушивать событие ENTER_FRAME фильма:

1

2

private function gameLoop(e:Event):void {

}

Теперь, когда мы определили функцию слушателя, нам нужно присоединить ее к соответствующему событию. Мы делаем это в конструкторе Main() . Добавьте следующую строку в функцию Main() :

01

02

03

04

05

06

07

08

09

10

11

12

13

14

15

16

17

18

19

20

21

public function Main() {

    var placeholder1:Sprite = createTurretPlaceholder();

    placeholder1.x = 200;

    var placeholder2:Sprite = createTurretPlaceholder();

    placeholder2.x = 60;

    var placeholder3:Sprite = createTurretPlaceholder();

    placeholder3.x = 350;

    addChild(placeholder1);

    addChild(placeholder2);

    addChild(placeholder3);

    ghost_turret = new Turret();

    ghost_turret.alpha = 0.5;

    ghost_turret.mouseEnabled = false;

    addChild(ghost_turret);

    stage.addEventListener(Event.ENTER_FRAME, gameLoop);

}


Шаг 13: Обновление всех турелей

Давайте поместим некоторый код в нашу gameLoop() .

1

2

for each(var turret:Turret in turrets) {

}

Мы перебираем массив turrets , который имеет ссылки на все турели на сцене, используя for each...in цикле.

1

2

3

for each(var turret:Turret in turrets) {

    turret.update();

}

И вызвать функцию update() каждой башни. И мы сделали. Если вы запустите фильм сейчас, вы сможете развернуть турели, которые всегда обращены к мышке. Что-то вроде этого:


Шаг 14: Застревание турелей

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

  1. Добавьте слушателя щелчка к стадии.
  2. Переберите все турели в вышеупомянутом слушателе.
  3. Рассчитайте угол от точки нажатия до башни.
  4. Создайте новую пулю и переместите ее в соответствующем направлении.

Давайте объявим функцию слушателя по имени shoot Добавьте функцию в класс Main :

1

2

private function shoot(e:MouseEvent):void {

}

А затем присоедините указанный выше слушатель к событию CLICK stage в конструкторе Main() :

1

2

3

stage.addEventListener(MouseEvent.CLICK, shoot);

stage.addEventListener(Event.ENTER_FRAME, gameLoop);

}


Шаг 15: Создание пули

Перед тем, как приступить к написанию кода для стрельбы в функции shoot() , мы определим новую функцию для создания маркера, как мы делали для создания заполнителя. Поэтому поместите следующую функцию ниже createTurretPlaceholder() :

1

2

3

4

5

6

7

8

9

private function createBullet():Sprite {

    var bullet:Sprite = new Sprite();

    // draw the graphics

    var g:Graphics = bullet.graphics;

    g.beginFill(0xEEEEEE);

    g.drawCircle(0, 0, 5);

    g.endFill();

    return bullet;

}

Ничего особенного здесь. Мы просто создаем новый Sprite , рисуем внутри него не совсем белый цветовой круг и возвращаем его. Теперь давайте продолжим определять нашу функцию shoot() .


Шаг 16: Как стрелять?

Пора добавить код в shoot() .

1

2

for each(var turret:Turret in turrets) {

}

Сначала мы перебираем все турели на сцене, используя цикл for each...in .

1

var new_bullet:Sprite = createBullet();

Теперь для каждой башни мы создаем пулю, используя функцию, которую создали ранее.

1

new_bullet.rotation = turret.rotation;

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

1

2

new_bullet.x = turret.x + Math.cos(new_bullet.rotation * Math.PI / 180) * 25;

new_bullet.y = turret.y + Math.sin(new_bullet.rotation * Math.PI / 180) * 25;

Эти две линии устанавливают начальную позицию пули, которая находится на расстоянии 25 пикселей от револьверной головки в направлении к лицу (помните свойство rotation ). Опять же, прочтите тригонометрию, если она вам незнакома.

И как обычный последний шаг, мы добавляем маркер в список отображения сцены.

Вот как должна выглядеть ваша функция shoot() :

1

2

3

4

5

6

7

8

9

private function shoot(e:MouseEvent):void {

    for each(var turret:Turret in turrets) {

        var new_bullet:Sprite = createBullet();

        new_bullet.rotation = turret.rotation;

        new_bullet.x = turret.x + Math.cos(new_bullet.rotation * Math.PI / 180) * 25;

        new_bullet.y = turret.y + Math.sin(new_bullet.rotation * Math.PI / 180) * 25;

        addChild(new_bullet);

    }

}

Если вы запустите свою игру сейчас и нажмете на любой заполнитель, турель будет развернута – но, к сожалению … у нас проблема здесь. Дополнительная пуля также создается с турелью. Давайте исправим это.


Шаг 17: Почему появилась эта лишняя пуля?

Чтобы понять это, нам нужно понять распространение событий в AS3 .

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

У нас есть слушатель CLICK привязанный к сцене и заполнителям. Но заполнители также дети сцены. Поэтому, когда мы щелкаем местозаполнитель, генерируется событие CLICK с целью в качестве заполнителя. Событие распространяется от этапа к заполнителю – фазе захвата . Он достигает заполнителя и его обработчик CLICK , addTurret() , выполняется, и у нас есть турель на сцене. Теперь событие распространяется в обратном направлении – фаза барботирования – и когда оно снова достигает стадии, оно также находит слушателя CLICK , который исполняется. В результате выполняется функция shoot() и на сцену добавляется пуля.

Так вот в чем проблема, но как ее решить? Нам нужно остановить дальнейшее распространение события, когда оно достигнет цели. Это означает, что в addTurret() мы прекращаем распространение события. Итак, добавьте строку в конце addTurret() :

1

2

3

4

5

6

7

8

9

private function addTurret(e:MouseEvent):void {

    var target_placeholder:Sprite = e.currentTarget as Sprite;

    var turret:Turret = new Turret();

    turret.x = target_placeholder.x;

    turret.y = target_placeholder.y;

    addChild(turret);

    turrets.push(turret);

    e.stopPropagation();

}

Строка, которую мы добавили, останавливает распространение события и не достигает стадии. Для более полного понимания структуры событий в Actionscript 3.0 прочитайте пост AS3 101 . Давайте продолжим с игрой.


Шаг 18: заставить пулю двигаться

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

1

2

3

private var ghost_turret:Turret;

private var turrets:Array = [];

private var bullet_speed:uint = 3;

Затем добавьте следующую функцию слушателя в класс Main :

1

2

3

4

5

private function moveBullet(e:Event):void {

    var bullet:Sprite = e.currentTarget as Sprite;

    bullet.x += Math.cos(bullet.rotation * Math.PI / 180) * bullet_speed;

    bullet.y += Math.sin(bullet.rotation * Math.PI / 180) * bullet_speed;

}

Что мы делаем в этом слушателе:

  • Получить ссылку на пулю, чей слушатель был запущен.
  • bullet_speed положение пули на величину bullet_speed в направлении вращения пули.

Наконец, присоедините только что созданный слушатель к событию ENTER_FRAME пули в функции shoot() :

01

02

03

04

05

06

07

08

09

10

private function shoot(e:MouseEvent):void {

    for each(var turret:Turret in turrets) {

        var new_bullet:Sprite = createBullet();

        new_bullet.rotation = turret.rotation;

        new_bullet.x = turret.x + Math.cos(new_bullet.rotation * Math.PI / 180) * 25;

        new_bullet.y = turret.y + Math.sin(new_bullet.rotation * Math.PI / 180) * 25;

        new_bullet.addEventListener(Event.ENTER_FRAME, moveBullet, false, 0, true);

        addChild(new_bullet);

    }

}

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


Шаг 19: Уничтожение пуль

Добавьте следующий код в конец функции moveBullet() :

01

02

03

04

05

06

07

08

09

10

11

private function moveBullet(e:Event):void {

    var bullet:Sprite = e.currentTarget as Sprite;

    bullet.x += Math.cos(bullet.rotation * Math.PI / 180) * bullet_speed;

    bullet.y += Math.sin(bullet.rotation * Math.PI / 180) * bullet_speed;

    if (bullet.x < 0 || bullet.x > stage.stageWidth || bullet.y < 0 || bullet.y > stage.stageHeight) {

        bullet.removeEventListener(Event.ENTER_FRAME, moveBullet);

        bullet.parent.removeChild(bullet);

        bullet = null;

    }

}

Здесь мы проверяем, находится ли пуля за пределами сцены. Если это так, мы удаляем его слушатель ENTER_FRAME и удаляем пулю со сцены. Мы также устанавливаем переменную bullet в значение null чтобы пуля не имела ссылки и была доступна для сборки мусора .


Вывод

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

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

Содержание

  1. Создание Tower Defense на игровом движке Unity 5.
  2. Создание поля.
  3. Добавление Waypoints
  4. Spawner
  5. Противник
  6. Скрипты
  7. Видео

Создание Tower Defense на игровом движке Unity 5.

Начиная с этого урока я буду рассказывать вам, как создать свою игру в стиле Tower Defense на игровом движке Unity 5.

Tower Defense – это жанр игры, в котором игроку необходимо расправится с врагами (ботами), до того как они пересекут определенную точку на карте. Делать это необходимо с помощью строительства башен в определенных местах на карте.

В этом уроке мы подготовим игровую сцену и реализуем движение ботов по определенным точкам (поинтам).

Начнем с подготовки сцены. Создадим новый проект в Unity и добавим несколько новых папок material, resources, scripts и scene. В этих папках мы будем хранить материалы, игровые объекты, скрипты и нашу игровую сцену.

assets

Создание поля.

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

Добавим плоскость на сцену с позициями по нулям и любым материалом, переименуем ее в pole.

pole

Далее, для возможности размещения башен на игровом поле необходимо создать платформу.

Добавим куб на сцену с размерами 1, 0.1, 1 и отключим у него возможность отбрасывания теней (Mesh render — Cast Shadows — off).

place

Переименуем куб в TowerPlace и сохраним его в префаб

Теперь продублируем нашу платформу (ctr+D) так, что бы они покрывали все игровое поле

towerplacescene

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

towerplacescene2

Удалим некоторые объекты для того, чтобы получилась дорожка для движения ботов

towerplacescene3

Добавление Waypoints

Создадим пустой объект на сцене с позициями по нулям и названием WayPoint. Изменим его иконку, что бы он отображался на сцене. Приподнимем его по оси y, ось x и z должны оставаться со значением ноль.

waypoint

Сохраним его в префаб и разместим наши вайпоинты на пустой дорожке, на краях поворота (дублировать CTR+D, перемещать с зажатой клавишей CTR). Перенесем все поинты в пустой объект weapoints.

waypoint2

Spawner

Создадим куб, который будет создавать ботов на сцене. Ставим его в начало пути ботов и убираем у него box collider, назовем его Start

Продублируем этот куб и разместим его в конце пути ботов (можно применить на кубы разные материалы), назовем его End.

startend

Противник

Создайте сферу на сцене и примените к ней любой материал. Добавьте компонент rigidbody. Переместите сферу в префаб и удалите со сцены. Это будет наш противник, который будет двигаться по сцене и которого должна будет уничтожить башня.

enemy

Скрипты

На этом подготовка сцены закончена, переходим к скриптам.

В папке Scripts создайте новый c# скрипт spawner и переместите его на объект Start. Откройте скрипт и напишите такой код:

using UnityEngine;

using System.Collections;

 
public class spawner : MonoBehaviour

{
 public Transform EnemyOrefab;//образец объекта для клонирования
 public Transform spawnPoint;
 public float timeBetweerWaves = 5f;// время через которое произойдет создание нового объекта
 private float countdown = 3f;
 private int waveIndex = 0;
 void Update()
 {
  if (countdown <= 0f) {
  StartCoroutine(SpawnWave());
  countdown = timeBetweerWaves;
  }
 countdown -= Time.deltaTime;// с каждым кадром переменная будет уменьшатся
 }
 IEnumerator SpawnWave(){
 waveIndex++;
 for (int i = 0; i < 10; i++) { // после каждого появления объекта к нему будет добавляться еще один объект
 i = Random.Range(1, 10);
 SpawnEnemy ();
 yield return new WaitForSeconds (0.3f); // через определенное время
 }
}

void SpawnEnemy () {
 Instantiate (EnemyOrefab, spawnPoint.position, spawnPoint. rotation); // создание объекта на сцене
 }
}

Этот скрипт будет отвечать за появление противника  на сцене, расстояние между  объектами и за количество появившихся объектов из начальной точки.

Создадим скрипт Enemy и перенесем его на префаб противника (Enemy), откроем его и напишем такой код:

using UnityEngine;
using System.Collections;

public class Enemy : MonoBehaviour {

Видео: Создание игры Tower Defense на Unity3D часть 1Скачать

Создание игры Tower Defense на Unity3D часть 1

Видео: Сделал игру Защита башни! Создание Tower Defense игры на Unity 1 часть.Скачать

Сделал игру Защита башни! Создание Tower Defense игры на Unity 1 часть.

public float speed = 10f; //скорость движения private Transform target; private int wavepointIndex =0; void Start (){ target = weapoints.points [0]; // указываем наш массив с поинтами } void Update(){ Vector3 dir = target.position - transform.position; // движение объекта к каждому поинту transform.Translate (dir.normalized * speed * Time.deltaTime, Space.World); if (Vector3.Distance (transform.position, target.position) <= 0.3f) { // если дистанция до точки меньше 0.3 GetNextWaypoint (); } } void GetNextWaypoint () { if (wavepointIndex >= weapoints.points.Length - 1) { // и если значение точки меньше значения точки из скрипта weapoints (который мы создадим позже) Destroy (gameObject); // то удаляем объект return; // делаем повтор } wavepointIndex++; target = weapoints.points [wavepointIndex]; } }

Этот скрипт будет отвечать за движение противника по точкам weapoints, скорость движения и удаление со сцены.

На объекте Start укажем в поле spawn object наш префаб Enemy и в поле Spawn point укажем сам Start

startscript

Создадим еще один скрипт  и назовем его weapoints, перенесем его сразу на объект weapons на сцене (группа наших поинтов). Откроем скрипт и напишем код:

 using UnityEngine;
 using System.Collections;

  public class weapoints : MonoBehaviour {

  public static Transform[] points; // здесь просто указывается массив с нашими точками

 void Awake(){
  points = new Transform[transform.childCount];
  for (int i = 0; i < points.Length; i++) {
  points[i]= transform.GetChild (i);
  }
 }
}

На этом этапе уже можно запустить нашу сцену, не забудьте перед этим добавить сцену в настройки проекта File — Built settings и сохранить сцену.

built

Так же, настройте камеру как вам удобно для просмотра сцены

camera

Запустите проект

logo

Как видите, наши объекты начали движение по заданному пути и пропадают в конце , осталось настроить появление башен и удаление противника со сцены. Этим мы и займемся в следующих уроках. Удачных проектов!

Предыдущий урок

Следующий урок

Видео

Unity уроки — Создание Tower Defense на Unity часть 1Скачать

Unity уроки - Создание Tower Defense на Unity часть 1

СОЗДАНИЕ TOWER DEFENSE НА UNITY || Создание игры #1 — Карта, ИИ врага, турели, строительствоСкачать

СОЗДАНИЕ TOWER DEFENSE НА UNITY || Создание игры #1 - Карта, ИИ врага, турели, строительство

2D Tower Defense на Unity #5 — Башни и стрельбаСкачать

2D Tower Defense на Unity #5 - Башни и стрельба

Первый месяц разработки страшного Tower Defence #programming #gamedevelopment #unrealengineСкачать

Первый месяц разработки страшного Tower Defence #programming #gamedevelopment #unrealengine

Введение, привязки, основной задний план / Tower defense #1Скачать

Введение, привязки, основной задний план / Tower defense #1

2D Tower Defense на Unity #1 — Построение СеткиСкачать

2D Tower Defense на Unity #1 - Построение Сетки

СДЕЛАЛ TOWER DEFENSE НА UNITY | +РАЗБОР КОДАСкачать

СДЕЛАЛ TOWER DEFENSE НА UNITY | +РАЗБОР КОДА

Сделал башню лучника и систему строительства в Tower defense на UnityСкачать

Сделал башню лучника и систему строительства в Tower defense на Unity

Создание игры Tower Defense на Unity3D часть 2Скачать

Создание игры Tower Defense на Unity3D часть 2

Улучшения башен в Tower defense! Создание игры на UnityСкачать

Улучшения башен в Tower defense! Создание игры на Unity

Как Создать Tower Defense Игру в Unity — Эпизод 1 | Tutorial, Unity, C#Скачать

Как Создать Tower Defense Игру в Unity - Эпизод 1 | Tutorial, Unity, C#

Создание своей игры на Unity в стиле Tower Defence #1 — Создание карты и враговСкачать

Создание своей игры на Unity в стиле Tower Defence #1 - Создание карты и врагов

Программирование в Unity 3D. Создаем игру в жанре Tower Defence. Часть 1.Скачать

Программирование в Unity 3D. Создаем игру в жанре Tower Defence. Часть 1.

Как Создать Tower Defense Игру в Unity — Эпизод 5 | Tutorial, Unity, C#Скачать

Как Создать Tower Defense Игру в Unity - Эпизод 5 | Tutorial, Unity, C#

Создание Tower Defense — 5.5Скачать

Создание Tower Defense - 5.5

ИИ врага (слежение по пути) / Tower defense #5 в UnityСкачать

ИИ врага  (слежение по пути)  / Tower defense #5 в Unity

Разбор чужого кода — так учат делать Tower Defence. Часть №1 (UNITY, C#, Рефакторинг и ревью кода)Скачать

Разбор чужого кода - так учат делать Tower Defence. Часть №1 (UNITY, C#, Рефакторинг и ревью кода)

Как сделать игру tower defense на unity3D 1: Игровое полеСкачать

Как сделать игру tower defense на unity3D 1: Игровое поле

Игры в стиле Tower Defense невероятно популярны и не удивительно, что нет ничего более захватывающего, чем наблюдение за вашей защитой, уничтожающей злых захватчиков!

В этом учебнике мы создадим неприступную оборону из монстров (выполняющих роль башен) в Unity! Вы узнаете, как …

  • Создать волны врагов и научить их следовать по путевым точкам (вейпоинтам).
  • Создать и модернизировать монстров и с их помощью измельчать врагов на пиксели.

В конце концов, вы получите фреймворк для этого игрового жанра, который в дальнейшем сможете усовершенствовать!

Вы должны знать основы Unity, например, как добавить игровые ассеты и компоненты, понимать, что такое префаб и знать основны C#.

Я использую версию OS X версию Unity, но вы можете и работать и под Windows.

Описание 2D игры на Unity в стиле Tower Defense

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

Игрок должен убить букашек прежде чем они начнут пировать на печенье, причем каждую новую волну врагов труднее победить. Игра заканчивается когда вам удалось пережить все атаки (Победа!), или когда пять врагов достигнут печенья (Поражение!). Вот скриншот готовой игры:

Игра в стиле Tower Defense на Unity. Защитите печенье!

Игра в стиле Tower Defense на Unity. Защитите печенье!

Начало работы с Unity

Если вы еще не установили Unity 5, или более новую версию, загрузите ее с официального сайта Unity. Кроме того, скачайте этот стартовый проект, разархивируйте и откройте проект TowerDefense-Part1-Starter в Unity.

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

Откройте GameScene, найденную в папке Scenes, и выберите соотношение сторон 4:3, чтобы все умещалось на экране. Вы должны увидеть следующее во вкладке Game view:

Скриншот начала игры

Работаем с монстрами

Монстры могут быть размещены только на местах, отмеченных x. Для того, чтобы добавить их на сцену, перетащите ImagesObjectsOpenspot из Project Browser на вкладку Scene (Сцена). На данный момент, положение не имеет значения.

Выберите в Иерархии Openspot, затем добавьте компонент, нажав на Add Component в Инспекторе и выберите Physics 2DBox Collider 2D. Unity отобразит бокс-коллайдер с зеленой линией на вкладке Сцена. Вы будете использовать этот коллайдер для обнаружения щелчков мыши на этом месте.

Unity автоматически определяет подходящий размер для коллайдера. Круто?

Unity автоматически определяет подходящий размер для коллайдера. Круто?

Теперь добавьте компонент AudioAudio Source в Openspot. Установите источники звука AudioClip в tower_place, которые вы можете найти в папке Audio и отключите Play On Awake.

Вам нужно создать еще 11 мест. Несмотря на искушение проделать эти шаги снова, Unity имеет более элегантное решение: префабы!

Перетащите Openspot из Иерархии в папку Prefabs в Project Browser (Браузере проекта). Его имя станет синим в Иерархии, чтобы показать, что он подключен к префаб:

Префаб

Теперь, когда у вас есть префаб, вы можете создать столько копий, сколько вам нужно. Просто перетащите Openspot из папки Prefabs в Браузере проекта во вкладку Сцена. Проделайте это 11 раз, чтобы на сцене было 12 объектов Openspot.

Теперь используйте Инспектор, чтобы установить положение этих 12 объектов в следующие позиции: (-5.2, 3.5, 0), (-2.2, 3.5, 0), (0.8, 3.5, 0), (3.8, 3.5, 0), (-3.8, 0.4, 0), (-0.8, 0.4, 0), (2.2, 0.4, 0), (5.2, 0.4, 0), (-5.2, -3.0, 0), (-2.2, -3.0, 0), (0.8, -3.0, 0), (3.8, -3.0, 0).

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

Места размещения башен в Unity

Размещаем монстров

Для удобства, в папке Prefab есть префаб Monster.

Префаб монстра, готовый к использованию

Префаб монстра, готовый к использованию

На данный момент она состоит из пустого игрового объекта с тремя различными спрайтами и их анимаций в качестве подобъектов.

Каждый спрайт представляет собой монстра с разным уровнем мощности. Префаб также содержит компонент Audio Source, который вы будете запускать для воспроизведение звука, когда монстр стреляет лазером.

Создадим скрипт, который может поместить Monster на Openspot.

В Браузере проекта выберите Openspot в папке Prefabs. В Инспекторе нажмите на Add Component (Добавить Компонент), затем выберите New Script (Новый Скрипт) и назовите его PlaceMonster. Выберите C Sharp как Language и нажмите на Create and Add (Создать и Добавить). Т.к. вы добавили скрипт в префаб Openspot все Openspot’ы будут также содержать этот скрипт.

Дважды щелкните по скрипту, чтобы открыть его в MonoDevelop. Затем добавьте эти две переменные:

public GameObject monsterPrefab;
private GameObject monster;

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

Один монстр на локацию

Добавьте следующий метод, чтобы в каждой локации был только один монстр:

private bool canPlaceMonster() {
  return monster == null;
}

В canPlaceMonster() можно проверить, является ли переменная monster пустой (null). Если все еще да, то значит, что монстра нет, и можно разместить еще одного.

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

//1
void OnMouseUp () {
  //2
  if (canPlaceMonster ()) {
    //3
    monster = (GameObject) 
      Instantiate(monsterPrefab, transform.position, Quaternion.identity);
    //4
    AudioSource audioSource = gameObject.GetComponent<AudioSource>();
    audioSource.PlayOneShot(audioSource.clip);
 
    // СДЕЛАТЬ: уменьшить золото
  }
}

Этот код помещает монстра по щелчку мыши или касанию экрана. Так, как это работает?

  1. Unity автоматически вызывает OnMouseUp когда игрок нажимает на физический коллайдер игрового объекта.
  2. При вызове этот метод помещает нового монстра, если canPlaceMonster() возвращает true.
  3. Монстра можно создать с помощью метода Instantiate, который создаёт образец префаба с определенной позицией и вращением. В таком случае можно скопировать monsterPrefab, применить к нему текущую позицию и вращение игрового объекта, прикрепить результат к GameObject и сохранить его в monster.
  4. И, наконец, вы вызываете PlayOneShot, чтобы проиграть звуковой эффект привязанный к компоненту объекта AudioSource.

Теперь ваш скрипт PlaceMonster может поместить нового монстра, но вам все еще нужно обозначить префаб.

Использование нужного Префаба

Сохраните файл и переключитесь обратно в Unity. Для того, чтобы назначить переменную monsterPrefab сперва выберите Openspot в папке Prefabs в Браузере проекта. В Инспекторе кликните на кружок справа от компонента PlaceMonster (Script) в поле Monster Prefab и выберите Monster в появившемся диалоговом окне.

Assign Prefab

Запустите сцену и создавайте монстров в различных точках, помеченных х, с помощью щелчка или касания экрана.

Теперь Вы можете создавать монстров

Теперь Вы можете создавать монстров. Тем не менее они похожи на странную кашу, потому что все дочерние спрайты монстров нарисованы. Мы исправим это позже.
Прокачка монстров в Unity-игре

На рисунке ниже вы видите, как ваши монстры выглядят все более ужасающими на более высоких уровнях.

Монстр в игре на Unity

Какой милашка! Но, если вы попытаетесь украсть печенье, этот монстр может превратиться в убийцу.

Скрипт служит основой для применения системы уровней монстров. Он отслеживает насколько сильный монстр должен быть на каждом уровне, и, конечно же, нынешний уровень монстра. Добавьте этот скрипт.

Выберите Prefabs/Monster в Браузере проекта. Добавьте новый C#-скрипт по имени MonsterData. Откройте его в MonoDevelop и добавьте следующий код перед классом MonsterData.

[System.Serializable]
public class MonsterLevel {
  public int cost;
  public GameObject visualization;
}

Мы создали класс MonsterLevel. В нем хранится стоимость (в золоте, которое мы пропишем позже) и внешний вид различных типов монстров.

Чтобы объекты можно было редактировать в инспекторе, добавьте строку [System.Serializable]на самый верх класса. Это позволяет быстро изменить все значения в классе Level — даже тогда, когда игра запущена. Это невероятно полезно для придания сбалансированности вашей игре.

Определение уровня монстра

В этом случае, вы будете хранить предопределенный MonsterLevel в List<T>.

Почему бы просто не использовать MonsterLevel[]? Хотя бы потому, что нам понадобятся индексы объектов MonsterLevel несколько раз. Код для этого написать не так и сложно, поэтому, вы будете использовать IndexOf(), который реализует функциональные возможности для Lists. Нет никакой необходимости изобретать колесо. :]

В самом верху MonsterData.cs, добавьте оператор using:

using System.Collections.Generic;

Это дает вам доступ к общим структурам данных, так что вы можете использовать класс List<T> в вашем скрипте.

Дженерик-функции являются большой частью C#. С их помощью можно определять структуры типобезопасных данных без обращения к их типу. Это очень удобно для классов вроде list и set.

Теперь добавьте следующую переменную MonsterData для хранения списка MonsterLevel:

public List<MonsterLevel> levels;

С помощью дженерик-функций можно удостовериться, что levels List может содержать только объекты MonsterLevel. Сохраните файл и переключитесь в Unity для настройки каждого уровня монстра. Выберите Prefabs/Monster в Браузере проекта. В Инспекторе вы можете увидеть папку Levels в компоненте MonsterData (Script). Установите size в 3.

Компонент MonsterData (Script)

Затем установите cost для каждого уровня:

  • Element 0: 200
  • Element 1: 110
  • Element 2: 120

Теперь присвоить значения дя поля visualization. В окне проектов разверните папку Prefabs/Monster , чтобы увидеть подпапки. Перетащите Monster0 в поле visualization элемента Element 0.

Повторим то же самое для присвоения Monster1 к Element 1, а Monster2 к Element 2. GIF-картинка, размещенная ниже, наглядно демонстрирует этот процесс:

assign-monsters2

Когда вы выбираете Prefabs/Monster, префаб должен выглядеть следующим образом:

Определение уровней монстров в Инспекторе Unity

Определение уровней монстров в Инспекторе

Определение текущего уровня

Переключитесь обратно на MonsterData.cs в MonoDevelop и добавьте еще одну переменную MonsterData.

private MonsterLevel currentLevel;

В приватной переменной currentLevel у нас будет записан… секундочку… текущий уровень монстра. Не ожидали? :]

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

//1
public MonsterLevel CurrentLevel {
  //2
  get {
    return currentLevel;
  }
  //3
  set {
    currentLevel = value;
    int currentLevelIndex = levels.IndexOf(currentLevel);
 
    GameObject levelVisualization = levels[currentLevelIndex].visualization;
    for (int i = 0; i < levels.Count; i++) {
      if (levelVisualization != null) {
        if (i == currentLevelIndex) {
          levels[i].visualization.SetActive(true);
        } else {
          levels[i].visualization.SetActive(false);
        }
      }
    }
  }
}

Совсем немного C#, не так ли? Вот, что мы здесь делаем:

  1. Определяем свойство для приватной переменной currentLevel. Когда свойство определено, можно его вызывать как любую переменную: либо как CurrentLevel (изнутри класса), либо как monster.CurrentLevel(извне). Можно задать поведение в геттерах и сеттерах свойства. Используя только геттер, только сеттер, либо используя их оба, можно делать свойство доступным только для чтения, только для записи, либо доступным для обоих этих действий.
  2. В геттере, вы возвращает значение currentLevel.
  3. В сеттере вы назначаете новое значение currentLevel. Далее вы получите индекс текущего уровня. Наконец, вы перебираете все levels и установить визуализацию в активном или неактивном состоянии, в зависимости от currentLevelIndex. Это здорово, потому что это означает, что всякий раз, когда кто-то устанавливает currentLevel, спрайт обновляется автоматически. Свойства здесь очень пригождаются!

Добавьте следующее в OnEnable:

void OnEnable() {
  CurrentLevel = levels[0];
}

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

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

OnEnable будет вызываться сразу при создании префаба (если префаб был сохранен во включенном состоянии), но OnStart не вызывается до тех пор, пока объект не запущен.

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

Сохраните файл и переключитесь на Unity. Запустите проект и расставьте монстров. Теперь они показывают правильно.

Никакой пощады

Апгрейд монстров

Вернитесь в MonoDevelop и добавьте следующий метод в MonsterData:

public MonsterLevel getNextLevel() {
  int currentLevelIndex = levels.IndexOf (currentLevel);
  int maxLevelIndex = levels.Count - 1;
  if (currentLevelIndex < maxLevelIndex) {
    return levels[currentLevelIndex+1];
  } else {
    return null;
  }
}

В getNextLevel вы получаете индекс currentLevel и индекс максимального уровня. Если монстр еще не полностью прокачен, возвращаем индекс следующего уровеня, в противном случае — null.

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

public void increaseLevel() {
  int currentLevelIndex = levels.IndexOf(currentLevel);
  if (currentLevelIndex < levels.Count - 1) {
    CurrentLevel = levels[currentLevelIndex + 1];
  }
}

Здесь вы получаете индекс текущего уровня и проверяете меньше ли он levels.Count - 1. Если да, — монстра еще можно прокачать, поэтому мы присваиваем CurrentLevel следующий уровень.

Тестирование возможности апгрейда

Сохраните файл, а затем переключитесь на PlaceMonster.cs в MonoDevelop и добавьте этот новый метод:

private bool canUpgradeMonster() {
  if (monster != null) {
    MonsterData monsterData = monster.GetComponent<MonsterData> ();
    MonsterLevel nextLevel = monsterData.getNextLevel();
    if (nextLevel != null) {
      return true;
    }
  }
  return false;
}

Здесь мы смотрим, есть ли монстр, которого можно модернизировать, путем проверки переменной monster на null. Если это так, то получаем текущий уровень монстра из MonsterData. После этого мы проверяем доступен ли более высокий уровень, который при getNextLevel() не возвращает null. Если «прокачка» монстра возможна, возвращаем true, в противном случае — false.

Включить апгрейд с помощью золота

Чтобы включить опцию апгрейда, добавьте оператор else if к OnMouseUp:

if (canPlaceMonster ()) {
  // Здесь код остается прежним
} else if (canUpgradeMonster()) {
  monster.GetComponent<MonsterData>().increaseLevel();
  AudioSource audioSource = gameObject.GetComponent<AudioSource>();
  audioSource.PlayOneShot(audioSource.clip);
  // Предстоит сделать: золотишка стало меньше
}

Проверьте, является ли апгрейд возможен с помощью canUpgradeMonster(). Если да, то вы получаете доступ к компоненту MonsterData с GetComponent() и вызываете increaseLevel(), что повышает уровень монстра. Наконец, вы запускаете звук для монстра AudioSource. Сохраните файл и вернитесь обратно в Unity. Запустите игру, расставьте и модернизируйте как можно больше монстров… пока это возможно.

Этапы апгрейда монстра в Unity

Этапы апгрейда монстра в Unity

На следующем занятии по Tower Defense на Unity мы введем в игру игровую валюту, поработаем на маршрутом для монстров и др.

Поле

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

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

Туториал создавался в Unity 2018.3.0f2.

Поле, готовое к использованию в тайловой игре жанра tower defense.

Игра жанра Tower Defense

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

Я буду считать, что вы уже изучили серию туториалов по управлению объектами.

Поле

Игровое поле — самая важная часть игры, поэтому его мы создадим первым. Это будет игровой объект (game object) с собственным компонентом GameBoard, который можно инициализировать заданием размера в двух измерениях, для чего мы можем воспользоваться значением Vector2Int. Поле должно работать с любым размером, но выбирать размер мы будем где-нибудь в другом месте, поэтому создадим для этого общий метод Initialize.

Кроме того, мы визуализируем поле одним четырёхугольником (quad), который будет обозначать землю. Мы не будем делать четырёхугольником сам объект поля, а добавим ему дочерний объект quad. При инициализации мы сделаем масштаб XY земли равным размеру поля. То есть каждый тайл будет иметь размер в одну квадратную единицу измерения движка.

using UnityEngine;
	public class GameBoard : MonoBehaviour {
	[SerializeField]
	Transform ground = default;
	Vector2Int size;
	public void Initialize (Vector2Int size) {
	this.size = size;
	ground.localScale = new Vector3(size.x, size.y, 1f);
	}
	}

Зачем явным образом задавать ground значение по умолчанию?

Идея заключается в том, что всё настраиваемое через редактор Unity, доступно через сериализированные скрытые поля. Нужно, чтобы эти поля можно было менять только в инспекторе. К сожалению, редактор Unity постоянно будет показывать предупреждение компилятора о том, что значение никогда не присваивается. Мы можем подавить это предупреждение, явно задав полю значение по умолчанию. Можно присвоить и null, но я сделал так, чтобы явно показать, что мы просто используем значение по умолчанию, которое не представляет собой истинную ссылку на ground, поэтому применяем default.

Создадим объект поля в новой сцене и добавим ему дочерний quad с материалом, который выглядит как земля. Так как мы создаём простую игру-прототип, вполне достаточно будет однородного зелёного материала. Повернём quad на 90° по оси X, чтобы он лежал на плоскости XZ.

Игровое поле.

Почему бы не расположить игру на плоскости XY?

Хоть игра будет проходить в 2D-пространстве, рендерить мы её будем в 3D, с 3D-врагами и камерой, которую можно двигать относительно определённой точки. Плоскость XZ более удобна для этого и соответствует стандартной ориентации скайбокса, используемой для окружающего освещения.

Игра

Далее создадим компонент Game, который будет отвечать за всю игру. На данном этапе это будет означать, что он инициализирует поле. Мы просто сделаем размер настраиваемым через инспектор и заставим компонент инициализировать поле при его пробуждении. Давайте используем по умолчанию размер 11×11.

using UnityEngine;
	public class Game : MonoBehaviour {
	[SerializeField]
	Vector2Int boardSize = new Vector2Int(11, 11);
	[SerializeField]
	GameBoard board = default;
	void Awake () {
	board.Initialize(boardSize);
	}
	}

Размеры поля могут быть только положительными и не имеет особого смысла создавать поле с единственным тайлом. Поэтому давайте ограничим минимум размером 2×2. Это можно сделать, добавив метод OnValidate, принудительно ограничивающий минимальные значения.

 void OnValidate () {
	if (boardSize.x < 2) {
	boardSize.x = 2;
	}
	if (boardSize.y < 2) {
	boardSize.y = 2;
	}
	}

Когда вызывается Onvalidate?

Если он существует, то редактор Unity вызывает его для компонентов после их изменения. В том числе при добавлении их к game object, после загрузки сцены, после рекомпиляции, после изменения в редакторе, после отмены/повтора и после сброса компонента.

OnValidate — это единственное место в коде, где допускается присвоение значений полям конфигурации компонентов.

Game object.

Теперь при запуске режима игры мы будем получать поле с верным размером. Во время игры расположите камеру так, чтобы была видна вся доска, скопируйте её компонент transformation, выйдите из режима игры (play mode) и вставьте значения компонента. В случае поля размером 11×11, находящегося в начале координат, для получения удобного вида сверху можно расположить камеру в позиции (0,10,0) и повернув её на 90° по оси X. Мы оставим камеру в этом фиксированном положении, но возможно изменим его в будущем.

Камера над полем.

Как копировать и вставлять значения компонентов?

Через раскрывающееся меню, которое появляется при нажатии на кнопку с шестерёнкой в правом верхнем углу компонента.

Префаб тайла

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

Стрелка на чёрном фоне.

Поместите текстуру стрелки в свой проект и включите опцию Alpha As Transparency. Затем создайте для стрелки материал, который может быть стандартным материалом (default material), для которого выбран режим cutout, а в качестве основной текстуры выберите стрелку.

Материал стрелки.

Зачем использовать режим рендеринга cutout?

Он позволяет затенять стрелку при использовании стандартного конвейера рендеринга Unity.

Для обозначения каждого тайла в игре мы будем использовать game object. Каждый из них будет иметь свой quad с материалом стрелки, так же, как у поля есть quad земли. Также мы добавим тайлам компонент GameTile со ссылкой на их стрелку.

using UnityEngine;
	public class GameTile : MonoBehaviour {
	[SerializeField]
	Transform arrow = default;
	}

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

Префаб тайла.

Где находится иерархия префаба тайла?

Режим редактирования префаба можно открыть, дважды щёлкнув на ассет префаба, или выбрав префаб и нажав на кнопку Open Prefab в инспекторе. Выйти из режима редактирования префаба можно, нажав на кнопку со стрелкой в левом верхнем углу его заголовка иерархии.

Учтите, что сами тайлы необязательно должны быть game objects. Они нужны только для того, чтобы отслеживать состояние поля. Мы могли бы использовать тот же подход, что и для поведения в серии туториалов Object Management. Но на ранних этапах простых игр или прототипов game objects вполне нас устраивают. В будущем это можно будет изменить.

Располагаем тайлы

Для создания тайлов GameBoard должен иметь ссылку на префаб тайла.

 [SerializeField]
	GameTile tilePrefab = default;

Ссылка на префаб тайла.

Затем он может создать его экземляры с помощью двойного цикла по двум измерениям сетки. Хоть размер и выражен как X и Y, мы будем располагать тайлы на плоскости XZ, как и само поле. Так как поле центрировано относительно точки начала координат, нам нужно вычесть из компонентов позиции тайла соответствующий размер минус один, разделённый на два. Учтите, что это должно быть деление с плавающей запятой, в противном случае для чётных размеров оно не сработает.

	public void Initialize (Vector2Int size) {
		this.size = size;
		ground.localScale = new Vector3(size.x, size.y, 1f);
		Vector2 offset = new Vector2(
			(size.x - 1) * 0.5f, (size.y - 1) * 0.5f
		);
		for (int y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++) {
				GameTile tile = Instantiate(tilePrefab);
				tile.transform.SetParent(transform, false);
				tile.transform.localPosition = new Vector3(
					x - offset.x, 0f, y - offset.y
				);
			}
		}
	}

Созданные экземпляры тайлов.

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

	GameTile[] tiles;
	public void Initialize (Vector2Int size) {
		…
		tiles = new GameTile[size.x * size.y];
		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				GameTile tile = tiles[i] = Instantiate(tilePrefab);
				…
			}
		}
	}

Как работает это присвоение?

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

GameTile t = Instantiate(tilePrefab);
tiles[i] = t;
GameTile tile = t;

Поиск пути

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

Соседи тайлов

Пути идут от тайла к тайлу, в северном, восточном, южном или западном направлении. Чтобы упростить поиск, заставим GameTile отслеживать ссылки на четырёх его соседей.

	GameTile north, east, south, west;

Отношения между соседями симметричны. Если тайл является восточным соседом второго тайла, то второй является западным соседом первого. Добавим к GameTile общий статический метод для определения этих отношений между двумя тайлами.

	public static void MakeEastWestNeighbors (GameTile east, GameTile west) {
		west.east = east;
		east.west = west;
	}

Зачем использовать статический метод?

Мы можем сделать его и методом экземпляра с единственным параметром, и в таком случае будем вызывать его как eastTile.MakeEastWestNeighbors(westTile) или что-то подобное. Но в случаях, когда непонятно, каким из тайлов должен быть вызван метод, лучше использовать статические методы. Примерами являются методы Distance и Dot класса Vector3.

После установления связи она никогда не должна меняться. Если это случится, то мы совершили ошибку в коде. Можно проверять это, сравнивая обе ссылки перед присваиванием значений с null, и выводя в консоль ошибку, если это неверно. Для этого можно использовать метод Debug.Assert.

	public static void MakeEastWestNeighbors (GameTile east, GameTile west) {
		Debug.Assert(
			west.east == null && east.west == null, "Redefined neighbors!"
		);
		west.east = east;
		east.west = west;
	}

Что делает Debug.Assert?

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

Добавим аналогичный метод для создания отношений между северными и южными соседями.

	public static void MakeNorthSouthNeighbors (GameTile north, GameTile south) {
		Debug.Assert(
			south.north == null && north.south == null, "Redefined neighbors!"
		);
		south.north = north;
		north.south = south;
	}

Мы можем установить эти отношения при создании тайлов в GameBoard.Initialize. Если координата X больше нуля, то мы можем создать отношение восток-запад между текущим и предыдущим тайлом. Если координата Y больше нуля, то мы можем создать отношение север-юг между текущим тайлом и тайлом из предыдущей строки.

		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…
				if (x > 0) {
					GameTile.MakeEastWestNeighbors(tile, tiles[i - 1]);
				}
				if (y > 0) {
					GameTile.MakeNorthSouthNeighbors(tile, tiles[i - size.x]);
				}
			}
		}

Учтите, что тайлы на краях поля имеют не четырёх соседей. Одна или две ссылки на соседей будут оставаться равными null.

Расстояние и направление

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

	GameTile north, east, south, west, nextOnPath;
	int distance;

Каждый раз, когда мы решим, что нужно искать пути, нам нужно будет инициализировать данные пути. Пока путь не найден, следующего тайла нет и расстояние можно считать бесконечным. Мы можем представить это максимальным возможным целочисленным значением int.MaxValue. Добавим общий метод ClearPath, чтобы выполнить сброс GameTile к этому состоянию.

	public void ClearPath () {
		distance = int.MaxValue;
		nextOnPath = null;
	}

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

	public void BecomeDestination () {
		distance = 0;
		nextOnPath = null;
	}

В конечном итоге все тайлы должны превратиться в путь, поэтому их расстояние больше не будет равно int.MaxValue. Добавим удобное свойство-геттер, чтобы проверять, есть ли в данный момент у тайла путь.

	public bool HasPath => distance != int.MaxValue;

Как работает это свойство?

Это укороченная запись задания свойства-геттера, содержащего только одно выражение. Она делает то же самое, что и показанный ниже код.

	public bool HasPath {
		get {
			return distance != int.MaxValue;
		}
	}

Оператор-стрелку => также можно использовать по отдельности для геттера и сеттера свойств, для тел методов, конструкторов и в некоторых других местах.

Выращиваем путь

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

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

	void GrowPathTo (GameTile neighbor) {
		Debug.Assert(HasPath, "No path!");
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
	}

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

	void GrowPathTo (GameTile neighbor) {
		Debug.Assert(HasPath, "No path!");
		if (neighbor == null || neighbor.HasPath) {
			return;
		}
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
	}

То, как GameTile отслеживает своих соседей, неизвестно остальному коду. Поэтому GrowPathTo является скрытым. Мы добавим общие методы, приказывающие тайлу вырастить его путь в определённом направлении, косвенно вызывая GrowPathTo. Но код, который занимается поиском по всему полю, должен отслеживать, какие тайлы были посещены. Поэтому сделаем так, чтобы он возвращал соседа или null, если выполнение прекращено.

	GameTile GrowPathTo (GameTile neighbor) {
		if (!HasPath || neighbor == null || neighbor.HasPath) {
			return null;
		}
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
		return neighbor;
	}

Теперь добавим методы для выращивания пути в конкретных направлениях.

	public GameTile GrowPathNorth () => GrowPathTo(north);
	public GameTile GrowPathEast () => GrowPathTo(east);
	public GameTile GrowPathSouth () => GrowPathTo(south);
	public GameTile GrowPathWest () => GrowPathTo(west);

Поиск в ширину

Гарантировать, что все тайлы содержат верные данные пути, должен GameBoard. Мы реализуем это выполнением поиска в ширину (breadth-first search). Начнём с тайла конечной точки, а затем вырастим путь до его соседей, потом до соседей этих тайлов, и так далее. С каждым шагом расстояние увеличивается на единицу, а пути никогда не растут в сторону тайлов, у которых уже есть пути. Это гарантирует, что все тайлы в результате будут указывать вдоль кратчайшего пути к конечной точке.

А как насчёт поиска пути с помощью A*?

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

Для выполнения поиска нам нужно отслеживать тайлы, которые мы добавили к пути, но из которых пока не вырастили путь. Эту коллекцию тайлов часто называют границей поиска (search frontier). Важно, чтобы тайлы обрабатывались в том же порядке, в котором они добавляются к границе, поэтому давайте используем очередь Queue. Позже нам придётся выполнять поиск несколько раз, поэтому зададим её как поле (field) GameBoard.

using UnityEngine;
using System.Collections.Generic;
public class GameBoard : MonoBehaviour {
	…
	Queue<GameTile> searchFrontier = new Queue<GameTile>();
	…
}

Чтобы состояние игрового поля всегда было верным, мы должны находить пути в конце Initialize, но поместить код в отдельный метод FindPaths. Первым делом нужно очистить путь у всех тайлов, затем сделать один тайл конечной точкой и добавить его к границе. Давайте сначала выберем первый тайл. Так как tiles является массивом, мы можем использовать цикл foreach, не боясь загрязнения памяти. Если позже мы перейдём от массива к списку, то нужно будет также заменить циклы foreach циклами for.

	public void Initialize (Vector2Int size) {
		…
		FindPaths();
	}
	void FindPaths () {
		foreach (GameTile tile in tiles) {
			tile.ClearPath();
		}
		tiles[0].BecomeDestination();
		searchFrontier.Enqueue(tiles[0]);
	}

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

	public void FindPaths () {
		foreach (GameTile tile in tiles) {
			tile.ClearPath();
		}
		tiles[0].BecomeDestination();
		searchFrontier.Enqueue(tiles[0]);
		GameTile tile = searchFrontier.Dequeue();
		searchFrontier.Enqueue(tile.GrowPathNorth());
		searchFrontier.Enqueue(tile.GrowPathEast());
		searchFrontier.Enqueue(tile.GrowPathSouth());
		searchFrontier.Enqueue(tile.GrowPathWest());
	}

Повторяем этот этап, пока в границе есть тайлы.

		while (searchFrontier.Count > 0) {
			GameTile tile = searchFrontier.Dequeue();
			searchFrontier.Enqueue(tile.GrowPathNorth());
			searchFrontier.Enqueue(tile.GrowPathEast());
			searchFrontier.Enqueue(tile.GrowPathSouth());
			searchFrontier.Enqueue(tile.GrowPathWest());
		}

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

			GameTile tile = searchFrontier.Dequeue();
			if (tile != null) {
				searchFrontier.Enqueue(tile.GrowPathNorth());
				searchFrontier.Enqueue(tile.GrowPathEast());
				searchFrontier.Enqueue(tile.GrowPathSouth());
				searchFrontier.Enqueue(tile.GrowPathWest());
			}

Отображаем пути

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

	static Quaternion
		northRotation = Quaternion.Euler(90f, 0f, 0f),
		eastRotation = Quaternion.Euler(90f, 90f, 0f),
		southRotation = Quaternion.Euler(90f, 180f, 0f),
		westRotation = Quaternion.Euler(90f, 270f, 0f);

Также добавим общий метод ShowPath. Если расстояние равно нулю, то тайл является конечной точкой и ему не на что указывать, поэтому деактивируем его стрелку. В противном случае активирум стрелку и задаём её поворот. Нужное направление можно определить, сравнив nextOnPath с его соседями.

	public void ShowPath () {
		if (distance == 0) {
			arrow.gameObject.SetActive(false);
			return;
		}
		arrow.gameObject.SetActive(true);
		arrow.localRotation =
			nextOnPath == north ? northRotation :
			nextOnPath == east ? eastRotation :
			nextOnPath == south ? southRotation :
			westRotation;
	}

Вызовем этот метод для всех тайлов в конце GameBoard.FindPaths.

	public void FindPaths () {
		…
		foreach (GameTile tile in tiles) {
			tile.ShowPath();
		}
	}

Найденные пути.

Почему мы не поворачиваем стрелку непосредственно в GrowPathTo?

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

Изменяем приоритет поиска

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

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

		tiles[tiles.Length / 2].BecomeDestination();
		searchFrontier.Enqueue(tiles[tiles.Length / 2]);

Конечная точка в центре.

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

Изменить результат можно, настроив приоритеты направлений. Давайте поменяем местами восток и юг. Так мы должны получить симметрию «север-юг» и «восток-запад».

				searchFrontier.Enqueue(tile.GrowPathNorth());
				searchFrontier.Enqueue(tile.GrowPathSouth());
				searchFrontier.Enqueue(tile.GrowPathEast());
				searchFrontier.Enqueue(tile.GrowPathWest())

Порядок поиска «север-юг-восток-запад».

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

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

	public bool IsAlternative { get; set; }

Это свойство мы будем задавать в GameBoard.Initialize. Сначала пометим тайлы как альтернативные, если их координата X чётная.

		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…
				tile.IsAlternative = (x & 1) == 0;
			}
		}

Что делает операция (x & 1) == 0?

Одиночный амперсанд — это двоичный оператор И (AND). Он выполняет логическую операцию И для каждой отдельной пары битов его операндов. Поэтому чтобы конечный бит был равен 1, оба бита пары должны быть равны 1. Например 10101010 и 00001111 дают нам 00001010.

В компьютерах числа хранятся в двоичном виде. В них могут использоваться только 0 и 1. В двоичном виде последовательность 1, 2, 3, 4 записывается как 1, 10, 11, 100. Как видите, самый младший разряд чётных чисел равен нулю.

Мы используем двоичное AND как маску, игнорируя всё, кроме самого младшего разряда. Если результат равен нулю, то мы имеем дело с чётным числом.

Во-вторых, изменим знак результата, если их координата Y чётная. Так мы создадим шахматный узор.

				tile.IsAlternative = (x & 1) == 0;
				if ((y & 1) == 0) {
					tile.IsAlternative = !tile.IsAlternative;
				}

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

			if (tile != null) {
				if (tile.IsAlternative) {
					searchFrontier.Enqueue(tile.GrowPathNorth());
					searchFrontier.Enqueue(tile.GrowPathSouth());
					searchFrontier.Enqueue(tile.GrowPathEast());
					searchFrontier.Enqueue(tile.GrowPathWest());
				}
				else {
					searchFrontier.Enqueue(tile.GrowPathWest());
					searchFrontier.Enqueue(tile.GrowPathEast());
					searchFrontier.Enqueue(tile.GrowPathSouth());
					searchFrontier.Enqueue(tile.GrowPathNorth());
				}
			}

Переменный порядок поиска.

Изменяем тайлы

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

Содержимое тайла

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

public enum GameTileContentType {
	Empty, Destination
}

Далее создадим тип компонента GameTileContent, который позволит задавать тип его содержимого через инспектор, а доступ к нему будет осуществляться через общее свойство-геттер.

using UnityEngine;
public class GameTileContent : MonoBehaviour {
	[SerializeField]
	GameTileContentType type = default;
	public GameTileContentType Type => type;
}

Затем создадим префабы для двух типов контента, у каждого из которых компонент GameTileContent с соответствующим заданным типом. Давайте для обозначения тайлов конечных точек воспользуемся голубым сплющенным кубом. Так как он почти плоский, коллайдер ему не нужен. Для префаба пустого содержимого используем пустой game object.

destination

empty

Префабы конечной точки и пустого содержимого.

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

Фабрика содержимого

Чтобы сделать содержимое редактируемым, мы также создадим для этого фабрику, воспользовавшись тем же подходом, что и в туториале Object Management. Это значит, что GameTileContent должен отслеживать свою исходную фабрику, которая должна задаваться только один раз, и отправлять себя обратно на фабрику в методе Recycle.

	GameTileContentFactory originFactory;
	…
	public GameTileContentFactory OriginFactory {
		get => originFactory;
		set {
			Debug.Assert(originFactory == null, "Redefined origin factory!");
			originFactory = value;
		}
	}
	public void Recycle () {
		originFactory.Reclaim(this);
	}

Это предполагает существование GameTileContentFactory, поэтому создадим для этого тип scriptable object с обязательным методом Recycle. На данном этапе мы пока не будем заморачиваться созданием полнофункциональной фабрики, утилизирующей содержимое, поэтому заставим её просто уничтожать содержимое. Позже можно будет добавить к фабрике многократное использование объектов без изменения всего остального кода.

using UnityEngine;
using UnityEngine.SceneManagement;
[CreateAssetMenu]
public class GameTileContentFactory : ScriptableObject {
	public void Reclaim (GameTileContent content) {
		Debug.Assert(content.OriginFactory == this, "Wrong factory reclaimed!");
		Destroy(content.gameObject);
	}
}

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

	GameTileContent Get (GameTileContent prefab) {
		GameTileContent instance = Instantiate(prefab);
		instance.OriginFactory = this;
		MoveToFactoryScene(instance.gameObject);
		return instance;
	}

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

	Scene contentScene;
	…
	void MoveToFactoryScene (GameObject o) {
		if (!contentScene.isLoaded) {
			if (Application.isEditor) {
				contentScene = SceneManager.GetSceneByName(name);
				if (!contentScene.isLoaded) {
					contentScene = SceneManager.CreateScene(name);
				}
			}
			else {
				contentScene = SceneManager.CreateScene(name);
			}
		}
		SceneManager.MoveGameObjectToScene(o, contentScene);
	}

У нас есть только два типа содержимого, поэтому просто добавим для них два поля конфигурации префабов.

	[SerializeField]
	GameTileContent destinationPrefab = default;
	[SerializeField]
	GameTileContent emptyPrefab = default;

Последнее, что нужно сделать для работы фабрики — создать общий метод Get с параметром GameTileContentType, получающий экземпляр соответствующего префаба.

	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			case GameTileContentType.Destination: return Get(destinationPrefab);
			case GameTileContentType.Empty: return Get(emptyPrefab);
		}
		Debug.Assert(false, "Unsupported type: " + type);
		return null;
	}

Обязательно ли добавлять каждому тайлу собственный экземпляр пустого содержимого?

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

Создадим ассет фабрики и настроим её ссылки на префабы.

Фабрика содержимого.

А затем передадим Game ссылку на фабрику.

	[SerializeField]
	GameTileContentFactory tileContentFactory = default;

Game с фабрикой.

Касание тайла

Чтобы изменять поле, нам нужно иметь возможность выбора тайла. Мы сделаем так, чтобы это было возможно в режиме игры. Будем испускать луч в сцену в месте, где игрок нажал на окно игры. Если луч пересекается с тайлом, то его коснулся игрок, то есть его необходимо изменить. Game будет обрабатывать ввод игрока, но за определение того, какого тайла коснулся игрок, будет отвечать GameBoard.

Не все лучи пересекутся с тайлом, поэтому иногда мы не будем получать ничего. Поэтому добавим в GameBoard метод GetTile, который изначально всегда возвращает null (это означает, что тайл не был найден).

	public GameTile GetTile (Ray ray) {
		return null;
	}

Чтобы определить, пересёк ли луч тайл, нам нужно вызвать Physics.Raycast, указав в качестве аргумента луч. Он возвращает информацию о том, было ли пересечение. Если да, то мы сможем вернуть тайл, хоть пока и не знаем какой, поэтому пока возвращаем null.

	public GameTile TryGetTile (Ray ray) {
		if (Physics.Raycast(ray) {
			return null;
		}
		return null;
	}

Чтобы узнать, было ли пересечение с тайлом, нам нужно больше информации о пересечении. Physics.Raycast может предоставить эту информацию с помощью второго параметра RaycastHit. Это выходной параметр, что обозначается словом out перед ним. Это означает, что вызов метода может присвоить значение переменной, которую мы ему передаём.

		RaycastHit hit;
		if (Physics.Raycast(ray, out hit) {
			return null;
		}

Мы можем встроить объявление переменных, используемых для выходных параметров, поэтому давайте так и сделаем.

		if (Physics.Raycast(ray, out RaycastHit hit) {
			return null;
		}

Нас не волнует, с каким именно коллайдером произошло пересечение, мы просто используем позицию XZ пересечения, чтобы определить тайл. Координаты тайла мы получаем, прибавив к координатам точки пересечения половину размера поля, а затем преобразовав результаты в целые значения. Окончательный индекс тайла в результате будет его координатой X плюс координатой Y, умноженной на ширину поля.

		if (Physics.Raycast(ray, out RaycastHit hit)) {
			int x = (int)(hit.point.x + size.x * 0.5f);
			int y = (int)(hit.point.z + size.y * 0.5f);
			return tiles[x + y * size.x];
		}

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

			int x = (int)(hit.point.x + size.x * 0.5f);
			int y = (int)(hit.point.z + size.y * 0.5f);
			if (x >= 0 && x < size.x && y >= 0 && y < size.y) {
				return tiles[x + y * size.x];
			}

Изменение содержимого

Чтобы можно было изменять содержимое тайла, добавим к GameTile общее свойство Content. Его геттер просто возвращает содержимое, а сеттер утилизирует предыдущее содержимое, если оно было, и размещает новое содержимое.

	GameTileContent content;
	public GameTileContent Content {
		get => content;
		set {
			if (content != null) {
				content.Recycle();
			}
			content = value;
			content.transform.localPosition = transform.localPosition;
		}
	}

Это единственное место, где нужно проверять содержимое на null, потому что изначально у нас нет содержимого. Для гарантии выполним assert, чтобы сеттер не вызывался с null.

		set {
			Debug.Assert(value != null, "Null assigned to content!");
			…
		}

И, наконец, нам нужен ввод игрока. Преобразование щелчка мыши в луч можно выполнить вызовом ScreenPointToRay с Input.mousePosition в качестве аргумента. Вызов нужно выполнять для основной камеры, доступ к которой можно получить через Camera.main. Добавим для этого свойство в Game.

		Ray TouchRay => Camera.main.ScreenPointToRay(Input.mousePosition);

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

	void Update () {
		if (Input.GetMouseButtonDown(0)) {
			HandleTouch();
		}
	}
	void HandleTouch () {
		GameTile tile = GetTile(TouchRay);
		if (tile != null) {
			tile.Content =
				tileContentFactory.Get(GameTileContentType.Destination);
		}
	}

Теперь мы можем превратить любой тайл в конечную точку нажатием курсора.

Несколько конечных точек.

Делаем поле правильным

Хоть мы и можем превращать тайлы в конечные точки, это пока не влияет на пути. Кроме того, мы пока не задали пустое содержимое для тайлов. Сохранение правильности и целостности поля является задачей GameBoard, поэтому давайте передадим ему и обязанность задания содержимого тайла. Чтобы реализовать это, передадим ему ссылку на фабрику содержимого через его метод Intialize, и используем её, чтобы дать всем тайлам экземпляр пустого содержимого.

	GameTileContentFactory contentFactory;
	public void Initialize (
		Vector2Int size, GameTileContentFactory contentFactory
	) {
		this.size = size;
		this.contentFactory = contentFactory;
		ground.localScale = new Vector3(size.x, size.y, 1f);
		tiles = new GameTile[size.x * size.y];
		for (int i = 0, y = 0; y < size.y; y++) {
			for (int x = 0; x < size.x; x++, i++) {
				…
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
			}
		}
		FindPaths();
	}

Теперь Game должен передать свою фабрику полю.

	void Awake () {
		board.Initialize(boardSize, tileContentFactory);
	}

Почему бы не добавить поле конфигурации фабрики в GameBoard?

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

Так как теперь у нас есть несколько конечных точек, изменим GameBoard.FindPaths так, чтобы он вызывал BecomeDestination для каждой и добавлял их все в границу. И это всё, что нужно для поддержки нескольких конечных точек. Все остальные тайлы как обычно очищаются. Затем мы удаляем жёстко заданную конечную точку в центре.

	void FindPaths () {
		foreach (GameTile tile in tiles) {
			if (tile.Content.Type == GameTileContentType.Destination) {
				tile.BecomeDestination();
				searchFrontier.Enqueue(tile);
			}
			else {
				tile.ClearPath();
			}
		}
		//tiles[tiles.Length / 2].BecomeDestination();
		//searchFrontier.Enqueue(tiles[tiles.Length / 2]);
		…
	}

Но если мы можем превращать тайлы в конечные точки, то у нас должна быть возможность выполнять и обратную операцию, превращать конечные точки в пустые тайлы. Но тогда у нас может получиться поле совсем без конечных точек. В таком случае FindPaths не сможет выполнять свою задачу. Это происходит, когда граница пуста после инициализации путей для всех ячеек. Обозначим это как неверное состояние поля, возвращая false и завершая выполнение; в противном случае возвращаем в конце true.

	bool FindPaths () {
		foreach (GameTile tile in tiles) {
			…
		}
		if (searchFrontier.Count == 0) {
			return false;
		}
		…
		return true;
	}

Проще всего реализовать поддержку удаления конечных точек, сделав её операцией переключения. Нажав на пустые тайлы, мы будем превращать их в конечные точки, а нажимая на конечные точки, мы будем их удалять. Но теперь изменением содержимого занимается GameBoard, поэтому дадим ему общий метод ToggleDestination, параметром которого является тайл. Если тайл является конечной точкой, то делаем её пустой и вызываем FindPaths. В противном случае делаем его конечной точкой и тоже вызываем FindPaths.

	public void ToggleDestination (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Destination) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else {
			tile.Content = contentFactory.Get(GameTileContentType.Destination);
			FindPaths();
		}
	}

Добавление конечной точки никогда не может создать неверное состояние поля, а удаление конечной точки — может. Поэтому будем проверять, удалось ли успешно выполнить FindPaths после того, как мы сделали тайл пустым. Если нет, то отменяем изменение, снова превратив тайл в конечную точку, и снова вызываем FindPaths, чтобы возвратиться к предыдущему верному состоянию.

		if (tile.Content.Type == GameTileContentType.Destination) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			if (!FindPaths()) {
				tile.Content =
					contentFactory.Get(GameTileContentType.Destination);
				FindPaths();
			}
		}

Можно ли сделать проверку правильности более эффективной?

С помощью дополнительного поля можно отслеживать, сколько конечных точек есть на поле. Однако это важно только когда игрок пытается удалить последнюю конечную точку, что случается редко. Кроме того, состояние поля может стать неверным и во многих других случаях. Мы просто положимся в определении правильности на FindPaths, который всё равно выполняется очень быстро.

Теперь в конце Initialize мы можем вызвать ToggleDestination с центральным тайлом в качестве аргумента, вместо того, чтобы явно вызывать FindPaths. Это единственный раз, когда мы начинаем с неверного состояния поля, но закончим мы гарантированно с правильным состоянием.

	public void Initialize (
		Vector2Int size, GameTileContentFactory contentFactory
	) {
		…
		//FindPaths();
		ToggleDestination(tiles[tiles.Length / 2]);
	}

Наконец, заставим Game вызывать ToggleDestination вместо того, чтобы задавать само содержимое тайла.

	void HandleTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			//tile.Content =
				//tileContentFactory.Get(GameTileContentType.Destination);
			board.ToggleDestination(tile);
		}
	}

Несколько конечных точек с правильными путями.

Разве мы не должны запретить Game задавать содержимое тайла напрямую?

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

Стены

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

Содержимое

Стены — это ещё один тип содержимого, поэтому добавим в GameTileContentType элемент для них.

public enum GameTileContentType {
	Empty, Destination, Wall
}

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

root

cube

prefab

Префаб стены.

Добавим префаб стены в фабрику, и в коде, и в инспекторе.

	[SerializeField]
	GameTileContent wallPrefab = default;
	…
	public GameTileContent Get (GameTileContentType type) {
		switch (type) {
			case GameTileContentType.Destination: return Get(destinationPrefab);
			case GameTileContentType.Empty: return Get(emptyPrefab);
			case GameTileContentType.Wall: return Get(wallPrefab);
		}
		Debug.Assert(false, "Unsupported type: " + type);
		return null;
	}

Фабрика с префабом стены.

Включение и отключение стен

Добавим в GameBoard метод включения-отключения стен, как мы сделали это для конечной точки. Изначально проверять неверное состояние поля мы не будем.

	public void ToggleWall (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Wall) {
			tile.Content = contentFactory.Get(GameTileContentType.Empty);
			FindPaths();
		}
		else {
			tile.Content = contentFactory.Get(GameTileContentType.Wall);
			FindPaths();
		}
	}

Мы обеспечим поддержку переключения только между пустыми тайлами и тайлами стен, не позволяя стенам напрямую заменять конечные точки. Поэтому создавать стену мы будем только тогда, когда тайл пуст. Кроме того, стены должны блокировать поиск пути. Но каждый тайл должен иметь путь к конечной точке, в противном случае враги застрянут. Для этого нам снова нужно использовать проверку FindPaths, и отменять изменения, если они создали неверное состояние поля.

		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Wall);
			if (!FindPaths()) {
				tile.Content = contentFactory.Get(GameTileContentType.Empty);
				FindPaths();
			}
		}

Включение-отключение стен будет использоваться гораздо чаще, чем включение-отключение конечных точек, поэтому сделаем так, чтобы переключение стен в Game выполнялось основным касанием. Конечные точки можно переключать дополнительным касанием (обычно это правая клавиша мыши), которое можно распознать, передав в Input.GetMouseButtonDown значение 1.

	void Update () {
		if (Input.GetMouseButtonDown(0)) {
			HandleTouch();
		}
		else if (Input.GetMouseButtonDown(1)) {
			HandleAlternativeTouch();
		}
	}
	void HandleAlternativeTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			board.ToggleDestination(tile);
		}
	}
	void HandleTouch () {
		GameTile tile = board.GetTile(TouchRay);
		if (tile != null) {
			board.ToggleWall(tile);
		}
	}

Теперь у нас есть стены.

Почему у меня получаются большие зазоры между тенями соседних по диагонали стен?

Так происходит, потому что кубы стен едва касаются друг друга по диагоналям, а тени немного смещены, чтобы избежать артефактов. зазоры можно отключить, уменьшив смещение освещения, уменьшив far clipping plane камеры и увеличив разрешение карты теней. Например, я уменьшил far plane до 20 и задал нулевое нормальное отклонение освещения. Кроме того, в сочетании со стандартными направленными тенями артефакты создаёт MSAA, поэтому я отключил и его.

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

	public void ToggleDestination (GameTile tile) {
		if (tile.Content.Type == GameTileContentType.Destination) {
			…
		}
		else if (tile.Content.Type == GameTileContentType.Empty) {
			tile.Content = contentFactory.Get(GameTileContentType.Destination);
			FindPaths();
		}
	}

Блокировка поиска пути

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

	GameTile GrowPathTo (GameTile neighbor) {
		if (!HasPath  neighbor == null  neighbor.HasPath) {
			return null;
		}
		neighbor.distance = distance + 1;
		neighbor.nextOnPath = this;
		return
			neighbor.Content.Type != GameTileContentType.Wall ? neighbor : null;
	}

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

	bool FindPaths () {
		…
		foreach (GameTile tile in tiles) {
			if (!tile.HasPath) {
				return false;
			}
		}
		foreach (GameTile tile in tiles) {
			tile.ShowPath();
		}
		return true;
	}

Стены влияют на пути.

Чтобы убедиться, что у стен и в самом деле есть правильные пути, нужно сделать кубы полупрозрачными.

Прозрачные стены.

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

Скрываем пути

Визуализация путей позволяет нам увидеть, как работает поиск пути и убедиться, что он и в самом деле верен. Но её не нужно показывать игроку, или по крайней мере необязательно. Поэтому давайте обеспечим возможность отключения стрелок. Это можно сделать, добавив в GameTile общий метод HidePath, который просто отключает его стрелку.

	public void HidePath () {
		arrow.gameObject.SetActive(false);
	}

Состояние отображения путей — это часть состояния поля. Добавим к GameBoard булево поле, по умолчанию равное false, чтобы отслеживать его состояние, а также общее свойство в качестве геттера и сеттера. Сеттер должен показывать или скрывать пути на всех тайлах.

	bool showPaths;
	public bool ShowPaths {
		get => showPaths;
		set {
			showPaths = value;
			if (showPaths) {
				foreach (GameTile tile in tiles) {
					tile.ShowPath();
				}
			}
			else {
				foreach (GameTile tile in tiles) {
					tile.HidePath();
				}
			}
		}
	}

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

	bool FindPaths () {
		…
		if (showPaths) {
			foreach (GameTile tile in tiles) {
				tile.ShowPath();
			}
		}
		return true;
	}

По умолчанию визуализация путей отключена. Отключим стрелку в префабе тайла.

Стрелка префаба по умолчанию неактивна.

Сделаем так, чтобы Game переключал состояние визуализации при нажатии клавиши. Логично было бы использовать клавишу P, но она также является горячей клавишей включения-отключения режима игры в редакторе Unity. В результате визуализация будет переключаться, когда использована горячая клавиша выхода из режима игры, что выглядит не очень красиво. Поэтому давайте используем клавишу V (сокращение от visualization).

Без стрелок.

Отображение сетки

Когда стрелки скрыты, становится трудно разглядеть расположение каждого тайла. Давайте добавим линии сетки. Скачайте отсюда текстуру сетки с квадратной границей, которую можно использовать как контур отдельного тайла.

Текстура сетки.

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

	[SerializeField]
	Texture2D gridTexture = default;

Поле с текстурой сетки.

Добавим ещё одно булево поле и свойство для управления состоянием визуализации сетки. В данном случае сеттер должен изменять материал земли, что можно реализовать вызовом GetComponent<MeshRenderer> для земли и получив доступ к свойству material результата. Если сетку нужно отображать, то назначим свойству mainTexture материала текстуру сетки. В противном случае назначим ему null. Учтите, что при изменении текстуры материала будут создаваться дубликаты экземпляра материала, поэтому он становится независимым от ассета материала.

	bool showGrid, showPaths;
	public bool ShowGrid {
		get => showGrid;
		set {
			showGrid = value;
			Material m = ground.GetComponent<MeshRenderer>().material;
			if (showGrid) {
				m.mainTexture = gridTexture;
			}
			else {
				m.mainTexture = null;
			}
		}
	}

Сделаем так, чтобы Game переключал визуализацию сетки клавишей G.

	void Update () {
		…
		if (Input.GetKeyDown(KeyCode.G)) {
			board.ShowGrid = !board.ShowGrid;
		}
	}

Кроме того, добавим визуализацию сетки по умолчанию в Awake.

	void Awake () {
		board.Initialize(boardSize, tileContentFactory);
		board.ShowGrid = true;
	}

Неотмасштабированная сетка.

Пока мы получили границу вокруг всего поля. Она соответствует текстуре, но это не то, что нам нужно. Нам нужно отмасштабировать основную текстуру материала, чтобы она соответствовала размеру сетки. Можно это сделать, вызвав метод SetTextureScale материала с именем свойства текстуры (_MainTex) и двухмерным размером. Мы можем использовать непосредственно размер поля, который косвенно преобразуется в значение Vector2.

			if (showGrid) {
				m.mainTexture = gridTexture;
				m.SetTextureScale("_MainTex", size);
			}

without

with

Отмасштабированная сетка с отключенной и включённой визуализацией путей.

Итак, на данном этапе мы получили функционирующее поле для тайловой игры жанра tower defense. В следующем туториале мы добавим врагов.

Репозиторий

PDF

Понравилась статья? Поделить с друзьями:
  • Как написать игровой сценарий
  • Как написать игровой искусственный интеллект unity 2d
  • Как написать игрового бота на python для web
  • Как написать игривое сообщение мужчине
  • Как написать игрек на клавиатуре