How-To: einfaches Browser-Spiel

„Wenn ich Programmierer lerne, kann ich bis an mein Lebensende Computerspiele programmieren, testen und bekomm auch noch Geld dafür.“

Mit dieser fälschlichen Meinung habe ich mich damals für meinen heutigen Beruf entschieden. Auch wenn das in keinster Weise meinen tatsächlichen Arbeitsalltag widerspiegelt, sind Spiele wohl eine der besten Alternative für die altbekannten „Hello World“- und „Sortier“- Beispiele, die man im Netz hinterher geworfen bekommt.

Da ich derzeit auch am Thema „Neuronale Netzwerke“ arbeite, und dafür ebenfalls ein anschauliches Anwendungsbeispiel schreiben möchte, bieten sich Computerspiele an. Das Problem dabei: Wenn wir auf Spiele wie Super Mario oder Ähnliche zurückgreifen, hätten wir einige Probleme:

  • das Spiel ist an sich sehr komplex und würde die gegen Einfachheit dieses Tutorials sprechen
  • wir müssten uns damit beschäftigen, wie wir das Geschehen bei Super Mario auswerten, um einen numerischen Input daraus zu generieren

Alles in allem habe ich mich nun dazu entschieden, ein recht einfaches Game selbst zu schreiben, um auch später eventuelle Anpassungen (zusätzliche Logs, …) implementieren zu können.

Die Idee

Ein Spiel wird umso komplexer, umso mehr Möglichkeiten wir dem Spieler lassen. So ist ein Spiel auf dem alten Gameboy Color weniger komplex, als ein Spiel auf der PlayStation4. Zum Vergleich hier die Knöpfe, die uns alleine der Controller zur Verfügung stellt:

PS4 – ControllerLinks, Rechts, Oben, Unten, Dreieck, Kreis, Viereck,
Kreuz, Linker Stick, Rechter Stick, PS-Taste, Optionstaste,
R1, R2, L1, L2
GameBoy ColorLinks, Rechts, Oben, Unten, A, B, Select, Start

Um es einfach zu halten, benötigen wir ein Konzept, mit wenigen Eingaben arbeiten zu können. Ich wähle hierfür die Richtungen links und rechts. 

Wir haben ein Spielfeld mit einigen Etagen. Unsere Figur ist ein Ball, den wir nach links und nach rechts navigieren können. Ziel ist es, durch die Etagen zu navigieren und ins Ziel (grün) zu kommen. Zu langweilig? Dann noch eine kleine Herausforderung: Wir können auch Gegner an  platzieren, bei deren Berührung wir erneut beginnen müssen.

Die Umsetzung

Als erstes benötigen wir ein HTML-Element, in dem alle weiteren Komponenten dargestellt werden sollen. Hierfür verwende ich eine Div mit den Abmessungen von 350×300 Pixel.

<!-- index.html -->
<link rel="stylesheet" href="./style.css"></div>
<div id="wrapper"></div>

/* style.css */
#wrapper {
margin-left: 50px;
margin-top: 50px;
width: 350px;
height: 300px;
border: 1px solid black;
position: absolute;
}

Innerhalb dieses Elementes müssen wir unsere Ebenen, Gegner und unseren Ball hinzufügen. Das könnte man theoretisch einfach in den HTML-Code einfügen und positionieren. Jedoch sind wir dann relativ unflexibel, da wir, wenn wir z.B. unser Level neu gestalten wollen, die HTML-Struktur verändern müssen. Deshalb schauen wir uns die Elemente einmal genauer an. Was sind die Unterschiede eines Gegners und einer Linie, die die Etage kennzeichnet?

  • Position
  • Abmessungen
  • Farbe
  • Klasse
  • Id

Mit diesen Angaben können wir also unser Spielfeld ohne weiteres generieren lassen. Bei einer Gesamthöhe von 300px und 5 Ebenen, ergibt das pro Ebene eine Höhe von 60px. Unsere Zwischenlinien haben eine Höhe von 2px. Sprich wir haben zum Setzen der Elemente pro Ebene 58px Platz.

Gesagt, getan.

// layout.js
var ball = null; // contains our ball-div after generating playground
var wrapper = document.getElementById('wrapper');

var elements = [
{ class: '', id: 'ball', top: 28, left: 120, width: 30 },
{ class: 'floor', id: '', top: 58, left: 0, width: 300, floor: 1 },
{ class: 'floor', id: '', top: 118, left: 0, width: 130, floor: 2 },
{ class: 'floor', id: '', top: 118, left: 220, width: 130, floor: 2 },
{ class: 'floor', id: '', top: 178, left: 0, width: 50, floor: 3 },
{ class: 'floor', id: '', top: 178, left: 120, width: 110, floor: 3 },
{ class: 'floor', id: '', top: 178, left: 300, width: 50, floor: 3 },
{ class: 'floor', id: '', top: 238, left: 70, width: 210, floor: 4 },
{ class: 'enemy', id: '', top: 23, left: 5, width: 40, floor: 1 },
{ class: 'enemy', id: '', top: 203, left: 75, width: 80, floor: 4 },
{ class: '', id: 'door', top: 250, left: 150, width: 80, floor: 5 },
];

function generatePlayground() {
elements.forEach(element => {
var div = document.createElement('div');
div.style.top = element.top;
div.style.left = element.left;
div.style.width = element.width;

if (element.class) {
div.classList.add(element.class);
} else {
div.id = element.id;
if (div.id === 'ball') {
div.style.height = 30;
ball = div;
}
}

wrapper.appendChild(div);
});
}
<!-- index.html -->
<script src="./layout.js"></script>
<script>generatePlayground();</script>
/* style.css */
.floor {
height: 2px;
position: absolute;
background-color: black;
}

.enemy {
height: 35px;
background-color: red;
position: absolute;
}

#door {
width: 20px;
height: 50px;
position: absolute;
background-color: green;
}

#ball {
height: 30px;
border-radius: 50%;
background-color: blue;
position: relative;
}

Im Ergebnis sollte das dann wie folgt ausschauen:

Da wir das Spielfeld und seine Elemente unter gewissen Umständen auch zurücksetzen müssen, implementieren wir zusätzlich folgende Funktion.

// layout.js
function reset() {
isAnimationRunning = false;
floorBall = 1;
while(wrapper.hasChildNodes()) {
wrapper.removeChild(wrapper.firstChild);
}

generatePlayground();
}

Wir sehen unsere Ebenen, die 2 Gegner in rot und unseren positionierten Spielball, den wir mit Hilfe der Richtungstasten von links nach rechts bewegen und in Ziel befördern müssen.

Jetzt kommt Bewegung ins Spiel <( ° o ° <)

Wie kann man nun dem Ball sagen, sich nach links zu bewegen, wenn wir die linke Pfeiltaste drücken? Das Schlüsselwort heißt „keydown“-Event. Wenn wir hierauf einen Event-Listener setzen, wir bei jedem Tastendruck das Event gefeuert und wir können darauf zugreifen. Wir filtern also bei jedem Tastendruck heraus, welche Taste gedrückt wurde und rufen die entsprechenden Funktionen auf.

// interaction.js
var floorBall = 1; // actual floor number from our ball
var isAnimationRunning = false; // stop movement when animation is running

document.addEventListener('keydown', function(event) {
if (isAnimationRunning) {
return;
}

switch (event.key) {
case 'ArrowRight':
moveRight();
break;
case 'ArrowLeft':
moveLeft();
break;
}
});
<!-- index.html -->
<script src="./interaction.js"></script>

Um den Ball zu bewegen, müssen wir lediglich seine Positionierung von links verändern, indem wir sie erhöhen oder verringern. Da unser Element bei den Properties left, right, width und height einen String zurückgibt und wir die Werte in Pixel setzen, benötigen wir zusätzlich eine Hilfsfunktion um den Pixel-Wert herauszuziehen.

// interaction.js
function pxToInt(pixel) {
return parseInt(pixel.replace('px', ''));
}

function moveLeft() {
ball.style.left = pxToInt(ball.style.left) - 5;
evaluatePosition();
checkBallMatchFloor('left');
}

function moveRight() {
ball.style.left = pxToInt(ball.style.left) + 5;
evaluatePosition();
checkBallMatchFloor('right');
}

Don’t fight, just die…

Nun sitzt direkt in der ersten ein Gegner neben unserem Ball. Sofern wir diesen Berühren, müsste unser Spielfeld zurückgesetzt werden. Wie kann man das überprüfen?

Anhand der Positionen vom Ball und der Elemente können wir schauen, ob sie sich überschneiden. Hierzu nehmen wir zusätzlich die Eigenschaft „floor“ (sprich die Ebene) hinzu, da wir sonst die Position für jeden Gegner überprüfen und nicht nur den in der aktuellen Höhe.

Wir fragen also ab, ob der Ball den Gegner links oder rechts berührt.

// interaction.js
function evaluatePosition() {
var ballLeft = pxToInt(ball.style.left);
var ballWidth = pxToInt(ball.style.width);
var ballTop = pxToInt(ball.style.top);
var ballHeight = pxToInt(ball.style.height);

elements.forEach(el => {
if (el.class === 'enemy' && el.floor === floorBall) {
if (ballLeft > el.left && el.left + el.width > ballLeft) {
reset();
}

if (el.left > ballLeft && ballLeft + ballWidth > el.left) {
reset();
}
}
});
}

Jetzt muss der Ball natürlich auch nach unten fallen, wenn mehr als die Hälfte über den Boden hinaus ragt. Hierzu überprüfen wir nach jeder Bewegung die Position auf dem Boden und starten- wenn er überragt – eine Animation, um den Ball zur nächsten Ebene zu befördern.

Das machen wir rekursiv. Versucht einmal die Logik zu verstehen. Wenn es zu viele Fragen dazu gibt, kann ich auch noch weiterführende Informationen hinzufügen. Einige Überprüfungen sind lediglich dazu da, dass es etwas flüssiger und natürlicher verläuft.

// interaction.js
function checkBallMatchFloor(direction) {
if (floorBall === 5) { return; }

var onFloor = 0;
var notOnFloor = 0;
var floorCoordinates = [];
var ballLeft = pxToInt(ball.style.left);
var ballWidth = pxToInt(ball.style.width);

elements
.filter(el => el.class === 'floor' && el.floor === floorBall)
.forEach(floor => {
for (var i = floor.left; i < floor.left + floor.width; i++) {
floorCoordinates.push(i);
}
});

for (var i = ballLeft; i < ballLeft + ballWidth; i++) {
if (floorCoordinates.indexOf(i) !== -1) {
onFloor += 1;
} else {
notOnFloor += 1;
}
}

if (notOnFloor >= onFloor) {
isAnimationRunning = true;
moveToNextFloor(onFloor, direction);
}
}

function moveToNextFloor(onFloor, direction) {
if (!isAnimationRunning) { return; }
var valueHorizont = direction === 'left' ? -1 : 1;

if (onFloor > 0) {
setTimeout(function() {
ball.style.left = pxToInt(ball.style.left) + valueHorizont;
if (onFloor <= 7) {
setTimeout(function() {
ball.style.top = pxToInt(ball.style.top) + 3;
evaluatePosition();
}, 15);
}

moveToNextFloor(--onFloor, direction);
}, 15);
} else {
var ballTH = pxToInt(ball.style.top) + pxToInt(ball.style.height);
if (ballTH < (floorBall * 60) + 58) {
setTimeout(function() {
ball.style.top = pxToInt(ball.style.top) + 3;
evaluatePosition();
moveToNextFloor(onFloor, direction);
}, 15);
} else {
isAnimationRunning = false;
floorBall += 1;
}
}
}

Was noch nicht funktioniert, ist das Zurücksetzen, wenn wir von oben auf den zweiten Gegner fallen sowie die Benachrichtigung, wenn wir im Ziel ankommen. Wir werden nicht direkt beim „Aufschlag“ zurück auf Anfang gesetzt. Hierfür passen wir unsere „evaluatePosition“-Funktion an.

// interaction.js

function evaluatePosition() {
// [...]
elements.forEach(el => {
if (el.class === 'enemy' && el.floor === floorBall) {
// [...]
} else if (el.class === 'enemy' && el.floor === floorBall + 1) {
if (ballTop + ballHeight > el.top && ballLeft > el.left
&& ballLeft + ballWidth < el.left + el.width
) {
isAnimationRunning = false;
reset();
}
} else if (el.id === 'door' && el.floor === floorBall) {
if (ballLeft > el.left && el.left + el.width > ballLeft) {
window.alert('done');
}
if (el.left > ballLeft && ballLeft + ballWidth > el.left) {
window.alert('done');
}
}
});
}

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.