Notatki programisty: Kolizje, poprawki czyli rzeźbienia gry w Box2D/SFML ciąg dalszy
Czytając poprzednie wpisy dowiedzieliśmy jak napisać reprezentację graficzną ciała Box2D. Poznaliśmy ciała statyczne i dynamiczne. A nawet wprawiliśmy co niektóre obiekty w ruch. W poniższym tekście rozwiniemy nasze dotychczasowe rozwiązania by jeszcze bardziej zbliżyć się do umownego celu jakim jest demo technologiczne gry 2D. Zapraszam do lektury.
Naprawa skoku bohatera
W poprzednim tekście wspomniałem że funkcjonalność skakania naszego bohatera daleka jest od ideału. Bohater może odbić się z powietrza i szybować w przestworza w nieskończoność. Aby naprawić ten nieszczęsny „ficzer” napiszemy prostą funkcję sprawdzającą czy ciało naszego bohatera dotyka jakiegoś ciała a następnie dodamy ją do klasy BodyMover. Implementacja prezentuje się następująco a komentarz do niej jest zbyteczny:
bool BodyMover::jumpTest() { bool toReturn = false; if(this->m_body->GetContactList() != nullptr){ toReturn = true; } return toReturn; }
Zdecydowałem się na to by funkcja testu skoku była prywatną metodą klasy BodyMover i była dostępna tylko dla wąskiego elitarnego grona funkcji. Z użyciem testu skoku, nasza funkcja move() będzie wyglądać następująco:
void BodyMover::move(Direction moveDir) { switch(moveDir) { case Direction::Right: { const b2Vec2 oldVelocity = m_body->GetLinearVelocity(); const b2Vec2 newVelocity = b2Vec2(oldVelocity.x + m_bodySpeedChangeValue, oldVelocity.y); m_body->SetLinearVelocity(newVelocity); break; } case Direction::Left: { const b2Vec2 oldVelocity = m_body->GetLinearVelocity(); const b2Vec2 newVelocity = b2Vec2(oldVelocity.x - m_bodySpeedChangeValue, oldVelocity.y); m_body->SetLinearVelocity(newVelocity); break; } case Direction::Jump: { if(this->jumpTest()){ const b2Vec2 oldVelocity = m_body->GetLinearVelocity(); const b2Vec2 newVelocity = b2Vec2(oldVelocity.x, - m_bodyJumpForce); m_body->SetLinearVelocity(newVelocity); } break; } } }
Po uruchomieniu naszego programu powinniśmy utracić możliwość skakania aż do księżyca. Born to fly nie tym razem. ;)
Kolizje czyli zmora początkującego programisty gier
Koncept w jaki sposób, w Box2D, obsługujemy kolizje wymaga nieco wyjaśnienia. Zaczniemy od tego że mając świat i ciała w grze, w chwili wystąpienia kolizji musimy wiedzieć do kogo należy to ciało, jakiego jest typu itp. W Box2D mamy możliwość przypisywania do ciał wskaźników na dodatkowe struktury danych. Poniżej znajduje się prosta klasa z informacjami jakie będziemy chcieli przypisać do naszego ciała.
class BodyUserData { public: enum Type : int { None, Player, Map, Wall, Enemy }; BodyUserData(BodyUserData::Type type); BodyUserData::Type getType() const; private: BodyUserData::Type m_type; }; BodyUserData::BodyUserData(BodyUserData::Type type) : m_type(type) {} BodyUserData::Type BodyUserData::getType() const { return m_type; }
Nie wygląda strasznie. Jak widać klasa zawiera tylko informację o typie do jakiego będzie przypisane nasze ciało albo na odwrót jak kto woli. Typie w rozumieniu: czy ciało jest elementem mapy, bohaterem czy może należy do przeciwnika. Oczywiście nic nie stoi na przeszkodzie aby dodać do niej więcej informacji aczkolwiek, na chwilę obecną, myślę że tyle nam wystarczy. Mając gotową klasę danych dodatkowych dla obiektu fizycznego możemy je przypisać. Poniżej przykładowe przypisanie. Dla pewności możemy uruchomić projekt czy po przypisaniu do któregoś z ciał dodatkowych informacji, aplikacja nam się nie wyłoży. ;)
/* Obiekty mapy. */ DrawablePolygonBody bodyPlatform(createStaticBody(myWorld.get(), 300, 40)); bodyPlatform.setPosition(300.f, 450.f); bodyPlatform.setTexture(textureMap); std::shared_ptr<BodyUserData>bodyPlatformData (new BodyUserData(BodyUserData::Type::Map)); bodyPlatform.getBody()->SetUserData((void*)bodyPlatformData.get());
W Box2D do wykrywania zdarzeń kolizji służy klasa b2ContactListener. Obiekt tej klasy będzie naszym „nasłuchiwaczem” wypadków. Jako że chcemy posiadać bardziej spersonalizowaną klasę by mieć większą kontrolę nad jakie kolizje będą przechwytywane, napiszemy własną klasę pochodną. Jako że jestem lepszym koderem niż publicystą, zapraszam do zapoznania się z kodem i komentarzami.
class WorldContactListener : public b2ContactListener { public: /* Gdy wystapi jakas kolizja czyli * stykna sie dwa ciala, chcemy wiedziec * jaki charakter ma ta kolizja stad * pomocniczy tryb wyliczeniowy, * implementacja i uzycie powinno * rozjasnic ogolny zamysl. */ enum ContactType : int { Empty, PlayerTouchEnemy }; /* Funkcja BeginContact jest wywolana w * chwili gdy silnik fizyczny wychwyci jakias * nowa kolizje: sytuacje w ktorej to * dwa ciala sie stykaja. */ void BeginContact(b2Contact* contact); /* Mozna sie domyslic na podstawie komentarza * do funkcji BeginContact. ;) */ void EndContact(b2Contact* contact); ContactType getContactType() const; private: ContactType m_contactType; }; void WorldContactListener::BeginContact(b2Contact* contact) { std::cout << __func__ << "\n"; /* Gdy wystepuje kolizja do funkcji przesylany * jest obiekt contact ktory zawiera informacje * jakie ciala sie spotkaly, do identyfikacji cial * uzywamy wczesniej przypisanych danychUzytkownika. */ BodyUserData* bodyUserDataA = (BodyUserData*)(contact->GetFixtureA()->GetBody()->GetUserData()); BodyUserData* bodyUserDataB = (BodyUserData*)(contact->GetFixtureB()->GetBody()->GetUserData()); std::cout << bodyUserDataA->getType() << "\n"; std::cout << bodyUserDataB->getType() << "\n"; if(bodyUserDataA != nullptr && bodyUserDataB != nullptr){ /* Sprawdz czy ktores z cial jest typu: Player. */ bool bodyIsPlayer = bodyUserDataA->getType() == BodyUserData::Type::Player || bodyUserDataB->getType() == BodyUserData::Type::Player; /* Sprawdz czy ktores cial jest typu: Enemy. */ bool bodyIsEnemy = bodyUserDataA->getType() == BodyUserData::Type::Enemy || bodyUserDataB->getType() == BodyUserData::Type::Enemy; /* Jezeli dwa ciala, jedno typu Player, drugie typu Enemy * dotykaja sie, ustaw informacje o rodzaju contactu. */ if(bodyIsPlayer && bodyIsEnemy) { m_contactType = ContactType::PlayerTouchEnemy; } } } void WorldContactListener::EndContact(b2Contact* contact) { std::cout << __func__ << "\n"; /* Sytuacja bardzo podobna tylko dotyczy * zakonczenia kontaktu miedzy dwoma cialami. */ BodyUserData* bodyUserDataA = (BodyUserData*)(contact->GetFixtureA()->GetBody()->GetUserData()); BodyUserData* bodyUserDataB = (BodyUserData*)(contact->GetFixtureB()->GetBody()->GetUserData()); std::cout << bodyUserDataA->getType() << "\n"; std::cout << bodyUserDataB->getType() << "\n"; if(bodyUserDataA != nullptr && bodyUserDataB != nullptr){ bool bodyIsPlayer = bodyUserDataA->getType() == BodyUserData::Type::Player || bodyUserDataB->getType() == BodyUserData::Type::Player; bool bodyIsEnemy = bodyUserDataA->getType() == BodyUserData::Type::Enemy || bodyUserDataB->getType() == BodyUserData::Type::Enemy; /* Gdy ciala typu Player i Enemy sie nie dotykaja * to ustaw dane ContactType na pusty. */ if(bodyIsPlayer && bodyIsEnemy) { m_contactType = ContactType::Empty; } } } WorldContactListener::ContactType WorldContactListener::getContactType() const { return m_contactType; }
Na pierwszy rzut oka wydaje się że jest tego sporo aczkolwiek po chwili myślę że wszystko się wyjaśni. Mając te wszystkie rzeczy zebrane w całość, warto przyjrzeć do funkcji głównej:
int main(int argc, char *argv[]) { /* Tworzenia swiata i przypisanie nasluchiwacza kolizji. */ WorldContactListener myContactLister; std::unique_ptr<b2World> myWorld(createWorld()); myWorld.get()->SetContactListener(&myContactLister); /* Tekstury. */ sf::Texture textureMap; if(!textureMap.loadFromFile("textureMap.png")){ std::cout << "textureMap problem \n"; } sf::Texture texturePlayer; if(!texturePlayer.loadFromFile("textureHero.png")){ std::cout << "texturePlayer problem \n"; } /* Obiekty mapy. */ DrawablePolygonBody bodyPlatform(createStaticBody(myWorld.get(), 300, 40)); bodyPlatform.setPosition(300.f, 450.f); bodyPlatform.setTexture(textureMap); std::shared_ptr<BodyUserData>bodyPlatformData (new BodyUserData(BodyUserData::Type::Map)); bodyPlatform.getBody()->SetUserData((void*)bodyPlatformData.get()); /* Platforma-przeciwnik. */ DrawablePolygonBody bodySecondPlatform(createStaticBody(myWorld.get(), 100, 40)); bodySecondPlatform.setPosition(600.f, 450.f); bodySecondPlatform.setTexture(textureMap); std::shared_ptr<BodyUserData>secondPlatformData (new BodyUserData(BodyUserData::Type::Enemy)); bodySecondPlatform.getBody()->SetUserData((void*)secondPlatformData.get()); /* Obiekt gracza i jego *poruszacza*. */ DrawablePolygonBody myPlayerBody(createPlayerBody(myWorld.get())); myPlayerBody.setTexture(texturePlayer); myPlayerBody.setPosition(300.f, 50.f); std::shared_ptr<BodyUserData>playerData (new BodyUserData(BodyUserData::Type::Player)); myPlayerBody.getBody()->SetUserData((void*)playerData.get()); BodyMover playerMove(myPlayerBody.getBody()); /* Ewidencja cial. */ std::vector<DrawablePolygonBody> listWorldBodies; listWorldBodies.push_back(bodyPlatform); listWorldBodies.push_back(bodySecondPlatform); listWorldBodies.push_back(myPlayerBody); /* Okno SFML. */ sf::RenderWindow window( sf::VideoMode(800, 600, 32), std::string("Box2d - SFML"), sf::Style::Default); window.setFramerateLimit(60); /* W zaleznosci od kolizji gracza z * platforma-przeciwnikiem * bedziemy zmieniac tlo ekranu. */ sf::Color colorWindowBackground; /* Petla glowna. */ while(window.isOpen()) { /* Zdarzenia. */ sf::Event myEvent; while(window.pollEvent(myEvent)) { if(myEvent.type == sf::Event::Closed){ window.close(); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::Space)) { playerMove.move(BodyMover::Direction::Jump); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::D)) { playerMove.move(BodyMover::Direction::Right); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::A)) { playerMove.move(BodyMover::Direction::Left); } } /* Kolizje. */ if(myContactLister.getContactType() == WorldContactListener::ContactType::PlayerTouchEnemy) { colorWindowBackground = sf::Color::White; } else { colorWindowBackground = sf::Color::Black; } /* Inna logika. */ beforeGameLoop(*myWorld.get()); myPlayerBody.update(); /* Render. */ window.clear(colorWindowBackground); for(DrawablePolygonBody& item : listWorldBodies){ item.render(window); } window.display(); } return 0; }
Zmiany w funkcji main w stosunku do poprzedniego wpisu są dość subtelne. Warty podkreślenia jest fakt aby od tej pory każde ciało naszej symulacji miało ustawione jakiekolwiek BodyUserData. Funkcje beginContact() i endContact() mogą w którymś momencie pobrać ciało które tych danych nie będzie posiadało i zrzutować nam jakieś pamięciowe śmieci przez co aplikacja nam się, pisząc kolokwialnie, wyłoży. Jest to na razie chyba jedyna niedogodność obecnego rozwiązania. Dodatkowo wprowadziliśmy prosty mechanizm zmiany koloru tła aby faktycznie przekonać się że nasz detektor kolizji działa.
Na zakończenie oczywiście zachęcam do samodzielnego przetestowania kodu. Tym razem bez zrzutu ekranowego. Jest zbyt efekciarski by jego „majestat” zamykać w zwykłym obrazku. ;)
Jak zawsze, dzięki za uwagę!