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.