14. Komputerowy showdown, czyli zaczynamy kolejną grę

Nasza poprzednia gra, pozwalająca zmierzyć czas reakcji użytkownika na odtwarzanie dźwięku, była dość statyczna. Czas pójść krok dalej i stworzyć coś nieco bardziej dynamicznego.

Zajmiemy się napisaniem komputerowej symulacji showdown, gry, w której zawodnicy odbijają paletkami brzęczącą piłeczkę, toczącą się po stole.

W prawdziwym Showdown, stół ma kształt prostokąta z bardzo mocno zaokrąglonymi narożnikami, na jego końcach znajdują się niewielkie bramki, w które może wpaść piłka. Krawędzie stołu są obudowane ogranicznikiem, utrudniającym wypadnięcie piłki poza stół.

W naszej wersji, stół będzie prostokątny, a piłka będzie mogła spaść z niego wzdłuż całych krótszych krawędzi. Obudowa przy dłuższych krawędziach stołu pozostaje, więc piłka nigdy nie wypadnie na aut. Gracz przesuwa się wzdłuż swojej krótszej krawędzi stołu i w każdym punkcie może obronić tylko jej fragment. Słuchając przemieszczającej się piłki, musi przewidzieć miejsce wypadnięcia piłki ze stołu i znaleźć się tam odpowiednio wcześniej, aby skutecznie odbić piłkę w stronę przeciwnika.

Przeciwnikiem dla gracza, będzie komputer.

Co ciekawe, posiadamy już praktycznie całą wiedzę, niezbędną do zrealizowania tego pomysłu.

Płynne przemieszczanie dźwięku

W naszej poprzedniej grze, dźwięk był emitowany skrajnie w lewym kanale, prawym lub w obu. W jaki sposób płynnie przemieszczać dźwięk, aby symulować piłkę toczącą się po stole?

Wiemy, że atrybut pan obiektu Sound, możemy ustawiać płynnie w przedziale od -1 (skrajnie lewy kanał) do 1 (skrajnie prawy). Zmienianie go o pewną niewielką wartość w krótkich odcinkach czasu, powinno rozwiązać problem.

so=Sound.new('c:\windows\media\ding.wav', 1, true)
so.pan=-1
so.play
for i in -10..10
so.pan=i/10.0
delay(0.3)
end
so.stop

Rozszerzone wywołanie Sound.new z ustawieniem trzeciego parametru “looper” na true spowodowało, że dźwięk jest odtwarzany w zapętleniu. Dlatego też, na końcu programu, wywołujemy sound.stop, aby zakończyć jego odtwarzanie.

Własne dźwięki

Wcześniej czy później, byłoby to nieuniknione, tak jak w poprzedniej grze, aby przygotować własny zestaw dźwięków.

Mając możliwość prototypowania aplikacji przy użyciu app dev sandbox, omówionego w odc. 12, nie ma sensu z tym nadmiernie zwlekać.

Potrzebne są nam następujące dźwięki:

  • Piłka tocząca się po stole (ball.ogg). Dźwięk musi być możliwie dobrze zapętlony.
  • Piłka odbijająca się od bocznej zabudowy stołu (ball_edge.ogg).
  • Piłka spadająca ze stołu (ball_fall.ogg).
  • Uderzenie paletką w piłkę (ball_shot.ogg)
  • Przesuwanie się gracza (move.ogg).
  • Gracz dociera do narożnika (move_edge.ogg).

Gotowych dźwięków na odpowiednich licencjach można poszukać w sieci, można też spróbować nagrać i przyciąć własne… Na początek posłużymy się dźwiękami zsyntetyzowanymi na szybko w Goldwave.

Gotowy zestaw do pobrania w pliku showdown1.

Uruchamiając showdown.rb na powyższych wartościach, ale z plikiem ball.ogg, słyszymy, że zmiana balansu jest wyraźnie skokowa.

Po skróceniu delay do 0.03 sekundy i zwiększeniu ilości skoków od -100..100, wszystko działa już płynnie.

Zmiana głośności

Spróbujmy równocześnie zmieniać głośność dźwięku. Jak zapewne pamiętamy, atrybut volume obiektu Sound możemy zmieniać od 0 do 1 (i dużo wyżej, ale zostańmy przy maksymalnej wartości 1).

Jeśli zmienna sterująca naszej pętli i przyjmuje wartości od -100 do 100, a chcielibyśmy, aby głośność dźwięku najpierw płynnie narastała, a później płynnie opadała, możemy tego dokonać następująco.

  • Obliczyć wartość absolutną zmiennej i (dla -100 to będzie 100, a dla -1 to będzie 1), następnie dla 1=1, dla 100=100).
  • Odejmiemy od 1 tę wartość podzieloną przez 100, więc przy 100 i -100 będziemy mieć 0, a przy 1 i -1 będziemy mieć 1.

Po zaimplementowaniu tego algorytmu, nasza metoda main wygląda następująco:

  def main
    so = Sound.new(appfile("ball.ogg"), 1, true)
    so.pan = -1
    so.volume = 0
    so.play
    for i in -100..100
      so.pan = i / 100.0
      so.volume = 1 - (i.abs / 100.0)
      delay(0.03)
    end
    so.close
  end #main

Dlaczego 100.0?

Zapis 100.0 przy dzieleniu może się wydawać nieco dziwny, czy nie wystarczyłoby wpisanie 100? A jednak, jak łatwo sprawdzić w konsoli, nie:

1/100
: #=> 0
1/100.0
: #=> 0.01

Taki rezultat występuje nie tylko w Ruby, również w niektórych innych językach programowania. Jeśli dzielną i dzielnikiem są liczby całkowite, wówczas następuje dzielenie z resztą i wynik zwracany jest jako zmienna całkowita.

Jeśli dzielna lub dzielnik to liczba rzeczywista (zmiennoprzecinkowa), wówczas wynik jest zwracany jako liczba zmiennoprzecinkowa. Wpisując 100.0 gwarantujemy, że dzielnik jest liczbą zmiennoprzecinkową, a więc także i wynik taki będzie.

Gracz w centrum wszechświata

Postacią centralną gry, jest oczywiście sam gracz. Jeżeli piłka znajduje się na środku stołu i gracz znajduje się na środku swojej krawędzi, wówczas piłkę ma dokładnie przed sobą, będzie ją słyszał na wprost (pan=0).

Jeżeli natomiast gracz jest po prawej stronie swojej krawędzi stołu, a piłka znajduje się przy jego lewym narożniku, to wówczas będzie ją słyszał skrajnie z lewej strony (pan=-1).

Różnice w osi X (lewo / prawo) odzwierciedla balans, natomiast różnice w osi Y (bliżej / dalej) będzie odzwierciedlać głośność.

Umowne jednostki miary

Chcąc symulować jakąś przestrzeń, musimy przyjąć nie tylko układ odniesienia, ale też jednostkę miary. Mogłyby to być metry, stopy lub cale, albo nawet jednostki nieokreślone.

Jeżeli wyobrazimy sobie, że pole, którego może w danej chwili bronić gracz, ma rozmiar R, to nie jest tak bardzo istotne, czy to będzie 10, 20 czy 30 centymetrów. Istotne jest ile takich pól mieści się wzdłuż krawędzi stołu, przyjmijmy, że będzie to 7. Wartość dobra na początek, a w przyszłości można ją będzie zmienić, edytując jedną linię w programie.

Przypuśćmy, że rozmiar R, to 15 centymetrów, czyli możemy sobie wyobrazić, że krótsza krawędź stołu ma wymiar 105 cm.

Załóżmy następnie, że stół ma długość 2-krotnie większą od szerokości, a zatem jeśli szerokość stołu to było 7 umownych jednostek, to jego długość będzie wynosić 14 jednostek. W prawdziwym Showdown wymiary stołu to 122 cm szerokości i 366 cm długości, ale w naszej symulacji tak duże proporcje prawdopodobnie okażą się niepraktyczne.

class Grzezlo_Eltendev_showdown < Program_AppDevSandbox
  TABLE_WIDTH = 7
  TABLE_LENGTH = 14

Układ odniesienia

Skoro przyjęliśmy wcześniej, że x jest osią lewo / prawo,a y osią przód / tył, załóżmy następnie, że punkt x=1, y=1, jest lewym narożnikiem stołu po stronie użytkownika. Punkt x = TABLE_WIDTH, y=1, to prawy narożnik po stronie użytkownika. Punkt x = 1, y = TABLE_LENGTH to narożnik na krawędzi przeciwnika, po lewej stronie z punktu widzenia użytkownika, a po prawej z punktu widzenia przeciwnika. Punkt x = TABLE_WIDTH, y = TABLE_LENGTH to drugi narożnik na krawędzi przeciwnika.

Współrzędne x < 1, x >TABLE_WIDTH, y < 1, y > TABLE_LENGTH znajdują się poza krawędziami stołu, czyli poza polem gry.

Obiekty i zdarzenia

Obiektami w grze są: użytkownik, przeciwnik, piłka.

Wszystkie te obiekty są w każdej chwili gry konkretnie umiejscowione w przestrzeni (mają współrzędne x i y).

Obiektem nadrzędnym jest stół, na którym toczy się rozgrywka. Nie ma on swoich współrzędnych, gdyż sam określa przestrzeń gry, dlatego musi mieć szerokość (width) i długość (length). Różne obiekty gry będą potrzebowały w działaniu informacji o rozmiarach stołu.

Możemy zdefiniować klasę stołu gry (GameTable) i obiektu gry (GameObject).

  class GameTable
    attr_reader :width, :length

    def initialize(width, length)
      @width = width
      @length = length
    end #initialize
  end #GameTable
  
    class GameObject
    attr_reader :x, :y

    def initialize(table, x = 0, y = 0)
      @table = table
      set_position(x,y)
    end

    def set_position(x, y)
      @x = x
      @y = y
    end
  end #class GameObject

Klasa obiekt gry (GameObject) posiada atrybuty x i y, przy inicjalizacji musi dostać jako parametr, utworzony wcześniej obiekt stołu. Może też opcjonalnie dostać początkowe współrzędne tworzonego obiektu. Metoda set_position pozwala ustawiać atrybuty x i y. Instrukcją attr_reader są one udostępniane do odczytu we wszystkich obiektach klasy GameObject.

Piłka w grze jest najbardziej dynamicznym, przemieszczającym się obiektem. Dziedziczy z GameObject.

  class GameBall < GameObject
  ...

Gracze (użytkownik i komputer) mają ze sobą sporo wspólnych atrybutów i akcji, chociaż także w wielu się różnią. Napiszemy więc ogólną klasę Player, z której będziemy dziedziczyć wspólne atrybuty i akcje w klasie PlayerUser i PlayerComputer. Sama klasa GamePlayer będzie natomiast dziedziczyć z GameObject.

  class GamePlayer < GameObject
...
  class GamePlayerUser < GamePlayer
  ...
  class GamePlayerComputer < GamePlayer
...

Obiekty dźwięków gry

Wszystkie dźwięki w grze są ściśle powiązane z emitującymi je obiektami (właścicielami).

  • Gracze: dźwięk po przemieszczeniu się wzdłuż swojej krawędzi (move.ogg), po dotarciu do narożnika, gdy dalej iść się nie da (move_edge.ogg).
  • Piłka: dźwięk przez cały czas w trakcie ruchu (ball.ogg), po uderzeniu paletką (ball_shot.ogg), po odbiciu się od bocznej krawędzi stołu (ball_edge.ogg), oraz po wypadnięciu na którąś ze stron (ball_fall.ogg).

Ponadto, wszystkie dźwięki muszą być pozycjonowane względem pozycji użytkownika.

Są to dwa dobre powody, aby napisać klasę dźwięków gry, podobnie jak poprzednim razem, ale w tym przypadku zagnieżdżoną wewnątrz klasy naszego programu.

Każdy dźwięk przy inicjalizacji będzie powiązany z konkretnym właścicielem (którymś graczem lub piłką). Właściciel (ang. owner) udostępnia atrybuty x i y, określające jego położenie w przestrzeni.

Każdy dźwięk musi “znać” położenie użytkownika (słuchacza). Znając położenie właściciela czyli obiektu emitującego dany dźwięk, oraz położenie słuchacza (ang. listener), możemy ustawić balans i głośność dźwięku. Dla ścisłości, potrzebna jest jeszcze do tego wiedza o rozmiarach przestrzeni, w której operujemy.

Klasa GameSound musi posiadać statyczną metodę set_listener, pozwalającą na jej powiązanie z obiektem użytkownika.

Statyczna metoda set_space_size umożliwi określenie przestrzeni dźwięku. Nie przekazujemy do klasy GameSound obiektu GameTable, gdyż możemy chcieć w przyszłości używać tej klasy w innej grze audio.

I jak w poprzedniej grze, set_samples_dir umożliwi ustalenie folderu z samplami.

  class GameSound
    def self.set_listener(listener)
      @@listener = listener
    end

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

    def self.set_space_size(width, length)
      @@space_width = width
      @@space_length = length
    end

Do inicjalizacji obiekt GameSound potrzebuje bazowej nazwy pliku, obiektu właściciela, oraz informacji, czy jego odtwarzanie ma być zapętlone (piłka), domyślnie false.

    def initialize(file_name, owner, loop=false)
      @so = Sound.new(@@samples_dir + file_name + ".ogg", 1, loop)
      @owner = owner
    end # initialize

Metody play i stop przepisujemy z poprzedniej gry:

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

    def play
      stop
      @so.position = 0
      @so.play
    end

Obliczanie położenia dźwięku

Najciekawszą metodą, jest update_position, która na podstawie informacji o położeniu właściciela obiektu i słuchacza, ustawi dla dźwięku atrybuty pan i volume.

Balans

Jeżeli słuchacz jest w lewym narożniku (listener.x=1) a piłka w prawym (owner.x=7), to powinien ją słyszeć skrajnie w prawym kanale (pan=1). I odwrotnie: gdy owner.x=1, a listener.x=7, to pan powinno być =-1.

Obliczymy najpierw różnicę w osi x x_diff (diff,to skrót od difference, czyli różnica).

x_diff = @owner.x - @@listener.x

Gdy owner.x=1 a listener.x=7 da to -6.

I odwrotnie: gdy owner.x=7 a listener.x=1, otrzymamy 6.

Wystarczy teraz podzielić otrzymaną wartość przez space_width, aby uzyskać wartość atrybutu pan.

Ponieważ 6/7 to nie będzie 1, można podzielić przez space_width-1…

    def update_position
      x_diff = @owner.x - @@listener.x
      x_diff = x_diff / (@@space_width - 1).to_f
      @so.pan = x_diff
...

Jak to Sprawdzić?

Pracując w konsoli, mieliśmy do dyspozycji funkcję puts, która wypisywała dowolnie złożony tekst w polu na dane wyjściowe. Teraz, działając w Eltenowym programie, takiej możliwości nie mamy. Skorzystanie z funkcji alert jest dość mało wygodne, gdyż wypowiedź może być przerwana przez inny komunikat, może też zostać niedokładnie przeczytana (przy wyłączonej interpunkcji) i nie ma prostej metody prześledzenia jej znak po znaku.

W Ruby jest wbudowana procedura p wyświetlająca jej parametr jako tekst (w Eltenie pojawi się on w oknie dialogowym typu systemowy Messagebox).

Wystarczy więc na końcu dotychczasowej implementacji metody update_position dopisać:

  p x_diff

Teraz w metodzie main musimy utworzyć wstępne obiekty i doprowadzić do wywołania update_position na jakimś dźwięku.

Jedynym dotychczas zaimplementowanym obiektem udostępniającym atrybuty x i y jest GameObject, więc takie dwa obiekty utworzymy w roli listener i owner.

  def main
    GameSound.set_samples_dir(appfile)
    table = GameTable.new(TABLE_WIDTH, TABLE_LENGTH)
    GameSound.set_space_size(table.width, table.length)
    listener = GameObject.new(table, 7, 1)
        owner = GameObject.new(table, 1, 1)
GameSound.set_listener(listener)
    ball_sound = GameSound.new("ball", owner, true)
    ball_sound.update_position
    ball_sound.play
    delay(2)
    ball_sound.stop
  end

Okienko dialogowe z wartością -1.0, oraz dźwięk słyszany skrajnie w lewym kanale, pokazują, że wszystko przebiegło zgodnie z planem.

Zamieniamy pozycje listener i owner, aby sprawdzić, czy analogicznie zadziała w drugą stronę:

    listener = GameObject.new(table, 1, 1)
        owner = GameObject.new(table, 7, 1)

I okazuje się, że tak.

Głośność

Poziom głośności będzie zależeć od różnicy współrzędnych y słuchacza i właściciela dźwięku. Im dalej, tym mniejszy, ale nie mniejszy niż 0. Minimalna wartość 0 oznaczałaby, że aktywność po drugiej stronie stołu byłaby niesłyszalna, więc trzeba założyć jakąś minimalną głośność, poniżej której nie schodzimy:

  class GameSound
    MIN_VOLUME = 0.1
  1. Obliczymy najpierw y_diff jako wartość absolutną różnicy w osi y.
  2. Następnie, podzielimy y_diff przez space_length-1, uzyskując wartość 0 (blisko) do 1 (daleko).
  3. Następnie przemnożymy uzyskaną wartość przez zakres dozwolonej zmiany głośności, czyli przez (1 - MIN_VOLUME), uzyskamy wartość od 0 (najbliżej) do 0.9 (najdalej).
  4. Wreszcie tę wartość odejmiemy od 1, otrzymując wartość atrybutu głośności dla dźwięku: od 1 (najbliżej) do 0.1 (najdalej).
  5. Na koniec ustawimy obliczoną wartość jako atrybut volume dźwięku.
  6. I wyświetlimy obliczoną wartość funkcją p.
      y_diff = (@@listener.y - @owner.y).abs
      y_diff = y_diff / (@@space_length - 1).to_f
      y_diff = (1 - MIN_VOLUME) * y_diff
      y_diff = 1 - y_diff
      @so.volume = y_diff
      p y_diff

Sprawdzamy głośność

Zmieniamy położenie obiektów owner i listener w funkcji main, tym razem tworząc różnicę w osi y, ustawiając owner na przeciwnym końcu stołu niż gracz (słuchacz):

    listener = GameObject.new(table, 1, 1)
    owner = GameObject.new(table, 1, table.length)

Sprawdzamy jeszcze umieszczając owner po przekątnej stołu, czy zmieni się jednocześnie głośność i balans:

    owner = GameObject.new(table, table.width, table.length)

I nie zapomnijmy usunąć z kodu wywołanie procedury p.

Optymalizacje, czyli szukamy oszczędności.

Optymalizacje algorytmu to działania, które mają spowodować, że będzie bardziej oszczędny: skonsumuje mniej pamięci, lub jego wykonanie zajmie mniej czasu.

Warto też optymalizować w odniesieniu do zasobów programisty: jeśli można zaoszczędzić trochę czasu na wpisywaniu jakichś linii, albo trochę pamięci człowieka, że coś musi koniecznie wpisać, to warto to zrobić.

Set_space_size.

Dlaczego wywołujemy osobno GameSound.set_space_size, skoro moglibyśmy to robić w konstruktorze obiektu GameTable?

    def initialize(width, length)
      @width = width
      @length = length
      GameSound.set_space_size(width, length)
    end #initialize

update_position

Konieczność wywoływania update_position na każdym dźwięku, nawet tuż po jego utworzeniu, aby zsynchronizować jego atrybuty z położeniem właściciela, jest na pewno zbędną uciążliwością.

Gdybyśmy wywoływali update_position w metodzie initialize klasy GameSound, rozwiązalibyśmy problem konieczności wywoływania jej ręcznie przy tworzeniu obiektu.

Ale wszystkie obiekty w grze będą się przemieszczać, łatwo zapomnieć o ręcznym wywoływaniu update_position zawsze, gdy to się dzieje. Najlepiej byłoby, gdyby każdy obiekt klasy GameObject posiadał listę utworzonych przez siebie dźwięków i automatycznie aktualizował ich położenie po wywołaniu set_position danego obiektu gry.

Pustą listę dźwięków @sounds utworzymy w metodzie initialize. Metoda new_sound w klasie GameObject pozwoli utworzyć dźwięk z automatycznym wskazaniem jego odpowiedniego właściciela, oraz dopisze ten dźwięk do tablicy @sounds.

Metoda update_sounds wykona update_position na wszystkich dźwiękach z tablicy @sounds.

Wreszcie na końcu metody set_position wywołamy update_sounds.

Tablica @sounds będzie tablicą haszową, którą dopiszemy do listy attr_reader. Dzięki temu po utworzeniu dźwięku, program będzie miał do niego łatwy dostęp przez nazwę, bez konieczności przechowywania odniesień do dźwięku w innych zmiennych. Gdyby jednak z jakiegoś powodu przechowywanie odniesienia do tworzonego dźwięku było wygodniejsze w osobnej zmiennej, funkcja new_sound zwróci takie odniesienie do opcjonalnego wykorzystania.

  class GameObject
    attr_reader :x, :y, :sounds

    def initialize(table, x = 0, y = 0)
      @table = table
      @sounds = {}
      set_position(x, y)
    end

    def new_sound(file_name, loop = false)
      so = GameSound.new(file_name, self, loop)
      so.update_position
      @sounds[file_name] = so
      return so
    end #new_sound

    def update_sounds
      for name, obj in @sounds
        obj.update_position
      end
    end #update_sounds

    def set_position(x, y)
      @x = x
      @y = y
      update_sounds
    end
  end #class GameObject

Testujemy

Dla przetestowania działania, zmodyfikujemy wcześniejszą metodę main naszego programu. Utworzony obiekt owner powiążemy z dźwiękiem toczącej się piłki, przypiszemy do niego także dźwięk move.ogg. Następnie w pętli będziemy zmieniać położenie obiektu owner i sprawdzimy, czy zmienia się położenie emitowanych dźwięków.

  def main
    GameSound.set_samples_dir(appfile)
    table = GameTable.new(TABLE_WIDTH, TABLE_LENGTH)
    listener = GameObject.new(table, 1, 1)
    owner = GameObject.new(table, table.width, table.length)
    GameSound.set_listener(listener)
    owner.new_sound("ball", true)
    owner.sounds["ball"].play
    move_sound = owner.new_sound("move")
    table.width.downto(1) { |i|
      move_sound.play
      owner.set_position(i, table.length)
      delay(0.5)
    }
    owner.sounds["ball"].stop
  end #main

Podsumowanie

Utworzyliśmy podstawową definicję obiektu stołu gry, oraz klasę obiektów gry z możliwością łatwego powiązywania ich z emitowanymi dźwiękami.

Ciąg dalszy już wkrótce, a tymczasem aktualną wersję showdown.rb można pobrać w pliku showdown2.zip.

5 komentarzy

  1. No nie bardzo, bo szerokość masz względem pozycji użytkownika, jak to zostało powiedziane.
    Teoretycznie stół mógłby mieć 80 pozycji, a pole uwagi użytkownika np. 20, no i w danej pozycji x obejmowałby słuchem x-10 do x+10.

  2. Czy gdybym chciał obliczyć tak zwany pan_step, czyli o ile ma się przesunąć dźwięk na każdą jednostkę wystarczyłoby zrobić tylko 100.0 / width?

Dodaj komentarz

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