Robimy grę platformową w Ruby Gosu!

Tematy różne, różniste.
Awatar użytkownika
Rave

Golden Forki 2010 - Dema (miejsce 2)
Posty: 1940
Rejestracja: 15 kwie 2009, 21:33
Lokalizacja: '; DROP TABLE 'Messages'

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Rave » 16 sty 2014, 05:17

Mam problem. Jeśli wskoczę pod "odpowiednim" kątem na platformę powyżej, czasem postać "utyka" w platformie około 1/3 do połowy:

http://i.imgur.com/oPZ3zOA.png

Napisałem więc taki kod w Scene_Map.update (na końcu):

Kod: Zaznacz cały

      if !no_ground?(@player.middle_x,@player.bottom) then
         @player.y -= (@player.middle_y / @tileset.tilesize).floor
      end


Kod działa, postać nie utyka (jest drobny problem zwący się postać przesuwa się powoli zamiast natychmiastowo), jednak dostaje "padaki", podnosi się nawet jak już nie musi co powoduje ciągłe skakanie.
Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 16 sty 2014, 14:30

Z tego fragmentu kodu raczej nic nie wynika, przydałoby się żebyś podesłał demo. Muszę zobaczyć jak to się dzieje, jak wygląda i mieć głębszy wgląd w kod, bo problem może leżeć gdzieś indziej :p
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Rave

Golden Forki 2010 - Dema (miejsce 2)
Posty: 1940
Rejestracja: 15 kwie 2009, 21:33
Lokalizacja: '; DROP TABLE 'Messages'

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Rave » 16 sty 2014, 14:39

Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 16 sty 2014, 15:02

Przebrnąłem przez kod, udało mi się nawet dojść do tego jak pośród tych wszystkich klas sprawdzać blokowanie na danych współrzędnych, i wygląda na to, że blokowanie w tamtym miejscu jest poprawne. Nie potrafię dokładnie wskazać błędu, ale najbliższe co mi się wydaje to to, że w momencie gdy spadamy i sterujemy postacią nie wykrywa Ci (a przynajmniej wykrywa niepoprawnie) blokowania po bokach, więc gdy jesteśmy w powietrzu możemy wlecieć w ścianę.
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Rave

Golden Forki 2010 - Dema (miejsce 2)
Posty: 1940
Rejestracja: 15 kwie 2009, 21:33
Lokalizacja: '; DROP TABLE 'Messages'

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Rave » 16 sty 2014, 15:49

Tak, i temu miał zapobiegać ten snippecik. I zapobiega, tylko robi tak że postać gracza dostaje padaki.
Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 19 sty 2014, 17:12

Lekcja 6: Poprawki



Dzisiaj zajmiemy się małymi poprawkami w tym, co już zrobiliśmy. Jak odpalicie grę na pewno rzuci wam się w oczy poruszanie naszej postaci. Na razie jest ono bardzo prosto zrobione; nasza postać porusza się powoli, skakanie też jest bardzo słabe. Przydałoby się poprawić te dwie rzeczy.
Najpierw zwiększymy prędkość ruchu. W naszej metodzie 'Player#update' mamy ją ustawioną na 1. Zwiększmy do 3.

Kod: Zaznacz cały

      if @move_x > 0 then
            @move_x -= 1
            @x += 1
         elsif @move_x < 0 then
            @move_x += 1
            @x -= 1
         elsif @move_x == 0 then
            @moving = false
         end

Teraz animacja nie będzie nadążała za naszą postacią, także w 'draw' zmieńcie wartość przy frame. Ja zmieniłem na 90, ale wartości między 75 a 100 wyglądają równie dobrze

Kod: Zaznacz cały

frame = milliseconds / 90 % @sprite.size

Te prostszą rzecz mamy już z głowy ;) teraz czas na skok. Do tego będziemy potrzebowali dwóch zmiennych:

Kod: Zaznacz cały

      @v_acc = 1
      @max_v_acc = 15

Te dwie zmienne będą reprezentowały nasze pionowe przyśpieszenie (acceleration). W przypadku skoków będziemy obniżać przyśpieszenie (postać wznosi się coraz wolniej), a przy spadaniu - podnosić. Zacznijmy od spadania:

Kod: Zaznacz cały

   def fall
      if @jump == 0 then
         @y += @v_acc
         @v_acc = @v_acc * 1.25
         @v_acc = @max_v_acc if @v_acc > @max_v_acc
         if @dir == :left then
            @sprite = @jump_left
         elsif @dir == :right then
            @sprite = @jump_right
         end
      end
   end

W powyższym kodzie widać trzy obliczenia. Pierwsze dodaje wartość '@v_acc' do naszego '@y'. Zaczyna od dodania 1, potem ta wartość wzrasta. W drugiej linijce mamy właśnie ten wzrost. Co kratkę nasz '@v_acc' zwiększa się o 25%. Następnie sprawdzamy, czy prędkość nie jest za duża, i jeśli jest - ustawiamy ją do naszej maksymalnej wartości (15). Musimy jeszcze w jakiś sposób to przyśpieszenie zresetować. Ta metoda:

Kod: Zaznacz cały

   def reset_acceleration
      @v_acc = 1
   end

idzie do 'Player', a w 'SceneMap#update' musimy poprawić:

Kod: Zaznacz cały

@player.fall if no_ground?(@player.get_x, @player.get_y)

na:

Kod: Zaznacz cały

      if no_ground?(@player.get_x, @player.get_y) then
         @player.fall
      else
         @player.reset_acceleration
      end

To nam będzie resetowało nasze przyśpieszenie za każdym razem, gdy nie będziemy spadać. Drugą rzeczą do poprawki jest skok. Najpierw musimy dodać do naszej metody 'Player#jump' zmianę startowego przyśpieszenia (ponieważ będzie ono spadać!):

Kod: Zaznacz cały

   def jump
      @jump = 15 if @jump == 0
      @v_acc = 20
   end

Dałem wartość 20, która pozwoli na całkiem wysoki skok - potrzebuje ok. 15 kratek żeby spaść na tyle, żeby grawitacja była silniejsza ;) następnie w naszym update zmieńmy nasz kod od spadania:

Kod: Zaznacz cały

      if @jump > 0 then
         @y -= 2

Na:

Kod: Zaznacz cały

      if @jump > 0 then
         @y -= @v_acc
         @v_acc = @v_acc * 0.75

Analogicznie do spadania: najpierw nasza wartość '@y' jest zmniejszana o obecne przyśpieszenie, a potem samo przyśpieszenie jest zmniejszane o 25%. Jak teraz uruchomicie grę możecie zauważyć, że nawet się unosimy przez pół sekundy w powietrzu. Można to poprawić zmniejszając wartość @jump w metodzie 'Player#jump' z 15 na np. 12. Wartość mniejsza niż 10 daje gorsze wyniki, więc nie zalecam zmniejszania jej aż tak.
Ostatnia rzecz do skoku, którą musimy zmienić, to przy 'Player#reset_jump' dodać zmianę '@v_acc = 1'.


Drugą poprawką jaką musimy zrobić jest blokowanie tileseta. Na razie mamy dość prosto - jest jakiś tile? Blokujemy. Nie ma? Można przelatywać. Jednak jak zauważycie, jest kilka tili które mogą być wykorzystane nieco inaczej:
Obrazek
Pierwszym jest kolec. Logicznie rzecz biorąc kolec powinien ranić/zabijać naszą postać. Następnie mamy pochyłe tile, które mogłyby sprawiać, że nasza postać po nich po prostu zjeżdża. Trzeci przykład to możliwość tzn. półprzepuszczalnych tili - czyli takich, które np. pozwolą przejść przez nie postaci, ale gdy na takiego skoczymy zatrzymuje nas. Dlatego ustalmy takim tilom kilka id:
0 - powietrze/w pełni przepuszczający
1 - całkowicie blokuje
2 - blokuje tylko po bokach
3 - blokuje tylko przy upadaniu (od góry)
4 - rani postać
5 - pochyły w lewo
6 - pochyły w prawo
Z czasem będzie można również dodać więcej, ale przy obecnym tilesecie to nie jest potrzebne. Tylko teraz jak to zrobić?
Otóż wystarczy zrobić plik zawierający te ID pokrywające się z naszym tilesetem. Zrobiłem taki plik, możecie go pobrać tutaj.
Plik umieśćcie w tym samym folderze w którym mamy grafikę naszego tileseta ('graphics/tiles/'). Zauważcie, że ma taką samą nazwę jak nasz tileset, poza rozszerzeniem. Wczytujemy go tak samo jak wczytywaliśmy nasze mapy w tiled w Tutorialu Specjalnym 4.5. Więc po kolei:

Kod: Zaznacz cały

      @tile_data = []
      data_raw = File.read("graphics/tiles/#{@used_tileset.delete(".png")}.pass")
      pass = data_raw.scan(/\d+/)
      @tile_data = pass.collect! &:to_i

Najpierw tworzymy sobie pustą zmienną w której będziemy mieli potrzebne nam dane. Następnie w niemal identyczny sposób jak w wyżej wymienionym tutorialu wczytujemy dane z pliku i przetwarzamy je na tablicę. Teraz każdy tile w naszej zmiennej '@tileset' ma swoje ID umieszczone w zmiennej '@tile_data'. Skoro zrobiliśmy już to, zajmiemy się teraz edycją naszego blokowania i ogólnie wykrywaniem tili w danym miejscu. Zacznijmy od usunięcia wszystkich trzech metod które to obliczają: 'SceneMap#solid_overhead?/no_ground?/wall?'. Zróbcie zamiast nich nową metodę: 'get_tile_info(x,y,pos=:down)'. Pos będzie zawierała symbole oznaczające gdzie (od podanego x i y) chcemy sprawdzić blokowanie: :up, :right, :down, :left.

Kod: Zaznacz cały

   def get_tile_info(x,y,pos=:down)
      tile_x = (x/16).to_i
      tile_y = (y/16).to_i
      case pos
      when :up
         tile_y -= 2
      when :right
         tile_x += 1
         tile_y -= 1
      when :left
         tile_x -= 1
         tile_y -= 1
      end
      return @tile_data[@level[0][tile_y][tile_x]]
   end

Kod jest dość prosty i mam nadzieję, że nie muszę go wyjaśniać ;) musimy teraz pozmieniać każde odwołanie do poprzednich metod tą metodą. To może być nieco podchwytliwe, bo często musimy sprawdzać dwie wartości (np. przy poruszaniu się na boki czy tile nie jest ani 1, ani 2). Można zrobić wielokrotne warunki ('if a != 1 and a != 2'), ale jest szybszy sposób.

Kod: Zaznacz cały

if $window.button_down?(KbLeft) and ![1,2].include?(get_tile_info(@player.get_x, @player.get_y, :left)) then
         @player.move_left
      end
      if $window.button_down?(KbRight) and ![1,2].include?(get_tile_info(@player.get_x, @player.get_y, :right)) then
         @player.move_right
      end
      @player.update
      if [0,2].include?(get_tile_info(@player.get_x, @player.get_y,:down)) then
         @player.fall
      else
         @player.reset_acceleration
      end
      if @player.is_jumping? then
         if get_tile_info(@player.get_x, @player.get_y,:up) != 0 then
            @player.reset_jump
         end
      end

Fragmenty '[0,1].include?' sprawdzają, czy 'get_tile_info' zwróci którąś z tych wartości. Musimy jeszcze zmienić naszą metodę sprawdzającą wciśnięcie przycisku skoku:

Kod: Zaznacz cały

         if [1,3,5,6].include?(get_tile_info(@player.get_x, @player.get_y,:down)) then
            @player.jump
         end

Sprawę blokowania (i przeniesienie na nowy system) mamy z głowy. Teraz zróbmy zabijanie (i respawn) postaci gdy spadnie na kolce. Najpierw przejdźmy do klasy Player, w której potrzebujemy dwóch zmiennych: '@spawn_x' i '@spawn_y'. Oczywiście przyjmują one wartości startowego x i y, ale nie są zmieniane. Dodajmy jeszcze metodę 'respawn':

Kod: Zaznacz cały

      # Initialize:
      @spawn_x = x
      @spawn_y = y

   def respawn
      @real_x = @spawn_x
      @real_x = @spawn_y
      @x = @real_x + (@sprite[0].width / 2)
      @y = @real_y + @sprite[0].height
   end

I wróćmy do naszego 'SceneMap'. Tutaj będziemy mogli wykorzystać już istniejący warunek do spadania. Po prostu sprawdzimy dodatkowe ID w naszym 'include?' i następnie w przypadku konkretnego ID (4) wywołamy respawn:

Kod: Zaznacz cały

      if [0,2,4].include?(get_tile_info(@player.get_x, @player.get_y,:down)) then
         @player.fall
         if get_tile_info(@player.get_x, @player.get_y,:down) == 4 then
            @player.respawn
         end
      else

Gdy wejdziemy na kolce jesteśmy automatycznie teleportowani na nasz spawn. Z czasem oczywiście dodamy trochę więcej do naszego respawna, jak np. miganie postaci oraz przemieszczanie spawnpointu przy check pointach.
Kolejną rzeczą jaką powinniśmy zrobić jest zjeżdżanie postaci po pochyłych powierzchniach, ale zanim to zrobimy poprawimy jeden błąd który dzisiaj utworzyliśmy. Przez dodanie niepełnych wartości do współrzędnych (przy spadaniu) nasza postać nie zawsze lądowała na pełnym tilu (jak pewnie zauważyliście), tylko "wpadała" kilka pikseli za nisko:
Obrazek
Jest na to proste rozwiązanie, które zaczął robić Rave.

Kod: Zaznacz cały

while ![0,2].include?(get_tile_info(@player.get_x, @player.get_y - 1, :down)) do
         @player.move_up
      end

I w Player:

Kod: Zaznacz cały

def move_up
      @y -= 1
   end

To poprawi ten mały problem. Teraz czas na ślizganie się naszej postaci. Musimy sprawdzić, czy postać jest na tilu o ID 5 lub 6, a następnie wywołać odpowiednią metodę:

Kod: Zaznacz cały

      if get_tile_info(@player.get_x, @player.get_y,:down) == 5 then
         @player.slide_left
      elsif get_tile_info(@player.get_x, @player.get_y,:down) == 6 then
         @player.slide_right
      end

I w klasie 'Player':

Kod: Zaznacz cały

   def slide_left
      @x -= random(3,5)
      @y -= random(1,4)
      @dir = :left
      @sprite = @jump_left
   end

   def slide_right
      @x += random(3,5)
      @y -= random(1,4)
      @dir = :right
      @sprite = @jump_right
   end

Kod w klasie Player jest dość prosty. Przy zjeżdżaniu dodaje/odejmuje (w zależności od kierunku) nam losową wartość (między 3 a 5) do x, oraz odejmuje małą wartość od y. Dlaczego odejmujemy od y? Otóż żeby postać lekko podskakiwała na małych wybojach ;)
Tile przepuszczające w połowie zrobię innym razem, ponieważ zapomniałem na swojej mapie umieścić obiektów, które takie coś będą wykorzystywać :p ale to nie jest trudne do zrobienia, możecie sami spróbować (a nawet powinniście, jako swojego rodzaju test!).


Kolejną rzeczą którą chciałem poruszyć są ukryte przejścia. Na swojej mapce mam jedno takie, wcześnie na początku, ale:
Obrazek
Cóż, nie wygląda to za dobrze. Możemy zrobić proste rozwiązanie, które sprawi, że postać będzie półprzeźroczysta gdy jest na tilach ukrywających takie przejście:
Obrazek
Ale to też średnio ciekawie wygląda. Może jakby postać gracza była nieco ciemniejsza?
Obrazek
Nieco lepiej... ale to wciąz nie to. Poza tym jak by były jakieś ukryte obiekty (które będziemy robić już niedługo :P) to ich nie będzie widać. Zróbmy więc to trochę inaczej. Jak wiemy, druga warstwa jest idealna do zakrywania ukrytych przejść (nie jest brana pod uwagę przy blokowaniu). Tak więc najpierw sprawdźmy, czy postać jest pod takim tilem:

Kod: Zaznacz cały

      if in_hidden_entrance then

      end

I ta metoda:

Kod: Zaznacz cały

   def in_hidden_entrance
      tile_x = (@player.get_x/16).to_i
      tile_y = (@player.get_y/16).to_i
      if @level[1][tile_y-1][tile_x] > 0 then
         return true
      else
         return false
      end
   end

Teraz wiemy czy postać jest pod danym przejściem. Co dalej? Zróbmy zmienną '@hidden_tiles = []' w initialize. W niej będziemy mieli współrzędne tych tili, które chcemy ukryć. I nasze wykrywanie tych tili:

Kod: Zaznacz cały

#Update
      if in_hidden_entrance then
         @hidden_tiles = []
         for y in 0...@level[1].size
            for x in 0...@level[1][y].size
               curx = (x * 16) + 8
               cury = (y * 16) + 8
               dist = Gosu::distance(@player.get_x, @player.get_y(:center), curx, cury)
               if dist < 32 then
                  @hidden_tiles << [x,y]
               end
            end
         end
      else
         @hidden_tiles = []
      end

Jak działa ta metoda? Najpierw czyścimy naszą zmienną '@hidden_tiles' ze wszystkich wartości. Potem sprawdzamy każdego tila na warstwie 1, i obliczamy jego odległość od środka naszej postaci. Do tego musieliśmy zmienić nieco metodę 'Player#get_y', kod macie niżej. Jeśli odległość jest mniejsza niż 32 piksele, współrzędne danego tila są zapisywane. I oczywiście jeśli nie jesteśmy w jakimś ukrytym przejściu, zmienna również jest czyszczona z wszystkich wartości.

Kod: Zaznacz cały

      # Zmiana do zwracania wartości y
      def get_y(arg = nil)
      return @y if arg == nil
      return @real_y + @sprite[0].height / 2 if arg == :center
   end

Do czego teraz możemy wykorzystać tę zmienną? Do tego:

Kod: Zaznacz cały

      for l in 0...@level.size
         for y in 0...@level[l].size
            for x in 0...@level[l][y].size
               if l == 1 then
                  if @hidden_tiles.include?([x,y]) then
                     @tileset[@level[l][y][x]].draw((x*16)-@camera_x,(y*16)-@camera_y,l+1,1,1,Color.new(160,255,255,255))
                  else
                     @tileset[@level[l][y][x]].draw((x*16)-@camera_x,(y*16)-@camera_y,l+1)
                  end
               else
                  @tileset[@level[l][y][x]].draw((x*16)-@camera_x,(y*16)-@camera_y,l+1)
               end
            end
         end
      end

To jest nasz kod rysowania planszy. Nieco się rozrósł ;) teraz sprawdzamy czy rysujemy warstwę 1. Jeśli tak, sprawdzamy, czy tile który jest aktualnie rysowany jest zawarty w zmiennej '@hidden_tile'. Jeśli jest, rysujemy go z przeźroczystością. Jeśli nie: rysujemy go normalnie. Efekt:
Obrazek
Jeśli zmienimy z postaci na 1 (czyli pod tę warstwę), wygląda to jeszcze lepiej:
Obrazek
Z każdym ruchem widoczne kratki przechodzą dalej. Niestety, wartość 32 dla odległości jest (jak dla mnie) najbardziej optymalna, przy wyższej wartości nasz FPS niestety spada na bardzo niski. A szkoda, bo dodanie nawet dwóch kratek więcej sprawia różnicę:
Obrazek
Wtedy również możnaby pomyśleć o płynnej przeźroczystości (im dalej od postaci, tym mniejsza przeźroczystość tila). Jeśli ktoś ma jakieś pomysły jak zoptymalizować to bardziej, wyślijcie mi PW ;)

A na dzisiaj myślę, że to wszystko. W następnej lekcji zajmiemy się prawdopodobnie jakimiś objektami (w końcu!).



Archiwum winrara z efektem dzisiejszej pracy
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 20 sty 2014, 21:50

Lekcja 7: Diamenty!



Dzisiaj w końcu zajmiemy się tworzeniem obiektów, ich zbieraniem i punktacją. Będziemy potrzebowali do tego nieco grafik. Wystarczyłaby jedna, ale od razu zrobimy wszystkie obiekty dodające punkty: diamenty i monety. Zapiszcie wszystkie poniższe grafiki do '/graphics/sprites':
Obrazek
Obrazek
Obrazek
Obrazek
Obrazek
Obrazek
Obrazek
Obrazek
W nazwie pliku macie przy okazji przykładową punktację. Będziemy również potrzebowali nowej klasy. Zróbcie więc plik 'GameCollectible.rb'. Standardowo musimy go importować ;).
Jako, że powoli nam dochodzi klas (a będzie ich jeszcze więcej), zróbmy małą automatyzację importu. To nie jest konieczne, i jeśli chcecie, możecie to pominąć. Przy automatycznym importowaniu klas mogą się pojawić problemy, jeśli macie klasy i ich podklasy: jeśli podklasa zaczyna się literą wcześniejszą niż jej główna klasa (dajmy na to 'class Execute < Script') wyskoczy nam błąd. W takim przypadku musicie się upewniać, że importujecie je w dobrej kolejności (np. te klasy importować ręcznie, a resztę automatycznie).
Kod który pozwoli nam na automatyczny import to:

Kod: Zaznacz cały

classes = Dir.new("scripts/").entries
classes.delete(".")
classes.delete("..")
for i in 0...classes.size
   require "scripts/#{classes[i]}"
end

Kod jest prosty. Najpierw tworzymy zmienną 'classes' która ma zapisane nazwy wszystkich plików w folderze 'scripts'. Następnie usuwamy z niej dwie wartości: "." i ".." (które oznaczają cofanie do poprzedniego folderu), zostawiając same nazwy skryptów (np. "GameCollectible.rb"). Następnie każdy z tych plików jest importowany ;)
A teraz wracając do naszej klasy 'GameCollectible'. Przy tworzeniu będziemy potrzebowali trzech argumentów: x, y i kind. Oczywiście poza tym potrzebujemy metody 'update' i 'draw':

Kod: Zaznacz cały

class GameCollectible

   def initialize(x,y,kind)

   end

   def update

   end

   def draw(camera_x, camera_y, z = 1)

   end

end

Musimy wczytać grafikę dla danego obiektu oraz ustalić wartość punktową. Tak więc:

Kod: Zaznacz cały

class GameCollectible

   def initialize(x,y,kind)
      @x = x
      @y = y
      @kind = kind
      @sprite = Image.load_tiles($window, "graphics/sprites/#{@kind.to_s}.png", 16,16,false)
      case @kind
      when :gem10
         @score = 10
      when :gem20
         @score = 20
      when :gem35
         @score = 35
      when :gem65
         @score = 65
      when :gem100
         @score = 100
      when :gem125
         @score = 125
      when :coingold
         @score = 250
      when :coinsilver
         @score = 175
      end
   end

   def update

   end

   def draw(camera_x, camera_y, z = 1)
      frame = milliseconds / 150 % @sprite.size
      @sprite[frame].draw(@x - camera_x, @y - camera_y, z)
   end

end

Dla testów dodamy teraz do mapy nasz obiekt.
W 'SceneMap#initialize':

Kod: Zaznacz cały

      @entities = []
      load_entities
      @entities << GameCollectible.new(275,173,:gem10)

i '#draw':

Kod: Zaznacz cały

@entities.each{|en| en.draw(@camera_x, @camera_y)}

Metoda której użyliśmy przy update/draw, czyli .each jest bardzo użyteczna. Działa bardzo podobnie do pętli 'for', tj. wywołuje podany kod na każdym obiekcie w tablicy.
Teraz gdy odpalimy grę w końcu ujrzymy nasz diamencik:
Obrazek
Do zbierania potrzebujemy dwóch metod, które pozwolą nam sprawdzić x i y:

Kod: Zaznacz cały

   def get_x
      return @x + @sprite[0].width/2
   end

   def get_y
      return @y + @sprite[0].height/2
   end

Zauważcie, że zwracamy nie wartość x i y, a środek naszego obiektu. Będziemy go potrzebowali dla obliczenia kolizji, dzięki której też będziemy zbierali diamenty ;) wróćmy więc do mapy...

Kod: Zaznacz cały

      @entities.each{|en|
         en.update
         dist = Gosu::distance(@player.get_x, @player.get_y(:center), en.get_x, en.get_y)
         if dist < 20 then
            p "collect!"
         end
      }

To jest rozwinięcie naszego kodu w 'update'. Odległość 20 pikseli między środkiem postaci a środkiem obiektu jest wystarczająca - kolizja następuje mniej więcej w momencie dotknięcia obu spritów. Potrzebujemy jeszcze zrobić coś, żeby zebrane diamenty znikały i dodawały nam punkty. Zacznijmy od zrobienia zmiennej '@score' w naszej 'SceneMap', i dodania metody 'GameCollectible#get_score'.

Kod: Zaznacz cały

   def get_score
      return @score
   end

Zanim zrobimy dodawanie punktów zróbmy najpierw ich wyświetlanie. Do tego przyda nam się klasa "Text" która będzie rozwinięciem "Gosu::Font".

Kod: Zaznacz cały

class Text < Font

   def initialize
      @font = Font.new($window,"graphics/Cookies.ttf",20)
   end

   def draw_text(text,x,y,z,scale_x = 1.0, scale_y = 1.0, color = Color.new(255,255,255), mode = :default)
      @font.draw(text,x,y,z,scale_x = 1.0, scale_y = 1.0, color = Color.new(255,255,255), mode = :default)
   end

end

Czcionka której używam to Cookies. Możecie oczywiście wybrać swoją czcionkę sami. W naszym 'GameWindow' zróbmy zmiennę tekstu:

Kod: Zaznacz cały

$text = Text.new

i w 'SceneMap#draw' zróbmy wyświetlanie naszej punktacji

Kod: Zaznacz cały

$text.draw_text("Score: #{@score}",16,16,10)

Teraz gdy nasza punktacja jest widoczna
Obrazek
możemy wrócić do zbierania naszych diamentów ;) Zrobienie tego będzie naprawdę proste. Mamy już wykrywanie kolizji, teraz wystarczy tylko do naszej punktacji dodać tyle, ile punktów daje dany obiekt, a następnie go usunąć:

Kod: Zaznacz cały

            @score += en.get_score
            @entities.delete(en)

Ten kod umieśćmy w miejscu 'p "collect!"' ;) Proste, prawda? Możecie sprawdzić pozostałe obiekty zmieniając argument "kind" przy tworzeniu, żeby upewnić się, że wszystko działa dobrze. Skoro upewniliśmy się, że wszystko działa, to usuńcie linijkę

Kod: Zaznacz cały

@entities << GameCollectible.new(250,178,:coinsilver)

i otwórzcie nasz SceneEditor. Nie będziemy się męczyć z dodawaniem wszystkiego ręcznie, po to mamy edytor ;) Zasada jest taka sama jak gdy tworzyliśmy edytor - musicie dodać i umieścić grafiki w odpowiednich miejscach, sprawdzić kliknięcia i zapisać. Dość proste, wierzę, że dacie sobie z tym radę ;)

Kod: Zaznacz cały

      # Initialize
      @gem10_graphic = Image.load_tiles($window, "graphics/sprites/gem10.png", 16, 16, false)
      @gem20_graphic = Image.load_tiles($window, "graphics/sprites/gem20.png", 16, 16, false)
      @gem35_graphic = Image.load_tiles($window, "graphics/sprites/gem35.png", 16, 16, false)
      @gem65_graphic = Image.load_tiles($window, "graphics/sprites/gem65.png", 16, 16, false)
      @gem100_graphic = Image.load_tiles($window, "graphics/sprites/gem100.png", 16, 16, false)
      @gem125_graphic = Image.load_tiles($window, "graphics/sprites/gem125.png", 16, 16, false)
      @coinsilver_graphic = Image.load_tiles($window, "graphics/sprites/coinsilver.png", 16, 16, false)
      @coingold_graphic = Image.load_tiles($window, "graphics/sprites/coingold.png", 16, 16, false)
      
      # Click
      
      elsif $window.mouse_x.between?(208,224) and $window.mouse_y.between?(352,368) then
         select_object(:gem10)
      elsif $window.mouse_x.between?(240,256) and $window.mouse_y.between?(352,368) then
         select_object(:gem20)
      elsif $window.mouse_x.between?(272,288) and $window.mouse_y.between?(352,368) then
         select_object(:gem35)
      elsif $window.mouse_x.between?(208,224) and $window.mouse_y.between?(384,400) then
         select_object(:gem65)
      elsif $window.mouse_x.between?(240,256) and $window.mouse_y.between?(384,400) then
         select_object(:gem100)
      elsif $window.mouse_x.between?(272,288) and $window.mouse_y.between?(384,400) then
         select_object(:gem125)
      elsif $window.mouse_x.between?(128,144) and $window.mouse_y.between?(432,448) then
         select_object(:coinsilver)
      elsif $window.mouse_x.between?(160,176) and $window.mouse_y.between?(432,448) then
         select_object(:coingold)
      
      # Draw
      frame = milliseconds / 150 % @gem10_graphic.size
      @gem10_graphic[frame].draw(208,352,1)
      @gem20_graphic[frame].draw(240,352,1)
      @gem35_graphic[frame].draw(272,352,1)
      @gem65_graphic[frame].draw(208,384,1)
      @gem100_graphic[frame].draw(240,384,1)
      @gem125_graphic[frame].draw(272,384,1)
      frame = milliseconds / 150 % @coinsilver_graphic.size
      @coinsilver_graphic[frame].draw(128,432,1)
      @coingold_graphic[frame].draw(160,432,1)
      
      # Drawing all objects on map
      for i in 0...@objects.size
         case @objects[i][0]
            when :player
               frame = milliseconds / 150 % @player_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @player_graphic[frame].draw(rx,ry,6)
            when :gem10
               frame = milliseconds / 150 % @gem10_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @gem10_graphic[frame].draw(rx,ry,6)
            when :gem20
               frame = milliseconds / 150 % @gem10_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @gem20_graphic[frame].draw(rx,ry,6)
            when :gem35
               frame = milliseconds / 150 % @gem10_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @gem35_graphic[frame].draw(rx,ry,6)
            when :gem65
               frame = milliseconds / 150 % @gem10_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @gem65_graphic[frame].draw(rx,ry,6)
            when :gem100
               frame = milliseconds / 150 % @gem10_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @gem100_graphic[frame].draw(rx,ry,6)
            when :gem125
               frame = milliseconds / 150 % @gem10_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @gem125_graphic[frame].draw(rx,ry,6)
            when :coinsilver
               frame = milliseconds / 150 % @coinsilver_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @coinsilver_graphic[frame].draw(rx,ry,6)
            when :coingold
               frame = milliseconds / 150 % @coinsilver_graphic.size
               rx = @objects[i][1] - (@offset_x * 16) + 368
               ry = @objects[i][2] - (@offset_y * 16) + 160
               @coingold_graphic[frame].draw(rx,ry,6)
         end
      end

      # Object info
      case @object_held
         when nil

         when :player
            @player_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :gem10
            @gem10_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :gem20
            @gem20_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :gem35
            @gem35_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :gem65
            @gem65_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :gem100
            @gem100_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :gem125
            @gem125_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :coinsilver
            @coinsilver_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
         when :coingold
            @coingold_graphic[0].draw($window.mouse_x, $window.mouse_y,10)
      end   

Trochę kodu tutaj jest, ale wszystko powinno ładnie działać ;) teraz tylko musimy wziąć pod uwagę nowe obiekty podczas wczytywania mapy z pliku, co w prosty sposób zrobimy edytując lekko 'SceneMap#load_entities':

Kod: Zaznacz cały

   def load_entities
      for i in 0...@objects.size
         case @objects[i][0]
         when :player
            @player = Player.new(@objects[i][1], @objects[i][2])
         when :gem10, :gem20, :gem35, :gem65, :gem100, :gem125, :coinsilver, :coingold
            @entities << GameCollectible.new(@objects[i][1], @objects[i][2], @objects[i][0])
         end
      end
   end

I nasze nowe obiekty się wczytują:
Obrazek
Wszystko działa tak, jak powinno.

Na dzisiaj to wszystko. W następnej lekcji zajmiemy się kolejnym rodzajem obiektu - :invulnerability, czyli Nietykalność. Dodamy również kilka dźwięków do gry. I zobaczymy, jeśli nie będzie za długa lekcja, może dorzucę jeszcze coś więcej ;)



Archiwum winrara z efektami dzisiejszej pracy.
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Hubertov
Posty: 248
Rejestracja: 28 paź 2013, 16:07

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Hubertov » 20 mar 2014, 20:47

Co dalej? Czemu nic nie wrzucasz? :(
Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 21 mar 2014, 14:58

Hubertov pisze:Co dalej? Czemu nic nie wrzucasz? :(

Fakt, "trochę" bardzo zaniedbałem te poradniki, i powodów ku temu jest w sumie kilka, głównie brak czasu i motywacji. Ale postaram się jeszcze dzisiaj dokończyć już zaczętą lekcję 8 i wrzucić wam.
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 21 mar 2014, 23:15

Lekcja 8: Nietykalność, dźwięki i...



Witajcie w kolejnej, ósmej już lekcji. Najpierw chciałem przeprosić za tak długą nieaktywność, ale mieszanina wielu spraw skutecznie mnie odciągała od pisania dalszych lekcji - jak już był czas, to wolałem się skupić na własnym projekcie. Niemniej jednak wróciłem!
Dzisiaj, jak zapowiedziałem pod koniec poprzedniej, zajmiemy się kolejnym, ostatnim rodzajem obiektu - Nietykalnością. Zapiszcie sobie tę grafikę:
Obrazek
Przejdźmy do naszego GameCollectible i w naszym 'case @kind' dodajmy nowy warunek.

Kod: Zaznacz cały

when :invulnerability

Teraz jest mały problem. Nasza zmienna '@score' zawiera punktację. Nietykalność nie da nam punktów, a jedynie sprawi, że nasza postać będzie chwilowo niezniszczalna. Tutaj możemy wykorzystać fakt, że w Ruby wszystko jest obiektem, przez co nasza zmienna '@score' może się nagle zmienić z Integera (czyli liczby całkowitej) na inną wartość. Dlatego moglibyśmy np. ustawić ją na '@score = "UNTOUCHABLE"', albo kolejny symbol. Ale żeby nie komplikować sobie życia, pozostańmy przy stałych rodzajach, i ustawmy naszą zmienną na '-1'.

Kod: Zaznacz cały

      when :invulnerability
         @score = -1

Dla celów testowych ustawcie gdzieś na mapie jeden taki obiekt (ręcznie, jak to robiliśmy poprzednim razem). Po uruchomieniu gry z nowym obiektem rzuci nam się w oczy pewien problem:
Obrazek
Otóż nasza grafika invulnerability.png ma pojedynczą kratkę 32x32 piksele, a każda z innych - 16x16. Dlatego musimy jeszcze dodać mały warunek:

Kod: Zaznacz cały

      if @kind == :invulnerability then
         @sprite = Image.load_tiles($window, "graphics/sprites/#{@kind.to_s}.png", 32,32,false)
      else
         @sprite = Image.load_tiles($window, "graphics/sprites/#{@kind.to_s}.png", 16,16,false)
      end

Teraz wszystko działa - przynajmniej wizualnie. Gdy zbierzecie go, zobaczycie, że dostajemy -1 punkt.
Czyli dokładnie to, co miało się dziać. Albo inaczej - to, co na razie ustaliliśmy, żeby się działo. Musimy teraz w 'SceneMap#update' przerobić nieco zbieranie naszych obiektów. Znajdźmy ten kod:

Kod: Zaznacz cały

      @entities.each{|en|
         en.update
         dist = Gosu::distance(@player.get_x, @player.get_y(:center), en.get_x, en.get_y)
         if dist < 20 then
            @score += en.get_score
            @entities.delete(en)
         end
      }

Musimy dodać jeden prosty warunek. Jeśli punktacja obiektu jest mniejsza od zera, robimy jedną czynność, jeśli większa - dzieje się to, co się działo. Więc:

Kod: Zaznacz cały

         if dist < 20 then
            if en.get_score < 0 then
               p "invulnerability"
               @entities.delete(en)
            else
               @score += en.get_score
               @entities.delete(en)
            end
         end

Szybki test, i wygląda na to, że działa. Tę część mamy więc za sobą. Teraz czas sprawić, żeby faktycznie to działało. Przejdźcie do klasy 'Player'.
Najpierw ustalmy w jaki sposób będzie działała nasza niewrażliwość. W momecie gdy zostanie ona aktywowana, nasza postać zmieni nieco kolor. Z czasem jak efekt będzie się kończył, postać będzie powoli wracała do swojego koloru. Oczywiście w trakcie nietykalności postać nie może zostać w żaden sposób zraniona. Zróbmy dwie zmienne oraz metodę:

Kod: Zaznacz cały

      #Initialize   
      @invulnerability = 0
      @color_alter = 255
      
   
   def invulnerable
      @invulnerability = 300
      @color_alter = 165
   end

Wartość 300 kratek będzie wystarczyła na 5 sekund nietykalności. Teraz w update dajmy:

Kod: Zaznacz cały

      if @invulnerability > 0 then
         @invulnerability -= 1
         @color_alter += 0.3
      end

Kod jest prosty. Jeśli '@invulnerability' jest większe od zera, zmniejszamy tę wartość co kratkę i dodajmy 0.3 do '@color_alter'. W 300 kratek nasz '@color_alter' zmieni się z 165 na 255, czyli tyle, ile trzeba ;) Zmiana jednej linijki w 'Player#draw' również da nam wizualną reakcję:

Kod: Zaznacz cały

      @sprite[frame].draw(@real_x - camera_x, @real_y - camera_y, z, 1.0, 1.0, Color.new(@color_alter,@color_alter,@color_alter,@color_alter))

Ale gdy zobaczycie jak to wygląda, na pewno rzuci wam się w oczy fakt, że trochę ciężko jest zauważyć ile tak naprawdę pozostało nam tej nietylalności... za to gdyby się nam nad głową unosił pasek odmierzający czas...
Tak, to jest to!

Kod: Zaznacz cały

      if @invulnerability > 0 then
         bar_width = (32 * @invulnerability) / 300
         $window.draw_quad(@real_x - camera_x, @real_y - camera_y - 4, Color.new(255,35,35),
                     @real_x - camera_x + bar_width, @real_y - camera_y - 4, Color.new(35,255,35),
                     @real_x - camera_x + bar_width, @real_y - camera_y, Color.new(35,255,35),
                     @real_x - camera_x, @real_y - camera_y, Color.new(255,35,35), z)
      end

Ten kod jest dość prosty. Najważniejszą rzeczą jest tu obliczanie 'bar_width', czyli jakiej długości pasek powinniśmy narysować. Reszta to jest wykorzystanie wbudowanej metody 'Gosu::Window#draw_quad'. Kolor paska można zmienić edytując wartość w 'Color.new'. Kod podany przeze mnie da nam dość standardowy pasek przechodzący z zieleni w czerwień. Teraz gdy podniesiemy nasza nietykalność powinniśmy zobaczyć coś takiego:
Obrazek
Ale jest jeden problem. Nietykalnosć jeszcze nie działa. Na razie wystarczy, że w naszej metodzie 'respawn', na samym początku damy:

Kod: Zaznacz cały

return if @invulnerability > 0

Działanie tego możemy sprawdzić na kolcach. Ale skoro już jesteśmy w tej części kodu, dodajmy na końcu metody 'respawn' wywołanie metody 'invulnerable'. Dzięki temu gdy postać zginie, przez pierwsze pięć sekund będzie nietykalna ;)
Obrazek
Kolejny problem który można zauważyć w przypadku upadnięcia na kolce. Dzięki nietykalności nie giniemy, ale... nie możemy też się wydostać! Musimy dodać ID 4 do listy umożliwiającej nam skok. Dzięki temu skok z kolców również będzie możliwy.
Kwestię nietykalności raczej mamy z głowy. Postaje nam tylko dodać ją do naszego Edytora. To sami dacie radę zrobić bez problemu. Możecie umieścić ten obiekt w punkcie 208:416.
Pamiętajcie też, że przy wczytywaniu mapy musicie do Scene_Map#load_entities, we fragmencie

Kod: Zaznacz cały

when :gem10, :gem20, :gem35, :gem65, :gem100, :gem125, :coinsilver, :coingold

dodać :invulnerability!



Częścią drugą dzisiaj jest dodanie dźwięków. Zajmiemy się na razie czterema dźwiękami: Skok gracza, zebranie diamentu, monety i nietykalności. Ściągnijcie tę paczkę i wypakujcie ją do 'audio/sounds'. Zaczniemy od skoku gracza. Przejdźmy do klasy 'Player' i w 'initialize' zróbmy nowy dźwięk:

Kod: Zaznacz cały

@jump_sound = Sample.new($window, "audio/sounds/04-Jump.ogg")

Oraz w metodzie 'jump' dajmy odtworzenie tego dźwięku:

Kod: Zaznacz cały

@jump_sound.play

I to wszystko. Teraz gdy uruchomicie grę, skokowi naszej postaci będzie towarzyszył dźwięk. Jak widzicie, to jest bardzo proste. Dźwięk zebrania obiektu jest niemal identycznie robiony, z tym, że musimy ustalić jaki dźwięk dajemy. Więc:

Kod: Zaznacz cały

      if [:gem10, :gem20, :gem35, :gem65, :gem100, :gem125].include?(@kind) then
         @sound = Sample.new($window, "audio/sounds/09-Gem.wav")
      elsif [:coingold, :coinsilver].include?(@kind) then
         @sound = Sample.new($window, "audio/sounds/10-Coin.wav")
      elsif @kind == :invulnerability then
         @sound = Sample.new($window, "audio/sounds/11-Invincibility.ogg")
      end   

A nasze odtworzenie dźwięku możemy dać w metodzie 'get_score', ponieważ to ją wywołujemy w momencie zebrania ów obiektu ;)



Ostatnią rzeczą jaką zrobimy będzie mała poprawka do skoku. Otóż w większości platformówek system jest tak skonstruowany, że postać gracza ma ułamek sekundy po zejściu z platformy na wykonanie skoku - co przydaje się np. na małych platformach. I właśnie coś takiego zrobimy!
Najpierw zajmijmy się klasą Player. Potrzebujemy zrobić w Player#initialize nową zmienną:

Kod: Zaznacz cały

@fall = 0

Musimy dodać zmienianie tej wartości do Player#fall:

Kod: Zaznacz cały

   def fall
      if @jump == 0 then
         @y += @v_acc
         @v_acc = @v_acc * 1.25
         @v_acc = @max_v_acc if @v_acc > @max_v_acc
         if @dir == :left then
            @sprite = @jump_left
         elsif @dir == :right then
            @sprite = @jump_right
         end
         @fall += 1 # <<<<<<
      end
   end

i Player#reset_acceleration:

Kod: Zaznacz cały

   def reset_acceleration
      @v_acc = 1
      @fall = 0
   end

Krótkie wyjaśnienie co tu się dzieje: zmienna @fall zapisuje jak długo postać spada. Z każdą kratką spadania ta zmienna wzrasta o 1 (w Player#fall). Jeśli skończymy spadać, resetujemy ją do startowej wartości - 0 (Player#reset_acceleration). Tyle wystarczy nam w klasie Player, żeby uwarunkować "opóźniony skok". Jeszcze tylko stwórzcie funkcję, która wyśle nam wartość @fall!

Przejdźmy teraz do Scene_Map. Tutaj sprawa jest bardzo prosta. Potrzebujemy tylko zmienić nieco naszą funkcję 'button_down()', a konkretnie część odpowiedzialną za skok. Prosta zmiana:

Kod: Zaznacz cały

if id == KbUp then
         if [1,3,4,5,6].include?(get_tile_info(@player.get_x, @player.get_y,:down)) then
            @player.jump if @player.get_fall < 5
         end

prawda?
Nie. Tzn. zmiana jest prosta, ale to nie zadziała. Dlaczego? Otóż @player.jump jest wywołane w przypadku, jeśli postać stoi na jakimś tilu. Gdy spadamy, już nie jesteśmy na żadnym tilu. Więc musimy rozbić warunek:

Kod: Zaznacz cały

if [1,3,4,5,6].include?(get_tile_info(@player.get_x, @player.get_y,:down)) then
            @player.jump
         else
            
         end

I mamy sprawdzanie: gracz stoi na tilu. Jeśli tak - skoczy. Jeśli nie - coś innego. W części 'else' musimy dodać tylko warunek który był powyżej - jeśli @fall playera jest mniejsze niż 5 (albo inna wartość, wybierzcie sami!), to możemy skoczyć. Teoretycznie moglibyśmy w środku dać jeden z tych warunków:

Kod: Zaznacz cały

if @player.get_fall < 5 then
   @player.jump
end

#=====
@player.jump if @player.get_fall < 5

Ale to tylko rozbijanie na więcej kodu. Zamiast tego dajcie:

Kod: Zaznacz cały

      if [1,3,4,5,6].include?(get_tile_info(@player.get_x, @player.get_y,:down)) then
            @player.jump
         elsif @player.get_fall < 5 then
            @player.jump
         end

I będzie działało. Przy testach możecie mieć mały problem z zauważeniem tego, bo, bądź co bądź zmiana jest drobna, ale dla samego sprawdzenia czy daje, warunek możecie powiększyć (do np. fall < 60 dla sekundy na reakcję).
Mała zmiana, ale zaufajcie, oszczędzi wam, oraz graczom, nerwów ;) przykład macie w przykładowej mapie którą dałem w projekcie - pod koniec jest półka skalna na którą bez tej rzeczy wskoczenie byłoby koszmarem. Sprawdźcie sami usuwając na chwilę ten "opóźniony skok".

Jeszcze na zakończenie mała lista tego, co pozostało nam do zrobienia. Każda z tych rzeczy prawdopodobnie będzie pojedynczą lekcją, więc macie jakiś wgląd w to, ile pracy nam jeszcze zostało:
  • Poprawki w specjalnych tilach: poślizg, tile "półprzepuszczalne"
  • Ładunki wybuchowe - miny, dynamit, bomba, używanie ich przez gracza
  • Wrogowie
  • Przechodzenie planszy, wczytywanie kolejnej
  • Menusy
  • Lokalne zapisywanie punktacji, high score
  • Kompilowanie do pliku .exe
A potem ewentualnie jakieś dodatki, upiększacze ;)



Archiwum winrara z efektami dzisiejszej pracy.
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Ekhart

Golden Forki 2015 - Zapowiedzi (zwycięstwo); Golden Forki 2011 - Dema (miejsce 1)
Posty: 405
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Carrigtwohill, IE

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Ekhart » 14 cze 2014, 15:05

Lekcja 9: Boom! Czyli ładunki wybuchowe.



Kolejna lekcja! Jak mówiłem w poprzednim tutorialu, zostało nam tylko parę rzeczy do zrobienia. Jedną z nich są ładunki wybuchowe, zarówno używane przez gracza, jak i statyczne ;) i właśnie nimi się dzisiaj zajmiemy. Potrzebujemy tych oto graficzek:
Obrazek
Obrazek

Graficzki oczywiście wrzucamy do "graphics/sprites". Na początek zrobimy Minę. Zasada działania jest prosta - obiekt na mapie. Jeśli nasza postać pojawi się w zasięgu miny, zaczyna się odliczanie (ilość sekund losowana przy postawieniu miny), po danym czasie mina wybucha. To zaczynamy!
Stwórzcie nową klasę - GameMine. W GameMine#initialize będziemy potrzebowali pozycji X i Y. Dodamy też losowanie ilości sekund do eksplozji - między 1 a 5 powinno być wystarczające. Tak więc:

Kod: Zaznacz cały

class GameMine

   def initialize(x,y)
      @x = x
      @y = y
      @cooldown = random(1,5).to_i * 60
      p "Cooldown set at #{@cooldown} frames"
      @sprite = Image.load_tiles($window,"graphics/sprites/mine_floor.png", -4, -1, false)
   end

   def update

   end

   def get_x
      return @x
   end

   def get_y
      return @y
   end

   def draw(camera_x, camera_y, z = 1)
      frame = milliseconds / 150 % @sprite.size
      @sprite[frame].draw(@x - camera_x, @y - camera_y, z)
   end

end


Jak widzicie, kod jest bardzo prosty. Dodałem też animację miny, identyczna jak w naszej klasie GameCollectible. W initialize macie również printa, jest on tymczasowy - żeby upewnić się, że działa ;) w tej chwili nie mamy miny, ani też nie mamy jak jej dodać poprzez edytor. Zróbmy więc nieco inny trick. Przejdźmy do SceneMap. Mamy tam zmienną @entities, która odpowiada za wszystkie obiekty w grze. Przejdźmy na dół, do #button_down(id), i skorzystajmy z naszego testowego buttona:

Kod: Zaznacz cały

if id == KbF9 then
         @entities << GameMine.new(314, 208)
      end

Teraz po naciśnięciu F9 pojawi nam się mina na ustalonej pozycji.
Obrazek
Przy okazji możemy zobaczyć na ile nasz zegar jest ustawiony. Na razie jeszcze nie usuwajmy tego printa - przyda się przy późniejszych testach.
Dobrze, miny już się pojawiają, nie ma żadnych błędów. Co dalej? Otóż musimy zrobić jej zasięg, odliczanie i wybuchanie! W naszej klasie odpowiedzialnej za minę zróbmy zmienne (w initialize):

Kod: Zaznacz cały

@countdown = false
@exploded = false

W GameMine#update dajmy sprawdzanie czy odliczanie się zaczęło, jeśli tak - zmniejszamy nasz cooldown, i jeśli jest on równy zeru - mina wybucha.

Kod: Zaznacz cały

   def update
      return if @exploded
      if @countdown then
         @cooldown -= 1 if @cooldown > 0
      end
      if @cooldown == 0 then
         @exploded = true
         @countdown = false
      end
   end

Co tu się dzieje? Najpierw sprawdzamy czy mina już przypadkiem nie wybuchła. Jeśli tak, to ignorujemy całe update. Następnie sprawdzamy, jeśli odliczanie jest zaczęte, odejmujemy 1 od @cooldown (ale tylko w przypadku, gdy jest on większy od 0). Jeśli @cooldown jest równy zeru, mina wybucha, dodatkowo wyłączamy dalsze odliczanie. Proste? Proste!
Teraz potrzebujemy jeszcze dwóch metod. Pierwsza sprawdzi, czy podane współrzędne są w zasięgu miny; druga zaś będzie tylko informowała SceneMap czy mina wybuchła czy nie. Zacznijmy od tej drugiej, proste return:

Kod: Zaznacz cały

   def mine_exploded?
      return @exploded
   end

Druga funkcja będzie ciut trudniejsza. Przede wszystkim potrzebujemy dwóch liczb: x i y:

Kod: Zaznacz cały

   def in_range?(px,py)

   end

Następnie musimy sprawdzać odległość. Samo liczenie odległości jest proste. Jednak nie chcemy naszej miny "zbyt" czułej. Nie chcemy, aby wykrywała naszą postać gdy jest poniżej oraz zbyt wysoko. Ustalmy więc:
- Zasięg miny to 5 kratek, 16 pikseli każda - 80 pikseli razem
- Zasięg w górę to 3 kratki, 48 pikseli
- Zasięg w dół to jedna kratka, 16 pikseli.
Więc nasz kod będzie wyglądał tak:

Kod: Zaznacz cały

   def in_range?(px,py)
      return if @countdown
      return if @cooldown == 0
      return if py < (@y - 48)
      return if py > (@y + 16)
      if Gosu::distance(@x, @y, px,py) < 80 then
         p "in range"
         @countdown = true
      end
   end   

Po kolei: pomijamy kod jeśli odliczanie już się zaczęło, jeśli się skończyło, jesteśmy o 48 pikseli ponad miną albo 16 pikseli poniżej. Jeśli tak nie jest, sprawdzamy odległość między podanymi współrzędnymi x i y gracza a x i y miny (używając wbudowanej funkcji Gosu::distance). Jeśli jest mniejszy niż 80 pikseli, rozpoczynamy odliczanie (i dla naszej wiadomości - print). Oczywiście zanim ta funkcja będzie działała, musimy się do niej odwołać w SceneMap#update.

Kod: Zaznacz cały

if en.is_a?(GameMine) then
            en.in_range?(@player.get_x, @player.get_y)
         end

Ten kod umieśćcie w naszym bloku @entities.each{}, pod en.update.
Funkcja .is_a?() sprawdza, czy dany obiekt jest obiektem klasy podanej w nawiasie. Dzięki temu ta funkcja będzie wywoływana tylko gdy dane entity jest Miną. Przetestujmy...
Obrazek
Coś jest nie tak... tylko co? Prześledźmy to, co pokazało nam się w oknie Wiersza Poleceń.
Sprawdziło dobrze odległość, odliczanie się zaczęło... a potem pojawił się błąd. Po jego treści powinniście się dowiedzieć - nasz skrypt próbuje naliczyć punkty za "zebranie" miny. Musimy temu zapobiec! Wykorzystajmy więc to, czego się nauczyliśmy, i umieśćmy obliczanie odległości między graczem a Entity, oraz naliczanie punktów, działo się tylko gdy Entity jest z klasy GameCollectible ;)

Kod: Zaznacz cały

      @entities.each{|en|
         en.update
         if en.is_a?(GameMine) then
            en.in_range?(@player.get_x, @player.get_y)
         elsif en.is_a?(GameCollectible) then
            dist = Gosu::distance(@player.get_x, @player.get_y(:center), en.get_x, en.get_y)
            if dist < 20 then
               if en.get_score < 0 then
                  @player.invulnerable
                  @entities.delete(en)
               else
                  @score += en.get_score
                  @entities.delete(en)
               end
            end
         end
      }

Szybki test i wygląda na to, że wszystko działa. Zbliżanie się do miny aktywuje ją. Odliczanie też działa, i po upłynięciu danej ilości sekund mina "wybucha" (sprawdzone na razie zwykłym printem). Z tym, że przydałoby się w jakiś sposób pokazać naszemu graczowi, że mina została aktywowana. Dlatego dodamy piknięcia co sekundę, aż do wybuchu. Ściągnijcie ten dźwięk (autor: Mike Koenig) i umieśćcie go w 'audio/sounds'. Utwórzcie zmienną z tym dźwiękiem (jako Gosu::Sample).

Kod: Zaznacz cały

@beep = Sample.new($window, "audio/sounds/beep.wav")

Przejdźmy do metody Update. Chcemy, żeby dźwięk był odtwarzany co sekundę, czyli co 60 kratek. Tak więc wystarczy użyć operatora Mod (%):

Kod: Zaznacz cały

if @cooldown % 60 == 0 and @countdown then
         @beep.play
      end

Szybki test i działa. Jeszcze dajmy jasny błysk co sekundę:

Kod: Zaznacz cały

def draw(camera_x, camera_y, z = 1)
      frame = milliseconds / 150 % @sprite.size
      if @countdown and [0,1,2,3,4,5,6].include?(@cooldown % 60) then
         @sprite[frame].draw(@x - camera_x, @y - camera_y, z, 1, 1, Color::WHITE)
      else
         @sprite[frame].draw(@x - camera_x, @y - camera_y, z)
      end
   end

Szybkie wyjasnienie - sprawdzamy, czy jest odliczanie i czy nasz @cooldown jest pomiędzy 0-6 frame. Zmiana koloru na jedną kratkę byłaby bez sensu, bo nie dałoby się jej zauważyć. Zapewne zwróciliście również uwagę, że jako kolor daliśmy Color::WHITE zamiast wpisywać ręcznie wartości. Wcześniej nie dawałem tej informacji, ale Kolor można podać na trzy sposoby - używając Gosu::Color.new(argb/rgb), dając wartość hex: 0xaarrggbb (od 00 do FF), oraz podając właśnie stałą wartość Color::[NONE/BLACK/GRAY/WHITE/AQUA/RED/GREEN/BLUE/YELLOW/FUCHSIA/CYAN]. Osobiście wolę używać Color.new, ponieważ o wiele łatwiej nim manipulować, ale uznałem, że może wam się ta informacja przydać ;)
Jeśli teraz przetestujecie naszą minę, zobaczycie, że... nic się nie stało. Powód jest dość prosty - Gosu interpretuje całkowicie biały kolor (niezależnie w jaki sposób go podacie) jako naturalny kolor obrazka. Mamy więc dwa wyjścia: zmienić kolor błysku (czerwony również pasuje), albo użyć osobnej, jasnej grafiki. Pierwszy sposób to zwykła zamiana ostatniej wartości na "Gosu::Color::RED". Drugi, nieco więcej pisania, ale też jest bardzo łatwy. Zapiszcie tę grafikę:
Obrazek
I załadujcie ją jako '@sprite_flash', identycznie jak zaimportowaliście oryginalną grafikę. Teraz wystarczy narysować ją w odpowiednim momencie - coś, co sami będziecie w stanie zrobić ;) Przetestujcie, czy działa.

Teraz dodamy jeszcze dwie rzeczy do naszej miny. Pierwsza jest czysto "kosmetyczna". Otóż dodamy jeszcze jeden rodzaj piknięcia - w ostatniej sekundzie mina da nam znać pikając cztery razy (ostatni dźwięk jaki usłyszymy przed śmiercią :p). Całość będzie się działa w update. Usuńcie cały fragment odpowiedzialny za piknięcie (trzy linijki - warunek i wywołanie pikania) i zamieńcie go z tym:

Kod: Zaznacz cały

if @countdown then
         if @cooldown > 60 then
            if @cooldown % 60 == 0 then
               @beep.play
            end
         else
            if @cooldown % 15 == 0 then
               @beep.play
            end
         end
      end

Tym razem sprawdzamy czy odliczanie jest uruchomione. Jeśli tak, to sprawdzamy czy jest ponad, czy poniżej sekundy, i odpowiednio wywołujemy kolejne pikanie. Możecie również połączyć główny warunek z drugim, który redukuje nasz '@cooldown' - w końcu oba te warunki sprawdzają dokładnie to samo. Dajmy jeszcze efekt naszej eksplozji - na ten moment samą śmierć postaci. Animacja eksplozji przyjdzie w następnym tutorialu (hint: Particles).
Przejdźcie do SceneMap#update. Chcemy sprawdzić, czy mina wybuchła, i jeśli tak - zabić/zranić naszą postać. Na razie bez tego drugiego, ale sam print będzie wskaźnikiem, że działa ;) tak więc w warunku sprawdzającym, czy dane Entity jest miną dodajmy:

Kod: Zaznacz cały

if en.mine_exploded? then
               p "mina wybucha"
               if Gosu::distance(@player.get_x, @player.get_y, en.get_x, en.get_y) < 60 then
                  p "postac ginie"
               end
               @entities.delete(en)
            end

W miejsce "postać ginie" wstawcie '@player.respawn', i usuńcie wcześniejszy print. Na chwilę zostawmy naszą minę i zajmijmy się drugim rodzajem ładunków wybuchowych: dynamitem.




Czym się różni dynamit od miny? Dwiema rzeczami: stały czas do eksplozji (3 sekundy), oraz tym, że możemy go postawić na ziemi ;) Zacznijmy od utworzenia nowej klasy: GameDynamite. Będzie ona bardzo podobna do GameMine.

Kod: Zaznacz cały

class GameDynamite

   def initialize(x,y)
      @x = x
      @y = y
      @cooldown = 180
      @sprite = Image.load_tiles($window, "graphics/sprites/dynamite_floor.png", -4, -1, false)
      @beep = Sample.new($window, "audio/sounds/beep.wav")
      @explosion_sound = Sample.new($window, "audio/sounds/explosion.wav")
      @exploded = false
   end

   def update

   end

   def dynamite_exploded?
      return @exploded
   end

   def get_x
      return @x
   end

   def get_y
      return @y
   end

   def draw(camera_x, camera_y, z = 1)
      frame = milliseconds / 150 % @sprite.size
      @sprite[frame].draw(@x - camera_x, @y - camera_y, z)
   end

end

Jak widać, jedyną różnicą jest całkowity brak sprawdzania, czy trwa odliczanie. Po prostu jest to nam niepotrzebne, bo zaraz po postawieniu dynamitu zaczniemy odliczanie. Przejdźmy do SceneMap. W #button_down dajcie sprawdzanie, czy ID == KbRightControl. Tym przyciskiem (prawy Control) będziemy upuszczać dynamit. Tak więc:

Kod: Zaznacz cały

if id == KbRightControl then
         @entities << GameDynamite.new(@player.get_x, @player.get_y)
      end

Obrazek
Hmm... nie do końca tak chcieliśmy. Musimy wziąć pod uwagę wysokość sprita przy dodawaniu dynamitu. W GameDynamite#initialize, gdzieś na końcu metody dajcie:

Kod: Zaznacz cały

@y -= @sprite[0].height

Obrazek
Jest lepiej, ale powinniście się już domyślić nieco innego problemu. A dokładnie:
Obrazek
Jeśli skoczymy i postawimy dynamit w locie, będzie się on unosił. Tego nie chcemy (raczej), więc trzeba sprawić, żeby grawitacja wpływała na nasz dynamit. Zrobimy to w miarę podobnie do spadania naszego Gracza. Najpierw metody dla samej klasy GameDynamite:

Kod: Zaznacz cały

# Initialize
      @v_acc = 1
      @max_v_acc = 15
      
def fall
      @y += @v_acc
      @v_acc = @v_acc * 1.25
      @v_acc = @max_v_acc if @v_acc > @max_v_acc
   end

   def move_up
      @y -= 1
   end
   
def get_y(arg = :real)
      return @y if arg == :real
      return @y + @sprite[0].height if arg == :bottom
   end

Jak widzicie, dodaliśmy dwie zmienne od przyśpieszenia, metodę "fall" i "move_up" oraz zmieniliśmy nieco metodę "get_y". Nie ma w tym nic dla was niezrozumiałego, mam nadzieję ;)

Teraz w SceneMap#update:

Kod: Zaznacz cały

elsif en.is_a?(GameDynamite) then
            # Gravity effect
            if get_tile_info(en.get_x, en.get_y(:bottom), :down) == 0 then
               en.fall
            end
            while ![0,2].include?(get_tile_info(en.get_x, en.get_y(:bottom) - 1, :down)) do
               en.move_up
            end

Oczywiście ten kod idzie w blok @entities.each{}. Sprawdzamy czy dynamit powinien spaść, jeśli tak - wywołujemy metodę "fall". Dajemy też poprawkę, jeśli nasz dynamit "wbije" się w ziemię (jak pamiętacie, mieliśmy ten problem ze spritem naszej postaci kilka tutoriali temu). Pozostało nam tylko odliczanie dynamitu i jego wybuch. Wszystko jest na praktycznie tej samej zasadzie, co przy minie. Nasz GameDynamite#update:

Kod: Zaznacz cały

if @cooldown > 60 then
         if @cooldown % 60 == 0 then
            @beep.play
         end
      else
         if @cooldown % 15 == 0 then
            @beep.play
         end
      end
      @cooldown -= 1 if @cooldown > 0
      if @cooldown == 0 then
         @exploded = true
         @explosion_sound.play
      end

Oraz SceneMap#update#@entites.each{}:

Kod: Zaznacz cały

if en.dynamite_exploded? then
               if Gosu::distance(@player.get_x, @player.get_y, en.get_x, en.get_y) < 60 then
                  @player.respawn
               end
               @entities.delete(en)
            end

Od razu mówię, że przepraszam, za brak odpowiednich dźwięków dla dynamitu. Nie mogłem znaleźć nic, co by dobrze pasowało ;)

I to wszystko na dzisiaj. W następnym tutorialu (który będzie dużo szybciej, niż ostatnie dwa!) pobawimy się efektami cząsteczkowymi ;)



Archiwum winrara z efektami dzisiejszej pracy.
No matter how tender, how exquisite… A lie will remain a lie.
Awatar użytkownika
Hubertov
Posty: 248
Rejestracja: 28 paź 2013, 16:07

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Hubertov » 23 lut 2016, 21:09

Szkoda, ze zaniechales tworzenia poradnika, bo jest przystepnie napisany i na interetach nie ma takich wiele. Po zakonczeniu moglbys sobie zrobic fajny pdf - ik, na ktorym kto wie, moze bys nawet zarobil! :)
Awatar użytkownika
Adrapnikram

Golden Forki 2015 - Dema (miejsce 2) Zapowiedzi (zwycięstwo)
Posty: 611
Rejestracja: 21 kwie 2014, 14:39
Lokalizacja: Kurpsie
Kontakt:

Re: Robimy grę platformową w Ruby Gosu!

Postautor: Adrapnikram » 23 lut 2016, 23:29

Ooo... To się przyda. Ostatnio szukałem jakiegoś kursu porządnego, który pomógłby mi ogarnąć jak wszystko w Rubym wygląda. Trafiłem na pajpera, ale tam po prostu był kurs Ruby z kilkadziesiątkami stron z której większość już umiałem. Chodziło mi o coś bardziej praktycznego. Dzięki.
Zapraszam na stronę o moich grach i pixelarcie :-D

Obrazek

Wróć do „Offtopic”

Kto jest online

Użytkownicy przeglądający to forum: Obecnie na forum nie ma żadnego zarejestrowanego użytkownika i 7 gości