6. Tworzymy grę, czyli to brzmi jak plan

W dzisiejszym odcinku zaczniemy od śmiałego pomysłu, zajmiemy się też losowaniem, mierzeniem czasu i znakiem ucieczki; przeprowadzimy kilka castingów, a zakończymy na pseudokodzie, ale ciąg dalszy z pewnością nastąpi…

Pomysł

Programowanie zaczyna się od pomysłu. Często im śmielszy, tym lepiej, bo cała przygoda staje się ciekawsza.

Czy z naszą aktualną wiedzą będziemy już w stanie napisać prostą grę audio?

Z pewnością nie od razu, ale dążenie do realizacji pomysłu ukierunkuje nasze poszukiwania brakujących elementów – wiedząc, co chcemy zrobić, będziemy wiedzieć, co jest nam potrzebne.

Plan gry

Chcemy stworzyć jakąś prostą grę.

  1. Niech program odczeka jakiś losowy czas – kilka sekund.
  2. Następnie, niech odtworzy jakiś dźwięk w wylosowanym kierunku: po lewej, po prawej lub z przodu.
  3. Użytkownik musi teraz nacisnąć strzałkę odpowiadającą kierunkowi usłyszanego dźwięku: w lewo, prawo lub w górę. Musi to zrobić jak najszybciej.
  4. Jeśli się pomyli, gra się kończy.
  5. W przeciwnym wypadku nastąpi kolejna runda gry, czyli powrót do punktu 1.
  6. Na koniec, użytkownik dostanie informację o średnim czasie reakcji na dźwięk.

Trochę już wiemy, że gra będzie się odbywać w rundach i zapewne zaprogramujemy ją w pętli “while się nie pomylił”, albo “until pomyłka użytkownika”…

Widać też, że paru elementów nam brakuje i poniżej te braki spróbujemy uzupełnić.

  • Jak losować?
  • Jak wykrywać naciskane klawisze?
  • Jak zaczekać kilka sekund?
  • Jak zmierzyć czas?
  • Jak odtworzyć dźwięk?

W konsoli Eltena pozbieramy poszczególne części, jakby podzespoły jakiegoś mechanizmu, a następnie byłoby dobrze umieścić je w solidnej obudowie, czyli wewnątrz Eltenowego programu, którego konstrukcję poznaliśmy w odcinku 4.

Losowanie

Do losowania w Ruby służy funkcja rand, opcjonalnie przyjmująca jako parametr ilość liczb całkowitych, wśród których chcemy losować. Wywołana bez parametru zwróci liczbę zmiennoprzecinkową (rzeczywistą) z zakresu od 0 do 1.

Nas interesuje pierwszy wariant, ponieważ chcemy losować jeden z 3 kierunków.

A zatem:

rand(3)
: #=> 1
: #=> 2
: #=> 2
: #=> 0

Uruchamiając wielokrotnie powyższe polecenie, możemy sprawdzić, że istotnie, system losuje liczby 0, 1 i 2.

Jeśli pozostałe elementy również okażą się tak proste, to jest szansa, że całą grę napiszemy jeszcze w tym odcinku.

Naciskane klawisze

Poniższy program wypowiada nazwę naciśniętej strzałki, aż do momentu naciśnięcia klawisza escape.

until escape
  loop_update
  if arrow_left
    alert("Left.")
elsif arrow_up
    alert("up.")
    elsif arrow_right
    alert("right.")
  end
end

Pętlę until znamy, escape, arrow_left, arrow_up i arrow_right to funkcje Eltena, które zwrócą true, gdy naciśnięto esc, strzałkę w lewo, górę lub w prawo. Z innymi klawiszami sprawa nie jest już tak prosta, ale o tym innym razem.

Sercem całej pętli jest loop_update, również funkcja Eltena, która musi być wywoływana, aby możliwe stało się odebranie naciśniętego klawisza za pomocą powyższych funkcji.

Co więcej, w takiej pętli bez loop_update, cały Elten przestałby odpowiadać.

Oczekiwanie

Do oczekiwania w Ruby służy funkcja sleep (śpij), która wstrzymuje wykonywanie programu na podaną liczbę sekund.

alert("Start")
sleep(3)
alert("stop")

Powyższy program wypowiada “Start”, zasypia na 3 sekundy i wypowiada “Stop”. Jak łatwo zauważyć, jest to na prawdę głęboki sen, gdyż cały Elten na ten czas przestaje odpowiadać.

Wiemy, że problem tkwi w braku uruchamiania loop_update, ale jak temu zaradzić?

Można podzielić czas oczekiwania na bardzo małe fragmenty i w każdym z tych fragmentów najpierw wywoływać loop_update, a później trochę drzemać.

Niech to będzie 0.02, dwie setne sekundy, co daje 50 cykli w sekundzie.

Zadaną ilość sekund przemnożymy przez 50 i wykonamy taką ilość krótkich cykli.

Ulepszony poprzedni przykład wygląda więc następująco:

alert("Start")
150.times do
loop_update
sleep(0.02)
end
alert("stop")

Jak widać, problem braku responsywności Eltena się rozwiązał.

A co stałoby się, gdybyśmy skrócili długość cyklu do jednej setnej, albo jednej tysięcznej sekundy i odpowiednio przemnożyli ilość cykli?

Przy jednej tysięcznej sekundy, oczekiwanie trwa kilka minut, a przy jednej setnej również wyraźnie dłużej niż zakładane 3 sekundy…

Dzieje się tak, ponieważ samo wykonanie funkcji loop_update, zajmuje pewną ilość czasu, możliwe, że nawet więcej niż jedna tysięczna sekundy, a tego nie uwzględniamy w naszych drzemkach.

Można by wywoływać w pętli oczekiwania funkcję loop_update, sprawdzać ile czasu upłynęło od początku pętli i wychodzić z oczekiwania, jeśli upłynęło tyle ile chcieliśmy. Ponieważ jednak przy tym losowym oczekiwaniu w naszej grze nie potrzebujemy jakiejś oszałamiającej precyzji, można wydłużyć cykl do dwóch setnych sekundy i uznać problem za rozwiązany w wystarczającym stopniu.

Wszyscy płacimy frycowe

Cóż innego można powiedzieć, gdy po udostępnieniu wpisu do roboczego folderu, czytający go autor Eltena pisze wiadomość, że w samym Eltenie istnieje już od dawna funkcja delay, której zadaniem jest właśnie oczekiwanie określoną ilość sekund z wywoływaniem loop_update.

Warto na pewno o tym wiedzieć, ale pojawia się pytanie, czy usuwać powyższe paragrafy, skoro stały się zbędne?

Zostawiłem, bo eksperymenty były ciekawe, a napisanie naszej własnej funkcji sleep i tak nas nie ominie.

Mierzenie czasu

Znamy metodę Time.now, która zwracała obiekt zawierający aktualny czas. Jedną z metod tego obiektu jest to_f, (to float, czyli do zmiennoprzecinkowej), zwracająca aktualny czas jako liczbę sekund z pewną ilością miejsc po kropce dziesiętnej. Metoda to_f jest standardowa również dla innych zmiennych, więc np.

1.to_f
: #=> 1.0

skonwertowało nam liczbę całkowitą 1, do liczby zmiennoprzecinkowej 1.0.

Time.now.to_f
: #=> 1633211411.684

Ta duża liczba to ilość sekund, która upłynęła od 1 stycznia 1970 roku; jest używana w różnych systemach jako tzw. znacznik czasu Unixa. Dla nas jest najważniejsze, że mamy aż 3 miejsca po kropce dziesiętnej, co oznacza, że czas jest podawany z dokładnością do milisekundy.

Mierzenie czasu można przeprowadzić w dwóch krokach:

  1. W chwili startu, do jakiejś zmiennej trzeba przypisać aktualną wartość czasu.
  2. W chwili zakończenia pomiaru, pobrać aktualną wartość czasu i odjąć od niej, zapisany wcześniej czas początkowy.

A ulepszona pętla oczekiwania z poprzedniego przykładu, mogłaby wyglądać następująco:

alert("Start")
ti=Time.now.to_f
ti=ti+3
while Time.now.to_f<ti
loop_update
end
alert("stop")

Pobieramy aktualny czas do zmiennej ti, zwiększamy zmienną o 3 sekundy, a następnie czekamy w pętli podczas gdy aktualny czas jest mniejszy od obliczonego czasu zakończenia oczekiwania.

Ile trwa loop_update?

Wiemy, jak mierzyć czas, więc możemy sobie odpowiedzieć na wcześniejsze pytanie, ile tak na prawdę trwa wykonanie funkcji loop_update? Zmierzenie czasu pojedynczego wykonania loop_update byłoby obciążone różnymi błędami pomiaru, dlatego warto zmierzyć czas wykonania badanej funkcji wielokrotnie i podzielić go przez ilość wykonań.

ti = Time.now.to_f
100.times do
  loop_update
end
ti = Time.now.to_f - ti
ti = ti / 100
puts("#{ti} sec.")

Okazuje się, że wyświetlony wynik to 0.017 sekundy, ale ile to będzie milisekund? Łatwo policzyć, ale niech program zrobi to za nas i poda wynik w ms.

Znaną liczbę sekund pomnożymy razy 1000:

0.016640000*1000
: #=> 16.64

Milisekundowa precyzja pomiaru w zupełności nam wystarczy, nie potrzebujemy jeszcze rozwinięcia po kropce dziesiętnej, jak się go pozbyć?

Podobnie jak istnieje w Ruby metoda to_f, konwertująca co się da na liczbę zmiennoprzecinkową, tak też istnieje metoda to_i, konwertująca na liczbę całkowitą.

16.64.to_i
: #=> 16

Ruby obciął wszystko po przecinku i zwrócił liczbę całkowitą, więc jest prawie dobrze, ale jednak chcielibyśmy, żeby 16.64 zostało prawidłowo zaokrąglone w górę, a nie w dół.

Użyjemy do tego metody round (zaokrąglij), a program pomiarowy finalnie wygląda następująco:

ti = Time.now.to_f
100.times do
  loop_update
end
ti = Time.now.to_f - ti
ti = ti / 100
ti = (ti * 1000).round
puts("#{ti} ms.")

Uzyskaliśmy wreszcie wynik jako 17 ms. Zmierzony czas na pewno zależy od używanego urządzenia i na innych systemach będzie inny – napiszcie w komentarzach ile wyszło u Was.

Ta prosta procedura pomiarowa może się czasem okazać przydatna, gdybyśmy chcieli sprawdzić, jak długo wykonuje się jakaś nasza funkcja i czy wprowadzonymi poprawkami w jej działaniu udało się skrócić ten czas.

Mając do dyspozycji możliwość reagowania na naciskane klawisze i mierzenia czasu, możemy też dość łatwo napisać własny stoper, co jednak zostawiamy dla dociekliwych i nie będziemy się tym teraz zajmować.

Rzutowanie typów

Rzutowanie typów (ang. type casting) ma w Rubym jeszcze kilka standardowych metod:

  • to_s – (ang. to string), czyli do łańcucha, zamienia co się da na postać tekstu.
  • to_a (ang. to array) zamienia do tablicy.
1.to_a
: #=> [1]

Powstała jednoelementowa tablica z liczbą 1.

Czy jednak da się np. łańcuch, czyli fragment tekstu zamienić na liczbę zmiennoprzecinkową?

"3.14 to jest liczba PI.".to_f
: #=> 3.14

Zadziałało nadspodziewanie dobrze, ale nie wymagajmy od tej metody zbyt wiele:

"Liczba PI to 3.14.".to_f
: #=> 0.0

Więc to_f po prostu bierze numeryczne znaki z lewej strony łańcucha i tworzy z nich liczbę zmiennoprzecinkową.

Odtwarzanie dźwięku

W Eltenie odtwarzanie dźwięku jest bajecznie proste, gdyż aby usłyszeć dźwięk, wystarczy wpisać w konsoli:

play('signal')

Nie u wszystkich to jednak zadziała, gdyż funkcja play korzysta z ustawień tematu dźwiękowego, więc jeśli ktoś ma to wyłączone w konfiguracji, nie usłyszy nic.

Trzeba skorzystać z nieznacznie bardziej skomplikowanego, ale dającego większe możliwości, obiektu Eltenowej klasy Sound.

so=Sound.new("c:\\windows\\media\\tada.wav")
so.play

W drugim odcinku mówiliśmy teoretycznie o klasach, z których można tworzyć obiekty, teraz czas na sprawdzenie jak to działa w praktyce.

Metoda new używana na klasach, powoduje utworzenie nowego obiektu danej klasy. W naszym przypadku nowy obiekt Eltenowej klasy Sound, został przypisany do zmiennej so.

W nawiasach okrągłych możemy (lub czasem nawet musimy) przesłać jakieś parametry do metody new, mające wpływ na nowo utworzony obiekt. W naszym przypadku, parametrem takim jest ścieżka i nazwa pliku, który utworzony obiekt so klasy Sound, będzie odtwarzał.

Wpisaliśmy ścieżkę do kultowego systemowego dźwięku tada, obecnego już w Windows 98, a może i wcześniej…

Jednak wygląda na to, że wkradł się tu błąd, te podwójne znaki backslash są chyba nie na miejscu.

Znak ucieczki

W Ruby i innych językach programowania, wartości tekstowe (łańcuchowe) umieszczaliśmy w cudzysłowiach.

puts("test")

A co, gdybyśmy chcieli umieścić w takim tekście także znak cudzysłowia? Skąd interpreter języka miałby wiedzieć, że jest to cudzysłów będący fragmentem tekstu, a nie znak końca tego tekstu?

Przyjęto, że wystarczy poprzedzić taki cudzysłów znakiem backslash i wpisać \", żeby było dobrze.

puts("test \"123\"")

Dostaliśmy oczekiwany rezultat test "123", ale jak w takim razie wpisać teraz znak backslash, skoro ma on takie specjalne znaczenie?

Podobnie jak z cudzysłowem, wystarczy znak backslash poprzedzić znakiem backslash i będzie dobrze.

puts("c:\\test")

Rzeczywiście w wyświetlonym tekście był tylko jeden znak \.

Skoro powstał już taki precedens dla cudzysłowia i backslasha, pomyślano, że warto byłoby rozszerzyć jego możliwości i tak, m.in.:

  • \t oznacza znak tabulacji,
  • \n znak nowej linii.

Cudzysłów czy apostrof, oto jest pytanie

W Rubym i nie tylko, teksty w programie można zamykać także w apostrofach, nie tylko w cudzysłowiach.

I łatwo zaobserwować pewną ciekawostkę:

puts("c:\notes")
c:
otes

puts('c:\notes')
c:\notes

W wersji z cudzysłowem, zostało zamienione na znak nowej linii i tak wyświetlone. W wersji z apostrofami, podobna zmiana nie nastąpiła.

puts('c:\windows')
c:\windows

puts('c:\\windows')
c:\windows

puts('It\'s a test.')
It's a test.

A zatem, jak widać, w wersji zapostrofem moc backslasha jest nieco ograniczona do znaku backslash i apostrof.

I w ten sposób znaleźliśmy kolejne możliwe wyjaśnienie, dlaczego znak ucieczki nazywa się znakiem ucieczki – eksperymentując z tym znaczkiem, mocno uciekliśmy od tematu tego odcinka.

Obiekt Sound

so=Sound.new("c:\\windows\\media\\tada.wav")
so.play

Wiemy co się tu działo, że mamy utworzony nowy obiekt klasy Sound i załadowany do niego dźwięk z pliku o podanej nazwie.

so.volume=0.6
so.play

Też słychać, ale dużo ciszej – zmodyfikowaliśmy głośność odtwarzania.

so.volume=2
so.play

Możemy też ustawić głośność większą od oryginalnej, i to dużo większą, przykładowe 2 to wcale nie jest możliwe maksimum. Wartością oryginalną jest 1.

so.pan=-1
so.play

Słyszymy skrajnie z lewej strony (można stosować wartości pośrednie, np. 0.2). Wartość 1 tego atrybutu ustawi balans skrajnie z prawej strony, a 0 – przywróci domyślny, na środku.

Pseudokod

Czasem dla opisu algorytmu, czyli sposobu działania jakiegoś programu lub funkcji, stosuje się zapis zwany pseudokodem.

Każdy język programowania musi być czytelny dla piszącego w nim człowieka, ale też dla czytającego i wykonującego dany program urządzenia.

Pseudokod musi być czytelny tylko dla człowieka, gdyż nie będzie nigdzie uruchamiany.

Mając już chyba wszystkie niezbędne elementy składowe, spróbujmy opisać w pseudokodzie, sposób działania programu naszej gry.

Wersja 1.

Zaczynamy od przepisania na pseudokod, pomysłu naszkicowanego na początku odcinka.

użytkownik się pomylił = false
while not użytkownik się pomylił
  odczekaj losowy czas
  wylosuj 1 z 3 kierunków
  Odtwórz dźwięk w wylosowanym kierunku
  Czekaj na naciśnięcie klawisza przez użytkownika
  if naciśnięto strzałkę niezgodną z wylosowanym kierunkiem dźwięku
    użytkownik się pomylił = true
  else
    prawidłowa odpowiedź
  end
  end # pętli while
podaj średni czas reakcji w milisekundach

Wersja 2.

Dopiero pisząc ostatnią linijkę zdaliśmy sobie sprawę, że skoro mamy podać średni czas reakcji, musimy go najpierw zmierzyć, a więc kod trzeba uzupełnić.

...
  wylosuj 1 z 3 kierunków
  Rozpocznij mierzenie czasu reakcji
  Odtwórz dźwięk w wylosowanym kierunku
  Czekaj na naciśnięcie klawisza przez użytkownika
  Zakończ mierzenie czasu reakcji
...

Wersja 3.

Nasz program ma poważną wadę: jedynym sposobem wyjścia z gry jest udzielenie nieprawidłowej odpowiedzi.

A co w sytuacji, gdy użytkownik zechce z gry wyjść teraz, natychmiast?

W trakcie oczekiwania wylosowany czas, oraz oczekiwania na naciśnięcie strzałki, musimy przetwarzać również sytuację naciśnięcia klawisza escape.

Koniec gry nastąpić może po udzieleniu błędnej odpowiedzi, ale też po naciśnięciu klawisza escape.

Najprościej obsłużyć to zmienną logiczną koniec gry.

Początkowo ustawimy ją na wartość false, pętlę while zmienimy na

while not koniec gry

i w różnych miejscach będziemy tę zmienną ustawiać.

...
  odczekaj wylosowany czas
  if escape
    koniec gry = true 
  else #Etap odtwarzania
    wylosuj kierunek
...

Wersja 7.

Fajnie byłoby, gdyby prócz dźwięku komputera, odtwarzanego z losowego kierunku, odtwarzane były też jakieś dźwięki prawidłowej i nieprawidłowej odpowiedzi. Dodajemy stosowne instrukcje w odpowiednich miejscach.

Wersja 11.

W każdej rundzie mierzymy czas reakcji użytkownika, ale jak wyliczyć jego średnią?

Najprościej prócz czasu zmierzonego w danej rundzie, przechowywać w jakiejś zmiennej, sumę czasu zmierzonego we wszystkich rundach.

Finalnie, dla wyliczenia średniej, wystarczy podzielić sumę zmierzonego czasu przez liczbę rund.

Wersja 12.

Dotychczas zakładaliśmy, że policzymy średnią ze wszystkich rund, czyli tych, w których użytkownik udzielił prawidłowej odpowiedzi, ale też i ostatniej, w której się pomylił.

Pomysł nie jest zły, ale odkrywamy, że ma poważny mankament: użytkownik celowo może chcieć udzielić nieprawidłowej odpowiedzi, ale możliwie jak najszybciej, aby w ten sposób zmniejszyć wyliczoną średnią czasu reakcji. Musimy przeciwdziałać takiemu nadużyciu, a najprostszym rozwiązaniem jest mierzenie czasu reakcji tylko w rundach z prawidłowymi odpowiedziami.

Wersja 14.

Byłoby miło, gdyby na początku rundy, program informował użytkownika, którą rundę zaczynamy.

Wprowadzimy do tego celu zmienną liczba rund, którą będziemy zwiększać na początku każdej rundy, a przed pętlą while, ustawimy jej wartość na 0..

Wersja 17.

Po wielu poprawkach podobnych do powyższych, uzyskujemy wreszcie projekt, który wydaje się sensowny i warty przepisania na Ruby.

koniec gry = false
liczba rund = 0
liczba prawidłowych odpowiedzi = 0
suma zmierzonego czasu = 0
while not koniec gry
  zwiększ liczbę rund
  Wylosuj czas oczekiwania
  odczekaj wylosowany czas
  if escape
    koniec gry = true 
  else #Etap odtwarzania
    wylosuj kierunek
    Zacznij odtwarzać dźwięk komputera w wylosowanym kierunku
    Rozpocznij mierzenie czasu reakcji
    czekaj na naciśnięcie strzałki lub escape
    zakończ mierzenie czasu reakcji
    zatrzymaj odtwarzany dźwięk
    if escape
      koniec gry = true
    elsif naciśnięta strzałka zgodna z kierunkiem dźwięku
      dodaj zmierzony czas do sumy mierzonego czasu
      odtwórz dźwięk prawidłowej odpowiedzi
      zwiększ o 1 liczbę prawidłowych odpowiedzi
    else #nieprawidłowa odpowiedź:
      koniec gry = true
      odtwórz dźwięk nieprawidłowej odpowiedzi
    end # nieprawidłowa odpowiedź
  end #etap odtwarzania
end # pętla while
średni czas reakcji = suma zmierzonego czasu / liczba prawidłowych odpowiedzi
podaj średni czas reakcji w milisekundach

Program napisany jest trochę po polsku, trochę w Ruby, (tylko w pseudokodzie nazwy zmiennych mogą zawierać spacje) i wiadomo, że nie uruchomi się w konsoli. Wiadomo też jednak, jak powinien zadziałać.

W trakcie pisania takiego pseudokodu można wykryć i usunąć jakieś błędy w logice, których wcześniej nie zauważylibyśmy na etapie pomysłu.

Można też zadać sobie pytania, których nie zadaliśmy sobie wcześniej.

W jakim przedziale losować moment w czasie?

Wydaje się, że 2 sekundy między rundami, to sensowna minimalna przerwa. Co z maksimum?

Może 5 będzie w sam raz? Zresztą te wartości będzie można poprawiać w praktyce, ale pojawia się pytanie, jak losować czas od 2 do 5 sekund i to niekoniecznie co sekundę, bo to byłoby zbyt proste?

Wartość minimalna jest stała, będą to przykładowe 2 sekundy.

Obszarem, w którym ma nastąpić wylosowany moment, jest czas od 2 do 5 sekundy, czyli odcinek czasu o długości 3 sekund.

Losowanie funkcją rand(4), które zwróciłoby liczbę całkowitą z zakresu od 0 do 3, wydaje się zbyt prymitywnym rozwiązaniem.

Pomysł, aby umownie podzielić ten czas na 300 setnych sekundy, wylosować liczbę z zakresu od 0 do 300 i o tyle setnych sekundy wydłużyć oczekiwanie, ma sens, ale można prościej.

Funkcja rand może być wywołana bez parametrów i wtedy zwraca liczbę rzeczywistą (zmiennoprzecinkową) z zakresu od 0 do 1.

Jak zamienić taką losową liczbę między 0 i 1 na liczbę między 0 i 3?

Wystarczy pomnożyć tę wylosowaną razy 3.

I wówczas, przykładowo, 0 * 3 =0, a 0.999 * 3 = 2.997.

Dotarliśmy do punktu, gdy wszystkie elementy układanki wystarczy poskładać w całość. Co ambitniejsi czytelnicy tego bloga mogą na własną rękę dokonać tego dzieła, a wspólnie zajmiemy się tym w moim następnym wpisie.

2 komentarze

  1. A to ciekawe, myślałem, że to będzie bardziej dynamiczne i interesujące niż jakieś inne przykłady, ale dla każdego coś ciekawego, Dawid w swoich wpisach pokazuje inne zagadnienia bardziej związane z klasycznymi aplikacjami…

  2. Jakby tu napisać delikatnie.
    Zawsze w nauce programowania zniechęcał mnie etap, gdy przykładem aplikacji miała być gra.
    Tak czy inaczej, dzięki, za to co robicie.

Dodaj komentarz

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