Wir haben nun das Zeitalter der KI erreicht, weshalb hier weniger Lösungen und mehr das Technik Tagebuch Posts kommen.
Lösungen verlinke ich Kategorisch extra auf den weiteren Seiten des Blogs
Wenn man im Internet sucht, finden sich viele Beispiele zur Programmierung einiger Sensoren, die ich ebenfalls selbst verwende. Die meisten Codeschnipsel funktionieren auch auf dem Wemos. Dennoch muss noch etwas herum probiert werden, um bestimmte Schwierigkeiten anzugehen, damit auch das erwartete Ergebnis kommt.
HTU21D, HTU21, SHT21
Wird ein Sensor gelesen bekommt man nach diesem Vorgang einen Rohwert, der dann in einen für uns bekannten und lesbaren Wert umgerechnet wird. Wir können dies ohne weiteres nach Datenblatt tun oder einen fertigen Beispiel Code verwenden.
Ich wollte meinen Programmcode mit anderen Beispielen Vergleichen, auf Grund einer Konfigurierbarkeit des Sensors. Leider war dazu auf Anhieb nichts zu finden, womit ein Grund bestand sich damit selbst auseinander zu setzten.
In diesem Fall ist es der Feuchtigkeit Sensor HTU21D und ist auch unter HTU21 oder SHT21 zu finden (Nicht ganz sicher, ob alle dieselben sind). Abgesehen Technischer Unterschiede, können alle drei mit dem gleichen Programmcode angesteuert werden. Vor ein paar Jahren hatte ich bereits ein Beispiel Code mit C# und .NET Micro Framework geschrieben, in dem ich damals keine Implementierung der Einstellmöglichkeiten vornahm.
Für den Sensor sind drei Register verfügbar, mit denen die Einstellungen abgefragt, festgelegt oder zurückgesetzt werden können.
Write user register 0xE6
Read user register 0xE7
Soft Reset 0xFE
Grundeinstellung
Wird der Sensor nur gestartet und gelesen, dann bestehen die Grundeinstellungen. Diese sollten im Normalfall reichen. Bei Speziellen Anwendungen könnten dieses Lesevorgänge zu lange dauern. Mit dem Kompromiss der Genauigkeit, kann der Lesevorgang für die Feuchtigkeit von 18ms auf 3ms gesengt werden. Anstatt der 12bit, werden jedoch nur 8bit verwendet.
Genauigkeit
Zeit
12bit
18ms
11bit
9ms
10bit
5ms
8bit
3ms
Abruf und Zuweisen der Einstellung
Im Grunde ist dies so simpel wie der Abruf der Sensor Daten. Für die Einstellwerte ist jedoch etwas umdenken erforderlich, da diese in einem Byte verpackt sind. Ein Byte besteht aus 8Bit und verfügt damit so acht Schalter für bestimmte Einstellungen. Zum Nachvollziehen habe ich aus dem Datenblatt die Tabelle mit den zugehörigen Bit Schaltern abgebildet.
Einstellen und Auswerten
Mit dem 'Read user register' 0xE7 kann die aktuelle Einstellung abgerufen werden. Das kann hilfreich sein, um die geschrieben Einstellungen gegen zu prüfen.
War das Schreiben der Einstellung erfolgreich, sollte mit der Methode 'Htu21ReadUserRegister()' das Binäre Ergebnis '1100 0001' ausgegeben werden.
Beispiel Projekt Wetterstation
Auf dem Github Repository stelle ich diesmal das kleine Projekt zur Verfügung, in dem ich bereits den HTU21 und den BMP180 Sensor verwende. Dort sind einige Methoden etwas weiter ausgeführt und zum Teil Kommentiert. Soweit ich immer Zeit habe, erweitere ich das Projekt mit Möglichst überschaubaren Funktionen.
Das ansteuern eines Servos über einen Xbox One Controller ist simpel umzusetzen. Für dieses Beispiel wird folgendes verwendet:
MonoGame
Xbox Controller
Arduino UNO oder vergleichbar
Servo
Motor Shield
Externe Batterie
Nach der Installation von MonoGame sind in Visual Studio mehre Vorlagen verfügbar. Benötigt wird das Template 'MonoGame Windows Project', das im folgenden Bild als erstes in der Liste erscheint.
Programmcode mit MonoGame
Sobald das Projekt angelegt würde, könnt ihr die Game1.cs Datei öffnen. Für die Verbindung zum Arduino wird die Klasse SerialPort verwendet. Dazu sollte vorher bekannt sein, welcher COM Port bei euch der Arduino verwendet. Die Baudrate von 115200 ist die maximale Geschwindigkeit, die zuverlässig funktioniert. Die restlichen Parameter sind die Default Werte der Seriellen Verbindung zum Arduino (siehe Programmcode).
Die überschriebenen Methoden 'Initialization()', 'LoadContent()' und 'Draw()' werden für das Beispiel nicht verwendet und können entfernt werden.
Mit der Update Methode wird der Xbox Controller eingelesen. Der Input Wert vom Stick kommt dann aus dem GamePadState unter '.ThumbSticks.Left.X'. Der Float Wert geht von -1 bis +1 und wird deshalb zunächst um plus eins dass Offset verschoben. Anschießend wird der Float Wert in ein Byte Wert transformiert, dass dann anschließend an den Serial Port geschrieben wird. Damit die Anwendung nicht zu viele Bytes versendet, wird am Ende 50 Millisekunden gewartet. Ansonsten läuft der Buffer im Arduino über.
GraphicsDeviceManager _graphics;
private SerialPort _serialPort;
public Game1()
{
_graphics = new GraphicsDeviceManager(this);
// Verwendete COM Port muss ggf. angepasst werden.
this._serialPort = new SerialPort("COM5", 115200, Parity.None, 8, StopBits.One);
this._serialPort.Open();
}
protected override void UnloadContent()
{
this._serialPort.DiscardOutBuffer();
this._serialPort.Close();
}
protected override void Update(GameTime gameTime)
{
GamePadState state = GamePad.GetState(PlayerIndex.One);
byte stickValue = (byte)((state.ThumbSticks.Left.X + 1) * 90);
this._serialPort.Write(new byte[] { stickValue }, 0, 1);
Task.Delay(50).Wait();
}
Arduino als Empfänger
Der Arduino muss nun die empfangenden Bytes vom PC entgegen nehmen und daraus ein PWM Signal erzeugen das zur Steuerung des Servos verwendet wird. Weiters habe ich im Programmcode beschrieben.
#include <Servo.h>
Servo servo;
int servoPin = 9;
int receivedValue;
void setup() {
// Muss die selbe Baudrate haben,
// wie in der MonoGame Anwendung.
Serial.begin(115200);
// die meisten Servos arbeiten mit einem
// HIGH Signal von 1ms bis 2ms.
servo.attach(servoPin, 1000, 2000);
}
void loop() {
if( Serial.available() > 0){
receivedValue = Serial.read();
// Den eingelesenen Wert Abschneiden,
// falls der maximale Stellwert überschritten wird.
if(receivedValue > 180) {
receivedValue = 180;
}
else if(receivedValue < 0) {
receivedValue = 0;
}
// alle weiteren eingegangenen bytes werden verworfen
Serial.flush();
}
// Aktuellen Einstellwert festlegen.
servo.write(receivedValue);
delay(20);
}
Was nun
Im Grunde ist die Ausführung simpel und das Schreiben dieses Post hat da deutlich mehr Zeit benötig. :D
Aber was kann man nun mit dem Wissen anstellen? Nun, man kann stattdessen einer Kabelgebundenen Ausführung, auf eine Bluetooth Funkverbindung wechseln. Damit ließe sich dann ein RC Model ansteuern. Mag etwas Umständlich sein, die Fernsteuerung gegen etwas eigenes gebasteltes zu ersetzen, aber darin liegt der Spaß.
Zur Vollständigkeit wieder ein Link zu dem Github Repository und dem gesamten Beispiel Programmcode. Nachtrag
Leider funktioniert dieses einfache Beispiel nur in dieser Konstellation. Sobald versucht wird, zwei Bytes zu versenden, treten Probleme auf mit der Seriellen Verbindung.
Wie man das behebt, beschreibe ich mit dem Post "Zeichnen auf dem TFT mit dem XBox Controller"
Wenn die Ergebnisse nicht den Erwartungen entsprechen, dann ist mit Sicherheit etwas falsch. Das geschah diesmal mit dem Wemos D1 Mini. Einen bereits fertiges Code Beispiel für das Auslesen eines BMP085 Sensors mit einem Arduino, verwendete ich diesmal auf dem Wemos. Nach dem hochladen zeigten sich die nicht erwartenden Ergebnisse. Zumindest war offensichtlich, dass in meiner Wohnung keine 119 Grad Celsius herrschten und bei einem Luftdruck von 4000 Pascal wäre ich sicherlich an Sauerstoffmangel oder kochendem Blut auseinander gegangen. Also musste was an der Berechnung nicht stimmen.
Behoben
Der Fehler ließ sich relativ schnell beheben. Die Verwendeten ValueTypes int und unsigned int wurden ersetzt durch int16_t und unt16_t.
Aber warum
Ein ValueType INT ist immer das gleiche, solange die Variable als INT definiert wird auf einem System. Das eine System ist die Arduino Plattform mit dem 8Bit Mikrocontroller. Der Wemos verwendet wiederum einen 32Bit Mikrocontroller. Das sollte als erstes Auffallen und schnell sollte klar sein, dass etwas mit den Typen etwas nicht stimmt und folglich nicht mit den richtigen Werten rechnet. Eigentlich ist das von Compiler abhängig, welcher Type aus einem INT angelegt wird.
Der Folgende Code macht die Größe eines INT sichtbar, sobald ihr das auf der Ziel Plattform ausführt.
Wie man sieht, wird auf dem Wemos kein 16Bit INT, sondern 32Bit INTEGER angelegt
Arduino Nano
Wemos D1 Mini
Aber Moment mal. Damit wäre eine Berechnung auf dem Wemos genauer und der Rechenfehler dürfte erst gar nicht auftreten. Sieht man sich allerdings die Funktionsinhalte an, dann sind mehrere Stellen auffällig, wo die Werte durch Byteshifting hoch oder runter gerechnet werden. Das nur passt wiederum nur mit 16 Bit. Andernfalls muss die Funktion geändert werden.
Zur Vollständigkeit der Beispielcode mit den geänderten ValueTypes. In diesem Fall reichten die Änderungen für INT aus und kann nun für Arduino oder Wemos verwendet werden.
Was wäre ein Abenteuer ohne einen Rucksack, in dem man seine Gefundenen Gegenstände einsammeln kann. Um diese Funktion Übersichtlich zu halten, wird der Rucksack sechs Plätze haben. Im Vorfeld muss festgelegt werden, wie zunächst die Informationen im Rucksack gehalten werden. Auch hier wird weiterhin eine Datenbanklose Lösung erzielt. Die Gegenstände müssen als Abstrakt betrachtet werden, so dass diese auf wesentliche Informationen eingeschränkt wird.
Ein wichtiger Punkt wird sein, die Funktionsvariablen entsprechend zu kommentieren. Das wird später hilfreich sein, die Informationen auch wieder zu zuordnen.
Ein Objekt sollte Grundlegende Eigenschaften haben:
Name
Bild (ein 16x16 Pixel Sprite)
Beschreibung (sollte nur für bestimmte Gegenstände verwendet werden)
Verwendungszweck
Damit der Gegenstand Zugeordnet werden kann, ist zusätzlich eine Identifikationsnummer erforderlich oder auch kurz ID. Diese wird z.B. für den Rucksack Funktion verwendet. Allerdings muss die ID Nummer nicht als Funktionsvariable angelegt werden und steht nur als Kommentar zu den verwendeten Daten.
// ID 01
// Name
const PROGMEM char itemKey01[10] = "Schluessel";
// Icon / Bild
const PROGMEM byte itemKey01Icon[256] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,10,10,10,0,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,10,0,0,0,0,10,0,0,10,10,10,10,10,10,10,10,10,10,10,0,0,10,0,0,10,10,0,10,0,0,0,0,10,0,0,0,0,10,0,0,10,0,0,0,0,0,0,0,10,0,0,0,10,0,0,0,0,0,0,0,0,0,0,0,0,10,10,10,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };
// Beschreibung
const PROGMEM char itemKey01Description[] = "Oeffnet eine Box";
// Verwendungszweck Id => kombinierte funktions abruf fur position und verknuepfte Box mit der selben Id
const PROGMEM uint16_t itemKey01Usage = 1;
// #######################################
// ID 02
// Name
const PROGMEM char itemCamera[6] = "Kamera";
// Icon / Bild
const PROGMEM byte itemCameraIcon[256] = { 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,1,9,9,9,9,9,9,1,0,0,0,0,0,1,1,1,1,9,19,19,19,19,9,1,1,1,1,0,1,9,9,9,9,9,9,1,1,9,9,9,9,9,9,1,1,9,9,9,9,1,1,11,11,1,1,9,19,19,9,1,1,9,9,9,9,1,11,11,11,11,1,9,19,19,9,1,1,9,9,9,1,11,11,11,11,11,11,1,9,9,9,1,1,9,9,9,1,11,11,11,11,11,11,1,9,9,9,1,1,9,9,9,9,1,11,11,11,11,1,9,9,9,9,1,1,9,9,9,9,1,1,11,11,1,1,9,9,9,9,1,1,9,9,9,9,9,9,1,1,9,9,9,9,9,9,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0 };
// Beschreibung
const PROGMEM char itemCameraDescription[] = "Mach ein paar Fotos!";
// Verwendungszweck
const PROGMEM uint16_t itemCameraUsage = 2;
Der Name ist klar, Bild muss sein und Beschreibung zu einem Objekt ist auch hilfreich. Aber wie sieht der Einsatz für die Eigenschaft 'Verwendungszweck' aus? Im Programmcode wird dort eine Nummer stehen. Hier kommt die Kollisionsabfrage ins Spiel.
Ein Fallbeispiel
Die Figur hat den Gegenstand 'Schlüssel' und kann damit eine Bestimmte Tür öffnen. Über die Kollisionsabfrage wird geprüft, ob das Hindernis eine Tür ist. Wenn ja, dann wird der Rucksack nach einem Objekt abgefragt, dass dem Verwendungszweck entspricht.
Weiteres zum Verwendungszweck, kommt im späteren Abschnitt und bleiben zunächst bei der Umsetzung Gegenstände einzusammeln.
Der Rucksack
Als erstes sollten die Taschenplätze im Unteren Bildschirm Bereich abgebildet werden. Im Aus übersichtlichen Gründen wird im Programmcode eine weitere Seite (Tab) angelegt mit dem Namen 'BackpackComponent'. Für die Anforderungen kommen einige Funktionen hinzu, um ein Item in den Rucksack zu schreiben, abzurufen oder zu entfernen.
// Grundeinstellung des Rucksackes
#define backpackPlacesCount 6
uint16_t backPlaces[backpackPlacesCount] = { 0, 0, 0, 0, 0, 0 };
byte tempIcon[256];
// … Item Objekte …
// Kopiert das array aus dem flash in den Arbeitsspeicher
void setItemIconToTemp(byte icon[]) {
for(int index = 0; index < 256; index++) {
tempIcon[index] = pgm_read_byte_near(icon + index);
}
}
// Pruefen ob das Item bereits vorhanden ist
boolean isItemInBackback(uint16_t itemId) {
for(byte index = 0; index < 6; index++) {
if(backPlaces[index] == itemId) { return true; }
}
return false;
}
// Legt das Item in die Tasche ab und Zeichnet es in einen offen Taschenplatz
bool setItemToBackpack(uint16_t itemId) {
if(isItemInBackback(itemId)) { return false; }
// id ablegen in ersten freien Taschenplatz
byte place = 0;
for(byte index = 0; index < sizeof(backPlaces); index++) {
if(backPlaces[index] == 0) {
backPlaces[index] = itemId;
place = index;
break;
}
}
byte relationPlaceX = 0;
byte relationPlaceY = 0;
setItemRelationPlace(place, &relationPlaceX, &relationPlaceY);
// Abruf des Icon zu dem Item
boolean isArrayCopy = true;
switch(itemId) {
case(1): { setItemIconToTemp(itemKey01Icon); break; } // Schluessel
case(2): { setItemIconToTemp(itemCameraIcon); break; } // Fotoapparat
case(3): { setItemIconToTemp(itemPhoto01Icon); break; } // Foto
default: { isArrayCopy = false; break; } // Nicht belegt, darf aber auch nicht eintreten
}
if(isArrayCopy) { drawTile(relationPlaceX, relationPlaceY, mapTileSize, mapTileSize, tempIcon, false); }
else { EsploraTFT.fillRect(relationPlaceX, relationPlaceY, mapTileSize, mapTileSize, 0xF800); }
// einen Rahmen darueber zeichnen
EsploraTFT.drawRect(relationPlaceX, relationPlaceY, mapTileSize, mapTileSize, mapNumberToColor(12));
return true;
}
// Holt die anfangs Position des Taschenplatzes das auf dem Bildschirm gerendert werden soll.
void setItemRelationPlace(byte place, byte* relationPlaceX, byte* relationPlaceY) {
if(place == 0 || place == 1 || place == 2) { *relationPlaceY = 96; } // erste Zeile
else if(place == 3 || place == 4 || place == 5) { *relationPlaceY = 112; } // zweite Zeile
if(place == 0 || place == 3) { *relationPlaceX = 0; } // erste Spalte
else if(place == 1 || place == 4) { *relationPlaceX = 16; } // zweite Spalte
else if(place == 2 || place == 5) { *relationPlaceX = 32; } // dritte Spalte
}
// Prueft die Karten Id mit einem Objekt aus dem Rucksack.
bool getItemToUsed(int16_t mapUsageId) {
int16_t itemId = 0;
// hole itemId aus der Karteneigenschaft ab.
if(mapUsageId == mapBarrierUsageDoor01) {
itemId = 1; // Id des zu verwendenden Schlussels
}
// pruefe die Tasche, ob das Item vorhanden ist und dann aus dem inventar nehmen
for(byte index = 0; index < sizeof(backPlaces); index++) {
// Item einmalig verwenden
if(itemId != 0 && backPlaces[index] == itemId) {
// Verwendungszweck
if(mapUsageId == mapBarrierUsageDoor01) {
mapBarrierDoorIsOpen = true;
backPlaces[index] = 0; // aus dem Inventar entfernen
}
}
}
if(mapUsageId == mapBarrierUsageDoor01 && mapBarrierDoorIsOpen == true) {
return true;
}
return false;
}
Die Tasche ist nun da. Jetzt fehlt noch das Einsammeln, dass mit Hilfe der Kollisionsabfrage ermöglicht. Bisher wurden nur die Werte für Begehbar und Wand geprüft. Auf der Karte kommt nun ein weiterer Wert hinzu, das für ein einzusammelndes Objekt steht. Damit wir diese Stelle wiedererkennen, muss auch das Rendern der Karte noch angepasst werden.
...
void renderMap(int positionX, int positionY, boolean renderAll) {
// zum probieren wird zunächst ein Grid gerendert.
byte index = 0;
for(byte y = 0; y < mapTileCountY; y++) {
for(byte x = 0; x < mapTileCountX; x++) {
if(((positionX >= (int)(x * mapTileSize) - (int)mapTileSize && positionX <= (int)(x + 1) * (int)mapTileSize &&
positionY >= (int)(y * mapTileSize) - (int)mapTileSize && positionY <= (int)(y + 1) * (int)mapTileSize)) ||
renderAll) {
byte bTile = pgm_read_byte_near(mapContent + index);
// TODO: Kartenspezifische abhangigkeit,
// Eigenschaften andern sich mit Kartenwechsel
if(bTile == 2 && mapKeyIsGet) { bTile = 0; }
if(bTile == 5 && mapBarrierDoorIsOpen) { bTile = 0; }
renderMapTile(x, y, bTile);
}
index++;
}
}
}
...
Momentan werden die zwei Werte noch direkt in der Funktion 'renderMap' aufgerufen. Die ergänzende Ausführung ist Simple. Solange sich noch die Objekte an ihren Stellen befinden, werden die Kacheln in der vorgesehenden Farbe eingefärbt. Die Funktion 'renderMapTile' benötigt daher weitere 'case´s'.
Die Kacheln, an dem eine Tür oder ein Schlüssel liegt, erfüllen zwei Eigenschaften. Die Kachel ist weiterhin begehbar und hat ein Objekt. Wurde das Objekt aufgenommen, wird jedoch im Karten Array der Wert nicht auf '0' gesetzt. Denn die Karte wird immer aus dem Flashspeicher geladen und kann nur gelesen werden. Deshalb werden neue Funktionsvariablen angelegt die den Status der Kachel wiedergeben. Das wird bereits in der Funktion 'renderMap' erledigt. Später erfüllen die Variablen auch für andere Karten dieselbe Rolle. Die Information wird jedoch für die Karte hinfällig, wenn sie verlassen wird. Aber dazu in einen späteren Post.
Die Kollisionsabfrage 'checkCollideNeighbor' wurde erweitert, um den Wert '2' und '5'. Die Werte '3' und '4' werden jetzt noch nicht verwendet, sollen aber später die selbe Eigenschaft haben, wie der Wert '2'. Der folgende Vorgang prüft ähnlich wie bei einer Kollision mit einer Wand. Allerdings wird hier nach einem Objekt geprüft, dass in der zu betretenden Kachel vorhanden ist.
...
if(bTile == 2) {
resultColide = checkCollide(positionX, positionY, mapOffsetX, mapOffsetY);
// Abruf des Objektes, dass zu der Karte gehoert an der Position.
if(!resultColide) {
if(setItemToBackpack(1)) {
mapKeyIsGet = true;
}
// nicht blockieren
resultColide = true;
}
}
...
Der Wert '5' benötigt ein anderes Vorgehen, hält sich jedoch ebenfalls simpel. Auch hier wird vorher abgefragt, ob ein Hindernis besteht. Wenn nicht, dann prüfe ob die Tür offen ist oder der Schlüssel die Tür öffnet. In diesem Fall verschwindet der braune Block.
...
if(bTile == 5) {
resultColide = checkCollide(positionX, positionY, mapOffsetX, mapOffsetY);
// Uebergabewert des Verwendungswecks > Tuer oeffnen.
// kollision aufheben
if(!resultColide) {
// ID 1 ist der Schlüssel und entscheidet,
// ob die Tuer sich oeffen laest.
resultColide = getItemToUsed(1);
}
}
...
Animationslos verschwindet die Tür. Hier färbt sich die braune Kachel in hell grün (sieht leider mehr grau aus), sowie die anderen Kacheln die begehbar sind. So dass sollte Inhaltlich vom Blogpost reichen. Das Thema ist länger geworden als vorgesehen und dabei habe ich einiges noch gekürzt. Alles weiter sowie Kommentar Beschreibungen sind in den Sourcen eingetragen, die ich wieder auf Github hoch geladen habe.
Dass die Grundfunktionen noch nicht reichen, dürfte klar sein und viele würden lieber ein Schwert ziehen und Monster bekämpfen. Aber, wie bereits ein weiser Mann Sprach: "Wie soll das Schwert richtig geschwungen werden, wenn das nicht mal mit einem Stock geht".
Sicherlich habt ihr entweder am Programcode oder beim Testen der Spielfunktionen bemerkt, dass die Kollisionsabfrage nur bedingt funktioniert. Sie ist zwar einfach, aber hier fehlt die Einschränkung, dass man sich nur von Block zu Block bewegen kann. Offen gestanden war ich kein Fan davon, das sich die Figur weiter bewegt bis der nächste Feld oder Kachel erreicht wurde.
Zu dem Thema Spieleprogrammierung und Kollisionsabfrage für 2D Spiele, können verschiedene Lösung im Internet gefunden werden. Ein Beispiel wird hier auf spieleprogrammierer.de/wiki beschrieben, wie man mit Geometrischen Objekten die Kollision Abfragen kann.
Die simple Form für die Kollisionserkennung ist das Verwenden von zwei Rechtecken. Im folgenden Code zeigt die Methode die wesentliche Abfrage von überschneidenden Rechtecken.
// Kachel Position mit zukuenftiger Position der Figur abgeleichen,
// durch ansetzten von Rechtecken und ob diese sich ueberschneiden.
boolean checkCollide(byte positionX, byte positionY, byte mapOffsetX, byte mapOffsetY) {
if(positionX < mapOffsetX + mapTileSize &&
positionX + 10 > mapOffsetX &&
positionY < mapOffsetY + mapTileSize &&
positionY + 16 > mapOffsetY)
{
// DEBUG: Nur fuer debug und visuelle kontrolle
EsploraTFT.drawRect(positionX, positionY, 10, 16, 0xFA8A);
return false;
}
return true;
}
Die Abfrage reicht jedoch nicht aus, um zu verhindern, dass die Figur wieder durch die Wand geht. Oft müssen auch übereinander oder nebeneinander liegende Kacheln zusätzlich geprüft werden. Für einen späteren Blogpost wird die Kachelgröße Reduziert von 16x16 auf 8x8. Spätestens dann wird die jetzige Abfrage erforderlich sein, alle Hindernisse zu erkennen. Das war leider nicht ganz ohne und zugegeben habe ich daran relativ viel Zeit damit verbracht, die Kollisionen durch zu debuggen.
// Prüfen, ob in diesem Bereich sich bewegt werden kann.
boolean CanEnterArea(int positionX, int positionY) {
boolean resultColide = true;
// umliegende Kacheln auf hindernis prüfen
// wenn hoch oder runter
if(directionX == 0) {
for(uint8_t i = 0; i < 3; i++) {
int tileX = (positionX + (collisionTiles[i] * mapTileSize)) / mapTileSize;
int tileY = -1;
int tileYTemp = tileY;
int positionYShift = 0;
while(tileY == tileYTemp && tileY != 0) {
if(directionY == -1) {
tileY = (positionY + directionY + positionYShift) / mapTileSize;
}
else { tileY = (positionY + directionY + 16 + positionYShift) / mapTileSize; }
positionYShift += mapTileSize * directionY;
}
resultColide = checkCollideNeighbor(positionX, positionY + directionY, tileX, tileY);
if(!resultColide) {
break;
}
}
}
// wenn links oder rechts
if(directionY == 0) {
for(uint8_t i = 0; i < 3; i++) {
int tileX = positionX / mapTileSize;
int tileY = (positionY + (collisionTiles[i] * mapTileSize)) / mapTileSize;
int tileXTemp = tileX;
int positionXShift = 0;
while(tileX == tileXTemp) {
if(directionX == -1) { tileX = (positionX + directionX + positionXShift) / mapTileSize; }
else { tileX = (positionX + directionX + 10 + positionXShift) / mapTileSize; }
positionXShift += mapTileSize * directionX;
}
resultColide = checkCollideNeighbor(positionX + directionX, positionY, tileX, tileY);
if(!resultColide) {
break;
}
}
}
return resultColide;
}
// Laedt aus dem Flashspeicher die Kachelelemente ab und
// prueft die Kollision mit neben anliegende Kacheln.
// Verhindert speziel den Fehhler zwischen zwei Kacheln, nur eine zu pruefen.
boolean checkCollideNeighbor(int positionX, int positionY, int tileX, int tileY) {
boolean resultColide = true;
int mapOffsetX = tileX * mapTileSize;
int mapOffsetY = tileY * mapTileSize;
int indexStart = (tileY * mapTileCountX) + tileX;
// hole die content Nummer ab um die kollisionsart zu bestimmen
byte bTile = pgm_read_byte_near(mapContent + indexStart);
if(bTile == 1 && resultColide) {
// DEBUG: Nur fuer debug und visuelle kontrolle
EsploraTFT.drawRect(mapOffsetX, mapOffsetY, mapTileSize, mapTileSize, 0xFA8A);
resultColide = checkCollide(positionX, positionY, mapOffsetX, mapOffsetY);
}
return resultColide;
}
Nun eckt die Figur in positiven Sinne überall an und kann sich nicht mehr wie ein Geist durch die Wand bewegen. In einen späteren Post wird die Kollisionsabfrage auch für Türen verwendet, um z.B. einen Kartenwechsel auszulösen.
Für Debug und Demo Zwecke, werden die Rechtecke mit eingezeichnet, die visuell die Kollision abbilden.
Der Clip zeigt die ungenaue Kollisionsabfrage, wie sie zuvor war. Wie bereits beschrieben, war diese simple und schnell umgesetzt.
Mit der implementieren der Abfrage von überschneidenden Rechtecken sieht das Ergebnis besser aus.
Der Arduino oder auch vielmehr der verwendete Mikrocontroller hat für viele Anwendungen genügend Arbeitsspeicher. Im ersten Teil der Blogpost Reihe verwendete ich einen Arduino Nano, der einen ATmega328 hat und einen Arbeitsspeicher von 2kByte besitzt. Der Arduino Esplora verwendet den ATmega32u4 der wiederum 2,5kByte Arbeitsspeicher aufweist. Trotz des etwas größeren Arbeitsspeichers muss für dieses Projekt dennoch sparsam damit umgegangen werden.
ATmega328P und ATmega32u4
Arbeitsspeicher verbrauch
Ein Sprite Bild besteht selbst aus 160 Bytes. Das klingt jetzt nicht viel, aber verbraucht den Arbeitsspeicher bereits mit über 6%. Würde man die Sprite Animation der Figur nicht mit dem Trick einzelner Bilder spiegeln, dann würden insgesamt 2,92kByte Arbeitsspeicher anfallen. Stattdessen werden momentan 1,12kB verwendet, dass allerdings für das Ziel immer noch zu viel ist. Und dann kommt noch die Karte mit 160 Bytes hinzu, die noch sehr grob ist. Da bleibt am Ende nicht viel übrig. Der jetzige Sketch verwendet ca. 1,575kBytes Arbeitsspeicher.
Vom Flashspeicher
Im Gegensatz zu dem insgesamten Flash Speicher mit 32kB, ist dieser gerade mal mit 12,45kB belegt. Damit liegt nahe, dass Sprites und weitere Daten am besten zur Laufzeit geladen werden. Hier kommt ein Kompromiss zustande über die Lesegeschwindigkeit von Flasch und RAM.
Eine kleine Umstellung und die Byte Array lassen sich aus dem Flashspeicher lesen, wenn diese zur Laufzeigt benötigt werden. Die folgenden Ergebnisse nach dem Kompilieren zeigen den Unterschied zwischen dem Sketch vom letzten Stand mit dem Anlegen der Karte und das gleiche jedoch nach der Umstellung mit PROGMEM.
Ohne PROGMEM
Mit PROGMEM
Weitere Informationen könnt ihr auf der Arduino Seite über PROGMEM erfahren.
Das folgende Code Ausschnitt zeigt die Änderung der Funktionsvariable eines Byte Array ergänzt wird.
Sobald alle Byte Arrays mit dem Präfix 'const' und 'PROGMEM' erweitert wurden, dürfte der Belegte Speicher deutlich gesunken sein. Ein Byte Array bleibt allerdings immer im Speicher, dass ist der Buffer oder wie im Beispiel 'tempArray' benannt wird aus dem Flash in das Byte Array geladen, das über eine einfache Funktion übertragen wird.
// kopiert den Array Inhalt vom Flashspeicher in den SRAM
Alle Funktionsvariablen die nicht zur aktuellen Ausführung verwendet werden, könnten über die Funktion erweitert werden. Also eine Spriteanimation rendert immer nur eines der angelegten Sprites.
Kaum angeschaut, habe ich mir den Wemos mit OLED Display beim Chinesischen Händler bestellt. Dann ca. vier Wochen später lag nun das Wemos@Lolin auf meinem Tisch und versuchte gleich ein Beispiel Code darauf zu schreiben. Leider musste ich zunächst feststellen, dass die bereits bei mir installierte Bibliothek nicht dieses Board aufführte. Und damit fing die Abendliche Suche an.
Nach kurzer suche fand ich diesen Link zu espressif. Zugegeben wollte ich nicht noch ein Tool installieren, dass mir im Grunde nur die Dateien in das Ziel Verzeichnis kopiert, also klickte ich auf den Download Button von dieser Seite des Github Accounts. Anschließend kopierte ich die Sourcen in den selbst angelegten Ordner "esp32/esp32" im Unterverzeichnis der Arduino Anwendung "../Arduino/hardware/"
Nach dem Start der Arduino Anwendung konnte unter Werkzeuge => Bord => WEMOS LOLIN32 ausgewählt werden.
Dann noch den Port auswählen und die Verbindung konnte hergestellt werden. Weitere Einstellungen mussten nicht vorgenommen werden.
Zuletzt fehlt noch die Bibliothek für die OLED Display von squix78 Github Account.
Nach dem Zip Download kann der entpackte Inhalt unter "…/Dokumente/Arduino/libraries/.." hinein kopiert werden.
Jetzt kann endlich ein Programmcode auf den Wemos geschrieben werden, dass auch das integrierte OLED ansteuern kann. Zu der OLED Bibliothek liegen bereits ein paar Beispiele.
Damit diese auch funktionieren, muss die Pin Zuweisung geändert werden. Sonst erhält ihr die Meldung "'D3' was not decleared in this scope".
Hierzu ändert die Pin Zuweisung auf 5 und 4. Dann sollte sich er Programmcode kompilieren lassen.
Damit nun der Programmcode auch auf dem Wemos geschrieben werden kann, muss mit dem hochladen die ‚Boot‘ Taste gedrückt werden.
Erst dann lässt sich das Programm erfolgreich hoch laden und euer Ergebnis ansehen.