Robimy grę platformową w Ruby Gosu!

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

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

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Rave »

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: 447
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Midleton

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 2041
Rejestracja: 15 kwie 2009, 21:33
Lokalizacja: '; DROP TABLE 'Messages'

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Rave »

Awatar użytkownika
Ekhart

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

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 2041
Rejestracja: 15 kwie 2009, 21:33
Lokalizacja: '; DROP TABLE 'Messages'

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Rave »

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: 447
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Midleton

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 447
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Midleton

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 253
Rejestracja: 28 paź 2013, 16:07

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Hubertov »

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

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

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 447
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Midleton

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 447
Rejestracja: 28 maja 2010, 10:12
Lokalizacja: Midleton

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Ekhart »

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: 253
Rejestracja: 28 paź 2013, 16:07

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Hubertov »

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
Adrap

GF2020 - Dema (miejsce 3); GF2019 - Pełne Wersje (miejsce 3) Zapowiedzi (zwycięstwo); GF2017 - Pełne Wersje (miejsce 3)(miejsce 3); GF2015 - Dema (miejsce 2) Zapowiedzi (zwycięstwo); KC I (miejsce 2); KC II (miejsce 3); TASC 4 (miejsce 2)(miejsce 3)
Posty: 815
Rejestracja: 21 kwie 2014, 14:39
Lokalizacja: Kurpsie

Re: Robimy grę platformową w Ruby Gosu!

Post autor: Adrap »

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.
Wydane:
Podziemia: Człowiek, 23 sekundy, Królobójcy, Najstarszy

Współautor gry: Amarok
(W produkcji)
ODPOWIEDZ