Notatki programisty: Czas na wyzwanie, czyli piszemy obiekt przeciwnika w grze Box2D/SFML
Oprogramowanie nie jest doskonałe. Nie ma doskonałego oprogramowania, jestem zwolennikiem podejścia: program jest skończony gdy jest dość dobry i mamy sytuacje w której nie możemy z projektu nic wyciąć. Tym optymistycznym akcentem zapraszam do lektury kolejnego wpisu w którym poznajemy podstawy i tajniki rzemiosła pisania gier.
Dzień zaczynamy od napraw
W nawiązaniu do wstępu wpis ten, jak i poprzedni, zaczniemy od „ulubionej” czynności programistów, czyli naprawy błędów. Ba, poprawka będzie dotyczyć tego samego co i poprzednim tekście czyli skoku bohatera. Zasada działania testu czy gracz może wykonać skok polegała na sprawdzeniu czy bohater dotyka jakiejś powierzchni, aczkolwiek nie przewidzieliśmy przypadku gry powierzchnią tą będzie, pionowa ściana. Jako że w „specyfikacji” serii nie mam żadnych wzmianek o tym że demo technologiczne będzie dotyczyć gry ze Spider-man'em w roli głównej, zajmiemy się naprawą tego „ficzeru”.
W tym miejscu pozwolę sobie na małą dygresję apropo projektowania kodu. Pytanie filozoficzne na dziś, brzmi: Czy nie łamie zasad hermetyzacji danych, sytuacja w której to klasa służąca do wprawiania w ruch ciał wie o tym że ciała mają jakieś dodatkowe dane, typy itp.? Po chwili zastanowienia myślę że poprawna odpowiedź brzmi: łamie. Aby zachować jak największą modułowość i niezależność kodu, nie powinniśmy tworzyć klas mutantów. Ktoś może zapytać ale czemu nie przeniesiemy tego testu do klasy nasłuchującej zdarzenia całego świata? Testowałem rozwiązanie i doszedłem do wniosku że jest problematyczne, niedokładne oraz zwiększa, w moim odczuciu, bałagan i nieczytelność kodu. Tak więc podejmujemy decyzję by funkcję naszego testu przeniesiemy do nowej klasy/struktury której deklaracja i implementacja prezentuje się następująco:
struct BodyJumpValidator { static bool test(b2Body* body); }; bool BodyJumpValidator::test(b2Body* body) { if(body->GetContactList() == nullptr){ return false; } for (b2Contact* contact = body->GetContactList()->contact; contact != nullptr; contact = contact->GetNext()) { BodyUserData* bodyUserDataA = (BodyUserData*)( contact->GetFixtureA()->GetBody()->GetUserData()); BodyUserData* bodyUserDataB = (BodyUserData*)( contact->GetFixtureB()->GetBody()->GetUserData()); if(bodyUserDataA != nullptr && bodyUserDataB != nullptr){ bool bodyIsWall = bodyUserDataA->getType() == BodyUserData::Type::Wall || bodyUserDataB->getType() == BodyUserData::Type::Wall; if(bodyIsWall){ return false; } } } return true; }
Posiadając wiedzę z poprzednich wpisów, kod nie powinien sprawiać problemów z rozumieniem tego co dzieje się w implementacji. W tym miejscu poznajemy kolejną niedogodność naszej technologii, mianowicie każde pionowe ściany na mapie będziemy musieli oznaczać jako BodyUserData::Type::Wall. Odbiegając od głównego wątku i wracając do projektowania i hermetyzacji. Czy aby dobrym rozwiązaniem jest zamieszczanie testu skoku w funkcji move()? Nie lepiej go wykonać w pętli głównej? Z jednej strony nasza pętla rozrośnie się o dwie linijki co jest cegiełką do nieczytelności ale z drugiej strony klasę odpowiedzialną za ruch powinno, czysto teoretycznie, guzik obchodzić jakieś warunki ruchu skoro zajmuje się samym ruchem. Jako że jestem programistą bez formalnego akademickiego wykształcenia, zostawię ten dylemat komuś „starszemu i mądrzejszemu”. Chętnie poznam zdanie kogoś innego. Wracając do tematu. Użycie nowego testu prezentuje się następująco w funkcji move():
case Direction::Jump: { if(BodyJumpValidator::test(m_body)){ const b2Vec2 oldVelocity = m_body->GetLinearVelocity(); const b2Vec2 newVelocity = b2Vec2(oldVelocity.x, - m_bodyJumpForce); m_body->SetLinearVelocity(newVelocity); } break; }
Po tych wszystkich rozmyślaniach i poprawkach powinniśmy uzyskać efekt o jaki nam chodziło czyli: bohater nie może skakać z powietrza i przy zetknięciu z obiektem oznaczonym jako Type::Wall. Rozwiązanie przetestujemy w dalszej części wpisu.
Czas podjąć wyzwanie!
Każdemu kto czytał moje poprzednie wpisy, znajoma jest metodyka programowania „magic of copy-pase”. W prezentowanym przykładzie będziemy potrzebowali elastycznej funkcji do tworzenia ciał dynamicznych dla obiektu gracza jak i wspomnianego w tytule, przeciwnika. Funkcja wygląda następująco:
b2Body* createDynamicBody( b2World* world, const float width, const float height) { /* Wielkosc ciala przesylamy * w parametrach. */ b2PolygonShape bodyShape; bodyShape.SetAsBox( (width/2)*G_PIXELS_TO_METERES, (height/2)*G_PIXELS_TO_METERES); b2FixtureDef bodyFixture; bodyFixture.density = 0.1f; bodyFixture.friction = 0.2f; bodyFixture.shape = &bodyShape; b2BodyDef bodyDef; bodyDef.type = b2_dynamicBody; b2Body* myBody = world->CreateBody(&bodyDef); myBody->CreateFixture(&bodyFixture); /* Wylaczamy "naprawiona" rotacje, * stwierdzilem ze troche slabo wyglada * grzybek ktory po przewroceniu ma * narzady ruchu na policzku. */ myBody->SetFixedRotation(true); return myBody; }
Wygląda zrozumiale. Kolejną rzeczą jaka będzie nam niezbędna do uatrakcyjnienia naszego demka, będzie funkcja która pozwoli nam na określenie czy ciało A znajduje się w obszarze widoku ciała B. Wyjdę w tym miejscu nieco na hipokrytę gdyż z jednej strony zastanawiam się nad poprawnością projektu a z drugiej strony, jako rozwiązanie problemu proponuje następujące rozwiązanie:
struct ObserverData { enum Side : int { Left, Right }; Side side; bool isSee; }; ObserverData isBodySeeBody( const b2Body* observer, const b2Body* target, const float seeRangeInPx) { ObserverData toReturn; const b2Vec2 targetPosition = target->GetPosition(); const float targetPositionX = targetPosition.x; const float targetPositionY = targetPosition.y; b2Vec2 observerPosition = observer->GetPosition(); const float observerPositionX = observerPosition.x; const float observerPositionY = observerPosition.y; if (targetPositionX < observerPositionX + seeRangeInPx*G_PIXELS_TO_METERES && targetPositionX > observerPositionX - seeRangeInPx*G_PIXELS_TO_METERES && targetPositionY < observerPositionY + seeRangeInPx*G_PIXELS_TO_METERES && targetPositionY > observerPositionY - seeRangeInPx) { toReturn.isSee = true; if(targetPositionX < observerPositionX){ toReturn.side = ObserverData::Side::Left; } else { toReturn.side = ObserverData::Side::Right; } } else { toReturn.isSee = false; } return toReturn; }
Metoda robi dwie rzeczy, sprawdza czy ciało A widzi ciało B a dodatkowo określa z której strony je widzi przy czym zwraca jakąś mało spójną abstrakcyjną strukturę. Zbrodnia jakich mało. Pomijając rozterki architektury, śmiało możemy przejść do krótkiego omówienia. Warto zauważyć że pobierając pozycje ciała Box2D pobieramy pozycje środka ciała a nie któreś z krawędzi. Przez co mając zasięg widzenia ustawiony na 100 pikseli warto pamiętać że odległość ta tyczy się odległości między środkami ciał a nie krawędziami ich kształtów. Warunek dotyczący tego czy ciało widzi ciało jest dość łopatologiczny. Czas na danie główne każdego tekstu serii czyli, przed państwem, funkcja główna:
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 textureEnemy; if(!textureEnemy.loadFromFile("textureEnemy.png")){ std::cout << "textureEnemy problem \n"; } sf::Texture textureWall; if(!textureWall.loadFromFile("textureWall.png")){ std::cout << "textureMap problem \n"; } sf::Texture texturePlayer; if(!texturePlayer.loadFromFile("textureHero.png")){ std::cout << "texturePlayer problem \n"; } /* Glowna podloga. */ std::shared_ptr<BodyUserData>bodyPlatformData ( new BodyUserData(BodyUserData::Type::Map)); DrawablePolygonBody bodyPlatform( createStaticBody(myWorld.get(), 500, 40)); bodyPlatform.setPosition(420.f, 450.f); bodyPlatform.setTexture(textureMap); bodyPlatform.getBody()->SetUserData((void*)bodyPlatformData.get()); /* Sciana pionowa A . */ std::shared_ptr<BodyUserData>bodyDataWallA ( new BodyUserData(BodyUserData::Type::Wall)); DrawablePolygonBody bodyWallA( createStaticBody(myWorld.get(), 40, 300)); bodyWallA.setPosition(150.f, 325.f); bodyWallA.setTexture(textureWall); bodyWallA.getBody()->SetUserData((void*)bodyDataWallA.get()); /* Sciana pionowa B . */ std::shared_ptr<BodyUserData>bodyDataWallB ( new BodyUserData(BodyUserData::Type::Wall)); DrawablePolygonBody bodyWallB( createStaticBody(myWorld.get(), 40, 300)); bodyWallB.setPosition(650.f, 325.f); bodyWallB.setTexture(textureWall); bodyWallB.getBody()->SetUserData((void*)bodyDataWallB.get()); /* Obiekt gracza. */ std::shared_ptr<BodyUserData>playerData ( new BodyUserData(BodyUserData::Type::Player)); DrawablePolygonBody bodyPlayer( createDynamicBody(myWorld.get(), 40, 40)); bodyPlayer.setTexture(texturePlayer); bodyPlayer.setPosition(300.f, 50.f); bodyPlayer.getBody()->SetUserData((void*)playerData.get()); BodyMover playerMover(bodyPlayer.getBody()); /* Obiekt przeciwnika, danych i jego poruszacz. */ std::shared_ptr<BodyUserData>enemyData ( new BodyUserData(BodyUserData::Type::Enemy)); DrawablePolygonBody bodyEnemy(createDynamicBody(myWorld.get(), 90, 60)); bodyEnemy.setTexture(textureEnemy); bodyEnemy.setPosition(500.f, 50.f); bodyEnemy.getBody()->SetUserData((void*)enemyData.get()); BodyMover enemyMover (bodyEnemy.getBody()); /* Ewidencja cial. */ std::vector<DrawablePolygonBody> listWorldBodies; listWorldBodies.push_back(bodyPlatform); listWorldBodies.push_back(bodyPlayer); listWorldBodies.push_back(bodyEnemy); listWorldBodies.push_back(bodyWallA); listWorldBodies.push_back(bodyWallB); /* Okno SFML. */ sf::RenderWindow window( sf::VideoMode(800, 600, 32), std::string("SFML/Box2D - tech demo"), 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)) { playerMover.move(BodyMover::Direction::Jump); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::D)) { playerMover.move(BodyMover::Direction::Right); } if(sf::Keyboard::isKeyPressed(sf::Keyboard::A)) { playerMover.move(BodyMover::Direction::Left); } } /* Troche niewydajne sie wydaje sprawdzanie * z kazdym obiegiem petli czy przeciwnik widzi * gracza aczkolwiek na razie przy nim zostaniemy. * Dzialajaca atrapa to dobra atrapa. ;) */ ObserverData isEnemySeePlayerData = isBodySeeBody( bodyEnemy.getBody(), bodyPlayer.getBody(), 150); /* Jezeli przeciwnik widzi gracza, * odczytujemy strone a nastepnie * wprawiamy w ruch cialo przeciwnika. */ if(isEnemySeePlayerData.isSee){ switch(isEnemySeePlayerData.side) { case ObserverData::Side::Left: { enemyMover.move(BodyMover::Direction::Left); break; } case ObserverData::Side::Right: { enemyMover.move(BodyMover::Direction::Right); break; } } } if(myContactLister.getContactType() == WorldContactListener::ContactType::PlayerTouchEnemy) { colorWindowBackground = sf::Color::White; } else { colorWindowBackground = sf::Color::Black; } beforeGameLoop(*myWorld.get()); bodyEnemy.update(); bodyPlayer.update(); /* Render. */ window.clear(colorWindowBackground); for(DrawablePolygonBody& item : listWorldBodies){ item.render(window); } window.display(); } return 0; }
W kodzie zamieściliśmy kilka warunków które poniekąd są skryptem zachowania naszego przeciwnika. Wszystkie elementy z jakich skorzystaliśmy zostały w każdym ze wpisów umówione. Po uruchomieniu przybliżony efekt naszych prac powinien przedstawiać się następująco:
Ot prosta mini gierka, nie dajemy się przygnieść przeciwnikowi do muru, co prawda nie mamy jeszcze warunków które decydują o tym czy wygraliśmy lub przegraliśmy ale to już temat na następne wpisy. Jak na razie: dzięki za uwagę!