Blog (35)
Komentarze (574)
Recenzje (0)
@biomenNotatki programisty: melduj się, czyli piszemy dziennik wielozdarzeń w aplikacji SFML/Box2D

Notatki programisty: melduj się, czyli piszemy dziennik wielozdarzeń w aplikacji SFML/Box2D

W niniejszym wpisie, naszym celem będzie rozbudowanie i modernizacja istniejących już mechanizmów naszego demka. Nie przedłużając, zapraszam do lektury.

Skakanie vol. 3

To już trzeci wpis, którego pierwszy punkt będzie poświęcony (dobrze zgadujecie) umiejętności skoku ciała. Modyfikacja nie wynika z jakiegoś nowego znalezionego błędu a założenia że w prezentowanym przykładzie chcemy oprogramować postać przeciwnika tak aby poza poruszaniem się w wzdłuż osi x, potrafiła ona również poderwać się do góry. Trzecie pokolenie funkcji walidatora skoku prezentuje się następująco:


bool BodyJumpValidator::test(b2Body* body)
{
    if(body->GetContactList() == nullptr){
        return false;

    } else {

        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;
                }

                bool bodyIsMap =
                        bodyUserDataA->getType() == BodyUserData::Type::Map ||
                        bodyUserDataB->getType() == BodyUserData::Type::Map;

                if(bodyIsMap){
                    return true;
                }
            }
        }
    }
    return false;
}

W prostych słowach można skrócić test do słów: jeżeli niczego nie dotykasz – nie skaczesz, jeżeli dotykasz ale dotykasz ściany pionowej – nie skaczesz, jeżeli dotykasz czegoś i dotykasz zadeklarowanej ustawowo podłogi, no to nie krępuj się: skacz. Dzięki modyfikacji unikniemy sytuacji w której nasz przeciwnik mając naszego bohatera na grzbiecie będzie mógł skakać w nieskończoność. Stary warunek wymagał tylko niedotykania ściany pionowej i jakiegokolwiek kontaktu. Można odnieść wrażenie ze nasza funkcja testu skoku poniekąd jest bardzo podobna do funkcji beginContact() z klasy nasłuchującej zdarzenia. Niestety ale temat balansu ruchu w grach jest dość trudny, świadczy o tym fakt że wiele gier, nawet popularnych, miała z nim „problem”. Na myśl przychodzi mi chociażby Dead Space który jak dla mnie był toporny w równym stopniu jak nie większym, co dwie pierwsze gry Piranha Bytes. Z tą różnicą że gry niemieckiego dewelopera bawiły mnie o wiele bardziej niż kosmiczny horror ale to inna historia. Wracając do tematu balansu. Do tej pory, wprawiając w ruch którąś z postaci nie mieliśmy określonej maksymalnej prędkości ciała przez co obiekt mógł się rozpędzać do niebotycznej prędkości. Dziś czas to zmienić. Do klasy BodyMover dopiszemy funkcje która pozwoli nam w większym stopniu kontrolować sposób poruszania się postaci. Deklaracja klasy, konstruktora i zmieniona wersja funkcji move() prezentują się następująco:


class BodyMover
{
public:
    enum Direction : int
    {
        Right,
        Left,
        Jump
    };

    BodyMover(b2Body* baseBody);

    void move(Direction moveDir);
    void setJumpForce(const float newBodySpeed);
    void setMoveSpeedChangeValue(const float newBodySpeed);
    void setMaxSpeed(const float newBodyMaxSpeed);

private:
    b2Body* m_body;

    float m_bodyJumpForce;
    float m_bodyMaxSpeed;
    float m_bodySpeedChangeValue;

    bool jumpTest();
    bool m_canJump;
};

BodyMover::BodyMover(b2Body* baseBody) :
    m_body(baseBody),
    m_bodyMaxSpeed(5.f),
    m_bodySpeedChangeValue(0.12f),
    m_bodyJumpForce(5.5f),
    m_canJump (true)
{ }

void BodyMover::move(Direction moveDir)
{
    switch(moveDir)
    {
        case Direction::Right:
        {
            const b2Vec2 oldVelocity = m_body->GetLinearVelocity();

            if(oldVelocity.x < m_bodyMaxSpeed)
            {
                const b2Vec2 newVelocity = b2Vec2(
                            oldVelocity.x + m_bodySpeedChangeValue, oldVelocity.y);

                m_body->SetLinearVelocity(newVelocity);
            }
            break;
        }
        
        case Direction::Left:
        {
            const b2Vec2 oldVelocity = m_body->GetLinearVelocity();

            if(oldVelocity.x > -m_bodyMaxSpeed){

                const b2Vec2 newVelocity = b2Vec2(
                            oldVelocity.x - m_bodySpeedChangeValue, oldVelocity.y);

                m_body->SetLinearVelocity(newVelocity);
            }
            break;
        }
        
        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;
        }
    }
}

Jako że korzystamy z pełnoprawnego silnika fizycznego, nasz balans zależny jest również od właściwości ciał jakie posiadamy w symulacji, ich ciężkości, gęstości itp. Na ten moment, temat balansu odwleczemy na czas nieokreślony.

Grunt to porządek

W poprzednim wpisie mieliśmy opracowaną funkcję która sprawdzała czy ciało A widzi ciało B. Jako że czytelność kodu i porządek w projekcie odgrywa jedną z kluczowych ról, opakujemy funkcje i strukturę w klasę. Deklaracja klasy i implementacja wygląda, jak poniżej:


class Observer
{
private:
    static const int METERES_TO_PIXELS;
    static const float PIXELS_TO_METERES;

public:
    struct Info
    {
        enum Side : int
        {
            Left,
            Right
        };
        Side side;
        bool isSee;
    };

    static Info isBodySeeBody(
            const b2Body* observer,
            const b2Body* target,
            const float seeRangeInPx);
};

const int Observer::METERES_TO_PIXELS = 50;
const float Observer::PIXELS_TO_METERES = 0.02f;

Observer::Info Observer::isBodySeeBody(
                    const b2Body* observer,
                    const b2Body* target,
                    const float seeRangeInPx)
{
    Info 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*PIXELS_TO_METERES &&
        targetPositionX > observerPositionX - seeRangeInPx*PIXELS_TO_METERES &&
        targetPositionY < observerPositionY + seeRangeInPx*PIXELS_TO_METERES &&
        targetPositionY > observerPositionY - seeRangeInPx)
    {
        toReturn.isSee = true;

        if(targetPositionX < observerPositionX){
            toReturn.side = Observer::Info::Side::Left;
        } else {
            toReturn.side = Observer::Info::Side::Right;
        }
    } else {
        toReturn.isSee = false;
    }

    return toReturn;
}

Co prawda zdublowaliśmy w klasie stałe służące do przeliczania jednostek aczkolwiek miejmy w głowie myśl że niezależność klas powinna dla nas mieć wyższy priorytet niż potencjalne oszczędności wynikające z kilku linijek kodu.

Dziennik zdarzeń czyli co się dzieje na pokładzie kapitanie

Dotychczas, nasza klasa nasłuchująca zdarzenia z silnika Box2D, posiadała funkcjonalność przechowywania informacji tylko o jednym zdarzeniu które miało miejsce. Jako że umownym, zapowiedzianym, warunkiem przegranej w naszym demie technologicznym, będzie sytuacja w której to bohater jest przyparty przez przeciwnika do muru, potrzebujemy mechanizmu który pozwoli nam zapisać więcej niż jedno zdarzenie. Kod nowego rozwiązania prezentuje się następująco:


class WorldContactListener : public b2ContactListener
{
public:
    enum ContactType : int
    {
        Empty,
        PlayerTouchEnemy,
        PlayerTouchWall
    };

    void BeginContact(b2Contact* contact);
    void EndContact(b2Contact* contact);

    bool isContactListIsEmpty() const;
    bool isContactListContains(ContactType type) const;

private:
    std::vector<ContactType> m_contactTypeList;
};

void WorldContactListener::BeginContact(b2Contact* contact)
{
    BodyUserData* bodyUserDataA = (BodyUserData*)(contact->GetFixtureA()->GetBody()->GetUserData());
    BodyUserData* bodyUserDataB = (BodyUserData*)(contact->GetFixtureB()->GetBody()->GetUserData());

    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;

        bool bodyIsWall =
                bodyUserDataA->getType() == BodyUserData::Type::Wall ||
                bodyUserDataB->getType() == BodyUserData::Type::Wall;

        /* Jezeli zachodzi jakas interesujace
         * nas kolizja informacje o niej
         * dodajemy do naszego dziennika-wektora. */
        if(bodyIsPlayer && bodyIsEnemy)
        {
            m_contactTypeList.push_back(ContactType::PlayerTouchEnemy);
        }
        if(bodyIsPlayer && bodyIsWall){

            m_contactTypeList.push_back(ContactType::PlayerTouchWall);
        }
    }
}

void WorldContactListener::EndContact(b2Contact* contact)
{
    BodyUserData* bodyUserDataA = (BodyUserData*)(contact->GetFixtureA()->GetBody()->GetUserData());
    BodyUserData* bodyUserDataB = (BodyUserData*)(contact->GetFixtureB()->GetBody()->GetUserData());

    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;

        bool bodyIsWall =
                bodyUserDataA->getType() == BodyUserData::Type::Wall ||
                bodyUserDataB->getType() == BodyUserData::Type::Wall;

        /* Jezeli zachodzi koniec jakiejs
         * interesujacej nas kolizji, usuwamy
         * informacje o niej z naszego wektoru
         * dziennika. */
        if(bodyIsPlayer && bodyIsEnemy)
        {
            auto iterator = std::find(
                        m_contactTypeList.begin(),
                        m_contactTypeList.end(),
                        ContactType::PlayerTouchEnemy);

            m_contactTypeList.erase(iterator);
        }
        if(bodyIsPlayer && bodyIsWall)
        {
            auto iterator = std::find(
                        m_contactTypeList.begin(),
                        m_contactTypeList.end(),
                        ContactType::PlayerTouchWall);

            m_contactTypeList.erase(iterator);
        }
    }
}

bool WorldContactListener::isContactListIsEmpty() const
{
    return m_contactTypeList.empty();
}

bool WorldContactListener::isContactListContains(ContactType type) const
{
    bool toReturn = true;

    auto iterator = std::find(
                m_contactTypeList.begin(),
                m_contactTypeList.end(),type);

    if(iterator == m_contactTypeList.end())
    {
        toReturn = false;
    }

    return toReturn;
}

Wydaje mi się ze rozwiązanie jest dość zrozumiałe. Mianowicie w chwili wystąpienia zdarzenia sprawdzamy czy w naszej ewidencji/dzienniku zdarzeń (wektorze) nie ma już takiego samego zapisanego incydentu. Jeżeli nie ma, dodajemy stosowną informację. W chwili zakończenia odbywa się procedura analogicznie odwrotna. Posiadamy dwie dodatkowe funkcje, jedna sprawdzająca stan dziennika – czy jest pusty (nie ma sensu wykonywać jakiś operacji gdy dziennik jest pusty, każdy herc procesora na wagę złota ;)) i druga która zwracająca informację czy w dzienniku znajduje się interesujący nas typ zdarzenia. Co prawda rozwiązanie to nie jest do końca doskonałe gdyż nie wiemy jeszcze między którymi ciałami zaszło zdarzenia ale to już niebawem. Funkcja główna naszego przykładu prezentuje się następująco, komentarze podkreślają, jak zawsze, oczywistość pewnych instrukcji.


int main(int argc, char *argv[])
{
    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(), 750, 40));

    bodyPlatform.setPosition(400.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(25.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(775.f, 325.f);
    bodyWallB.setTexture(textureWall);
    bodyWallB.getBody()->SetUserData((void*)bodyDataWallB.get());

    /* Bohater */
    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());

    /* Przeciwnik */
    std::shared_ptr<BodyUserData>enemyData (
                new BodyUserData(BodyUserData::Type::Enemy));

    DrawablePolygonBody bodyEnemy(createDynamicBody(myWorld.get(), 90, 60));
    bodyEnemy.setTexture(textureEnemy);
    bodyEnemy.setPosition(600.f, 50.f);
    bodyEnemy.getBody()->SetUserData((void*)enemyData.get());

    /* Przeciwnik jest wiekszy od bohatera
     * przez co jego sila skoku bedzie mniejsza. */
    BodyMover enemyMover (bodyEnemy.getBody());
    enemyMover.setJumpForce(13.f);
    enemyMover.setMaxSpeed(2.5f);

    /* Ewidencja cial. */
    std::vector<DrawablePolygonBody> listWorldBodies;
    listWorldBodies.push_back(bodyPlatform);
    listWorldBodies.push_back(bodyWallA);
    listWorldBodies.push_back(bodyWallB);
    listWorldBodies.push_back(bodyPlayer);
    listWorldBodies.push_back(bodyEnemy);

    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
     * przeciwnikiem bedziemy zmieniac
     * tlo ekranu. */
    sf::Color colorWindowBackground;

    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);
            }
        }

        /* Fizyka, kolizje. */
        beforeGameLoop(*myWorld.get());

        Observer::Info isEnemySeePlayerData =
                            Observer::isBodySeeBody(
                                bodyEnemy.getBody(),
                                bodyPlayer.getBody(), 150);

        if(isEnemySeePlayerData.isSee){
            switch(isEnemySeePlayerData.side)
            {
                case Observer::Info::Side::Left: {
                    enemyMover.move(BodyMover::Direction::Left);
                    break;
                }

                case Observer::Info::Side::Right: {
                    enemyMover.move(BodyMover::Direction::Right);
                    break;
                }
            }

            /* Jezeli dziennik kolizji nie jest
             * pusty sprawdzamy jego zawartosc. */
            if(!myContactLister.isContactListIsEmpty())
            {
                const bool isPlayerTouchEnemy =
                                myContactLister.isContactListContains(
                                    WorldContactListener::ContactType::PlayerTouchEnemy);

                if(isPlayerTouchEnemy)
                {
                    const float enemyPositionY = bodyEnemy.getPosition().y;
                    const float playerPositionY = bodyPlayer.getPosition().y;

                    /* Gdyby gracz byl "na grzbiecie"
                     * przeciwnika, ten zaczyna skakac
                     * aby go zrzucic. */
                    if(playerPositionY < enemyPositionY-10){
                        enemyMover.move(BodyMover::Direction::Jump);
                    }
                    colorWindowBackground = sf::Color::Yellow;
                }

                const bool isPlayerTouchWall =
                                myContactLister.isContactListContains(
                                    WorldContactListener::ContactType::PlayerTouchWall);

                /* Warunek przygniecenia - przegranej. */
                if(isPlayerTouchEnemy && isPlayerTouchWall)
                {
                    const float enemyPositionY = bodyEnemy.getPosition().y;
                    const float playerPositionY = bodyPlayer.getPosition().y;

                    /* Chcemy uniknac sytuacji w ktorej to
                     * gracz jest na grzbiecie przeciwnika
                     * obok sciany, technicznie dotyka
                     * przeciwnika  i sciany (warunek przegranej)
                     * ale nie jest to taka sytuacja ktora
                     * moglibysmy nazwac przyparciem do muru. */
                    if(playerPositionY > enemyPositionY-20){
                        colorWindowBackground = sf::Color::Red;
                    }
                }
            } else {
                colorWindowBackground = sf::Color::Black;
            }
        }

        bodyPlayer.update();
        bodyEnemy.update();

        /* Render. */
        window.clear(colorWindowBackground);
        for(DrawablePolygonBody& item : listWorldBodies){
            item.render(window);
        }

        window.display();
    }
    return 0;
}

Co prawda zachowanie przeciwnika pozostawia trochę do życzenia, należy przebudować warunek jego ruchu bo wskakując mu na grzbiet co prawda potrafi skoczyć do góry ale gdy próbujemy z niego zeskoczyć jego zachowanie bardziej wygląda na takie w którym to przeciwnik broni nam dostępu do podstawowego podłoża niż jakby chciał nas zrzucić Poprawki do zachowania przeciwnika i wiele innych, już niebawem w następnych wpisach. Oczywiście zachęcam do przetestowania kodu i eksperymentów związanych z balansem postaci. Jak będzie wyglądać skok smoka gdy ustawimy siłę na 13? Przekonajcie się sami. ;)

Jak zawsze, dzięki za uwagę!

Wybrane dla Ciebie
Komentarze (0)