Pong for Arduino – Teletennis

Über den Beitrag

In diesem Beitrag möchte ich euch mein „Pong for Arduino“ vorstellen, angelehnt an den Klassiker aus dem letzten Jahrhundert und für ein 1.8 Zoll Display. Einige mögen das Spiel als Teletennis oder unter weiteren Namen kennengelernt haben, aber jeder (aus meiner Altersgruppe) kennt es und sehr viele verbinden nostalgische Gefühle damit. 

In dem Video seht ihr es in Action. Beim Filmen vom TFT Display hat leider die Qualität etwas gelitten. Im Original sieht es schöner aus, vor allem ist der „Nachzieheffekt“ des Balls nicht so stark ausgeprägt. 

Natürlich fehlt meinem Pong for Arduino der typische alte Look. Im ersten Schritt war ich aber schon froh es vom Prinzip her überhaupt realisieren zu können. Vielleicht möchte ihr ja eine „originalere“ Version daraus entwickeln.

Pong for Arduino in Action

Die Hardware

Die Schaltung für Pong for Arduino ist nicht sonderlich komplex. Zwei 10 k Potis dienen als Steuerung für die Schläger. Ein LOW-aktiver Taster gibt das Signal für den Spielbeginn oder den Neustart. Der Bildschirm ist ein 1.8 Zoll Display mit sagenhaften 128×160 Bildpunkten, wie man es für unter zehn Euro z.B. bei Amazon bekommt. Ich habe eine Version mit SD-Kartenslot verwendet, weil ich die gerade da hatte – braucht man natürlich nicht.

Das Display für Pong for Arduino

Als Mikrocontroller habe ich ein Arduino Nano Board verwendet. Das ist schön klein und kann über seinen USB Anschluss mit Strom versorgt werden. Zu diesem Zweck verwende ich gerne eine Powerbank. Der Stromverbrauch ist groß genug, dass sie sich nicht abschaltet. Der USB Anschuss ist darüber hinaus praktisch, wenn man den Sketch nochmal ändern möchte. Man muss dann nicht einmal das Gehäuse aufschrauben.

Ich habe das Ganze in ein Plastikgehäuse eingebaut. Die Hauptplatine sitzt auf Abstandshaltern. Für den USB-Anschluss des Arduino habe ich einen Zugang ausgesägt. Plastik ist kein schönes Material zum Dremeln da es schnell schmilzt, deswegen sieht es etwas unschön aus. Das Display habe ich eingeschraubt und für seine Pinleiste einen (auch nicht perfekten) Schlitz in das Gehäuse geschnitten. Für den Taster habe ich vier kleine Löcher gebohrt und ihn mit Sekundenkleber fixiert. Die Leitungen für die Steuereinheiten habe ich mit Kabelbindern zugentlastet (so wie in den Steuereinheiten selbst, s.u.).

Die Haupteinheit des Pong for Arduino mit Zugang zum USB Anschluss.
Die Haupteinheit des Pong for Arduino mit Zugang zum USB Anschluss.
Die Haupteinheit, offen
Die Haupteinheit, offen

Die beiden Steuereinheiten sind kleine Plastikgehäuse auf denen die 10 kPotis sitzen. Wie man sieht ist in dem Gehäuse eigentlich nichts drin. Es dient lediglich der Zugentlastung und damit man etwas in der Hand hat. Über dreipolige Kabel sind die Steuereinheiten mit der Haupteinheit verbunden. 

Die Steuereinheit
Eine der Steuereinheiten, offen

Die Schaltung für Pong for Arduino

Anschlussschema für Pong for Arduino
Anschlussschema

Das Display wird über SPI angesteuert. Das Display nimmt nur Daten an und sendet nichts zurück, deswegen bleibt MISO (D12) unbelegt. An D2 ist ein LOW-aktiver Interrupt mit internem Pull-Up eingestellt. Ein Tasterdruck löst den Interrupt aus. Zwischen den 10 kPotis und GND liegt noch jeweils ein 1 k Widerstand, damit auch am Anschlag kein größerer Strom fließt. Die Poti Spannung wird an A0 bzw. A1 ausgelesen. 

 

Fritzing Schema von Pong für den Arduino
Fritzing Schema der Schaltung

Der Pong for Arduino Sketch

Die Programmierung des Sketches hat mich doch erheblich mehr Zeit gekostet als gedacht und es ist auch erheblich komplexer geworden als ich es zunächst erwartete. Ich werde versuchen euch in groben Zügen durch den Code zu führen, damit ihr ihn – wenn ihr wollt – nach eigenen Wünschen anpassen könnt. Ich hoffe ihr könnt meiner Logik folgen!

Der Programmcode von Pong for Arduino besteht aus vier Teilen:

  • pong.ino: enthält Definitionen, Setup, die Hauptschleife und einige Funktionen
  • draw.ino: enthält Funktionen, die primär etwas mit der Darstellung auf dem Display zu tun haben
  • Ball.h/Ball.cpp: die Klasse für den Ball
  • Racket.h/Racket.cpp: die Klasse für die beiden Schläger

Das ganze Paket könnt ihr von Github über diesen Link herunterladen. Entpackt es in euren Library Ordner. Öffnet Pong.ino in der Arduino IDE und speichert den Sketch in eurem Sketchordner.  

Zusätzlich werden die beiden Bibliotheken SPI und TFT eingebunden, die Teil der Arduino Installation sind. Die TFT Bibliothek erweitert die Adafruit Bibliotheken GFX und ST7735. Diese sollten entsprechend schon vorhanden sein. Falls nicht installiert sie nach. Die meisten verwendeten Funktionen der TFT Bibliothek sollten selbsterklärend sein. Für mehr Details schaut hier.

Grundsätzliches

  • Zu beachten ist, dass das TFT Display seinen Ursprung oben links hat. D.h. die x-Werte der Bildpunkte steigen von links nach rechts, die y-Werte steigen von oben nach unten, was etwas gewöhnungsbedürftig ist. 
  • Der Ball hat eine Ausdehnung von vier mal vier Pixeln. Wenn von der Position die Rede ist, dann bezieht sich diese auf das Pixel der linken oberen Ecke. Bezüglich des Referenzpunktes gilt dasselbe für die Schläger (Rackets).
  • Der Ball kennt vier Bewegungsrichtungen, Right-Up (DIR_RU), Right-Down (DIR_RD), Left-Up (DIR_LU) und Left-Down (DIR_LD). Die Definition erfolgt in Ball.h als enum: enum BallDir {DIR_LU = 1, DIR_LD, DIR_RU, DIR_RD, NEW_BALL}; Ein neuer Ball hat zunächst keine definierte Richtung, dafür wird NEW_BALL benötigt. 
  • Ein Bewegungsschritt besteht aus zwei Pixeln in horizontaler und einem Schritt in vertikaler Richtung. Der Winkel ggü. der Horizontalen ist also betragsmäßig immer derselbe. Mein gutes altes Teletennis kannte neben dem „normalen“ Winkel noch einen größeren. Dieser wurde aktiviert wenn man den Ball mit dem Rand des Schlägers traf – das ist vielleicht etwas für Pong for Arduino 2.0. 
  • Wurde ein Punkt erzielt, wird der Spielestand kurz angezeigt, dann läuft das Spiel automatisch weiter. 
  • Das Spiel endet, wenn einer der Spieler 15 Punkte hat.

pong.ino

Der Hauptsketch pong.ino beginnt mit Definitionen und dem Einbinden der Bibliotheken.  Ich hoffe, dass der Code durch die vielen eingefügten Kommentare verständlich ist. 

#include <TFT.h>
#include <SPI.h>
#include <Ball.h>  	// Bibliothek für den Ball
#include <Racket.h>  	// Bibliothek für den Schläger
#define CS   10 	// Chip Select Pin
#define DC   8  	// Data / Command Pin
#define RESET  9  	// Reset Pin
#define TAST_PIN 2  	// Taster Pin für Game Reset
#define RED 1  		// Farbendefinition -> siehe colour Funktion
#define BLUE 2
#define GREEN 3
#define BLACK 4
#define LIGHTBLUE 5
#define GREY 6
#define RIGHT_RACKET_XPOS 148  	// x-Position rechter Schlaeger
#define RIGHT_RACKET_POTI_PIN 1 // der Pin für den Poti vom rechten Schlaeger
#define LEFT_RACKET_XPOS 10 	// x-Position linker Schlaeger
#define LEFT_RACKET_POTI_PIN 0 	// der Pin für den Poti vom linken Schlaeger 
#define WINSCORE 15 	// Bei 15 gewinnt man
#define BALLDELAY 1000 	// Verzögerungswert 1 in microseconds
#define BALLDELAY2 6500 // Verzögerungswert 2

volatile bool gameReset; // steuert den Spielreset, wird "TRUE" bei Tasterdruck (Interrupt)
const int RACKET_WIDTH = 2; // Schlaeger Breite
const int RACKET_HEIGHT = 12; // Schlaeger Hoehe
const int BALL_WIDTH = 4; // Der Ball ist 4x4 Pixel groß
int currentLeftRacketYPos; // y Position linker Schlaeger 
int currentRightRacketYPos; // y Position rechter Schlager
int currentBallXPos; // x Position Ball
int currentBallYPos; // y Position Ball
// Position bedeutet: Koordinaten der linken oberen Ecke
int score[2]={0,0}; // Spielstand
BallDir currentFlightDir; // Flugrichtung des Balls (in Ball.h definiert)

TFT myScreen = TFT(CS, DC, RESET);
Ball myBall;
Racket leftRacket(LEFT_RACKET_XPOS, LEFT_RACKET_POTI_PIN);
Racket rightRacket(RIGHT_RACKET_XPOS, RIGHT_RACKET_POTI_PIN);
enum racketSide {LEFT=0,RIGHT} racket;

void setup(){
  pinMode(TAST_PIN, INPUT_PULLUP); // Tasterpin: bei Low ist aktiv
  attachInterrupt(digitalPinToInterrupt(TAST_PIN), gameResetPress, FALLING);
  myScreen.begin();  // Bildschirm wird initialisiert
  myScreen.setRotation(1); // Portrait -> Landscape, 0/0 = oben links
  myScreen.setTextSize(4); // Textgroesse fuer Spielstand
  myScreen.background(0,0,0);  // einmal Bildschirm schwarz machen
  showScore(); // Spielstand (hier 0:0) wird angezeigt
  resetField(); // das Spielfeld wird angelegt, die Schläger erscheinen
  myBall.newBall(); // jetzt kommt der Ball ins Spiel
  gameReset = false; // kein Reset bis Tasterduck 
}

void loop(){  
  myBall.move(); // Der Ball macht einen Schritt
  checkPositions(); // die Position der Schläger, des Balls und die Flugrichtung werden ermittelt
  drawBall(); // der Ball wird an der neuen Position "gemalt" und die alten Pixel gelöscht
  myBall.fenceCheck(); // Check ob der Ball die Bande berührt
  // Check, ob der Ball einen Schläger (von der richtigen Seite!) berührt:
  myBall.racketHitCheck(LEFT_RACKET_XPOS, currentLeftRacketYPos, RIGHT_RACKET_XPOS, currentRightRacketYPos, BALL_WIDTH, RACKET_HEIGHT, RACKET_WIDTH);
  moveRacketsToNewPos(false); // der Schläger bewegt sich zur aktuellen Position
  if(myBall.goalCheck()){ // Wenn der Ball ueber die Linie geht, dann.....
    deleteBall(); // Randpixel beseitigen
    if((currentFlightDir==DIR_RU)||(currentFlightDir==DIR_RD)) score[0]++; // wessen Punkt?
    else score[1]++;
    showScore();
    if(!gameReset){ // nächste Runde innerhalb eines Spiels
      resetField();
      myBall.newBall();
    }
    else newGame(); // neues Spiel 
  }
}

void gameResetPress(){  // Routine für den Interrupt
  gameReset=true; 
}

void newGame(){ // Routine für neues Spiel - selbsterklärend
   myBall.newBall(NEW_BALL);
   score[0]=0; score[1]=0;
   showScore();
   resetField();
   gameReset=false;  
}

void checkPositions(){ // Positionsermittlungen
  currentLeftRacketYPos = leftRacket.getYPos();
  currentRightRacketYPos = rightRacket.getYPos();
  currentBallXPos = myBall.getXPos();
  currentBallYPos = myBall.getYPos(); 
  currentFlightDir = myBall.getFlightDir(); 
}

 

draw.ino

Die meisten Funktionen im Programmteil draw.ino sollten halbwegs verständlich sein. Etwas komplexer sind die Funktionen drawBall und moveRacketsToNewPos. Sie werden weiter unten im Detail erklärt.

void colour(int farbe){  // Farbdefinitionen - selbsterklärend
  switch(farbe){
    case RED:
      myScreen.fill(0,0,255);
      myScreen.stroke(0,0,255); break;
    case GREEN:
      myScreen.fill(0,255,0);
      myScreen.stroke(0,255,0); break;
    case BLUE:
      myScreen.fill(255,0,0);
      myScreen.stroke(255,0,0); break;
    case LIGHTBLUE:
      myScreen.fill(255,128,128);
      myScreen.stroke(255,128,128);break;
    case GREY:
      myScreen.fill(64,64,64);
      myScreen.stroke(64,64,64);break;
    case BLACK:
      myScreen.fill(0,0,0);
      myScreen.stroke(0,0,0); break;
    default:
      myScreen.stroke(0,0,255);  
  }
}

void drawBall() { // Das Bedarf einer gesonderten Erklärung, siehe Beitrag
  int formerBallXPos, formerBallYPos;
  switch (currentFlightDir) {
    case DIR_LU:
      formerBallXPos = currentBallXPos + 2;
      formerBallYPos = currentBallYPos + 1;
      myScreen.rect(formerBallXPos - 2, formerBallYPos, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.rect(formerBallXPos + 2, formerBallYPos, 2, 4);
      delayMicroseconds(BALLDELAY);
      colour(GREEN);
      myScreen.rect(formerBallXPos - 2, formerBallYPos - 1, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.line(formerBallXPos - 2, formerBallYPos + 3, formerBallXPos + 2, formerBallYPos + 3);
      delayMicroseconds(BALLDELAY2);
      break;

    case DIR_LD:
      formerBallXPos = currentBallXPos + 2;
      formerBallYPos = currentBallYPos - 1;
      myScreen.rect(formerBallXPos - 2, formerBallYPos, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.rect(formerBallXPos + 2, formerBallYPos, 2, 4);
      delayMicroseconds(BALLDELAY);
      colour(GREEN);
      myScreen.rect(formerBallXPos - 2, formerBallYPos + 1, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.line(formerBallXPos - 2, formerBallYPos, formerBallXPos + 2, formerBallYPos);
      delayMicroseconds(BALLDELAY2);
      break;

    case DIR_RU:
      formerBallXPos = currentBallXPos - 2;
      formerBallYPos = currentBallYPos + 1;
      myScreen.rect(formerBallXPos + 2, formerBallYPos, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.rect(formerBallXPos, formerBallYPos, 2, 4);
      delayMicroseconds(BALLDELAY);
      colour(GREEN);
      myScreen.rect(formerBallXPos + 2, formerBallYPos - 1, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.line(formerBallXPos + 2, formerBallYPos + 3, formerBallXPos + 6, formerBallYPos + 3);
      delayMicroseconds(BALLDELAY2);
      break;

    case DIR_RD:
      formerBallXPos = currentBallXPos - 2;
      formerBallYPos = currentBallYPos - 1;
      myScreen.rect(formerBallXPos + 2, formerBallYPos, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.rect(formerBallXPos, formerBallYPos, 2, 4);
      delayMicroseconds(BALLDELAY);
      colour(GREEN);
      myScreen.rect(formerBallXPos + 2, formerBallYPos + 1, BALL_WIDTH, BALL_WIDTH);
      colour(BLACK);
      myScreen.line(formerBallXPos + 2, formerBallYPos, formerBallXPos + 6, formerBallYPos);
      delayMicroseconds(BALLDELAY2);
      break;
  }
  colour(GREEN);
}

void deleteBall(){ // Geht ein Ball über die "Torlinie", muss er komplett gelöscht werden
  colour(BLACK);
  myScreen.rect(currentBallXPos, currentBallYPos, BALL_WIDTH, BALL_WIDTH);
}

void drawRacket(racketSide racket) { // zeichnet einen neuen Schläger
  colour(GREEN);
  switch (racket) {
    case LEFT:
      myScreen.rect(LEFT_RACKET_XPOS, currentLeftRacketYPos, RACKET_WIDTH, RACKET_HEIGHT);
      break;
    case RIGHT:
      myScreen.rect(RIGHT_RACKET_XPOS, currentRightRacketYPos, RACKET_WIDTH, RACKET_HEIGHT);
      break;
  }
}

void moveRacketsToNewPos(bool newField) {  // zieht den Schläger in die aktuelle Position
  static int prevLeftYPos = currentLeftRacketYPos;
  static int prevRightYPos = currentRightRacketYPos;

  if(newField){
    prevLeftYPos = currentLeftRacketYPos;
    prevRightYPos = currentRightRacketYPos;
  }

  while (prevLeftYPos < currentLeftRacketYPos) { //fuege Balken oben zu, loesche untere Balken
    colour(GREEN);
    myScreen.line(LEFT_RACKET_XPOS, prevLeftYPos + RACKET_HEIGHT, LEFT_RACKET_XPOS + RACKET_WIDTH , prevLeftYPos + RACKET_HEIGHT);
    colour(BLACK);
    myScreen.line(LEFT_RACKET_XPOS, prevLeftYPos, LEFT_RACKET_XPOS + RACKET_WIDTH , prevLeftYPos);
    prevLeftYPos++;
  }
  while (currentLeftRacketYPos < prevLeftYPos) { // andersrum
    colour(BLACK);
    myScreen.line(LEFT_RACKET_XPOS, prevLeftYPos + RACKET_HEIGHT, LEFT_RACKET_XPOS + RACKET_WIDTH , prevLeftYPos + RACKET_HEIGHT);
    colour(GREEN);
    myScreen.line(LEFT_RACKET_XPOS, prevLeftYPos, LEFT_RACKET_XPOS + RACKET_WIDTH , prevLeftYPos);
    prevLeftYPos--;
  }
  while (prevRightYPos < currentRightRacketYPos) { //fuege Balken oben zu, loesche untere Balken
    colour(GREEN);
    myScreen.line(RIGHT_RACKET_XPOS, prevRightYPos + RACKET_HEIGHT, RIGHT_RACKET_XPOS + RACKET_WIDTH , prevRightYPos + RACKET_HEIGHT);
    colour(BLACK);
    myScreen.line(RIGHT_RACKET_XPOS, prevRightYPos, RIGHT_RACKET_XPOS + RACKET_WIDTH , prevRightYPos);
    prevRightYPos++;
  }
  while (currentRightRacketYPos < prevRightYPos) { // andersrum
    colour(BLACK);
    myScreen.line(RIGHT_RACKET_XPOS, prevRightYPos + RACKET_HEIGHT, RIGHT_RACKET_XPOS + RACKET_WIDTH , prevRightYPos + RACKET_HEIGHT);
    colour(GREEN);
    myScreen.line(RIGHT_RACKET_XPOS, prevRightYPos, RIGHT_RACKET_XPOS + RACKET_WIDTH , prevRightYPos);
    prevRightYPos--;
  }
  colour(GREEN);
}

void showScore(){ // zeige den Spielstand
  char buf[3];
  int leftScoreXPos;// x Position der Punkte des linken Spielers 
  int rightScoreXPos;
  (score[0]<10)?(leftScoreXPos=35):(leftScoreXPos=20); // Position in Abhaengigkeit ein-/zweistellig
  (score[1]<10)?(rightScoreXPos=115):(rightScoreXPos=100);
  myScreen.background(0,0,0);
  colour(LIGHTBLUE);
  if(score[0]<WINSCORE && score[1]<WINSCORE){ //wenn noch kein Endstand erreicht ist
    itoa(score[0], buf, 10);
    myScreen.text(buf,leftScoreXPos,50);
    itoa(score[1], buf, 10);
    myScreen.text(buf,rightScoreXPos,50);
    myScreen.text(":",75,50);
    delay(1500);
  }
  else{
    if(score[0]==WINSCORE) colour(GREEN); // Endstand erreicht
    else colour(RED);
    itoa(score[0], buf, 10);
    myScreen.text(buf,leftScoreXPos,50);
    colour(LIGHTBLUE);
    myScreen.text(":",75,50);
    if(score[1]==WINSCORE) colour(GREEN);
    else colour(RED);
    itoa(score[1], buf, 10);
    myScreen.text(buf,rightScoreXPos,50);
    while(!gameReset){/*WAIT*/}
  }
}

void resetField(){
  unsigned long prevTime = millis();
  myScreen.background(0,0,0);
  delay(100);  
  colour(GREEN);  
  checkPositions();
  drawRacket(LEFT);
  drawRacket(RIGHT);
  moveRacketsToNewPos(true);
  while((millis()-prevTime) < 1000){ // Schlaeger sind beweglich während auf den Ball gewartet wird
    delay(5);
    checkPositions();
    moveRacketsToNewPos(false);
  }
}

Die drawBall Funktion

Bei der Entwicklung des Sketches habe ich viel Zeit damit verbracht, einen Weg zu finden den Ball möglichst flüssig und mit minimalem „Schweif“ über den Bildschirm zu bewegen. Mein erster Ansatz war der naheliegendste: man löscht den Ball an der alten Position (übermalt ihn schwarz) und zeichnet ihn neu an der neuen Position. Das sah wirklich bescheiden aus. Was funktionierte, war den Ball in x-Richtung zu erweitern, dann die überschüssigen Pixel zu löschen und dasselbe in y-Richtung zu wiederholen. 

Der Nachteil an dieser Methode ist, dass sie richtungsabhängig ist, d.h. man braucht für jede Richtung eine eigene Prozedur. So sieht das schematisch für die Bewegung DIR_RU, also nach rechts oben aus:

Ballbewegung in Pong for Arduino am Beispiel DIR_RU
Ballbewegung nach rechts oben von „Former Position“ zu „Current Position“

Und so lauten die Anweisungen zu den einzelnen Schritten:

  1. myScreen.rect(formerBallXPos + 2, formerBallYPos, BALL_WIDTH, BALL_WIDTH);
  2. colour(BLACK); myScreen.rect(formerBallXPos, formerBallYPos, 2, 4);
  3. colour(GREEN); myScreen.rect(formerBallXPos + 2, formerBallYPos - 1, BALL_WIDTH, BALL_WIDTH); 
  4. colour(BLACK); myScreen.line(formerBallXPos + 2, formerBallYPos + 3, formerBallXPos + 6, formerBallYPos + 3);

Es werden also einfach Rechtecke angeflanscht und abgeschnitten. Über die BALLDELAYS nach Schritt 2 und 4 lässt sich die Geschwindigkeit des Spiels und das Erscheinungsbild des nicht ganz zu verhindernden Schweifes steuern. Probiert einfach mal selbst herum. 

Die moveRacketsToNewPos Funktion

Um die Bewegung des Schläger flüssig zu gestalten, werden bei einer Aufwärtsbewegung die Pixel zeilenweise oben angefügt und unten abgeschnitten bis sich der Schläger in der aktuellen Position befindet. Für die Abwärtsbewegung ist die Prozedur entsprechend umgekehrt. Bei den Schlägern sah genau wie beim Ball ein schlichtes Löschen und „Neumalen“ ziemlich bescheiden aus.

Die Racket Bibliothek

Nun zu den relevanteren Teilen der Racket Bibliothek. Im Wesentlichen geht es bei ihr um die Übersetzung der Spannung an den Potis in die jeweilige y-Position des Schlägers. Hier die Klassendefinition aus Racket.h:

class Racket{
  public:
    Racket();
    Racket(int, int);
    void setXPos(int);
    int getXPos();
    int getYPos();
    void move();
    
  private:
    int xPos;
    int yPos;
    int potiPin;
    const int racketHeight = 12;
    const int topScreenBorder = 0;
    const int downScreenBorder = 127;
    const int minPotiVal = 160;
    const int maxPotiVal = 700;
};

Die Funktion in Racket.cpp zu der Bestimmung der y-Position heißt getYPos:

int Racket::getYPos(){
  int yPosRaw; 
  yPosRaw = analogRead(potiPin);
  if (yPosRaw<minPotiVal) yPosRaw = minPotiVal;
  if (yPosRaw>maxPotiVal) yPosRaw = maxPotiVal;
  return(map(yPosRaw,minPotiVal,maxPotiVal,0,downScreenBorder-racketHeight)); 
}

Nicht der gesamte Spannungsbereich bzw. volle Poti Umfang wird ausgenutzt, da die Steuerung handhabbar sein muss. Oberhalb und unterhalb von maxPotiVal und minPotiVal bleibt der Schläger am Rand stehen. 

Die Ball Bibliothek

Die Ball Bibliothek ist wieder etwas komplexer und bedarf einiger Erklärungen. Dazu möchte ich einige Funktionen etwas näher erläutern.

Funktion newBall

void Ball::newBall(){
  int randomNumber;
  randomNumber = random(0,999);
  yPos = 6 +(random((downScreenBorder-6)/2))*2;
  if(flightDir==NEW_BALL) (((random(0,999))%2)==0)?(flightDir = DIR_LU):(flightDir = DIR_RD);
  if((flightDir==DIR_LD) || (flightDir==DIR_LU)){
    (((randomNumber)%2)==0)?(flightDir = DIR_RU):(flightDir = DIR_RD);
    xPos=leftScreenBorder;	
  }
  else if((flightDir==DIR_RD) || (flightDir==DIR_RU)){
    (((randomNumber)%2)==0)?(flightDir = DIR_LU):(flightDir = DIR_LD);
    xPos=rightScreenBorder;	
  }
}

Wenn zu Spielbeginn ein neuer Ball ins Spiel kommt, dann hat er noch keine Richtung (NEW_BALL). Er bekommt dann per Zufallsgenerator die Richtung DIR_LU oder DIR_RD. Wenn kein Spielbeginn vorliegt, dann hat der Ball schon eine Richtung und eine Seite hat gerade einen Punkt gemacht. Die Richtung soll dann wechseln (Anstoß für den Punktverlierer). Die zweite if-Abfrage sorgt auch dafür, dass bei Spielbeginn nicht nur DIR_LU oder DIR_RD als Richtung festgelegt wird, was ja das Ergebnis der ersten if-Abfrage ist. 

Funktionen fenceCheck und goalCheck

void Ball::fenceCheck(){
  if(yPos==0){
    if(flightDir==DIR_LU) flightDir = DIR_LD;
    else if(flightDir==DIR_RU) flightDir = DIR_RD;
  }
  else if(yPos==124){
    if(flightDir==DIR_LD) flightDir = DIR_LU;
    else if(flightDir==DIR_RD) flightDir = DIR_RU;
  }
}

bool Ball::goalCheck(){
  if((xPos==leftScreenBorder)&&((flightDir==DIR_LD)||(flightDir==DIR_LU))) return true;
  else if ((xPos==rightScreenBorder)&&((flightDir==DIR_RU)||(flightDir==DIR_RD))) return true;
  else return false;

Diese Funktionen sind relativ einfach. In fenceCheck wir geprüft, ob der Ball oben oder unten anstößt. In dem Fall muss entsprechend die Richtung geändert werden. Zu beachten ist die Ausdehnung des Balls und die Position seines Referenzpixels oben links. Oben stößt der Ball direkt mit seinem Referenzpunkt an, also bei y = 0. Unten stößt der Ball an bei y = 124  und nicht bei 127. Zuerst dachte ich das würde bei 127 – Ballkantenlänge, also bei 123 passieren. Denkfehler!

In goalCheck wird geprüft, ob die Torlinie überschritten wurde. Dabei soll im Gegensatz zum fenceCheck die Linie auch wirklich überschritten werden. Der Ball geht halb über die Linie und verschwindet dann ganz. Deswegen ist in Ball.h leftScreenBorder als -2 und rightScreenBorder als 158 definiert. 

Funktion racketHitCheck

void Ball::racketHitCheck(const int &left_xp, int &left_yp, const int &right_xp, int &right_yp, 
                          const int &ball_width, const int &height, const int &width){
  if((xPos==(left_xp + width)) && (yPos>=(left_yp-ball_width)) && (yPos<=(left_yp+height))){
    if(flightDir==DIR_LU) flightDir = DIR_RU;
    else if(flightDir==DIR_LD) flightDir = DIR_RD;
  }	
  else if(xPos==(right_xp-ball_width) && (yPos>=(right_yp-ball_width)) && (yPos<=(right_yp+height))){
    if(flightDir==DIR_RU) flightDir = DIR_LU;
    else if(flightDir==DIR_RD) flightDir = DIR_LD;
  }
}

Die racketHitCheck Funktion prüft, ob ein Schläger getroffen wurde. Dabei muss die aktuelle Position der Schläger übergeben werden und es müssen die Abmessungen der Schläger und des Balls berücksichtigt werden. Außerdem soll der Schläger nur in Richtung Spielfeld schlagen. Wenn ein neuer Ball ins Spiel kommt und den Schläger von hinten trifft, soll das wirkungslos bleiben. Allerdings reißt er in diesem Fall ein Loch in den Schläger, welches aber bei Bewegung des Schlägers wieder geschlossen wird. Das Verhalten abzustellen war mir schlicht zu aufwendig. 

Abschließende Worte

Wie ihr vielleicht gesehen habt, ist die Programmierung von Pong for Arduino durchaus etwas komplexer als man sich das vielleicht im ersten Moment vorstellt. Ein Hobbyprogrammierer wie ich kommt da schon etwas ins Schwitzen. Ursprünglich hatte ich die Idee, das Ganze so allgemein zu programmieren, dass man auch andere Bildschirmgrößen verwenden kann oder ganz easy zwischen Tennis und einem Ein-Spieler-Squash wechseln kann. Vielleicht mögen das andere fortführen.

Mir ist auch klar, dass die eine oder andere Stelle nicht ganz „sauber“ programmiert ist obgleich es funktioniert. Trotzdem hoffe ich, dass es für euch eine nützliche und vor allem spaßige Anregung ist. Ich freue mich über jede konstruktive Kritik!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.