Faire correspondre un morceau de monde généré par la procédure à un morceau d'un autre monde


18

Avez-vous lu The Chronicles of Amber de Roger Zelazny?

Imaginez-vous jouer dans un jeu MMO à la 3ème personne. Vous apparaissez dans le monde et commencez à vous promener. Après un certain temps, quand vous pensez que vous avez appris la carte, vous vous rendez compte que vous êtes dans un endroit que vous n'avez jamais vu auparavant. Vous revenez au dernier endroit que vous étiez sûr de connaître et il est toujours là. Mais le reste du monde a changé et vous n'avez même pas remarqué comment cela s'est produit.

J'ai lu sur la génération du monde procédural. J'ai lu sur le bruit Perlin et les octaves, le bruit Simplex, l'algorithme Diamond-square, la simulation des plaques tectoniques et l'érosion hydrique. Je crois avoir une vague compréhension de l'approche générale dans la génération du monde procédural.

Et avec cette connaissance, je n'ai aucune idée de comment pouvez-vous faire quelque chose comme écrit ci-dessus. Chaque idée qui me vient à l'esprit rencontre des problèmes théoriques. Voici quelques idées auxquelles je peux penser:

1) Génération mondiale "réversible" avec un numéro de départ comme entrée et un certain nombre de description complète

Je doute que ce soit même possible, mais j'imagine une fonction, qui recevra une graine et produira une matrice de nombres, sur laquelle des blocs sont construits. Et pour chaque numéro unique, il y a un morceau unique. Et une deuxième fonction, qui obtient ce numéro de bloc unique et produit une graine, qui contient ce numéro. J'ai essayé de faire un schéma dans l'image ci-dessous:

entrez la description de l'image ici

2) Faire des morceaux complètement aléatoires et faire une transition entre eux.

Comme l'a suggéré Aracthor . Les avantages de cette approche sont qu'elle est possible et ne nécessite pas de fonction magique :)

Les inconvénients de cette approche à mon avis, c'est qu'il n'est probablement pas possible d'avoir un monde diversifié. Si vous avez disons à la fois l'archipel et un continent représenté par un seul numéro et ses morceaux adjacents, la taille d'un morceau ne serait-elle pas égale au continent. Et je doute qu'il soit possible de faire une belle transition entre des morceaux. Suis-je en train de manquer quelque chose?

Donc, en d'autres termes, vous développez un MMO avec un monde généré de manière procédurale. Mais au lieu d'avoir un monde, vous en avez plusieurs . Quelle approche adopteriez-vous pour générer des mondes et comment mettriez-vous en œuvre la transition du joueur d'un monde à l'autre sans que le joueur ne remarque la transition.

Quoi qu'il en soit, je crois que vous avez l'idée générale. Comment l'auriez-vous fait?


J'ai donc quelques problèmes avec les réponses ici. @Aracthor Je vous ai déjà parlé de variétés lisses, ce genre s'applique ici. Cependant il y a 2 réponses assez élevées donc je me demande s'il y a un point ...
Alec Teal

@AlecTeal si vous avez quelque chose à ajouter, veuillez le faire. Je serais heureux d'entendre toutes les idées et suggestions.
netaholic

Réponses:


23

Utilisez une tranche de bruit d'ordre supérieur. Si vous avez déjà utilisé du bruit 2D pour une carte de hauteur, utilisez plutôt du bruit 3D avec la dernière coordonnée fixe. Vous pouvez maintenant changer lentement la position dans la dernière dimension pour modifier le terrain. Étant donné que le bruit Perlin est continu dans toutes les dimensions, vous obtiendrez des transitions fluides tant que vous modifiez en douceur la position où vous échantillonnez la fonction de bruit.

Si vous souhaitez uniquement modifier le terrain loin de la distance par rapport au joueur comme décalage par exemple. Vous pouvez également stocker le décalage pour chaque coordonnée sur la carte et seulement l'augmenter mais jamais le diminuer. De cette façon, la carte ne devient plus récente mais jamais plus ancienne.

Cette idée fonctionne également si vous utilisez déjà du bruit 3D, il vous suffit alors d'échantillonner à partir de 4D. Jetez également un œil au bruit Simplex. C'est la version améliorée du bruit Perlin et fonctionne mieux pour plus de dimensions.


2
C'est intéressant. Dois-je comprendre correctement que vous suggérez de générer un bruit 3D, d'utiliser une tranche xy à un certain z de celui-ci comme une carte de hauteur et de faire une transition en douceur vers une autre tranche en modifiant la coordonnée z à mesure que la distance du joueur augmente?
netaholic

@netaholic Exactement. Le décrire comme une tranche est une très bonne intuition. De plus, vous pouvez garder la trace de la valeur la plus élevée pour la dernière coordonnée partout sur la carte et seulement l'augmenter mais jamais la diminuer.
danijar

1
Ceci est une idée brillante. Fondamentalement, votre carte de terrain serait une tranche parabolique (ou autre courbe) à travers un volume 3D.
Fake Name

C'est une idée vraiment intelligente.
user253751

5

Votre idée de diviser le monde en plusieurs morceaux n'est pas mauvaise. C'est juste incomplet.

Le seul problème est les jonctions entre les morceaux. Par exemple, si vous utilisez du bruit perlin pour générer du relief et une graine différente pour chaque morceau, et risquez que cela se produise:

Bug de soulagement des morceaux

Une solution serait de générer un soulagement des morceaux non seulement à partir de sa source de bruit Perlin, mais aussi à partir d'autres morceaux autour de lui.

L'algorithme Perlin utilise des valeurs de carte aléatoire autour d'eux pour se "lisser". S'ils utilisent une carte commune, ils seraient lissés ensemble.

Le seul problème est que si vous changez une graine de morceau pour la rendre différente lorsque le joueur recule, vous devrez également recharger des morceaux, car leurs bordures devraient également changer.

Cela ne changerait pas la taille des morceaux, mais cela augmenterait la distance minimale entre le lecteur et le chargement / déchargement, car un morceau doit être chargé lorsque le joueur le voit, et, avec cette méthode, comme les morceaux adjacents doivent l'être aussi .

MISE À JOUR:

Si chaque morceau de votre monde est d'un type différent, le problème augmente. Il ne s'agit pas seulement de soulagement. Une solution coûteuse serait la suivante:

Morceaux coupés

Supposons que des morceaux verts soient des mondes forestiers, des archipels bleus et des déserts plats jaunes.
La solution ici est de créer des zones de «transition», où votre relief et votre nature au sol (ainsi que les objets mis à la terre ou tout ce que vous voulez) passeraient progressivement d'un type à un autre.

Et comme vous pouvez le voir sur cette image, l'enfer à coder serait de petits carrés dans les coins de morceaux: ils doivent faire un lien entre 4 morceaux, de natures potentiellement différentes.

Donc, pour ce niveau de complexité, je pense que les générations classiques du monde 2D comme Perlin2D ne peuvent tout simplement pas être utilisées. Je vous renvoie à la réponse @danijar pour cela.


Suggérez-vous de générer le «centre» d'un morceau à partir d'une graine et ses bords «lissés» en fonction des morceaux adjacents? Cela a du sens, mais cela augmentera la taille d'un morceau, car cela devrait être la taille d'une zone, que le joueur peut observer plus le double de la largeur d'une zone de transition vers des morceaux adjacents. Et la zone de morceau devient encore plus grande plus le monde est diversifié.
netaholic

@netaholic Ce ne serait pas plus grand, mais en quelque sorte. J'y ai ajouté un paragraphe.
Aracthor

J'ai mis à jour ma question. J'ai essayé de décrire certaines idées que j'ai
netaholic

Donc, l'autre réponse ici utilise (en quelque sorte, pas tout à fait) une troisième dimension comme graphiques. Vous voyez également l'avion comme un collecteur, et j'aime vos idées. Pour l'étendre un peu plus, vous voulez vraiment un collecteur lisse. Vous devez vous assurer que vos transitions sont fluides. Vous pourriez alors appliquer un flou ou un bruit à cela et la réponse serait parfaite.
Alec Teal

0

Bien que l'idée de danijar soit assez solide, vous pourriez finir par stocker beaucoup de données, si vous vouliez avoir la même zone locale et le décalage de distance. Et demander de plus en plus de tranches de bruit de plus en plus complexes. Vous pouvez obtenir tous ces éléments d'une manière 2d plus standard.

J'ai développé un algorithme pour générer de manière procédurale du bruit fractal aléatoire, en partie basé sur l'algorithme carré de diamant que j'ai fixé pour être à la fois infini et déterministe. Donc, le carré de diamant peut créer un paysage infini, ainsi que mon propre algorithme assez bloc.

L'idée est fondamentalement la même. Mais, plutôt que d'échantillonner un bruit de dimension supérieure, vous pouvez itérer des valeurs à différents niveaux itératifs.

Ainsi, vous conservez toujours les valeurs que vous avez demandées auparavant et vous les mettez en cache (ce schéma pourrait indépendamment être utilisé pour accélérer un algorithme déjà super rapide). Et lorsqu'une nouvelle zone est demandée, elle est créée avec une nouvelle valeur y. et toute zone non demandée dans cette demande est supprimée.

Donc, plutôt que de parcourir différents espaces dans des dimensions supplémentaires. Nous stockons un bit supplémentaire de données monotones à mélanger dans différents (à des quantités progressivement plus grandes à différents niveaux).

Si l'utilisateur se déplace dans une direction, les valeurs sont déplacées en conséquence (et à chaque niveau) et de nouvelles valeurs sont générées aux nouveaux bords. Si la graine itérative supérieure est modifiée, le monde entier sera radicalement changé. Si l'itération finale obtient un résultat différent, alors le montant de la modification sera très mineur + -1 bloc environ. Mais, la colline sera toujours là et la vallée, etc., mais les coins et recoins auront changé. À moins que vous n'alliez assez loin, la colline disparaîtra.

Donc, si nous avons stocké 100 x 100 morceaux de valeurs à chaque itération. Ensuite, rien ne pouvait changer à 100x100 du joueur. Mais, à 200x200, les choses pourraient changer d'un bloc. À 400x400, les choses pourraient changer de 2 blocs. À 800x800 de distance, les choses pourront changer de 4 blocs. Les choses vont donc changer et elles changeront de plus en plus à mesure que vous avancerez. Si vous revenez en arrière, elles seront différentes, si vous allez trop loin, elles seront complètement modifiées et complètement perdues car toutes les graines seront abandonnées.

L'ajout d'une dimension différente pour fournir cet effet de stabilisation fonctionnerait certainement, en décalant le y à distance, mais vous stockeriez beaucoup de données pour un grand nombre de blocs lorsque vous ne devriez pas avoir à le faire. Dans les algorithmes déterministes de bruit fractal, vous pouvez obtenir ce même effet en ajoutant une valeur changeante (à un montant différent) lorsque la position se déplace au-delà d'un certain point.

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>

En utilisant notre site, vous reconnaissez avoir lu et compris notre politique liée aux cookies et notre politique de confidentialité.
Licensed under cc by-sa 3.0 with attribution required.