Abbinare un pezzo di mondo generato proceduralmente a un pezzo di altro mondo


18

Hai letto The Chronicles of Amber di Roger Zelazny?

Immagina di giocare nel gioco MMO in terza persona. Ti spawn nel mondo e inizi a vagare. Dopo un po 'di tempo, quando pensi di aver appreso la mappa, ti rendi conto di essere in un posto, che non hai mai visto prima. Torna all'ultimo posto che eri sicuro di conoscere ed è ancora lì. Ma il resto del mondo è cambiato e non ti sei nemmeno accorto di come sia successo.

Ho letto della generazione procedurale mondiale. Ho letto del rumore e delle ottave di Perlin, del rumore simplex, dell'algoritmo Diamond-square, della simulazione di placche tettoniche e dell'erosione dell'acqua. Credo di avere una vaga comprensione dell'approccio generale nella generazione procedurale mondiale.

E con questa conoscenza non ho idea di come puoi fare qualcosa come scritto sopra. Ogni idea che mi viene in mente incontra alcuni problemi teorici. Ecco alcune idee che mi vengono in mente:

1) Generazione mondiale "reversibile" con un numero seme come input e un numero completamente descrittivo

Dubito che sia persino possibile, ma immagino una funzione, che riceverà un seme e produrrà una matrice di numeri, su cui sono costruiti i pezzi. E per ogni numero univoco c'è un pezzo unico. E una seconda funzione, che ottiene questo numero di pezzo unico e produce un seme, che contiene questo numero. Ho provato a fare uno schema nella foto qui sotto:

inserisci qui la descrizione dell'immagine

2) Rendere i blocchi completamente casuali e fare una transizione tra loro.

Come suggerì Aracthor . I vantaggi di questo approccio è che è possibile e non richiede una funzione magica :)

I contro che questo approccio ha secondo me è che probabilmente non è possibile avere un mondo diverso. Se hai, diciamo, sia l'arcipelago che un continente rappresentato da un solo numero ed i suoi pezzi adiacenti, la dimensione di un blocco non sarebbe uguale al continente. E dubito che sia possibile effettuare una bella transizione tra i pezzi. Mi sto perdendo qualcosa?

Quindi, in altre parole, stai sviluppando un MMO con un mondo generato proceduralmente. Ma invece di avere un mondo, ne hai molti . Quale approccio avresti adottato per generare mondi e come implementeresti la transizione del giocatore da un mondo all'altro senza che il giocatore notasse la transizione.

Comunque, credo che tu abbia l'idea generale. Come l'avresti fatto?


Quindi ho alcuni problemi con le risposte qui. @Aracthor Ti ho già parlato di varietà morbide, questo tipo di applicazione vale qui. Tuttavia ci sono 2 risposte abbastanza alte, quindi mi chiedo se c'è un punto ...
Alec Teal,

@AlecTeal se hai qualcosa da aggiungere, per favore, fallo. Sarei felice di ascoltare eventuali idee e suggerimenti.
netaholic,

Risposte:


23

Usa una fetta di rumore di ordine superiore. Se prima hai usato il rumore 2D per una mappa di altezza, usa invece il rumore 3D con l'ultima coordinata fissa. Ora puoi cambiare lentamente la posizione nell'ultima dimensione per modificare il terreno. Poiché il rumore di Perlin è continuo in tutte le dimensioni, otterrai transizioni fluide fintanto che cambierai uniformemente la posizione in cui campionerai la funzione rumore.

Ad esempio, se si desidera modificare il terreno lontano dalla distanza dal giocatore come offset. È inoltre possibile memorizzare l'offset per ciascuna coordinata sulla mappa e solo aumentare ma non diminuirlo mai. In questo modo la mappa diventa solo più nuova ma mai più vecchia.

Questa idea funziona anche se stai già utilizzando il rumore 3D, solo un campione da 4D quindi. Inoltre, dai un'occhiata al rumore Simplex. È la versione migliorata del rumore Perlin e funziona meglio per più dimensioni.


2
Questo è interessante. Capisco correttamente, che suggerisci di generare un rumore 3d, utilizzare una sezione xy a una certa z di esso come una mappa di altezza e fare una transizione graduale a un'altra sezione modificando la coordinata z all'aumentare della distanza dal giocatore?
netaholic,

@netaholic Exactly. Descriverlo come una fetta è un'ottima intuizione. Inoltre, è possibile tenere traccia del valore più alto per l'ultima coordinata in qualsiasi punto della mappa e solo aumentarlo ma mai diminuirlo.
danijar,

1
Questa è un'idea geniale. Fondamentalmente, la tua mappa del terreno sarebbe una sezione parabolica (o altra curva) attraverso un volume 3D.
Nome falso

Questa è un'idea davvero intelligente.
user253751

5

La tua idea di dividere il mondo in diversi pezzi non è male. È solo incompleto.

L'unico problema sono le giunzioni tra blocchi. Ad esempio, se si utilizza il rumore del perlin per generare sollievo e un seme diverso per ogni blocco, e rischiare che ciò accada:

Chunk relief bug

Una soluzione sarebbe quella di generare sollievo dai pezzi non solo dal suo seme di rumore Perlin, ma anche da altri pezzi attorno ad esso.

L'algoritmo di Perlin usa i valori della mappa casuale attorno a loro per "livellarsi". Se utilizzassero una mappa comune, verrebbero smussati insieme.

L'unico problema è che se cambi un seme per renderlo diverso quando il giocatore si ritira, dovrai ricaricare anche i pezzi, perché anche i loro bordi dovrebbero cambiare.

Questo non cambierebbe la dimensione dei pezzi, ma aumenterebbe la distanza minima dal giocatore al carico / scarico, perché un pezzo deve essere caricato quando il giocatore lo vede e, con questo metodo, anche i pezzi adiacenti devono essere troppo .

AGGIORNARE:

Se ogni pezzo del tuo mondo è di tipo diverso, il problema cresce. Non si tratta solo di sollievo. Una soluzione costosa sarebbe la seguente:

Pezzi tagliati

Supponiamo che i pezzi verdi siano mondi forestali, quelli blu arcipelaghi e quelli gialli deserti piatti.
La soluzione qui è quella di creare zone di "transizione", in cui il sollievo e la natura del suolo (così come gli oggetti radicati, o qualsiasi altra cosa tu voglia) passerebbero progressivamente da un tipo all'altro.

E come puoi vedere in questa immagine, la parte infernale del codice sarebbe rappresentata da quadratini negli angoli dei pezzi: devono creare un collegamento tra 4 pezzi, nature potenzialmente diverse.

Quindi, per questo livello di complessità, penso che le generazioni classiche del mondo 2D come Perlin2D non possano essere utilizzate. Ti rimando alla risposta di @danijar per questo.


Suggerisci di generare "centro" di un pezzo da un seme e i suoi bordi "levigati" in base a pezzi adiacenti? Ha senso, ma aumenterà la dimensione di un blocco, poiché dovrebbe essere la dimensione di un'area, che il giocatore può osservare più il doppio della larghezza di un'area di transizione verso blocchi adiacenti. E l'area del pezzo diventa ancora più grande quanto più diversificato è il mondo.
netaholic,

@netaholic Non sarebbe più grande, ma in qualche modo. Ho aggiunto un paragrafo su di esso.
Aracthor,

Ho aggiornato la mia domanda. Ho cercato di descrivere alcune idee che ho
netaholic il

Quindi l'altra risposta qui usa (una specie di, non del tutto) una terza dimensione come grafici. Inoltre vedi anche l'aereo come una varietà e mi piacciono le tue idee. Per estenderlo un po 'di più, vuoi davvero una varietà liscia. Devi assicurarti che le tue transizioni siano fluide. Potresti quindi applicare una sfocatura o un rumore a questo e la risposta sarebbe perfetta.
Alec Teal,

0

Mentre l'idea di danijar è piuttosto solida, potresti finire con l'archiviazione di molti dati, se vuoi avere la stessa area locale e lo spostamento della distanza. E richiedendo sempre più fette di rumore sempre più complesso. Puoi ottenere tutti questi in un modo 2d più standard.

Ho sviluppato un algoritmo per generare proceduralmente rumore frattale casuale, in parte basato sull'algoritmo del diamante quadrato che ho fissato per essere sia infinito che deterministico. Quindi il diamante quadrato può creare un paesaggio infinito, così come il mio algoritmo abbastanza bloccato.

L'idea è sostanzialmente la stessa. Invece di campionare il rumore di dimensioni superiori, è possibile iterare i valori a diversi livelli iterativi.

Quindi memorizzi ancora i valori richiesti in precedenza e li memorizzi nella cache (questo schema in modo indipendente potrebbe essere utilizzato per accelerare un algoritmo già superveloce). E quando viene richiesta una nuova area, viene creata con un nuovo valore y. e qualsiasi area non richiesta in quella richiesta viene rimossa.

Quindi, piuttosto che passare attraverso uno spazio diverso in dimensioni aggiuntive. Memorizziamo un po 'più di dati monotonici per mescolarli in diversi (a quantità progressivamente maggiori a livelli diversi).

Se l'utente viaggia in una direzione, i valori vengono spostati di conseguenza (e ad ogni livello) e i nuovi valori vengono generati sui nuovi bordi. Se il seme iterativo superiore viene cambiato, l'intero mondo verrà drasticamente spostato. Se all'iterazione finale viene dato un risultato diverso, l'importo della modifica sarà molto minore di + -1 blocco circa. Ma la collina sarà ancora lì e la valle ecc., Ma gli angoli e le fessure saranno cambiati. A meno che tu non vada abbastanza lontano, e poi la collina sarà sparita.

Quindi, se memorizzassimo un blocco di valori 100x100 ogni iterazione. Quindi nulla potrebbe cambiare a 100x100 dal lettore. Ma a 200x200 le cose potrebbero cambiare di 1 blocco. A 400x400 le cose potrebbero cambiare di 2 blocchi. A 800x800 di distanza le cose potranno cambiare di 4 blocchi. Quindi le cose cambieranno e cambieranno sempre di più man mano che vai avanti. Se torni indietro saranno diversi, se vai troppo lontano saranno completamente cambiati e completamente persi poiché tutti i semi sarebbero abbandonati.

L'aggiunta di una dimensione diversa per fornire questo effetto stabilizzante, funzionerebbe sicuramente, spostando y a distanza, ma quando non dovresti, dovresti archiviare molti dati per molti blocchi. Negli algoritmi deterministici del rumore frattale puoi ottenere questo stesso effetto aggiungendo un valore variabile (a una quantità diversa) man mano che la posizione si sposta oltre un certo punto.

https://jsfiddle.net/rkdzau7o/

var SCALE_FACTOR = 2;
//The scale factor is kind of arbitrary, but the code is only consistent for 2 currently. Gives noise for other scale but not location proper.
var BLUR_EDGE = 2; //extra pixels are needed for the blur (3 - 1).
var buildbuffer = BLUR_EDGE + SCALE_FACTOR;

canvas = document.getElementById('canvas');
ctx = canvas.getContext("2d");
var stride = canvas.width + buildbuffer;
var colorvalues = new Array(stride * (canvas.height + buildbuffer));
var iterations = 7;
var xpos = 0;
var ypos = 0;
var singlecolor = true;


/**
 * Function adds all the required ints into the ints array.
 * Note that the scanline should not actually equal the width.
 * It should be larger as per the getRequiredDim function.
 *
 * @param iterations Number of iterations to perform.
 * @param ints       pixel array to be used to insert values. (Pass by reference)
 * @param stride     distance in the array to the next requestedY value.
 * @param x          requested X location.
 * @param y          requested Y location.
 * @param width      width of the image.
 * @param height     height of the image.
 */

function fieldOlsenNoise(iterations, ints, stride, x, y, width, height) {
  olsennoise(ints, stride, x, y, width, height, iterations); //Calls the main routine.
  //applyMask(ints, stride, width, height, 0xFF000000);
}

function applyMask(pixels, stride, width, height, mask) {
  var index;
  index = 0;
  for (var k = 0, n = height - 1; k <= n; k++, index += stride) {
    for (var j = 0, m = width - 1; j <= m; j++) {
      pixels[index + j] |= mask;
    }
  }
}

/**
 * Converts a dimension into the dimension required by the algorithm.
 * Due to the blurring, to get valid data the array must be slightly larger.
 * Due to the interpixel location at lowest levels it needs to be bigger by
 * the max value that can be. (SCALE_FACTOR)
 *
 * @param dim
 * @return
 */

function getRequiredDim(dim) {
  return dim + BLUR_EDGE + SCALE_FACTOR;
}

//Function inserts the values into the given ints array (pass by reference)
//The results will be within 0-255 assuming the requested iterations are 7.
function olsennoise(ints, stride, x_within_field, y_within_field, width, height, iteration) {
  if (iteration == 0) {
    //Base case. If we are at the bottom. Do not run the rest of the function. Return random values.
    clearValues(ints, stride, width, height); //base case needs zero, apply Noise will not eat garbage.
    applyNoise(ints, stride, x_within_field, y_within_field, width, height, iteration);
    return;
  }

  var x_remainder = x_within_field & 1; //Adjust the x_remainder so we know how much more into the pixel are.
  var y_remainder = y_within_field & 1; //Math.abs(y_within_field % SCALE_FACTOR) - Would be assumed for larger scalefactors.

  /*
  Pass the ints, and the stride for that set of ints.
  Recurse the call to the function moving the x_within_field forward if we actaully want half a pixel at the start.
  Same for the requestedY.
  The width should expanded by the x_remainder, and then half the size, with enough extra to store the extra ints from the blur.
  If the width is too long, it'll just run more stuff than it needs to.
  */

  olsennoise(ints, stride,
    (Math.floor((x_within_field + x_remainder) / SCALE_FACTOR)) - x_remainder,
    (Math.floor((y_within_field + y_remainder) / SCALE_FACTOR)) - y_remainder,
    (Math.floor((width + x_remainder) / SCALE_FACTOR)) + BLUR_EDGE,
    (Math.floor((height + y_remainder) / SCALE_FACTOR)) + BLUR_EDGE, iteration - 1);

  //This will scale the image from half the width and half the height. bounds.
  //The scale function assumes you have at least width/2 and height/2 good ints.
  //We requested those from olsennoise above, so we should have that.

  applyScaleShift(ints, stride, width + BLUR_EDGE, height + BLUR_EDGE, SCALE_FACTOR, x_remainder, y_remainder);

  //This applies the blur and uses the given bounds.
  //Since the blur loses two at the edge, this will result
  //in us having width requestedX height of good ints and required
  // width + blurEdge of good ints. height + blurEdge of good ints.
  applyBlur(ints, stride, width + BLUR_EDGE, height + BLUR_EDGE);

  //Applies noise to all the given ints. Does not require more or less than ints. Just offsets them all randomly.
  applyNoise(ints, stride, x_within_field, y_within_field, width, height, iteration);
}



function applyNoise(pixels, stride, x_within_field, y_within_field, width, height, iteration) {
  var bitmask = 0b00000001000000010000000100000001 << (7 - iteration);
  var index = 0;
  for (var k = 0, n = height - 1; k <= n; k++, index += stride) { //iterate the requestedY positions. Offsetting the index by stride each time.
    for (var j = 0, m = width - 1; j <= m; j++) { //iterate the requestedX positions through width.
      var current = index + j; // The current position of the pixel is the index which will have added stride each, requestedY iteration
      pixels[current] += hashrandom(j + x_within_field, k + y_within_field, iteration) & bitmask;
      //add on to this pixel the hash function with the set reduction.
      //It simply must scale down with the larger number of iterations.
    }
  }
}

function applyScaleShift(pixels, stride, width, height, factor, shiftX, shiftY) {
  var index = (height - 1) * stride; //We must iteration backwards to scale so index starts at last Y position.
  for (var k = 0, n = height - 1; k <= n; n--, index -= stride) { // we iterate the requestedY, removing stride from index.
    for (var j = 0, m = width - 1; j <= m; m--) { // iterate the requestedX positions from width to 0.
      var pos = index + m; //current position is the index (position of that scanline of Y) plus our current iteration in scale.
      var lower = (Math.floor((n + shiftY) / factor) * stride) + Math.floor((m + shiftX) / factor); //We find the position that is half that size. From where we scale them out.
      pixels[pos] = pixels[lower]; // Set the outer position to the inner position. Applying the scale.
    }
  }
}

function clearValues(pixels, stride, width, height) {
  var index;
  index = 0;
  for (var k = 0, n = height - 1; k <= n; k++, index += stride) { //iterate the requestedY values.
    for (var j = 0, m = width - 1; j <= m; j++) { //iterate the requestedX values.
      pixels[index + j] = 0; //clears those values.
    }
  }
}

//Applies the blur.
//loopunrolled box blur 3x3 in each color.
function applyBlur(pixels, stride, width, height) {
  var index = 0;
  var v0;
  var v1;
  var v2;

  var r;
  var g;
  var b;

  for (var j = 0; j < height; j++, index += stride) {
    for (var k = 0; k < width; k++) {
      var pos = index + k;

      v0 = pixels[pos];
      v1 = pixels[pos + 1];
      v2 = pixels[pos + 2];

      r = ((v0 >> 16) & 0xFF) + ((v1 >> 16) & 0xFF) + ((v2 >> 16) & 0xFF);
      g = ((v0 >> 8) & 0xFF) + ((v1 >> 8) & 0xFF) + ((v2 >> 8) & 0xFF);
      b = ((v0) & 0xFF) + ((v1) & 0xFF) + ((v2) & 0xFF);
      r = Math.floor(r / 3);
      g = Math.floor(g / 3);
      b = Math.floor(b / 3);
      pixels[pos] = r << 16 | g << 8 | b;
    }
  }
  index = 0;
  for (var j = 0; j < height; j++, index += stride) {
    for (var k = 0; k < width; k++) {
      var pos = index + k;
      v0 = pixels[pos];
      v1 = pixels[pos + stride];
      v2 = pixels[pos + (stride << 1)];

      r = ((v0 >> 16) & 0xFF) + ((v1 >> 16) & 0xFF) + ((v2 >> 16) & 0xFF);
      g = ((v0 >> 8) & 0xFF) + ((v1 >> 8) & 0xFF) + ((v2 >> 8) & 0xFF);
      b = ((v0) & 0xFF) + ((v1) & 0xFF) + ((v2) & 0xFF);
      r = Math.floor(r / 3);
      g = Math.floor(g / 3);
      b = Math.floor(b / 3);
      pixels[pos] = r << 16 | g << 8 | b;
    }
  }
}


function hashrandom(v0, v1, v2) {
  var hash = 0;
  hash ^= v0;
  hash = hashsingle(hash);
  hash ^= v1;
  hash = hashsingle(hash);
  hash ^= v2;
  hash = hashsingle(hash);
  return hash;
}

function hashsingle(v) {
  var hash = v;
  var h = hash;

  switch (hash & 3) {
    case 3:
      hash += h;
      hash ^= hash << 32;
      hash ^= h << 36;
      hash += hash >> 22;
      break;
    case 2:
      hash += h;
      hash ^= hash << 22;
      hash += hash >> 34;
      break;
    case 1:
      hash += h;
      hash ^= hash << 20;
      hash += hash >> 2;
  }
  hash ^= hash << 6;
  hash += hash >> 10;
  hash ^= hash << 8;
  hash += hash >> 34;
  hash ^= hash << 50;
  hash += hash >> 12;
  return hash;
}


//END, OLSEN NOSE.



//Nuts and bolts code.

function MoveMap(dx, dy) {
  xpos -= dx;
  ypos -= dy;
  drawMap();
}

function drawMap() {
  //int iterations, int[] ints, int stride, int x, int y, int width, int height
  console.log("Here.");
  fieldOlsenNoise(iterations, colorvalues, stride, xpos, ypos, canvas.width, canvas.height);
  var img = ctx.createImageData(canvas.width, canvas.height);

  for (var y = 0, h = canvas.height; y < h; y++) {
    for (var x = 0, w = canvas.width; x < w; x++) {
      var standardShade = colorvalues[(y * stride) + x];
      var pData = ((y * w) + x) * 4;
      if (singlecolor) {
        img.data[pData] = standardShade & 0xFF;
        img.data[pData + 1] = standardShade & 0xFF;
        img.data[pData + 2] = standardShade & 0xFF;
      } else {
        img.data[pData] = standardShade & 0xFF;
        img.data[pData + 1] = (standardShade >> 8) & 0xFF;
        img.data[pData + 2] = (standardShade >> 16) & 0xFF;
      }
      img.data[pData + 3] = 255;
    }
  }
  ctx.putImageData(img, 0, 0);
}

$("#update").click(function(e) {
  iterations = parseInt($("iterations").val());
  drawMap();
})
$("#colors").click(function(e) {
  singlecolor = !singlecolor;
  drawMap();
})

var m = this;
m.map = document.getElementById("canvas");
m.width = canvas.width;
m.height = canvas.height;

m.hoverCursor = "auto";
m.dragCursor = "url(data:image/vnd.microsoft.icon;base64,AAACAAEAICACAAcABQAwAQAAFgAAACgAAAAgAAAAQAAAAAEAAQAAAAAAAAEAAAAAAAAAAAAAAgAAAAAAAAAAAAAA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8AAAA/AAAAfwAAAP+AAAH/gAAB/8AAAH/AAAB/wAAA/0AAANsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////////////////////////////////////////////////////////////////////////gH///4B///8Af//+AD///AA///wAH//+AB///wAf//4AH//+AD///yT/////////////////////////////8=), default";
m.scrollTime = 300;

m.mousePosition = new Coordinate;
m.mouseLocations = [];
m.velocity = new Coordinate;
m.mouseDown = false;
m.timerId = -1;
m.timerCount = 0;

m.viewingBox = document.createElement("div");
m.viewingBox.style.cursor = m.hoverCursor;

m.map.parentNode.replaceChild(m.viewingBox, m.map);
m.viewingBox.appendChild(m.map);
m.viewingBox.style.overflow = "hidden";
m.viewingBox.style.width = m.width + "px";
m.viewingBox.style.height = m.height + "px";
m.viewingBox.style.position = "relative";
m.map.style.position = "absolute";

function AddListener(element, event, f) {
  if (element.attachEvent) {
    element["e" + event + f] = f;
    element[event + f] = function() {
      element["e" + event + f](window.event);
    };
    element.attachEvent("on" + event, element[event + f]);
  } else
    element.addEventListener(event, f, false);
}

function Coordinate(startX, startY) {
  this.x = startX;
  this.y = startY;
}

var MouseMove = function(b) {
  var e = b.clientX - m.mousePosition.x;
  var d = b.clientY - m.mousePosition.y;
  MoveMap(e, d);
  m.mousePosition.x = b.clientX;
  m.mousePosition.y = b.clientY;
};

/**
 * mousedown event handler
 */
AddListener(m.viewingBox, "mousedown", function(e) {
  m.viewingBox.style.cursor = m.dragCursor;

  // Save the current mouse position so we can later find how far the
  // mouse has moved in order to scroll that distance
  m.mousePosition.x = e.clientX;
  m.mousePosition.y = e.clientY;

  // Start paying attention to when the mouse moves
  AddListener(document, "mousemove", MouseMove);
  m.mouseDown = true;

  event.preventDefault ? event.preventDefault() : event.returnValue = false;
});

/**
 * mouseup event handler
 */
AddListener(document, "mouseup", function() {
  if (m.mouseDown) {
    var handler = MouseMove;
    if (document.detachEvent) {
      document.detachEvent("onmousemove", document["mousemove" + handler]);
      document["mousemove" + handler] = null;
    } else {
      document.removeEventListener("mousemove", handler, false);
    }

    m.mouseDown = false;

    if (m.mouseLocations.length > 0) {
      var clickCount = m.mouseLocations.length;
      m.velocity.x = (m.mouseLocations[clickCount - 1].x - m.mouseLocations[0].x) / clickCount;
      m.velocity.y = (m.mouseLocations[clickCount - 1].y - m.mouseLocations[0].y) / clickCount;
      m.mouseLocations.length = 0;
    }
  }

  m.viewingBox.style.cursor = m.hoverCursor;
});

drawMap();
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<canvas id="canvas" width="500" height="500">
</canvas>
<fieldset>
  <legend>Height Map Properties</legend>
  <input type="text" name="iterations" id="iterations">
  <label for="iterations">
    Iterations(7)
  </label>
  <label>
    <input type="checkbox" id="colors" />Rainbow</label>
</fieldset>

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.