Это снова я! Надеюсь никто не подумал, что glVehicles заброшен, верно? Дело в том, что в последнее время несколько человек у меня спрашивали по поводу тех или иных вещей, реализуемых с помощью Box2d. В итоге я понял, что простенькие демки - это хорошо, их открытый код - это замечательно, но все же разобраться в нем не всегда просто. Поэтому я решил написать более-менее подробные комментарии к реализации демки (пока только одной - комментировать всё нет времени). Жребий пал на луноход, как наиболее прямолинейный в плане функциональности. Так что, если какие-то моменты были не очевидны, заставляли проклинать создателя демок, раздражаться при чтении кода - тогда стоит прочесть данное сообщение! Возможно, что-нибудь станет яснее! Аккуратно! Дальше много кода и букв... |
Итак, я буду параллельно читать свой код и немедленно делать пометку сюда - так, надеюсь, ничего не ускользнет от моего внимания.
Во-первых, стоит немного разделить всю функциональность на несколько моментов:
- инициализация программы
- основная физика уровня (создание изначальных препятствий, движения физических коробок)
- физика лунохода
- управление луноходом
- отрисовка всего на уровне
- дополнительно: текст, фпс
Теперь, разбив всю логику на такие составные части, мне будет проще строить комментарии.
Давайте взглянем на форму - изначально на ней ничего нет. Как же появляются glscene-объекты и все остальное? Все создается run-time. Уметь создавать все элементы программным (а не визуальным) способом зачастую важно:
- можно легко перенести проект с Delphi на Lazarus
- в Turbo Delphi есть ограничение на установку DesignTime-компонент
- становится легко выносить определенные менеджеры наружу, отвязывая их от формы
- не нужно тыкать по всем компонентам, чтобы узнать их настройки (и уж тем более читать dfm-файл) - все события и свойства указаны в коде
Поэтому мышь можно отложить в сторону - ведь для навигации по коду нужна только клавиатура.
Мы остановились на инициализации приложения, вот оно:
procedure TfrmDemo5.FormCreate(Sender: TObject); begin fFpsTimer := 0; CreateDemo(self, fGlScene, fCadencer, fViewer); InitDemo(fGlScene, fCamera, fMainDummy, fRenderer); fViewer.Camera := fCamera; fCadencer.OnProgress := OnCadencerProgress; fRenderer.OnRender := OnDirectRender; InitFont; CreatePhysicsDemo; end;
Сюда мы "придем" в первую очередь - срабатывает событие на создание формы и мы попадаем в обработчик.
Читаем по строчкам:
- выставляем таймер ФПС в нуль
- создаем основные объекты glscene. Так как из демки в демку этот код дублируется, поэтому я вынес его во внешний модуль uDemoInit, где создал процедуру CreateDemo(), внутри которой создается каденсер (в выключенном состоянии), сцена, viewer (который растягивается на всю клиентскую область)
- продолжает инициализироваться демка: создается камера, базовый даммикуб и объект класса TGLDirectOpenGL для дебаг-отрисовки.
- viewer'у выставляется текущая камера
- устанавливаем обработчик события на срабатывание cadencer'а - по сути игровой update'ер, обычно почти самое важное событие в игре
- устанавливаем обработчик события на рендер нашего GLDirectOpenGL 'а
- инициализируем шрифт и хелп-текст
- создаем физическую основу. Давайте заглянем внутрь CreatePhysicsDemo() и остановимся там подробнее...
Сперва нужно создать мир Box2d, в котором будут жить наши тела, сочленения, силы и скорости! В моем порте это выглядит так:
fb2World := TBox2d_World.Create(1/100, 20);fb2World.InitWorld(VectorMake(0, -10, 0), VectorMake(-50, -50, 50, 50), false);
аргументами при создании объекта класса TBox2d_World являются время, через которые будет проходить физическая симуляция, а также количество итераций, которые будут проведены для достижения стабильности вычисления. Первое значение обычно выставляют в зависимости от значения ФПС, которое будет держаться в игре, второе - в зависимости от предельных сил и скоростей, которые будут. Если в какой-то момент будет видно, что сочленения "рвутся" под напряжением больших сил, или то, что тяжелые тела проваливаются в более легкие - можно попробовать увеличить количество итераций вычислений (второй аргумент) - но знайте, что таким образом добавляется нагрузка на процессор. Нужно искать золотую середину.
Метод же InitWorld() создает сам Box2d-мир, первым вектором задает вектор гравитации, вторым - границы физического мира (кстати, начиная с версии Box2d 2.02 этого делать не нужно, но порт был адаптирован под более ранние версии), третий параметр - стоит ли создавать физические границы-бортики.
Далее задаем обработчик события:
fb2World.OnBeforeSimulation := OnSimulation;
теперь, перед физической симуляцией, мы сможем выставлять необходимые силы и моторы в нашем методе OnSimulation().
Так как в этой демке появляются сенсоры, при контакте с которыми происходит то или иное событие (поднимается лифт, например), поэтому нам также необходимо "слушать" контакты, чтобы, при соприкосновении с сенсорами, активировать интерактивные события:
b2_World_SetContactListenerCallBack(fb2World.Box2d_World, OnProceedContactCallBack);
Осталось еще создать два класса-списка, внутри первого из которых будут храниться все тела, внутри второго - все сочленения.
fb2BodyList := TBox2d_ObjectsList.Create(fb2World);fb2JointList := TBox2d_JointsList.Create(fb2World);
Итак, мир создан, теперь можно наполнять его физическими телами.
Если посмотреть на список методов класса TBox2d_ObjectsList, то можно найти основные, с помощью которых мы можем создавать необходимые физические тела:
Function AddSimpleBox(const aFriction, aResitution, aDensity, PosX, PosY, aRotation, Width, Height: Single): TBox2d_CustomBody;Function AddSimpleRotationBox(const aFriction, aResitution, aDensity, PosX, PosY, aRotation, Width, Height: Single): TBox2d_CustomBody;Function AddSimpleCircle(const aFriction, aResitution, aDensity, PosX, PosY, aRotation, aRadius: Single): TBox2d_CustomBody;Function AddSimplePolygon(const aFriction, aResitution, aDensity, PosX, PosY, aRotation: Single; aVertices: array of TVec2d): TBox2d_CustomBody;
Если вернуться внутрь метода CreatePhysicsDemo(), что мы сейчас рассматриваем, то найдем череду вызовов fb2BodyList .AddSimpleBox() - мы создаем параллелепипеды. Назначение аргументов, что мы передаем, легко переводится с английского, если посмотреть на описание функции - коэффициент трения, упругость, плотность, координаты центра, ориентация, ширина и высота. Все предельно просто. Стоит уточнить, что, выставив плотность в 0, мы определим тело как статическое - ни двигаться, ни прыгать оно не будет.
Таким образом мы создаем весь уровней - из прямоугольных коробок.
Двум телам выставляем флаг, указывающий на то, что они будут сенсорами (контакты считаются, но взаимодействия не происходит):
fDoorSensor.IsSensor := true;fLiftSensor.IsSensor := true;
Для двух тел установим UserData, чтобы при контакте "вытащить" сам объект и сравнить - с каким именно произошло столкновение:
fDoorSensor.SetBodyUserData(fDoorSensor);fLiftSensor.SetBodyUserData(fLiftSensor);
Далее в цикле всем телам выставляем "трение о воздух" как зачастую их называют. То есть параметры затухания скорости - линейной и угловой.
for i := 0 to ObjectsCount - 1 dobeginGetObjectByIndex(i).AngularDamping := 1;GetObjectByIndex(i).LinearDamping := 0.5;end;
Ну и в конце создаем луноход методом RestartCar() (об этом расскажу чуть ниже), устанавливаем время движения лифта в 0, "закрываем" дверь и создаем объект для дебаг-отрисовки:
RestartCar();fLiftTime := 0;fShouldOpenDoor := false;fb2DebugRenderer := TBox2d_DebugRenderer.Create;fb2DebugRenderer.SetBox2dList(fb2BodyList);
Итак, мы вплотную подошли к процессу создания колесного агрегата. Что же происходит в RestartCar()? Мы создаем сам объект лунохода, если он не был создан и чистим его от всех тел и сочленений:
if fCarVehicle = nil thenfCarVehicle := TCarVehicle.Create(fb2BodyList, fb2JointList);fCarVehicle.ClearVehicle;
Начало уже хорошее, затем, внутри все того же метода RestartCar() вызывается AddCar(-8, 6.5) - то есть мы создаем луноход в заданной позиции. По сути, как раз метод AddCar(x, y: single) и является местом, где создается луноход: определяется прямоугольник-тело, навешиваются колеса, позиционируется как надо:
Procedure TfrmDemo5.AddCar(x, y: single);beginwith fCarVehicle dobeginCarBody.AttachShape_Box(0, 0, 0.2, VectorMake(0, 0, 0), VectorMake(1, 0.3, 0));CarBody.Position := VectorMake(x, y, 0);AddWheel(VectorMake(-1.2, 0, 0), VectorMake(-1.2, -1, 0), 0.3, $0004);AddWheel(VectorMake(-0.6, 0, 0), VectorMake(-0.6, -1, 0), 0.3, $0004);AddWheel(VectorMake(0, 0, 0), VectorMake( 0, -1, 0), 0.3, $0004);AddWheel(VectorMake( 0.6, 0, 0), VectorMake( 0.6, -1, 0), 0.3, $0004);AddWheel(VectorMake( 1.2, 0, 0), VectorMake( 1.2, -1, 0), 0.3, $0004);CarBody.SetFilterData($0002, $0001, 0);CarBody.SetBodyUserData(CarBody);end;fb2BodyList.ReFilterAll;end;
Описание AddWheel() следующее:
Function TCarVehicle.AddWheel(aPrismLocalPoint: TVector; aWheelLocalPoint: TVector; aWheelRadius: Single; aCategory: cardinal): TWheelObject;
то есть сначала задаются точка прикрепления к основе лунохода, затем позиция колеса, радиус колеса и aCategory. Последний аргумент нужен для фильтрации контактов. Об этом можно прочесть в мануале. Нам нужно, чтобы колеса между собой и с телом лунохода не сталкивались, проходя друг через друга. В Box2d это реализуется с помощью масок, категорий и групп.
Как раз строка:
CarBody.SetFilterData($0002, $0001, 0);
и указывает на то, что теперь у "тела" лунохода категория 2, а маска столкновений - 1. У колес же категория 4, маска - 1 (маска зашита внутри AddWheel). При столкновениях берется побитовое пересечение (and) маски одного тела и категории другого. Если результат 0 - значит столкновение не обрабатывается. Если перефразировать и сказать проще, то категория - это цвет тела. Маска - это набор цветов, с которым данное тело может столкнуться. Для тела лунохода мы указали - наш цвет 2, мы сталкиваемся только с телами цвета 1. А колеса цвета 4, которые сталкиваются с цветом 1. Исходя из этого, луноход со своими колесами никогда не столкнется.
Телу лунохода также задаем UserData, чтобы использовать его в слушателе контактов:
CarBody.SetBodyUserData(CarBody);
Ну и выполняем глобальный fb2BodyList.ReFilterAll; чтобы применились категории, маски и группы для тел.
Все! Луноход добавлен, уровень создан, физический мир готов к работе!
И в этот момент выполняется событие FormActivate, которое запускает наш cadencer:
procedure TfrmDemo5.FormActivate(Sender: TObject);beginfCadencer.Enabled := true;end;
После этого у нас будет крутиться OnCadencerProgress(), посмотрим, что у нас там. Для большей читаемости комментариев, я помещу их сразу в код:
Procedure TfrmDemo5.OnCadencerProgress(Sender : TObject; const deltaTime, newTime : Double); begin // считаем время для таймера fFpsTimer := fFpsTimer + DeltaTime; // показываем значение ФПС UpdateFPS; // делаем шаг физики - Box2d сам просчитает новые позиции тел, их скорости и т.д. fb2World.DoProgress(DeltaTime); // обновим экран fViewer.Invalidate; // если нужно открыть дверь - открываем ее if fShouldOpenDoor then OpenDoor; // если лифт тронулся - продолжить движение if fLiftTime > 0 then begin fLiftTime := fLiftTime + deltaTime; if fLiftTime > 4.5 then fLiftTime := 4.5; end; // нажали пробел - необходимо перебросить луноход на стартовую позицию if IsKeyDown(' ') and not fSpacePressed then RestartCar; // запоминаем текущее состояние клавиши "пробел" fSpacePressed := IsKeyDown(' '); end;
Здесь самым важным вызовом является fb2World.DoProgress(DeltaTime) - обновление физики. После этого физические тела будут находиться на новом месте. Но важно понимать, что графические объекты останутся на прежних позициях. Соединение физики и графики происходит при отрисовке - когда графические тела принимают положение и ориентацию физических. По сути, последующий вызов fViewer.Invalidate() заставит перерисоваться весь viewer и только в этот момент обновится картинка на экране!
Итак, при рендере мы "придем" в наш OnDirectRender(), а оттуда уже вызовется:
Procedure TfrmDemo5.OnDirectRender(Sender: TObject; var rci: TRenderContextInfo); begin fb2DebugRenderer.RenderList(fViewer, rci); end;
Таким образом, объект fb2DebugRenderer для дебаг отрисовки нарисует все объекты разом на экране. Рисуется всё: статические, динамические объекты, а также сенсоры. Наверно, стоит также добавить отрисовку сочленений, но пока руки до этого не дошли. После вызова метода
RenderList() на экране видим что-то вроде этого:
Красные - сенсоры, остальное - обычные тела, взаимодействующие между собой по обычным законам физики. Такая дебаг-отрисовка зачастую пригождается для контролирования, что все сделано верно. А то иногда буравят мысли "почему дверь-то не открывается?", включил дебаг-отрисовку всех объектов и все становится ясно. Напомню, что сенсор - это особый вид объектов (включаемый установкой специального флага), с помощью которого можно отлавливать контакты с телами. Но сами сенсоры физически не взаимодействуют с остальными объектами.
Итак, посмотрим, где же происходит управление нашим луноходом. Помните, я говорил, что в OnSimulation() мы можем устанавливать необходимые силы и моторы? Так вот посмотрим, как это происходит в нашем демо:
Procedure TfrmDemo5.OnSimulation(const aFixedDeltaTime: Single); begin if IsKeyDown(VK_LEFT)then fCarVehicle.SetWheelsMotors(-20, 400) else if IsKeyDown(VK_RIGHT)then fCarVehicle.SetWheelsMotors( 20, 400) else fCarVehicle.SetWheelsMotors(0, 400); fLift.Position := VectorMake(-8, -7.4 + fLiftTime, 0); end;
Как видим, здесь все просто: на нажатие каждой клавиши выставляем моторы колесам нашего Лунохода. Заодно устанавливаем позицию нашего лифта, который поднимается после контакта с сенсором. Ну и напоследок посмотрим, как же мы ловим контакты. Все это происходит в Callback'е физики:
procedure OnProceedContactCallBack(AContactData: Pb2_ContactListenerCallBackData); cdecl; var Body1, Body2: TBox2d_CustomBody; begin with AContactData^ do begin Body1 := TBox2d_CustomBody(b2_Body_GetUserData(b2_Shape_GetBody(Shape1))); Body2 := TBox2d_CustomBody(b2_Body_GetUserData(b2_Shape_GetBody(Shape2))); if (Body1 <> nil) and (Body2 <> nil) then begin if (Body1 = frmDemo5.fDoorSensor) or (Body2 = frmDemo5.fDoorSensor) then frmDemo5.fShouldOpenDoor := true; if (Body1 = frmDemo5.fLiftSensor) or (Body2 = frmDemo5.fLiftSensor) then frmDemo5.StartLift; end; end; end;
По сути, сначала мы просто "достаем" ссылку на объект из UserData нашего физического тела. А затем уже проверяем, были ли активированы те или иные сенсоры и какие флаги стоит выставить.
На этом все, господа!
p.s. Это сообщение пролежало у меня в недописанном состоянии порядка полугода и сейчас я с легким сердцем публикую его! Ура, товарищи!
О, спасибо, некоторые куски кода нашел очень полезными для себя =)
ОтветитьУдалитьОтлично :) Познавательно, неплохую аналогию привел для масок и категорий. Сходу я их не очень понял, когда в августе ковырял box2d и его англоязычный мануал.
ОтветитьУдалитьgltrinix,
ОтветитьУдалитьздорово! я очень рад, что пригодилось! постараюсь не забросить glVehicles... хотя идей для продолжения не так много...
perfect daemon,
спасибо! приятно, что получилось внятно объяснить! а то у меня зачастую проблемы с доходчивыми аналогиями...