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?
how to install ruby gems into elten api program
Zgaduję. Zresztą chodzi tylko o pierwsze dwie cyfry i to z dokładnością do np. 5 w jedną lub drugą stronę.
Skąd znasz moj pesel? xDD.
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ć…
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.