W poprzednim odcinku starałem się przybliżyć technikę RayCastingu, tym razem przejdziemy do implementacji.

Będziemy robić wersję uproszczoną – bez teksturowania, podawania FPS-ów, pokazywania mapy czy pozycji na mapie. Jeśli będzie taka potrzeba to zrobię kolejnego tutoriala na ten temat.

—> Kliknij tutaj aby zobaczyć efekt końcowy tego tutoriala <—-

Na sam początek pójdzie główna klasa – Renderer, jako że łatwiej mi wszystko wyjaśnić przechodząc od ogółu do szczegółu. Ustalmy sobie, że klasa ta będzie odpowiedzialna za stworzenie potrzebnych elementów w drzewie DOM, natomiast argumentem przy tworzeniu nowej klasy niech będzie mapa (w formie tablicy). Dodatkowo ustawmy obiekt Settings, który będzie przechowywał wszystkie nasze ustawienia (jak szerokość ekranu, wysokość, itp.).

function Renderer(map){
        this.map=map;
}
var Settings = {
	iloscPaskow: 320,
	polowaIlosciPaskow:160,
	wysokoscPaskow:600,
	polowaWysokosciPaskow:300,
	katWidzenia:60,
	predkoscRuchu:3,
	predkoscObrotu:3,
	szerokoscScian:50,
	radian: Math.PI/180
}

Do renderowania potrzeba nam stworzenia pionowych pasków, którymi potem będziemy manipulować zmieniając ich wielkość  (można je też wyświetlać używając HTML5 elementu <CANVAS> – przyspieszyłoby to znacznie wyświetlanie, jednak my zrobimy to na zwykłym DOM-ie). W tym celu wprowadzimy klasę Stripe (pasek), która nam w tym pomoże. Wpierw konstruktor, który stworzy odpowiedni element i nada mu odpowiednią klasę oraz doda go do dokumentu.

function Stripe(){
        this.element=document.createElement('div');
        this.element.className="stripe";
        this.element.appendChild(document.createElement('div'));
}

Przy okazji zdefiniujmy też CSS dla tego obiektu:

.stripe{
        width:2px;
        float:left;
        background-color:red;
}

W ten sposób każdy pionowy pasek naszego obszaru na którym rysujemy będzie mieć 2 piksele szerokości, oraz będzie koloru czerwonego. “Float:left” ustalamy po to aby paski stały obok siebie. Warto byłoby teraz ustalić jak wyświetlamy dane paski. Stwierdziłem że najprościej będzie ustalić linię horyzontu dokładnie pośrodku naszego pola widzenia stąd paski mogą być centrowane w pionie. W tym celu są możliwe przynajmniej dwa rozwiązania – albo pasek przesuwać o połowę wartości: (max paska-wielkość paska), lub też stworzyć dodatkowy kontener dla każdego paska, który z mechanizmów przeglądarki centrowałby element wertykalnie. Ja skorzystałem z pierwszej metody, ale oczywiście nic nie stoi na przeszkodzie by zrobić inaczej. Przesunięcie również można uzyskać na wiele sposobów. Sprawę załatwiłem, ustawiając wielkość paska oraz górny margines:

Stripe.prototype.display = function (height){
        this.element.setAttribute("style","height:"+height*2+"px;margin-top:"+(Settings.polowaWysokosciPaskow-height)+"px");
}

Jak zauważyliście zamiast dzielić wartość marginesu, zakładam, że wysokość jest już podawana o połowę mniejsza (gdyż operacja mnożenia jest generalnie szybsza i prostszą operacją do wykonania).  Dodatkowego wyjaśnienia wymaga zastosowanie setAttribute – jest to zabieg przyspieszający ustawianie większej liczby styli niż jeden (co często wymusza na przeglądarce Reflow/Redraw). Według testów jakie robiłem dawno temu ta technika spowalnia niestety troche IE, dla którego warto zastosować normalne przypisanie styli.
Tu uprzedzę trochę fakty i dodam, że zamiast do Obiektu Stripe będzie potrzeba dostać się także do jego elementu w drzewie DOM, stąd też wprowadziłem dodatkową metodę:

Stripe.prototype.getElement=function()
{
        return this.element;
}
To koniec jeśli chodzi o metody Klasy Stripe. Wróćmy więc do Klasy głównej – Renderer – i dopiszmy odpowiednie powiązania w konstruktorze:
function Renderer(map){
        this.map=map;
        this.createStripes();
}

Renderer.prototype.createStripes = function(){
  this.stripes = document.body.appendChild(document.createElement('div'));
  this.lines=[];
  var i=Settings.iloscPaskow;
  while(--i>=0)
  {
    var element=new Stripe();
    this.lines.push(element);
    this.stripes.appendChild(element.getElement());
  }
}
Wprowadźmy jeszcze klasę gracza (Player), który będzie przechowywał informacje o aktualnej pozycji na mapie oraz zajmował się obsługą klawiszy.
function Player(initX,initY,initAngle,map)
{
  this.x=initX;
  this.y=initY;
  this.direction=initAngle;
  this.map=map;
  this.registerKeys();
}

Player.prototype.registerKeys = function(){
  this.klawisze=[255];
  var self = this;
  document.onkeydown=function(e)
  {
    self.klawisze[e&&e.keyCode? e.keyCode: event.keyCode] = 1;
  }
  document.onkeyup=function(e)
  {
    self.klawisze[e&&e.keyCode? e.keyCode: event.keyCode] = 0;
  }
}

Player.prototype.updateKeys = function (){
  if (this.klawisze[39]){    //    right 39
    this.direction-=Settings.predkoscObrotu;
    if    (this.direction<0)    this.direction+=360;
  }
  if (this.klawisze[38]){    //    up 38
    var    angl=this.direction;
    var    x=Math.cos(angl*Settings.radian)*Settings.predkoscRuchu;
    var    y=Math.sin(angl*Settings.radian)*Settings.predkoscRuchu;
    if (this.map[Math.floor(this.y/50)][Math.floor((this.x+x)/50)] ==0)   this.x+=x;
    if (this.map[Math.floor((this.y+y)/50)][Math.floor(this.x/50)] ==0)   this.y+=y;
  }
  if (this.klawisze[37]){    //    left 37
    this.direction+=Settings.predkoscObrotu;
    if    (this.direction>360)    this.direction-=360;
  }
  if(this.klawisze[40]){    //    down 40
    var    angl=this.direction-180;
    var    x=Math.cos(angl*Settings.radian)*Settings.predkoscRuchu;
    var    y=Math.sin(angl*Settings.radian)*Settings.predkoscRuchu;
    if (this.map[Math.floor(this.y/50)][Math.floor((this.x+x)/50)] ==0)   this.x+=x;
    if (this.map[Math.floor((this.y+y)/50)][Math.floor(this.x/50)] ==0)   this.y+=y;
  }
}

Tutaj jest trochę więcej kodu, ale jak się przyjrzycie nie ma tam żadnej magii. Konstruktor przyjmuje wartości do zainicjowania obiektu, oraz rejestruje zdarzenia obsługiwane przy naciśnięciu lub zwolnieniu przycisku. W tym celu stworzona została tablica “klawisze”, która w formie bitowej przechowuje czy dany klawisz jest aktualnie wciśnięty czy nie (1 – jest, 0 – nie jest). Następnie ta informacja wykorzystywana jest przy funkcji updateKeys(), którą będziemy wykorzystywać podczas kolejnych odświeżeń naszego ekranu. Obiekt stworzony z klasy Player posiada informacje o jego położeniu i kierunku w którym patrzy (kąt można jeszcze inaczej opisać za pomocą wektora, jednak w dalszych obliczeniach bardziej przyda się nam zapis kątowy). W tym zapisie obracanie w lewo lub w prawo jest banalnie proste – dodajemy lub odejmujemy wartość. Sprawa komplikuje się gdy chcemy przesunąć się w danym kierunku, uwzględniając pewien kąt. W tym pomaga nam proste obliczenia z zastosowaniem Cosinusa oraz Sinusa. Przy ruchu sprawdzane jest również czy Nasza nowa pozycja nie znajduje się na ścianie – jeśli tak, przesunięcie nie dochodzi do skutku.

Wróćmy teraz z nową klasą (Player) do klasy głównej (Renderer) i dodajmy ją w konstruktorze. Wprowadzę od razu nieskończoną pętle do odświeżania naszego pola widzenia, aby już nie wracać do konstruktora Renderera:

function Renderer(map){
  this.map=map;
  this.createStripes();
  this.player=new  Player(175,425,90,this.map);
  var self=this;
  setInterval(function(){
    self.render();
  },10);
}

Już jesteśmy przy końcu naszego pierwszego RayCastera. Mamy zbudowaną całą otoczkę i brakuje nam najważniejszego – algorytmu do szukania przecięć rzucanych promieni z napotkanymi ścianami. Tym razem będę umieszczać komentarze w kodzie, gdyż tak będzie prościej wytłumaczyć każdą linię kodu.

Renderer.prototype.render  =  function()
{
    var    i=Settings.iloscPaskow;
// Iterujemy po wszystkich paskach naszego pola widzenia
    var    anglePerLine=Settings.katWidzenia/i;
// Na kazdy pasek przypada inny kąt, stąd wyznaczamy jaka jest różnica wartości kątów pomiędzy dwoma sąsiadującymi paskami
    var    actualAngle;
// Aktualny kąt jako przechowanie wartości tymczasowej
    while(--i>=0)
// Każdorazowo rysujemy wszystkie paski od nowa...
    {
        actualAngle=this.player.direction-((-Settings.polowaIlosciPaskow+i)*anglePerLine);
// actualAngle jest wartością ustalaną na podstawie kierunku patrzenia gracza oraz kąta rzucanego promienia

        if (actualAngle<0)    actualAngle+=360;
        if (actualAngle>360) actualAngle-=360;
// Dla dalszych obliczeń określamy, że aktualny kąt może sie znajdować jedynie w zakresie 0-360;

        var horizontal=(actualAngle>90&&actualAngle<270)?-1:1,  // LEFT
	vertical=actualAngle>180?-1:1; // DOWN
// Na podstawie kątów określamy czy rzucany promień rozchodzi się na lewo (ujemne x), prawo (dodatnie x), do góry (ujemne Y) czy do dołu (dodatnie Y) od obserwatora.

	var kierunkowa = Math.abs(Math.tan(actualAngle*Settings.radian)),
// Wyznaczamy współczynnik kierunkowy prostej wychodzącej z punktu obserwatora. Potrzebna jest nam ta wartość aby kolejno przecinać rzucony promień z krawędziami
		mapY    =    Math.floor(this.player.y/Settings.szerokoscScian),
		mapX    =    Math.floor(this.player.x/Settings.szerokoscScian),
// W ten sposób określamy w jakim miejscu mapy ścian się znajdujemy (pamiętając, że w tablicy ścian 1 - oznacza ścianę wielkości 50x50 na mapie)
		walkX = this.player.x,
		walkY = this.player.y,
// Zmienne te przechowują aktualną wartość kroczących wartości X oraz Y. Idea sprowadza się do sprawdzania kolejnych X lub Y bez reszty dzielących się przez 50, uwzględniając kierunkową. W ten sposób nie sprawdzamy całej przestrzeni w poszukiwaniu ścian tylko sprawdzamy możliwe ich wystąpienia w krotnościach 50.
		x,y;
		while    (this.map[mapY][mapX]    ==    0) {
// Dopoki nie jesteśmy na ścianie...
			x = horizontal>0?Settings.szerokoscScian-(walkX%Settings.szerokoscScian) : walkX % Settings.szerokoscScian;
			y = (vertical>0?Settings.szerokoscScian-(walkY%Settings.szerokoscScian) : walkY % Settings.szerokoscScian) / kierunkowa;
// Obliczamy odległość do najbliższej krawędzi obszaru 50x50 w zależności od kierunku prostej rzucanej przez promień

			if (x==0) x=Settings.szerokoscScian;
			if (y==0) y=Settings.szerokoscScian/kierunkowa;
// Gdy jesteśmy już na krawędzi - do najbliższej następnej brakuje nam maximum (czyli 50)

			if (x < y)
// Sprawdzamy czy prosta promienia przetnie się z krawędzią równoległą do OX czy do OY
                        {
				walkX+= x * horizontal;
				walkY+= (x * vertical)*kierunkowa;
				mapX+=horizontal;
// Następuje przesunięcie o odpowiednią wartość zmiennych kroczących, oraz zmiana aktualnie sprawdzanej ściany
			} else {
				mapY+=vertical;
				walkX+= y * horizontal;
				walkY+= (y*vertical)*kierunkowa;
// Następuje przesunięcie o odpowiednią wartość zmiennych kroczących, oraz zmiana aktualnie sprawdzanej ściany
			}
		}
		var length=Math.sqrt((walkX-this.player.x)*(walkX-this.player.x)+ (walkY-this.player.y)*(walkY-this.player.y));
// Skoro jesteśmy na ścianie to obliczamy odległość od znalezionego punktu

        length*=Math.cos((-Settings.polowaIlosciPaskow+i)*anglePerLine*Settings.radian);
// Korygujemy sferyczność

        length=Math.round(6000/length);
// Odwracamy wartość (chcemy uzyskać wysokość - a ona jest tym większa im obiekt jest bliżej - tymczasem odległość rośnie, stąd odwracamy tę wartość)

        if (length>Settings.polowaWysokosciPaskow) length=Settings.polowaWysokosciPaskow;
// Ustalamy, że wysokość nie może przekroczyć naszego maximum wysokości

        this.lines[i].display(length);
// Wyswietlamy aktualny pasek
    }
	this.player.updateKeys();
// Na koniec sprawdzamy klawisze. Tu też nasępuje przesunięcie gracza
}

Przyznam, że przez komentarze kod wygląda trochę nieczytelnie dlatego na koniec daję pełną wersję kodu który został zapisany w artykule w postaci jednego pliku (bez komentarzy) do przeanalizowania. Dodatkowo przyklejam obraz z poprzedniego artykułu, który jest niezbędny do zrozumienia idei przedstawionej w funkcji .render.

Pokrótce przeanalizuję co się dzieje w funkcji. Zmienne mapX oraz MapY ustawiamy na wartości odpowiadające im w tablicy ścian – czyli dla podanego wyżej obrazka będzie to mapX=2 oraz mapY=0. Wyznaczamy kierunkową dla danego kąta patrzenia, a także zmienne kroczące aktualnego X oraz Y. W pętli while sprawdzamy czy aktualnie znajdujemy się na ścianie. Jeśli tak – sprawa prosta, jeśli nie – obliczamy odległość z aktualnie obliczanego miejsca (zmienne kroczące) do najbliższego przecięcia z krawędzią (na podstawie zmiennej kierunkowej). Ponieważ krawędzie są dwie (równoległa do OX i do OY) wybieramy tą wartość, która jest mniejsza (bliższa jakiejś krawędzi). Na podstawie czy była to krawędź równoległa do OX czy OY zmieniamy odpowiednio sprawdzaną wartość aktualną w tablicy ścian, oraz aktualizujemy zmienne kroczące. W ten sposób otrzymujemy algorytm, który sprawdza kolejne krawędzie aż napotka taką, za którą stoi ściana. Na powyższym rysunku będzie wyglądało to mniej więcej tak:
Spotkanie z krawędzią równoległą do OX, potem OY, OX, OX, OY, znaleziono ścianę.
Myślę, że to wszystko co potrzebne do stworzenia pierwszego prostego RayCastera. Brakuje nam jeszcze argumentów do wywołania naszej funkcji. Dla przykładu niech będzie to:

var map    =
 [
    [1,1,1,1,1,1,1,1,1,1],
    [1,0,1,1,1,1,1,1,1,1],
    [1,0,0,0,1,0,0,0,0,1],
    [1,0,1,0,1,1,0,1,0,1],
    [1,0,1,0,1,1,0,1,0,1],
    [1,0,1,0,0,1,0,1,0,1],
    [1,0,1,0,0,1,0,0,0,1],
    [1,0,1,0,0,0,0,0,0,1],
    [1,0,0,0,1,1,1,1,1,1],
    [1,1,1,1,1,1,1,1,1,1]
];
new  Renderer(map);

Żeby ułatwić Wam testowanie umieszczam tutaj plik z całym kodem opisanym w tym artykule (bez komentarzy, aby nie zaciemniać kodu). Dodatkowe featury takie jak: wyświetlanie mapy, pozycja na mapie, FPS czy teksturowanie zostawiam już jako trywialny problem dla Was. Jeśli coś jest niejasne, proszę o komentarze – na pewno sprostuję. Dziękuje za uwagę i do następnego tutoriala!

Komentarzy: 2


  1. [...] koniec części wyjaśniającej teoretyczne podstawy. W następnej części zajmiemy się implementacją krok po [...]

  2. Lemm on 21 sty 2011

    Dzięki! Szukałem dokładnie czegoś takiego!


Skomentuj