Основы UnrealScript: Учимся на практике

Содержание:
В предыдущих частях статьи мы ознакомились с основами языка Unreal Script. Обычно, после этого возникает вопрос: а что же дальше? Что нам нужно писать дальше? Какие классы создавать? Ответ до смешного банален - то, что нам нужно. А это значит, в первую очередь надо определить - что же нам, собственно говоря, нужно. Для этого и создают дизайнерский документ (сокращенно диздок).

Обычно, диздок создают еще перед созданием игры, но не смертельно, если мы набросаем его уже после определенного прогресса. Еще одним отступлением от обычного процесса будет то, что наш диздок будет оооочень упрощенным - обычно, он включает все детали игры, вплоть до всех мелочей, у нас же это будет просто набросок, в виде списка, состоящего из целей, которых мы хотим достигнуть в ходе создания игры.
Итак, для начала - стиль игры.
  • Это будет стрелялка с видом сверху на подобие "Alien Swarm" или "Crimsonland".
Зная игры похожего стиля, всегда можно прикинуть, какие элементы геймплея вам будет необходимо создать в коде. Идем дальше:
  • Враги будут появляться за пределами экрана и двигаться к игроку. Игрок должен отстреливаться от врагов, чтобы те не подошли вплотную, иначе получит урон.
Вроде бы, всего два предложения, однако, уже по них можно четко определить, что же нам нужно программировать. Весь пункт выше можно разделить на три подпункта:
  • Враги будут появляться за пределами экрана и двигаться к игроку. Уже тут можно увидеть три отдельных задания. В первую очередь, необходимо, чтобы враги откуда-то появлялись. Значит, нам необходимо будет создать актор, который будет отвечать за создание и размещение врагов. Дальше - враги должны появляться за пределами экрана. Согласитесь, будет не очень приятно, если враг появится сразу же возле персонажа, и у того не будет много времени, чтобы отбиться или убежать. Да и само по себе появление врага в поле зрения игрока будет смотреться не очень. Поэтому определенный участок когда должен будет следить за тем, чтобы враги спавнились только за пределами экрана. Ну и последний пункт - непосредственно создание врагов. Может, необходимо больше одного типа врага? Как насчет слабого, но быстрого противника? Вот моменты, о которых надо задуматься, при создании подобного списка целей.
  • Игрок должен отстреливаться от врагов, чтобы те не подошли вплотную... Тут нюансов поменьше. Сколько будет типов оружия? Как игрок будет их получать? Может, он будет начинать со слабым оружием, и получать более сильное в ходе игры? Определенный функционал для этого уже есть в стандартных классах UDK, однако, нам все равно придется создавать отдельные классы для специфических целей. Также можно добавить апгрейды, которые будут выпадать из врагов. Это значит, нужно будет создать классы непосредственно апгрейдов, прописать, как они будут влиять на оружие или игрока, и изменить классы врагов, чтобы те оставляли после смерти эти апгрейды.
  • ...иначе получит урон. Тут в класс врага нужно внедрить код, который будет отвечать за атаку. При каких условиях допустима атака? Как атаковать игрока? Также код должен включать функционал для нанесения и получения урона.
Как вы сами могли убедиться, даже крохотный список задач может превратиться в довольно большое количество задач для программиста. Может показаться, что это только усложняет дело, однако, разбиение списка на более мелкие задачи позволяет легче определять, какие классы нам нужны, и легче следить за нашим прогрессом.
Можно добавить еще один пункт:
  • Враги будут атаковать волнами, и с каждой волной они будут становиться сильней.
Тут нам понадобится код для отслеживания текущей волны, и для расчета характеристик врагов соответственной сложности.

Ну а теперь давайте реализуем что-то из этого на практике.
Думаю, неплохо начать с оружия, тем более, с ним мы уже немного работали. Помните о наследственности. Если мы хотим, чтобы все типы оружия поддавались апгрейду, соответствующий функционал лучше всего вынести в общий родительский класс для оружия.
Если вы найдете в UnCodeX ветку Actor \ Inventory \ Weapon \ UDKWeapon \ UTWeapon, то увидите, что от последнего класса уже созданы классы ракетницы, и лучевого лучевого ружья. Мы бы могли создавать свои подклассе также на этой основе, однако нам необходим уникальный функционал, поэтому мы создадим отдельную ветку классов оружия.
Для начала, почистим папку наших классов от мусора. Проследите, чтобы Development\Src\TestGame\Classes остались только классы TestActor, TestGame, TestPlayerController.
А теперь создайте класс TestWeapon.uc. Запишите в него следующий код:
class TestWeapon extends UTWeapon;

var int CurrentWeaponLevel;

function UpgradeWeapon()
{
    CurrentWeaponLevel++;
}

defaultproperties
{
}
Таким образом, мы создаем переменную для уровня оружия и, соответственно, функцию для повышения этого уровня. Может возникнуть вопрос - а почему бы не сделать функцию повышения уровня непосредственно в классе апгрейда? Конечно, можно так сделать, однако, куда более практичный подход - изменять значение переменных с помощью функций в классе, которому принадлежат эти переменные. Самый очевидный плюс - отследить вызов функции куда проще, чем изменение переменной напрямую. Таким образом, легче отслеживать возможные проблемы, связанные с соответственными переменными. Так что возьмите за привычку делать так, чтобы все, что может влиять на класс, было внутри самого класса.
Мы не будем помещать на уровень непосредственно TestWeapon, это всего лишь база для наших типов оружия. Поэтому, создаем непосредственно оружие, которое можно будет использовать для тестов. Создайте класс TestWeapon_RocketLauncher.uc со следующим кодом:
class TestWeapon_RocketLauncher extends TestWeapon;

defaultproperties
{
    Begin Object Name=PickupMesh
        SkeletalMesh=SkeletalMesh'WP_RocketLauncher.Mesh.SK_WP_RocketLauncher_3P'
    End Object

    AttachmentClass=class'UTGameContent.UTAttachment_RocketLauncher'

    WeaponFireTypes(0)=EWFT_Projectile
    WeaponFireTypes(1)=EWFT_Projectile
    WeaponProjectiles(0)=class'UTProj_Rocket'
    WeaponProjectiles(1)=class'UTProj_Rocket'

    AmmoCount=30
    MaxAmmoCount=30
}
По сути, мы просто копируем графические данные ракетницы с UT3, в качестве снарядов задаем ракеты, и увеличиваем стартовое и максимальное количество боеприпасов к 30.
Скомпилируйте код. Для того, чтобы игрок мог получить оружие во время игры, нужно поместить на уровень особый актор. Откройте браузер контента, перейдите на вкладку Actor Browser найдите ""NavigationPoint \ PickupFactory \ UDKPickupFactory \
UTPickupFactory \ UTWeaponPickupFactory и поместите его на карту. Этот актор предназначен для регулярного создания оружия как подбираемого предмета. Откройте его свойства, и выставите в Weapon Pickup Class наш TestWeapon_RocketLauncher"".
Запустите уровень. Все как и ожидалось - мы получаем ракетницу с 30 патронами.
Теперь нам необходимо создать класс непосредственно предмета для апгрейда. Поскольку функционал в нем будет предельно простой, нам не нужно отталкиваться от уже существующих классов - будет достаточно сделать его дочерним для TestActor. "Почему не Actor?", спросите вы. Все очень просто - для организации. Если бы мы делали наш апгрейд от Actor, в браузере акторов он бы отображался как его подкласс, которых довольно немало, и было бы довольно легко запутаться. Поэтому делая класс как подкласс TestActor, мы автоматически относим его в соответственную ветку, и нам становится куда легче найти наш собственный контент.
Для начала полностью очистите наш TestActor
class TestActor extends Actor;

defaultproperties
{
}
Теперь создадим для него новый подкласс под названием TestWeaponUpgrade
class TestWeaponUpgrade extends TestActor
    placeable;

event Touch(Actor Other, PrimitiveComponent OtherComp, vector HitLocation, vector HitNormal)
{
    if(Pawn(Other) != none && TestWeapon(Pawn(Other).Weapon) != none)
    {
        TestWeapon(Pawn(Other).Weapon).UpgradeWeapon();
        Destroy();
    }
}

defaultproperties
{
    bCollideActors=True
    Begin Object Class=DynamicLightEnvironmentComponent Name=MyLightEnvironment
        bEnabled=TRUE
    End Object
    Components.Add(MyLightEnvironment)
    
    Begin Object Class=StaticMeshComponent Name=PickupMesh
        StaticMesh=StaticMesh'UN_SimpleMeshes.TexPropCube_Dup'
        Materials(0)=Material'EditorMaterials.WidgetMaterial_Y'
        LightEnvironment=MyLightEnvironment
        Scale3D=(X=0.125,Y=0.125,Z=0.125)
    End Object
    Components.Add(PickupMesh)

    Begin Object Class=CylinderComponent Name=CollisionCylinder
        CollisionRadius=16.0
        CollisionHeight=16.0
        BlockNonZeroExtent=true
        BlockZeroExtent=true
        BlockActors=true
        CollideActors=true
    End Object
    CollisionComponent=CollisionCylinder
    Components.Add(CollisionCylinder)
}
Вроде бы, довольно большой кусок кода, однако, все предельно просто. Мы используем событие* Touch, которое вызывается, когда два актора сталкиваются. Внутри мы проверяем, является ли актор, с которым столкнулся наш апгрейд, представителем класса Pawn, и является ли оружие, которое он держит, представителем класса TestWeapon. Вспомним прошлую подстатью, так как тут присутствуют два тайпкаста. Первый - Pawn(Other). Поскольку событие Touch передает параметр Other как переменную класса Actor, мы должны проверить, является ли она представителем класса Pawn. Если проверка проходит, мы можем быть уверены, что это так.
Второй тайпкаст - TestWeapon(Pawn(Other).Weapon). Поскольку Other все еще представитель класса Actor, тайпкастом мы "оборачиваем" ее в класс Pawn, так как нам нужна переменная Weapon, которой нет в Actor, но которая есть в Pawn, после чего мы проверяем, является ли эта переменная представителем класса TestWeapon, так как в будущем мы собираемся вызывать в ней функцию UpgradeWeapon, которой нет в классе Weapon, но которая есть в TestWeapon. Если все эти проверки дали положительный результат, мы вызываем соответственную функцию, соблюдая все тайпкасты.
По сути, все это можно было бы записать, создавая соответственные переменные (да и немного понятней становится, что, где и почему):
event Touch(Actor Other, PrimitiveComponent OtherComp, vector HitLocation, vector HitNormal)
{
    local Pawn TestPawn;
    local TestWeapon MyWeapon;

    TestPawn = Pawn(Other);
    MyWeapon = TestWeapon(TestPawn.Weapon);
    if(TestPawn  != none && MyWeapon != none)
    {
        MyWeapon.UpgradeWeapon();
        Destroy();
    }
}
Однако, как вы видите, это едва ли не в два раза больше строчек, и две лишние переменные. Поэтому лучше разобраться с тайпкастом и пользоваться первым методом записи.
Теперь, вернувшись к теме "Функция или прямое изменение переменной", представим, будто повышение уровня происходит прямым изменением переменной:
TestWeapon(Pawn(Other).Weapon)CurrentWeaponLevel++;
А теперь представим, что нам необходимо модицифировать систему апгрейда, чтобы у оружия был ограниченный максимальный уровень. Тогда. нам бы понадобилось много вспомогательного кода, чтобы проверить, максимальный уровень, и т.д.. Более того, если бы этот актор был не единственным методом повышения уровня, надо было бы и в другие места изменения переменной вносить соответственные модификации. А так, мы можем просто внести правки в функцию UpgradeWeapon, не трогая места, где она вызывается. Вот так.
Ну, и после выполнения функции, мы уничтожаем актор.
Теперь перейдем к свойствам актора. Переменная bCollideActors как раз говорит нашему актору, что он должен реагировать на столкновения вызовом события Touch. Дальше, мы добавляем графику, в виде обычного куба с зеленым материалом. Дальше - добавляем небольшое свечение к объекту. Ну и напоследок задаем параметры коллизии.
Внесите маленькое изменение в функцию апгрейда, дабы мы могли отследить, работает ли она.
function UpgradeWeapon()
{
    CurrentWeaponLevel++;
    `log("Current Weapon Level:" @ CurrentWeaponLevel);
}
Скомилируйте код, откройте редактор, откройте браузер акторов, найдите наш апгрейд, и поместите на уровень.
Запустите тестовый уровень, подберите апгрейд и проверьте лог.
[0009.47] ScriptLog: Current Weapon Level: 1
Все работает правильно. Теперь установим максимальное значение для уровня, и сделаем, чтобы он влиял на свойства оружия.
Внесите соответственные правки в код TestWeapon
class TestWeapon extends UTWeapon;

const MAX_LEVEL = 5;

var int CurrentWeaponLevel;

function UpgradeWeapon()
{
    if(CurrentWeaponLevel > MAX_LEVEL)
        CurrentWeaponLevel++;
}

defaultproperties
{
}
Const - особый тип данных, которые невозможно поменять во время игры. Их можно использовать, чтобы задавать определенные величины, которые и не должны меняться, однако которые мы, по необходимости, можем менять, меняя в скрипте. Если записать просто CurrentWeaponLevel > 5, то каждый раз, когда мы захотим поменять максимальный уровень, нам придется искать эту строчку, и менять значение, а ведь оно может фигурировать не только тут. Вообще, это можно сделать и обычной переменной, с заданным в def props значением, однако, использована именно константа, просто чтобы показать, что так можно. Очевидно, что указанный код не позволит уровню повышаться выше 5. Для теста, поместите на тестовую локацию 6 апгрейдов.
Соберите все апгрейды, и посмотрите в лог.
[0008.29] ScriptLog: Current Weapon Level: 1
[0008.60] ScriptLog: Current Weapon Level: 2
[0008.87] ScriptLog: Current Weapon Level: 3
[0010.24] ScriptLog: Current Weapon Level: 4
[0011.76] ScriptLog: Current Weapon Level: 5
[0012.55] ScriptLog: Current Weapon Level: 5
Изменение функционала можно сделать в виде увеличения скорострельности. Объявите в классе оружия новый массив:
var float FireRates[MAX_LEVEL];
Вот тут можно увидеть преимущество констант перед переменными - они сразу получают свое значение, и поэтому их можно использовать, например, в качестве размера массива.
Теперь в блоке defaultproperties задайте значения элементам массива:
FireRates(0)=1.5
FireRates(1)=1.0
FireRates(2)=0.5
FireRates(3)=0.3
FireRates(4)=0.1
Получается, чем больше индекс элемента массива, тем меньше промежутки между выстрелами, и, соответственно, выше скорострельность. Не забывайте, что в свойствах по умолчанию индекс массива записывается в круглых скобках, а не квадратных. Теперь добавьте следующую строчку в функцию UpgradeWeapon:
FireInterval[0] = FireRates[CurrentWeaponLevel – 1];
Не забываем, что нумерация элементов массива начинается с нуля, поэтому от значения CurrentWeaponLevel нужно отнимать единицу, так как максимальному уровню 5 соответствует FireRates[4].
Нам нужен еще маленький кусочек кода, чтобы автоматическая стрельба (при зажатой левой кнопке мыши) прерывалась, когда мы подбираем апгрейд, иначезначение интервала выстрелов изменится, пока мы не перестанем стрелять.
if(IsInState('WeaponFiring'))
{
    ClearTimer(nameof(RefireCheckTimer));
    TimeWeaponFiring(CurrentFireMode);
}
Мы не будем детально разбирать этот код, однако вы можете самостоятельно заглянуть в классы выше нашего TestWeapon и увидеть, что делает та или иная переменная или функция. Это, кстати, довольно полезно.
Ну и последний штрих - сделаем, чтобы каждый апгрейд обновлял запас патронов оружии. Добавьте в конец функции:
AddAmmo(MaxAmmoCount);
Нам не нужно переживать, что количество патронов станет больше максимального, так как это уже предусмотрено в самой функции AddAmmo в UTWeapon.uc:
AmmoCount = Clamp(AmmoCount + Amount,0,MaxAmmoCount);
То есть, каждый раз, когда мы добавляем боеприпасы, их количество меняется таким образом, чтобы не быть больше максимально допущенного, или ниже нуля.
В конце концов, наш класс должен выглядеть следующим образом:
Класс TestWeapon.uc
class TestWeapon extends UTWeapon;

const MAX_LEVEL = 5;

var int CurrentWeaponLevel;
var float FireRates[MAX_LEVEL];

function UpgradeWeapon()
{
    if(CurrentWeaponLevel < MAX_LEVEL)
        CurrentWeaponLevel++;
    FireInterval[0] = FireRates[CurrentWeaponLevel - 1];

    if(IsInState('WeaponFiring'))
    {
        ClearTimer(nameof(RefireCheckTimer));
        TimeWeaponFiring(CurrentFireMode);
    }

    AddAmmo(MaxAmmoCount);
}
defaultproperties
{
    FireRates(0)=1.5
    FireRates(1)=1.0
    FireRates(2)=0.5
    FireRates(3)=0.3
    FireRates(4)=0.1
}
Осталось лишь одно - выставить параметр скорострельности по умолчанию для ракетницы. Добавьте в свойства ракетницы:
FireInterval(0)=1.75
FireInterval(1)=1.75
Скомпилируйте код, запустите тестовую локацию, и попробуйте пострелять с различным уровнем оружия.

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