Pages

Friday, October 1, 2010

Licznik ramek

Skoro temat timerów jest jeszcze całkiem świeży jest to doskonały czas na napisanie licznika ramek, czyli.. "FPS countera" :)

Zastanawiałem się czy nie zrobić fpsa na bazie klasy Timer, ale ostatecznie stwierdziłem, że kasuroid będzie potrzebował co najwyżej jednego takiego licznika. Nie ma więc sensu tworzyć obiekt klasy Timer. Jest za to sens stworzenia statycznej klasy Fps, zliczającej poszczególne ramki, a następnie wyliczającej liczbę ramek na sekundę.

W klasie Fps wyróżnić można następujące metody:
public static void reset()
{
    mLastTime = 0;
    mCurrentTime = 0;
    mFrames = 0;
    mElapsedTime = 0;
    mFps = 0;
}
Jak nazwa wskazuje resetuje ona wszystkie zmienne potrzebne do wyliczenia fps.
public static void update()
{
    mFrames++;
    mCurrentTime = System.currentTimeMillis();
    mElapsedTime = mCurrentTime - mLastTime;
    if (mElapsedTime >= (long)mFrameLength)
    {
        mFps = (int)((float)mFrames * mFrameLength / (float)mElapsedTime);
        mFrames = 0;
        mLastTime = mCurrentTime;
    }
}
Aktualizuje czasy poszczególnych ramek oraz oblicza aktualne fps.
public static int get()
{
    return mFps;
}
Zwraca (w formacie całkowitym) liczbę ramek na sekundę.

Metodę reset woła Core podczas inicjalizacji. Aby fps był poprawnie liczony Fps.update() musi być wywoływany w każdym przebiegu głównej pętli tj. w runSlice. Zatem, runSlice wygląda teraz tak:
protected int runSlice()
{
    int ret = RetCode.SUCCESS;

    try
    {
        synchronized (mLock) 
        {
            mRenderer.lock();

            // Update all active timers.
            ret = mTimerManager.updateAll();
            if (ret != RetCode.SUCCESS)
            {
                Debug.err(getClass().getName(), "Problem with updating timers!");
                mRenderer.unlock();
                return ret;
            }

            GameState currentState = null;
            if (mGameStatesStack.isEmpty() == false)
            {
                currentState = mGameStatesStack.peek();
                ret = currentState.update();
                if (ret != RetCode.SUCCESS)
                {
                    Debug.err(getClass().getName(), "Problem with updating the state!");
                    mRenderer.unlock();
                    return ret;
                }

                ret = currentState.render();
                if (ret != RetCode.SUCCESS)
                {
                    Debug.err(getClass().getName(), "Problem with rendering the state!");
                    mRenderer.unlock();
                    return ret;
                }
            }

            Fps.update();
        }
    }
    finally 
    {
        // An exception occured, but try to unlock the canvas.
        mRenderer.unlock();
    }

    return ret;
}
runSlice zmienia się wraz z rozwojem kasuroid. Aby zademonstrować działanie Fpsa zaktualizowałem także test "Two timers" o wyświetlanie aktualnego fpsa. Warto pobawić się "sleepTime" Corea i zobaczyć jak zmienia sie fps.
kasuroid zaczyna w końcu raczkować :) Do następnego!

Monday, September 27, 2010

Czas goni nas (2)

Zgodnie z zapowiedzią czas na TimerManagera. Jak sama nazwa na to wskazuje, umożliwia on zarządzanie timerami utworzonymi w kasuroid. Główną metodą jest create():
public Timer create()
{
    Timer timer = new Timer();
    mTimers.add(timer);

    return timer;
}
Tworzy ona obiekt Timer i dodaje go do wektora mTimers (przechowuje wszystkie referencje do timerow utworzonych w TimerManagerze). Istnieje także możliwość usunięcia tak utworzonego timera poprzez zawołanie metody remove:
public int remove(int id)
{
    Timer timer = null;
    Enumeration e = mTimers.elements();
    int i = 0;
    while (e.hasMoreElements())
    {
        timer = e.nextElement();
        if (timer.getId() == id)
        {
            Debug.inf(getClass().getName(), "Timer with id " + id + " found. Removing.");
            mTimers.remove(i);
            return RetCode.SUCCESS;
        }
        i++;
    }

    return RetCode.BAD_PARAM;
}
Oczywiście nic nie stoi na przeszkodzie, aby tworzyć timery samodzielnie, bez udziału TimerManagera. Należy wtedy jednak pamiętać, aby w każdej iteracji głównej pętli Corea zawołać metodę update w celu  zaktualizowania czasu timera.
Każdy utworzony timer identyfikowany jest poprzez unikalne id. TimerManager udostępnia interfejs do sterowania timerami właśnie poprzez id. Są to (znane już metody z klasy Timer):
  • start(int id)
  • stop(int id)
  • pause(int id)
  • resume(int id)
Dodatkowo, interfejs TimerManagera został rozszerzony o dodatkowe metody do zarządzania wszystkimi timerami, a mianowicie:
  • startAll()
  • stopAll()
  • pauseAll()
  • resumeAll()
  • updateAll()
Myślę, że wszystko jest jasne. Aby zademonstrować działanie TimerManagera utworzyłem test Timers. Jest to jedno activity (TestCaseTimersActivity) z jednym stanem (TestCaseTimersState). W teście tworzone są automatycznie dwa timery (timer1 oraz timer2). Użytkownik ma możliwość sterowania timerami poprzez przyciski (mocno symboliczne - na UI przyjdzie jeszcze czas :P) umieszczone na głównym ekranie. A tak test Timers wygląda w praktyce:

Przyciski niebieskie "wołają" odpowiednio (od lewej) startAll, stopAll, pauseAll, resumeAll. Przyciski szare sterują timerem 1, natomiast przyciski różowe sterują timerem 2. W centralnej części wyświetlane są aktualne sekundy poszczególnych timerów. Polecam zassanie źródeł i sprawdzenia timerów w praktyce :)

Wednesday, September 22, 2010

Czas goni nas (1)

Załóżmy, że czas wyrenderowania ramki to 100ms na sprzęcie A. Na sprzęcie B ta sama ramka renderowana jest w ciągu 20ms. Jak łatwo policzyć na sprzęcie A będzie renderowanych 10 klatek na sekundę. Na sprzęcie B będzie to już 50 klatek na sekundę. Jeśli teraz będziemy aktualizować pozycję obiektu o 10 pikseli w każdej ramce to po sekundzie obiekt przebędzie drogę:
  • na sprzęcie A: 10 * 10 pikseli = 100 pikseli
  • na sprzęcie B: 50 * 10 pikseli = 500 pikseli
Jak widać na szybszej maszynie B obiekt przebył znacznie większą drogę niż na maszynie A. Aby wyeliminować to zjawisko animacje obiektów należy opierać właśnie o czas. Wtedy prędkość poruszania się obiektu może być wyrażona w pikselach / sekundę (a nie jak w przykładzie piksele / klatkę), zatem:
  • na sprzęcie A: 1 sekunda * 10 pikseli / sekunda = 10 pikseli / sekunda
  • na sprzęcie B: 1 sekunda * 10 pikseli / sekunda = 10 pikseli / sekunda
Każdy poważny framework (a takim przecież ma być kasuroid :P) powinien posiadać możliwość zarządzania czasem. W kasuroid za czas odpowiedzialna jest klasa Timer. Udostępnia ona podstawowy interfejs do sterowania czasem:
public class Timer
{
    public int start() {}

    public int stop() {}

    public int pause() {}

    public int resume() {}

    public int update() {}
}
Wszelkie obliczenia odbywają się w metodzie update():
public int update()
{
    if ((isStarted() == true) &&
        (isPaused() == false))
    {
        mLastFrame = mCurrentFrame;
        mCurrentFrame = System.currentTimeMillis();
        mDelta = mCurrentFrame - mLastFrame;
        mTimeElapsed += mDelta;
        mDeltaSecs = (float)mDelta / mTimerFrameLength;
    }

    return RetCode.SUCCESS;
}
Pomiar czasu opiera się na różnicy czasów dwóch kolejnych ramek, więc potrzebne jest zainicjalizowanie mLastFrame oraz mCurrentFrame podczas startowania timera:
public int start()
{
    if (isStarted() == false)
    {
        mCurrentFrame = mLastFrame = System.currentTimeMillis();
        mTimeElapsed = 0;
        mDelta = 0;
        mDeltaSecs = 0.0f;

        Debug.inf(getClass().getName(), "Timer with id: " + mId + " started.");
        return RetCode.SUCCESS;
    }
    else
    {
        Debug.warn(getClass().getName(), "Timer already started!");
        return RetCode.SUCCESS;
    }
}
Warto zauważyć, że w update() obliczam od razu liczbę sekund trwania jednej ramki. Doszedłem do wniosku, że dużo obiektów będzie bazowało swoje animacje właśnie na podstawie tej wartości. Dlatego też wystarczy policzyć ją raz per ramka. Każdy obiekt może następnie skorzystać z poniższej metody do pobrania czasu (w sekundach):
public float getTimeDeltaSecs()
{
    return mDeltaSecs;
}
.. i obliczyć na jego podstawie kolejny krok animacji.

Timer liczy także czas jaki upłynął od czasu wywołania metody start() (zmienna mTimeElapsed).

I to chyba tyle. W następnej części przedstawie TimerManagera.

Sunday, September 19, 2010

Kapryśny Android

Sprawa dotyczy zachowania się activity podczas obrotu. Aby obrócić ekran emulatora należy użyć CTRL+F12. Wspomniana kombinacja klawiszy obraca ekran o 90 stopni. Możemy w ten sposób przetestować jak zachowa się nasza aplikacja podczas obracania rzeczywistego urządzenia. Co się okazuje (ja byłem całkiem mocno zaskoczony :)) podczas takiego obrotu niszczone jest bieżące activity, a następnie tworzone nowe. Niezła niespodzianka, prawda? ;)

Problem okazał się całkiem poważny, gdyż podczas onCreate oraz onDestroy (a dokładniej podczas tworzenia / niszczenia powierzchni w KasuroidView), kasuroid jest odpowiednio inicjalizowany / kończy pracę. Okazało się, że startowanie Corea w KasuroidView nie jest dobrym pomysłem (szczerze mówiąc od samego początku takie rozwiązanie mi się nie podobało i prędzej czy później chciałem to zmienić).

Pierwszym podejściem do rozwiązania problemu było zablokowanie przejścia do trybu landscape (obrót o 90 stopni w stosunku do trybu portrait, w którym odpalany jest emulator). Aby wymusić dany tryb wyświetlania aplikacji (bez możliwości jego zmiany) należy w manifeście dla odpowiedniego activity dodać android:screenOrientation="portrait". Niestety (jak można się domyślić) nie rozwiązało to problemu w 100% :) Ekran owszem nie jest skalowany do trybu landscape przy obrocie, ale activity nadal jest niszczone. Na dodatek chciałbym, żeby kasuroid jednak wspierał tryb landscape.

Chwila szukania / czytania API i.. jest światełko w tunelu. Okazuje się, że na activity tuż przed onDestroy wołana jest metoda onSaveInstanceState. Umożliwia ona zapisanie stanu aplikacji, który następnie może zostać odtworzony w onCreate

Na teraz nie bardzo sobie wyobrażam jakie dane mógłbym zapisywać (w przypadku gry mogą to być jednak parametry leveli itd.), ale to co mogę zrobić w takiej sytuacji to zabezpieczyć Core przed restartowaniem. Warto wspomnieć, że Core jest singletonem. Co za tym idzie obiekt Corea przechowywany jest w zmiennej statycznej, a te nie są niszczone, jeśli activity jest przenoszone do hmm.. tła (minimializowanie) lub np. jak w tym przypadku, następuje obrót ekranu.

Przede wszystkim rozszerzyłem interfejs Corea o możliwość zatrzymywania i włączania głównego wątku (metody stop() oraz start()). Wątek nie robi nic poza wołaniem runSlice, więc można spokojnie nim manipulować ;). Następnie cały proces inicjalizacji / przerywania pracy / startowania Corea przeniosłem do KasuroidActivity.

A w jaki sposób sprawdzić czy activity jest jedynie maksymalizowane czy też tworzone całkiem od nowa? Hmm.. zastosowałem trochę "hardkorowe" podejście i pewnie zostanę mocno skarcony przez znawców Androida :P. Zauważyłem, że jeśli activity wraca do pierwszego planu to parametr w onCreate nie jest zerowy (lub nie jest nullem). Wystarczy zatem sprawdzić uchwyt do Bundle przekazanego jako parametr i wtedy albo uaktualniamy jedynie powierzchnię Renderera albo inicjalizujemy Corea. Następnie wystarczy już tylko wystartować wątek Corea.

W związku z powyższą zmianą logiki startu activity, do KasuroidActivity dodałem dwie metody onInit oraz onTerm. onInit wołana jest w przypadku kiedy tworzone jest nowe activity, natomiast onTerm, kiedy activity permanentnie jest niszczone.

W przypadku wspomnianego w poprzednim wpisie testu stanów gry, inicjalizacja pierwszego stanu została przeniesiona z onCreate do onInit (rozwiązuje to problem zmiany stanu, w przypadku gdy activity przeszło do tła przy aktywnym stanie drugim).

Źródła uaktualnione, więc zachęcam do podejrzenia kodu.

Btw. cykl życia androidowego activity jest dość ciekawy i bardzo możliwe, że w trakcie rozwijania projektu KasuroidActivity trochę się pozmienia :) 

Jeszcze takie spostrzeżenie. Praca nad kasuroid w całkiem dużym stopniu przypomina programowanie ekstremalne ;)

Do następnego!

Thursday, September 16, 2010

Stany (nie do końca zjednoczone)

Środowisko do testów właściwie gotowe, więc można dłubać dalej. Poniżej postaram się przybliżyć odrobinę o co chodzi z tymi stanami gry i dlaczego warto stosować takie podejście.

Bardzo popularne w grach są różnego rodzaju intra. Np. najprostsze to wyświetlenie przez jakiś czas logo producenta gry. Następnie pojawia się na ogół główne menu gry. Po wybraniu "start/nowa gra" rozpoczyna się gra. To co wymieniłem to właśnie stany gry. Czy można się bez nich obejść, tzn. bez definiowania oddzielnych klas do ich obsługi? Oczywiście, że można, ale czy nie lepiej mieć porządek w kodzie i wszytko ładnie logicznie podzielone?

Jak to wygląda w praktyce?

W kasuroid zdefiniowałem klasę bazową dla stanów gry tj. GameState (tak, State wrócił do pierwotnej nazwy). Posiada ona następujące metody:
  • init()
  • term()
  • pause()
  • resume()
  • update()
  • render()
Powyższe metody oznaczone są jako final i nie mogą być przeciążone przez klasy pochodne. Zdecydowałem się na takie podejście, aby zapewnić wewnętrzna kontrolę stanu. Np. wywołanie update, gdy obiekt nie jest zainicjalizowany wygeneruje błąd. Można jednak przeciążyć metody, które będą wołane w odpowiednich stanach tj.:
  • onInit()
  • onTerm()
  • onPause()
  • onResume()
  • onUpdate()
  • onRender()
Zarządzanie stanami gry zrealizowałem w oparciu o stos (mGameStatesStack w Core). Core posiada interfejs do zarządzania stanami, a mianowicie:
  • changeState(GameState gameState) - czyści stos, po czym wrzuca na niego gameState
  • pushState(GameState gameState) - pauzuje aktualnie znajdujący się na szczycie stosu stan i dodaje gameState
  • popState() - zdejmuje ze stosu aktualny stan i włącza (resume) stan następny
Jest to chyba najprostszy sposób realizacji zarządzania stanami gry. Można jednak przy jego pomocy realizować całkiem ciekawe przejścia w grze (co też zamierzam w odpowiednim czasie pokazać).

Ok, czas na pierwszy oficjalny test :) Dodałem nowy pakiet do projektu: com.kasuroid.test.cases. Do tego pakietu mam zamiar wrzucać wszystkie testowe activity wraz z potrzebnymi klasami. Aby mieć w miarę porządek i jako, że pojedynczy test może składać się z wielu plików, pakiet com.kasuroid.test.cases będę dzielić na drobne "podpakiety". Pierwszy podpakiet to GameStates. Stworzyłem grupę "Game states" i zdefiniowałem TestCaseGameStateActivity oraz dwa "stany gry": TestCaseGameStateState1, TestCaseGameStateState2. Aby zainicjować pierwszy stan wystarczy wywołać change state w activity:
    public void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        
        Core.getInstance().changeState(new TestCaseGameStateState1());
    } 
Gdy jesteśmy w stanie 1 (aktywny TestCaseGameStateState1) i naciśniemy dowolny klawisz to przejdziemy do stanu 2 (TestCaseGameStateState2). Jeśli zostanie naciśnięty klawisz w stanie 2 to nastąpi powrót do stanu 1. Wyjść z programu można w dowolnym momencie klikając na ekran.

Stan 1:
Stan 2:
Kliknięcie na ekran powoduje wyjście z testu:
Aby lepiej zobaczyć jak zachowują się poszczególne elementy kasuroid proponuję uruchomić projekt i podejrzeć logi (próbowałem przekleić chociaż część logów, ale poszczególne wiersze nie mieszczą się w jednej linii, a mi nie bardzo chce się ich ręcznie ciąć :P).

Dobrej nocy!