ColorFighter - C ++ - isst ein paar Schlucker zum Frühstück
BEARBEITEN
- den Code aufgeräumt
- fügte eine einfache aber effektive Optimierung hinzu
- einige GIF-Animationen hinzugefügt
Gott, ich hasse Schlangen (tu einfach so, als wären sie Spinnen, Indy)
Eigentlich liebe ich Python. Ich wünschte, ich wäre weniger faul und hätte angefangen, es richtig zu lernen, das ist alles.
Trotzdem musste ich mich mit der 64-Bit-Version dieser Schlange herumschlagen, um den Richter zum Laufen zu bringen. PIL mit der 64-Bit-Version von Python unter Win7 zum Laufen zu bringen, erfordert mehr Geduld, als ich bereit war, mich dieser Herausforderung zu widmen. Am Ende bin ich (schmerzhaft) auf die Win32-Version umgestiegen.
Außerdem stürzt der Richter häufig schwer ab, wenn ein Bot zu langsam ist, um zu reagieren.
Da ich kein Python-Kenner bin, habe ich es nicht behoben, aber es hat mit dem Lesen einer leeren Antwort nach einem Timeout auf stdin zu tun.
Eine kleine Verbesserung wäre, die stderr-Ausgabe für jeden Bot in eine Datei zu legen. Dies würde die Nachverfolgung für das Post-Mortem-Debugging erleichtern.
Abgesehen von diesen kleinen Problemen fand ich den Richter sehr einfach und angenehm zu bedienen.
Ein dickes Lob für eine weitere erfinderische und unterhaltsame Herausforderung.
Der Code
#define _CRT_SECURE_NO_WARNINGS // prevents Microsoft from croaking about the safety of scanf. Since every rabid Russian hacker and his dog are welcome to try and overflow my buffers, I could not care less.
#include "lodepng.h"
#include <vector>
#include <deque>
#include <iostream>
#include <sstream>
#include <cassert> // paranoid android
#include <cstdint> // fixed size types
#include <algorithm> // min max
using namespace std;
// ============================================================================
// The less painful way I found to teach C++ how to handle png images
// ============================================================================
typedef unsigned tRGB;
#define RGB(r,g,b) (((r) << 16) | ((g) << 8) | (b))
class tRawImage {
public:
unsigned w, h;
tRawImage(unsigned w=0, unsigned h=0) : w(w), h(h), data(w*h * 4, 0) {}
void read(const char* filename) { unsigned res = lodepng::decode(data, w, h, filename); assert(!res); }
void write(const char * filename)
{
std::vector<unsigned char> png;
unsigned res = lodepng::encode(png, data, w, h, LCT_RGBA); assert(!res);
lodepng::save_file(png, filename);
}
tRGB get_pixel(int x, int y) const
{
size_t base = raw_index(x,y);
return RGB(data[base], data[base + 1], data[base + 2]);
}
void set_pixel(int x, int y, tRGB color)
{
size_t base = raw_index(x, y);
data[base+0] = (color >> 16) & 0xFF;
data[base+1] = (color >> 8) & 0xFF;
data[base+2] = (color >> 0) & 0xFF;
data[base+3] = 0xFF; // alpha
}
private:
vector<unsigned char> data;
void bound_check(unsigned x, unsigned y) const { assert(x < w && y < h); }
size_t raw_index(unsigned x, unsigned y) const { bound_check(x, y); return 4 * (y * w + x); }
};
// ============================================================================
// coordinates
// ============================================================================
typedef int16_t tCoord;
struct tPoint {
tCoord x, y;
tPoint operator+ (const tPoint & p) const { return { x + p.x, y + p.y }; }
};
typedef deque<tPoint> tPointList;
// ============================================================================
// command line and input parsing
// (in a nice airtight bag to contain the stench of C++ string handling)
// ============================================================================
enum tCommand {
c_quit,
c_update,
c_play,
};
class tParser {
public:
tRGB color;
tPointList points;
tRGB read_color(const char * s)
{
int r, g, b;
sscanf(s, "(%d,%d,%d)", &r, &g, &b);
return RGB(r, g, b);
}
tCommand command(void)
{
string line;
getline(cin, line);
string cmd = get_token(line);
points.clear();
if (cmd == "exit") return c_quit;
if (cmd == "pick") return c_play;
// even more convoluted and ugly than the LEFT$s and RIGHT$s of Apple ][ basic...
if (cmd != "colour")
{
cerr << "unknown command '" << cmd << "'\n";
exit(0);
}
assert(cmd == "colour");
color = read_color(get_token(line).c_str());
get_token(line); // skip "chose"
while (line != "")
{
string coords = get_token(line);
int x = atoi(get_token(coords, ',').c_str());
int y = atoi(coords.c_str());
points.push_back({ x, y });
}
return c_update;
}
private:
// even more verbose and inefficient than setting up an ADA rendezvous...
string get_token(string& s, char delimiter = ' ')
{
size_t pos = 0;
string token;
if ((pos = s.find(delimiter)) != string::npos)
{
token = s.substr(0, pos);
s.erase(0, pos + 1);
return token;
}
token = s; s.clear(); return token;
}
};
// ============================================================================
// pathing
// ============================================================================
class tPather {
public:
tPather(tRawImage image, tRGB own_color)
: arena(image)
, w(image.w)
, h(image.h)
, own_color(own_color)
, enemy_threat(false)
{
// extract colored pixels and own color areas
tPointList own_pixels;
color_plane[neutral].resize(w*h, false);
color_plane[enemies].resize(w*h, false);
for (size_t x = 0; x != w; x++)
for (size_t y = 0; y != h; y++)
{
tRGB color = image.get_pixel(x, y);
if (color == col_white) continue;
plane_set(neutral, x, y);
if (color == own_color) own_pixels.push_back({ x, y }); // fill the frontier with all points of our color
}
// compute initial frontier
for (tPoint pixel : own_pixels)
for (tPoint n : neighbour)
{
tPoint pos = pixel + n;
if (!in_picture(pos)) continue;
if (image.get_pixel(pos.x, pos.y) == col_white)
{
frontier.push_back(pixel);
break;
}
}
}
tPointList search(size_t pixels_required)
{
// flood fill the arena, starting from our current frontier
tPointList result;
tPlane closed;
static tCandidate pool[max_size*max_size]; // fastest possible garbage collection
size_t alloc;
static tCandidate* border[max_size*max_size]; // a FIFO that beats a deque anytime
size_t head, tail;
static vector<tDistance>distance(w*h); // distance map to be flooded
size_t filling_pixels = 0; // end of game optimization
get_more_results:
// ready the distance map for filling
distance.assign(w*h, distance_max);
// seed our flood fill with the frontier
alloc = head = tail = 0;
for (tPoint pos : frontier)
{
border[tail++] = new (&pool[alloc++]) tCandidate (pos);
}
// set already explored points
closed = color_plane[neutral]; // that's one huge copy
// add current result
for (tPoint pos : result)
{
border[tail++] = new (&pool[alloc++]) tCandidate(pos);
closed[raw_index(pos)] = true;
}
// let's floooooood!!!!
while (tail > head && pixels_required > filling_pixels)
{
tCandidate& candidate = *border[head++];
tDistance dist = candidate.distance;
distance[raw_index(candidate.pos)] = dist++;
for (tPoint n : neighbour)
{
tPoint pos = candidate.pos + n;
if (!in_picture (pos)) continue;
size_t index = raw_index(pos);
if (closed[index]) continue;
if (color_plane[enemies][index])
{
if (dist == (distance_initial + 1)) continue; // already near an enemy pixel
// reached the nearest enemy pixel
static tPoint trail[max_size * max_size / 2]; // dimensioned as a 1 pixel wide spiral across the whole map
size_t trail_size = 0;
// walk back toward the frontier
tPoint walker = candidate.pos;
tDistance cur_d = dist;
while (cur_d > distance_initial)
{
trail[trail_size++] = walker;
tPoint next_n;
for (tPoint n : neighbour)
{
tPoint next = walker + n;
if (!in_picture(next)) continue;
tDistance prev_d = distance[raw_index(next)];
if (prev_d < cur_d)
{
cur_d = prev_d;
next_n = n;
}
}
walker = walker + next_n;
}
// collect our precious new pixels
if (trail_size > 0)
{
while (trail_size > 0)
{
if (pixels_required-- == 0) return result; // ;!; <-- BRUTAL EXIT
tPoint pos = trail[--trail_size];
result.push_back (pos);
}
goto get_more_results; // I could have done a loop, but I did not bother to. Booooh!!!
}
continue;
}
// on to the next neighbour
closed[index] = true;
border[tail++] = new (&pool[alloc++]) tCandidate(pos, dist);
if (!enemy_threat) filling_pixels++;
}
}
// if all enemies have been surrounded, top up result with the first points of our flood fill
if (enemy_threat) enemy_threat = pixels_required == 0;
tPathIndex i = frontier.size() + result.size();
while (pixels_required--) result.push_back(pool[i++].pos);
return result;
}
// tidy up our map and frontier while other bots are thinking
void validate(tPointList moves)
{
// report new points
for (tPoint pos : moves)
{
frontier.push_back(pos);
color_plane[neutral][raw_index(pos)] = true;
}
// remove surrounded points from frontier
for (auto it = frontier.begin(); it != frontier.end();)
{
bool in_frontier = false;
for (tPoint n : neighbour)
{
tPoint pos = *it + n;
if (!in_picture(pos)) continue;
if (!(color_plane[neutral][raw_index(pos)] || color_plane[enemies][raw_index(pos)]))
{
in_frontier = true;
break;
}
}
if (!in_frontier) it = frontier.erase(it); else ++it; // the magic way of deleting an element without wrecking your iterator
}
}
// handle enemy move notifications
void update(tRGB color, tPointList points)
{
assert(color != own_color);
// plot enemy moves
enemy_threat = true;
for (tPoint p : points) plane_set(enemies, p);
// important optimization here :
/*
* Stop 1 pixel away from the enemy to avoid wasting moves in dogfights.
* Better let the enemy gain a few more pixels inside the surrounded region
* and use our precious moves to get closer to the next threat.
*/
for (tPoint p : points) for (tPoint n : neighbour) plane_set(enemies, p+n);
// if a new enemy is detected, gather its initial pixels
for (tRGB enemy : known_enemies) if (color == enemy) return;
known_enemies.push_back(color);
tPointList start_areas = scan_color(color);
for (tPoint p : start_areas) plane_set(enemies, p);
}
private:
typedef uint16_t tPathIndex;
typedef uint16_t tDistance;
static const tDistance distance_max = 0xFFFF;
static const tDistance distance_initial = 0;
struct tCandidate {
tPoint pos;
tDistance distance;
tCandidate(){} // must avoid doing anything in this constructor, or pathing will slow to a crawl
tCandidate(tPoint pos, tDistance distance = distance_initial) : pos(pos), distance(distance) {}
};
// neighbourhood of a pixel
static const tPoint neighbour[4];
// dimensions
tCoord w, h;
static const size_t max_size = 1000;
// colors lookup
const tRGB col_white = RGB(0xFF, 0xFF, 0xFF);
const tRGB col_black = RGB(0x00, 0x00, 0x00);
tRGB own_color;
const tRawImage arena;
tPointList scan_color(tRGB color)
{
tPointList res;
for (size_t x = 0; x != w; x++)
for (size_t y = 0; y != h; y++)
{
if (arena.get_pixel(x, y) == color) res.push_back({ x, y });
}
return res;
}
// color planes
typedef vector<bool> tPlane;
tPlane color_plane[2];
const size_t neutral = 0;
const size_t enemies = 1;
bool plane_get(size_t player, tPoint p) { return plane_get(player, p.x, p.y); }
bool plane_get(size_t player, size_t x, size_t y) { return in_picture(x, y) ? color_plane[player][raw_index(x, y)] : false; }
void plane_set(size_t player, tPoint p) { plane_set(player, p.x, p.y); }
void plane_set(size_t player, size_t x, size_t y) { if (in_picture(x, y)) color_plane[player][raw_index(x, y)] = true; }
bool in_picture(tPoint p) { return in_picture(p.x, p.y); }
bool in_picture(int x, int y) { return x >= 0 && x < w && y >= 0 && y < h; }
size_t raw_index(tPoint p) { return raw_index(p.x, p.y); }
size_t raw_index(size_t x, size_t y) { return y*w + x; }
// frontier
tPointList frontier;
// register enemies when they show up
vector<tRGB>known_enemies;
// end of game optimization
bool enemy_threat;
};
// small neighbourhood
const tPoint tPather::neighbour[4] = { { -1, 0 }, { 1, 0 }, { 0, -1 }, { 0, 1 } };
// ============================================================================
// main class
// ============================================================================
class tGame {
public:
tGame(tRawImage image, tRGB color, size_t num_pixels)
: own_color(color)
, response_len(num_pixels)
, pather(image, color)
{}
void main_loop(void)
{
// grab an initial answer in case we're playing first
tPointList moves = pather.search(response_len);
for (;;)
{
ostringstream answer;
size_t num_points;
tPointList played;
switch (parser.command())
{
case c_quit:
return;
case c_play:
// play as many pixels as possible
if (moves.size() < response_len) moves = pather.search(response_len);
num_points = min(moves.size(), response_len);
for (size_t i = 0; i != num_points; i++)
{
answer << moves[0].x << ',' << moves[0].y;
if (i != num_points - 1) answer << ' '; // STL had more important things to do these last 30 years than implement an implode/explode feature, but you can write your own custom version with exception safety and in-place construction. It's a bit of work, but thanks to C++ inherent genericity you will be able to extend it to giraffes and hippos with a very manageable amount of code refactoring. It's not anyone's language, your C++, eh. Just try to implode hippos in Python. Hah!
played.push_back(moves[0]);
moves.pop_front();
}
cout << answer.str() << '\n';
// now that we managed to print a list of points to stdout, we just need to cleanup the mess
pather.validate(played);
break;
case c_update:
if (parser.color == own_color) continue; // hopefully we kept track of these already
pather.update(parser.color, parser.points);
moves = pather.search(response_len); // get cracking
break;
}
}
}
private:
tParser parser;
tRGB own_color;
size_t response_len;
tPather pather;
};
void main(int argc, char * argv[])
{
// process command line
tRawImage raw_image; raw_image.read (argv[1]);
tRGB my_color = tParser().read_color(argv[2]);
int num_pixels = atoi (argv[3]);
// init and run
tGame game (raw_image, my_color, num_pixels);
game.main_loop();
}
Erstellen der ausführbaren Datei
Ich habe LODEpng.cpp und LODEpng.h zum Lesen von PNG-Bildern verwendet.
Auf die einfachste Art und Weise brachte ich dieser verzögerten C ++ - Sprache bei, wie man ein Bild liest, ohne ein halbes Dutzend Bibliotheken erstellen zu müssen.
Kompilieren und verlinken Sie einfach LODEpng.cpp zusammen mit dem Main und Bob ist Ihr Onkel.
Ich habe mit MSVC2013 kompiliert, aber da ich nur ein paar STL-Basiscontainer (Deque und Vektoren) verwendet habe, funktioniert es möglicherweise mit gcc (wenn Sie Glück haben).
Wenn nicht, probiere ich vielleicht einen MinGW-Build aus, aber ehrlich gesagt habe ich keine Lust mehr auf C ++ - Portabilitätsprobleme.
Ich habe in meinen Tagen ziemlich viel portables C / C ++ gemacht (auf exotischen Compilern für verschiedene 8- bis 32-Bit-Prozessoren sowie auf SunOS, Windows von 3.11 bis Vista und Linux von den Anfängen bis zu Ubuntu, das Zebra gurrt, oder was auch immer, denke ich Ich habe eine ziemlich gute Vorstellung davon, was Portabilität bedeutet, aber zu der Zeit mussten die unzähligen Diskrepanzen zwischen der GNU- und der Microsoft-Interpretation der kryptischen und aufgeblähten Spezifikation des STL-Monsters nicht auswendig gelernt (oder entdeckt) werden.
Ergebnisse gegen Swallower
Wie es funktioniert
Im Kern handelt es sich um ein einfaches Brute-Force-Floodfill-Pathing.
Die Grenze der Farbe des Players (dh die Pixel, die mindestens einen weißen Nachbarn haben) wird als Ausgangswert für den klassischen Entfernungsüberschwemmungsalgorithmus verwendet.
Wenn ein Punkt die Nähe einer feindlichen Farbe erreicht, wird ein Rückwärtspfad berechnet, um eine Folge von Pixeln zu erzeugen, die sich zum nächsten feindlichen Punkt bewegen.
Der Vorgang wird wiederholt, bis genügend Punkte für eine Antwort der gewünschten Länge gesammelt wurden.
Diese Wiederholung ist unglaublich teuer, besonders wenn man in der Nähe des Feindes kämpft.
Jedes Mal, wenn eine Reihe von Pixeln gefunden wurde, die von der Grenze zu einem feindlichen Pixel führen (und wir brauchen mehr Punkte, um die Antwort zu vervollständigen), wird die Überflutungsfüllung von Anfang an überarbeitet und der neue Pfad zur Grenze hinzugefügt. Dies bedeutet, dass Sie möglicherweise 5 oder mehr Überflutungen durchführen müssen, um eine 10-Pixel-Antwort zu erhalten.
Wenn keine feindlichen Pixel mehr erreichbar sind, werden arbitratische Nachbarn der Grenzpixel ausgewählt.
Der Algorithmus führt zu einer eher ineffizienten Überflutung, dies geschieht jedoch erst, nachdem der Ausgang des Spiels entschieden wurde (dh es gibt kein neutrales Gebiet mehr, für das man kämpfen kann).
Ich habe es so optimiert, dass der Richter nicht ewig die Karte auffüllt, sobald der Wettbewerb abgeschlossen ist. In seinem gegenwärtigen Zustand ist die Ausführungszeit im Vergleich zum Richter selbst vernachlässigbar.
Da die feindlichen Farben zu Beginn nicht bekannt sind, wird das ursprüngliche Bild der Arena gespeichert, um die Startbereiche des Feindes zu kopieren, wenn dieser seinen ersten Zug macht.
Wenn der Code zuerst abgespielt wird, wird er einfach ein paar beliebige Pixel füllen.
Dies macht den Algorithmus in der Lage, eine beliebige Anzahl von Gegnern und möglicherweise sogar neue Gegner, die zu einem zufälligen Zeitpunkt eintreffen, oder Farben, die ohne Startbereich erscheinen, zu bekämpfen (obwohl dies absolut keinen praktischen Nutzen hat).
Die Behandlung von Feinden auf Basis von Farbe pro Farbe würde es auch ermöglichen, dass zwei Instanzen des Bots zusammenarbeiten (unter Verwendung von Pixelkoordinaten, um ein geheimes Erkennungszeichen zu übergeben).
Hört sich nach Spaß an, das werde ich wahrscheinlich ausprobieren :).
Berechnungsintensives Pathing wird ausgeführt, sobald neue Daten verfügbar sind (nach einer Bewegungsmeldung), und einige Optimierungen (die Grenzaktualisierung) werden unmittelbar nach einer Antwort ausgeführt (um während der anderen Bots-Runden so viel wie möglich zu berechnen) ).
Auch hier könnte es Möglichkeiten geben, subtilere Dinge zu tun, wenn es mehr als einen Gegner gibt (z. B. einen Rechenvorgang abzubrechen, wenn neue Daten verfügbar werden), aber ich sehe jedenfalls nicht, wo Multitasking erforderlich ist, solange der Algorithmus vorhanden ist Volllast arbeiten können.
Performance-Probleme
All dies funktioniert nicht ohne schnellen Datenzugriff (und mehr Rechenleistung als das gesamte Appolo-Programm, dh Ihr durchschnittlicher PC, wenn Sie mehr als ein paar Tweets posten).
Die Geschwindigkeit ist stark vom Compiler abhängig. Normalerweise schlägt GNU Microsoft um 30% (das ist die magische Zahl, die ich bei drei anderen Code-Herausforderungen im Zusammenhang mit Pfaden bemerkt habe), aber dieser Kilometerstand kann natürlich variieren.
Der Code in seinem aktuellen Zustand bringt Arena 4 kaum ins Schwitzen. Der Windows-Leistungsmesser meldet eine CPU-Auslastung von 4 bis 7%, sodass er in der Lage sein sollte, mit einer 1000x1000-Karte innerhalb der Reaktionszeit von 100 ms fertig zu werden.
Das Herzstück eines jeden Pfadalgorithmus ist ein FIFO (möglicherweise priorisiert, aber nicht in diesem Fall), für das wiederum eine schnelle Elementzuweisung erforderlich ist.
Da das OP die Größe der Arena verbindlich einschränkte, habe ich einige Berechnungen angestellt und festgestellt, dass feste Datenstrukturen mit einer maximalen Größe (dh 1.000.000 Pixel) nicht mehr als ein paar Dutzend Megabyte verbrauchen, die Ihr durchschnittlicher PC zum Frühstück zu sich nimmt.
Unter Win7 und mit MSVC 2013 kompiliert, verbraucht der Code in Arena 4 ungefähr 14 MB, während die beiden Threads von Swallower mehr als 20 MB belegen.
Ich habe mit STL - Containern begonnen, um das Prototyping zu vereinfachen, aber STL hat den Code noch weniger lesbar gemacht, da ich nicht die Absicht hatte, eine Klasse zu erstellen, die jedes einzelne Datenbit kapselt, um die Verschleierung zu verbergen (ob das nun an meinen eigenen Unfähigkeiten liegt) Die Bewältigung des STL bleibt der Wertschätzung des Lesers überlassen.
Ungeachtet dessen war das Ergebnis so schrecklich langsam, dass ich zunächst dachte, ich würde versehentlich eine Debug-Version erstellen.
Ich denke, dies liegt zum einen an der unglaublich schlechten Microsoft-Implementierung der STL (wo zum Beispiel Vektoren und Bitsätze gebundene Prüfungen oder andere kryptische Operationen auf operator [] ausführen, die direkt gegen die Spezifikation verstoßen) und zum anderen am STL-Design selbst.
Ich könnte mit den grausamen Syntax- und Portabilitätsproblemen (z. B. Microsoft vs GNU) fertig werden, wenn die Leistungen vorhanden wären, aber dies ist sicherlich nicht der Fall.
Zum Beispiel deque
ist es von Natur aus langsam, weil es viele Buchhaltungsdaten durcheinanderbringt, während es darauf wartet, dass der Anlass seine superschicke Größenänderung vornimmt, die mir gleichgültig ist.
Natürlich hätte ich einen benutzerdefinierten Allokator und andere benutzerdefinierte Vorlagenbits implementieren können, aber ein benutzerdefinierter Allokator allein kostet ein paar hundert Codezeilen und den größten Teil eines Tages, um zu testen, was mit dem Dutzend von Schnittstellen zu tun ist, die er implementieren muss, während a Die handgefertigte äquivalente Struktur ist ungefähr null Codezeilen (obwohl gefährlicher, aber der Algorithmus hätte nicht funktioniert, wenn ich nicht gewusst hätte - oder geglaubt hätte - was ich sowieso tat).
Also habe ich schließlich die STL-Container in unkritischen Teilen des Codes aufbewahrt und meinen eigenen brutalen Allokator und FIFO mit zwei Arrays von ca. 1970 und drei nicht signierten Shorts erstellt.
Schlucken den Schlucker
Wie der Autor bestätigte, werden die fehlerhaften Muster des Schluckers durch Verzögerungen zwischen den Benachrichtigungen über feindliche Bewegungen und Aktualisierungen des Pfad-Threads verursacht.
Der System-Perfmeter zeigt deutlich, dass der Pfad-Thread die ganze Zeit 100% CPU verbraucht, und die gezackten Muster treten auf, wenn sich der Fokus des Kampfes auf einen neuen Bereich verlagert. Dies wird auch bei den Animationen deutlich.
Eine einfache aber effektive Optimierung
Nachdem ich mir die epischen Luftkämpfe zwischen Swallower und meinem Kämpfer angeschaut hatte, fiel mir ein altes Sprichwort aus dem Go-Spiel ein: Verteidigung aus der Nähe, aber Angriff aus der Ferne.
Darin liegt Weisheit. Wenn Sie versuchen, sich zu sehr an Ihren Gegner zu halten, verschwenden Sie wertvolle Moves, um jeden möglichen Pfad zu blockieren. Im Gegenteil, wenn Sie nur einen Pixel entfernt bleiben, vermeiden Sie wahrscheinlich, kleine Lücken zu schließen, die nur sehr wenig zunehmen würden, und setzen Sie Ihre Schritte ein, um wichtigeren Bedrohungen entgegenzuwirken.
Um diese Idee umzusetzen, habe ich einfach die Züge eines Feindes verlängert (die 4 Nachbarn jedes Zuges als feindliches Pixel markiert).
Dadurch wird der Suchalgorithmus einen Pixel von der feindlichen Grenze entfernt gestoppt, sodass sich mein Kämpfer um einen Gegner bewegen kann, ohne in zu viele Luftkämpfe verwickelt zu werden.
Sie können die Verbesserung sehen
(obwohl alle Läufe nicht so erfolgreich sind, können Sie die viel glatteren Umrisse bemerken):