воскресенье, 28 апреля 2013 г.

Движение врагов по траектории (по контрольным точкам).

Всем привет.




Сделал траектории для врагов. Написание данного поста постоянно упорядочивало мои мысли. По-этому при написании рефакторился код. А после рефакторинга переписывался пост. И все по новой. Отчасти по-этому я так долго не мог выпустить данный пост. Отчасти же погоду испортил WOT со своими акциями то на КВ-1С то на ИС...
Вообще подходов к программированию траекторий может быть много. В моем случае это траектория по контрольным точкам. Мне так удобнее потом дезайнить уровень. Смотрим рисунок:
      Любое пересечение черных линий - это контрольная точка. Любая контрольная точка имеет свой номер, равный сумме значений по горизонтали и вертикали. "42" - это верхний левый угол уровня, а "192" (180+12) - нижний правый угол уровня.  Если я хочу, чтобы враг вылетел на экран сверху по середине уровня, то его первой командой будет, например, "27". Думаю, достаточно вас запутал. Попробую графически объяснить. Пусть врагу задается такая серия команд:  "25-143-91-168-233". Вот как эта траектория будет выглядеть:
       Надеюсь теперь всем понятно, что я имею ввиду, когда говорю о контрольных точках. Позже я расскажу о том, что "контрольная точка" и "команда" в моем понимании - разные вещи. 
       Далее я объясню, зачем мне нужны контрольные точки за пределами экрана. Ближайшая линия, например первая горизонталь - для того,чтобы враги с нее стартовали. То есть чтобы они возникали за экраном и затем вылетали на экран. Ну а самая крайняя линия - линия смерти. Все что вылетело выше нулевой линии или ниже линии B - уничтожается. То же относится к нулевой горизонтали и горизонтали E. Если взять нарисованную выше траекторию и пустить по ней объект, то он уничтожится после прохождения последней точки. Причем просто уничтожится, без взрыва. 

      Для полноты вышесказанного приведу простейший код, заполняющий данные команд. В двойном цикле заполняются стандартные команды полета, имеющие координаты. А команды с номером болше 239 - специальные. О них поговорим позже.
// some code
for (int i = 0; i < 14; i++)
    for (int j = 0; j < 11; j++)
 FlyProgramCommandMap.put((j*20 + i), new FlyProgramCommand(i*64-128 , j*64-128));
for (int i = 0; i < 10; i++)
 FlyProgramCommandMap.put((240 + i), new FlyProgramCommand(-300 , i));
for (int i = 0; i < 10; i++)
 FlyProgramCommandMap.put((250 + i), new FlyProgramCommand(-301 , i));
for (int i = 0; i < 10; i++)
 FlyProgramCommandMap.put((260 + i), new FlyProgramCommand(-302 , i));  
// some code
 public class FlyProgramCommand{
    public float x, y;
    public FlyProgramCommand(float pX, float pY){
     this.x = pX; this.y = pY;
    }
   }  
Теперь далее.      В моей версии движения так же будет использоваться время. Время полета от одной точки до другой. Изначально в XML мы задаем только команды для полета. А время полета от одной точки до другой - вычисляется в коде. Время полета у разных объектов будет разным. Есть враги -тяжеловесы с большими пушками. А есть маленькие и юркие враги без оружия, но летающие стайками, гораздо более быстрые. Время полета до следующей точки будем вычислять через расстояние и скорость. Скорость мы так же определим уже в самом объекте.      Объект у нас состоит из частей, некоторые из них будут влиять на маневренность и скорость. Когда я делал игру про механических врагов - там были двигатели. И чем больше и мощнее движки у врага - тем он более быстр. Здесь у нас пришельцы. Пусть все их маленькие щупальца отвечают за движение. Чем больше будет у врага щупалец - тем он проворнее. При создании врага просто будем проверять ID каждой составной части. И если эта часть отвечает за движение - будем прибавлять некоторую величину к итоговой линейной и угловой скорости.       При очередном рефакторинге я решил, что логичнее вынести функцию движения в отдельный класс. Так появился класс ObjectEngine.java. В наш EnemyObject мы запишем его просто как поле. А затем в конструкторе вычислим все исходные данные для создания "двигателя" и вызовем его конструктор. Вот как теперь выглядит конструктор EnemyObject.
//...
public ObjectEngine mEngine;
//...
public EnemyObject(int id, int flyProgramID, int flyProgramShift) {
  
 this.setX(0); this.setY(0);
 this.mX = (mDB.FlyProgramCommandMap.get(mDB.FlyProgramMap.get(flyProgramID).command.get(0))).x 
               + flyProgramShift;
 this.mY = (mDB.FlyProgramCommandMap.get(mDB.FlyProgramMap.get(flyProgramID).command.get(0))).y;
 
 this.total = mDB.EnemyConstructionMap.get(id).total;
 this.totalAlive = this.total;
 
 int mass = 0;
 float vR = 0, vA = 0;
 for (int i = 0; i < mDB.EnemyConstructionMap.get(id).total; i++){   
  this.dependList.add(mDB.EnemyConstructionMap.get(id).dependList.get(i));
  HealthObject tmp = new HealthObject(mDB.EnemyConstructionMap.get(id).partsList.get(i),
   mX + mDB.EnemyConstructionMap.get(id).connectionX.get(i) + flyProgramShift,
   mY + mDB.EnemyConstructionMap.get(id).connectionY.get(i),
   0, 0);      
  this.attachChild(tmp);
  mass += tmp.GetHealth();
  switch (tmp.GetTypeOfObject()){
  case 201: {vR =+ 30; vA += 1.5f; break;} //eye
  case 240: {vR =+ 100; vA += 3.0f; break;}   //engine
  default:{};
}}
  mEngine = new ObjectEngine(flyProgramID, flyProgramShift, vR, vA, mass);
}
     Строчки 5 и 7 берут координаты первой контрольной точки из первой команды. Это точка "рождения" нашего объекта.      В строчке 12 появляется некая локальная переменная "масса". Ниже мы увидим, что чем болше масса, тем более неповоротливым и медленным становится объект. Об этом чуть позже.      В строчке 21 видно, что масса всего объекта равна сумме здоровья всех его частей. Суммируются даже те части, которые позже не будут участвовать в коллизиях. То есть, если уточнить вышесказанное - чем больше здоровья у объекта, тем он медленнее.      В строчках 23 и 24 есть очень интересные переменные. vA - угловая скорость в секунду. vR - радиальная скорость в секунду. То есть если в объекте попадется запчасть с ID=240, то суммарная радиальная скорость объекта увеличится на 100 единиц в секунду, а угловая - на 3 в секунду. Кстати, 240-я запчасть - это двигатель. Такой с красными щупальцами. А 201 - это часть с глазиком. Ведь есть же у меня маленькие объекты с глазиком но без двигателя. ...Вот пишу этот пост а сам думаю. Надо бы убрать у глазастой запчасти функции двигателя. И передать их боковым колышущимся щупальцам. Думаю так будет логичнее. Но это я сделаю после написания поста, иначе я так его никогда не выложу... ... отвлекся что-то я... Ну и в строчке 29 создается "двигатель" объекта по следующим параметрам: программа полета, сдвиг программы полета, радиальная скорость, угловая скорость, масса. Вот конструктор "двигателя":
ObjectEngine(int flyProgramID, int flyProgramShift, float pVR, float pVA, float pMass){
 this.mX = (mDB.FlyProgramCommandMap.get(mDB.FlyProgramMap.get(flyProgramID).command.get(0))).x 
          + flyProgramShift;
 this.mY = (mDB.FlyProgramCommandMap.get(mDB.FlyProgramMap.get(flyProgramID).command.get(0))).y;
 
 this.vR = pVR * 500/pMass;
 this.vA = pVA * 500/pMass;
 
 totalFlyProgramCommands = mDB.FlyProgramMap.get(flyProgramID).total;
 for (int i = 0; i < totalFlyProgramCommands; i++)
  flyProgram.add(mDB.FlyProgramMap.get(flyProgramID).command.get(i));
}
     Стартовые координаты мы берем из первой команды полета. (строчки 2 и 4)      Строчки 6 и 7... Здесь мы уменьшаем нашу скорость пропорционально массе объекта. Чем тяжелее объект - тем он медленнее и неповоротливее. Ну а множитель 500 выведен опытным путем проб и ошибок.      Ну а в строчках 9-11 мы загружаем программу полета. Хотя можно было бы в коде каждый раз брать значения из базы данных... но мне мое решение показалось более читабельным. Хотя, возможно, оно менее производительно. Возможно, обращаться каждый раз в базу данных - менее накладно, чем хранить список команд в каждом объекте. Об этом я подумаю в другой раз...

     Вот теперь самое интересное. Функция Update(dt).
public void Update(float dt){
flyTime += dt;
if (flyTime < targetTime) 
{ if (flyProgram.get(currentFlyCommand) < 250){
  if (flyProgram.get(currentFlyCommand) > 241){
   targetX = mHero.getX(); targetY = mHero.getY();}
  float deltaX = targetX - this.mX;
  float deltaY = targetY - this.mY;   
  double target_point_angle = Math.atan2(deltaX,deltaY);
  if (Math.cos(target_point_angle - currentAngle) > 0){
   if (Math.sin(currentAngle - target_point_angle) > 0.1f)
    currentAngle -= vA * dt;
   else if (Math.sin(currentAngle - target_point_angle) < -0.1f)
    currentAngle += vA * dt;}
  else {
   if (Math.sin(currentAngle - target_point_angle) > 0)
   currentAngle  -= vA * dt;
  else currentAngle += vA * dt;}
}}
 else{  // if current fly-program is out of time
  flyTime = 0;
  currentFlyCommand += 1;
  
  // current command type definition
  {if (currentFlyCommand > totalFlyProgramCommands - 1)
   currentFlyCommand = 0;
  if (flyProgram.get(currentFlyCommand) >= 260) // cycling command
   currentFlyCommand = flyProgram.get(currentFlyCommand) - 270;
  
  // current command coordinates definition
  if (flyProgram.get(currentFlyCommand) < 240){ //standart "fixed-point-fly-command" 
   targetX = mDB.FlyProgramCommandMap.get(flyProgram.get(currentFlyCommand)).x;
   targetY = mDB.FlyProgramCommandMap.get(flyProgram.get(currentFlyCommand)).y;}
  else{ // special commands   
   targetX = mHero.getX();
   targetY = mHero.getY();}}
  
  // calculating time for current command
  if (flyProgram.get(currentFlyCommand) < 241){
   float x = mX - targetX;
   float y = mY - targetY;
   targetTime = ((1 - 1.5f * dt) * FloatMath.sqrt(x*x + y*y))/ vR;}
  else 
   targetTime = flyProgram.get(currentFlyCommand) % 10;  
  
  }
 this.SetSpeed(vR * FloatMath.sin(currentAngle), vR * FloatMath.cos(currentAngle));   
 this.mX += this.vX * dt;
 this.mY += this.vY * dt;
}
      В строчке 2 мы увеличиваем счетчик времени.
      В строчке 3 мы проверяем время полета. Если оно больше targetTime - это значит, что время текущей команды истекло и надо переходить к следующей.
      Проще объяснить "с конца". То есть начать с объяснения той части функции, которая отвечает за обсчитывание новой команды полета. К тому же тут есть одна особенность. Изначально полю targetTime присвоено значение -1. Таким образом при первом проходе функции Update мы обязательно попадем во вторую ее часть. Если бы я так не сделал, нужно было бы изначально в конструкторе вычислить все параметры полета - тип второй команды, направление на нее, время до нее и т.п. А затем уже в Update делать все то же самое для других команд. Было бы дублирование кода.
      Итак, строчка 3 выбада нам false и мы попали в строчку 20.
      Для команд 0-239 нужно взять из базы данных значения Х и У для статической точки на экране. А также вычислить время полета до этой точки, исходя из радиальной скорости объекта.
     Для команды 240 - почти то же самое. только вместо координат из базы данных мы возьмем мгновенные координаты героя.
     Для команд 241-259 надо вычислить время полета делением по модулю 10. То есть команда 241 будет выполняться 1 секунду, 242 - 2 секунды, 254 - 4 секунды, 267 - 7 секунд.
     Команды 241-249 - это команды постоянного наведения на игрока.
     Команды 250-259 - Это "продолжать двигаться в том же направлении"
     Команды 260 и старше - это команды зацикливания. 260 - перейти к выполнению нулевой команды в программе полете, 261- к первой команде, 262 - ко второй и т.д.
     Я пытался объяснить это все подробно, но получалось запутано. По-этому я просто оставлю описания команд. И небольшое замечание - для некоторых команд происходит вычисление "мусорных" значений. Например, для команд старше 260 не надо вычислять ни время выполнения ни значения targetX и targetY.  Однако, я оставил так как есть. Думаю проще выполнить пару мусорных вычислений, чем делать гораздо более сложную структуру из IF-ELSE.
     Кстати, в строчке 45 есть интересный множитель (1 - 1.5f * dt). Он понадобился для устранения эффекта, схожего с туннельным. Без этого множителя иногда возникают неприятные косяки, если в программе полета после текущей команды будет команда 251-259.

     Теперь вкратце опишу то, что происходит, если время не истекло и текущая точка все еще "актуальна". Если время не истекло - идем в строчку 4, где проверяем номер текущей команды.                  
     Команды 0-240 - это команды полета к определенной точке на экране.
     А команды 241-249 - это команды преследования игрока. И для тех и для других надо ежекадрово обсчитывать текущие значения направлений. Собственно, направлений два. То, куда мы в данный момент летим. И то, куда нам надо лететь. И если эти направления не совпадают, нам надо поворачивать направление движения объекта. Как быстро мы можем поворачивать - зависит от угловой скорости, которую мы вычислили в конструкторе.  
     В строчках 7-18 происходит волшебство, поворачивающее наш объект в сторону точки, в которую должен лететь объект. Объяснять все строчки не буду. Если кто-то захочет, он может сам взять листик в клеточку, вспомнить синусы-косинусы и полярные координаты. И сам вычислит приметно такую-же логическую конструкцию. Вкратце можно сказать вот что.
     В строчках с 10 по 14 просчитывается случай. когда угол между вектором движения объекта и направлением на цель меньше 90 градусов.
     А в строчках 15-18 - для угла больше 90 градусов. То есть если объект летит от цели и его нужно разворачивать. Еще кое-что.
     В строчках 11 и 13 можно увидеть интересное значение 0.1f. Если вместо него поставить 0, то объект будет дергаться то влево то право, потому что точное мгновенное направление движения объекта никогда не совпадет с направлением на цель. Вот я и ввел небольшую погрешность в 0.1f. То есть если объект летит почти на цель - он не будет разворачиваться. С одной стороны, визуально такая погрешность незаметна. С другой позволяет избежать дерганья. Да что я вам рассказываю - поменяйте на 0 и сами увидите что будет.

     В строчках 47-49 вычисляются координаты, которые нам так нужны.

      С этим кодом все. В результате мы получим объект, который стремится в указанную нами точку в течение вычисленного нами времени. Под термином "стремится" я подразумеваю, что объект поворачивает свой вектор скорости и старается его направить в сторону цели. Как быстро он будет поворачиваться - зависит от его угловой скорости. Как быстро он летит - от радиальной скорости. И тут возникает "эффект сглаживания углов" (сам назвал :) ). Давайте подумаем, что будет с нашим объектом, если он полетит по траектории, которую я нарисовал выше. Изначально объект стартует с нулевым угловым направлением. В моем случае, это направление ровно вниз. А направление на цель - не ровно вниз а чуть левее. В каждом следующем кадре объект будет немного "поворачивать" в сторону цели. Для такого движения это будет почти не заметно. Если объект достаточно поворотливый (с большим значением мгновенной угловой скорости) - мы не заметим этого небольшого "доворота". Однако, когда объект подлетит к точке 143 - его время полета для данной точки закончится и он переключиться на точку 91. Он не сможет мгновенно резко развернуться, а сделает это плавно, в течение нескольких кадров. Более того, если еще немного понаблюдать и подумать, то можно заметить, что объект вообще не долетает до контрольной точки, потому что его время полета для данной точки заканчивается чуть раньше, чем он успеет до нее долететь. Заканчивается оно раньше, потому что объект потратил немного времени на разворот в предыдущей точке. Ниже для наглядности нарисованы 2 траектории. Синяя - для объекта с большим значением угловой скорости, более "поворотливым". А зеленая пунктирная - для более неповоротливого объекта с низкой угловой скоростью.
Почти все.
В нашем случае команды 15-19, 35-39 55-59 и т.д. остались не задействованными.
Так же я не до конца сделал сдвиг траектории. Надо бы для каждой статической точки для команд 0-239 делать прибавление значения flyProgramShift к значению X.Долго это объяснять. Просто поверьте, что так будет намного легче жить и проще дизайнить уровни.


Комментариев нет:

Отправить комментарий