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.
@grzezlo class GaneSound < Sound
def stoptozero
stop
#position to 0
end
end
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?
@Bomberman The BASS classes are not enough to create a complex audiogame environment. How would you deal with caching, 3D panning, distance rendering, ETC?
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…
@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.
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.
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
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.