Разработка под Apple iOS / [Из песочницы] Интеграция физического движка Box2D в UIKit-приложение для iOS

в 11:15, , рубрики: Новости, метки: , , , , , , ,

Привет!

Сегодня мы покажем, насколько легко встроить физический движок Box2D в любое игровое приложение, написанное на стандартных фреймворках Apple. Примером послужит интерактивная книга, выпущенная нашей студией полгода назад. Эта книга была нашим первым приложением для детей, и когда мы начинали работу над ней, у нас было мало опыта в создании анимаций, поэтому мы выбрали знакомые нам мощные и отлично документированные стандартные фреймворки Apple – так было проще на тот момент. Книга была готова уже через два месяца. Однако, некоторые задумки реализованы не были. Из этих пожеланий был оставлен список на будущее, чтобы, когда будет время и знания, вернуться к проекту.
Физика

Одним из пунктов была симуляция физического мира, чтобы у пользователя была возможность играть с предметами: создавать их, бросать, перекидывать из угла в угол средствами акселерометра и так далее. Для реализации этой возможности требовалась интеграция в проект физического движка. И вот, когда на новом проекте было освоены Cocos2D и Box2D, возник резонный вопрос: если Box2D по своей сути не зависим от графической реализации программы, то почему бы не использовать его в самой первой книге? Недолгие поиски на просторах Сети привели к замечательной и лаконичной статье в блоге http://www.cocoanetics.com, просто и доступно объясняющей, как использовать Box2D в стандартном приложении на UIKit. И мы принялись за дело. Больше всего опасений у нас вызывал тот факт, что работа движка, написанного на C++, потребует масштабных изменений в текущем коде проекта. Но к счастью обошлось всего лишь парой небольших изменений — тип файлов классов страниц были изменен на Objective-C++, да была проведена легкая оптимизация кода. На данные изменения ушло примерно 4 часа.
Принцип работы

Принцип интеграции движка прост (смотри указанную статью). При загрузке страницы создается физический мир. Далее ему назначаются требуемые границы (в данном случае — весь экран), и создаются требуемые тела. Так как созданные тела не видны пользователю, им в соответствие с помощью свойства тела userData назначаются необходимые объекты UIKit, например, картинки класса UIImageView. Далее, при вызове метода viewDidAppear запускается таймер с нужной частотой, который, обрабатывая положения всех тел в физическом мире, перемещает на нужные позиции связанные с телами соответствующие картинки. Тем самым создается иллюзия того, что сталкиваются непосредственно сами картинки. При вызовет метода viewDidDissapear таймер останавливается, все созданные пользователем тела и соответствующие им изображения — удаляются из мира и из self.view. Вопреки опасениям, что эта схема не даст приемлемой частоты кадров, рендеринг оказался быстрым даже на старых устройствах типа iPhone 3G, на глаз не уступая рендерингу в приложении на основе Cocos2D.
Тела

Описанный в исходной статье способ позволял создавать только прямоугольные тела по размерам view, передаваемых в сообщении о создания тела. Для нас же это был недостаточно гибкий способ, так как тела у нас могли быть любой формы. В работе над проектом на основе Cocos2D мы задействовали удобную программу для Mac OS — SpriteHelper от индивидуального разработчика Bogdan Vladu. Платная лицензия позволяет делать текстурные карты из заготовленных картинок и задавать параметры физическим телам: форму, плотность, коэффициент трения, упругость и т.д. — всё что надо. Для работы с получаемыми файлами автор написал класс SpriteHelperLoader, который позволяет в одно сообщение создать нужное тело в нужном физическом мире и слое Cocos2D. Одна беда — этот класс был жестко ориентирован на совместную работу с Cocos2D. Пришлось потратить некоторое время и «вырезать» из него все упоминания «кокоса». По сути нам он нужен был лишь для получения одного параметра тела — формы (текстурные карты в данном случае не используются). Теперь у нас в руках оказался удобный, а главное привычный способ, чтобы добавить физику в наши первые книги, дать им второе дыхание и, надеемся, прибавить положительных отзывов. Оригинальный метод добавления тел из исходной статьи был переписан:
- (void) addPhysicalBodyForView:(UIImageView *)physicalImageView ofType:(NSString *)type
{
// get image's center coordinates
CGPoint position = physicalImageView.center;

// Define the dynamic body.
b2BodyDef bodyDef;
bodyDef.type = b2_dynamicBody;
bodyDef.position.Set(position.x/PTM_RATIO, (screenSize.height - position.y)/PTM_RATIO); // convert into Box2D coordinates
bodyDef.userData = physicalImageView;

// Tell the physics world to create the body
b2Body *body = world->CreateBody(&bodyDef);

position = CGPointMake(bodyDef.position.x, bodyDef.position.y);

// use modified SpriteHelperLoader to get shape
tempBody = [bodyLoader bodyWithUniqueName:type atPosition:position world:world];

b2Fixture* fixture = tempBody->GetFixtureList();
b2Shape *shape = fixture->GetShape();

// Define the dynamic body fixture.
b2FixtureDef fixtureDef;
fixtureDef.shape = shape;
fixtureDef.density = fixture->GetDensity();
fixtureDef.friction = fixture->GetFriction();
fixtureDef.restitution = fixture->GetRestitution();
body->CreateFixture(&fixtureDef);

// a dynamic body reacts to forces right away
body->SetType(b2_dynamicBody);

world->DestroyBody(tempBody);

fixture = nil;
shape = nil;
}

Обработка касаний

Для обработки касаний мы воспользовались встроенным механизмом Box2D и стандартными методами регистрации прикосновений UIKit. При регистрации касания на главном view происходит трансформация координат точки касания из координатной системы UIKit в координатную систему Box2D. Далее происходит проверка на попадание в какое-либо тело в физическом мире. И если попадание есть, то между данным телом создается физическая связь с телом-землей (groundBody), которая позволяет таскать тело пальцем по экрану. При завершении касания тело отправляется в свободный полет (путем уничтожения связи) согласно приобретенному в процессе перемещения импульсу. Выглядит это довольно натурально. Стоит отметить, что для того, чтобы ваши перетаскиваемые тела не вылетали за границы экрана, надо разрешить расчет столкновений у создаваемой связи между данными телами. Для этого надо свойству связи collideConnected присвоить значение YES.
class QueryCallback : public b2QueryCallback
{
public:
QueryCallback(const b2Vec2& point)
{
m_point = point;
m_fixture = NULL;
}
bool ReportFixture(b2Fixture* fixture)
{
b2Body* body = fixture->GetBody();
if (body->GetType() == b2_dynamicBody)
{
bool inside = fixture->TestPoint(m_point);
if (inside)
{
m_fixture = fixture;
// We are done, terminate the query.
return false;
}
}
// Continue the query.
return true;
}
b2Vec2 m_point;
b2Fixture* m_fixture;
};

[...]

#pragma mark - Drag and Drop
// source - http://iphonedev.net/2009/08/05/how-to-grab-a-sprite-with-cocos2d-and-box2d/
- (void) touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch* myTouch = [touches anyObject];
CGPoint location = [myTouch locationInView: [myTouch view]];
location = CGPointMake(location.x, screenSize.height-location.y);
m_mouseWorld.Set(location.x/PTM_RATIO, location.y/PTM_RATIO);
if (m_mouseJoint != NULL)
{
NSLog(@"m_mouseJoint != NULL");
return;
}
b2AABB aabb;
b2Vec2 d;
d.Set(0.001f, 0.001f);
aabb.lowerBound = m_mouseWorld - d;
aabb.upperBound = m_mouseWorld + d;

// Query the world for overlapping shapes.
QueryCallback callback(m_mouseWorld);
world->QueryAABB(&callback, aabb);

b2Body* nbody = NULL;

if (callback.m_fixture)
{
nbody = callback.m_fixture->GetBody();
}

if (nbody)
{
b2MouseJointDef md;
md.bodyA = groundBody; //
md.bodyB = nbody;
md.target = m_mouseWorld;
md.collideConnected = YES;
#ifdef TARGET_FLOAT32_IS_FIXED
md.maxForce = (nbody->GetMass() GetMass()) : float32(16000.0);
#else
md.maxForce = 1000.0f * nbody->GetMass();
#endif
m_mouseJoint = (b2MouseJoint*)world->CreateJoint(&md);
nbody->SetAwake(YES);
}
}

- (void) touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event
{
UITouch* myTouch = [touches anyObject];
CGPoint location = [myTouch locationInView: [myTouch view]];
// translate uikit coordinates into box2d coordinates
location = CGPointMake(location.x, screenSize.height-location.y);
m_mouseWorld.Set(location.x/PTM_RATIO, location.y/PTM_RATIO);

if (m_mouseJoint)
{
m_mouseJoint->SetTarget(m_mouseWorld);
}
}

- (void) touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event
{
NSLog(@"touches ended");
if (m_mouseJoint)
{
AudioServicesPlaySystemSound (soundThrust);
world->DestroyJoint(m_mouseJoint);
m_mouseJoint = NULL;
}
}

- (void) touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event
{
[self touchesEnded:touches withEvent:event];
}

Акселерометр

При попытке создать реалистичную коробочку с предметами надо не забыть про гравитацию, и тут есть несколько подводных камней. Можно, конечно, пойти по простому пути и получать компоненты вектора гравитации из показаний акселерометра, взяв значения по двум осям — X и Y, как это сделано в исходной статье:
- (void)accelerometer:(UIAccelerometer *)accelerometer didAccelerate:(UIAcceleration *)acceleration
{
b2Vec2 gravity;
gravity.Set( acceleration.x * 9.81, acceleration.y * 9.81 );
world->SetGravity(gravity);
}

Но данный способ позволяет случайно создать нежелательную невесомость в случае расположения устройства на горизонтальной поверхности, так как проекции земной силы тяжести на оси X и Y устройства станут почти равны нулю. В случае такой виртуальной невесомости предметы будут неестественно парить в воздухе. Более натурально будет, если вектор тяжести всегда будет равен 1 и предметы будут находиться под действием силы постоянной величины независимо от расположения устройства: на коленях или столе, вертикально в руках или вообще над головой экраном вниз. Мы реализовали этот несколько более сложный алгоритм расчета направления силы тяжести, но оставим его в секрете, как свое маленькое ноу-хау. Так же стоит упомянуть, что для более точного задания вектора тяжести и более тонкого управления им на новых девайсах можно задействовать акселерометр, а от простого использования протокола UIAccelerometerDelegate (который уже не стоит использовать в iOS 5) стоит перейти к комплексному CMMotionManager из фреймворка CoreMotion.
Видео демонстрация результата

Вместо заключения

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

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js