Wie Sie 3D-Spiele mit HTML5 Canvas und Ray Casting erstellen können.

Mit der Steigerung der Browserleistung in letzter Zeit ist es einfacher geworden, Spiele in JavaScript über einfache Spiele wie Tic-Tac-Toe hinaus zu implementieren. Wir brauchen Flash nicht mehr zu verwenden, um coole Effekte zu erzielen und mit dem Aufkommen des HTML5 Canvas-Elements ist es einfacher als je zuvor, pfiffig aussehende Webspiele und dynamische Grafiken zu erstellen. Ein Spiel oder eine Game-Engine, die wir seit einiger Zeit implementieren wollten, war eine Pseudo-3D-Engine, wie sie im alten Wolfenstein 3D-Spiel von iD Software verwendet wurde. Wir durchliefen zwei verschiedene Ansätze, zuerst den Versuch, eine „normale“ 3D-Engine mit Canvas zu erstellen und später einen Ray Casting-Ansatz mit reinen DOM-Techniken.

retro mario e

In diesem Artikel werden wir das letzgenannte Projekt dekonstruieren und die Details durchgehen, wie Sie ihre eigene Pseudo-3D Ray Casting-Engine erstellen können. Wir sprechen hier von Pseudo-3D, denn was wir im Wesentlichen erstellen, ist ein 2D-Map-/Labyrinthspiel, das wir in 3D erscheinen lassen können, solange wir einschränken, wie der Spieler die Welt sehen kann. Zum Beispiel können wir nicht zulassen, dass sich die „Kamera“ um andere Achsen als die vertikale Achse dreht. Dadurch wird sichergestellt, dass jede vertikale Linie in der Spielwelt auch als vertikale Linie auf dem Bildschirm dargestellt wird, eine Notwendigkeit, da wir uns in der rechteckigen Welt von DHTML befinden. Wir werden auch nicht zulassen, dass der Spieler springt oder kauert, obwohl dies ohne großen Aufwand umgesetzt werden könnte. Wir werden nicht zu tief in die theoretische Seite des Ray Castings einsteigen, auch wenn es ein relativ einfaches Konzept ist.

Der Artikel setzt ein angemessenes Maß an Erfahrung mit JavaScript, Vertrautheit mit dem HTML5 -Element und mindestens ein grundlegendes Verständnis der Trigonometrie voraus. Einige Dinge werden am besten durch die Codebeispiele erklärt, die Sie im gesamten Artikel finden, aber beachten Sie, dass diese Ausschnitte nicht alles abdecken.

Erste Schritte.

Wie bereits erwähnt, wird die Basis der Engine eine 2D-Map sein, so dass wir vorerst die dritte Dimension vernachlässigen und uns auf die Erstellung eines 2D-Labyrinths konzentrieren werden, um das wir herumlaufen können. Das -Element wird verwendet, um eine Draufsicht auf die Welt zu zeichnen, die als eine Art Minimap fungiert. Das eigentliche „Spiel“ besteht darin, reguläre DOM-Elemente zu manipulieren. Das bedeutet, dass alle gängigen Browser standardmäßig unterstützt werden (z.B. Firefox 2+, Opera 9+, Safari 3, IE7). Das Element wird derzeit von Firefox, Opera und Safari unterstützt, nicht aber vom Internet Explorer. Glücklicherweise können wir das mit dem ExCanvas-Projekt umgehen, einer kleinen JavaScript-Datei, die eine gewisse -Funktionalität mit VML simuliert.

Die Map.

Das erste, was wir brauchen, ist ein Map-Format. Eine einfache Möglichkeit, diese Daten zu speichern, besteht in einem Array von Arrays. Jedes Element in den verschachtelten Arrays ist eine ganze Zahl, die einem Block (2), einer Wand (1) (im Wesentlichen eine Anzahl von mehr als 0 Punkten auf eine Wand oder ein Hindernis irgendeiner Art) oder einem Freiraum (0) entspricht. Der Wandtyp wird später verwendet, um zu bestimmen, welche Textur dargestellt werden soll.

Copy to Clipboard

Auf diese Weise können wir durch die Map iterieren, indem wir jedes verschachtelte Array durchlaufen und jedes Mal, wenn wir auf den Wandtyp für einen bestimmten Block zugreifen müssen, können wir durch eine einfache Map [x] [y] Lookup zu ihm gelangen.

Als nächstes richten wir eine Initialisierungsfunktion ein, mit der wir das Spiel einrichten und starten. Zuerst greift es nach dem Minimap -Element und durchläuft die Mapdaten, wobei es farbige Quadrate zeichnet, wenn es auf einen festen Wandblock trifft. Dadurch entsteht eine Draufsicht auf die Ebene.

Copy to Clipboard

Bewegen des Players.

Jetzt haben wir das Spiel, das eine Draufsicht auf unsere Welt bietet, aber es passiert nicht viel, da wir noch keinen Spielcharakter haben, um uns zu bewegen. Wir beginnen mit dem Hinzufügen einer weiteren Funktion, gameCycle(). Diese Funktion wird einmal aufgerufen, die Initialisierungsfunktion ruft sich dann rekursiv auf, um die Spielansicht ständig zu aktualisieren. Wir fügen einige Spielervariablen hinzu, um die aktuelle (x, y)-Position innerhalb der Spielwelt sowie die Richtung, in die wir gehen, d.h. den Drehwinkel, zu speichern. Dann erweitern wir den Spielzyklus um einen Aufruf einer move()-Funktion, die sich um das Bewegen des Spielers kümmert.

Copy to Clipboard

Wir sammeln alle unsere spielerbezogenen Variablen in einem einzigen Spielerobjekt. Dies erleichtert es, die Verschiebefunktion später zu erweitern, um andere Objekte zu verschieben. Dies funktioniert gut, solange diese Objekte das gleiche „Interface“ teilen, d.h. die gleichen Eigenschaften haben.

Copy to Clipboard

Wie Sie sehen können, hängt die Bewegung und Rotation davon ab, ob die Variablen player.dir und player.speed „eingeschaltet“ sind oder nicht, d.h. sie sind nicht gleich Null. Damit sich der Spieler tatsächlich bewegen kann, benötigen wir ein paar Keybindungen, um diese Variablen zu setzen. Wir binden die Pfeile nach oben und unten, um die Bewegungsgeschwindigkeit zu steuern und nach links und rechts, um die Richtung zu ändern.

Copy to Clipboard

Der Spieler kann sich nun um das Level herum bewegen, aber es gibt ein sehr offensichtliches Problem: die Wände. Wir brauchen eine Art Collision Detection, um sicherzustellen, dass der Spieler nicht wie ein Geist durch die Wände gehen kann. Wir werden uns vorerst mit der einfachsten Lösung zufrieden geben, da eine ordnungsgemäße Collision Detection einen ganzen Artikel für sich allein in Anspruch nehmen könnte. Was wir tun werden, ist nur zu überprüfen, ob sich der Punkt, zu dem wir versuchen zu gelangen, in einem Wandblock befindet. Wenn ja, dann stoppen Sie und bewegen Sie sich nicht weiter, wenn nicht, dann lassen Sie den Spieler sich bewegen.

Copy to Clipboard

Wie Sie sehen können, überprüfen wir nicht nur, ob sich der Punkt innerhalb einer Wand befindet, sondern auch , ob wir versuchen, uns außerhalb der Ebene zu bewegen. Solange wir einen soliden „Rahmen“ aus Wänden haben, die die Ebene umgeben, sollte das niemals der Fall sein, aber wir werden ihn für alle Fälle behalten. Versuchen Sie nun Demo 3 mit der neuen Collision Detection und versuchen Sie, sich durch die Wände zu bewegen.

Ray Casting.

Da wir nun den Spielcharakter sicher durch die Welt bewegt haben, können wir anfangen, uns in die dritte Dimension zu bewegen. Um das zu erreichen, müssen wir herausfinden, was im aktuellen Sichtfeld des Spielers sichtbar ist. Zu diesem Zweck verwenden wir eine Technik mit der Bezeichnung Ray Casting. Um dies zu verstehen, stellen Sie sich vor, wie Strahlen aus dem Betrachter in alle Richtungen in ihrem Sichtfeld geschossen oder „ausgestoßen“ werden. Wenn der Strahl auf einen Block trifft (indem er eine seiner Wände schneidet), wissen wir, welcher Block bzw. welche Wand auf der Karte in diese Richtung angezeigt werden soll.

Betrachten Sie einen 320×240 Spielbildschirm, der ein 120° Sichtfeld (FOV) bietet. Wenn wir einen Strahl alle 2 Pixel ausstoßen, brauchen wir 160 Strahlen, 80 Strahlen auf jeder Seite der Richtung des Spielers. Auf diese Weise wird der Bildschirm in vertikale Streifen von 2 Pixel Breite unterteilt. Für diese Demo verwenden wir ein FOV von 60° und eine Auflösung von 4 Pixel pro Streifen, aber diese Zahlen sind leicht zu ändern.

Bei jedem Spielzyklus durchlaufen wir diese Streifen, berechnen die Richtung basierend auf der Drehung des Spielers und werfen einen Strahl, um die nächstgelegene Wand zu finden. Der Winkel des Strahls wird bestimmt, indem der Winkel der Linie vom Spieler zum Punkt auf dem Bildschirm oder der Ansicht berechnet wird.

Der knifflige Teil hier ist natürlich das eigentliche Ray Casting, aber wir können die Vorteile des einfachen Kartenformats nutzen, das wir verwenden. Da alles auf der Map auf einem gleichmäßig verteilten Raster aus vertikalen und horizontalen Linien angeordnet ist, brauchen wir nur ein wenig grundlegende Mathematik, um unser Problem zu lösen. Der einfachste Weg, dies zu tun, ist, zwei Testläufe durchzuführen, einen, bei dem wir auf Ray Collision gegen die „vertikalen“ Wände und einen weiteren für die „horizontalen“ Wände testen.

Zuerst gehen wir durch die vertikalen Streifen auf dem Bildschirm. Die Anzahl der Strahlen, die wir werfen müssen, entspricht der Anzahl der Streifen.

Copy to Clipboard

Die Funktion castRays() wird einmal pro Spielzyklus nach dem Rest der Spiellogik aufgerufen. Als nächstes kommt das eigentliche Ray Casting, wie oben beschrieben.

Copy to Clipboard

Der Test für die horizontalen Wände ist fast identisch mit dem vertikalen Test, also werden wir nicht auf dieses Teil eingehen. Wir nehmen nur die mit der kürzesten Entfernung, wenn eine Wand in beiden Läufen gefunden wird. Am Ende des Ray Castings zeichnen wir den aktuellen Strahl auf die Minimap. Dies ist nur vorübergehend und zu Testzwecken. Es braucht in einigen Browsern einiges an CPU, also werden wir das Ray Drawing entfernen, sobald wir anfangen, die 3D-Ansicht der Welt zu rendern. Wir werden den Code dafür hier nicht einfügen, aber Sie können ihn im Beispiel-Code finden.

Texturen.

Bevor wir weitermachen, lassen Sie uns einen Blick auf die Texturen werfen, die wir verwenden werden. Da unsere bisherigen Projekte stark von Wolfenstein 3D inspiriert waren, werden wir uns daran halten und eine kleine Auswahl an Wandtexturen aus diesem Spiel verwenden. Jede Wandtextur ist 64×64 Pixel groß und mit den Wandtyp-Indizes in den Maparrays ist es einfach, die richtige Textur für einen bestimmten Mapblock zu finden, d.h. wenn ein Mapblock den Wandtyp 2 hat, bedeutet das, dass wir uns den Teil des Bildes ansehen sollten, der von 64px bis 128px in vertikaler Richtung reicht. Später, wenn wir anfangen, die Textur zu dehnen, um Abstand und Höhe zu simulieren, wird das etwas komplizierter, aber das Prinzip bleibt das gleiche. Es gibt zwei Versionen jeder Textur, eine normale und eine etwas dunklere. Es ist relativ einfach, ein wenig Schatten zu fälschen, indem man alle Wände, die nach Norden oder Osten ausgerichtet sind, einen Satz Texturen und alle Wände, die nach Süden oder Westen ausgerichtet sind, den anderen Satz verwenden lässt.

Opera und Bild Interpolation.

In Opera gibt es einen kleinen Gotcha in Bezug auf die Texturwiedergabe. Es scheint, dass Opera interne Windows GDI + Methoden zum Rendern und Skalieren von Bildern verwendet und aus welchen Grund auch immer, dies zwingt opake Bilder mit mehr als 19 Farben zur Interpolation (mit einem bikubischen oder bilinearen Algorithmus). Dies kann eine solche Engine drastisch verlangsamen, da sie darauf angewiesen ist, viele Bilder mehrmals pro Sekunden ständig neu zu skalieren. Glücklicherweise kann diese Funktion in opera:config unter „Multimedia“ deaktiviert werden (deaktivieren Sie „Show Animation“, dann Save). Alternativ können Sie ihre Texturbilder entweder mit einer Palette von 20 Farben oder weniger speichern oder mindestens einen transparenten Pixel irgendwo in der Textur erstellen. Allerdings scheint es auch bei diesem letztgenannten Ansatz immer noch eine Verlangsamung zu geben, verglichen mit der vollständigen Abschaltung der Interpolation. Es kann auch die visualle Qualität der Texturen drastisch reduzieren, so dass eine solche Korrektur wahrscheinlich für andere Browser deaktiviert werden sollte.

Copy to Clipboard

Auf dem Weg zu 3D.

Es sieht momentan noch nicht sehr gut aus, aber wir haben jetzt eine solide Basis, um eine Pseudo-3D-Ansicht zu rendern. Jeder Strahl entspricht einer vertikalen Linie auf dem „Bildschirm“ und wir kennen den Anstand zur Wand, den wir in diese Richtung betrachten. Jetzt ist es an der Zeit, etwas Tapete auf die Wände zu kleben, die wir gerade mit unseren Strahlen getroffen haben, aber bevor wir das tun können, müssen wir unseren Spielbildschirm einrichten. Zuerst erstellen wir ein Container-Div-Element mit den richtigen Abmessungen.

Copy to Clipboard

Dann erstellen wir alle Streifen als Kinder dieses Elements. Die Streifenelemente sind auch Div-Elemente, die mit einer Breite erstellt werden, die der zuvor festgelegten Bandbreite entspricht und in Abständen so positioniert sind, dass sie zusammen den gesamten Bildschirm ausfüllen. Es ist wichtig, dass der Überlauf der Streifenelemente auf versteckt gesetzt ist, um die Teile der Textur, die nicht zu diesem Streifen gehören, auszublenden. Als Kind jedes Strips fügen wir nun ein Bildelement hinzu, das das Texturbild enthält. Dies alles geschieht in einer Funktion, die wir in der init() aufrufen, die wir am Anfang dieses Artikels erstellt haben.

Copy to Clipboard

Die Änderung der Wandtextur, die für einen bestimmten Streifen verwendet wird, ist nun eine Frage der Bewegung des Texturbildes nach oben und unten und wenn man es nach links/rechts bewegt, während man es dehnt, kann man einen bestimmten Teil der Textur zeichnen. Um den scheinbaren Abstand zur Wand zu kontrollieren, passen wir die Höhe des Streifenelements an und dehnen das Texturbild vertikal aus, damit die individuelle Textur der neuen Höhe entspricht. Wir behalten unseren Horizont in der Mitte des Bildschirms, so dass alles, was übrig bleibt, darin besteht, das Streifenelement nach unten in die Mitte des Bildschirms zu verschieben, abzüglich der Hälfte seiner eigenen Höhe.

Die Streifenelemente und ihre Unterbilder werden in einem Array gespeichert, so dass wir später über den Streifenindex leicht darauf zugreifen können.

Gehen wir nun zurück zur Rendering-Schleife. In den Ray Casting-Schleifen müssen wir uns nun ein wenig zusätzliche Informationen über die von uns getroffene Wand merken, nämlich den spezifischen Punkt an der getroffenen Wand und die Art der Wand. Dies ist es, was bestimmt, wie wir das Texturbild innerhalb des Streifenelements verschieben, um sicherzustellen, dass das richtige Teil sichtbar ist. Wir werfen nun den minimalen Ray Drawing-Teil von früher weg und ersetzen ihn durch den Code, der die Bildschirmstreifen manipulieren wird.

Wir haben bereits des quadratischen Abstand zur Wand berechnet, also nehmen wir die Quadratwurzel aus dem gespeicherten „Abstand“, um den tatsächlichen Abstand zur Wand zu erhalten. Dies ist zwar die tatsächliche Entfernung zu dem Punkt an der Wand, der vom Strahl getroffen wurde, aber wir müssen ihn ein wenig anpassen, damit wir nicht etwas bekommen, das allgemein als „Fish-Eye“-Effekt bezeichnet wird.

Achten Sie darauf, wie die Wände geknickt erscheinen. Glücklicherweise ist die Reparatur einfach – wir müssen nur den Abstand senkrecht zur getroffenen Wand halten. Dies geschieht durch Multiplikation des Wandabstandes mit dem Kosinus des relativen Strahlungswinkels.

Copy to Clipboard

Jetzt können wir die projizierte Wandhöhe berechnen, da die Wandblöcke Würfel sind, ist die Wandbreite in diesem einzelnen Streifen gleich, obwohl wir die Textur zusätzlich um einen Faktor entsprechend der Streifenbreite dehnen müssen, damit sie richtig dargestellt wird. Während wir in der Raycasting-Schleife auf die Wand treffen, haben wir auch den Typ der Wand gespeichert, der uns sagt, wie weit wir das Texturbild nach oben verschieben müssen. Wir multiplizieren diese Zahl im Grunde genommen mit der projizierten Wandhöhe und das ist alles. Schließlich, wie bereits beschrieben, bewegen wir einfach das Streifenelement und sein Bild an seinen Platz.

Copy to Clipboard

Wir sind noch nicht ganz fertig, weil es noch einige Dinge zu tun gibt, bevor wir das Ergebnis als Spiel bezeichnen können, aber die erste große Hürde ist genommen und es gibt eine 3D-Welt, die erweitert werden kann. Das Letzte, was wir brauchen, ist, einen Boden und eine Decke hinzuzufügen, aber dieser Teil ist trivial, wenn wir beide eine einheitliche Farbe geben. Fügen Sie einfach zwei div-Elemente hinzu, die jeweils die Hälfte der Bildschirmfläche einnehmen. Positionieren Sie sie unter den Streifen mit dem Z-Index und färben Sie sie nach Bedarf.

Ideen für die Weiterentwicklung.

  • Trennen Sie die Darstellung vom Rest der Spiellogik (Bewegung etc.) Bewegung und andere Dinge sollten unabhängig von der Framerate sein.
  • Optimierung – mehrere Stellen können optimiert werden, um kleine Leistungssteigerungen zu erzielen, d.h. die Style-Eigenschaften auf den Strips nur dann setzen, wenn sie sich tatsächlich geändert haben und dergleichen.
  • Statische Sprites – das Hinzufügen der Fähigkeit, statische Sprites (wie Lampen, Tische, Pickups etc.) zu rendern, würde die 3D-Welt viel interessanter machen.
  • Feinde/NSCs – wenn die Engine in der Lage ist, statische Sprites zu rendern und sich um mindestens eine Entität bewegen kann, sollten wir auch in der Lage sein, diese beiden mit einer einfachen KI zu kombinieren, um die Welt zu bevölkern.
  • Bessere Bewegungsführung und Kollisionserkennung – die Spielerbewegung ist eher grob, d.h. der Spieler kommt zum Stillstand, sobald Sie die Taste loslassen. Ein wenig Beschleunigung im Umgang mit Bewegung und Rotation würde für ein reibungsloseres Erlebnis sorgen. Die aktuelle Collision Detection ist etwas brutal – der Spieler wird einfach tot aufgehalten. Die Möglichkeit, entlang der Wände zu gleiten, wäre eine große Verbesserung.
  • Sounds – mit einer Flash/JavaScript-Soundbrücke wie Scott Schills SoundManager2 wäre es einfach, Soundeffekte zu verschiedenen Ereignissen hinzuzufügen.

Nun haben wir eine Grundkarte für de Spieler erstellt, in der er herumlaufen kann und eine Pseudo-3D-Darstellung der Spielwelt mit Raycasting-Techniken. Im Folgenden werden wir zuerst die Codebasis verbessern, indem wir den Rendering-Prozess optimieren, um eine bessere Leistung zu erzielen und die Collision Detection zwischen dem Spieler und den Wänden zu verbessern. Desweiteren werden wir statische Sprites implementieren, um der Burg ein wenig Athmosphäre zu verleihen und schließlich ein oder zwei Feinde hinzuzufügen.

Optimierung.

Lassen Sie uns ohne weiteres mit der Optimierung der bestehenden Codebasis fortfahren.

Splitten des Renderings und der Spiellogik.

Bisher haben wir aus Gründen der Einfachheit Rendering und Spiellogik im selben Timer zusammengeführt. Das erste, was wir tun werden, ist, das in zwei Teile zu teilen. Das bedeutet, dass Sie das Raycasting und Rendering aus der gameCycle-Funktion herausziehen und einen neuen RenderCycle erstellen. Die schwere Arbeit wird während des Renderings erledigt und wird sich immer auf die Spielgeschwindigkeit auswirken, aber wenn wir sie aufteilen, können wir zumindestens eine etwas bessere Kontrolle über die Geschwindigkeit, mit der diese beiden Komponenten laufen, erlangen und Sie auf Wunsch mit unterschiedlichen Frameraten laufen lassen. Der gameCycle könnte beispielsweise mit einer festen Anzahl von Malen pro Sekunde laufen, während der Renderingzyklus einfach so oft wie möglich läuft. Wir werden versuchen, sicherzustellen, dass beide eine Rate von 30 Bildern pro Sekunde einhalten.

Copy to Clipboard

In der Funktion gameCycle kompensieren wir die durch die Rendering-Funktionen verursachte Verzögerung, indem wir die Zeit seit dem letzten Aufruf von gameCycle mit der idealen gameCycleDelay-Zeit verglich. Anschließend stellen wir die Verzögerung für den nächsten setTimeout-Aufruf entsprechend ein.

Diese Zeitdifferenz wird nun auch beim Aufruf der Move-Funktion (die sich um die Bewegung unseres Spielers kümmert) verwendet.

Copy to Clipboard

Wir können nun die timeDelta Time verwenden, um festzustellen, wie viel Zeit vergangen ist, im Vergleich zu wie viel hätte vergangen sein sollen. Wenn Sie Bewegung und Rotation mit diesem Faktor multiplizieren, bewegt sich der Spieler mit einer konstanten Geschwindigkeit, auch wenn das Spiel nicht mit perfekten 30 fps läuft. Beachten Sie, dass ein Nachteil dieses Ansatzes darin besteht, dass, wenn es genügend Verzögerung gibt, das Risiko besteht, dass der Spieler in der Lage sein wird, durch eine Wand zu gehen, es sei denn, wir erhalten entweder eine bessere Collision Detection oder ändern den gameCycle, so dass die Bewegung mehrmals aufgerufen wird und an der Verzögerung abplatzt.

Da sich die gameCycle-Funktion nur noch um die Spiellogik kümmert (vorerst nur um das Bewegen des Spielers), wurde ene neue renderCycle-Funktion mit gleichzeitiger Verwaltung der Maßnahmen entwickelt. Überprüfen Sie den Beispielcode, um diese Funktion zu sehen.

Optimierung des Renderings.

Als nächstes werden wir den Rendering-Prozess ein wenig optimieren. Für jeden vertikalen Streifen verwenden wir derzeit div-Elemente mit overflow:hidden, um die Teile des Texturbildes auszublenden, die nicht an jedem Punkt angezeigt werden müssen.Wenn wir stattdessen CSS-Clipping verwenden, können wir diese zusätzlichen div-Elemente loswerden, in diesem Fall müssen wir nur halb so viele DOM-Elemente in jedem Renderingzyklus manipulieren.

Einige Browser (Opera) werden auch etwas besser funktionieren, wenn wir das große Texturbild in kleinere Bilder schneiden, die jeweils eine Wandtextur enthalten. Wir werden ein Flag hinzufügen, um zwischen der Verwendung eines einzelnen großen Texturbildes und der Verwendung separater Bilder zu wechseln. Durch das Zuschneiden der Textur in kleinere Bilder können Sie auch schönere Texturen für Opera erhalten, ohne die 19 Farben-Grenze zu überschreiten, über die wir bereits gesprochen haben, da die Texturen nicht mehr die gleichen wenigen Farben teilen müssen. Die ursprünglichen Wolfenstein 3D-Texturen verwendeten jeweils nur 16 Farben, so dass wir jetzt mehr als genug haben. Firefox scheint viel besser mit dem großen, monolithischen Texturbild zu funktionieren, also werden wir diese Funktionalität beibehalten und automatisch mit ein wenig schmutzigem Browser-Sniffing umschalten.

Es gibt auch einiges zu gewinnen, wenn Sie die Style-Eigenschaften der Streifen nur dann einstellen, wenn sie sich tatsächlich ändern. Wenn Sie sich auf der Ebene bewegen, ändern sich die Positionen, Abmessungen und Ausschnitte aller Streifen, aber sie ändern sich nicht unbedingt alle, wenn Sie seit dem letzten Rendering-Aufruf nur eine kleine Menge bewegt oder gedreht haben. Deshalb werden wir jedes Stripe-Element mit einem oldStyles-Objekt erweitern, mit dem wir die neuen Werte beim Rendern vergleichen kann, bevor wir die tatsächlichen Style-Eigenschaften einsetzen.

Daher müssen wir zunächst unsere initScreen-Funktion ändern, die sich um die Erstellung der Streifenelemente kümmert. Anstatt Div-Elemente mit img-Childs zu erstellen, erstellt der Code nun nur noch img. Die neue initScreen-Funktion sieht so aus:

Copy to Clipboard

Sie können sehen, wie nur ein DOM-Element (ein img) pro Streifen erstellt wird und wie wir ein Pseudo-Objekt erstellen, um die aktuellen Werte zu speichern.

Als nächstes werden wir die Funktion castSingleRay modifizieren, um mit diesen neuen Streifenobjekten zu arbeiten. Um CSS-Clipping anstelle von Div-Masking zu verwenden, müssen Sie eigentlich keinen der Werte ändern. Sie werden nur verwendet, um verschiedene Style-Eigenschaften festzulegen. Anstatt eine rechteckige Maske mit dem div zu erstellen, verwenden wir nun die Eigeschaft clip, um eine Clipping-Maske zu erstellen.

Das Bild muss nun relativ zum Bildschirm und nicht mehr relativ zum enthaltenen div positioniert werden, also füge ich einfach die früher als div bezeichnete Position zur Position des Bildes hinzu. Die Position und die Abmessungen des div werden dann verwendet, um das Clipping-Rechteck zu definieren.

Im folgenden Code können Sie auch sehen, wie die neuen Werte mit den Werten des alten Styles verglichen werden, bevor Sie die aktuellen Elementstile berühren.

Copy to Clipboard

Collision Detection.

Werfen wir jetzt einen Blick auf die Collision Detection. Weiter oben haben wir das Problem gelöst, indem wir den Spieler einfach gestoppt haben, wenn sich dieser in eine Wand bewegt hat. Während dies sicherstellt, dass man nicht durch Wände gehen kann, fühlt es sich nicht sehr elegant an. Zuerst einmal wäre es schön, ein wenig Abstand zwischen dem Spieler und den Wänden zu halten, sonst kann man sich so nah bewegen, dass die Texturen supergedehnt werden, was nicht sehr schön aussieht. Zweitens sollten wir in der Lage sein, entlang der Wände zu gleiten, anstatt jedes Mal, wenn man eine Wand berührt, in einen toten Punkt zu kommen.

Um das Entfernungsproblem zu lösen, müssen wir uns etwas anderes einfallen lassen, als nur die Position des Spielers anhand der Map zu überprüfen. Eine Lösung besteht darin, sich den Spieler einfach als Kreis und die Wände als Liniensegmente vorzustellen. Indem Sie sicherstellen, dass der Kreis keines der Liniensegmente schneidet, wird der Spieler immer auf einem Abstand von mindestens dem Radius dieses Kreises gehalten.

Glücklicherweise beschränkt sich die Map auf das einfache rasterbasierte Layout, so dass unsere Berechnungen recht einfach gehalten werden können. Konkret müssen wir nur sicherstellen, dass der Abstand zwischen dem Spieler und dem nächstgelegenen Punkt auf jeder umgebenden Wand gleich oder größer als der Radius ist, und da die Wände aufgrund ihrer Ausrichtung auf dem Gitter alle horizontal oder vertikal sind, wird die Entfernungsberechnung trivial.

Also werden wir die alte isBlocking-Funktion durch eine neue checkCollision-Funktion ersetzen. Anstatt einen wahren oder falschen Wert zurückzugeben, der angibt, ob der Spieler die gewünschte Position anfahren kann oder nicht, gibt diese Funktion die neu eingestellte Position zurück. Die isBlocking-Funktion wird innerhalb der checkCollision-Funktion weiterhin verwendet, um zu überprüfen, ob eine bestimmte Kachel fest ist oder nicht.

Copy to Clipboard

Der Spieler kann nun problemlos an den Wänden entlang gleiten und einen Mindestabstand zwischen ihnen und den Wänden einhalten, so dass sowohl die Leistung als auch die Bildqualität auch in Wandnähe angemessen sind. Probieren Sie den neuen Wall Collision Code aus:

Sprites.

Nachdem das aus dem Weg ist, lassen Sie uns nun ein wenig mehr Details in die Welt einbringen. Bisher waren es nur offene Flächen und Wände, also ist es an der Zeit, dass wir eine Inneneinrichtung machen.

Zuerst werden wir die verfügbaren Elementtypen definieren. Sie kann mit einem einfachen Array von Objekten geschehen, das zwei Informationen enthält, dem Pfad zum Bild und einen booleschen Wert, der definiert, ob dieser Elementtyp den Spieler daran hindert, es zu durchlaufen.

Copy to Clipboard

Anschließend werden wir ein paar davon auf die Map legen. Auch hier ist die Datenstruktur ein Array von einfachen Objekten.

Copy to Clipboard

Wir haben ein paar Lampen um das Schloss herum hinzugefügt und ein Esszimmer am unteren Rand der Map eingerichtet. In der Zip-Datei, die am Anfang des Artikels verlinkt ist, finden Sie auch Sprites für eine Pflanze und eine Rüstung, mit der Sie herumspielen können, wenn Sie möchten.

Jetzt erstellen wir eine initSprites-Funktion, die von der init-Funktion zusammen mit initScreen und dem anderen Initialisierungscode aufgerufen wird. Diese Funktion erstellt ein zweidimensionales Array, das der Map entspricht und füllt es mit den oben im MapItems-Array definierten Sprite-Objekten. Die Sprite-Objekte erhalten außerdem einige zusätzliche Eigenschaften: ihr img-Element, ein sichtbares Flag und die bereits erwähnten Sperrinformationen.

Copy to Clipboard

So können wir jetzt eine einfache spriteMap[x][y] Suche überall auf der Map durchführen und überprüfen, ob es ein Sprite in dieser Kachel gibt. Wie Sie im obigen Code sehen können, haben wir alle img-Elemente als Kinder des Bildschirmelements hinzugefügt. Der Trick besteht nun darin, festzustellen, welche sichtbar sind und wohin sie auf dem Bildschirm gehen sollen. Tippen Sie dazu auf die Raycasting-Funktion castSingleRay:

Copy to Clipboard

Wie Sie sich vielleicht erinnern, wird diese Funktion einmal pro Frame für jeden der vertikalen Streifen auf dem Bildschirm aufgerufen. Wenn die Strahlen geworfen werden, bewegt er sich in Schritten nach außen, die sicherstellen, dass er alle Fliesen berührt, die der Strahl durchläuft, so dass ich einfach bei jedem Schritt gegen die Sprite-Map prüfen und überprüfen kann, ob es dort ein Sprite gibt. Wenn ja, wird die Sichtbarkeit des Sprites umgeschaltet (wenn wir es noch nicht getan haben) und es wird dem visibleSprites-Array hinzugefügt. Dies geschieht natürlich sowohl für den horizontalen als auch für den vertikalen Lauf.

Im renderCycle fügen wir nun zwei neue Aufrufe hinzu, einen zum Löschen der Liste der sichtbaren Sprites und einen zum Rendern der neu markierten sichtbaren Sprites. Das erste wird vor dem Raycasting gemacht und Letzeres anschließend.

Copy to Clipboard

Die clearSprites-Funktion ist ziemlich einfach.

Copy to Clipboard

Und nun zum Schluss möchten wir unsere Aufmerksamkeit auf das eigentliche Rendering der Sprites richten. Wir werden alle Sprites durchlaufen, die während des Raycasting gefunden wurden, d.h. diejenigen, die sich jetzt im visibleSprites-Array befinden. Für jedes sichtbare Sprite übersetze ich zuerst seine Position in den Betrachterraum, so dass wir seine Position relativ dazu haben, wo sich der Spieler befindet. Betrachten Sie, dass 0,5 zu den x- und y-Koordinaten addiert wird, um die Mitte der Kachel zu erhalten. Wenn wir einfach die x und y des Sprites verwenden, erhalten wir die linke obere Ecke des Map Tiles. Ausgehend vom akuellen Drehwinkel des Spielers wird der Rest mit einer einfachen Trigonometrie berechnet.

Copy to Clipboard

Optional kann ein ähnlicher Ansatz wie beim Raycasting mit einem oldStyles-Objekt auch für Sprites implementiert werden, was möglicherweise ein wenig mehr Leistung bringt. Wie auch immer, jetzt sind die Sprites korrekt auf dem Bildschirm platziert und es werden nur die angezeigt, die sich in der Sicht des Spielers befinden.

Wenn wir die Wände und Sprites Pixel für Pixel zeichnen würden, müssten wir diese Objekte nach der Entfernung sortieren und zuerst die entferntesten zeichnen, um Objekte, die vom Rendern vor näheren Objekten ausgeschlossen werden sollten, zu erhalten. Glücklicherweise ist die Situation viel einfacher, da es sich um HTML-Elemente handelt. Damit haben wir ein leistungsfähiges Tool zur Lösung dieses Problems, die CSS Z-Index-Eigenschaft. Wir können die Z-Index-Eigenschaft einfach auf einen Wert setzen, der proportional zum Abstand zum betreffenden Sprite oder Wandstreifen ist. Dann kümmert sich der Browser um den Rest und erspart uns das Sortieren.

Copy to Clipboard

Und jetzt sind die Sprites und Wände in der richtigen Reihenfolge geschichtet. Da ein hoher Z-Index bedeutet, dass das DOM-Element über den niedriger indizierten Elementen angezeigt wird, verwenden wir den negativen Wert der Entfernung. Da die Abstände numerisch eher klein sind, multiplizieren wir auch mit 1000 (oder einer anderen hohen Zahl), um ausreichend unterschiedliche Ganzzahlen zu erhalten.

Schließlich wird die isBlocking-Funktion so geändert, dass auch blockierende Sprites berücksichtigt werden, so dass Sie nicht durch die Tabellen laufen können.

Copy to Clipboard

Feinde.

Als nächstes werden wir die ganze Geschichte noch ein wenig aufpeppen. Dafür müssen wir eine zweite Art von Sprite hinzufügen, eines, das in der Lage ist, sich in der Ebene auf die gleiche Weise wie der Spieler zu bewegen.

Wir werden die Feindtypen und die Positionen der Feinde auf der Map so definieren, wie wir es bereits bei den statischen Sprites getan haben. Jeder gegnerische Typ hat einige Eigenschaften wie Bewegungsgeschwindigkeit, Drehgeschwindigkeit und die Gesamtzahl der „Zustände“. Die Zustände entsprechen jedem Bild in dem oben gesetzten Sprite – so steht ein Feind im Zustand 0 still, während ein Feind im Zustand 10 tot auf dem Boden liegt. In diesem Artikel werden wir nur die ersten 5 Zustände verwenden, um die Wachen dazu zu bringen, uns auf der Map zu verfolgen. Wir werden den Kampf für einen weiteren Tag aufheben.

Copy to Clipboard

Als nächstes benötigen wir eine initEnemies-Funktion, die zusammen mit dem Rest von init aufgerufen wird. Diese Funktion funktioniert ein wenig wie die initSprites-Funktion, die wir gerade erstellt haben, aber sie ist auch in vielerlei Hinsicht anders. Während die statischen Sprites alle an eine bestimmte Kachel auf der Map gebunden sein könnten, können die Feinde natürlich frei hingehen, wohin sie wollen, so dass wir nicht die gleiche zweidimensionale Mapstruktur verwenden können, um ihre Positionen zu speichern. Stattdessen nehmen wir den einfachen Ausweg und halten einfach alle Feinde in einem einzigen Array, auch wenn das bedeutet, dass wir dieses Array auf jedem Frame durchlaufen müssen, um zu bestimmen, welche wir rendern sollten. Da wir es nicht mit vielen Feinden zu tun haben werden, sollte das vorerst kein allzu großes Problem sein.

Copy to Clipboard

Auf diese Weise wie bei den Sprites, werden wir für jeden Feind ein Bildelement erstellen und dem feindlichen Objekt einige zusätzliche Informationen hinzufügen. Das nächste, was wir tun müssen, ist, eine renderEnemies-Funktion zu erstellen, die vom renderCycle aufgerufen wird. Die Grundidee hier ist, die Feinde zu durchlaufen und festzustellen, ob sie vor uns stehen, indem man den relativen Winkel zwischen ihnen und der Richtung, in die wir schauen, betrachtet (wir sollten dafür eigentlich das Sichtfeld verwenden). Wenn Sie es sind, wird der Code ihn auf die gleiche Weise darstellen wie die Sprites. Wenn Sie nicht vor uns sind, verbirgt unser Code einfach die Sprite-Bilder. Siehe die Codekommentare unten für weitere Details.

Copy to Clipboard

Wie Sie sehen können, wird das oldStyles-Objekt wieder verwendet, um sicherzustellen, dass die Style-Eigenschaften nur gesetzt werden, wenn sich die Werte tatsächlich geändert haben. Die x-Position auf dem Bildschirm wird bestimmt, als wäre es ein statisches Sprite, erst jetzt berücksichtige ich den aktuellen Zustand des Sprites. Wenn der aktuelle Zustand beispielsweise 3 (Teil des Gehzyklus) ist, wird das Sprite-Bild 3 * sprite_size nach links positioniert. Ein CSS-Clipping-Rechteck stellt anschließend sicher, dass nur der aktuelle Zustand sichtbar ist.

In gameCycle fügen wir einen Aufruf zur AI-Funktion hinzu, die sich um die Auswertung der gegnerischen Aktionen kümmert. Als nächstes werden wir eine kleine Änderung an der Move Funktion vornehmen. Bis jetzt war es an das Player-Objekt gebunden, also ändern wir es so, dass es zwei Argumente braucht, das timeDelta, das wir zuvor eingeführt haben und eine neue Entität, das ist jedes Objekt, das die Eigenschaften hat, die benötigt werden, um es zu verschieben (z.B. x, y, moveSpeed, rot etc.). Die Move-Funktion wird anschließend so modifiziert, dass dieses Objekt anstelle des Player-Objekts verwendet wird und unser Aufruf im gameCycle wird entsprechend geändert. Das bedeutet, dass wir jetzt die gleiche Funktion verwenden können, um andere Dinge zu bewegen – wie Feinde.

Copy to Clipboard

Nun zur eigentlichen AI-Funktion. Für jeden Feind berechnen wir die Distanz zum Spieler und wenn sie über einen bestimmten Wert liegt (wir haben eine Distanz von 4 verwendet), wird der Feind dazu gebracht, den Spieler zu verfolgen. Wir tun das, indem wir die Rotation des Gegners gleich dem Winkel zwischen ihm und dem Spieler setzen und seine Geschwindigkeit auf 1 stellen, anschließend nennen wir den gleichen Zug, den wir früher verwendet haben, um den Spieler zu bewegen, natürlich nur jetzt mit mit dem feindlichen Objekt statt dem Spieler. Die gleichen Kollisionsregeln und so etwas gelten, da es dem Zug egal ist, was wir bewegen.

Copy to Clipboard

Hier setzen wir auch die oben in der Funktion renderEnemies verwendete State Property. Wenn sich der Feind nicht bewegt, ist der Zustand einfach 0 (das „Standbild“). Wenn sich der Feind bewegt, dass lassen wir ihn die Zustände 1 bis 4 durchlaufen. Durch die Verwendung des % (modulo) Operators auf die aktuelle Zeit mit der Zeit für einen kompletten Gehzyklus als Divisor haben wir einen schönen zeitbasierten Gehzyklus.

Nun laufen die Wachen dem Spieler hinterher, bis sie sich in einer bestimmten Entfernung befinden. Zugegeben, dies ist noch nicht die fortgeschrittenste KI, aber es ist ein Anfang. Der Versuch, sie in Kurven einzufangen, macht Spaß, jedenfalls für ein paar Minuten.

Ausblick.

Danke fürs Lesen – wir hoffen, Sie hatten bisher Spaß gehabt. Falls Sie noch Fragen haben sollten, können Sie sich gerne an unsere Fachexperten in unserem Forum wenden.

Vielen Dank für ihren Besuch.