Пишем 3D игру под Windows Mobile, ч.1
На хабре достаточно много пользователей коммуникаторов. И вы рассказывали достаточно много историй о том, как вы их использовали в своё время: раскладывали пасьянс, играли в шарики, сидели в интернете, читали книги и в конце-концов, использовали как телефон. А что, если я вам скажу, что на коммуникаторах было аж два API для рисования 3D графики? Причем оба могли уметь хардварное ускорение. Так почему бы не написать 3D игрушку под них, причем с фоллбеком до девайсов без 3D ускорения?
Что будет за игра?
Давайте определимся с вектором развития нашей игры: жанр, проекция, GAPI, поддерживаемые платформы и методы ввода.
Игра будет в классическом жанре скроллшутера. Только шутер у нас 3D, поэтому вид у нас будет сзади нашего космического корабля. Как многие догадываются, в таких играх кораблик обычно никуда не летит, он стоит на месте, а впереди него просто спавнятся и летят в его сторону враги. За это у нас будет отвечать SpawnDirector. Как GAPI мы возьмём D3D Mobile — спец GAPI для Windows Mobile, появившееся с WM5.0(но его можно установить и на Pocket PC 2003). Из платформ мы будем поддерживать от PPC2003, до WM6.5, т.е почти все устройства. Из методов ввода у нас будут как хардварные кнопки с альтернативными биндами(например влево — это dpad и кнопка a, для девайсов с полноценной клавиатурой аля qtek 9100) и тачскрин. В качестве «графа» сцены будет использоваться классическая концепция сущностей — есть World, есть Entity, а игровые объекты наследуются от этого Entity и обновляют своё состояние/рисуют себя сами. Язык будет C#, основной принцип программирования — KISS. Я решил излагать в начале каждого «модуля» концепцию каждого отдельного класса, за что он отвечает и как влияет на общую картину. Возможно тот, кто читает эту статью не имел опыта в разработке игр, или имел самый базовый на готовом движке типа unity, и пытается теперь этот опыт тащить во все остальные сферы — это совершенно необязательно.
Стоит отметить что каких-то разительных отличий разработки именно под WM нет — здесь нет концепции бандлов из iOS или ассетов из Android, тачскрин == мышка, нет удобного DirectSound, но есть классический waveOut. Однако достаточно переносимое кроссплатформенное ядро будет реализовано. Настолько переносимое, что игру можно будет портировать за пару часов на совершенно другое GAPI или платформу(там, где есть .NET).
Хочется отметить, что разработку и отладку мы в первую очередь будем проводить на ПК(не под эмулятором). Постоянно деплоить на КПК неудобно и долго, поэтому у нас будет две версии игры — под ПК для отладки игровой логики, и для коммуникатора для оптимизации и адаптации управления. Именно такая практика позволит сделать ядро игры легко портируемым куда угодно. Изначально бОльшую часть игры я написал деплоя на реальный девайс(дабы понимать потянет ли софтрендер вообще такое качество графики), но когда пришел черед доводки геймплейных механик — портировал игру на ПК. Порт занял где-то 15-20 минут(это не шутка).
Во время портирования игры я свожу к минимуму использование препроцессора и всякие #if WIN32 #if WINMOBILE #if ANDROID и.т.п.
Рендерер
Для такой игры сложный рендерер с крутыми тенями и оптимизациями не нужен. Наоборот — чем меньше тем лучше. Как я уже говорил — Entity сам может себя рисовать, и для этого ему предоставлен фактически один единственный метод — DrawMesh, принимающий в себя сам меш, матрицу трансформации и материал. Установкой рендерстейтов(а большинство рендерстейтов задаются при создании контекста) занимается тоже он, в зависимости от материала. По большей части это текстура(у которой фиксированный сэмплерстейт — repeat режим адрессации текстуры, отключенный мипмаппинг и point фильтрация), и цвет. Ещё например, можно отключить запись в глубину(что используется для фона).
За рендеринг отвечает класс Graphics. Он создаёт контекст, задает рендерстейты, отображает картинку на экран, задаёт трансформацию и проекцию для камеры и.т.п. Текстуры только 2D(никаких кубмап), и загружаются они с помощью встроенного загрузчика текстур(я не увидел смысла писать свой, поскольку у d3d нативный загрузчик очень шустрый) GAPI. Меши содержат в себе VertexBuffer(меши не индексированные), и грузятся из md2(простой и лёгкий формат, грузится только первый кадр анимации). Кроме того, он умеет рисовать текст(TextRenderer) используя нативное API системы.
Фактически порт с WM на ПК у меня занял 10-20 минут, поскольку D3DM очень похож на D3D8, а следовательно, и на D3D9. Всё те же концепции — «вершинные потоки», FVF, texture stages. Нет только шейдеров(только FFP), и есть более тонкая настройка некоторых аспектов(например перспективная коррекция текстур).
Под WM(и вообще старые платформы, в том числе Symbian) нужно соблюдать некоторые правила, например стоит вообще отказаться от альфа-блендинга, и заменить его на альфа-тест. Альфа-тест не нужно сортировать в отличии от блендинга(поэтому он используется например в cutout шейдерах листвы). Здесь нет сглаживания(оно хардварно может не поддерживаться драйвером), а формат бэкбуфера обычно RGB565(вместо ARGB/XRGB). Глубина обычно либо D16, либо D15S1(15 бит под глубину, 1 под трафарет).
Вот например, код заполнения структуры инициализации контекста:
PresentParameters pp = new PresentParameters(); pp.AutoDepthStencilFormat = DepthFormat.D16; pp.BackBufferCount = 1; pp.BackBufferFormat = Format.R5G6B5; pp.BackBufferWidth = Engine.Current.Window.ViewportWidth; pp.BackBufferHeight = Engine.Current.Window.ViewportHeight; pp.EnableAutoDepthStencil = true; pp.PresentFlag = PresentFlag.None; pp.SwapEffect = SwapEffect.Discard; pp.MultiSample = MultiSampleType.None; pp.FullScreenPresentationInterval = PresentInterval.Default; pp.Windowed = true;
Нужно быть готовым к тому, что драйвер может даже не поддерживать VSync(PresentationInterval), кроме Default(интеловский не поддерживает). Напоминает ситуацию с встроенной графикой Intel в нулевых, когда она кое-как поддерживала d3d и очень слабо держала ogl.
Формат вершины имеет позицию и текстурную координату. Нормалей нет, т.к ни освещения, ни отражений нет. На данный момент, они тут не нужны. Все расчёты в float(на девайсах без FPU, ага).
Движок
Громко сказано. Это просто центральный объект, который управляет игровым циклом, считает delta time, создаёт окно и остальные подмодули. А ещё он занимается логированием(один-единственный метод Log) и резолвом ассетов. Выполнен как синглтон с конструктором, который создаёт окошко и задаёт базовые параметры, и методом Init, который инициализирует собственно сами подмодули(адепты RAII покидают этот пост и ставят минус)
Звук
Классика. waveOut. Никакого 3D звука и даже панирования, сырой PCM поток направляется прямо в системный микшер через waveOut. Есть ещё DirectShow, но с ним отваливается PPC2003 — а у меня целых два деваса на этой платформе и терять её я не хочу ;P
WAV 8-битный, моно. Этого качества вполне хватает и оно универсально между платформами.
Ввод
Ввод представлен как стандартный Sony-like контроллер, плюс тачскрин. Никакой абстракции типа GetAxis нет, игра получает инпут ровно от того, что ей нужно. Игра поддерживает как тачскрин(игровое поле поделено на две части — каждое отвечает за свое направление движение кораблика), так и дпад. Тоже самое и в менюшках — в будущем это даст возможность портировать игру, например, на Android TV.
Ввод посылает Window, он же разбирается с назначениями клавиш. Таким образом легко протянуть в игру ввод с XInput, Android геймпадов, XNA геймпадов(если будет порт на wp7). Это лучше чем городить отдельные абстракции типа клавиатуры, мыши, тача и геймпада. Фактически весь инпут это структура Touch(одно касание только) и IsKeyPressed. Всё.
Матлиба
Опять же, громко сказано. Универсальная абстракция, дабы не зависеть от типов векторов/матриц GAPI. Реализован Mathf с широко-используемой математикой(интерполяция, градусы в радианы, clamp) и Vector3/Vector4. Для физики используется BoundingBox.
Сами матрицы не представлены как обёртки над матрицами матлибы того GAPI, что мы используем. Вместо этого есть класс Transform который оперирует с позицией/поворотом(углы Эйлера, в будущем можно легко добавить кватернионы)/масштабом.
public Matrix Matrix; public Vector3 Position; public Vector3 Rotation; public Vector3 Scale; public Transform(Vector3 pos, Vector3 rot, Vector3 scale)
Всё! Это весь «движок»! Без шуток 😉 Теперь переходим к геймплею.
Геймплей
Как я уже говорил — геймплей строится на том, что кораблик летает из точки в точку по одной оси, и уворачивается(и отстреливает) астероиды(однако противников можно добавить в будущем). «Граф» сцены строится на списке игровых объектов, а игровые объекты наследуются от Entity. Так давайте по порядку!
World
Основной класс в игре, в каком-то смысле даже годобжект. Именно он управляет списком сущностей, спавном и деспавном игровых объектов, рисует фон, анимирует его, считает время и счёт и создаёт SpawnDirector. Кроме того, он рисует HUD(Head’s Up Display — «геймплейный» UI).
SpawnDirector же управляет спавном врагов(т.е астероидов) и бонусов в фиксированные промежутки времени в зависимости от сложности. Сложность — нарастающая переменна раз в минуту, которая означает стадию игры. Чем выше стадия, тем быстрее спавнятся и летят астероиды на игрока.
Entity базовый класс для всех игровых объектов. Сам по себе очень примитивный, управляет апдейтами и отрисовкой. Имеет общие для всех объектов свойства — позицию, поворот, масштаб и хитбокс. Адепты противников виртуальных методов могут высказать своё мнение в комментариях(в манагед языке то).
public Vector3 Position; public Vector3 Rotation; public Vector3 Scale; public BoundingBox Bounds; public Entity() < Scale = new Vector3(1, 1, 1); >public BoundingBox GetBounds() < return new BoundingBox(Position.X + Bounds.X, Position.Y + Bounds.Y, Position.Z + Bounds.Z, Bounds.X2, Bounds.Y2, Bounds.Z2); >protected Transform GetTransform() < return new Transform(Position, Rotation, Scale); >public virtual void Update() < >public virtual void Draw()
Player
Собственно, класс игрока. Рисует кораблик, обрабатывает ввод от игрока, обрабатывает эффекты от бонусов и анимацию.
public override void Update() < base.Update(); UpdateFOVEffect(); UpdateInput(); if (nextAttack < 0) < if (Bonus == PlayerBonus.DoubleTheFun) < Projectile p1 = new Projectile(50, 1, false); p1.Position = Position; p1.Position.X -= p1.Bounds.X2; Projectile p2 = new Projectile(50, 1, false); p2.Position = Position; p2.Position.X += p1.Bounds.X2; Game.Current.World.Spawn(p1); Game.Current.World.Spawn(p2); nextAttack = 0.5f; >else < Projectile proj = new Projectile(50, 1, false); proj.Position = Position; Game.Current.World.Spawn(proj); if (Bonus == PlayerBonus.QuickDick) nextAttack = 0.3f; else nextAttack = 0.5f; >> if (BonusTime
Где UpdateFOVEffect — анимирует плавающий угол обзора, создающий эффект полёта и качения, а UpdateInput — собственно обрабатывает ввод и двигает кораблик:
float strafe = 0; if (Engine.Current.Input.IsPressed(Key.Left)) strafe = -1; if (Engine.Current.Input.IsPressed(Key.Right)) strafe = 1; if (Engine.Current.Input.Touch.IsTouching) < if (Engine.Current.Input.Touch.X < Engine.Current.Window.ViewportWidth / 2) strafe = -1; else strafe = 1; >Position.X += strafe * (Speed * Engine.Current.DeltaTime); Rotation.Z = strafe * -RotationEffect; >
Кораблик сам стреляет в определенные промежутки времени, а эффекты бонусов обрабатываются «на месте», без всяких модных IBonusModifier.
Projectile
Собственно, сам снаряд. Спавнит его игрок, но попозже его смогут спавнить и враги в сторону игрока. Каждый кадр «проходятся» по списку сущностей и, если пересекаются с кем-то, то наносят урон и сами себя уничтожают. Урон снаряда, и его направление регулирует тот, кто стреляет.
public override void Update() < base.Update(); foreach (Entity ent in Game.Current.World.EntityList) < if (ent.GetBounds().Intersects(GetBounds())) < if (ent is Enemy) < ((Enemy)ent).Health -= damage; Game.Current.World.Destroy(this); >> > if (lifeTime
Pickup
Очевидно — различные бонусы, которые игра может подкидывать, чтобы игрок не скучал(и не умирал слишком рано). Есть два типа — аптечка и случайный бонус:
public abstract class Pickup : Entity < public const float Speed = 15.0f; public Mesh Mesh; public Material Material; public Pickup() < Mesh = Mesh.FromFile("pickup.md2"); Bounds = new BoundingBox(-15, -15, -15, 15, 15, 15); >public override void Draw() < base.Draw(); Engine.Current.Graphics.DrawMesh(Mesh, GetTransform(), Material); >public override void Update() < base.Update(); if(GetBounds().Intersects(Game.Current.World.Player.GetBounds())) < Pick(); Game.Current.World.Destroy(this); >Rotation.Y += (Speed * 3) * Engine.Current.DeltaTime; Position.Z -= Speed * Engine.Current.DeltaTime; > public abstract void Pick(); > public class HealthPickup : Pickup < public HealthPickup() : base() < Material.Texture = Texture2D.FromFile("health.jpg"); >public override void Pick() < Game.Current.World.Player.Health = Mathf.Clamp(Game.Current.World.Player.Health + 40, 0, 120); >> public class BonusPickup : Pickup < public BonusPickup() : base() < Material.Texture = Texture2D.FromFile("upgrade.jpg"); >public override void Pick() < Game.Current.World.Player.TakeBonus((PlayerBonus)new Random().Next(1, (int)PlayerBonus.MaxBonus - 1)); >>
Enemy
Базовый класс для врагов. Отрисовывает врага, отрабатывает столкновение с игроком. Остальное поведение(в т.ч анимацию и «пуляния»/траекторию отрабатывает дочерний класс).
Asteroid — один из видов врагов. Вот фактически весь код его реализации:
public sealed class Asteroid : Enemy < public const float Speed = 15; public const float Rotate = 56; public const int HPAmount = 20; public Asteroid() < Mesh = Mesh.FromFile("asteroid.md2"); Material.Texture = Texture2D.FromFile("as0.jpg"); Bounds = new BoundingBox(-10, -10, -10, 10, 10, 10); >public override void Update() < base.Update(); if (Game.Current.World.Player.GetBounds().Intersects(GetBounds())) < Game.Current.World.Player.Health -= HPAmount; Game.Current.World.Destroy(this); >Rotation.X += Rotate * Engine.Current.DeltaTime; Position.Z -= Speed * Engine.Current.DeltaTime; > >
Просто и понятно? Я тоже так считаю, и можно легко добавить новых!
HUD
HUD рисует UI во время игры — т.е здоровье, очки и всё такое. Кроме того, он рисует статус игрока(подобран бонус например).
Заключение
Это скорее технодемка, нежели действительно полноценная игра. Её ещё предстоит доводить в следующей статье — вектор развития выбираете вы в голосовании. Хоть на данный момент это технодемка, в целом, получившийся фреймворк можно использовать не только для таких примитивных игр — например можно сделать простенькие гоночки. Или три в ряд. Возможно будет порт на Android. Если вам зашел такой стиль подачи игровой архитектуры — то я могу написать девлоги и по другим демкам, коих у меня несколько. Например «ралли кубок на тазах»(рабочее название, я питаю тёплые чувства к машинам этого автозавода):
Или арена-шутер для мобилок «мощный семён» и его 2D версия
Всем спасибо. Жду вас во второй части, а снизу опрос, на что делать упор.