0t1 steckt noch in den Kinderschuhen.

Buntes Snake mit Arduino und p5.js

Das Spiel »Snake« erreichte in den 1990ern Kultstatus und ist bis heute ein weithin bekannter Zeitvertreib. In diesem Beitrag zeigen wir, wie das Spiel mithilfe von einem Arduino und p5.js nachgebaut werden kann.

Unser Ziel

Dieses Tutorial zeigt, wie mithilfe von einem Arduino, einigen Buttons und etwas Javascript ein ›Snake‹-Spiel entsteht, welches durch die selbst gebaute Kontrollstruktur gesteuert wird.

Was dafür gebraucht wird

An Hardware benötigt man:

  • Arduino

  • 4x Button

  • 2x 10k Ohm Widerstand

  • 1x 220k Ohm Widerstand

  • 1x 1M Ohm Widerstand

An Software benötigt man:

  • p5.js Library

  • p5.serialport.js Library

Fangen wir an

Zunächst benötigt man die p5.js Library, sowie die darauf aufbauende p5.serialport.js Library. Nachdem diese heruntergeladen wurden, kann die Serialport Library im index.html des "empty-example" von p5.js eingebunden werden. Optional lässt sich ein Stylesheet einbinden, um Hintergrund und Schrift zu personalisieren.

Basics

Boardaufbau

Für die Eingabe des Spieles braucht man ein Steuerkreuz. Dafür schließt man die vier Buttons folgendermaßen an den analogen Eingang A0 des Arduinos an.

Arduino Input Code

Damit p5.js die Werte lesen kann, werden die leicht ungenauen analogen Werte in einen genauen digitalen Wert umgewandelt.

int xkeys = 0;    // Variable für Serial Port

void setup() {

  Serial.begin(9600); // Initialisieren Serial Verbindung

}

void loop() {

  int sensorValue;

  sensorValue = analogRead(A0); // Lesen der Buttons

  int keyVal = analogRead(A0);

  

  if(keyVal >= 1005){ xkeys = 220; }

  else if(keyVal >=701 && keyVal <= 1004){ xkeys = 200; }

  else if(keyVal >=201 && keyVal <= 700){ xkeys = 250; }

  else if(keyVal >=5 && keyVal <= 200){ xkeys = 210; }

  else { xkeys = 0; }

  

  Serial.write(xkeys); // Schreiben des Wertes zum Serial Port, damit p5.js es auslesen kann

}

Grundlegende Variablen

Zunächst stellen wir sicher, dass das Spiel später mit dem Arduino kommunizieren kann und bereiten alles für die Verbindung vor.

var serial;   // Variable, die eine Instanz der Serialport-Bibliothek enthaelt
var portName = '/dev/tty.usbmodem14201';    // Trage hier den Namen der seriellen Schnittstelle ein
var inData;   // Variable zur Aufnahme der Eingangsdaten vom Arduino

Nun wenden wir uns dem Spiel zu und setzen einige grundlegende Einstellungen für die Schlange auf. Neben der Länge und der Ausrichtung zu Spielstart, bestimmen wir auch die Startposition.

let numSegments = 10; //Initiallaenge
let direction = 'right'; //Initialrichtung

const xStart = 0; //Startkoordinate x der Schlange
const yStart = 250; //Startkoordinate y der Schlange
const diff = 10;

let xCor = []; //Aktuelle x Koordinate der Schlange
let yCor = []; //Aktuelle y Koordinate der Schlange

let xFruit = 0; //x Koordinate der Frucht
let yFruit = 0; //y Koordinate der Frucht
let scoreElem; //Spielstand

Die Variablen die setColor() aufrufen, brauchen wir erst ganz zum Schluss, setzen diese aber gleich mit auf.

let clrStart = setColor(); //Schlangenfarbe
let bgClr = setColor(); //Hintergrundfarbe

Grundlegende Funktionen

Um das Spielfeld, den Punktenstand, sowie die Schlange zu Spielbeginn anzeigen zu lassen, muss die setup()-Funktion befüllt werden.

Zunächst wird der Spielstand in die obere, linke Ecke eingefügt, danach das dazugehörige Spielfeld, welches sich in Größe und Spielgeschwindigkeit nach Wünschen einstellen lässt. Dazu einfach die Werte in createCanvas() oder frameRate() anpassen.

function setup() {
  /*Spielstand*/
  scoreElem = createDiv('Score = 0');
  scoreElem.position(20, 20);
  scoreElem.id = 'score';
  scoreElem.style('color', 'white');

  /*Spielfeld*/
  createCanvas(500, 500);
  frameRate(8); //Spielgeschwindigkeit
  stroke(setColor());
  strokeWeight(10);
  updateFruitCoordinates();

Die Schlange initalisieren wir nun mit Hilfe einer Schleife, die gemäß der Länge der Schlange Segmente auf unser Canvas setzt.

/*Schlange initalisieren*/
  for (let i = 0; i < numSegments; i++) {
    xCor.push(xStart + i * diff);
    yCor.push(yStart);
  }

Zu guter Letzt wird der Serialport initialisiert und die Verarbeitung der Daten vorbereitet.

/*Serialport initialisieren*/
  serial = new p5.SerialPort();       // eine neue Instanz der serialport- Bibliothek erstellen
  serial.on('list', printList);  // eine Callback-Funktion fuer das Ereignis "serialport list" festlegen
  serial.on('connected', serverConnected); // Callback fuer die Verbindung zum Server
  serial.on('open', portOpen);        // Callback fuer die Oeffnung des Ports
  serial.on('data', serialEvent);     // Rueckruf, wenn neue Daten eintreffen
  serial.on('error', serialError);    // Callback fuer Fehler
  serial.on('close', portClose);      // Rueckruf fuer das Schliessen des Anschlusses
  
  serial.list();                      // die seriellen Schnittstellen auflisten
  serial.open(portName);              // eine serielle Schnittstelle oeffnen
}

Um während der Spielzeit die aktuelle Position der Schlange und der Früchte anzuzeigen, wird die draw()-Funktion benutzt.

Als erstes nutzen wir die vorhin implementierte Variable bgClr und geben damit der Spielfläche eine zufällige Farbe. Anschließend wird die Schlange anhand ihrer aktuellen Koordinaten auf das Canvas gezeichnet.

Des Weiteren werden mit updateSnakeCoordinates() die Koordinaten der Schlange aktualisiert, mit checkGameStatus() überprüft ob der Spielfeldrand oder die Schlange getroffen wurden und mit checkForFruit() überprüft, ob die Schlange eine Frucht gefunden hat.

inputBtn() überwacht die Eingabe des Nutzers und wertet diese aus.

function draw() {
  background(bgClr);
  for (let i = 0; i < numSegments - 1; i++) {
    line(xCor[i], yCor[i], xCor[i + 1], yCor[i + 1]);
  }
  updateSnakeCoordinates(); //Koordinaten der Schlange anpassen
  checkGameStatus(); //Spielstand abfragen
  checkForFruit(); //Punktestand abfragen
  inputBtn(); // ueberwacht Eingabe 
}

Bewegung

Die Bewegung der Schlange ist ein einfaches Verschieben des zweiten Segmentes auf die Position des Ersten. Anschließend wird die Länge der Schlange, ausgehend von der neuen Position des Kopfes und ihrer Richtung, an diesen hinten angehangen.

function updateSnakeCoordinates() {
  for (let i = 0; i < numSegments - 1; i++) {
    xCor[i] = xCor[i + 1];
    yCor[i] = yCor[i + 1];
  }
  switch (direction) {
    case 'right':
      xCor[numSegments - 1] = xCor[numSegments - 2] + diff;
      yCor[numSegments - 1] = yCor[numSegments - 2];
      break;
    case 'up':
      xCor[numSegments - 1] = xCor[numSegments - 2];
      yCor[numSegments - 1] = yCor[numSegments - 2] - diff;
      break;
    case 'left':
      xCor[numSegments - 1] = xCor[numSegments - 2] - diff;
      yCor[numSegments - 1] = yCor[numSegments - 2];
      break;
    case 'down':
      xCor[numSegments - 1] = xCor[numSegments - 2];
      yCor[numSegments - 1] = yCor[numSegments - 2] + diff;
      break;
  }
}

Nachdem die Schlange sich nun bewegen kann, kann die Steuerung implementiert werden, mit den zu Beginn festgeschriebenen Werten für die einzelnen Buttons, lässt sich ein Switch realisieren, der je nach Eingabe die direction ändert. Dabei wird sichergestellt, das die Schlange keine 180° Wendungen vornehmen kann.

Zusätzlich wird bei jeder Eingabe, egal ob diese die Richtung tatsächlich ändert, ein Wechsel der Hintergrundfarbe vorgenommen, in dem der Variablen bgClr ein neuer, zufälliger Farbwert zugewiesen wird.

function inputBtn() {
  var xkeys = inData;
  switch (xkeys) {
    case 200:
      if (direction !== 'right') {     // verhindert 180° Wendung
        direction = 'left';
      }
      bgClr = setColor();
      break;
    case 210:
      if (direction !== 'left') {
        direction = 'right';
        bgClr = setColor();
      }
      break;
    case 220:
      if (direction !== 'down') {
        direction = 'up';
        bgClr = setColor();
      }
      break;
    case 250:
      if (direction !== 'up') {
        direction = 'down';
        bgClr = setColor();
      }
      break;
  }
}

Kollisionen

Da die Schlange sich nie selbst kreuzen darf, wird nun eine Kontrollmechanik hinzugefügt die überprüft, ob die Position des Schlangenkopfs mit einer der restlichen Positionen des Körpers übereinstimmt.

function checkSnakeCollision() {
  const snakeHeadX = xCor[xCor.length - 1];
  const snakeHeadY = yCor[yCor.length - 1];
  for (let i = 0; i < xCor.length - 1; i++) {
    if (xCor[i] === snakeHeadX && yCor[i] === snakeHeadY) {
      return true;
    }
  }
}

Neben dem eigenen Körper, führt auch der Kontakt des Spielrandes zum Abbruch des Spiels. Auch hierfür wird die Position des Kopfes mit dem Rand abgeglichen. Sollte diese mit dem Spielrand oder dem eigenen Körper übereinstimmen, bricht das Spiel ab und zeigt dem Nutzer den finalen Spielstand an.

function checkGameStatus() {
  if (
    xCor[xCor.length - 1] > width ||
    xCor[xCor.length - 1] < 0 ||
    yCor[yCor.length - 1] > height ||
    yCor[yCor.length - 1] < 0 ||
    checkSnakeCollision()
  ) {
    noLoop();
    const scoreVal = parseInt(scoreElem.html().substring(8));
    scoreElem.html('Game ended! Your score was : ' + scoreVal);
  }
}

Die Früchte

Um die Schlange wachsen zu lassen, werden Früchte benötigt, die der Nutzer einsammeln kann. Die Grundlagen dafür wurden bereits zu Beginn geschaffen.

Ähnlich wie bei der Kollision wird dafür die Position des Kopfs und die der aktuellen Frucht verglichen. Stimmen diese Positionen überein, wird der Spielstand aktualisiert, eine neue Frucht wird gesetzt, die Schlange verlängert sich und ändert ihre Farbe.

function checkForFruit() {
  point(xFruit, yFruit);
  if (xCor[xCor.length - 1] === xFruit && yCor[yCor.length - 1] === yFruit) {
    const prevScore = parseInt(scoreElem.html().substring(8));
    scoreElem.html('Score = ' + (prevScore + 1));
    xCor.unshift(xCor[0]);
    yCor.unshift(yCor[0]);
    numSegments++;
    updateFruitCoordinates();
    stroke(setColor());
  }
}

Eine neue Frucht wird an einem zufälligen Ort generiert. Dieser wird durch die Funktion updateFruitCoordinates() erzeugt.

function updateFruitCoordinates() {
  xFruit = floor(random(10, (width - 100) / 10)) * 10;
  yFruit = floor(random(10, (height - 100) / 10)) * 10;
}

Farbe ins Spiel bringen

Um das Spiel farbig zu gestalten und jede Session einzigartig zu gestalten, werden die Farben zufällig mit setColor() gewählt und für die Schlange und den Hintergund verwendet.

function setColor(){
  var o = Math.round, r = Math.random, s = 255;
  return 'rgb(' + o(r()*s) + ',' + o(r()*s) + ',' + o(r()*s) + ')';
}

Fazit

Das Spiel sieht zu Beginn komplexer aus, als es tatsächlich ist, sobald ein Blick hinter die Kulissen geworfen wird, erscheint es deutlich leichter. Die analoge Steuerung ermöglicht etwas Basteln und garantiert zum Schluss besten Spaß.

Als Grundlage für dieses Tutorial diente dieses Projekt von Prashant Gupta.


Quellcode

var serial;   // Variable, die eine Instanz der Serialport-Bibliothek enthaelt
var portName = '/dev/tty.usbmodem14201';    // Tragen Sie hier den Namen Ihrer seriellen Schnittstelle ein
var inData;   // Variable zur Aufnahme der Eingangsdaten vom Arduino

// die Schlange wird in kleine Segmente unterteilt, die bei jedem "Draw"-Aufruf gezeichnet und bearbeitet werden
let numSegments = 10;
let direction = 'right';

const xStart = 0; //Start-X-Koordinate fuer die Schlange
const yStart = 250; //Start-Y-Koordinate fuer die Schlange
const diff = 10;
3
let xCor = [];
let yCor = [];

let xFruit = 0;
let yFruit = 0;
let scoreElem;
let clrStart = setColor();
let bgClr = setColor();

function setColor(){
  var o = Math.round, r = Math.random, s = 255;
  return 'rgb(' + o(r()*s) + ',' + o(r()*s) + ',' + o(r()*s) + ')';
}

function setup() {
  scoreElem = createDiv('Score = 0');
  scoreElem.position(20, 20);
  scoreElem.id = 'score';
  scoreElem.style('color', 'white');

  createCanvas(500, 500);
  frameRate(8);
  stroke(setColor());
  strokeWeight(10);
  updateFruitCoordinates();

  for (let i = 0; i < numSegments; i++) {
    xCor.push(xStart + i * diff);
    yCor.push(yStart);
  }
   
    //Kommunikationsanschluss einrichten
    serial = new p5.SerialPort();       // eine neue Instanz der serialport-Bibliothek erstellen
    serial.on('list', printList);  // eine Callback-Funktion fuer das Ereignis "serialport list" festlegen
    serial.on('connected', serverConnected); // Callback fuer die Verbindung zum Server
    serial.on('open', portOpen);        // Callback fuer die Oeffnung des Ports
    serial.on('data', serialEvent);     // Rueckruf, wenn neue Daten eintreffen
    serial.on('error', serialError);    // Callback fuer Fehler
    serial.on('close', portClose);      // Rueckruf fuer das Schliessen des Anschlusses
  
    serial.list();                      // die seriellen Schnittstellen auflisten
    serial.open(portName);              // eine serielle Schnittstelle oeffnen

}

function draw() {
  background(bgClr);
  for (let i = 0; i < numSegments - 1; i++) {
    line(xCor[i], yCor[i], xCor[i + 1], yCor[i + 1]);
  }
  updateSnakeCoordinates();
  checkGameStatus();
  checkForFruit();
  inputBTM(); // bekomme INPUT
}

/*
 Die Segmente werden entsprechend der Richtung der Schlange aktualisiert.
 Alle Segmente von 0 bis n-1 werden einfach auf 1 bis n kopiert, d. h. Segment 0
 erhaelt den Wert von Segment 1, Segment 1 erhaelt den Wert von Segment 2, und so weiter,
 und dies fuehrt zur Bewegung der Schlange.

 Das letzte Segment wird je nach der Richtung, in die sich die Schlange bewegt, hinzugefuegt,
 Wenn sie nach links oder rechts geht, wird die x-Koordinate des letzten Segments um einen
 vordefinierten Wert 'diff' als das vorletzte Segment erhoeht. Und wenn sie sich nach oben
 oder abwaerts, wird die y-Koordinate des Segments beeinflusst.
*/
function updateSnakeCoordinates() {
  for (let i = 0; i < numSegments - 1; i++) {
    xCor[i] = xCor[i + 1];
    yCor[i] = yCor[i + 1];
  }
  switch (direction) {
    case 'right':
      xCor[numSegments - 1] = xCor[numSegments - 2] + diff;
      yCor[numSegments - 1] = yCor[numSegments - 2];
      break;
    case 'up':
      xCor[numSegments - 1] = xCor[numSegments - 2];
      yCor[numSegments - 1] = yCor[numSegments - 2] - diff;
      break;
    case 'left':
      xCor[numSegments - 1] = xCor[numSegments - 2] - diff;
      yCor[numSegments - 1] = yCor[numSegments - 2];
      break;
    case 'down':
      xCor[numSegments - 1] = xCor[numSegments - 2];
      yCor[numSegments - 1] = yCor[numSegments - 2] + diff;
      break;
  }
}

/*
 Wir ueberpruefe immer die Kopfposition der Schlange xCor[xCor.length - 1] und
 yCor[yCor.length - 1] um zu sehen, ob sie die Grenzen des Spiels beruehrt
 oder ob die Schlange sich selbst beruehrt.
*/
function checkGameStatus() {
  if (
    xCor[xCor.length - 1] > width ||
    xCor[xCor.length - 1] < 0 ||
    yCor[yCor.length - 1] > height ||
    yCor[yCor.length - 1] < 0 ||
    checkSnakeCollision()
  ) {
    noLoop();
    const scoreVal = parseInt(scoreElem.html().substring(8));
    scoreElem.html('Game ended! Your score was : ' + scoreVal);
  }
}

/*
 Wenn die Schlange sich selbst trifft, bedeutet das, dass die (x,y)-Koordinate des Schlangenkopfes
 des Schlangenkopfes mit der (x,y)-Koordinate eines seiner eigenen Segmente uebereinstimmen muss.
*/
function checkSnakeCollision() {
  const snakeHeadX = xCor[xCor.length - 1];
  const snakeHeadY = yCor[yCor.length - 1];
  for (let i = 0; i < xCor.length - 1; i++) {
    if (xCor[i] === snakeHeadX && yCor[i] === snakeHeadY) {
      return true;
    }
  }
}

/*
 Jedes Mal, wenn die Schlange eine Frucht verzehrt, erhoehen wir die Anzahl der Segmente,
 und fuegen das Schwanzsegment einfach wieder am Anfang des Arrays ein (im Grunde
 fuegen wir das letzte Segment wieder am Schwanz ein, wodurch der Schwanz verlaengert wird)
*/
function checkForFruit() {
  point(xFruit, yFruit);
  if (xCor[xCor.length - 1] === xFruit && yCor[yCor.length - 1] === yFruit) {
    const prevScore = parseInt(scoreElem.html().substring(8));
    scoreElem.html('Score = ' + (prevScore + 1));
    xCor.unshift(xCor[0]);
    yCor.unshift(yCor[0]);
    numSegments++;
    updateFruitCoordinates();
    stroke(setColor());
  }
}

function updateFruitCoordinates() {
  /*
    Die komplexe mathematische Logik kommt daher, dass der Punkt
    zwischen 100 und Breite-100 liegt und auf die naechste durch 10 teilbare Zahl abgerundet wird, die durch 10 teilbar ist, da wir die Schlange in Vielfachen von 10 bewegen.
  */

  xFruit = floor(random(10, (width - 400) / 10)) * 10;
  yFruit = floor(random(10, (height - 400) / 10)) * 10;
}



function inputBTM() {
  var xkeys = inData;
  switch (xkeys) {
    case 200:
      if (direction !== 'right') {
        direction = 'left';
      }
      bgClr = setColor();
      break;
    case 210:
      if (direction !== 'left') {
        direction = 'right';
        bgClr = setColor();
      }
      break;
    case 220:
      if (direction !== 'down') {
        direction = 'up';
        bgClr = setColor();
      }
      break;
    case 250:
      if (direction !== 'up') {
        direction = 'down';
        bgClr = setColor();
      }
      break;
  }
}

// Die folgenden Funktionen geben den Status der seriellen Kommunikation zu Debugging-Zwecken auf der Konsole aus

function printList(portList) {
  // portList ist ein Array von Namen serieller Anschluesse
  for (var i = 0; i < portList.length; i++) {
  // Zeigen Sie die Liste auf der Konsole an:
  print(i + " " + portList[i]);
  }
 }
 
 function serverConnected() {
   print('connected to server.');
 }
 
 function portOpen() {
   print('the serial port opened.')
 }
 
 function serialEvent() {
   inData = Number(serial.read());
 }
 
 function serialError(err) {
   print('Something went wrong with the serial port. ' + err);
 }
 
 function portClose() {
   print('The serial port closed.');
 }

Schlagworte


© 0t1

Cookies

0t1 mag keine Kekse (und kein Tracking). Wir verwenden lediglich notwendige Cookies für essentielle Funktionen.

Wir verwenden Schriftarten von Adobe Fonts. Dafür stellt dein Browser eine Verbindung zu den Servern von Adobe in den USA her. Wenn du unsere Seite nutzen möchtest, musst du dich damit einverstanden erklären.

Weitere Informationen in unserer Datenschutzerklärung.