16. Showdown 3. Czy stół nadaje się na managera?

W dzisiejszym odcinku kontynuujemy prace nad komputerową wersją gry showdown, którą doprowadziliśmy do wersji umożliwiającej pierwszą wymianę piłek z komputerem.

Niepokonany przeciwnik

Problem z komputerem polega jednak na tym, że aktualnie jest on przeciwnikiem niemożliwym do pokonania. Jak pamiętamy, metoda work komputerowego gracza polega na odbijaniu piłki, jeśli to tylko jest możliwe, co oznacza, że komputer będzie próbował uderzyć piłkę kilkadziesiąt razy na sekundę.

Byłoby lepiej, gdybyśmy mogli sterować czasem reakcji komputera, co w przyszłości umożliwiłoby jego grę na różnych poziomach trudności, zależnych od czasu opóźnień reakcji.

W klasie GamePlayerComputer wprowadzimy atrybut reaction_time, domyślnie ustawiany na 1 sekundę.

Wprowadzimy też licznik czasu oczekiwania wait_time. Następnie w metodzie work będziemy dodawać work_time do wait_time, a gdy wait_time osiągnie wartość większą lub równą wartości reaction_time, wykonamy akcję komputerowego gracza. Wtedy też zresetujemy wartość reaction_time.

  class GamePlayerComputer < GamePlayer
    def initialize(table, ball, x, y)
      super
      @reaction_time = 1
      @wait_time = 0
    end

    def reaction_time=(t)
      @reaction_time = t
    end

    def work
      @wait_time = @wait_time + work_time
      return if @wait_time < @reaction_time
      @wait_time = @wait_time - @reaction_time
      @ball.shot(self)
    end #work
  end # class GamePlayerComputer

Jak to działa? Piłka przemieszcza się po stole z początkową prędkością 2 pola na sekundę, ma do pokonania 14 pól, czyli będzie to trwało 7 sekund. Na 3 polach najbliższych gracza, może on skutecznie odbić piłkę, co oznacza, że początkowo ma na to 1.5 sekundy. Z każdym strzałem, piłka przyśpiesza o 12 procent, więc proporcjonalnie skraca się okienko czasowe na odbicie piłki.

shot_number=2
reaction_time_window=1.5/1.12**shot_number

Po trzecim strzale okienko skraca się do 1 sekundy, po 10-tym do 48 setnych… Od czwartego strzału włącznie, wszystko zależy od tego, czy moment aktywności komputera, następujący co sekundę, zsynchronizuje się z okienkiem czasowym na wykonanie strzału.

Im dłuższe to okno, tym większe prawdopodobieństwo, że synchronizacja nastąpi.

Stan gry

W końcu komputer wpuszcza piłkę do swojej bramki, ale niedługo potem ponownie wprawia ją w ruch. Użytkownika dotyczy ten sam problem.

Po wpuszczeniu piłki przez któregoś z graczy, przeciwnik powinien dostać punkt, powinno to być ogłoszone i wówczas też powinna nastąpić jakaś przerwa przed następnym serwem.

Można sobie wyobrazić 3 stany, w jakich może być gra:

  • Serw: któryś gracz serwuje czyli wykona strzał w wybranym przez siebie punkcie swojej krawędzi stołu.
  • Piłka w grze: gracze odbijają piłkę, jej prędkość z każdym strzałem wzrasta.
  • Piłka poza stołem: piłka spadła ze stołu.

Pojawia się też pytanie, będące tytułem tego odcinka: potrzebny byłby jakiś manager gry, czyli obiekt przechowujący aktualny stan gry, który wszystkie obiekty w grze mogłyby obserwować, albo zmieniać. Naturalnym kandydatem do tej roli wydaje się stół: każdy obiekt gry ma dostęp do obiektu stołu, więc łatwo może z niego odczytać stan gry.

Nie działa to w drugą stronę: stół nie ma dostępu do obiektów gry, a jako manager całej rozgrywki, powinien mieć.

Najlepiej byłoby przenieść całą logikę rozgrywki do klasy GameTable, tworząc tam metodę game_run, w której odbędzie się cała rozgrywka.

Przeniesiemy tam też game_sleep.

Użyte zmienne (user, opponent, ball…) zamienimy przy okazji na atrybuty klasy, aby mieć do nich dostęp z innych przyszłych metod GameTable.

Zdefiniujemy atrybut state, jako attr_accessor.

W metodzie main Eltenowego programu, przeprowadzimy niezbędne inicjalizacje i wywołamy metodę game_run.

  class GameTable
    attr_reader :width, :length
    attr_accessor :state

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

    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 #game_sleep

    def game_run
      @ball = GameBall.new(self, 1, 1)
      @user = GamePlayerUser.new(self, @ball, 1, 1)
      GameSound.set_listener(@user)
      @opponent = GamePlayerComputer.new(self, @ball, 1, @length)
      while not GameObject.end_game?
        loop_update
        GameObject.work_all_objects
      end
      GameObject.stop_all_objects_sounds
    end #game_run
  end #GameTable

  def main
    GameSound.set_samples_dir(appfile)
    table = GameTable.new(TABLE_WIDTH, TABLE_LENGTH)
    table.game_run
  end #main

Identyfikatory

Mamy w grze atrybut state, pozostaje tylko ustalić, jakie wartości powinien przyjmować.

W większości języków programowania, ustalilibyśmy określone stałe np. const STATE_BALL_SERVE=1 i tak dalej…

W Ruby na stałości stałych nie bardzo można polegać i chyba sami twórcy języka zdawali sobie z tego sprawę, skoro zaproponowali identyfikatory.

Identyfikator to nazwa poprzedzona dwukropkiem, posiadająca niezmienną wartość.

Wprowadzimy następujące identyfikatory stanów gry: :ball_serve, :ball_running i :ball_fall.

Stan :ball_serve

Stan :ball_serve jest ustawiany przez managera gry na początku serwu, po ustawieniu najpierw współrzędnej y piłki na współrzędną któregoś z graczy.

    def game_run
...
      @opponent = GamePlayerComputer.new(self, @ball, 1, @length)
      @state = :ball_serve
      while not GameObject.end_game?
...

W metodzie move klasy GamePlayer w tym stanie współrzędna x piłki jest ustawiana na współrzędną gracza o zgodnej współrzędnej y.

    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)
        @ball.set_position(new_x, @y) if @table.state == :ball_serve and @ball.y == @y
      else
        @sounds["move_edge"].play
      end
    end #move

Metoda shot przełącza stan gry na :ball_running.

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

Stan :ball_running

Ustawiany w metodzie shot klasy GameBall, a w metodzie fall przestawiany na :ball:fall.

    def fall(new_y)
...
      @sounds["ball"].stop
      @table.state = :ball_fall
      @sounds["ball_fall"].play
      @delta_y = 0
    end #fall

Aby sprawdzić dokonane modyfikacje, po uruchomieniu programu przesuńmy się jedno lub więcej pól w prawo i uderzmy piłkę. Spadnie ze stołu po stronie komputera, a przeciwnik nie podejmie żadnej próby jej odbicia, gdyż znajduje się na innej współrzędnej x.

Gdy gra jest w stanie :ball_running, w metodzie work komputer powinien najpierw próbować znaleźć się na tej samej współrzędnej x co piłka.

    def work
      @wait_time = @wait_time + work_time
      return if @wait_time < @reaction_time
      @wait_time = @wait_time - @reaction_time
      ball_running_action if @table.state == :ball_running
    end #work

    def ball_running_action
      if @ball.x.to_i > @x.to_i
        move(1)
      elsif @ball.x.to_i < @x.to_i
        move(-1)
      else
        @ball.shot(self)
      end
    end #ball_running_action

Całą logikę podążania za toczącą się piłką wyodrębniliśmy do metody ball_running_action, w innej, osobnej metodzie, oprogramujemy później akcję serwowania piłki przez komputer…

Stan :ball_fall

Włączany w metodzie fall klasy GameBall, wyłączany przez managera gry na końcu serwu.

W klasie GameTable możemy teraz wyodrębnić całą logikę rozgrywania pojedynczego serwu do osobnej metody, do której jako parametr przekażemy obiekt serwującego użytkownika.

    def single_serve(serving_player)
      if serving_player == @user
        alert("User serves.")
      else
        alert("Computer serves.")
      end
      @state = :ball_serve
      @ball.set_position(serving_player.x, serving_player.y)
      @state = :ball_serve
      while not GameObject.end_game? and @state != :ball_fall
        loop_update
        GameObject.work_all_objects
      end
      if @state == :ball_fall
        if @ball.y.round == @user.y.round
          alert("Computer gets point.")
        else
          alert("User gets point.")
        end
        game_sleep 2
      end # state ball_fall
    end #single_serve

    def game_run
...
      while not GameObject.end_game?
        single_serve(@user)
      end
      GameObject.stop_all_objects_sounds
    end #game_run

Komputer serwuje czyli method_defined?

Chcielibyśmy zdefiniować metodę start_serve w klasie GamePlayerComputer, pozwalającą wykonać określone działania przy rozpoczęciu serwowania. Taka metoda nie jest nam potrzebna w klasie GamePlayerUser, ani też w klasie nadrzędnej. Czy możemy sprawdzić, że dana instancja obiektu posiada określoną metodę i wywołać ją tylko wtedy, jeśli tak jest?

Służy do tego metoda method_defined?, jednak można ją wywołać tylko na klasach, a nie na instancjach obiektów zainicjowanych z tych klas.

Na szczęście, klasę, której dany obiekt jest instancją, udostępnia on w atrybucie class, a zatem:

2.round
: #=> 2
2.class.method_defined? :round
: #=> true

A wracając do naszego przypadku, chcąc wywołać metodę start_serve na obiekcie, po prostu sprawdzimy, czy klasa, której instancją jest dany obiekt, posiada taką metodę:

      serving_player.start_serve if serving_player.class.method_defined? :start_serve

Możemy przejść do programowania metody start_serve; również w klasie GamePlayerComputer oprogramujemy przechodzenie do wylosowanej pozycji w metodzie ball_serve_action…

    def start_serve
      @start_serve_x = rand(@table.width - 1) + 1
    end

    def ball_serve_action
      return if @start_serve_x == nil
      if @x.to_i < @start_serve_x.to_i
        move(1)
      elsif @x.to_i > @start_serve_x.to_i
        move(-1)
      else
        @start_serve_x = nil
        @ball.shot(self)
      end
    end #ball_serve_action

    def work
      @wait_time = @wait_time + work_time
      return if @wait_time < @reaction_time
      @wait_time = @wait_time - @reaction_time
      if @table.state == :ball_running
        ball_running_action
      elsif @table.state == :ball_serve and @ball.y.round == @y.round
        ball_serve_action
      end
    end #work
  end # class GamePlayerComputer

Aby przetestować, zmieniamy wywołanie single_serve w game_run:

        single_serve(@opponent)

Nierówne

Wprowadzamy jeszcze drobną zmianę w metodzie work klasy GamePlayerUser, aby ruch i strzał były możliwe tylko gdy stan gry to :ball_running lub :ball_serve, ale podniesienie flagi end_game następowało zawsze po Escape.

    def work
      if escape
        GameObject.end_game = true
      elsif @table.state != :ball_running and @table.state != :ball_serve
        return
      elsif arrow_left
        move(-1)
      elsif arrow_right
        move(1)
      elsif arrow_up or space
        @ball.shot(self)
      end
    end #work

Czy mówiliśmy już o operatorze !=? Jest to odwrotność do == i oznacza nierówne.

Podobnie instrukcja unless jest odwrotnością do if.

Zmiana kąta piłki

Powoli nasza gra zaczyna się nadawać do użytku, największym teraz problemem jest fakt, że gracze strzelają tylko na wprost. Gdyby strzelali pod losowym kątem, rozgrywka byłaby dużo ciekawsza.

Ruch pod kątem możemy osiągnąć, wprowadzając prócz atrybutu piłki delta_y również atrybut delta_x, oznaczający przyrost współrzędnej x na sekundę. Osie x i y tworzą kąt prosty, więc po wprowadzaniu zmian współrzędnych piłki, zmianą w danej jednostce czasu będzie przeciwprostokątna trójkąta prostego tworzonego przez delta_x i delta_y. To właśnie ta przeciwprostokątna, a nie jak dotychczas, delta_y, będzie oznaczać rzeczywistą prędkość piłki.

Z twierdzenia Pitagorasa wynika, że suma kwadratów przyprostokątnych jest równa kwadratowi przeciwprostokątnej.

Wprowadzimy w klasie GameBall atrybut speed, oznaczający prędkość piłki w polach na sekundę, początkowo równy 2 i zwiększany w każdym strzale o 12 procent.

Metoda calc_delta będzie losować delta_x i odpowiednio do wylosowanej wartości obliczać delta_y, aby przeciwprostokątna była równa wartości speed.

    def calc_delta
      square_speed = @speed ** 2
      @delta_x = (rand() * square_speed / 4) ** 0.5
      @delta_x = @delta_x * -1 if rand(2) == 0
      @delta_y = (square_speed - @delta_x ** 2) ** 0.5
      @delta_y = @delta_y * -1 if @speed < 0
    end #calc_delta

Log.info

W pierwszej wersji calc_delta wkradł się jakiś błąd, który sprawiał, że w pewnych sytuacjach piłka toczyła się między lewą i prawą krawędzią, bez zmian w osi y. Próba podglądnięcia wyliczonych wartości przy pomocy funkcji p, nie dała dobrych rezultatów, gdyż wyświetlenie systemowego okna dialogowego i kliknięcie przycisku OK zamrażało wykonanie programu, a po kliknięciu przycisku wywoływana była funkcja work z wysoką wartością parametru czasu od ostatniego uruchomienia, co kończyło badany serw.

Byłoby lepiej, gdyby można zapisać wartości nie powodując opóźnień w działaniu programu, a gdy przydarzy się problemowa sytuacja, móc później podglądnąć jakie były te wyliczone parametry.

Do tego celu służy metoda Eltena Log.info, zapisująca zadany łańcuch w pliku elten.log.

Dodając w programie linię:

Log.info "delta_x=#{@delta_x}, delta_y=#{@delta_y}, speed=#{@speed}."

otrzymaliśmy w pliku logu zapis:

I: delta_x=-3.29539038315446, delta_y=NaN, speed=2.809856. (16:41:28)

co oznaczało, że coś poszło nie tak.

Po analizie okazało się, że problemem był brak obliczania pierwiastka kwadratowego z delta_x.

W powyżej prezentowanej wersji metody calc_delta ten błąd został już naprawiony.

Jak obliczyć poziom trudności?

Poziom trudności rozgrywki zależy od dwóch zmiennych: czasu reakcji komputerowego gracza i początkowej prędkości piłki.

Przy naszych aktualnych ustawieniach jest problem, że komputer w niektórych przypadkach może nie zdążyć dotrzeć do piłki i oddać pierwszego strzału.

Jeśli komputer znajduje się skrajnie po lewej, a gracz zaserwuje skrajnie po prawej stronie stołu, piłka potoczy się idealnie na wprost, to pokona długość stołu w 7 sekund. W tym samym czasie komputer zdąży dotrzeć do skrajnego narożnika, ale zabraknie mu sekundy na uderzenie piłki.

A zatem prędkość komputera powinna być wyliczona jako `((długość stołu / prędkość piłki ) + 1) / szerokość stołu.

W klasie GameTable wyodrębnimy metodę game_init, która utworzy niezbędne obiekty i ustawi wszystko dla wymaganego poziomu trudności:

Zliczanie i podawanie punktacji

Dodajemy atrybut points w klasie GamePlayer, który będziemy odpowiednio zwiększali obsługując stan :ball_fall.

class GamePlayer < GameObject
  attr_accessor :points
      def initialize(table, ball, x, y)
...
      @points = 0
    end
...
        if @ball.y.round == @user.y.round
          alert("Computer gets point.")
          @opponent.points=@opponent.points+1
        else
          alert("User gets point.")
                  @user.points=@user.points+1
end

Ogłaszanie wyniku oprogramujemy w metodzie announce_results:

    def announce_points
      alert "User has #{@user.points} points, computer has #{@opponent.points} points."
    end

Atrybut frequency i przyśpieszanie piłki

Obiekty klasy Sound posiadają atrybut frequency, określający ich częstotliwość próbkowania (np. 44100 hz).

Atrybut ten można nie tylko odczytywać, ale też modyfikować, wpływając na prędkość odtwarzania dźwięku.

Skoro piłka po każdym strzale przyśpiesza, być może warto odzwierciedlić ten fakt zwiększając częstotliwość o pół tonu za każdym strzałem?

W naszym obiekcie GameSound zapamiętamy przy inicjalizacji bazową częstotliwość dźwięku, oraz napiszemy metodę set_freq_semitones, zmieniającą częstotliwość odtwarzania o zadaną ilość półtonów (wartość ujemna obniża względem bazowej).

    def initialize(file_name, owner, looping = false)
      @so = Sound.new(@@samples_dir + file_name + ".ogg", 1, looping)
      @base_freq = @so.frequency
...
    def set_freq_semitones(st)
      factor = 1.0594631 ** st.abs
      factor = 1 / factor if st < 0
      @so.frequency = @base_freq * factor
    end

I teraz w metodzie shot:

    def shot(player)
...
        @sounds["ball"].play
        @shots = 0
        @sounds["ball"].set_freq_semitones(@shots)
        @table.state = :ball_running
      else # next shot
        @speed = @speed * -1.08
        calc_delta
        @shots = @shots + 1
        @sounds["ball"].set_freq_semitones(@shots)
      end

Podsumowanie

Nasz komputerowy showdown nadaje się właściwie do użytku. Pozostały kosmetyczne możliwe modyfikacje, takie jak podział rozgrywki na sety, zostawiam je czytelnikom. A na którym poziomie trudności udało się wam pokonać komputer?

Aktualna wersja showdown4.zip

5 komentarzy

  1. Zgaduję. Zresztą chodzi tylko o pierwsze dwie cyfry i to z dokładnością do np. 5 w jedną lub drugą stronę.

  2. A co ja mam powiedzieć, jeśli z twierdzenia o pierwszych dwóch cyfrach numeru pesel wynika, że u mnie Pitagoras był dużo dawniej…?
    Łatwo nie było, ale jakoś udało się przypomnieć…

  3. Twierdzenie pitagorasa było daaawno. Matematyka mnie zabije. Nie no żartuję, nigdy nie było u mnie źle z matmą, ale dopiero w liceum miałem dobrego nauczyciela.

Dodaj komentarz

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

EltenLink