8. Wracamy do gry

W dzisiejszym odcinku napiszemy naszą klasę, wytworzymy parę własnych obiektów, poskładamy w całość elementy pozbierane w poprzednim odcinku i nie spoczniemy, dopóki nie wejdziemy do działającej gry.

Ulepszamy funkcję sleep

Na początek wyodrębnijmy do wielokrotnego użytku ulepszoną funkcję sleep, którą omawialiśmy w moim poprzednim wpisie.

Prócz oczekiwania określoną ilość sekund z częstym wywoływaniem loop_update chcemy się dowiedzieć, czy użytkownik w trakcie tego oczekiwania nie nacisnął escape.

Przyjmijmy więc, że nasza funkcja game_sleep zwróci true, jeśli użytkownik nacisnął escape, a false w przeciwnym wypadku.

W ten sposób w programie wywołującym funkcję, łatwo będziemy mogli reagować na escape.

def game_sleep(wait_time)
  wait_time = Time.now.to_f + wait_time
  stop = false
  while not stop and Time.now.to_f < wait_time
    loop_update
    stop = true if escape
  end
  return stop
end

To chyba pierwszy przypadek, gdy użyliśmy dwuelementowego warunku dla pętli while. Będzie ona wykonywana gdy not stop czyli innymi słowy stop == false, oraz gdy aktualny czas jest mniejszy od czasu końca oczekiwania. Wewnątrz pętli umieściliśmy prostą instrukcję warunkową, która przestawia stop na true, jeśli naciśnięto escape.

Piszemy naszą klasę

Analizując nasz pseudokod gry, można zadać pytanie, jak uprościć odtwarzanie dźwięków w grze?

Na etapie prototypu użyjemy dźwięków systemowych z folderu media, ale później, gdy już obudujemy grę w Eltenowy program, być może przygotujemy jakieś własne, lepsze sample.

Wygodnie byłoby wówczas jedną linią zmienić folder, w którym one wszystkie się znajdują.

Jak wiemy, odtwarzanie dźwięków będzie polegało na określeniu ich balansu (kanał lewy, prawy lub oba) i może też wygodnie byłoby móc zrobić to jedną instrukcją.

Krótko mówiąc – czas napisać naszą klasę.

W rzeczywistości pierwszą klasę napisaliśmy już w odc. 4., tworząc szkielet Eltenowego programu.

Dzisiaj napiszemy drugą – SimpleGameSound, czyli klasę dźwięków naszej prostej gry (ang. simple game).

class SimpleGameSound
  def initialize(name)
    @so = Sound.new('c:\windows\media\\' + name + ".wav")
    @so.volume = 2
  end
end # class

Utworzyliśmy klasę i zdefiniowaliśmy w niej metodę initialize. Metoda ta będzie automatycznie wywołana na obiektach utworzonych z naszej klasy przy pomocy SimpleGameSound.new.

Zmienna @so (skrót od sound), która pojawia się w initialize, to zmienna, do której załadowaliśmy nowy obiekt klasy Sound. Obiekt ten utworzyliśmy ze ścieżką do systemowego folderu media i rozszerzeniem .wav, więc jako parametr do funkcji wystarczy podawać bazową nazwę pliku dźwiękowego do wczytania, np. SimpleGameSound.new('tada')

Przy okazji warto zwrócić uwagę na zastosowanie znaku + dla dodawania do siebie łańcuchów:

puts("Ala"+" ma kota")
Wynik: "Ala ma kota"

Znak małpy @ przed nazwą zmiennej oznacza, że jest to zmienna instancji obiektu, który utworzymy z naszej klasy.

Przykładowo, jeśli napiszemy:

sound_bad = SimpleGameSound.new("notify")
sound_good = SimpleGameSound.new("chimes")
sound_computer = SimpleGameSound.new("tada")

to obiekt sound_bad będzie miał swoją zmienną @so ustawioną na obiekt z dźwiękiem notify, sound_good będzie miał @so jako obiekt utworzony z dźwięku himes, a sound_computer z dźwięku tada.

Gdybyśmy zmienną so napisali bez małpy, to byłaby ona lokalna dla metody, czyli moglibyśmy się do niej odwoływać tylko w obrębie metody initialize. W wersji z małpą jest dostępna dla innych metod naszej klasy, tych, które za chwilę utworzymy.

Analizując pseudokod wiemy, że będzie nam potrzebna metoda odtwarzania dźwięku z podaniem jego balansu czyli kierunku, oraz metoda zatrzymywania dźwięku.

Rozbudujmy zatem naszą klasę:

class SimpleGameSound
  def initialize(name)
    @so = Sound.new('c:\windows\media\\' + name + ".wav")
    @so.volume = 2
  end

  def stop
    @so.stop if @so.playing?
  end

  def play(direction)
    stop
    @so.pan = direction
    @so.play
  end
end

Interpreter przyjął naszą definicję, więc nie pozostaje nic innego, jak przetestować w działaniu:

test = SimpleGameSound.new('tada')
test.play(1)
sleep(0.5)
test.stop
test.play(-1)

Uruchamiamy program i niestety nie działa on tak, jak byśmy tego oczekiwali. Metoda stop powinna zatrzymać odtwarzanie, a okazuje się, że działa jak pauza, czyli użycie następnie metody play powoduje wznowienie odtwarzania od miejsca, gdzie zostało zatrzymane.

Jest to kolejny dobry powód, aby utworzyć własną klasę dźwięków i następnie w jednym miejscu dla wszystkich naszych sampli zastosować obejście problemu.

Obejściem będzie w tym przypadku ustawienie atrybutu position przed rozpoczęciem odtwarzania, czyli przewinięcie na początek, więc nasza metoda play wyglądać będzie następująco:

  def play(direction)
    stop
    @so.position = 0
    @so.pan = direction
    @so.play
  end

Aktualizujemy definicję naszej klasy w konsoli, powtarzamy poprzedni test i okazuje się, że skutecznie rozwiązaliśmy problem.

Inicjalizacja zmiennych

Jak widać w napisanym pseudokodzie, program naszej gry można podzielić na 3 niezbędne elementy:

  • Inicjalizacja zmiennych, czyli nadanie wszystkim zmiennym wartości początkowych,
  • pętla kolejnych rund gry,
  • prezentacja wyników.

Zacznijmy więc od ustawienia początkowego stanu zmiennych i załadowania dźwięków:

sound_bad = SimpleGameSound.new("notify")
sound_good = SimpleGameSound.new("chimes")
sound_computer = SimpleGameSound.new("tada")
end_game = false
rounds_count = 0
correct_answers_count = 0
time_sum = 0

Główna pętla

while not end_game
  rounds_count = rounds_count + 1
  alert("Round #{rounds_count}.")

Rozpoczęliśmy pętlę while, która będzie wykonywana podczas gdy zmienna end_game ma wartość false. Zwiększyliśmy licznik rund i wypowiadamy numer aktualnej rundy.

Wylosuj czas oczekiwania

  wait_time = rand() * 3
  wait_time = wait_time + 2
  end_game = game_sleep(wait_time)
  break if end_game

Widełki czasowe obliczamy zgodnie z algorytmem opracowanym w poprzednim odcinku: losujemy liczbę z zakresu od 0 do 1 i przemnażamy ją przez 3, czyli ilość sekund w obszarze losowania czasu. Następnie dodajemy do wyliczonej wartości 2 sekundy, czyli minimalny czas oczekiwania.

Wreszcie przekazujemy wyliczoną wartość do funkcji game_sleep, a do zmiennej end_game dostaniemy informację zwrotną, jeśli w trakcie oczekiwania, użytkownik nacisnął escape.

Linia break if end_game od razu obsłuży taki przypadek, niezwłocznie wychodząc z pętli while.

Wylosuj kierunek

Losujemy 1 z 3 możliwych kierunków, więc użyjemy do tego funkcji rand(3). Zwróci ona wartość 0, 1 lub 2, a nam są potrzebne wartości kompatybilne z parametrem play naszego obiektu SimpleGameSound, czyli wartości -1, 0 i 1. Od wylosowanej liczby, wystarczy odjąć 1, aby uzyskać taką, o którą nam chodzi.

  computer_direction = rand(3) - 1
  sound_computer.play(computer_direction)

Powyższe dwie linie losują kierunek i rozpoczynają odtwarzanie dźwięku w wylosowanym kierunku.

Badanie reakcji użytkownika

Włączamy stoper i czekamy na reakcję użytkownika. Wyjście z pętli oczekiwania na reakcję użytkownika, może nastąpić, gdy nacisnął escape, albo któryś z interesujących nas klawiszy strzałek.

Naciśnięcie escape spowoduje ustawienie wartości end_game na true i następnie wyjście z pętli. Naciśnięcie którejś ze strzałek, ustawi zmienną user_direction na wartość -1, 0 lub 1, zależnie od kierunku naciśniętej strzałki. Wartość user_direction będzie kompatybilna z computer_direction, łatwo będzie porównywać.

Abyśmy mogli zaprogramować warunek wykonywania pętli while, ustawiamy najpierw zmienną user_direction na nil, czyli wartość nieustaloną, pustą. Następnie wchodzimy w pętlę wykonywaną dopóki end_game jest false i user_direction jest nil.

Po zakończeniu tej pętli, zatrzymujemy stoper i wyłączamy dźwięk komputera. Jeśli end_game stało się true (użytkownik nacisnął escape), wyskakujemy z pętli głównej.

To samo zapisane w Ruby, wygląda następująco:

  reaction_time = Time.now.to_f
  user_direction = nil
  while not end_game and user_direction == nil
    loop_update
    if escape
      end_game = true
    elsif arrow_left
      user_direction = -1
    elsif arrow_up
      user_direction = 0
    elsif arrow_right
      user_direction = 1
    end
  end #wait for user direction
  reaction_time = Time.now.to_f - reaction_time
  sound_computer.stop
  break if end_game

Sprawdzenie prawidłowości reakcji

Jeśli user_direction jest równe computer_direction, to znaczy, że użytkownik udzielił prawidłowej odpowiedzi. W takim przypadku zwiększamy liczbę prawidłowych odpowiedzi o 1, zwiększamy sumę czasu o czas reakcji w tej rundzie i odtwarzamy dźwięk poprawnej odpowiedzi.

Dajemy też użytkownikowi 2 sekundy na delektowanie się sukcesem, przechwytując wynik z game_sleep do end_game.

Gdy user_direction jest inne niż computer_direction, to znaczy, że użytkownik udzielił nieprawidłowej odpowiedzi. Wówczas ustawiamy zmienną end_game na true, odtwarzamy dźwięk nieprawidłowej odpowiedzi i również dajemy użytkownikowi 2 sekundy na jego posłuchanie.

Warto zwrócić uwagę, że w tym przypadku nie przyjmujemy wyniku game_sleep do żadnej zmiennej, gdyż i tak end_game ustawimy na true. Można bez konsekwencji wywołać funkcję ignorując zwracany przez nią wynik, który w danym przypadku nie jest potrzebny.

  if computer_direction == user_direction
    correct_answers_count = correct_answers_count + 1
    time_sum = time_sum + reaction_time
    sound_good.play(user_direction)
    end_game = game_sleep(2)
  else
    sound_bad.play(user_direction)
    game_sleep(2)
    end_game = true
  end

Zakończenie pętli i gry

Najprostsze na koniec: zamknąć pętlę główną i wyświetlić średni czas reakcji…

  end #main while loop
alert("Game ended.")
reaction_avg = time_sum / correct_answers_count
reaction_avg = (reaction_avg * 1000).round
alert("Reaction time: #{reaction_avg} ms, after #{correct_answers_count} correct answers.")

Mamy to, wystarczy teraz poskładać w całość 80 linii kodu omówionego w tym odcinku i zapisać do jakiegoś pliku.

Następnie, gdy tylko zechcemy, możemy zawartość tego pliku wkleić w konsolę Eltena i nacisnąć Ctrl+Enter, aby uruchomić grę.

Montujemy obudowę

O ile przeklejanie pliku do konsoli oczywiście działa, to jednak możemy sobie uprościć życie i mieć naszą świetną grę zawsze pod ręką w menu “Programy”.

Na początek, warto umieścić w osobnej funkcji całą logikę gry, żeby móc łatwiej się do niej odwoływać.

Zdefiniujmy więc funkcję game_play i spakujmy do niej całą naszą grę od inicjalizacji zmiennych zaczynając, a na wyświetleniu wyniku kończąc.

def game_play
  sound_bad = SimpleGameSound.new("notify")
  sound_good = SimpleGameSound.new("chimes")
  sound_computer = SimpleGameSound.new("tada")
...
  alert("Reaction time: #{reaction_avg} ms, after #{correct_answers_count} correct answers.")
end # game_play

game_play

Cały przebieg gry został zamknięty w pojedynczej funkcji, łatwo do niego się odwołać wywołując tę funkcję, co robimy w ostatniej linii pliku z grą.

W pliku tym mamy teraz 4 elementy: klasę SimpleGameSound, funkcję game_sleep, definicję funkcji game_play i wywołanie game_play.

Tworzymy Eltenowy program

Nasza funkcja game_play to może być ten środek, bez którego zostawił nas w odcinku 4 współautor tego bloga.

Tworzymy więc plik __app.rb, piszemy nagłówkowe informacje i przeklejamy całą zawartość gry, którą dotychczas napisaliśmy.

Następnie, funkcje game_sleep i game_play obudowujemy Eltenową klasą dziedziczącą z program, a w funkcji main tej klasy, wywołujemy game_play.

=begin EltenAppInfo
Name=Reaction measurement Game
Version=1.0
Author=grzezlo
=end EltenAppInfo

class SimpleGameSound
...
end # class SimpleGameSound

class ReactionMeasurementGame < Program
  def main
    game_play
    finish
  end

  def game_sleep(wait_time)
...
def game_play
...
end # class SimpleReactionGame 

W pliku __app.rb mamy teraz dwie klasy: SimpleReactionGame i napisaną dawniej SimpleGameSound.

Mamy też możliwość uruchamiania naszej gry wprost z menu Programy Eltena, ale niestety nie ma róży bez kolców. Każdy błąd, jaki teraz popełnimy w programie, może spowodować brak możliwości uruchamiania Eltena, a wprowadzenie jakiejkolwiek zmiany, na pewno będzie wymagać restartowania Eltena.

Dlatego testowanie wszystkiego jak długo się dało w konsoli, miało sens.

Jednak przydałoby się menu

Modyfikacja, oparta na gotowym rozwiązaniu pokazanym w odc. 4., nie jest zbyt skomplikowana. Widać tu, dlaczego warto było wyodrębnić całą rozgrywkę do osobnej funkcji, łatwiej przenieść jedną linię wywołującą game_play, niż kilkadziesiąt stanowiących zawartość funkcji.

  def main
    menu = Menu.new
    menu.option("New game") {
          game_play
    }
    menu.option("Exit") {
      finish
    }
    menu.show
  end

Całkiem przyjemnie się już z gry korzysta, jednak w jaki sposób można zastąpić użyte sample własnymi?

app_file, czyli plik programu

Funkcja app_file, użyta w klasie dziedziczącej z Program, zwraca nazwę pliku podaną jako parametr ze ścieżką do bieżącego folderu naszego programu.

Niestety, nasza klasa SimpleGameSound nie dziedziczy z klasy program, a więc nie możemy tam użyć tej funkcji.

Musimy zatem wykonać krok wstecz i podawać jako parametr do metody SimpleGameSound.new, pełną ścieżkę do pliku.

Modyfikujemy metodę initialize klasy SimpleGameSound:

class SimpleGameSound
  def initialize(name)
    @so = Sound.new(name)
  end

Następnie, w game_play, nasze sample zainicjujemy z pełną ścieżką i nazwą:

sound_bad = SimpleGameSound.new(app_file('bad.ogg'))
    sound_good = SimpleGameSound.new(app_file('good.ogg'))
    sound_computer = SimpleGameSound.new(app_file('computer'))

Łapiemy pierwszego buga

Bug to z angielskiego pluskwa. Tą nazwą określa się błędy w oprogramowaniu, co ma swoją genezę w dawnych czasach i komputerach wielkości całych pomieszczeń, w których czasem rzeczywiste pluskwy zwierały jakieś obwody elektryczne, powodując nieprawidłowe działanie.

W końcu mamy swojego pierwszego buga – błąd, który pojawia się, jeśli użytkownik pomylił się w pierwszej rundzie.

W takim przypadku, liczba prawidłowych odpowiedzi wynosi 0, dzieląc sumę czasu przez liczbę prawidłowych odpowiedzi, następuje niedozwolone w matematyce dzielenie przez 0. I temu musimy zapobiec:

    alert("Game ended.")
    if correct_answers_count > 0
      reaction_avg = time_sum / correct_answers_count
      reaction_avg = (reaction_avg * 1000).round
      alert("Reaction time: #{reaction_avg} ms, after #{correct_answers_count} correct answers.")
    else
      alert("No correct answers.")
    end

Własne dźwięki, self i dwie małpy na pomoc

Kilkanaście minut z Goldwave i mamy zestaw 3 prostych własnych dźwięków do gry. Warto zrobić z nich użytek.

Sposób pokazany poprzednio, podawania całej ścieżki do sampli, był co prawda prosty, ale nie jedyny i nawet nie najlepszy.

W Ruby zmienne poprzedzane jedną małpą, są zmiennymi konkretnej instancji obiektu. Tak więc nasz obiekt @so dla każdego obiektu utworzonego z klasy SimpleGameSound będzie miał inną wartość.

Są też w Ruby zmienne poprzedzane dwoma małpami – są to zmienne klasy. Takie zmienne dla wszystkich instancji obiektu danej klasy, będą miały tę samą wartość.

W tego typu zmiennej można by więc przechowywać ścieżkę do folderu z samplami, ustawianą na początku, zanim jeszcze zostanie zainicjowany jakikolwiek obiekt.

Do jej ustawienia służyłaby metoda set_samples_dir, którą szybko napiszemy:

class SimpleGameSound
  def self.set_samples_dir(dir)
  @@samples_dir=dir
  end
  ...
  end

Następnie wywołamy tę metodę przed utworzeniem pierwszego obiektu dźwięku:

SimpleGameSound.set_samples_dir('c:\windows\media\\')
Error: undefined method `set_samples_dir' for SimpleGameSound:Class

Tego się nie spodziewaliśmy. Z drugiej strony, dotychczasowe doświadczenie pokazuje, że tak napisana metoda działałaby prawidłowo dla obiektu utworzonego z tej klasy. Gdybyśmy mieli już jakikolwiek utworzony obiekt, to nasza metoda straciłaby rację bytu, gdyż musi zostać wywołana zanim jeszcze jakikolwiek obiekt zostanie utworzony.

Takie metody, wywoływane dla klasy, a nie jakiegoś jej obiektu, to metody statyczne.

self

Słówko self (w niektórych językach this), oznacza wewnątrz definicji metod klasy, tę konkretną instancję obiektu. Gdybyśmy chcieli porównać nasz obiekt z jakimś innym obiektem, moglibyśmy użyć właśnie self…

class Test
def is_equal(obj)
return self==obj
end
end
a=Test.new
b=Test.new
alert(a.is_equal(a)) # true
alert(b.is_equal(b)) # false

Porównując obiekt a z obiektem a, otrzymujemy true, a porównując b z obiektem a – false. W wywołaniu a.is_equal zmienna self to jest obiekt a, a w b.is_equal – self to obiekt b.

Tak to wygląda wewnątrz definicji metod, ale na zewnątrz, na poziomie definicji klasy, zmienna self odnosi się do tej konkretnej klasy.

Aby zadeklarować metodę statyczną, działającą na poziomie całej klasy, wystarczy, że napiszemy:

  def self.set_samples_dir(dir)

I okazuje się, że wszystko działa zgodnie z oczekiwaniem.

Skoro nasze sample przygotowaliśmy w preferowanym przez Eltena formacie ogg, to jeszcze podmieniamy rozszerzenie pliku w initialize, a następnie w game_play ładujemy nasze dźwięki.

def initialize(name)
    @so = Sound.new(@@samples_dir + name + ".ogg")
    @so.volume = 0.8
  end
...
  def game_play
  SimpleGameSound.set_samples_dir(app_file)
    sound_bad = SimpleGameSound.new("bad")
    sound_good = SimpleGameSound.new("good")
    sound_computer = SimpleGameSound.new("computer")
    ...

Prawie osiągnęliśmy setkę

Aktualna wersja naszej gry ma prawie 100 linii kodu (pustych nie liczymy), dokładnie 99. Dla porównania, czytnik ekranu NVDA składa się z około 87 tys. linii kodu, a Windowsowy klient Eltena 2.4.3 z trochę ponad 41 tys. linii.

Rzuca to pewne światło na złożoność tych programów, chociaż porównywanie ilości linii kodu, może być trochę jak porównywanie ciężarówki cegieł do laptopa pod względem ich masy i wnioskowanie na tej podstawie o proporcjonalnym stopniu skomplikowania ich konstrukcji.

Z drugiej strony, czy na pewno konstrukcja ciężarówki, zwłaszcza z jej komputerem pokładowym, jest mniej złożona od laptopa?

Po skróceniu do jednej sekundy minimalnego czasu oczekiwania, wydłużeniu do 4 sekund losowanej przestrzeni i skróceniu do 1 sekundy przerwy po odtworzeniu dźwięku prawidłowej lub błędnej odpowiedzi, możemy już bez przeszkód delektować się rozgrywką. Napiszcie w komentarzach, jakie osiągnęliście rezultaty.

Gotowy program

Gotowy program wraz z niezbędnymi plikami sampli, jest do pobrania pod poniższym linkiem. Należy go wypakować komendą “Wypakuj tutaj”, a następnie przenieść utworzony folder “reaction_measurement_game” do Eltenowego folderu apps.

Pobierz plik zip z grą

8 komentarzy

  1. Ja wiem że to pytanie nie na temat, ale jak Ruby radzi sobie z serializacją obiektów? To znaczy, jak to działa od środka? W C# jest refleksja, a tutaj?

  2. @Bomberman The BASS classes are not enough to create a complex audiogame environment. How would you deal with caching, 3D panning, distance rendering, ETC?

  3. Ok, let’s say that the educational purpose was the most important here.
    In my next audio game project, i’ll discuss on this blog about part 15., it’ll be many more good reasons to create our own game sound objects…

  4. @grzezlo ok, but isn’t it better to make stop function right in the bass:sound class, not creating my own? there must be pause function that calls regular stop method, and a stop function that alows us to rewind to 0. i will definately pull request this soon.

  5. Regarding SimpleGameSound object, there are 3 reasons to create it:
    1. To automatically set playing position to 0 before calling play, to be sure we’re starting the sound from the beginning each time.
    2. To simplify instantiating few objects from files placed in the same directory.
    3. And the last but not least, for educational purposes, to demonstrate our own objects’ creation.
    Regarding the calculations, of course they could be integrated to one line, but in my opinion, this line would be less readable especially for the beginners.

  6. isn’t it simpler to turn this lines
    reaction_avg = time_sum / correct_answers_count
    reaction_avg = (reaction_avg * 1000).round
    to
    reaction_avg = (time_sum / correct_answers_count*1000).round

  7. why do we need to make other sound class to an existing Sound or Bass::Sound class? it is so complicated. we can just use Sound.new instead of Simplegamesound.

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

EltenLink