HTML 5 - Spiel programmieren (Live Demo)

HTML 5 - Spiel programmieren (Live Demo)

Finale

Unser momentaner Stand

screen2.png

Abfangen von Tastatur und Maus Eingaben

Wir erweitern unsere init Funktion um folgende Zeilen

window.addEventListener("keydown", function(e) { keysDown[e.keyCode] = true; }, false);
window.addEventListener("keyup", function(e) { delete keysDown[e.keyCode]; }, false);
window.addEventListener("click", shoot, false);

Das keydown Event wird ausgelöst, solange eine Taste gedrückt ist. Solange dies der Fall ist, wird in dem keysDown Array der Tasten Code gespeichert. Sobald das keyup Event ausgelöst wird, also die Taste losgelassen wird, wird der jeweilige Tasten Code aus dem keysDown Array entfernt. So haben wir in dem Array alle Tasten, welche momentan gedrückt werden. Warum wir nicht einfach direkt die Tasteneingaben verwerten und stattdessen diese Technik verwenden hängt damit zusammen, dass wir später in der Spiellogik Funktion die zugehörige Tastatureingaben Logik auch der Frametime entsprechend verarbeiten wollen. Das click Event wird, wie der Name schon sagt, ausgelöst sobald ein Mausklick stattfindet. Sollte dies der Fall sein, wird die Funktion shoot aufgerufen, um welche wir uns jetzt kümmern.

Feuern des Feuerballs in Mausrichtung

 Angenommen unser Mauszeiger befindet sich vom Charakter aus, rechts oben: mouse_screen.png Wie schaffen wir es nun, dass wir unseren Feuerball in diese Richtung bewegen? Das ist simpel. Wir brauchen den Winkel. Stellen wir uns also vor, dass wir ein Dreieck haben.

mouse_screen_tri.png

Die Hypotenuse, also die längste Seite des Dreiecks, bildet sich aus der Position des Charakters und der Position des Mauszeigers, genauer der Differenz. Um nun den Winkel Alpha (gelbes a) zu erhalten benötigen wir den Arkustangens, welchen wir mithilfe der atan2 Methode erhalten. Dieser Methode müssen wir die Differenz als Parameter übergeben.

Wichtig ist, dass bei dieser Methode zuerst Y und dann X angegeben wird.
So sieht dann unsere shoot Funktion aus:

function shoot(e)
{
    if(character.shooting) return;
    
    character.shooting = true;
    
    shot.posX = character.posX;
    shot.posY = character.posY;
    
	var x = e.clientX - character.posX;
    var y = e.clientY - character.posY;

	var angle = Math.atan2(y, x);

	shot.dirX = Math.cos(angle);
    shot.dirY = Math.sin(angle);
}

Wir haben den Parameter e, der die Mausklick Position beeinhaltet. Wir prüfen in der Funktion, ob der Feuerball schon aktiv ist. Im Spiel soll es immer nur einen aktiven Feuerball auf dem Bildschirm geben. Falls einer noch aktiv ist, brechen wir die Fortsetzung der Funktion mit einem einfachen return ab. Falls der Feuerball nicht aktiv ist, können wir ihn abfeuern. Zuerst setzen wir die Start Position des Feuerballs auf die momentane Position des Charakters, damit dieser nicht oben links bei (0, 0) startet.

Dann berechnen wir die Differenz zwischen Charakter Position und Mauszeiger Position, die Hypotenuse. Die erhaltenen Werte setzen wir nun in die atan2 Methode ein um den erwähnten Winkel Alpha zu erhalten. Mit diesem Winkel und mithilfe von Kosinus und Sinus können wir nun ganz einfach die Bewegungsrichtung auf der X und Y Achse unseres Feuerballs berechnen. Später müssen wir diese nur noch mit der Geschwindigkeit unseres Feuerballs multiplizieren.

Achtung: Sollte sich das Canvas Element nicht an der Position (0, 0) befinden, sondern ist an einer anderen Stelle des Dokuments, muss die Maus Position relativiert werden. Das geht, indem wir folgende Zeilen bearbeiten:

var x = (e.clientX - canvas.getBoundingClientRect().left) - character.posX;
var y = (e.clientY - canvas.getBoundingClientRect().top) - character.posY;

Berechnen der Frametime

Die Frametime ist simpel ausgedrückt die Zeit, welche benötigt wird um 1x unsere Spielschleife zu verarbeiten, also um einmal das Spiel zu zeichnen und die Logik auszuführen. Der Sinn der Frametime wird deutlich, wenn wir uns folgende Frage stellen: Was würde passieren, wenn das Spiel auf verschiedenen Geräten unterschiedliche bzw. inkonstante FPS aufweist? infofps.png

Ganz einfach, 10 Frames pro Sekunde (10fps) bedeutet dass unsere Spielschleife 10x die Sekunde aufgerufen wird. 30fps bedeuten somit, dass unsere Spielschleife 30x die Sekunde aufgerufen wird und bei 60fps, 60x. Auf dem ersten Rechner wird also nur 10x, auf dem anderen 30x und auf dem schnellsten 60x pro Sekunde multipliziert. Auf dem langsamen Rechner wäre somit unser Charakter sehr langsam, während auf dem schnellen Rechner unser Charakter nach einer Sekunde schon außerhalb des Sichtbereichs wäre. Die Frage ist nun, wie umgehen wir dieses Problem?

Wir müssen einfach dafür sorgen, dass wir festlegen können, wieviel Pixel in einer Sekunde insgesamt hinterlegt werden. Und das schaffen wir mit einer einfachen Rechnung. Wir nehmen die Frametime des letzten Frames und multiplizieren diese mit der Geschwindigkeit. Bei konstanten 60 Frames pro Sekunde, dürfte jeder Frame ~0,017 Sekunden benötigen. Angenommen aber die fps brechen ein, der Rechner wird langsamer oder schafft die 60fps gar nicht und der erste Frame würde deshalb 0,020s, der darauf folgende vielleicht 0,024s (usw.) benötigen. Durch die einfache Multiplikation bewegt sich der Charakter dann je nachdem, wie sich die Frametime verhält, in einem Frame mal schneller und mal langsamer, um genau diese schwankende fps auszugleichen.

Frametime

Bewegung

Bei 0,017s

5,1px

Bei 0,020s

6px

Bei 0,024s

7,2px

Dadurch ist die hinterlegte Strecke, nach einer Sekunde, auf allen Rechnern gleich.

Die endgültige Version der gameLoop sieht dann wie folgt aus:

function gameLoop()
{
    var now = Date.now();
    var frametime = (now - frametimeBefore) / 1000;
    
    logic(frametime);
    draw();
    
    frametimeBefore = now;  
}

Das teilen durch 1000 dient dazu, die Millisekunden in Sekunden umzurechnen. Wir übergeben der logic Funktion die Frametime, um welche wir uns gleich kümmern. Davor kümmern wir uns aber noch um das generieren der Gegner.

Gegner generieren

function generateEnemies()
{
    ++level;
    
    for(var i = 0; i < level * 3; ++i)
    {
        var ranX = Math.floor(Math.random() * (canvas.width * 3)) - canvas.width;
        var ranY = Math.floor(Math.random() * (canvas.height * 3)) - canvas.height;
        
        enemies[enemies.length] = { img: enemyImg, posX: ranX, posY: ranY};
    }
}

Wir erhöhen das aktuelle Level bei jedem Aufruf von generateEnemies. Mithilfe einer Zähschleife generieren wir die Gegner. Desto höher das Level, desto mehr Gegner. Im ersten Level 3 Gegner, im zweiten 6, im dritten 9 usw. Wir berechnen eine zufällige Position, die sich im und außerhalb des Sichtbereiches befinden kann. Zum Abschluss erweitern wir das enemies Array um ein neues Gegner Objekt, das aus den zufälligen Position und einer Kopie des Gegner Image Objekts besteht.

In der init Funktion müssen wir jetzt nur noch generateEnemies aufrufen.

generateEnemies();

Die Spiellogik

Vielleicht auch interessant
Gether - Final (Full Code)
Gether - Final (Full Code)

Gether - Kooperatives Multiplayer Game - Full Code

Kommen wir zum Herzstück des Spiels, der logic Funktion

function logic(frametime)
{
    if(87 in keysDown) character.posY -= character.speed * frametime; // W
    if(65 in keysDown) character.posX -= character.speed * frametime; // A
    if(83 in keysDown) character.posY += character.speed * frametime; // S
    if(68 in keysDown) character.posX += character.speed * frametime; // D

    // ...
}

Wir prüfen ob die Tastencodes von W, A, S, D im keysDown Array sind und bewegen den Charakter entsprechend der Eingabe um 300 Pixel die Sekunde. Diese Bewegung ergibt sich, wie beim Abschnitt "Berechnen der Frametime" erklärt, aus der Geschwindigkeit multipliziert mit der Frametime.

Nun bewegen wir unseren über die Funktion shoot initiierten Feuerball:

if(character.shooting)
{
   shot.posX += shot.dirX * shot.speed * frametime;
   shot.posY += shot.dirY * shot.speed * frametime;

   if(shot.posX < 0 || shot.posX > canvas.width || shot.posY < 0 || shot.posY > canvas.height)
   {
      character.shooting = false;        
   }
}

Dazu nehmen wir die in der shoot berechneten Richtung, multiplizieren diese mit der Schussgeschwindigkeit und dann wieder mit der Frametime und erhalten dann die Verschiebung in Pixel für den aktuellen Frame. Wir prüfen außerdem, ob unser Feuerball noch im Sichtbereich, also in unserem Canvas ist.

Dann iterieren wir durch das enemies Array und rufen die Gegnerlogik für jeden Gegner auf

for(var i = 0; i < enemies.length; ++i)
{
   enemyLogic(i, frametime);
}

Anschließend prüfen wir, ob alle Gegner besiegt worden sind, also ob das enemies Array leer ist und generieren dann gegebenfalls die nächste Gegnerwelle.

if(enemies.length == 0) generateEnemies();

Das war unsere logic Funktion. Jetzt benötigen wir nur noch die enemyLogic Funktion

function enemyLogic(i, frametime)
{
    var x = character.posX - enemies[i].posX;
    var y = character.posY - enemies[i].posY;

    var angle = Math.atan2(y, x);

    enemies[i].posX += Math.cos(angle) * 200 * frametime;
    enemies[i].posY += Math.sin(angle) * 200 * frametime;

    if(character.shooting && 
       shot.posX >= enemies[i].posX && shot.posX <= enemies[i].posX + 32 &&
       shot.posY >= enemies[i].posY && shot.posY <= enemies[i].posY + 32)
    {
        enemies.splice(i, 1);
        character.shooting = false;
    }

    if(character.hp > 0 &&
       enemies[i].posX >= character.posX && enemies[i].posX <= character.posX + 32 &&
       enemies[i].posY >= character.posY && enemies[i].posY <= character.posY + 32)
    {
        character.hp -= 50 * frametime;
    }   
}

Das ist vielleicht etwas viel aufeinmal, aber schnell erklärt. Zuerst tun wir genau das selbe, wie beim abschießen eines Feuerballs, wir berechnen den Winkel zwischen Charakter und Gegner, so dass sich die Gegner immer in Richtung Charakter bewegen. Dann prüfen wir wieder ob es einen aktiven Feuerball gibt, falls das der Fall ist, führen wir eine einfache und etwas ungenaue, aber für unseren Fall ausreichende Kollisionsabfrage aus.

Sollte der Feuerball mit einem Gegner kollidieren, wird dieser Gegner mit der Methode splice aus dem enemies Array entfernt und der Schuss Status wird auf false gesetzt, so dass ein neuer Feuerball abgefeuert werden kann. Als nächstes prüfen wir ob der Charakter noch lebt und dann ob dieser mit einem Gegner kollidiert, sollte dies der Fall sein, ziehen wir dem Gegner pro Sekunde 50 Lebenspunkte ab. Würden wir hier die Frametime nicht miteinbeziehen, wäre es das selbe Prinzip wie mit der Geschwindigkeit, auf dem langsamen Rechner verliert der Charakter 10fps * 50hp die Sekunde und auf dem schnellen 60fps * 50hp, was natürlich nicht fair wäre. Und dass wars schon, das Spiel ist bereit gespielt zu werden!

Vielleicht hat dieses Tutorial auf den ein oder anderen etwas happig gewirkt, aber Übung macht den Meister, irgendwann wird man über dieses bisschen Quelltext lachen.

Live Demo

=> Spielprojekt runterladen

Hinterlasse gerne einen Like oder Kommentar (~‾▿‾)~
Name Text
Kommentare
Wladimir Schreiber> 1 JDu hast bei der "Die Spiellogik / logic" ein " } ". Bitte verbessere es wenn du es kannst.
Erich> 1 J@CodeTastisch: Das liegt daran, dass in deinem dritten EventListener "shoot" steht. Die Fehlermeldung lautet "shoot is not defined". Außerdem am Ende der Zeile << ctx.fillText("Level: " + level, 20, 30) >> ein Semikolon.
CodeTastisch> 1 JHey bei mir geht es null : html: <!DOCTYPE html> <html> <head> <title>Apfel vs. Twix | HTML Game</title> </head> <body> <canvas id="canvas" width="800" height="600">Kein Support</canvas> <script type="text/javascript" src="Game.js"> </script> </body> </html> js: var canvas = document.getElementById('canvas'); var ctx = canvas.getContext('2d'); var backgroundImg = new Image(); var enemyImg = new Image(); var character = null; var fireball = null; var enemies = {}; var keysDown = {}; var frametimeBefore = Date.now(); var level = 0; function init() { character = { img: new Image(), posX: 200, posY: 200, shooting: false, hp: 1000, speed: 300 }; shot = { img: new Image(), posX: 0, posY: 0, dirX: 0, dirY: 0, speed: 600 }; backgroundImg.src = 'images/HG.png'; character.img.src = 'images/character.png'; shot.img.src = 'images/shot.png'; enemyImg.src = 'images/enemy.png'; window.addEventListener("keydown", function(e) { keysDown[e.keyCode] = true; }, false); window.addEventListener("keyup", function(e) { delete keysDown[e.keyCode]; }, false); window.addEventListener("click", shoot, false); } function draw() { ctx.drawImage(backgroundImg, 0, 0); if(character.hp > 0) { for(var i = 0; i < enemies.length; ++i) { ctx.drawImage(enemies[i].img, enemies[i].posX, enemies[i].posY); } ctx.drawImage(character.img, character.posX, character.posY); if(character.shooting) { ctx.drawImage(shot.img, shot.posX, shot.posY); } } ctx.font = "20px Verdana"; ctx.fillStyle = 'white'; ctx.fillText("Level: " + level, 20, 30) ctx.fillText("HP: " + Math.ceil(character.hp), 20, 60); } function gameLoop() { draw(); } init(); setInterval(gameLoop, 0);