15. Showdown 2. Jak bije serce gry?

W moim poprzednim wpisie rozpoczęliśmy prace nad komputerową wersją gry Showdown. Doprowadziliśmy do sytuacji, że przemieszczanie obiektów klasy GameObject prawidłowo i automatycznie aktualizuje parametry powiązanych z nimi dźwięków.

Ale te atrybuty (balans i głośność) nie zależą jedynie od właściciela dźwięku, gdyż również od położenia słuchacza.

Jeśli zmieni się położenie słuchacza, żaden dźwięk nie zaktualizuje automatycznie swoich parametró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
    delay 1
    listener.set_position(table.width, 1)
    delay 1
    owner.sounds["ball"].stop
  end #main

Po sekundzie odtwarzania dźwięku, słuchacz przeskakuje z lewego do prawego narożnika swojej krawędzi stołu, ale dźwięk piłki słyszany z prawej strony, się nie zmienia.

Lista obiektów gry

Każdy obiekt gry posiada automatycznie aktualizowaną listę wszystkich swoich dźwięków.

Idąc tym torem, utworzymy listę wszystkich obiektów klasy GameObject uzupełnianą w momencie powstawania nowego obiektu tej klasy.

Skoro każdy obiekt klasy GameObject posiada metodę update_sounds, utworzymy statyczną metodę update_all_objects_sounds, która wywoła update_sounds na wszystkich obiektach klasy GameObject:

  class GameObject
    attr_reader :x, :y, :sounds
    @@objects = []

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

    def self.update_all_objects_sounds
      for obj in @@objects
        obj.update_sounds
      end
    end
    ...
        listener.set_position(table.width, 1)
    GameObject.update_all_objects_sounds
...

I jak widać, pomogło. Ponowne uruchomienie programu prawidłowo odzwierciedla przeskok słuchacza.

Dobrze byłoby jeszcze pozbyć się konieczności pamiętania o wywoływaniu update_all_objects_sounds po zmianie pozycji słuchacza.

W tym celu w metodzie set_position klasy GameObject sprawdzimy, czy uaktualniana jest pozycja słuchacza. Jeśli tak, wywołamy GameObject.update_all_objects_sounds, a w przeciwnym wypadku, uaktualnimy tylko dźwięki bieżącego obiektu.

Żeby takie sprawdzenie było możliwe, musimy udostępnić w klasie GameSound statyczną metodę listener, zwracającą obiekt ustawiony wcześniej metodą set_listener:

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

    def self.listener
      return @@listener
    end
...
  end #class GameSound

...
  class GameObject
...
    def set_position(x, y)
      @x = x
      @y = y
      if self == GameSound.listener
        GameObject.update_all_objects_sounds
      else
        update_sounds
      end
    end #set_position

Usuwamy wywołanie update_all_objects_sounds z funkcji main i uruchamiamy program ponownie:

...
    owner.sounds["ball"].play
    delay 1
    listener.set_position(table.width, 1)
    delay 1
    owner.sounds["ball"].stop
    ...

Kiedy akcja?

Zdefiniowaliśmy pewne obiekty, ich atrybuty i metody, ale istotą projektowanej przez nas gry jest aktywność, której tu na razie nie widać: piłka powinna się toczyć po stole, komputerowy gracz powinien przemieszczać się po swojej stronie stołu, a użytkownik po swojej w odpowiedzi na naciskane klawisze strzałek.

Wszystko to powinno się odbywać płynnie i bez opóźnień.

Jak tego dokonać?

Odpowiedzią jest metoda work (ang. praca), którą zdefiniujemy pierwotnie jako pustą akcję w klasie GameObject, a inne bardziej wyspecjalizowane klasy dziedziczące z GameObject będą ją mogły nadpisywać. To właśnie w tej metodzie będzie wykonywana cała praca obiektu.

Podobnie jak z metodą statyczną update_all_objects_sounds zdefiniujemy metodę work_all_objects, która wywoła metodę work dla wszystkich obiektów gry.

Wreszcie w klasie naszego programu zdefiniujemy metodę game_sleep, znaną z poprzedniej gry, która będzie w pętli wywoływać GameObject.work_all_objects oraz Eltenową loop_update.

  class GameObject
...
    def work
    end

    def self.work_all_objects
      for obj in @@objects
        obj.work
      end
    end
...
  end #class GameObject

  def game_sleep(wait_time)
    wait_time = Time.now.to_f + wait_time
    while Time.now.to_f < wait_time
      loop_update
      GameObject.work_all_objects
    end
  end

Flaga końca gry

W naszej poprzedniej grze, musieliśmy dość skrupulatnie sprawdzać wynik funkcji game_sleep, który, jeśli był true, oznaczał naciśnięcie przez użytkownika klawisza Escape.

W obecnej obiektowej konstrukcji, można sobie wyobrazić kilka lepszych sposobów rozwiązania tego problemu, może np. kolejną statyczną metodę klasy GameObject:

  class GameObject
...
    @@end_game = false
...
    def self.end_game=(v)
      @@end_game = v
    end

    def self.end_game?
      return @@end_game
    end

...
  end #class GameObject
  
  def game_sleep(wait_time)
    wait_time = Time.now.to_f + wait_time
    while not GameObject.end_game? and Time.now.to_f < wait_time
      loop_update
      GameObject.work_all_objects
      GameObject.end_game = true if escape
    end
  end

Definicja def x=(y) pozwala w Ruby zdefiniować metodę, która na zewnątrz będzie widoczna jako atrybut, którego wartość można ustawiać na wartość y. Ta wartość y zostanie w rzeczywistości przekazana do zdefiniowanej metody x=.

Zapis def x? to spotykany w konwencji nazywniczej Ruby wariant, w którym pytamy o wartość jakiegoś atrybutu. W innych językach zamiast niedozwolonego w nazwie znaku zapytania stosuje się nazywnicze sztuczki np. is_end_game.

Każdy obiekt gry, albo program główny, może ustawić statyczną metodę obiektu GameObject na true, powodując podniesienie flagi końca gry. Tu chwilowo ustawiamy w game_sleep, gdy naciśnięto escape, ale oczywiście nie tak to powinno wyglądać. Obsługa klawisza escape powinna mieć miejsce w metodzie work obiektu użytkownika.

Obiekt użytkownika

Jak to zostało powiedziane na etapie projektowania, klasa graczy GamePlayer będzie dziedziczyć z GameObject, a GamePlayerUser (gracz użytkownik) będzie dziedziczyć z GamePlayer. W metodzie work klasy GamePlayerUser obsłużymy ustawienie flagi end_game, gdy naciśnięto escape, czyli odpowiednią linię przeniesiemy tam z game_sleep.

  class GamePlayer < GameObject
  end #class GamePlayer

  class GamePlayerUser < GamePlayer
    def work
      if escape
        GameObject.end_game = true
      end
    end #work
  end #class GamePlayerUser

  def game_sleep(wait_time)
    wait_time = Time.now.to_f + wait_time
    while not GameObject.end_game? and Time.now.to_f < wait_time
      loop_update
      GameObject.work_all_objects
    end
  end

Jak łatwo stwierdzić, program prawidłowo reaguje na klawisz escape.

Ruch gracza

Obaj gracze (użytkownik i komputer) mogą przemieszczać się wzdłuż swojej krawędzi stołu, czyli tylko w osi x.

W klasie GamePlayer możemy zdefiniować metodę move, przyjmującą jeden parametr: 1 lub -1 odpowiednio zwiększający lub zmniejszający atrybut x pozycji gracza.

W metodzie move sprawdzimy także, czy gracz nie wychodzi poza obszar gry.

W metodzie work użytkownika dodamy wywołanie move po naciśnięciu strzałki w lewo i prawo, oraz w main wydłużymy game_sleep do 60 sekund, aby mieć czas na sprawdzenie, czy pozycja gracza się zmienia. W każdej chwili możemy przecież nacisnąć escape, aby wyjść z programu.

  class GamePlayer < GameObject
    def move(delta_x)
      new_x = @x + delta_x
      if new_x >= 1 and new_x <= @table.width
        set_position(new_x, @y)
      end
    end #move
  end #class GamePlayer

  class GamePlayerUser < GamePlayer
    def work
      if escape
        GameObject.end_game = true
      elsif arrow_left
        move(-1)
      elsif arrow_right
        move(1)
      end
    end #loop
  end #class GamePlayerUser

Jest super, ale gdzie dźwięk ruchu?

Słychać, że zmienia się balans odtwarzanej piłki po naciśnięciu strzałek, ale brakuje dźwięku ruchu gracza, który mamy w pliku move.ogg.

Dźwięk ten można by utworzyć metodą new_sound, zdefiniowaną w poprzednim wpisie o showdown i należałoby to zrobić w metodzie initialize.

Problem w tym, że zdefiniowanie initialize w klasie GamePlayer spowoduje brak uruchomienia initialize klasy nadrzędnej, czyli GameObject. A w initialize klasy GameObject, dzieje się przecież sporo ważnych rzeczy.

Zarówno w metodzie initialize, jak też w każdej innej, którą chcielibyśmy nadpisać w klasie potomnej, możemy wywołać metodę klasy nadrzędnej pod nazwą super. Metodę nadrzędną super można wywołać bez parametrów, a wówczas zostaną do niej przesłane parametry wywołania metody, w której się znajdujemy. Można też wywołać super z parametrami, ale na praktyczne zastosowanie tego przypadku przyjdzie jeszcze czas.

W metodzie initialize klasy GamePlayer wywołamy super bez parametrów, a następnie utworzymy potrzebne dźwięki.

Następnie w metodzie move odtworzymy plik move.ogg albo move_edge.ogg zależnie od tego, czy ruch w wybranym kierunku jest możliwy.

  class GamePlayer < GameObject
    def initialize(table, x, y)
      super
      new_sound("move")
      new_sound("move_edge")
    end

    def move(delta_x)
      new_x = @x + delta_x
      if new_x >= 1 and new_x <= @table.width
        @sounds["move"].play
        set_position(new_x, @y)
      else
        @sounds["move_edge"].play
      end
    end #move
  end #class GamePlayer

I jest prawie dobrze, gdyby nie fakt, że program wyrzuca dziwny błąd w metodzie update_position.

Po przeanalizowaniu okazuje się, że tworząc obiekt gracza, ładując dla niego w initialize dźwięk move, wywoływana jest position_update, a nie ma jeszcze określonego obiektu listener, więc parametrów dźwięku nie da się wyliczyć.

Aby rozwiązać problem, trzeba na początku position_update w GameSound sprawdzać, czy ustawiony jest listener:

    def update_position
      return if not @@listener
...

Programujemy piłkę

Klasa piłki GameBall będzie dziedziczyć z klasy GameObject.

Piłka może leżeć nieruchomo na krawędzi stołu, czekając na serw użytkownika, może się toczyć w którąś stronę, albo znajdować się poza stołem, gdy spadła po stronie jednego z graczy.

Zacznijmy od zaprogramowania ruchu piłki w osi y bez zmian współrzędnej x.

Zmiana położenia piłki następować będzie w metodzie work. Nie wiadomo ile razy na sekundę wywoływana będzie ta metoda, szacujemy, że kilkadziesiąt, ale trudno na takim szacowaniu polegać, jeśli chcielibyśmy uzyskiwać w miarę powtarzalny i precyzyjny ruch.

Jeśli przyjmiemy jakąś wyjściową prędkość piłki, np. 2 pola na sekundę, to oznacza, że tocząc się po linii prostej wzdłuż długiej krawędzi stołu, powinna go pokonać w 7 sekund. W metodzie work będziemy potrzebowali znać czas w sekundach, jaki upłynął od poprzedniego wywołania tej metody, np. 0.02 sekundy. Pomnożymy ten czas razy prędkość piłki, czyli przykładowo 0.02*2, i o tyle właśnie zwiększymy lub zmniejszymy współrzędną y piłki w pojedynczym wykonaniu work.

Następne wywołanie work może nastąpić po 0.06, albo 0.01 sekundy, więc obliczenia trzeba będzie powtórzyć. Mogło minąć również kilka sekund, albo kilka minut, jeśli użytkownik “na chwilę” wyszedł z naszego programu do menu, otworzył inną funkcję i dopiero później powrócił do programu.

Czas jaki upłynął od ostatniego wywołania work dla danego obiektu, może być potrzebny nie tylko dla obiektu piłki, więc warto uprościć jego pozyskiwanie na poziomie klasy GameObject.

Zdefiniujemy tu atrybut last_work_time, oraz metodę work_time, która zwróci czas jaki upłynął od ostatniego wywołania work_time, a także przestawi last_work_time na wartość bieżącego czasu. Metodę work_time można będzie wywoływać tylko raz, na początku metody work danego obiektu.

  class GameObject
...
    def initialize(table, x = 0, y = 0)
    ...
      @last_work_time = Time.now.to_f
    end
    def work_time
      ti = Time.now.to_f
      res = ti - @last_work_time
      res = 0 if res < 0
      @last_work_time = ti
      return res
    end

Warto pamiętać, że wynik zwracany przez work_time może być =0, jeśli całe wykonanie ostatniej pętli obiektów trwało mniej niż najmniejsza mierzona jednostka czasu, np. milisekunda.

Linia res = 0 if res < 0 to zabezpieczenie przed zmianą czasu, w której rezultacie zwrócona mogłaby zostać wartość ujemna.

Prędkość piłki

Zdefiniujemy dla klasy GameBall atrybut delta_y o następujących wartościach:

  • 0 – piłka jest nieruchoma.
  • -2 – piłka toczy się w kierunku użytkownika z prędkością 2 pól na sekundę (jej współrzędna y będzie maleć w kolejnych wykonaniach loop).
  • 2 – piłka toczy się od użytkownika z prędkością 2 pól na sekundę (jej współrzędna y będzie rosnąć).

W metodzie initialize ustawimy delta_y na wartość 0, załadujemy także wszystkie potrzebne dźwięki.

  class GameBall < GameObject
    def initialize(table, x, y)
      super
      new_sound("ball", true)
      new_sound("ball_edge")
      new_sound("ball_fall")
      new_sound("ball_shot")
      @delta_y = 0
    end
...

Wiemy trochę jak powinna wyglądać metoda work klasy GameBall, ale nie mówiliśmy jeszcze o najważniejszym: co ma się dziać, gdy piłka dotrze do krawędzi stołu i jak ją wprawić w ruch?

Po dotarciu do krawędzi stołu, zostanie wywołana w work metoda fall, która na razie odtworzy odpowiedni dźwięk i zatrzyma ruch piłki.

Wstępna implementacja obu tych metod, wygląda następująco:

    def work
      ti = work_time
      return if ti == 0 or @delta_y == 0
      new_y = @y + (ti * @delta_y)
      if new_y < 1 or new_y > @table.length
        fall(new_y)
      else
        set_position(@x, new_y)
      end #if
    end #loop

    def fall(new_y)
      if new_y > @table.length
        new_y = @table.length + 0.01
      elsif new_y < 1
        new_y = 1 - 0.01
      else
        return
      end
      set_position(@x, new_y)
      @sounds["ball"].stop
      @sounds["ball_fall"].play
      @delta_y = 0
    end #fall

Przygotowujemy pierwszy strzał

Wprawienie piłki w ruch odbywa się na skutek jej uderzenia paletką przez któregoś z graczy.

W naszym oprogramowaniu, gracze (obiekty klasy GamePlayer) nie mają żadnej wiedzy o położeniu piłki, a piłka nic nie wie o graczach.

Aby rozwiązać ten problem, musimy przekazywać obiektom GamePlayer obiekt piłki, najlepiej podobnie jak przekazujemy obiekt utworzonego wcześniej stołu.

Będzie to wymagało rozszerzenia definicji metody initialize klasy GamePlayer, a co za tym idzie, zapowiadanego wcześniej wywoływania metody super z 3 parametrami.

  class GamePlayer < GameObject
    def initialize(table, ball, x, y)
      super(table, x, y)
      @ball=ball
      new_sound("move")
      new_sound("move_edge")
    end
...

Obiekty klasy GamePlayer będziemy tworzyć przekazując im informację o stole, piłce, x i y, a w initialize tych obiektów, wywołujemy metodę super klasy GameObject z 3 parametrami (stół, x, y).

Wymusza to na nas określoną kolejność tworzenia obiektów: najpierw stół (GameTable), następnie piłkę z parametrem stół, następnie gracz z obiektami stół i piłka…

  def main
    GameSound.set_samples_dir(appfile)
    table = GameTable.new(TABLE_WIDTH, TABLE_LENGTH)
    ball=GameBall.new(table, 1, 1)
    user= GamePlayerUser.new(table, ball, 1, 1)
    GameSound.set_listener(user)
...

Skoro gracze już wiedzą, gdzie znajduje się piłka, mogliby wykonać jej uderzenie. Aby móc uderzyć piłkę, gracz:

  • Musi się znajdować w tym samym polu x, co piłka z dokładnością do wartości całkowitej, czyli np. piłka może mieć współrzędną x=1.5, a gracz x=1.
  • Różnica odległości w osi y między graczem i piłką nie może być większa niż 3 pola.
  • Piłka musi się toczyć w kierunku gracza, albo być nieruchoma: dla gracza na y=1, delta_y piłki musi być <=0; dla gracza o y=table.length: delta_y musi być >=0.

W klasie GameBall zdefiniujemy metodę can_shot?(player), która odpowie na pytanie, czy podany w parametrze gracz, może uderzyć piłkę. Metoda shot, wykona to uderzenie.

Jeśli przeczytamy ball.can_shot?(player), możemy odnieść wrażenie, że to piłka będzie strzelać do gracza, a nie odwrotnie. Definiowanie metod can_shot? i shot w obiekcie gracza, a nie piłki, nie miałoby sensu, gdyż przenosiłoby logikę ustawiania wewnętrznych zmiennych obiektu piłka, (np. delta_y) na zewnątrz klasy tego obiektu.

Na pewno jednak warto wywołać metodę @ball.shot w metodzie work klasy GamePlayerUser, gdy naciśnięta zostanie strzałka w górę lub spacja.

  class GameBall < GameObject
...
    def can_shot?(player)
      return false if @x.to_i != player.x.to_i
      y_dist = (@y - player.y).abs
      return false if y_dist > 3
      if player.y == 1
        delta_ok = @delta_y <= 0
      else
        delta_ok = @delta_y >= 0
      end
      return delta_ok
    end #can_shot?

    def shot(player)
      return if not can_shot?(player)
      if player.y == 1
        @delta_y = 2
      else
        @delta_y = -2
      end
      @sounds["ball_shot"].play
      @sounds["ball"].play
    end #shot
  end #class GameBall
...
  class GamePlayerUser < GamePlayer
    def work
      if escape
        GameObject.end_game = true
      elsif arrow_left
        move(-1)
      elsif arrow_right
        move(1)
              elsif arrow_up or space
@ball.shot(self)
      end
    end #work
  end #class GamePlayerUser

Pierwszy strzał

Wszystko gotowe, wystarczy usunąć stare artefakty z metody main i uruchomić program:

  def main
    GameSound.set_samples_dir(appfile)
    table = GameTable.new(TABLE_WIDTH, TABLE_LENGTH)
    ball = GameBall.new(table, 1, 1)
    user = GamePlayerUser.new(table, ball, 1, 1)
    GameSound.set_listener(user)
    game_sleep(60)
  end #main

Przydałby się jakiś przeciwnik…

Utworzenie klasy komputerowego gracza staje się w tym momencie bardzo proste. W klasie GamePlayerComputer metodę work napiszemy w jednej linii – @ball.shot(self). Oznacza to, że tak często, jak to tylko możliwe, komputerowy gracz będzie próbował uderzyć piłkę.

Wreszcie utworzymy komputerowego przeciwnika w metodzie main i zastąpimy wywołanie game_sleep zwykłą pętlą powtarzaną do chwili końca gry:

  class GamePlayerComputer < GamePlayer
    def work
      @ball.shot(self)
    end #work
  end # class GamePlayerComputer

  def main
...
    opponent = GamePlayerComputer.new(table, ball, 1, table.length)
    while not GameObject.end_game?
      loop_update
      GameObject.work_all_objects
    end
  end #main

W klasie GameObject dopisujemy jeszcze metody stop_sounds i self.stop_all_objects_sounds, którą wywołamy po wyjściu z pętli gry. Na koniec tego odcinka, dodajemy w metodzie shot przyśpieszanie piłki o 12 procent po każdym strzale, aby gra nabrała dynamiki.

    def shot(player)
      return if not can_shot?(player)
      if @delta_y==0 # first shot
      if player.y == 1
        @delta_y = 2
      else
        @delta_y = -2
      end
      @sounds["ball"].play
      else # next shot
      @delta_y=@delta_y*-1.12
      end
      @sounds["ball_shot"].play
    end #shot

A aktualną wersję kodu gry można pobrać w pliku showdown3.zip.

Dodaj komentarz

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