Map Tiling-Algorithmus


153

Die Karte

Ich erstelle ein kachelbasiertes Rollenspiel mit Javascript, verwende Perlin-Rauschhöhenkarten und weise dann einen Kacheltyp basierend auf der Höhe des Rauschens zu.

Die Karten sehen am Ende ungefähr so ​​aus (in der Minikartenansicht).

Geben Sie hier die Bildbeschreibung ein

Ich habe einen ziemlich einfachen Algorithmus, der den Farbwert aus jedem Pixel im Bild extrahiert und ihn in eine Ganzzahl (0-5) umwandelt, abhängig von seiner Position zwischen (0-255), die einer Kachel im Kachelwörterbuch entspricht. Dieses 200x200-Array wird dann an den Client übergeben.

Die Engine ermittelt dann die Kacheln aus den Werten im Array und zeichnet sie auf die Zeichenfläche. Am Ende habe ich interessante Welten mit realistisch aussehenden Merkmalen: Berge, Meere usw.

Als nächstes wollte ich eine Art Mischalgorithmus anwenden, der dazu führt, dass sich Kacheln nahtlos in ihre Nachbarn einfügen, wenn der Nachbar nicht vom gleichen Typ ist. Die obige Beispielkarte ist das, was der Spieler in seiner Minikarte sieht. Auf dem Bildschirm sehen sie eine gerenderte Version des durch das weiße Rechteck markierten Abschnitts. Hier werden die Kacheln mit ihren Bildern und nicht als einfarbige Pixel gerendert.

Dies ist ein Beispiel dafür, was der Benutzer auf der Karte sehen würde, aber es ist nicht derselbe Ort, den das obige Ansichtsfenster zeigt!

Geben Sie hier die Bildbeschreibung ein

In dieser Ansicht möchte ich, dass der Übergang stattfindet.

Der Algorithmus

Ich habe einen einfachen Algorithmus entwickelt, der die Karte innerhalb des Ansichtsfensters durchläuft und ein weiteres Bild über jeder Kachel rendert, vorausgesetzt, es befindet sich neben einer Kachel unterschiedlichen Typs. (Ändern Sie die Karte nicht! Rendern Sie nur einige zusätzliche Bilder.) Die Idee des Algorithmus bestand darin, die Nachbarn der aktuellen Kachel zu profilieren:

Ein Beispiel für ein Kachelprofil

Dies ist ein Beispielszenario für das, was die Engine möglicherweise rendern muss, wobei die aktuelle Kachel mit dem X markiert ist.

Ein 3x3-Array wird erstellt und die umgebenden Werte werden eingelesen. In diesem Beispiel würde das Array also so aussehen.

[
    [1,2,2]
    [1,2,2]
    [1,1,2]
];

Meine Idee war es dann, eine Reihe von Fällen für die möglichen Kachelkonfigurationen auszuarbeiten. Auf einer sehr einfachen Ebene:

if(profile[0][1] != profile[1][1]){
     //draw a tile which is half sand and half transparent
     //Over the current tile -> profile[1][1]
     ...
}

Welches ergibt dieses Ergebnis:

Ergebnis

Das funktioniert als Übergang von [0][1]nach [1][1], aber nicht von [1][1]nach [2][1], wo eine harte Kante bleibt. Also dachte ich mir, dass in diesem Fall eine Eckplatte verwendet werden müsste. Ich habe zwei 3x3-Sprite-Blätter erstellt, von denen ich dachte, dass sie alle möglichen Kombinationen von Kacheln enthalten, die benötigt werden könnten. Dann habe ich dies für alle Kacheln im Spiel repliziert (Die weißen Bereiche sind transparent). Dies ergibt 16 Kacheln für jeden Kacheltyp (Die mittleren Kacheln auf jedem Spritesheet werden nicht verwendet.)

SandSand2

Das ideale Ergebnis

Mit diesen neuen Kacheln und dem richtigen Algorithmus würde der Beispielabschnitt folgendermaßen aussehen:

Richtig

Jeder Versuch, den ich gemacht habe, ist jedoch fehlgeschlagen, es gibt immer einen Fehler im Algorithmus und die Muster enden seltsam. Ich kann nicht alle Fälle richtig machen und insgesamt scheint es eine schlechte Art zu sein, dies zu tun.

Eine Lösung?

Wenn also jemand eine alternative Lösung anbieten könnte, wie ich diesen Effekt erzeugen könnte oder in welche Richtung der Profilerstellungsalgorithmus geschrieben werden soll, wäre ich sehr dankbar!


7
Schauen Sie sich diesen Artikel und die verlinkten Artikel an, insbesondere diesen . Der Blog selbst enthält viele Ideen, die als Ausgangspunkt dienen können. Hier ist eine Übersicht.
Darcara

Sie sollten Ihren Algorithmus vereinfachen. Überprüfen Sie dies: Two-Dimensional-Cellular-Automata
user1097489

Antworten:


117

Die Grundidee dieses Algorithmus besteht darin, in einem Vorverarbeitungsschritt alle Kanten zu finden und dann die richtige Glättungskachel entsprechend der Kantenform auszuwählen.

Der erste Schritt wäre, alle Kanten zu finden. In dem Beispiel unter den Randfliesen mit einem X gekennzeichnet sind , sind alle grünen Fliesen mit einem tan Fliese als eine oder mehrere ihrer acht benachbarten Fliesen. Bei verschiedenen Geländetypen kann diese Bedingung dazu führen, dass ein Plättchen ein Randplättchen ist, wenn es Nachbarn mit einer niedrigeren Geländezahl hat.

Kantenfliesen.

Sobald alle Kantenplättchen erkannt wurden, müssen Sie als Nächstes das richtige Glättungsplättchen für jedes Randplättchen auswählen. Hier ist meine Darstellung Ihrer Glättungskacheln.

Fliesen glätten.

Beachten Sie, dass es tatsächlich nicht so viele verschiedene Arten von Kacheln gibt. Wir brauchen die acht äußeren Kacheln von einem der 3x3-Quadrate, aber nur die vier Eckquadrate vom anderen, da die geraden Kacheln bereits im ersten Quadrat gefunden werden. Dies bedeutet, dass es insgesamt 12 verschiedene Fälle gibt, zwischen denen wir unterscheiden müssen.

Wenn wir nun eine Randkachel betrachten, können wir bestimmen, in welche Richtung sich die Grenze dreht, indem wir die vier Kacheln der nächsten Nachbarn betrachten. Wenn Sie eine Randkachel wie oben mit X markieren, haben wir die folgenden sechs verschiedenen Fälle.

Sechs Fälle.

Diese Fälle werden verwendet, um die entsprechende Glättungskachel zu bestimmen, und wir können die Glättungskacheln entsprechend nummerieren.

Geglättete Fliesen mit Zahlen.

Es gibt immer noch die Wahl zwischen a oder b für jeden Fall. Dies hängt davon ab, auf welcher Seite sich das Gras befindet. Eine Möglichkeit, dies festzustellen, könnte darin bestehen, die Ausrichtung der Grenze zu verfolgen. Die wahrscheinlich einfachste Möglichkeit besteht darin, eine Kachel neben der Kante auszuwählen und zu sehen, welche Farbe sie hat. Das Bild unten zeigt die beiden Fälle 5a) und 5b), die unterschieden werden können, indem beispielsweise die Farbe der oberen rechten Kachel überprüft wird.

Auswahl von 5a oder 5b.

Die endgültige Aufzählung für das ursprüngliche Beispiel würde dann so aussehen.

Endgültige Aufzählung.

Und nach Auswahl der entsprechenden Randkachel würde der Rand ungefähr so ​​aussehen.

Endergebnis.

Abschließend könnte ich sagen, dass dies funktionieren würde, solange die Grenze etwas regelmäßig ist. Genauer gesagt müssen Randkacheln, die nicht genau zwei Randkacheln als Nachbarn haben, separat behandelt werden. Dies tritt bei Randkacheln am Rand der Karte auf, die einen einzelnen Randnachbarn haben, und bei sehr schmalen Geländestücken, bei denen die Anzahl der benachbarten Randkacheln drei oder sogar vier betragen kann.


1
Das ist großartig und sehr hilfreich für mich. Ich habe es mit einem Fall zu tun, in dem einige Kacheln nicht direkt in andere übergehen können. Zum Beispiel können die "Schmutz" -Fliesen in "helles Gras" und "leichtes Gras" in "mittleres Gras" übergehen. Tiled (mapeditor.org) erledigt dies hervorragend, indem es eine Art Baumsuche für den Geländepinsel implementiert. Ich muss es jedoch noch reproduzieren.
Clay

12

Das folgende Quadrat repräsentiert eine Metallplatte. In der oberen rechten Ecke befindet sich eine "Wärmeentlüftung". Wir können sehen, wie die Metallplatte bei konstanter Temperatur dieses Punktes an jedem Punkt gegen eine konstante Temperatur konvergiert und in der Nähe der Oberseite von Natur aus heißer ist:

Heizplatte

Das Problem, die Temperatur an jedem Punkt zu finden, kann als "Randwertproblem" gelöst werden. Der einfachste Weg, die Wärme an jedem Punkt zu berechnen, besteht darin, die Platte als Gitter zu modellieren. Wir kennen die Punkte auf dem Gitter bei konstanter Temperatur. Wir stellen die Temperatur aller unbekannten Punkte auf Raumtemperatur ein (als ob die Entlüftung gerade erst eingeschaltet worden wäre). Wir lassen dann die Wärme über die Platte verteilen, bis wir Konvergenz erreichen. Dies geschieht durch Iteration: Wir iterieren durch jeden (i, j) Punkt. Wir setzen Punkt (i, j) = (Punkt (i + 1, j) + Punkt (i-1, j) + Punkt (i, j + 1) + Punkt (i, j-1)) / 4 [es sei denn Punkt (i, j) hat eine Wärmeentlüftung mit konstanter Temperatur]

Wenn Sie dies auf Ihr Problem anwenden, ist es sehr ähnlich, nur durchschnittliche Farben anstelle von Temperaturen. Sie würden wahrscheinlich ungefähr 5 Iterationen benötigen. Ich schlage vor, ein 400x400-Raster zu verwenden. Das sind 400x400x5 = weniger als 1 Million Iterationen, die schnell sein werden. Wenn Sie nur 5 Iterationen verwenden, müssen Sie sich wahrscheinlich keine Gedanken darüber machen, ob Punkte eine konstante Farbe haben, da sie sich nicht zu stark von ihrem Original verschieben (tatsächlich können nur Punkte innerhalb des Abstands 5 von der Farbe durch die Farbe beeinflusst werden). Pseudocode:

iterations = 5
for iteration in range(iterations):
    for i in range(400):
        for j in range(400):
            try:
                grid[i][j] = average(grid[i+1][j], grid[i-1][j],
                                     grid[i][j+1], grid[i][j+1])
            except IndexError:
                pass

Könntest du das etwas erweitern? Ich bin neugierig und ich kann deine Erklärung nicht verstehen. Wie verwendet man den durchschnittlichen Farbwert, nachdem Sie die Iterationen durchgeführt haben?
Chii

1
Jedes Gitterpunktgitter [i] [j] kann als kleines Rechteck (oder einzelnes Pixel) der entsprechenden Farbe auf die Leinwand gezeichnet werden.
Robert King

5

Ok, die ersten Gedanken sind also, dass die Automatisierung einer perfekten Lösung des Problems einige ziemlich fleischige Interpolationsmathematik erfordert. Aufgrund der Tatsache, dass Sie vorgerenderte Kachelbilder erwähnen, gehe ich davon aus, dass die vollständige Interpolationslösung hier nicht gerechtfertigt ist.

Auf der anderen Seite führt das Fertigstellen der Karte von Hand zu einem guten Ergebnis ... aber ich gehe auch davon aus, dass ein manueller Vorgang zum Beheben von Störungen ebenfalls keine Option ist.

Hier ist ein einfacher Algorithmus, der kein perfektes Ergebnis liefert, der jedoch aufgrund des geringen Aufwands sehr lohnend ist.

Anstatt zu versuchen, JEDE Randkachel zu mischen (was bedeutet, dass Sie entweder zuerst das Ergebnis der Mischung der benachbarten Kacheln kennen müssen - Interpolation, oder Sie müssen die gesamte Karte mehrmals verfeinern und können sich nicht auf vorgenerierte Kacheln verlassen). Warum nicht Fliesen in einem abwechselnden Schachbrettmuster mischen?

[1] [*] [2]
[*] [1] [*]
[1] [*] [2]

Dh nur die Kacheln in der Matrix oben mischen?

Unter der Annahme, dass die einzigen zulässigen Wertschritte einzeln sind, müssen Sie nur wenige Kacheln entwerfen ...

A    [1]      B    [2]      C    [1]      D    [2]      E    [1]           
 [1] [*] [1]   [1] [*] [1]   [1] [*] [2]   [1] [*] [2]   [1] [*] [1]   etc.
     [1]           [1]           [1]           [1]           [2]           

Insgesamt gibt es 16 Muster. Wenn Sie die Rotations- und Reflexionssymmetrie nutzen, gibt es noch weniger.

'A' wäre eine einfache [1] Kachel. 'D' wäre eine Diagonale.

An den Ecken der Kacheln treten kleine Diskontinuitäten auf, die jedoch im Vergleich zu dem von Ihnen angegebenen Beispiel geringfügig sind.

Wenn ich kann, werde ich diesen Beitrag später mit Bildern aktualisieren.


Das hört sich gut an. Es würde mich interessieren, es mit einigen Bildern zu sehen, um eine bessere Vorstellung davon zu bekommen, was Sie meinen.
Dan Prince

Ich kann keine Bilder zusammenstellen, weil ich nicht die Software habe, von der ich dachte, dass ich sie habe ... Aber ich habe nachgedacht und es ist keine so gute Lösung, wie es sein könnte. Sie können natürlich diagonale Übergänge ausführen, aber andere Übergänge werden durch diesen Glättungsalgorithmus nicht wirklich unterstützt. Sie können nicht einmal garantieren, dass Ihre Karte KEINE 90-Grad-Übergänge enthält. Tut mir leid, ich denke, das ist ein bisschen enttäuschend.
Perfektionist

3

Ich habe mit etwas Ähnlichem herumgespielt, es wurde aus mehreren Gründen nicht beendet; Aber im Grunde würde es eine Matrix von 0 und 1 brauchen, wobei 0 der Boden und 1 eine Wand für eine Labyrinthgeneratoranwendung in Flash ist. Da AS3 JavaScript ähnelt, ist das Umschreiben in JS nicht schwierig.

var tileDimension:int = 20;
var levelNum:Array = new Array();

levelNum[0] = [1, 1, 1, 1, 1, 1, 1, 1, 1];
levelNum[1] = [1, 0, 0, 0, 0, 0, 0, 0, 1];
levelNum[2] = [1, 0, 1, 1, 1, 0, 1, 0, 1];
levelNum[3] = [1, 0, 1, 0, 1, 0, 1, 0, 1];
levelNum[4] = [1, 0, 1, 0, 0, 0, 1, 0, 1];
levelNum[5] = [1, 0, 0, 0, 0, 0, 0, 0, 1];
levelNum[6] = [1, 0, 1, 1, 1, 1, 0, 0, 1];
levelNum[7] = [1, 0, 0, 0, 0, 0, 0, 0, 1];
levelNum[8] = [1, 1, 1, 1, 1, 1, 1, 1, 1];

for (var rows:int = 0; rows < levelNum.length; rows++)
{
    for (var cols:int = 0; cols < levelNum[rows].length; cols++)
    {
        // set up neighbours
        var toprow:int = rows - 1;
        var bottomrow:int = rows + 1;

        var westN:int = cols - 1;
        var eastN:int = cols + 1;

        var rightMax =  levelNum[rows].length;
        var bottomMax = levelNum.length;

        var northwestTile =     (toprow != -1 && westN != -1) ? levelNum[toprow][westN] : 1;
        var northTile =         (toprow != -1) ? levelNum[toprow][cols] : 1;
        var northeastTile =     (toprow != -1 && eastN < rightMax) ? levelNum[toprow][eastN] : 1;

        var westTile =          (cols != 0) ? levelNum[rows][westN] : 1;
        var thistile =          levelNum[rows][cols];
        var eastTile =          (eastN == rightMax) ? 1 : levelNum[rows][eastN];

        var southwestTile =     (bottomrow != bottomMax && westN != -1) ? levelNum[bottomrow][westN] : 1;
        var southTile =         (bottomrow != bottomMax) ? levelNum[bottomrow][cols] : 1;
        var southeastTile =     (bottomrow != bottomMax && eastN < rightMax) ? levelNum[bottomrow][eastN] : 1;

        if (thistile == 1)
        {
            var w7:Wall7 = new Wall7();
            addChild(w7);
            pushTile(w7, cols, rows, 0);

            // wall 2 corners

            if      (northTile === 0 && northeastTile === 0 && eastTile === 1 && southeastTile === 1 && southTile === 1 && southwestTile === 0 && westTile === 0 && northwestTile === 0)
            {
                var w21:Wall2 = new Wall2();
                addChild(w21);
                pushTile(w21, cols, rows, 270);
            }

            else if (northTile === 0 && northeastTile === 0 && eastTile === 0 && southeastTile === 0 && southTile === 1 && southwestTile === 1 && westTile === 1 && northwestTile === 0)
            {
                var w22:Wall2 = new Wall2();
                addChild(w22);
                pushTile(w22, cols, rows, 0);
            }

            else if (northTile === 1 && northeastTile === 0 && eastTile === 0 && southeastTile === 0 && southTile === 0 && southwestTile === 0 && westTile === 1 && northwestTile === 1)
            {
                var w23:Wall2 = new Wall2();
                addChild(w23);
                pushTile(w23, cols, rows, 90);
            }

            else if (northTile === 1 && northeastTile === 1 && eastTile === 1 && southeastTile === 0 && southTile === 0 && southwestTile === 0 && westTile === 0 && northwestTile === 0)
            {
                var w24:Wall2 = new Wall2();
                addChild(w24);
                pushTile(w24, cols, rows, 180);
            }           

            //  wall 6 corners

            else if (northTile === 1 && northeastTile === 1 && eastTile === 1 && southeastTile === 0 && southTile === 1 && southwestTile === 1 && westTile === 1 && northwestTile === 1)
            {
                var w61:Wall6 = new Wall6();
                addChild(w61);
                pushTile(w61, cols, rows, 0); 
            }

            else if (northTile === 1 && northeastTile === 1 && eastTile === 1 && southeastTile === 1 && southTile === 1 && southwestTile === 0 && westTile === 1 && northwestTile === 1)
            {
                var w62:Wall6 = new Wall6();
                addChild(w62);
                pushTile(w62, cols, rows, 90); 
            }

            else if (northTile === 1 && northeastTile === 1 && eastTile === 1 && southeastTile === 1 && southTile === 1 && southwestTile === 1 && westTile === 1 && northwestTile === 0)
            {
                var w63:Wall6 = new Wall6();
                addChild(w63);
                pushTile(w63, cols, rows, 180);
            }

            else if (northTile === 1 && northeastTile === 0 && eastTile === 1 && southeastTile === 1 && southTile === 1 && southwestTile === 1 && westTile === 1 && northwestTile === 1)
            {
                var w64:Wall6 = new Wall6();
                addChild(w64);
                pushTile(w64, cols, rows, 270);
            }

            //  single wall tile

            else if (northTile === 0 && northeastTile === 0 && eastTile === 0 && southeastTile === 0 && southTile === 0 && southwestTile === 0 && westTile === 0 && northwestTile === 0)
            {
                var w5:Wall5 = new Wall5();
                addChild(w5);
                pushTile(w5, cols, rows, 0);
            }

            //  wall 3 walls

            else if (northTile === 0 && eastTile === 1 && southTile === 0 && westTile === 1)
            {
                var w3:Wall3 = new Wall3();
                addChild(w3);
                pushTile(w3, cols, rows, 0);
            }

            else if (northTile === 1 && eastTile === 0 && southTile === 1 && westTile === 0)
            {
                var w31:Wall3 = new Wall3();
                addChild(w31);
                pushTile(w31, cols, rows, 90);
            }

            //  wall 4 walls

            else if (northTile === 0 && eastTile === 0 && southTile === 1 && westTile === 0)
            {
                var w41:Wall4 = new Wall4();
                addChild(w41);
                pushTile(w41, cols, rows, 0);
            }

            else if (northTile === 1 && eastTile === 0 && southTile === 0 && westTile === 0)
            {
                var w42:Wall4 = new Wall4();
                addChild(w42);
                pushTile(w42, cols, rows, 180);
            }

            else if (northTile === 0 && northeastTile === 0 && eastTile === 1 && southeastTile === 0 && southTile === 0 && southwestTile === 0 && westTile === 0 && northwestTile === 0)
            {
                var w43:Wall4 = new Wall4();
                addChild(w43);
                pushTile(w43, cols, rows, 270);
            }

            else if (northTile === 0 && northeastTile === 0 && eastTile === 0 && southeastTile === 0 && southTile === 0 && southwestTile === 0 && westTile === 1 && northwestTile === 0)
            {
                var w44:Wall4 = new Wall4();
                addChild(w44);
                pushTile(w44, cols, rows, 90);
            }

            //  regular wall blocks

            else if (northTile === 1 && eastTile === 0 && southTile === 1 && westTile === 1)
            {
                var w11:Wall1 = new Wall1();
                addChild(w11);
                pushTile(w11, cols, rows, 90);
            }

            else if (northTile === 1 && eastTile === 1 && southTile === 1 && westTile === 0)
            {
                var w12:Wall1 = new Wall1();
                addChild(w12);
                pushTile(w12, cols, rows, 270);
            }

            else if (northTile === 0 && eastTile === 1 && southTile === 1 && westTile === 1)
            {
                var w13:Wall1 = new Wall1();
                addChild(w13);
                pushTile(w13, cols, rows, 0);
            }

            else if (northTile === 1 && eastTile === 1 && southTile === 0 && westTile === 1)
            {
                var w14:Wall1 = new Wall1();
                addChild(w14);
                pushTile(w14, cols, rows, 180);
            }

        }
        // debug === // trace('Top Left: ' + northwestTile + ' Top Middle: ' + northTile + ' Top Right: ' + northeastTile + ' Middle Left: ' + westTile + ' This: ' + levelNum[rows][cols] + ' Middle Right: ' + eastTile + ' Bottom Left: ' + southwestTile + ' Bottom Middle: ' + southTile + ' Bottom Right: ' + southeastTile);
    }
}

function pushTile(til:Object, tx:uint, ty:uint, degrees:uint):void
{
    til.x = tx * tileDimension;
    til.y = ty * tileDimension;
    if (degrees != 0) tileRotate(til, degrees);
}

function tileRotate(tile:Object, degrees:uint):void
{
    // http://www.flash-db.com/Board/index.php?topic=18625.0
    var midPoint:int = tileDimension/2;
    var point:Point=new Point(tile.x+midPoint, tile.y+midPoint);
    var m:Matrix=tile.transform.matrix;
    m.tx -= point.x;
    m.ty -= point.y;
    m.rotate (degrees*(Math.PI/180));
    m.tx += point.x;
    m.ty += point.y;
    tile.transform.matrix=m;
}

Grundsätzlich wird dabei jede Kachel von links nach rechts, von oben nach unten überprüft und davon ausgegangen, dass die Randkacheln immer 1 sind. Ich habe mir auch die Freiheit genommen, die Bilder als Datei zu exportieren, um sie als Schlüssel zu verwenden:

Wandfliesen

Dies ist unvollständig und wahrscheinlich ein hackiger Weg, um dies zu erreichen, aber ich dachte, es könnte von Nutzen sein.

Bearbeiten: Screenshot des Ergebnisses dieses Codes.

Generiertes Ergebnis


1

Ich würde ein paar Dinge vorschlagen:

  • Es spielt keine Rolle, was die "mittlere" Kachel ist, oder? es könnte 2 sein, aber wenn alle anderen 1 sind, würde es 1 zeigen?

  • Es ist nur wichtig, was die Ecken sind, wenn es einen Unterschied in den unmittelbaren Nachbarn oben oder seitlich gibt. Wenn alle unmittelbaren Nachbarn 1 sind und eine Ecke 2 ist, wird 1 angezeigt.

  • Ich würde wahrscheinlich alle möglichen Kombinationen von Nachbarn vorberechnen und ein 8-Index-Array erstellen, wobei die ersten vier die Werte der oberen / unteren Nachbarn und die zweite die Diagonalen angeben:

Kanten [N] [E] [S] [W] [NE] [SE] [SW] [NW] = welcher Versatz auch immer in Sprite versetzt wird

In Ihrem Fall ist also [2] [2] [1] [1] [2] [2] [1] [1] = 4 (das 5. Sprite).

In diesem Fall wäre [1] [1] [1] [1] 1, [2] [2] [2] [2] 2, und der Rest müsste ausgearbeitet werden. Die Suche nach einer bestimmten Kachel wäre jedoch trivial.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.