Sokoban auf einem Milchtüten-Display

von | 16.01.2021

Haben Sie schon einmal etwas von einem Milchtüten-Display gehört? Kennen Sie Sokoban? Und können Sie sich vorstellen, dass Sie Sokoban auf einem Milchtüten-Display spielen können?

Oftmals reizen mich neue Ideen, um am Wochenende ein wenig zu basteln. Als mir ein Kollege von SWIFT.CONSULT¹ von ihrem Milchtüten-Display erzählte, war ich sofort begeistert. Die Inspiration für das Display hatten sie von Tetrapix.²

Ein Milchtüten-Display ist ein LED-Panel, das aus 7 x 7 Milchtüten besteht, wobei jede Milchtüte mit einer eigenen LED in einer gewünschten Farbe beleuchtet wird. Insgesamt gibt es sechs dieser LED-Panels, die zu einem gemeinsamen Display angeordnet werden können. Ein Display mit einer Auflösung von 21 x 16 oder 16 x 21 Pixel. Mit einem selbst gebastelten Controller könnte man auf diesem Display bspw. Tetris spielen. Oder eben Sokoban.

Falls Sie das Spiel nicht kennen: Sokoban ist ein Spiel mit verschiedenen Leveln, bei dem eine Spielfigur vorhandene Kisten auf vorgegebene Zielfelder bugsieren muss. Dabei muss die Spielfigur darauf achten, sich nicht selbst den Weg zu verbauen, denn sonst beginnt das Spiel von vorne. Schnell war die Idee geboren, dass jedes Pixel ein Feld ist und die Farbe des Pixels das Element definiert: Wand, Spieler, Box und Zielfeld.

Die eingesetzte Hardware und Software

Wie ist das Setup für das Projekt? Das Milchtüten-Display besteht aus miteinander verlöteten RGB-LEDs, abgeschnittenen Milchtüten und einem Breakout-Board. Gesteuert wird das Ganze von einem Arduino Mikrocontroller inklusive einiger weiterer Steuerelemente zur Eingabe wie Joysticks und Buttons. Programmiert wird das Spiel in C mit der Arduino IDE. Für kleinere Projekte ist die Arduino IDE mehr als ausreichend, da sie einerseits Standardbibliotheken zur Ansteuerung der Peripherie bietet, und andererseits die Übertragung des Programms auf dem Mikrocontroller unterstützt.

Die virtuelle Entwicklung von Sokoban für das Milchtüten-Display

Was können Sie tun, wenn Sie nicht “zufällig” ein Milchtüten-Display zu Hause haben? Ich habe mich entschieden, die Implementierung virtuell durchzuführen, also das Programm auf einem Windows PC zu entwickeln, aber den Zugriff auf Display, Eingaben und Systemaufrufe von der Logik des Spiels zu trennen, um so später die Implementierung leicht an die “reale Hardware” anpassen zu können. Da die die Entwicklung von Desktop-Anwendungen von der Arduino IDE nicht unterstützt wird, habe ich Visual Studio dafür genutzt.

Die Entwicklung von “sokoban.cpp”

Die Logik im klassischen C-Stil ist in einer einzelnen Datei – der “sokoban.cpp” – implementiert. Aufgrund der Simplizität des Problems habe ich auf eine Objektorientierung für die Spiellogik verzichtet und eine bewährte C++ Bibliothek namens SFML verwendet, die einen Rahmen für die Fensterstellung und die Benutzereingabenverarbeitung bereitstellt.

Für die Eingaben und Systemaufrufe haben ich mich für eine simple Lösung entschieden. Für jede Eingabe und jeden Systemaufruf gibt es eine Funktion, die in der virtuellen Umgebung durch SFML implementiert wird.

void delay(int32_t milliseconds)
{
  sf::sleep(sf::milliseconds(milliseconds));
}

bool key_left_down()
{
  return sf::Keyboard::isKeyPressed(sf::Keyboard::Key::A);
}

bool key_right_down()
{
  return sf::Keyboard::isKeyPressed(sf::Keyboard::Key::D);
}

bool key_up_down()
{
  return sf::Keyboard::isKeyPressed(sf::Keyboard::Key::W);
}

bool key_down_down()
{
  return sf::Keyboard::isKeyPressed(sf::Keyboard::Key::S);
}

bool key_action_down()
{
  return sf::Keyboard::isKeyPressed(sf::Keyboard::Key::Space);
}

Die Spiellogik in derselben Datei  weiter unten verwendet einfach diese Funktionen. Dadurch wird die spätere Austauschbarkeit gewährleistet.

void update_game(int16_t elapsedMillis)
{
  position_t target_player;
  position_t target_box;

  target_player.x = player.x;
  target_player.y = player.y;
  target_box.x = player.x;
  target_box.y = player.y;

  if (key_left_down())
  {
    // game logic
  }

  // ...
  delay(100);
}

Die Virtualisierung des Bildschirms

Für die Virtualisierung des Bildschirms bietet “Adafruit GFX Library” eine gute Unterstützung. Die Bibliothek stellt Implementierungen für verschiedene Displays des Herstellers Adafruit bereit. Der von SWIFT.CONSULT geschriebene Display-Treiber für das Milchtüten-Display implementiert ein C++ Interface namens “Adafruit_GFX” und somit bietet es sich an, die Spiellogik gegen dieses Interface zu implementieren. Allerdings müssen Sie das Interface nicht vollständig implementieren, denn nur wenige Funktionen des Interfaces werden genutzt.

Da Sie eine Klasse benötigen, die das Interface Adafruit_GFX implementiert, ergibt es Sinn von C auf C++ zu wechseln. Die Implementierung merkt sich alle Pixel in einem Array mit 16-Bit Integers. Sämtliche Methoden lesen von oder schreiben in dieses Array. Wenn das Display aktualisiert wird, wird das Array ausgewertet und mit Hilfe von SFML für jedes Pixel ein Rechteck auf dem Display gezeichnet. Die Implementierung ist relativ einfach wie Sie hier sehen können:

// VirtualDisplay.h

#include "Adafruit_GFX.h"

class VirtualDisplay : public Adafruit_GFX {
private:
	sf::RenderWindow& _window;
	sf::RectangleShape _shape;
	int _width;
	int _height;
	int16_t* _pixels;

public:
	VirtualDisplay(sf::RenderWindow &window, int16_t w, int16_t h);
	~VirtualDisplay();

public:
	void drawPixel(int16_t x, int16_t y, uint16_t color);
	void display();

private:
	unsigned long rgb16_to_rgb32(unsigned short a);
};

// VirtualDisplay.cpp

VirtualDisplay::VirtualDisplay(sf::RenderWindow &window, int16_t w, int16_t h):
	_window(window),
	_width(w),
	_height(h),
	_pixels(nullptr) {
	_pixels = new int16_t[w*h];
	for (auto i = 0; i < w*h; i++) _pixels[i] = 0;
}

VirtualDisplay::~VirtualDisplay() {
	delete[] _pixels;
}

void VirtualDisplay::drawPixel(int16_t x, int16_t y, uint16_t color) {
	if (x < 0 || x >= _width || y < 0 || y >= _height) return;

	_pixels[y*_width + x] = color;
}

void VirtualDisplay::display() {
	sf::Event event;
	while (_window.pollEvent(event)) {
		if (event.type == sf::Event::Closed) _window.close();
	}

	auto size = _window.getSize();

	auto pixelSize = std::min(size.x / _width, size.y / _height);
	auto offsetX = (size.x - pixelSize * _width) / 2;
	auto offsetY = (size.y - pixelSize * _height) / 2;

	_shape.setSize(sf::Vector2f(pixelSize,pixelSize));

	_window.clear(sf::Color::Blue);
	for (auto x = 0; x < _width; x++) {
		for (auto y = 0; y < _height; y++) {
			_shape.setPosition(sf::Vector2f(offsetX + x * pixelSize, offsetY + y * pixelSize));

			auto color = _pixels[y*_width + x];
			auto colorBits = rgb16_to_rgb32(color);
			_shape.setFillColor(sf::Color(colorBits));

			_window.draw(_shape);
		}
	}
	_window.display();
}

unsigned long VirtualDisplay::rgb16_to_rgb32(unsigned short a) {
	unsigned long r = (a & 0xF800) >> 11;
	unsigned long g = (a & 0x07E0) >> 5;
	unsigned long b = (a & 0x001F);
	r = std::lroundf((float)r / 31.0f * 255.0f);
	g = std::lroundf((float)g / 63.0f * 255.0f);
	b = std::lroundf((float)b / 31.0f * 255.0f);
	return 0x000000ff | (r << 24) | (g << 16) | (b << 8);
}

Die Virtualisierung des Rahmens

Das Ganze wird noch in einen kleinen Rahmen gepackt, quasi als virtueller Mikrocontroller, der als Windowsanwendung läuft. Auch in der “sokoban.cpp”-Datei ist der Code implementiert. Im Folgenden sehen Sie den gesamten virtuelle Rahmen, der ebenfalls überschaubar ist:

//----------------------------------------------------------------
// virtual, replaceable input, system calls and display reference
//----------------------------------------------------------------
Adafruit_GFX *tft = nullptr; // filled in main function

void delay(int32_t milliseconds)
{
	// ...
}

// Further functions

//----------------------------------------------------------------
//game logic
//----------------------------------------------------------------

//here is the game logic using input, system calls and tft display reference.
//----------------------------------------------------------------
//Virtual machine running the game inside a win32 shell.
//----------------------------------------------------------------

int main()
{
	sf::RenderWindow window(sf::VideoMode(800, 600), "SFML works!");

	std::unique_ptr<VirtualDisplay> local_tft = std::make_unique<VirtualDisplay>(window, WIDTH, HEIGHT);
	//fill global variable
	tft = local_tft.get();

	setup();

	while (window.isOpen())
	{
		loop();
		local_tft->display();
	}

	return 0;
}

Das war schon alles. Einfach, oder?

Von der Virtualisierung in die Realität

Schön, wenn alles “virtuell” wie gewünscht funktioniert, aber klappt es auch in der “harten” Realität? Einen Arduino Mega habe ich zur Hand. Auch Joystick und Buttons befinden sich auf meinem Schreibtisch. Und ein 2,8 Zoll TFT LCD Shield von Adafruit konnte ich schnell besorgen. Gut, dass sich alles leicht auf einem Breakout-Board verbinden lässt.

Von der Virtualisierung in die Realität

Und wie geht es jetzt weiter? Als erstes benenne ich “Sokoban.cpp” in “sokoban.ino” um und tausche die Implementierungen für das Adafruit Interface, die Systemaufrufe und die Eingaben. Hier finden Sie die Änderungen an der “sokoban.ino”:

#include <Elegoo_GFX.h>
#include <Elegoo_TFTLCD.h>

#define LCD_CS A3 // Chip Select goes to Analog 3
#define LCD_CD A2 // Command/Data goes to Analog 2
#define LCD_WR A1 // LCD Write goes to Analog 1
#define LCD_RD A0 // LCD Read goes to Analog 0
#define LCD_RESET A4 // Can alternately just connect to Arduino's reset pin

Elegoo_TFTLCD tft(LCD_CS, LCD_CD, LCD_WR, LCD_RD, LCD_RESET);

bool left_down = false;
bool right_down = false;
bool down_down = false;
bool up_down = false;
bool action_down = false;

bool key_left_down()
{
  return left_down;
}

bool key_right_down()
{
  return right_down;
}

bool key_up_down()
{
  return up_down;
}

bool key_down_down()
{
  return down_down;
}

bool key_action_down()
{
  return action_down;
}

//-----------------------------------------------------------------------------------------------
// GAME Implementation
//-----------------------------------------------------------------------------------------------

void setup()
{  
  tft.reset();
  tft.begin(0x9341);
  tft.fillScreen(0x0000);

  //load initial level
}

void loop()
{
  int vertical = analogRead(A11);
  int horizontal = analogRead(A12);
  
  action_down = digitalRead(34) == 0;
  left_down = horizontal < 128;
  right_down = horizontal > 896;
  up_down = vertical > 896;
  down_down = vertical < 128;

  delay(FRAME_TIME_MILLISECONDS);

  //run game logic

Kurz zusammengefasst: setup() und loop() sind die Funktionen, die Arduino aufruft. “Setup()” wird einmal beim Start des Microcontrollers aufgerufen und initialisiert das Adafruit Display. “Loop()” wird wiederholt vom Microcontroller in einer Schleife aufgerufen. Hier werden die Ports ausgelesen, an denen Joystick und Buttons angeschlossen sind, und Tastendrücke ermittelt. Die “delay()” Funktion könnte Sie sogar komplett entfernen, da dieselbe Signatur direkt von der Arduino Basisbibliothek bereitgestellt wird. Das Ganze lässt sich aus der Arduino IDE direkt auf den Microcontroller übertragen und ausführen.

Zugegebenermaßen sind 21×16 Pixel sehr klein, wenn das Display eine wesentlich höhere Auflösung bietet. Ansonsten hat aber alles bei mir auf Anhieb funktioniert.

Levelverwaltung inklusive Editor

Sokoban ist ein Spiel, bei dem sich der Spieler sich Level für Level durch das Spiel arbeitet. Diese Levels müssen Sie natürlich noch im Spiel anlegen. Dafür gibt es verschiedene Optionen wie bspw. eine SD-Karte, auf die entsprechende XML Dateien abgelegt werden. Ich habe mich aber für eine noch einfachere Lösung entschieden und die Levels direkt im Code als Arrays abgelegt. Der Vorteil ist, dass die Daten direkt bereitstehen und nur wenig Platz verbrauchen (21 x 16 = 336 Bytes pro Level). Die Datenstruktur sieht wie folgt aus:

//Block definitions
const int8_t E = 0; // empty
const int8_t W = 1; // wall
const int8_t S = 2; // Start player
const int8_t B = 3; // box
const int8_t T = 4; // target for box
const int16_t LEVEL_COUNT = 9;
const int8_t level_data[LEVEL_COUNT][WIDTH*HEIGHT] = {
  {
    E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,W,W,W,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,W,T,W,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,W,E,W,W,W,W,E,E,E,E,E,E,E,
    E,E,E,E,E,E,W,W,W,B,E,B,T,W,E,E,E,E,E,E,E,
    E,E,E,E,E,E,W,T,B,S,E,W,W,W,E,E,E,E,E,E,E,
    E,E,E,E,E,E,W,W,W,W,B,W,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,W,T,W,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,W,W,W,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,
    E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E,E
  },
  //....
};

Es macht allerdings keinen Spaß, diese Daten komplett per Hand zu erstellen, so dass ich mir eine kleine Toolkette ausgedacht habe, um verschiedene Level mit vertretbarem Aufwand generieren zu können. Genutzt habe ich dafür den Tiled Map Editor.

Tile Set im Tiled Map Editor

Der Tiled Map Editor³ ist ein fantastisches Open Source Tool, um zweidimensionale Karten zu erstellen, und diese in ein benutzerdefiniertes Format zu exportieren. Für Sokoban müssen Sie lediglich ein so genanntes “Tile Set” für die Box, die Wand, das Zielfeld und den Spielerstart sowie eine Vorlage zum Öffnen erstellen. Das Ergebnis lässt sich bspw. als separate XML Datei im “tmx”-Format exportieren und anschließend mit einem Texteditor wie Visual Studio Code oder Notepad++ öffnen.

Und nun? In der Datei gibt es ein Element <data>. Dieses enthält die zu ersetzenden Feldinformationen, sprich die Zahlen, die nun per Suchen- und Ersetzen-Kommando zu Buchstaben werden sollen:

  • 0 -> E
  • 1 -> W
  • 2 -> S
  • 3 -> B
  • 4 -> T

Als letzten Schritt müssen Sie lediglich diese Daten ans Ende des Array der Variablen “level_data” verschieben und die Variable LEVEL_COUNT um eins erhöhen. Nach einem Compiliervorgang und dem Upload auf den Microcontroller steht alles bereit zum Testen. Fertig.

Fazit

Gerne möchte ich Sie ermuntern, das Projekt nachzubauen. Alle notwendigen Informationen inklusive Dokumenation in Markdown-Dateien finden Sie hier in einem Github repository.

Mir hat die Umsetzung des Projekts viel Spaß bereitet. SFML ist für für mich ein Heimspiel, da ich die Bibliothek kenne und auch schon viel damit experimentiert habe. Zu der Hardware finden Sie bei Arduino sehr viele nützliche Informationen, so dass Sie sicherlich schnell einen Einstieg finden. Und Sie dann das Spiel auf einem niedlichen, kleinen Display mit Ihrem Joystick steuern, dann hat sich der Aufwand auf jeden Fall gelohnt. Versprochen.

 

Hinweise:

Interessieren Sie sich für weitere Erfahrungen aus der Praxis? Testen Sie unseren wöchentlichen Newsletter mit interessanten Beiträgen, Downloads, Empfehlungen und aktuellem Wissen.

Natürlich würde ich mich sehr freuen, wenn die Kollegen bei SWIFT.CONSULT das Ganze auf dem echten Display testen würden. Und was mich besonders interessiert: wer schafft alle Level?

[1] SWIFT.CONSULT
[2] Tetrapix
[3] Tiled Map Editor

So sieht das Spiel Tetris auf dem Milchtüten-Display aus:

Tetris spielen auf dem Milchtüten-Display

Peter Friedland hat im t2informatik Blog einige weitere Beiträge veröffentlicht, u. a.

t2informatik Blog: Performance Optimierung bei WPF Anwendungen

Performance Optimierung bei WPF Anwendungen

t2informatik Blog: CI/CD Pipeline auf einem Raspberry Pi

CI/CD Pipeline auf einem Raspberry Pi

t2informatik Blog: Warum ich bei Godot gelandet bin

Warum ich bei Godot gelandet bin

Peter Friedland
Peter Friedland

t2informatik GmbH

Peter Friedland ist bei der t2informatik GmbH als Software Consultant tätig. In verschiedenen Kundenprojekten entwickelt er innovative Lösungen in enger Zusammenarbeit mit Partnern und den Ansprechpartnern vor Ort. Und ab und an bloggt er auch.