Parties 1-3: maillage, couleurs et hauteurs de celluleParties 4-7: bosses, rivières et routesParties 8-11: eau, reliefs et rempartsParties 12-15: sauvegarde et chargement, textures, distancesParties 16-19: trouver le chemin, équipes de joueurs, animationsParties 20-23: Brouillard de guerre, recherche cartographique, génération procéduraleParties 24-27: cycle de l'eau, érosion, biomes, carte cylindriquePartie 24: régions et érosion
- Ajoutez une bordure d'eau autour de la carte.
- Nous divisons la carte en plusieurs régions.
- Nous utilisons l'érosion pour couper les falaises.
- Nous déplaçons le terrain pour lisser le relief.
Dans la partie précédente, nous avons jeté les bases de la génération de cartes procédurales. Cette fois, nous limiterons les lieux d'occurrence possible des terres et agirons sur elles avec l'érosion.
Ce didacticiel a été créé dans Unity 2017.1.0.
Séparez et lissez le terrain.Bordure de la carte
Étant donné que nous augmentons les zones de terrain au hasard, il peut arriver que le terrain touche le bord de la carte. Cela peut être indésirable. La carte limitée par l'eau contient une barrière naturelle qui empêche les joueurs de s'approcher du bord. Par conséquent, ce serait bien si nous interdisions au terrain de s'élever au-dessus du niveau de l'eau près du bord de la carte.
Taille de bordure
À quelle distance la terre doit-elle être au bord de la carte? Il n'y a pas de bonne réponse à cette question, nous allons donc rendre ce paramètre personnalisable. Nous allons ajouter deux curseurs au composant
HexMapGenerator
, l'un pour les bordures le long des bords le long de l'axe X, l'autre pour les bordures le long de l'axe Z. Nous pouvons donc utiliser une bordure plus large dans l'une des dimensions, ou même créer une bordure dans une seule dimension. Utilisons un intervalle de 0 à 10 avec une valeur par défaut de 5.
[Range(0, 10)] public int mapBorderX = 5; [Range(0, 10)] public int mapBorderZ = 5;
Curseurs de bordure de carte.Nous limitons les centres des zones terrestres
Sans bordures, toutes les cellules sont valides. Lorsqu'il existe des limites, les coordonnées de décalage minimales autorisées augmentent et les coordonnées maximales autorisées diminuent. Puisque pour générer les tracés, nous aurons besoin de connaître l'intervalle autorisé, suivons-le en utilisant quatre champs entiers.
int xMin, xMax, zMin, zMax;
Nous initialisons les contraintes dans
GenerateMap
avant de créer des sushis. Nous utilisons ces valeurs comme paramètres pour les appels
Random.Range
, donc les aigus sont en fait exceptionnels. Sans bordure, elles sont égales au nombre de cellules de mesure, donc pas moins 1.
public void GenerateMap (int x, int z) { … for (int i = 0; i < cellCount; i++) { grid.GetCell(i).WaterLevel = waterLevel; } xMin = mapBorderX; xMax = x - mapBorderX; zMin = mapBorderZ; zMax = z - mapBorderZ; CreateLand(); … }
Nous n'interdirons pas strictement l'apparition de terres au-delà de la frontière de la frontière, car cela créerait des bords fortement coupés. Au lieu de cela, nous limiterons uniquement les cellules utilisées pour démarrer la génération des tracés. Autrement dit, les centres approximatifs des sites seront limités, mais certaines parties des sites pourront dépasser la zone frontalière. Cela peut être fait en modifiant
GetRandomCell
afin qu'il sélectionne une cellule dans la plage de décalages autorisés.
HexCell GetRandomCell () {
Les bordures de la carte sont 0 × 0, 5 × 5, 10 × 10 et 0 × 10.Lorsque tous les paramètres de la carte sont définis sur leurs valeurs par défaut, une bordure de taille 5 protégera de manière fiable le bord de la carte contre tout contact avec la terre. Cependant, ce n'est pas garanti. Le terrain peut parfois se rapprocher du bord, et parfois le toucher à plusieurs endroits.
La probabilité que la terre traverse la frontière entière dépend de la taille de la frontière et de la taille maximale du site. Sans hésitation, les sections restent des hexagones. Hexagone complet avec rayon
contient

les cellules. S'il y a des hexagones avec un rayon égal à la taille de la frontière, alors ils peuvent la traverser. Un hexagone complet avec un rayon de 5 contient 91 cellules. Comme par défaut, le maximum est de 100 cellules par section, cela signifie que la terre pourra poser un pont sur 5 cellules, surtout s'il y a des vibrations. Pour éviter cela, réduisez la taille maximale du tracé ou augmentez la taille de la bordure.
Comment dérive la formule du nombre de cellules dans la région hexagonale?Avec un rayon de 0, nous avons affaire à une seule cellule. Il est venu de 1. Avec un rayon de 1 autour du centre, il y a six cellules supplémentaires, soit

. Ces six cellules peuvent être considérées comme les extrémités de six triangles touchant le centre. Avec un rayon de 2, une deuxième ligne est ajoutée à ces triangles, c'est-à-dire que deux cellules supplémentaires sont obtenues sur le triangle, et au total

. Avec un rayon de 3, une troisième ligne est ajoutée, c'est-à-dire trois cellules supplémentaires par triangle, et au total

. Et ainsi de suite. Autrement dit, en termes généraux, la formule ressemble à

.
Pour voir cela plus clairement, nous pouvons définir la taille de la frontière à 200. Puisqu'un hexagone complet avec un rayon de 8 contient 217 cellules, la terre est susceptible de toucher le bord de la carte. Au moins si vous utilisez la valeur de taille de bordure par défaut (5). Si vous augmentez la bordure à 10, la probabilité diminuera considérablement.
Le terrain a une taille constante de 200, les limites de la carte sont 5 et 10.Pangaea
Notez que lorsque vous augmentez la bordure de la carte et conservez le même pourcentage de terrain, nous forçons le terrain à former une zone plus petite. En conséquence, une grande carte par défaut est très susceptible de créer une seule grande masse de terre - le supercontinent Pangaea - éventuellement avec plusieurs petites îles. Avec une augmentation de la taille de la frontière, la probabilité que cela augmente, et à certaines valeurs, nous sommes presque assurés d'obtenir un supercontinent. Cependant, lorsque le pourcentage de terrain est trop grand, la plupart des zones disponibles se remplissent et, par conséquent, nous obtenons une masse de terrain presque rectangulaire. Pour éviter que cela ne se produise, vous devez réduire le pourcentage de terrain.
Sushi à 40% avec une bordure de carte de 10.D'où vient le nom Pangea?C'était le nom du dernier supercontinent connu qui existait sur Terre il y a de nombreuses années. Le nom est composé des mots grecs pan et Gaia, ce qui signifie quelque chose comme «toute la nature» ou «toute la terre».
Nous protégeons des cartes impossibles
Nous générons la bonne quantité de terre en continuant simplement à élever la terre jusqu'à ce que nous atteignions la masse terrestre souhaitée. Cela fonctionne parce que tôt ou tard nous élèverons chaque cellule au niveau de l'eau. Cependant, lorsque vous utilisez la bordure de la carte, nous ne pouvons pas atteindre chaque cellule. Lorsqu'un pourcentage de terrain trop élevé est requis, cela entraînera des «tentatives et des échecs» sans fin du générateur pour élever plus de terrain, et il restera coincé dans un cycle sans fin. Dans ce cas, l'application se bloquera, mais cela ne devrait pas se produire.
Nous ne pouvons pas trouver de manière fiable des configurations impossibles à l'avance, mais nous pouvons nous protéger contre des cycles sans fin. Nous allons simplement suivre le nombre de cycles exécutés dans
CreateLand
. S'il y a trop d'itérations, alors nous sommes très probablement bloqués et devons arrêter.
Pour une grande carte, mille itérations semblent acceptables et dix mille itérations semblent déjà absurdes. Utilisons donc cette valeur comme point de terminaison.
void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
Si nous obtenons une carte endommagée, alors 10 000 itérations ne prendront pas beaucoup de temps, car de nombreuses cellules atteindront rapidement la hauteur maximale, ce qui empêchera la croissance de nouvelles zones.
Même après avoir rompu la boucle, nous obtenons toujours la bonne carte. Il n'a tout simplement pas la bonne quantité de terrain et il ne sera pas très intéressant. Affichez une notification à ce sujet dans la console, nous faisant savoir quel terrain restant nous n'avons pas dépensé.
void CreateLand () { … if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } }
95% des terrains avec une bordure de carte de 10 n'ont pas pu dépenser la totalité du montant.Pourquoi une carte défaillante a-t-elle encore des variantes?Le littoral présente une variabilité, car lorsque les hauteurs à l'intérieur de la zone de création deviennent trop élevées, de nouvelles zones ne leur permettent pas de croître vers l'extérieur. Le même principe ne permet pas aux parcelles de se développer sur de petites surfaces jusqu'à ce qu'elles atteignent la hauteur maximale et se révèlent simplement manquantes. De plus, la variabilité augmente lors de l'abaissement des parcelles.
paquet d'unitéPartitionner une carte
Maintenant que nous avons la bordure de la carte, nous avons essentiellement divisé la carte en deux régions distinctes: la région de la frontière et la région où les tracés ont été créés. Étant donné que seule la région de création est importante pour nous, nous pouvons considérer un tel cas comme une situation avec une seule région. La région ne couvre tout simplement pas la totalité de la carte. Mais si cela est impossible, rien ne nous empêche de diviser la carte en plusieurs régions non liées de la création de terres. Cela permettra aux masses terrestres de se former indépendamment les unes des autres, désignant différents continents.
Région de la carte
Commençons par décrire une région de la carte comme une structure. Cela simplifiera notre travail avec plusieurs régions. Créons une structure
MapRegion
pour cela, qui contient simplement les champs de bordure de la région. Puisque nous n'utiliserons pas cette structure en dehors de
HexMapGenerator
, nous pouvons la définir à l'intérieur de cette classe comme une structure interne privée. Ensuite, quatre champs entiers peuvent être remplacés par un champ
MapRegion
.
Pour que tout fonctionne, nous devons ajouter le préfixe de
region.
aux champs minimum-maximum dans
GenerateMap
region.
.
region.xMin = mapBorderX; region.xMax = x - mapBorderX; region.zMin = mapBorderZ; region.zMax = z - mapBorderZ;
Et aussi dans
GetRandomCell
.
HexCell GetRandomCell () { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
Plusieurs régions
Pour prendre en charge plusieurs régions, remplacez un champ
MapRegion
liste de régions.
À ce stade, il serait intéressant d'ajouter une méthode distincte pour créer des régions. Il doit créer la liste souhaitée ou l'effacer si elle existe déjà. Après cela, il déterminera une région, comme nous l'avons fait auparavant, et l'ajoutera à la liste.
void CreateRegions () { if (regions == null) { regions = new List<MapRegion>(); } else { regions.Clear(); } MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); }
Nous appellerons cette méthode dans
GenerateMap
et nous ne créerons pas la région directement.
Pour que
GetRandomCell
puisse fonctionner avec une région arbitraire, donnez-lui le paramètre
MapRegion
.
HexCell GetRandomCell (MapRegion region) { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
Maintenant, les
SinkTerrain
RaiseTerraion
et
SinkTerrain
doivent passer la région correspondante à
GetRandomCell
. Pour ce faire, chacun d'eux a également besoin d'un paramètre de région.
int RaiseTerrain (int chunkSize, int budget, MapRegion region) { searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(region); … } int SinkTerrain (int chunkSize, int budget, MapRegion region) { searchFrontierPhase += 1; HexCell firstCell = GetRandomCell(region); … }
La méthode
CreateLand
doit déterminer pour chaque région d'augmenter ou de diminuer les sections. Pour équilibrer les terres entre les régions, nous allons simplement parcourir à plusieurs reprises la liste des régions du cycle.
void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1); if (Random.value < sinkProbability) { landBudget = SinkTerrain(chunkSize, landBudget, region); } else { landBudget = RaiseTerrain(chunkSize, landBudget, region); } } } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } }
Cependant, nous devons encore faire la baisse des parcelles uniformément réparties. Cela peut être fait en décidant pour toutes les régions s'il faut les omettre.
for (int guard = 0; landBudget > 0 && guard < 10000; guard++) { bool sink = Random.value < sinkProbability; for (int i = 0; i < regions.Count; i++) { MapRegion region = regions[i]; int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
Enfin, afin d'utiliser exactement la totalité du terrain, nous devons arrêter le processus dès que le montant atteint zéro. Cela peut se produire à n'importe quelle étape du cycle de la région. Par conséquent, nous déplaçons la vérification à somme nulle dans la boucle intérieure. En fait, nous ne pouvons effectuer ce contrôle qu'après avoir soulevé un terrain, car lors de l'abaissement, le montant n'est jamais dépensé. Si nous avons terminé, nous pouvons immédiatement quitter la méthode
CreateLand
.
Deux régions
Bien que nous ayons désormais le soutien de plusieurs régions, nous n'en demandons toujours qu'une.
CreateRegions
les
CreateRegions
pour qu'il divise la carte en deux verticalement. Pour ce faire, nous
xMax
deux la valeur
xMax
de la région ajoutée. Ensuite, nous utilisons la même valeur pour
xMin
et utilisons à nouveau la valeur d'origine pour
xMax
, en l'utilisant comme deuxième région.
MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region);
Générer des cartes à ce stade ne fera aucune différence. Bien que nous ayons identifié deux régions, elles occupent la même région qu'une ancienne région. Pour les séparer, vous devez laisser un espace vide entre eux. Cela peut être fait en ajoutant un curseur à la frontière de la région, en utilisant le même intervalle et la même valeur par défaut que pour les frontières de la carte.
[Range(0, 10)] public int regionBorder = 5;
Curseur de bordure de région.Comme le terrain peut se former de chaque côté de l'espace entre les régions, la probabilité de créer des ponts terrestres aux bords de la carte augmentera. Pour éviter cela, nous utilisons la frontière de la région pour définir une zone sans terre entre la ligne de division et la région dans laquelle les parcelles peuvent commencer. Cela signifie que la distance entre les régions voisines est deux fois supérieure à la taille de la frontière de la région.
Pour appliquer cette limite de région, soustrayez-la du
xMax
première région et ajoutez la deuxième région à
xMin
.
MapRegion region; region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region);
La carte est divisée verticalement en deux régions.Avec les paramètres par défaut, deux régions sensiblement séparées seront créées, cependant, comme dans le cas d'une région et d'une grande bordure de carte, nous ne sommes pas garantis de recevoir exactement deux masses terrestres. Le plus souvent ce sera deux grands continents, éventuellement avec plusieurs îles. Mais parfois, deux ou plusieurs grandes îles peuvent être créées dans une région. Et parfois, deux continents peuvent être reliés par un isthme.
Bien sûr, nous pouvons également diviser la carte horizontalement, en changeant les approches pour mesurer X et Z. Choisissons au hasard l'une des deux orientations possibles.
MapRegion region; if (Random.value < 0.5f) { region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); } else { region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); }
Carte horizontalement divisée en deux régions.Puisque nous utilisons une carte large, des régions plus larges et plus minces seront créées avec une séparation horizontale. En conséquence, ces régions sont plus susceptibles de former plusieurs masses terrestres divisées.
Quatre régions
Rendons le nombre de régions personnalisable, créons un support de 1 à 4 régions.
[Range(1, 4)] public int regionCount = 1;
Curseur pour le nombre de régions.Nous pouvons utiliser l'
switch
pour sélectionner l'exécution du code de région correspondant. Nous commençons par répéter le code d'une région, qui sera utilisé par défaut, et laissons le code de deux régions pour le cas 2.
MapRegion region; switch (regionCount) { default: region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); } else { region.xMin = mapBorderX; region.xMax = grid.cellCountX - mapBorderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } break; }
Qu'est-ce que l'instruction switch?Il s'agit d'une alternative à l'écriture d'une séquence d'instructions if-else-if-else. est appliqué à la variable et des étiquettes sont utilisées pour indiquer quel code doit être exécuté. Il existe également une étiquette
default
, qui est utilisée comme dernier bloc
else
. Chaque option doit se terminer par une instruction
break
ou une
return
.
Pour garder le bloc
switch
lisible, il est généralement préférable de garder tous les cas courts, idéalement avec une seule instruction ou un appel de méthode. Je ne ferai pas cela comme un exemple de code de région, mais si vous voulez créer des régions plus intéressantes, je vous recommande d'utiliser des méthodes distinctes. Par exemple:
switch (regionCount) { default: CreateOneRegion(); break; case 2: CreateTwoRegions(); break; case 3: CreateThreeRegions(); break; case 4: CreateFourRegions(); break; }
Trois régions sont similaires à deux, seuls les tiers sont utilisés au lieu de la moitié. Dans ce cas, la division horizontale créera des régions trop étroites, nous avons donc créé un support uniquement pour la division verticale. Notez qu'en conséquence, nous avons doublé la zone frontalière de la région, de sorte que l'espace pour créer de nouvelles sections est inférieur à celui de deux régions.
switch (regionCount) { default: … break; case 2: … break; case 3: region.xMin = mapBorderX; region.xMax = grid.cellCountX / 3 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = grid.cellCountX / 3 + regionBorder; region.xMax = grid.cellCountX * 2 / 3 - regionBorder; regions.Add(region); region.xMin = grid.cellCountX * 2 / 3 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); break; }
Trois régions.Quatre régions peuvent être créées en combinant la séparation horizontale et verticale et en ajoutant une région à chaque coin de la carte.
switch (regionCount) { … case 4: region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.xMin = grid.cellCountX / 2 + regionBorder; region.xMax = grid.cellCountX - mapBorderX; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); region.xMin = mapBorderX; region.xMax = grid.cellCountX / 2 - regionBorder; regions.Add(region); break; } }
Quatre régions.L'approche utilisée ici est le moyen le plus simple de diviser une carte. Il génère approximativement les mêmes régions en masse de terres, et leur variabilité est contrôlée par d'autres paramètres de génération de cartes. Cependant, il sera toujours assez évident que la carte a été divisée en lignes droites. Plus nous avons besoin de contrôle, moins le résultat sera organique. Par conséquent, c'est normal si vous avez besoin de régions à peu près égales pour le gameplay. Mais si vous avez besoin du terrain le plus varié et le plus illimité, vous devrez le faire avec l'aide d'une seule région.
De plus, il existe d'autres façons de diviser la carte. Nous ne pouvons pas nous limiter uniquement aux lignes droites. Nous n'avons même pas besoin d'utiliser des régions de la même taille, ni de couvrir la carte entière avec elles. Nous pouvons laisser des trous. Vous pouvez également autoriser les intersections de régions ou modifier la répartition des terres entre les régions. Vous pouvez même définir vos propres paramètres de générateur pour chaque région (bien que cela soit plus compliqué), par exemple, pour avoir un grand continent et un archipel sur la carte.
paquet d'unitéL'érosion
Jusqu'à présent, toutes les cartes que nous avons générées semblaient plutôt grossières et cassées.
Un vrai relief peut ressembler à ceci, mais avec le temps, il devient de plus en plus lisse, ses parties acérées deviennent ternes à cause de l'érosion. Pour améliorer les cartes, nous pouvons appliquer ce processus d'érosion. Nous le ferons après avoir créé un terrain accidenté, selon une méthode distincte. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); SetTerrainType(); … } … void ErodeLand () {}
Pourcentage d'érosion
Plus le temps passe, plus l'érosion apparaît. Par conséquent, nous voulons que l'érosion ne soit pas permanente, mais personnalisable. Au minimum, l'érosion est nulle, ce qui correspond aux cartes créées précédemment. Au maximum, l'érosion est complète, c'est-à-dire que la poursuite de l'application des forces d'érosion ne changera plus le terrain. Autrement dit, le paramètre d'érosion doit être un pourcentage de 0 à 100, et par défaut, nous prendrons 50. [Range(0, 100)] public int erosionPercentage = 50;
Curseur d'érosion.Recherche de cellules destructrices d'érosion
L'érosion rend le relief plus lisse. Dans notre cas, les seules parties acérées sont les falaises. Ils seront donc la cible du processus d'érosion. Si une falaise existe, l'érosion devrait la réduire jusqu'à ce qu'elle se transforme finalement en pente. Nous ne lisserons pas les pentes, car cela conduira à un terrain ennuyeux. Pour ce faire, nous devons déterminer quelles cellules se trouvent au sommet des falaises et abaisser leur hauteur. Ce seront des cellules sujettes à l'érosion.Créons une méthode qui détermine si une cellule peut être sujette à l'érosion. Il le détermine en vérifiant les voisins de la cellule jusqu'à ce qu'il trouve une différence de hauteur suffisamment grande. Étant donné que les falaises nécessitent une différence d'au moins un ou deux niveaux de hauteur, la cellule est sujette à l'érosion si un ou plusieurs de ses voisins se trouvent à au moins deux marches en dessous. S'il n'y a pas un tel voisin, alors la cellule ne peut pas subir d'érosion. bool IsErodible (HexCell cell) { int erodibleElevation = cell.Elevation - 2; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (neighbor && neighbor.Elevation <= erodibleElevation) { return true; } } return false; }
Nous pouvons utiliser cette méthode ErodeLand
pour parcourir toutes les cellules et écrire toutes les cellules sujettes à l'érosion dans une liste temporaire. void ErodeLand () { List<HexCell> erodibleCells = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (IsErodible(cell)) { erodibleCells.Add(cell); } } ListPool<HexCell>.Add(erodibleCells); }
Une fois que nous connaissons le nombre total de cellules sujettes à l'érosion, nous pouvons utiliser le pourcentage d'érosion pour déterminer le nombre de cellules sujettes à l'érosion restantes. Par exemple, si le pourcentage est de 50, alors nous devons érosion des cellules jusqu'à ce que la moitié de la quantité d'origine reste. Si le pourcentage est de 100, nous ne nous arrêterons pas tant que nous n'aurons pas détruit toutes les cellules sujettes à l'érosion. void ErodeLand () { List<HexCell> erodibleCells = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int targetErodibleCount = (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f); ListPool<HexCell>.Add(erodibleCells); }
Ne devrions-nous pas considérer uniquement les cellules sujettes à l'érosion des terres?. , , .
Réduction cellulaire
Commençons par une approche naïve et supposons qu'une simple réduction de la hauteur des cellules détruites par l'érosion ne la rendra plus sujette à l'érosion. Si cela était vrai, nous pourrions simplement prendre des cellules aléatoires dans la liste, réduire leur hauteur, puis les supprimer de la liste. Nous répéterions cette opération jusqu'à ce que nous atteignions le nombre souhaité de cellules sensibles à l'érosion. int targetErodibleCount = (int)(erodibleCells.Count * (100 - erosionPercentage) * 0.01f); while (erodibleCells.Count > targetErodibleCount) { int index = Random.Range(0, erodibleCells.Count); HexCell cell = erodibleCells[index]; cell.Elevation -= 1; erodibleCells.Remove(cell); } ListPool<HexCell>.Add(erodibleCells);
Pour empêcher la recherche requise erodibleCells.Remove
, nous remplacerons la cellule actuelle en dernier dans la liste, puis supprimerons le dernier élément. Nous ne nous soucions toujours pas de leur commande.
Diminution naïve de 0% et 100% des cellules sujettes à l'érosion, carte des graines 1957632474.Suivi de l'érosion
Notre approche naïve nous permet d'appliquer l'érosion, mais pas au bon degré. Cela se produit car la cellule après une diminution de hauteur peut toujours rester sujette à l'érosion. Par conséquent, nous ne supprimerons une cellule de la liste que lorsqu'elle ne sera plus soumise à l'érosion. if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); }
100% d'érosion tout en conservant les cellules sujettes à l'érosion dans la liste.Nous obtenons donc une érosion beaucoup plus forte, mais lorsque nous utilisons 100%, nous ne nous débarrassons toujours pas de toutes les falaises. La raison en est qu'après avoir réduit la hauteur de la cellule, l'un de ses voisins peut devenir sujet à l'érosion. Par conséquent, par conséquent, nous pourrions avoir plus de cellules sujettes à l'érosion qu'elles ne l'étaient à l'origine.Après avoir abaissé la cellule, nous devons vérifier tous ses voisins. Si maintenant ils sont sujets à l'érosion, mais qu'ils ne sont pas encore sur la liste, vous devez les ajouter là-bas. if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && IsErodible(neighbor) && !erodibleCells.Contains(neighbor) ) { erodibleCells.Add(neighbor); } }
Toutes les cellules érodées sont omises.Nous économisons beaucoup de terrain
Maintenant, le processus d'érosion peut continuer jusqu'à ce que toutes les falaises disparaissent. Cela affecte grandement la terre. La majeure partie de la masse terrestre a disparu et nous avons obtenu beaucoup moins que le pourcentage de terres nécessaires. Cela s'est produit parce que nous supprimons des terres de la carte.La véritable érosion ne détruit pas la matière. Elle le prend d'un endroit et le place ailleurs. Nous pouvons faire de même. Avec une diminution d'une cellule, nous devons élever l'un de ses voisins. En fait, un niveau de hauteur est transféré dans une cellule inférieure. Cela enregistre la quantité totale de hauteurs de carte, tout en la lissant simplement.Pour réaliser cela, nous devons décider où transférer les produits d'érosion. Ce sera notre objectif d'érosion. Créons une méthode pour déterminer le point cible d'une cellule à éroder. Étant donné que cette cellule contient une interruption, il serait logique de sélectionner la cellule située sous cette interruption comme cible. Mais une cellule sujette à l'érosion peut avoir plusieurs ruptures, nous allons donc vérifier tous les voisins et mettre tous les candidats sur une liste temporaire, puis nous choisirons l'un d'eux au hasard. HexCell GetErosionTarget (HexCell cell) { List<HexCell> candidates = ListPool<HexCell>.Get(); int erodibleElevation = cell.Elevation - 2; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (neighbor && neighbor.Elevation <= erodibleElevation) { candidates.Add(neighbor); } } HexCell target = candidates[Random.Range(0, candidates.Count)]; ListPool<HexCell>.Add(candidates); return target; }
Dans ErodeLand
nous définissons la cellule cible immédiatement après avoir sélectionné la cellule d'érosion. Ensuite, nous diminuons et augmentons immédiatement la hauteur des cellules. Dans ce cas, la cellule cible elle-même peut devenir sensible à l'érosion, mais cette situation est résolue lorsque nous vérifions les voisins de la cellule nouvellement érodée. HexCell cell = erodibleCells[index]; HexCell targetCell = GetErosionTarget(cell); cell.Elevation -= 1; targetCell.Elevation += 1; if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); }
Depuis que nous avons élevé la cellule cible, une partie des voisins de cette cellule peut ne plus être soumise à l'érosion. Il faut les contourner et vérifier s'ils sont sujets à l'érosion. Sinon, mais ils sont dans la liste, vous devez les supprimer. for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); … } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && !IsErodible(neighbor) && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } }
100% d'érosion tout en maintenant la masse terrestre.L'érosion peut maintenant lisser le terrain beaucoup mieux, en abaissant certaines zones et en soulevant d'autres. En conséquence, la masse de terre peut à la fois augmenter et rétrécir. Cela peut modifier le pourcentage de terres de plusieurs pour cent dans un sens ou dans un autre, mais de graves écarts se produisent rarement. Autrement dit, plus nous appliquons d'érosion, moins nous aurons de contrôle sur le pourcentage de terres qui en résulte.Érosion accélérée
Bien que nous n'ayons pas vraiment besoin de nous soucier de l'efficacité de l'algorithme d'érosion, nous pouvons y apporter des améliorations simples. Tout d'abord, notez que nous vérifions explicitement si la cellule que nous avons érodée peut être érodée. Sinon, nous le supprimons essentiellement de la liste. Par conséquent, vous pouvez ignorer la vérification de cette cellule lors de la traversée des voisins de la cellule cible. for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && !IsErodible(neighbor) && erodibleCells.Contains(neighbor) ) { erodibleCells.Remove(neighbor); } }
Deuxièmement, nous devions vérifier les voisins de la cellule cible uniquement lorsqu'il y avait une rupture entre eux, mais maintenant ce n'est plus nécessaire. Cela ne se produit que lorsque le voisin est maintenant un cran plus haut que la cellule cible. Si c'est le cas, le voisin est garanti d'être sur la liste, nous n'avons donc pas besoin de vérifier cela, c'est-à-dire que nous pouvons ignorer la recherche inutile. HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && neighbor.Elevation == targetCell.Elevation + 1 && !IsErodible(neighbor)
Troisièmement, nous pouvons utiliser une astuce similaire lors de la vérification des voisins d'une cellule sujette à l'érosion. S'il y a maintenant une falaise entre eux, alors le voisin est sujet à l'érosion. Pour le savoir, nous n'avons pas besoin d'appeler IsErodible
. HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && neighbor.Elevation == cell.Elevation + 2 &&
Cependant, nous devons encore vérifier si la cellule cible est sensible à l'érosion, mais le cycle illustré ci-dessus ne le fait plus. Par conséquent, nous effectuons cela explicitement pour la cellule cible. if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (IsErodible(targetCell) && !erodibleCells.Contains(targetCell)) { erodibleCells.Add(targetCell); } for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … }
Maintenant, nous pouvons appliquer l'érosion assez rapidement et au pourcentage souhaité par rapport au nombre initial de falaises générées. Notez que du fait que nous avons légèrement changé l'endroit où la cellule cible est ajoutée à la liste sujette à l'érosion, le résultat a légèrement changé par rapport au résultat avant optimisations.25%, 50%, 75% et 100% d'érosion.Notez également que malgré la forme modifiée de la côte, la topologie n'a pas fondamentalement changé. Les masses terrestres restent généralement connectées ou séparées. Seules les petites îles peuvent se noyer complètement. Les détails en relief sont lissés, mais les formes générales restent les mêmes. Une articulation étroite peut disparaître ou grossir un peu. Un petit espace peut se remplir ou se dilater légèrement. Par conséquent, l'érosion ne collera pas fortement les régions divisées.Quatre régions complètement érodées restent séparées.paquet d'unitéPartie 25: Le cycle de l'eau
- Affichez les données cartographiques brutes.
- Nous formons un climat de cellules.
- Créez une simulation partielle du cycle de l'eau.
Dans cette partie, nous ajouterons de l'humidité sur terre.Ce didacticiel a été créé dans Unity 2017.3.0.Nous utilisons le cycle de l'eau pour déterminer les biomes.Les nuages
Jusqu'à présent, l'algorithme de génération de carte ne modifiait que la hauteur des cellules. La plus grande différence entre les cellules était de savoir si elles étaient au-dessus ou au-dessous de l'eau. Bien que nous puissions définir différents types de terrain, ce n'est qu'une simple visualisation de la hauteur. Il sera préférable de préciser les types de relief, compte tenu du climat local.Le climat de la Terre est un système très complexe. Heureusement, nous n'avons pas besoin de créer des simulations climatiques réalistes. Nous aurons besoin de quelque chose d'assez naturel. L'aspect le plus important du climat est le cycle de l'eau, car la flore et la faune ont besoin d'eau liquide pour survivre. La température est également très importante, mais pour l'instant, nous nous concentrons sur l'eau, laissant essentiellement la température globale constante et ne modifiant que l'humidité.Le cycle de l'eau décrit le mouvement de l'eau dans l'environnement. Autrement dit, les étangs s'évaporent, ce qui conduit à la création de nuages qui pleuvent, qui se jettent à nouveau dans les étangs. Le système comporte de nombreux autres aspects, mais la simulation de ces étapes peut déjà être suffisante pour créer une distribution naturelle de l'eau sur la carte.Visualisation des données
Avant d'entrer dans cette simulation, il sera utile de voir directement les données pertinentes. Pour ce faire, nous allons changer le shader Terrain . Nous lui ajoutons une propriété commutable, qui peut être commutée en mode de visualisation des données, qui affiche des données cartographiques brutes au lieu des textures de relief habituelles. Cela peut être implémenté à l'aide d'une propriété float avec un attribut commutable qui définit le mot-clé. Pour cette raison, il apparaîtra dans l'inspecteur des matériaux comme un indicateur qui contrôle la définition d'un mot-clé. Le nom du bien lui-même n'a pas d'importance, nous ne nous intéressons qu'au mot-clé. Nous utilisons SHOW_MAP_DATA . Properties { _Color ("Color", Color) = (1,1,1,1) _MainTex ("Terrain Texture Array", 2DArray) = "white" {} _GridTex ("Grid Texture", 2D) = "white" {} _Glossiness ("Smoothness", Range(0,1)) = 0.5 _Specular ("Specular", Color) = (0.2, 0.2, 0.2) _BackgroundColor ("Background Color", Color) = (0,0,0) [Toggle(SHOW_MAP_DATA)] _ShowMapData ("Show Map Data", Float) = 0 }
Basculez pour afficher les données cartographiques.Ajoutez une fonction de shader pour activer la prise en charge des mots clés. #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA
Nous allons lui faire afficher un seul flottant, comme c'est le cas avec le reste des données de relief. Pour l'implémenter, nous ajouterons un Input
champ à la structure mapData
lorsque le mot clé sera défini. struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility;
Dans le programme vertex, nous utilisons le canal Z de ces cellules pour remplir mapData
, comme toujours interpolé entre les cellules. void vert (inout appdata_full v, out Input data) { … #if defined(SHOW_MAP_DATA) data.mapData = cell0.z * v.color.x + cell1.z * v.color.y + cell2.z * v.color.z; #endif }
Lorsque vous devez afficher des données de cellule, utilisez-les directement comme fragment d'albédo au lieu de la couleur habituelle. Multipliez-le par la grille afin que la grille soit toujours activée lors du rendu des données. void surf (Input IN, inout SurfaceOutputStandardSpecular o) { … o.Albedo = c.rgb * grid * _Color * explored; #if defined(SHOW_MAP_DATA) o.Albedo = IN.mapData * grid; #endif … }
Pour transférer réellement des données vers un shader. nous devons ajouter à la HexCellShaderData
méthode qui écrit quelque chose sur le canal de données de texture bleue. Les données sont une valeur flottante unique limitée à 0–1. public void SetMapData (HexCell cell, float data) { cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 255f) : (byte)255); enabled = true; }
Cependant, cette décision affecte le système de recherche. Une valeur de données de canal bleu 255 est utilisée pour indiquer que la visibilité des cellules est en transition. Pour que ce système continue de fonctionner, nous devons utiliser au maximum la valeur d'octet 254. Notez que le mouvement du détachement effacera toutes les données de la carte, mais cela nous convient, car elles sont utilisées pour déboguer la génération de cartes. cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254);
Ajoutez une méthode avec le même nom et dans HexCell
. Il transférera la demande dans ses données de shader. public void SetMapData (float data) { ShaderData.SetMapData(this, data); }
Pour vérifier le fonctionnement du code, nous le modifions HexMapGenerator.SetTerrainType
afin qu'il définit les données de chaque cellule de la carte. Visualisons la hauteur convertie de l'entier en flottant dans l'intervalle 0–1. Cela se fait en soustrayant la hauteur minimale de la hauteur de la cellule, puis en divisant par la hauteur maximale moins le minimum. Faisons la division en virgule flottante. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } }
Nous pouvons maintenant basculer entre le terrain normal et la visualisation des données à l'aide de la case à cocher Afficher les données cartographiques de l'actif matériel Terrain .Carte 1208905299, terrain normal et visualisation des hauteurs.Création du climat
Pour simuler le climat, nous devons suivre les données climatiques. La carte étant constituée de cellules discrètes, chacune d'elles a son propre climat local. Créez une structure ClimateData
pour stocker toutes les données pertinentes. Bien sûr, vous pouvez ajouter des données aux cellules elles-mêmes, mais nous ne les utiliserons que lors de la génération de la carte. Par conséquent, nous les enregistrerons séparément. Cela signifie que nous pouvons définir cette structure en interne HexMapGenerator
, comme MapRegion
. Nous commencerons par suivre uniquement les nuages, qui peuvent être implémentés à l'aide d'un seul champ flottant. struct ClimateData { public float clouds; }
Ajoutez une liste pour suivre les données climatiques de toutes les cellules. List<ClimateData> climate = new List<ClimateData>();
Maintenant, nous avons besoin d'une méthode pour créer une carte climatique. Il doit commencer par effacer la liste des zones climatiques, puis ajouter un élément pour chaque cellule. Les données climatiques initiales sont tout simplement nulles, cela peut être réalisé en utilisant un constructeur standard ClimateData
. void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } }
Le climat doit être créé après une exposition à l'érosion des terres avant de définir les types de relief. En réalité, l'érosion est principalement causée par le mouvement de l'air et de l'eau, qui font partie du climat, mais nous ne simulerons pas cela. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … }
Modifiez SetTerrainType
afin que nous puissions voir les données du nuage au lieu de la hauteur des cellules. Initialement, il ressemblera à une carte noire. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData(climate[i].clouds); } }
Changement climatique
La première étape de la simulation climatique est l'évaporation. Quelle quantité d'eau devrait s'évaporer? Contrôlons cette valeur à l'aide du curseur. Une valeur de 0 signifie aucune évaporation, 1 - évaporation maximale. Par défaut, nous utilisons 0,5. [Range(0f, 1f)] public float evaporation = 0.5f;
Curseur d'évaporation.Créons une autre méthode spécifiquement pour façonner le climat d'une cellule. Nous lui donnons l'indice de cellule comme paramètre et nous l'utilisons pour obtenir la cellule correspondante et ses données climatiques. Si la cellule est sous l'eau, il s'agit alors d'un réservoir qui doit s'évaporer. Nous transformons immédiatement la vapeur en nuages (en ignorant les points de rosée et la condensation), nous ajouterons donc directement l'évaporation à la valeur des nuages cellulaires. Lorsque vous avez terminé, copiez les données climatiques dans la liste. void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; }
Appelez cette méthode pour chaque cellule CreateClimate
. void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
Mais cela ne suffit pas. Pour créer une simulation complexe, nous devons façonner le climat des cellules plusieurs fois. Plus nous le faisons souvent, meilleur sera le résultat. Choisissons simplement une valeur constante. J'utilise 40 cycles. for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
Depuis, alors que nous n'augmentons que la valeur des nuages au-dessus des cellules inondées d'eau, nous obtenons ainsi des terres noires et des réservoirs blancs.Evaporation sur l'eau.Diffusion des nuages
Les nuages ne sont pas constamment au même endroit, surtout lorsque de plus en plus d'eau s'évapore. La différence de pression fait bouger l'air, qui se manifeste sous forme de vent, ce qui fait aussi bouger les nuages.S'il n'y a pas de direction dominante du vent, alors en moyenne les nuages de cellules se disperseront uniformément dans toutes les directions, apparaissant dans les cellules voisines. Lors de la génération de nouveaux nuages dans le cycle suivant, répartissons tous les nuages de la cellule dans ses voisins. Autrement dit, chaque voisin reçoit un sixième des nuages de cellules, après quoi il y a une diminution locale à zéro. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate;
Pour ajouter des nuages à vos voisins, vous devez les contourner en boucle, obtenir leurs données climatiques, augmenter la valeur des nuages et les recopier dans la liste. float cloudDispersal = cellClimate.clouds * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; climate[neighbor.Index] = neighborClimate; } cellClimate.clouds = 0f;
Nuages épars.Cela crée une carte presque blanche, car à chaque cycle, les cellules sous-marines ajoutent de plus en plus de nuages au climat mondial. Après le premier cycle, les cellules terrestres à côté de l'eau auront également des nuages qui doivent être dispersés. Ce processus se poursuit jusqu'à ce que la majeure partie de la carte soit couverte de nuages. Dans le cas de la carte 1208905299 avec les paramètres par défaut, seul l'intérieur de la grande masse de terrain dans le nord-est est resté complètement découvert.Notez que les étangs peuvent générer un nombre infini de nuages. Le niveau d'eau ne fait pas partie de la simulation climatique. En réalité, les réservoirs ne sont préservés que parce que l'eau y retourne à peu près au rythme de l'évaporation. Autrement dit, nous ne simulons qu'un cycle partiel de l'eau. C'est normal, mais il faut comprendre que plus la simulation se déroule, plus l'eau est ajoutée au climat. Jusqu'à présent, la perte d'eau ne se produit que sur les bords de la carte, où les nuages dispersés sont perdus en raison du manque de voisins.Vous pouvez voir la perte d'eau en haut de la carte, en particulier dans les cellules en haut à droite. Dans la dernière cellule, il n'y a pas de nuages du tout, car il reste la dernière dans laquelle le climat se forme. Elle n'a pas encore reçu de nuages d'un voisin.Le climat de toutes les cellules ne devrait-il pas se former en parallèle?, . - , . 40 . - , .
Précipitations
L'eau ne reste pas froide pour toujours. À un moment donné, elle devrait retomber au sol. Cela se produit généralement sous forme de pluie, mais parfois il peut s'agir de neige, de grêle ou de neige mouillée. Tout cela s'appelle généralement précipitation. L'ampleur et le taux de disparition des nuages varient considérablement, mais nous utilisons simplement un taux de pluie mondial personnalisé. Une valeur de 0 signifie aucune précipitation, une valeur de 1 signifie que tous les nuages disparaissent instantanément. La valeur par défaut est 0,25. Cela signifie qu'à chaque cycle, un quart des nuages disparaîtra. [Range(0f, 1f)] public float precipitationFactor = 0.25f;
Curseur de coefficient de précipitation.Nous simulerons les précipitations après évaporation et avant la diffusion des nuages. Cela signifie qu'une partie de l'eau évaporée des réservoirs précipite immédiatement, de sorte que le nombre de nuages dispersés diminue. Sur terre, les précipitations entraîneront la disparition des nuages. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; float cloudDispersal = cellClimate.clouds * (1f / 6f);
Des nuages qui disparaissent.Maintenant, lorsque nous détruisons 25% des nuages à chaque cycle, la terre redevient presque noire. Les nuages ne parviennent à se déplacer vers l'intérieur des terres qu'en quelques étapes, après quoi ils deviennent invisibles.paquet d'unitéHumidité
Bien que les précipitations détruisent les nuages, elles ne doivent pas éliminer l'eau du climat. Après être tombée au sol, l'eau est économisée, uniquement dans un état différent. Il peut exister sous de nombreuses formes, que nous considérerons généralement comme de l'humidité.Suivi de l'humidité
Nous allons améliorer le modèle climatique en suivant deux conditions de l'eau: les nuages et l'humidité. Pour implémenter cela, ajoutez dans le ClimateData
champ moisture
. struct ClimateData { public float clouds, moisture; }
Dans sa forme la plus généralisée, l'évaporation est le processus de conversion de l'humidité en nuages, du moins dans notre modèle climatique simple. Cela signifie que l'évaporation ne doit pas être une valeur constante, mais un autre facteur. Par conséquent, nous effectuons le refactoring-renommage evaporation
en evaporationFactor
. [Range(0f, 1f)] public float evaporationFactor = 0.5f;
Lorsque la cellule est sous l'eau, nous annonçons simplement que le taux d'humidité est 1. Cela signifie que l'évaporation est égale au coefficient d'évaporation. Mais maintenant, nous pouvons également obtenir l'évaporation des cellules de sushi. Dans ce cas, nous devons calculer l'évaporation, la soustraire de l'humidité et ajouter le résultat aux nuages. Après cela, les précipitations sont ajoutées à l'humidité. if (cell.IsUnderwater) { cellClimate.moisture = 1f; cellClimate.clouds += evaporationFactor; } else { float evaporation = cellClimate.moisture * evaporationFactor; cellClimate.moisture -= evaporation; cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation;
Étant donné que les nuages sont maintenant soutenus par l'évaporation de la terre, nous pouvons les déplacer plus à l'intérieur des terres. Maintenant, la majeure partie de la terre est devenue grise.Nuages avec évaporation de l'humidité.Modifions-le SetTerrainType
pour qu'il affiche l'humidité au lieu des nuages, car nous allons l'utiliser pour déterminer les types de relief. cell.SetMapData(climate[i].moisture);
Affichage de l'humidité.À ce stade, l'humidité ressemble assez aux nuages (sauf que toutes les cellules sous-marines sont blanches), mais cela changera bientôt.Ruissellement pluvial
L'évaporation n'est pas le seul moyen par lequel l'humidité peut quitter la cellule. Le cycle de l'eau nous dit que la majeure partie de l'humidité ajoutée à la terre se retrouve en quelque sorte dans l'eau. Le processus le plus notable est l'écoulement de l'eau sur la terre sous l'influence de la gravité. Nous ne simulerons pas de vraies rivières, mais utiliserons un coefficient de ruissellement des précipitations personnalisé. Il indiquera le pourcentage d'eau s'écoulant vers les zones inférieures. Par défaut, le stock sera égal à 25%. [Range(0f, 1f)] public float runoffFactor = 0.25f;
Curseur de vidange.Nous ne générerons pas de rivières?.
Le ruissellement de l'eau agit comme une dispersion des nuages, mais avec trois différences. Premièrement, toute l'humidité n'est pas éliminée de la cellule. Deuxièmement, il transporte l'humidité, pas les nuages. Troisièmement, il ne descend, c'est-à-dire qu'aux voisins de hauteur inférieure. Le coefficient de ruissellement décrit la quantité d'humidité qui s'écoulerait de la cellule si tous les voisins étaient inférieurs, mais souvent ils sont moindres. Cela signifie que nous ne réduirons l'humidité des cellules que lorsque nous trouverons un voisin ci-dessous. float cloudDispersal = cellClimate.clouds * (1f / 6f); float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = climate[neighbor.Index]; neighborClimate.clouds += cloudDispersal; int elevationDelta = neighbor.Elevation - cell.Elevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } climate[neighbor.Index] = neighborClimate; }
L'eau s'écoule à une hauteur inférieure.En conséquence, nous avons une distribution plus diversifiée de l'humidité, car les cellules élevées transmettent leur humidité à l'inférieur. Nous voyons également beaucoup moins d'humidité dans les cellules côtières, car elles drainent l'humidité dans les cellules sous-marines. Pour affaiblir cet effet, nous devons également utiliser le niveau d'eau pour déterminer si la cellule est plus basse, c'est-à-dire prendre la hauteur apparente. int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
Utilisez la hauteur visible.Seepage
Non seulement l'eau coule, elle se répand, s'infiltre à travers la topographie plane et est absorbée par le terrain adjacent aux plans d'eau. Cet effet peut avoir peu d'effet, mais il est utile pour lisser la distribution de l'humidité, alors ajoutons-le à la simulation. Créons-lui son propre coefficient personnalisé, par défaut égal à 0,125. [Range(0f, 1f)] public float seepageFactor = 0.125f;
Curseur de fuite.Le suintement est similaire à un drain, sauf qu'il est utilisé lorsque le voisin a la même hauteur visible que la cellule elle-même. float runoff = cellClimate.moisture * runoffFactor * (1f / 6f); float seepage = cellClimate.moisture * seepageFactor * (1f / 6f); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … int elevationDelta = neighbor.ViewElevation - cell.ViewElevation; if (elevationDelta < 0) { cellClimate.moisture -= runoff; neighborClimate.moisture += runoff; } else if (elevationDelta == 0) { cellClimate.moisture -= seepage; neighborClimate.moisture += seepage; } climate[neighbor.Index] = neighborClimate; }
Ajout d'une petite fuite.paquet d'unitéOmbres de pluie
Bien que nous ayons déjà créé une simulation digne du cycle de l'eau, elle ne semble pas très intéressante, car elle n'a pas d'ombres de pluie, ce qui démontre le plus clairement les différences climatiques. Les ombres pluviales sont des zones dans lesquelles il y a un manque important de précipitations par rapport aux zones voisines. Ces zones existent parce que les montagnes empêchent les nuages de les atteindre. Leur création nécessite de hautes montagnes et une direction de vent dominante.Le vent
Commençons par ajouter une direction de vent dominante à la simulation. Bien que les directions dominantes du vent varient considérablement à la surface de la Terre, nous nous en sortirons avec une direction mondiale du vent personnalisable. Utilisons le nord-ouest par défaut. De plus, rendons la force du vent réglable de 1 à 10 avec une valeur par défaut de 4. public HexDirection windDirection = HexDirection.NW; [Range(1f, 10f)] public float windStrength = 4f;
La direction et la force du vent.La force du vent dominant est exprimée par rapport à la dispersion totale des nuages. Si la force du vent est de 1, la diffusion est la même dans toutes les directions. Lorsqu'il est de 2, la diffusion est deux fois plus élevée dans la direction du vent que dans les autres directions, et ainsi de suite. Nous pouvons le faire en modifiant le diviseur dans la formule de dispersion des nuages. Au lieu de six, il sera égal à cinq plus l'énergie éolienne. float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
De plus, la direction du vent détermine la direction à partir de laquelle le vent souffle. Par conséquent, nous devons utiliser la direction opposée comme direction principale de diffusion. HexDirection mainDispersalDirection = windDirection.Opposite(); float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
Nous pouvons maintenant vérifier si le voisin est dans la direction principale de diffusion. Si c'est le cas, alors nous devons multiplier la dispersion des nuages par la force du vent. ClimateData neighborClimate = climate[neighbor.Index]; if (d == mainDispersalDirection) { neighborClimate.clouds += cloudDispersal * windStrength; } else { neighborClimate.clouds += cloudDispersal; }
Vent du nord-ouest, force 4.Le vent dominant ajoute une direction à la distribution de l'humidité sur la terre. Plus le vent est fort, plus l'effet devient puissant.Hauteur absolue
Le deuxième ingrédient pour obtenir des ombres de pluie est la montagne. Nous n'avons pas de classification stricte de ce qu'est une montagne, tout comme la nature ne l'a pas non plus. Seule la hauteur absolue est importante. En fait, lorsque l'air se déplace au-dessus de la montagne, il est forcé de s'élever, est refroidi et peut contenir moins d'eau, ce qui conduit à des précipitations avant que l'air ne passe au-dessus de la montagne. Par conséquent, de l'autre côté, nous obtenons de l'air sec, c'est-à-dire une ombre de pluie.Plus important encore, plus l'air monte, moins il peut contenir d'eau. Dans notre simulation, nous pouvons imaginer cela comme une restriction forcée de la valeur maximale du nuage pour chaque cellule. Plus la hauteur de cellule visible est élevée, plus ce maximum doit être bas. La façon la plus simple de procéder consiste à définir le maximum sur 1 moins la hauteur apparente, divisé par la hauteur maximale. Mais en fait, divisons par un maximum de moins 1. Cela permettra à une petite fraction des nuages de traverser même les cellules les plus hautes. Nous attribuons ce maximum après calcul des précipitations et avant diffusion. float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); HexDirection mainDispersalDirection = windDirection.Opposite();
Si, par conséquent, nous obtenons plus de nuages qu'il n'est acceptable, nous convertissons simplement les nuages en excès en humidité. En fait, c'est ainsi que nous ajoutons des précipitations supplémentaires, comme cela se produit dans de vraies montagnes. float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); if (cellClimate.clouds > cloudMaximum) { cellClimate.moisture += cellClimate.clouds - cloudMaximum; cellClimate.clouds = cloudMaximum; }
Ombres de pluie causées par la haute altitude.paquet d'unitéNous terminons la simulation
A ce stade, nous disposons déjà d'une simulation partielle de très haute qualité du cycle de l'eau. Mettons-le un peu dans l'ordre, puis appliquons-le pour déterminer le type de relief des cellules.Informatique parallèle
Comme mentionné précédemment sous le spoiler, l'ordre dans lequel les cellules sont formées affecte le résultat de la simulation. Idéalement, cela ne devrait pas être et, en substance, nous formons toutes les cellules en parallèle. Cela peut être fait en appliquant tous les changements du stade actuel de formation à la deuxième liste de climat nextClimate
. List<ClimateData> climate = new List<ClimateData>(); List<ClimateData> nextClimate = new List<ClimateData>();
Effacez et initialisez cette liste, comme tout le monde. Ensuite, nous échangerons des listes à chaque cycle. Dans ce cas, la simulation utilisera alternativement les deux listes et appliquera les données climatiques actuelles et suivantes. void CreateClimate () { climate.Clear(); nextClimate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(initialData); } for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } List<ClimateData> swap = climate; climate = nextClimate; nextClimate = swap; } }
Lorsqu'une cellule affecte le climat de son voisin, nous devons changer les données climatiques suivantes, pas celles actuelles. for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } ClimateData neighborClimate = nextClimate[neighbor.Index]; … nextClimate[neighbor.Index] = neighborClimate; }
Et au lieu de copier les données climatiques suivantes dans la liste climatique actuelle, nous obtenons les données climatiques suivantes, leur ajoutons l'humidité actuelle et les copions dans la liste suivante. Après cela, nous réinitialisons les données de la liste actuelle afin qu'elles soient mises à jour pour le cycle suivant.
Pendant que nous faisons cela, fixons également le niveau d'humidité à un maximum de 1 afin que les cellules terrestres ne puissent pas être plus humides que sous l'eau. nextCellClimate.moisture += cellClimate.moisture; if (nextCellClimate.moisture > 1f) { nextCellClimate.moisture = 1f; } nextClimate[cellIndex] = nextCellClimate;
Informatique parallèle.Humidité d'origine
Il est possible que la simulation produise trop de terres sèches, en particulier avec un pourcentage élevé de terres. Pour améliorer l'image, nous pouvons ajouter un niveau d'humidité initial personnalisé avec une valeur par défaut de 0,1. [Range(0f, 1f)] public float startingMoisture = 0.1f;
Ci-dessus se trouve le curseur de l'humidité d'origine.Nous utilisons cette valeur pour l'humidité de la liste climatique initiale, mais pas pour les suivantes. ClimateData initialData = new ClimateData(); initialData.moisture = startingMoisture; ClimateData clearData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(clearData); }
Avec humidité d'origine.Définition des biomes
Nous concluons en utilisant l'humidité au lieu de la hauteur pour spécifier le type de relief cellulaire. Utilisons la neige pour les terres complètement sèches, pour les régions arides, nous utilisons la neige, puis il y a la pierre, l'herbe pour assez humide et la terre pour les cellules saturées d'eau et sous-marines. Le moyen le plus simple consiste à utiliser cinq intervalles par incréments de 0,2. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float moisture = climate[i].moisture; if (!cell.IsUnderwater) { if (moisture < 0.2f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.4f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.6f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.8f) { cell.TerrainTypeIndex = 1; } else { cell.TerrainTypeIndex = 2; } } else { cell.TerrainTypeIndex = 2; } cell.SetMapData(moisture); } }
Biomes.Lorsque vous utilisez une distribution uniforme, le résultat n'est pas très bon et il ne semble pas naturel. Il est préférable d'utiliser d'autres seuils, par exemple 0,05, 0,12, 0,28 et 0,85. if (moisture < 0.05f) { cell.TerrainTypeIndex = 4; } else if (moisture < 0.12f) { cell.TerrainTypeIndex = 0; } else if (moisture < 0.28f) { cell.TerrainTypeIndex = 3; } else if (moisture < 0.85f) { cell.TerrainTypeIndex = 1; }
Biomes modifiés.paquet d'unitéPartie 26: biomes et rivières
- Nous créons les rivières provenant de cellules élevées avec de l'humidité.
- Nous créons un modèle de température simple.
- Nous utilisons la matrice du biome pour les cellules, puis la modifions.
Dans cette partie, nous compléterons le cycle de l'eau avec des rivières et la température, ainsi que des biomes plus intéressants aux cellules.Le didacticiel a été créé à l'aide de Unity 2017.3.0p3.La chaleur et l'eau animent la carte.Génération de la rivière
Les rivières sont une conséquence du cycle de l'eau. En fait, ils sont formés par des ruissellements arrachant à l'aide de l'érosion des canaux. Cela implique que vous pouvez ajouter des rivières en fonction de la valeur des drains cellulaires. Cependant, cela ne garantit pas que nous obtiendrons quelque chose qui ressemble à de vraies rivières. Lorsque nous commencerons la rivière, elle devra couler le plus loin possible, potentiellement à travers de nombreuses cellules. Cela n'est pas cohérent avec notre simulation du cycle de l'eau, qui traite les cellules en parallèle. De plus, le contrôle du nombre de rivières sur une carte est généralement nécessaire.Les fleuves étant très différents, nous les générerons séparément. Nous utilisons les résultats de la simulation du cycle de l'eau pour déterminer l'emplacement des rivières, mais les rivières, à leur tour, n'affecteront pas la simulation.Pourquoi le débit de la rivière est-il parfois faux?TriangulateWaterShore
, . , . , , . , . , , . («»).
void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … }
Cellules à humidité élevée
Sur nos cartes, une cellule peut ou non avoir une rivière. De plus, ils peuvent se ramifier ou se connecter. En réalité, les rivières sont beaucoup plus flexibles, mais nous devons nous en tirer avec cette approximation, qui ne crée que de grandes rivières. Plus important encore, nous devons déterminer l'emplacement du début d'un grand fleuve, qui est choisi au hasard.Étant donné que les rivières ont besoin d'eau, la source de la rivière doit se trouver dans une cellule très humide. Mais cela ne suffit pas. Les rivières coulent le long des pentes, donc idéalement la source devrait avoir une grande hauteur. Plus la cellule est haute au-dessus du niveau de l'eau, meilleure est sa candidature au rôle de source de la rivière. Nous pouvons visualiser cela sous forme de données cartographiques en divisant la hauteur des cellules par la hauteur maximale. Pour que le résultat soit obtenu par rapport au niveau de l'eau, nous allons le soustraire des deux hauteurs avant de diviser. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); } }
Humidité et altitude. Grande carte numéro 1208905299 avec paramètres par défaut.Les meilleurs candidats sont les cellules qui ont à la fois une humidité élevée et une hauteur élevée. On peut combiner ces critères en les multipliant. Le résultat sera la valeur de la forme physique ou du poids pour les sources des rivières. float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data);
Poids pour les sources des rivières.Idéalement, nous utiliserions ces poids pour rejeter la sélection aléatoire de la cellule source. Bien que nous puissions créer une liste avec les poids corrects et faire votre choix, il s'agit d'une approche non triviale et cela ralentit le processus de génération. Une classification plus simple de l'importance divisée en quatre niveaux nous suffira. Les premiers candidats seront des poids avec des valeurs supérieures à 0,75. Les bons candidats ont des poids de 0,5. Les candidats éligibles sont supérieurs à 0,25. Toutes les autres cellules sont jetées. Montrons à quoi cela ressemble graphiquement. float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (data > 0.75f) { cell.SetMapData(1f); } else if (data > 0.5f) { cell.SetMapData(0.5f); } else if (data > 0.25f) { cell.SetMapData(0.25f); }
Catégories de poids des sources fluviales.Avec ce schéma de classification, nous aurons probablement des rivières avec des sources dans les zones les plus hautes et les plus humides de la carte. Néanmoins, la probabilité de créer des rivières dans des zones relativement sèches ou basses demeure, ce qui augmente la variabilité.Ajoutez une méthode CreateRivers
qui remplit une liste de cellules en fonction de ces critères. Les cellules éligibles sont ajoutées à cette liste une fois, les bonnes deux fois et les principaux candidats quatre fois. Les cellules sous-marines sont toujours jetées, vous ne pouvez donc pas les vérifier. void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); if (cell.IsUnderwater) { continue; } ClimateData data = climate[i]; float weight = data.moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); if (weight > 0.75f) { riverOrigins.Add(cell); riverOrigins.Add(cell); } if (weight > 0.5f) { riverOrigins.Add(cell); } if (weight > 0.25f) { riverOrigins.Add(cell); } } ListPool<HexCell>.Add(riverOrigins); }
Cette méthode doit être appelée après CreateClimate
pour que les données d'humidité soient à notre disposition. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); CreateRivers(); SetTerrainType(); … }
Après avoir terminé le classement, vous pouvez vous débarrasser de la visualisation de ses données sur la carte. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { …
Points de la rivière
De combien de rivières avons-nous besoin? Ce paramètre doit être personnalisable. Étant donné que la longueur des rivières varie, il sera plus logique de la contrôler à l'aide de points fluviaux, qui déterminent le nombre de cellules terrestres dans lesquelles les rivières doivent être contenues. Exprimons-les en pourcentage avec un maximum de 20% et une valeur par défaut de 10%. Comme le pourcentage de sushis, il s'agit d'une valeur cible, non garantie. Par conséquent, nous pourrions avoir trop peu de candidats ou de rivières trop courtes pour couvrir la quantité de terre requise. C'est pourquoi le pourcentage maximum ne doit pas être trop important. [Range(0, 20)] public int riverPercentage = 10;
Slider pour cent des rivières.Pour déterminer les points fluviaux, exprimés en nombre de cellules, nous devons nous rappeler combien de cellules terrestres ont été générées CreateLand
. int cellCount, landCells; … void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f); landCells = landBudget; for (int guard = 0; guard < 10000; guard++) { … } if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); landCells -= landBudget; } }
À l'intérieur, le CreateRivers
nombre de points fluviaux peut maintenant être calculé de la même manière que nous le faisons dans CreateLand
. void CreateRivers () { List<HexCell> riverOrigins = ListPool<HexCell>.Get(); for (int i = 0; i < cellCount; i++) { … } int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); ListPool<HexCell>.Add(riverOrigins); }
De plus, nous continuerons de prendre et de supprimer des cellules aléatoires de la liste d'origine, alors que nous avons toujours des points et des cellules sources. En cas de finalisation du nombre de points, nous afficherons un avertissement dans la console. int riverBudget = Mathf.RoundToInt(landCells * riverPercentage * 0.01f); while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); } if (riverBudget > 0) { Debug.LogWarning("Failed to use up river budget."); }
De plus, nous ajoutons une méthode pour créer directement des rivières. Comme paramètre, il a besoin d'une cellule initiale, et après l'achèvement, il doit retourner la longueur de la rivière. Nous commençons par stocker une méthode qui retourne une longueur nulle. int CreateRiver (HexCell origin) { int length = 0; return length; }
Nous appellerons cette méthode à la fin du cycle que nous venons d'ajouter CreateRivers
, en utilisant pour réduire le nombre de points restants. Nous nous assurons qu'une nouvelle rivière est créée uniquement si la cellule sélectionnée n'a pas de rivière qui la traverse. while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } }
Rivières actuelles
Il est logique de créer des rivières coulant vers la mer ou un autre plan d'eau. Lorsque nous partons de la source, nous obtenons immédiatement la longueur 1. Après cela, nous sélectionnons un voisin aléatoire et augmentons la longueur. Nous continuons de nous déplacer jusqu'à ce que nous atteignions la cellule sous-marine. int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { HexDirection direction = (HexDirection)Random.Range(0, 6); cell.SetOutgoingRiver(direction); length += 1; cell = cell.GetNeighbor(direction); } return length; }
Rivières aléatoires.À la suite d'une telle approche naïve, nous obtenons des fragments de rivière dispersés de manière aléatoire, principalement en raison du remplacement des rivières précédemment générées. Cela peut même conduire à des erreurs, car nous ne vérifions pas si le voisin existe réellement. Nous devons vérifier toutes les directions dans la boucle et nous assurer qu'il y a un voisin là-bas. Si c'est le cas, nous ajoutons cette direction à la liste des directions d'écoulement potentielles, mais seulement si la rivière ne traverse pas encore ce voisin. Sélectionnez ensuite une valeur aléatoire dans cette liste. List<HexDirection> flowDirections = new List<HexDirection>(); … int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor || neighbor.HasRiver) { continue; } flowDirections.Add(d); } HexDirection direction =
Avec cette nouvelle approche, nous pouvons avoir zéro sens d'écoulement disponible. Lorsque cela se produit, la rivière ne peut plus couler plus loin et doit s'arrêter. Si à ce moment la longueur est de 1, cela signifie que nous ne pouvons pas fuir de la cellule d'origine, c'est-à-dire qu'il ne peut pas y avoir de rivière du tout. Dans ce cas, la longueur de la rivière est nulle. flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; }
Fleuves préservés.Délabré
Maintenant, nous sauvons les rivières déjà créées, mais nous pouvons toujours obtenir des fragments isolés des rivières. Cela se produit parce que nous avons ignoré les hauteurs. Chaque fois que nous avons forcé la rivière à couler à une plus grande hauteur, nous avons HexCell.SetOutgoingRiver
interrompu cette tentative, qui a entraîné des ruptures dans les rivières. Par conséquent, nous devons également sauter des directions qui font remonter les rivières. if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d);
Des rivières qui coulent.Nous nous débarrassons donc de nombreux fragments de rivières, mais il en reste encore. A partir de ce moment, se débarrasser des rivières les plus laides devient une affaire de raffinement. Pour commencer, les rivières préfèrent couler le plus vite possible. Ils ne choisiront pas nécessairement l'itinéraire le plus court possible, mais la probabilité est grande. Pour simuler cela, nous ajouterons trois fois les instructions à la liste. if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d);
Évitez les virages serrés
En plus de couler, l'eau a également une inertie. Une rivière est plus susceptible de couler directement ou de se plier légèrement que de faire un virage brusque et brutal. Nous pouvons ajouter cette distorsion en suivant la dernière direction de la rivière. Si la direction potentielle du courant ne s'écarte pas trop de cette direction, ajoutez-la à nouveau à la liste. Ce n'est pas un problème pour la source, nous l'ajouterons donc toujours. int CreateRiver (HexCell origin) { int length = 1; HexCell cell = origin; HexDirection direction = HexDirection.NE; while (!cell.IsUnderwater) { flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } if ( length == 1 || (d != direction.Next2() && d != direction.Previous2()) ) { flowDirections.Add(d); } flowDirections.Add(d); } if (flowDirections.Count == 0) { return length > 1 ? length : 0; }
Cela réduit considérablement la probabilité de zigzags de rivières qui semblent moche.Moins de virages serrés.Confluence de la rivière
Parfois, il s'avère que la rivière coule juste à côté de la source de la rivière précédemment créée. Si la source de cette rivière n'est pas à une altitude plus élevée, alors nous pouvons décider que la nouvelle rivière se jette dans l'ancienne. En conséquence, nous obtenons une longue rivière et non deux rivières voisines.Pour ce faire, nous ne laisserons passer le voisin que s'il y a une rivière entrante ou s'il est la source de la rivière actuelle. Après avoir déterminé que cette direction n'est pas vers le haut, nous vérifions s'il y a une rivière sortante. S'il y en a, alors nous avons retrouvé la vieille rivière. Comme cela se produit assez rarement, nous ne serons pas engagés dans la vérification d'autres sources voisines et combinerons immédiatement les rivières. HexCell neighbor = cell.GetNeighbor(d);
Rivières avant et après la mise en commun.Gardez vos distances
Étant donné que les bons candidats pour le rôle source sont généralement regroupés, nous obtiendrons des groupes de rivières. De plus, nous pouvons avoir des rivières qui prennent la source juste à côté du réservoir, ce qui donne des rivières de longueur 1. Nous pouvons répartir les sources, en rejetant celles qui se trouvent à proximité de la rivière ou du réservoir. Nous faisons cela en contournant les voisins de la source sélectionnée dans une boucle à l'intérieur CreateRivers
. Si nous trouvons un voisin qui viole les règles, alors la source ne nous convient pas et nous devons le sauter. while (riverBudget > 0 && riverOrigins.Count > 0) { int index = Random.Range(0, riverOrigins.Count); int lastIndex = riverOrigins.Count - 1; HexCell origin = riverOrigins[index]; riverOrigins[index] = riverOrigins[lastIndex]; riverOrigins.RemoveAt(lastIndex); if (!origin.HasRiver) { bool isValidOrigin = true; for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = origin.GetNeighbor(d); if (neighbor && (neighbor.HasRiver || neighbor.IsUnderwater)) { isValidOrigin = false; break; } } if (isValidOrigin) { riverBudget -= CreateRiver(origin); } }
Et bien que les rivières coulent toujours les unes à côté des autres, elles ont tendance à couvrir une plus grande superficie.Sans distance et avec elle.Nous terminons la rivière avec un lac
Toutes les rivières n'atteignent pas le réservoir, certaines se coincent dans les vallées ou sont bloquées par d'autres rivières. Ce n'est pas un problème particulier, car souvent les vraies rivières semblent également disparaître. Cela peut se produire, par exemple, s'ils coulent sous terre, se dispersent dans une zone marécageuse ou se dessèchent. Nos rivières ne peuvent pas visualiser cela, alors elles finissent simplement.Cependant, nous pouvons essayer de minimiser le nombre de ces cas. Bien que nous ne puissions pas unir les rivières ou les faire couler, nous pouvons les faire aboutir dans des lacs, qui se trouvent souvent dans la réalité et qui ont l'air bien. Pour celaCreateRiver
devrait augmenter le niveau d'eau dans la cellule si elle se coince. La possibilité de cela dépend de la hauteur minimale des voisins de cette cellule. Par conséquent, afin de suivre cela lors de l'étude des voisins, une petite modification du code est nécessaire. while (!cell.IsUnderwater) { int minNeighborElevation = int.MaxValue; flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d);
Si nous sommes bloqués, nous devons d'abord vérifier si nous sommes toujours à la source. Si oui, alors annulez simplement la rivière. Sinon, nous vérifions si tous les voisins sont au moins aussi élevés que la cellule actuelle. Si oui, alors nous pouvons élever l'eau à ce niveau. Cela créera un lac à partir d'une cellule, à moins que la hauteur de la cellule ne reste au même niveau. Si c'est le cas, attribuez simplement la hauteur à un niveau en dessous du niveau de l'eau. if (flowDirections.Count == 0) {
Les extrémités des rivières sans lacs et avec lacs. Dans ce cas, le pourcentage de rivières est de 20.Notez que nous pouvons maintenant avoir des cellules sous-marines au-dessus du niveau d'eau utilisé pour générer la carte. Ils désigneront les lacs au-dessus du niveau de la mer.Lacs supplémentaires
Nous pouvons également créer des lacs, même si nous ne sommes pas coincés. Cela peut entraîner une rivière qui coule dans et hors du lac. Si nous ne sommes pas coincés, un lac peut être créé en augmentant le niveau de l'eau, puis la hauteur actuelle des cellules, puis en réduisant la hauteur des cellules. Cela ne s'applique que lorsque la hauteur minimale du voisin est au moins égale à la hauteur de la cellule actuelle. Nous le faisons à la fin du cycle de la rivière et avant de passer à la cellule suivante. while (!cell.IsUnderwater) { … if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } cell = cell.GetNeighbor(direction); }
Sans lacs supplémentaires et avec eux.Plusieurs lacs sont magnifiques, mais sans limites, nous pouvons créer trop de lacs. Par conséquent, ajoutons une probabilité personnalisée pour des lacs supplémentaires, avec une valeur par défaut de 0,25. [Range(0f, 1f)] public float extraLakeProbability = 0.25f;
Elle contrôlera la probabilité de générer un lac supplémentaire, si possible. if ( minNeighborElevation >= cell.Elevation && Random.value < extraLakeProbability ) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; }
Lacs supplémentaires.Qu'en est-il de la création de lacs avec plus d'une cellule?, , , . . : . , . , , , .
paquet d'unitéLa température
L'eau n'est qu'un des facteurs qui peuvent déterminer le biome d'une cellule. Un autre facteur important est la température. Bien que nous puissions simuler l'écoulement et la diffusion des températures comme la simulation de l'eau, pour créer un climat intéressant, nous n'avons besoin que d'un facteur complexe. Par conséquent, gardons la température simple et la réglons pour chaque cellule.Température et latitude
La plus grande influence sur la température est la latitude. Il fait chaud à l'équateur, froid aux pôles, et il y a une transition en douceur entre eux. Créons une méthode DetermineTemperature
qui renvoie la température d'une cellule donnée. Pour commencer, nous utilisons simplement la coordonnée Z de la cellule divisée par la dimension Z comme latitude, puis nous utilisons cette valeur comme température. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; return latitude; }
Nous définissons la température à l'intérieur SetTerrainType
et l'utilisons comme données cartographiques. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell); cell.SetMapData(temperature); float moisture = climate[i].moisture; … } }
Latitude comme température, hémisphère sud.On obtient un gradient de température linéaire croissant de bas en haut. Vous pouvez l'utiliser pour simuler l'hémisphère sud, avec un pôle en bas et un équateur en haut. Mais nous n'avons pas besoin de décrire l'ensemble de l'hémisphère. Avec une différence de température plus faible ou aucune différence, nous pouvons décrire une zone plus petite. Pour ce faire, nous allons personnaliser les températures basses et hautes. Nous allons définir ces températures dans la plage de 0 à 1 et utiliser les valeurs extrêmes comme valeurs par défaut. [Range(0f, 1f)] public float lowTemperature = 0f; [Range(0f, 1f)] public float highTemperature = 1f;
Curseurs de température.Nous appliquons la plage de température en utilisant une interpolation linéaire, en utilisant la latitude comme interpolateur. Puisque nous exprimons la latitude comme une valeur de 0 à 1, nous pouvons l'utiliser Mathf.LerpUnclamped
. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; }
Notez que les basses températures ne sont pas nécessairement inférieures aux hautes. Si vous le souhaitez, vous pouvez les retourner.Hémisphère
Nous pouvons maintenant simuler l'hémisphère sud, et peut-être l'hémisphère nord, si nous prenons d'abord les températures. Mais il est beaucoup plus pratique d'utiliser une option de configuration distincte pour basculer entre les hémisphères. Créons une énumération et un champ pour cela. Ainsi, nous ajouterons également l'option de création des deux hémisphères, qui est applicable par défaut. public enum HemisphereMode { Both, North, South } public HemisphereMode hemisphere;
Le choix de l'hémisphère.Si nous avons besoin de l'hémisphère nord, alors nous pouvons simplement inverser la latitude, en la soustrayant de 1. Pour simuler les deux hémisphères, les pôles doivent être en dessous et au-dessus de la carte, et l'équateur doit être au milieu. Vous pouvez le faire en doublant la latitude, tandis que l'hémisphère inférieur sera traité correctement et que l'hémisphère supérieur aura une latitude de 1 à 2. Pour résoudre ce problème, nous soustrayons la latitude de 2 lorsqu'elle dépasse 1. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; if (hemisphere == HemisphereMode.Both) { latitude *= 2f; if (latitude > 1f) { latitude = 2f - latitude; } } else if (hemisphere == HemisphereMode.North) { latitude = 1f - latitude; } float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; }
Les deux hémisphères.Il convient de noter que cela crée la possibilité de créer une carte exotique dans laquelle l'équateur est froid et les pôles sont chauds.Plus le froid est élevé
En plus de la latitude, la température est également significativement affectée par l'altitude. En moyenne, plus nous montons, plus il fait froid. Nous pouvons en faire un facteur, comme nous l'avons fait avec les candidats de la rivière. Dans ce cas, nous utilisons la hauteur de cellule. De plus, cet indicateur diminue avec la hauteur, c'est-à-dire égal à 1 moins la hauteur divisée par le maximum par rapport au niveau de l'eau. Pour que l'indicateur au plus haut niveau ne tombe pas à zéro, nous ajoutons au diviseur. Utilisez ensuite cet indicateur pour mettre à l'échelle la température. float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); return temperature;
La hauteur affecte la température.Fluctuations de température
Nous pouvons rendre la simplicité du gradient de température moins perceptible en ajoutant des fluctuations de température aléatoires. Une petite chance de le rendre plus réaliste, mais avec trop de fluctuations, ils auront l'air arbitraires. Personnalisons la puissance des fluctuations de température et exprimons-la comme l'écart de température maximum avec une valeur par défaut de 0,1. [Range(0f, 1f)] public float temperatureJitter = 0.1f;
Curseur de fluctuation de température.Ces fluctuations devraient être fluides avec de légers changements locaux. Vous pouvez utiliser notre texture de bruit pour cela. Nous appellerons HexMetrics.SampleNoise
et utiliserons comme argument la position de la cellule, mise à l'échelle par 0,1. Prenons le canal W, centrons-le et redimensionnons-le par le coefficient d'oscillation. Ensuite, nous ajoutons cette valeur à la température précédemment calculée. temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); temperature += (HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) * temperatureJitter; return temperature;
Fluctuations de température avec des valeurs de 0,1 et 1.Nous pouvons ajouter une légère variabilité aux fluctuations sur chaque carte, en choisissant au hasard parmi les quatre canaux de bruit. Définissez le canal une fois SetTerrainType
, puis indexez les canaux de couleur DetermineTemperature
. int temperatureJitterChannel; … void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { … } } float DetermineTemperature (HexCell cell) { … float jitter = HexMetrics.SampleNoise(cell.Position * 0.1f)[temperatureJitterChannel]; temperature += (jitter * 2f - 1f) * temperatureJitter; return temperature; }
Différentes fluctuations de température avec une force maximale.paquet d'unitéBiomes
Maintenant que nous avons des données sur l'humidité et la température, nous pouvons créer une matrice de biome. En indexant cette matrice, nous pouvons attribuer des biomes à toutes les cellules, créant un paysage plus complexe que d'utiliser une seule dimension de données.Matrice du biome
Il existe de nombreux modèles climatiques, mais nous n'en utiliserons aucun. Nous allons le rendre très simple, nous ne nous intéressons qu'à la logique. Sec signifie désert (froid ou chaud), pour cela nous utilisons du sable. Froid et humide signifie neige. Chaud et humide signifie beaucoup de végétation, c'est-à-dire de l'herbe. Entre eux, nous aurons une taïga ou une toundra, que nous désignerons comme une texture grisâtre de la terre. Une matrice 4 × 4 sera suffisante pour créer des transitions entre ces biomes.Auparavant, nous attribuions des types d'élévation basés sur cinq intervalles d'humidité. Nous abaissons simplement la bande la plus sèche à 0,05 et économisons le reste. Pour les bandes de température, nous utilisons 0,1, 0,3, 0,6 et plus. Pour plus de commodité, nous allons définir ces valeurs dans des tableaux statiques. static float[] temperatureBands = { 0.1f, 0.3f, 0.6f }; static float[] moistureBands = { 0.12f, 0.28f, 0.85f };
Bien que nous ne spécifions que le type de relief sur la base du biome, nous pouvons l'utiliser pour déterminer d'autres paramètres. Par conséquent, définissons dans une HexMapGenerator
structure Biome
qui décrit la configuration d'un biome individuel. Jusqu'à présent, il ne contient que l'indice de bosse plus la méthode constructeur correspondante. struct Biome { public int terrain; public Biome (int terrain) { this.terrain = terrain; } }
Nous utilisons cette structure pour créer un tableau statique contenant des données matricielles. Nous utilisons l'humidité comme coordonnée X et la température comme Y. Nous remplissons la ligne avec la température la plus basse avec de la neige, la deuxième ligne avec de la toundra et les deux autres avec de l'herbe. Ensuite, nous remplaçons la colonne la plus sèche par le désert, redéfinissant le choix de température. static Biome[] biomes = { new Biome(0), new Biome(4), new Biome(4), new Biome(4), new Biome(0), new Biome(2), new Biome(2), new Biome(2), new Biome(0), new Biome(1), new Biome(1), new Biome(1), new Biome(0), new Biome(1), new Biome(1), new Biome(1) };
Matrice de biomes avec indices d'un tableau unidimensionnel.Définition du biome
Pour déterminer les SetTerrainType
cellules du biome, nous allons parcourir les plages de température et d'humidité du cycle pour déterminer les indices matriciels dont nous avons besoin. Nous les utilisons pour obtenir le biome souhaité et spécifier le type de topographie cellulaire. void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell);
Relief basé sur une matrice de biome.Configuration du biome
Nous pouvons aller au-delà des biomes définis dans la matrice. Par exemple, dans la matrice, tous les biomes secs sont définis comme des déserts de sable, mais tous les déserts secs ne sont pas remplis de sable. Il existe de nombreux déserts rocheux qui sont très différents. Par conséquent, remplaçons certaines des cellules du désert par des pierres. Nous le ferons simplement en fonction de la hauteur: le sable est à basse altitude et des roches nues se trouvent généralement au-dessus.Supposons que le sable se transforme en pierre lorsque la hauteur de la cellule est plus proche de la hauteur maximale que du niveau de l'eau. C'est la ligne de hauteur des déserts rocheux que nous pouvons calculer au début SetTerrainType
. Lorsque nous rencontrons une cellule avec du sable et que sa hauteur est suffisamment grande, nous changeons le relief du biome en pierre. void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); int rockDesertElevation = elevationMaximum - (elevationMaximum - waterLevel) / 2; for (int i = 0; i < cellCount; i++) { … if (!cell.IsUnderwater) { … Biome cellBiome = biomes[t * 4 + m]; if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } cell.TerrainTypeIndex = cellBiome.terrain; } else { cell.TerrainTypeIndex = 2; } } }
Déserts de sable et de rochers.Un autre changement basé sur la hauteur consiste à forcer les cellules à hauteur maximale à se transformer en pics de neige, quelle que soit leur température, uniquement si elles ne sont pas trop sèches. Cela augmentera la probabilité de pics de neige près de l'équateur chaud et humide. if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } else if (cell.Elevation == elevationMaximum) { cellBiome.terrain = 4; }
Bouchons de neige à hauteur maximale.Les plantes
Faisons maintenant des biomes déterminer le niveau de cellules végétales. Pour ce faire, ajoutez au Biome
domaine des plantes et incluez-le dans le constructeur. struct Biome { public int terrain, plant; public Biome (int terrain, int plant) { this.terrain = terrain; this.plant = plant; } }
Dans les biomes les plus froids et les plus secs, il n'y aura pas de plantes du tout. À tous les autres égards, plus le climat est chaud et humide, plus il y a de plantes. La deuxième colonne d'humidité ne reçoit que le premier niveau de plantes pour la rangée la plus chaude, donc [0, 0, 0, 1]. La troisième colonne augmente les niveaux de un, à l'exception de la neige, c'est-à-dire [0, 1, 1, 2]. Et la colonne la plus humide les augmente à nouveau, c'est-à-dire qu'il s'avère [0, 2, 2, 3]. Modifiez la baie biomes
en y ajoutant la configuration de l'installation. static Biome[] biomes = { new Biome(0, 0), new Biome(4, 0), new Biome(4, 0), new Biome(4, 0), new Biome(0, 0), new Biome(2, 0), new Biome(2, 1), new Biome(2, 2), new Biome(0, 0), new Biome(1, 0), new Biome(1, 1), new Biome(1, 2), new Biome(0, 0), new Biome(1, 1), new Biome(1, 2), new Biome(1, 3) };
Matrice des biomes avec les niveaux des plantes.Maintenant, nous pouvons définir le niveau de plantes pour la cellule. cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant;
Biomes avec des plantes.Les plantes sont-elles maintenant différentes?, . (1, 2, 1) (0.75, 1, 0.75). (1.5, 3, 1.5) (2, 1.5, 2). — (2, 4.5, 2) (2.5, 3, 2.5).
, : (13, 114, 0).
Nous pouvons changer le niveau des plantes pour les biomes. Nous devons d'abord nous assurer qu'ils n'apparaissent pas sur le terrain enneigé, que nous pourrions déjà mettre en place. Deuxièmement, augmentons le niveau de plantes le long des rivières, s'il n'est pas déjà au maximum. if (cellBiome.terrain == 4) { cellBiome.plant = 0; } else if (cellBiome.plant < 3 && cell.HasRiver) { cellBiome.plant += 1; } cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant;
Plantes modifiées.Biomes sous-marins
Jusqu'à ce moment, nous avons complètement ignoré les cellules sous-marines. Ajoutons-leur une petite variation, et nous n'utiliserons pas la texture de la terre pour tous. Une solution simple basée sur la hauteur sera déjà suffisante pour créer une image plus intéressante. Par exemple, utilisons l'herbe pour les cellules une étape en dessous du niveau de l'eau. Utilisons également l'herbe pour les cellules au-dessus du niveau de l'eau, c'est-à-dire pour les lacs créés par les rivières. Les cellules avec une hauteur négative sont des zones d'eau profonde, nous utilisons donc de la pierre pour elles. Toutes les autres cellules restent broyées. void SetTerrainType () { … if (!cell.IsUnderwater) { … } else { int terrain; if (cell.Elevation == waterLevel - 1) { terrain = 1; } else if (cell.Elevation >= waterLevel) { terrain = 1; } else if (cell.Elevation < 0) { terrain = 3; } else { terrain = 2; } cell.TerrainTypeIndex = terrain; } } }
Variabilité sous-marine.Ajoutons quelques détails pour les cellules sous-marines le long de la côte. Ce sont des cellules avec au moins un voisin au-dessus de l'eau. Si une telle cellule est peu profonde, nous créerons une plage. Et si c'est à côté de la falaise, ce sera le détail visuel dominant, et nous utilisons la pierre.Pour le déterminer, nous allons vérifier les voisins des cellules situées à un pas sous le niveau de l'eau. Comptons le nombre de connexions par les falaises et les pentes avec les cellules terrestres voisines. if (cell.Elevation == waterLevel - 1) { int cliffs = 0, slopes = 0; for ( HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++ ) { HexCell neighbor = cell.GetNeighbor(d); if (!neighbor) { continue; } int delta = neighbor.Elevation - cell.WaterLevel; if (delta == 0) { slopes += 1; } else if (delta > 0) { cliffs += 1; } } terrain = 1; }
Nous pouvons maintenant utiliser ces informations pour classer les cellules. Premièrement, si plus de la moitié des voisins sont des terres, alors nous avons affaire à un lac ou à une baie. Pour ces cellules, nous utilisons une texture d'herbe. Sinon, si nous avons des falaises, nous utilisons de la pierre. Sinon, si nous avons des pentes, nous utilisons du sable pour créer une plage. La seule option restante est une zone peu profonde au large de la côte, pour laquelle nous utilisons toujours de l'herbe. if (cell.Elevation == waterLevel - 1) { int cliffs = 0, slopes = 0; for ( HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++ ) { … } if (cliffs + slopes > 3) { terrain = 1; } else if (cliffs > 0) { terrain = 3; } else if (slopes > 0) { terrain = 0; } else { terrain = 1; } }
Variabilité de la côte.Enfin, vérifions que nous n'avons pas de cellules sous-marines vertes dans la plage de température la plus froide. Pour ces cellules, nous utilisons la terre. if (terrain == 1 && temperature < temperatureBands[0]) { terrain = 2; } cell.TerrainTypeIndex = terrain;
Nous avons eu la possibilité de générer des cartes aléatoires qui semblent assez intéressantes et naturelles, avec de nombreuses options de configuration.paquet d'unitéPartie 27: plier une carte
- Nous divisons les cartes en colonnes qui peuvent être déplacées.
- Centrez la carte dans l'appareil photo.
- Nous effondrons tout.
Dans cette dernière partie, nous ajouterons un support pour minimiser la carte, reliant les bords est et ouest.Le didacticiel a été créé à l'aide de Unity 2017.3.0p3.Le pliage fait tourner le monde.Cartes pliantes
Nos cartes peuvent être utilisées pour modéliser des zones de différentes tailles, mais elles sont toujours limitées à une forme rectangulaire. Nous pouvons créer une carte d'une île ou d'un continent entier, mais pas de la planète entière. Les planètes sont sphériques, elles n'ont pas de frontières rigides qui gênent le mouvement à leur surface. Si vous continuez à vous déplacer dans une direction, vous reviendrez tôt ou tard au point de départ.Nous ne pouvons pas enrouler une grille d'hexagones autour d'une sphère; un tel chevauchement est impossible. Dans les meilleures approximations, la topologie icosaédrique est utilisée, dans laquelle les douze cellules doivent être des pentagones. Cependant, sans distorsion ni exception, le maillage peut être enroulé autour du cylindre. Pour ce faire, connectez simplement les bords est et ouest de la carte. À l'exception de la logique d'habillage, tout le reste reste le même.Un cylindre est une mauvaise approximation d'une sphère, car nous ne pouvons pas modéliser de pôles. Mais cela n'a pas empêché les développeurs de nombreux jeux d'utiliser le pliage d'est en ouest pour modéliser les cartes de la planète. Les régions polaires ne font tout simplement pas partie de la zone de jeu.Que diriez-vous de tourner vers le nord et le sud?, . , , . -, -. .
Il existe deux façons de mettre en œuvre le pliage cylindrique. La première consiste à rendre la carte cylindrique en pliant sa surface et tout ce qui s'y trouve afin que les bords est et ouest soient en contact. Maintenant, vous jouerez non pas sur une surface plane, mais sur un vrai cylindre. La deuxième approche consiste à enregistrer une carte plate et à utiliser la téléportation ou la duplication pour s'effondrer. La plupart des jeux utilisent la deuxième approche, nous allons donc la suivre.Pliage en option
La nécessité de réduire la carte dépend de son échelle - locale ou planétaire. Nous pouvons utiliser le support des deux en rendant le pliage facultatif. Pour ce faire, ajoutez un nouveau commutateur au menu Créer une nouvelle carte avec la réduction activée par défaut.Le menu de la nouvelle carte avec l'option de réduire.Ajoutez au NewMapMenu
champ pour suivre la sélection, ainsi qu'une méthode pour la modifier. Faisons invoquer cette méthode lorsque l'état du commutateur change. bool wrapping = true; … public void ToggleWrapping (bool toggle) { wrapping = toggle; }
Lorsqu'une nouvelle carte est demandée, nous transmettons la valeur de l'option de réduction. void CreateMap (int x, int z) { if (generateMaps) { mapGenerator.GenerateMap(x, z, wrapping); } else { hexGrid.CreateMap(x, z, wrapping); } HexMapCamera.ValidatePosition(); Close(); }
Modifiez-le HexMapGenerator.GenerateMap
pour qu'il accepte ce nouvel argument, puis le transmet à HexGrid.CreateMap
. public void GenerateMap (int x, int z, bool wrapping) { … grid.CreateMap(x, z, wrapping); … }
code> HexGrid devrait savoir si nous nous effondrons, alors ajoutez-y un champ et CreateMap
définissez-le. Les autres classes devraient changer leur logique selon que la grille est minimisée, nous allons donc rendre le champ général. De plus, il vous permet de définir la valeur par défaut via l'inspecteur. public int cellCountX = 20, cellCountZ = 15; public bool wrapping; … public bool CreateMap (int x, int z, bool wrapping) { … cellCountX = x; cellCountZ = z; this.wrapping = wrapping; … }
HexGrid
appels propres CreateMap
à deux endroits. Nous pouvons simplement utiliser son propre champ pour l'argument d'effondrement. void Awake () { … CreateMap(cellCountX, cellCountZ, wrapping); } … public void Load (BinaryReader reader, int header) { … if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z, wrapping)) { return; } } … }
Le commutateur de pliage de grille est activé par défaut.Sauvegarde et chargement
Étant donné que le pliage est défini pour chaque carte, elle doit être enregistrée et chargée. Cela signifie que vous devez modifier le format d'enregistrement des fichiers, augmentez donc la constante de version dans SaveLoadMenu
. const int mapFileVersion = 5;
Lors de l'enregistrement, laissez- HexGrid
le simplement écrire la valeur de pliage booléenne après la taille de la carte. public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); writer.Write(wrapping); … }
Lors du chargement, nous le lirons uniquement avec la version correcte du fichier. Si elle est différente, il s'agit d'une ancienne carte et elle ne doit pas être minimisée. Enregistrez ces informations dans une variable locale et comparez-les avec l'état actuel du pliage. S'il est différent, nous ne pouvons pas réutiliser la topologie de carte existante de la même manière que lors du chargement d'une carte avec d'autres tailles. public void Load (BinaryReader reader, int header) { ClearPath(); ClearUnits(); int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } bool wrapping = header >= 5 ? reader.ReadBoolean() : false; if (x != cellCountX || z != cellCountZ || this.wrapping != wrapping) { if (!CreateMap(x, z, wrapping)) { return; } } … }
Mesures pliantes
La réduction de la carte nécessitera des changements majeurs dans la logique, par exemple lors du calcul des distances. Par conséquent, ils peuvent toucher du code qui n'a pas de lien direct avec la grille. Au lieu de transmettre ces informations comme arguments, ajoutons-les à HexMetrics
. Ajoutez un entier statique contenant la taille de pliage qui correspond à la largeur de la carte. S'il est supérieur à zéro, il s'agit alors d'une carte pliable. Pour vérifier cela, ajoutez une propriété. public static int wrapSize; public static bool Wrapping { get { return wrapSize > 0; } }
Nous devons définir la taille de pliage pour chaque appel HexGrid.CreateMap
. public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; HexMetrics.wrapSize = wrapping ? cellCountX : 0; … }
Étant donné que ces données ne survivront pas à la recompilation en mode Lecture, nous les configurerons OnEnable
. void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; HexMetrics.wrapSize = wrapping ? cellCountX : 0; ResetVisibility(); } }
Largeur de cellule
Lorsque vous travaillez avec des cartes pliantes, nous devons souvent faire face à des positions le long de l'axe X, mesurées dans la largeur des cellules. Bien qu'il puisse être utilisé pour cela HexMetrics.innerRadius * 2f
, il serait plus pratique de ne pas ajouter de multiplication à chaque fois. Ajoutons donc une constante HexMetrics.innerDiameter
. public const float innerRadius = outerRadius * outerToInner; public const float innerDiameter = innerRadius * 2f;
On peut déjà utiliser le diamètre à trois endroits. Tout d'abord, HexGrid.CreateCell
lors du positionnement d'une nouvelle cellule. void CreateCell (int x, int z, int i) { Vector3 position; position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter; … }
Deuxièmement, en HexMapCamera
limitant la position de la caméra. Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); … }
Et aussi dans la HexCoordinates
conversion de position en coordonnées. public static HexCoordinates FromPosition (Vector3 position) { float x = position.x / HexMetrics.innerDiameter; … }
paquet d'unitéCentrage de la carte
Lorsque la carte ne s'effondre pas, elle a clairement défini les bords est et ouest, et donc un centre horizontal clair. Mais dans le cas d'une carte pliable, tout est différent. Il n'a ni le bord oriental ni le bord ouest, ni le centre. Comme alternative, nous pouvons supposer que le centre est l'endroit où se trouve la caméra. Cela sera utile car nous voulons que la carte soit toujours centrée sur notre point de vue. Ensuite, où que nous soyons, nous ne verrons pas les bords est ou ouest de la carte.Colonnes de fragments de carte
Pour que la visualisation de la carte soit centrée par rapport à la caméra, nous devons changer le placement des éléments en fonction du mouvement de la caméra. S'il se déplace vers l'ouest, nous devons prendre ce qui se trouve actuellement au bord de la partie orientale et le déplacer vers le bord de la partie ouest. La même chose s'applique à la direction opposée.Idéalement, dès que la caméra se déplace vers la colonne de cellules voisine, nous devons immédiatement déplacer la colonne de cellules la plus éloignée de l'autre côté. Cependant, nous n'avons pas besoin d'être aussi précis. Au lieu de cela, nous pouvons transférer des fragments de carte entiers. Cela nous permet de déplacer des parties de la carte sans avoir à modifier les maillages.Comme nous déplaçons des colonnes entières de fragments en même temps, regroupons-les en créant un objet colonne parent pour chaque groupe. Ajoutez un tableau pour ces objets HexGrid
et nous l'initialiserons CreateChunks
. Nous les utiliserons uniquement comme conteneurs, nous n'avons donc qu'à suivre le lien vers leurs composants Transform
. Comme dans le cas des fragments, leurs positions initiales sont situées à l'origine locale des coordonnées de la grille. Transform[] columns; … void CreateChunks () { columns = new Transform[chunkCountX]; for (int x = 0; x < chunkCountX; x++) { columns[x] = new GameObject("Column").transform; columns[x].SetParent(transform, false); } … }
Maintenant, le fragment doit devenir un enfant de la colonne correspondante, pas de la grille. void CreateChunks () { … chunks = new HexGridChunk[chunkCountX * chunkCountZ]; for (int z = 0, i = 0; z < chunkCountZ; z++) { for (int x = 0; x < chunkCountX; x++) { HexGridChunk chunk = chunks[i++] = Instantiate(chunkPrefab); chunk.transform.SetParent(columns[x], false); } } }
Fragments regroupés en colonnes.Puisque tous les fragments sont maintenant devenus des enfants des colonnes, CreateMap
il nous suffit de détruire directement toutes les colonnes, pas les fragments. Nous allons donc nous débarrasser des fragments de fille. public bool CreateMap (int x, int z, bool wrapping) { … if (columns != null) { for (int i = 0; i < columns.Length; i++) { Destroy(columns[i].gameObject); } } … }
Colonnes de téléportation
Ajoutez à la HexGrid
nouvelle méthode CenterMap
avec la position X comme paramètre. Convertissez la position en index de colonne, en la divisant par la largeur du fragment en unités Unity. Ce sera l'index de la colonne dans laquelle se trouve actuellement la caméra, c'est-à-dire que ce sera la colonne centrale de la carte. public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); }
Il nous suffit de modifier la visualisation de la carte uniquement lorsque l'index de la colonne centrale change. Alors, suivons-le sur le terrain. Nous utilisons la valeur par défaut −1 lors de la création d'une carte afin que les nouvelles cartes soient toujours centrées. int currentCenterColumnIndex = -1; … public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; currentCenterColumnIndex = -1; … } … public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); if (centerColumnIndex == currentCenterColumnIndex) { return; } currentCenterColumnIndex = centerColumnIndex; }
Maintenant que nous connaissons l'indice de la colonne centrale, nous pouvons déterminer les indices minimum et maximum en soustrayant simplement et en ajoutant la moitié du nombre de colonnes. Puisque nous utilisons des valeurs entières, avec un nombre impair de colonnes, cela fonctionne parfaitement. Dans le cas d'un nombre pair, il ne peut pas y avoir de colonne parfaitement centrée, donc l'un des indices sera un cran plus loin que nécessaire. Cela crée un décalage d'une colonne dans la direction du bord le plus éloigné de la carte, mais pour nous, ce n'est pas un problème. currentCenterColumnIndex = centerColumnIndex; int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2;
Notez que ces indices peuvent être négatifs ou supérieurs à l'indice de colonne maximal naturel. Le minimum est nul uniquement lorsque la caméra est proche du centre naturel de la carte. Notre tâche consiste à déplacer les colonnes afin qu'elles correspondent à ces indices relatifs. Cela peut être fait en modifiant la coordonnée X locale de chaque colonne de la boucle. int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2; Vector3 position; position.y = position.z = 0f; for (int i = 0; i < columns.Length; i++) { position.x = 0f; columns[i].localPosition = position; }
Pour chaque colonne, nous vérifions si l'indice de l'indice minimum est inférieur. Si c'est le cas, alors c'est trop loin à gauche du centre. Il doit se téléporter de l'autre côté de la carte. Cela peut être fait en faisant sa coordonnée X égale à la largeur de la carte. De même, si l'index de colonne est supérieur à l'index maximum, il est trop loin à droite du centre et devrait se téléporter de l'autre côté. for (int i = 0; i < columns.Length; i++) { if (i < minColumnIndex) { position.x = chunkCountX * (HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else if (i > maxColumnIndex) { position.x = chunkCountX * -(HexMetrics.innerDiameter * HexMetrics.chunkSizeX); } else { position.x = 0f; } columns[i].localPosition = position; }
Déplacement de la caméra
Changez HexMapCamera.AdjustPosition
pour que lorsque vous travaillez avec une carte pliable, il ClampPosition
appelle à la place WrapPosition
. Tout d'abord, faites simplement de la nouvelle méthode un WrapPosition
doublon ClampPosition
, mais avec la seule différence: au final, elle appellera CenterMap
. void AdjustPosition (float xDelta, float zDelta) { … transform.localPosition = grid.wrapping ? WrapPosition(position) : ClampPosition(position); } … Vector3 WrapPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); float zMax = (grid.cellCountZ - 1) * (1.5f * HexMetrics.outerRadius); position.z = Mathf.Clamp(position.z, 0f, zMax); grid.CenterMap(position.x); return position; }
Pour que la carte soit immédiatement centrée, nous appelons la OnEnable
méthode ValidatePosition
. void OnEnable () { instance = this; ValidatePosition(); }
Déplacez-vous vers la gauche et la droite lorsque vous centrez l'appareil photo.Bien que nous restreignions toujours le mouvement de la caméra, la carte essaie maintenant de se centrer par rapport à la caméra, téléportant des colonnes de fragments de carte si nécessaire. Avec une petite carte et une caméra distante, cela est clairement visible, mais sur une grande carte, les fragments téléportés sont en dehors de la zone de vision de la caméra. De toute évidence, seuls les bords est et ouest initiaux de la carte sont visibles, car il n'y a pas encore de triangulation entre eux., Supprimer la restriction de sa coordonnée X afin de minimiser et d'une caméra WrapPosition
. Au lieu de cela, nous continuerons d'augmenter la coordonnée X de la largeur de la carte lorsqu'elle est inférieure à zéro et de la réduire lorsqu'elle est supérieure à la largeur de la carte. Vector3 WrapPosition (Vector3 position) {
La caméra déroulante se déplace le long de la carte.Textures de shader pliables
À l'exception de l'espace de triangulation, la réduction de la caméra en mode jeu devrait être imperceptible. Cependant, lorsque cela se produit, un changement visuel se produit dans la moitié de la topographie et de l'eau. Cela se produit parce que nous utilisons une position dans le monde pour échantillonner ces textures. Une téléportation nette du fragment modifie l'emplacement des textures.Nous pouvons résoudre ce problème en faisant apparaître les textures dans des tuiles qui sont des multiples de la taille du fragment. La taille du fragment est calculée à partir des constantes dans HexMetrics
, alors créons le fichier d' inclusion du shader HexMetrics.cginc et collez-y les définitions correspondantes. L'échelle de mosaïque de base est calculée à partir de la taille du fragment et du rayon extérieur de la cellule. Si vous utilisez d'autres mesures, vous devrez modifier le fichier en conséquence. #define OUTER_TO_INNER 0.866025404 #define OUTER_RADIUS 10 #define CHUNK_SIZE_X 5 #define TILING_SCALE (1 / (CHUNK_SIZE_X * 2 * OUTER_RADIUS / OUTER_TO_INNER))
Cela nous donne une échelle de tuilage de 0,00866025404. Si nous utilisons un multiple entier de cette valeur, la texturation ne sera pas affectée par la téléportation de fragments. De plus, les textures aux bords est et ouest de la carte se rejoindront de manière transparente après avoir triangulé correctement leur connexion. Nous avons utilisé 0,02comme échelle UV dans le shader Terrain . Au lieu de cela, nous pouvons utiliser l'échelle de carrelage double, qui est de 0,01732050808. L'échelle est obtenue un peu moins qu'elle ne l'était, et l'échelle de la texture a légèrement augmenté, mais visuellement elle est invisible. #include "../HexMetrics.cginc" #include "../HexCellData.cginc" … float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3( IN.worldPos.xz * (2 * TILING_SCALE), IN.terrain[index] ); … }
Dans le shader Roads pour le bruit UV, nous avons utilisé une échelle de 0,025. Au lieu de cela, vous pouvez utiliser la triple échelle de mosaïque. Cela nous donne 0,02598076212, ce qui est assez proche. #include "HexMetrics.cginc" #include "HexCellData.cginc" … void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE)); … }
Enfin, chez Water.cginc, nous avons utilisé 0,015 pour la mousse et 0,025 pour les vagues. Ici, nous pouvons à nouveau remplacer ces valeurs par une échelle de mosaïque doublée et triplée. #include "HexMetrics.cginc" float Foam (float shore, float2 worldXZ, sampler2D noiseTex) { shore = sqrt(shore) * 0.9; float2 noiseUV = worldXZ + _Time.y * 0.25; float4 noise = tex2D(noiseTex, noiseUV * (2 * TILING_SCALE)); … } … float Waves (float2 worldXZ, sampler2D noiseTex) { float2 uv1 = worldXZ; uv1.y += _Time.y; float4 noise1 = tex2D(noiseTex, uv1 * (3 * TILING_SCALE)); float2 uv2 = worldXZ; uv2.x += _Time.y; float4 noise2 = tex2D(noiseTex, uv2 * (3 * TILING_SCALE)); … }
paquet d'unitéL'union de l'est et de l'ouest
À ce stade, la seule preuve visuelle de la réduction de la carte est un petit écart entre les colonnes les plus à l'est et à l'ouest. Cet écart se produit car nous n'avons pas encore triangulé les bords et les coins entre les cellules des côtés opposés de la carte sans plier.Espace sur le bord.Voisins pliants
Pour trianguler la connexion est-ouest, nous devons faire en sorte que les cellules des côtés opposés soient voisines les unes des autres. Jusqu'à présent, nous ne le faisons pas, car la HexGrid.CreateCell
connexion E - W n'est établie avec la cellule précédente que si son index dans X est supérieur à zéro. Pour réduire cette connexion, nous devons connecter la dernière cellule de la ligne avec la première cellule de la même ligne lorsque le pliage de la carte est activé. void CreateCell (int x, int z, int i) { … if (x > 0) { cell.SetNeighbor(HexDirection.W, cells[i - 1]); if (wrapping && x == cellCountX - 1) { cell.SetNeighbor(HexDirection.E, cells[i - x]); } } … }
Après avoir établi la connexion des voisins E - W, nous obtenons une triangulation partielle de l'écart. La connexion des bords n'est pas idéale, car la distorsion n'est pas masquée correctement. Nous y reviendrons plus tard.Composés E - W.Nous devons également réduire les liens NE - SW. Pour ce faire, connectez la première cellule de chaque ligne paire aux dernières cellules de la ligne précédente. Ce sera juste la cellule précédente. if (z > 0) { if ((z & 1) == 0) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]); if (x > 0) { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]); } else if (wrapping) { cell.SetNeighbor(HexDirection.SW, cells[i - 1]); } } else { … } }
Connexions NE - SW.Enfin, les connexions SE - NW sont établies à la fin de chaque ligne impaire en dessous de la première. Ces cellules doivent être connectées à la première cellule de la ligne précédente. if (z > 0) { if ((z & 1) == 0) { … } else { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]); if (x < cellCountX - 1) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]); } else if (wrapping) { cell.SetNeighbor( HexDirection.SE, cells[i - cellCountX * 2 + 1] ); } } }
Composés SE - NW.Pliage de bruit
Pour masquer parfaitement l'écart, nous devons nous assurer que les bords est et ouest de la carte correspondent au bruit parfaitement utilisé pour déformer les positions des sommets. Nous pouvons utiliser la même astuce que celle utilisée pour les shaders, mais une échelle de bruit de 0,003 a été utilisée pour la distorsion. Pour garantir la mosaïque, vous devez augmenter considérablement l'échelle, ce qui entraînera une distorsion plus chaotique des sommets.Une solution alternative n'est pas de mesurer le bruit, mais de faire une atténuation douce du bruit aux bords de la carte. Si vous effectuez une atténuation douce sur la largeur d'une cellule, la distorsion créera une transition douce sans espaces. Le bruit dans cette zone sera légèrement lissé et à longue distance, le changement semblera net, mais ce n'est pas si évident lorsque vous utilisez une légère distorsion des sommets.Et les fluctuations de température?. , . , . , .
Si nous n'effondrons pas la carte, nous pouvons nous en tirer avec un HexMetrics.SampleNoise
seul échantillon. Mais lors du pliage, il est nécessaire d'ajouter une atténuation. Par conséquent, avant de renvoyer l'échantillon, enregistrez-le dans une variable. public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample; }
Lors de la minimisation, nous devons mélanger avec le deuxième échantillon. Nous effectuerons la transition dans la partie orientale de la carte, de sorte que le deuxième échantillon doit être déplacé vers l'ouest. Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); }
L'atténuation est effectuée en utilisant une simple interpolation linéaire de la partie ouest vers la partie est, sur la largeur d'une cellule. if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) ); }
Mélange de bruit, une solution imparfaite.Par conséquent, nous n'obtenons pas de correspondance exacte, car certaines cellules du côté est ont des coordonnées X négatives. Pour ne pas approcher cette zone, déplaçons la région de transition vers la moitié ouest de la largeur de la cellule. if (Wrapping && position.x < innerDiameter * 1.5f) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) - 0.5f ); }
Atténuation correcte.Modification de cellule
Maintenant que la triangulation semble correcte, assurons-nous que nous pouvons tout éditer sur la carte et sur la couture de pliage. Il s'avère que, dans les fragments téléportés, les coordonnées sont erronées et les grands pinceaux sont coupés par une couture.La brosse est taillée.Pour résoudre ce problème, nous devons signaler le HexCoordinates
pliage. Nous pouvons le faire en faisant correspondre la coordonnée X dans la méthode constructeur. Nous savons que la coordonnée axiale X est obtenue à partir de la coordonnée X du décalage en soustrayant la moitié de la coordonnée Z. Vous pouvez utiliser ces informations pour effectuer la transformation inverse et vérifier si la coordonnée zéro est inférieure à zéro. Si oui, alors nous avons les coordonnées au-delà du côté est de la carte dépliée. Étant donné que dans chaque direction, nous ne téléportons pas plus de la moitié de la carte, il nous suffira d'ajouter une fois la taille de pliage à X. Et lorsque la coordonnée de décalage est supérieure à la taille de pliage, nous devons effectuer une soustraction. public HexCoordinates (int x, int z) { if (HexMetrics.Wrapping) { int oX = x + z / 2; if (oX < 0) { x += HexMetrics.wrapSize; } else if (oX >= HexMetrics.wrapSize) { x -= HexMetrics.wrapSize; } } this.x = x; this.z = z; }
Parfois, lors de l'édition du bas ou du haut de la carte, j'obtiens des erreursCela se produit lorsque, en raison de la distorsion des sommets, le curseur apparaît dans la ligne de cellules en dehors de la carte. Il s'agit d'un bogue qui se produit car nous ne faisons pas correspondre les coordonnées HexGrid.GetCell
avec le paramètre vectoriel. Cela peut être résolu en appliquant une méthode GetCell
avec des coordonnées comme paramètres qui effectueront les vérifications nécessaires. public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position);
Pliage côtier
La triangulation fonctionne bien pour le terrain, mais le long de la couture est-ouest, il n'y a pas de bords de la côte de l'eau. En fait, ils le sont, ils ne s'effondrent pas. Ils sont retournés et étirés de l'autre côté de la carte.Bord de l'eau manquant.Cela se produit, car lors de la triangulation de l'eau de la côte, nous utilisons la position d'un voisin. Pour résoudre ce problème, nous devons déterminer ce à quoi nous avons affaire, situé de l'autre côté de la carte. Pour simplifier la tâche, nous allons ajouter une HexCell
colonne de cellules à la propriété de l'index. public int ColumnIndex { get; set; }
Attribuez cet index à HexGrid.CreateCell
. Elle est simplement égale à la coordonnée de décalage X divisée par la taille du fragment. void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … }
Nous pouvons maintenant HexGridChunk.TriangulateWaterShore
déterminer ce qui est minimisé en comparant l'index de colonne de la cellule actuelle et de son voisin. Si l'indice de la colonne du voisin est inférieur à un pas de moins, alors nous sommes du côté ouest et le voisin est du côté est. Par conséquent, nous devons tourner notre voisin vers l'ouest. Le même et dans la direction opposée. Vector3 center2 = neighbor.Position; if (neighbor.ColumnIndex < cell.ColumnIndex - 1) { center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) { center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; }
Côtes de la côte, mais pas de coins.Nous avons donc pris soin des côtes de la côte, mais jusqu'à présent, nous ne nous sommes pas occupés des virages. Nous devons faire de même avec le prochain voisin. if (nextNeighbor != null) { Vector3 center3 = nextNeighbor.Position; if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) { center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) { center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ? HexMetrics.GetFirstWaterCorner(direction.Previous()) : HexMetrics.GetFirstSolidCorner(direction.Previous())); … }
Côte correctement réduite.Génération de cartes
L'option de connexion des côtés est et ouest affecte la génération de cartes. Lors de la minimisation de la carte, l'algorithme de génération doit également être minimisé. Cela conduira à la création d'une autre carte, mais lorsque vous utilisez une bordure de carte X non nulle , le pliage n'est pas évident.Grande carte 1208905299 avec paramètres par défaut. Avec pliage et sans elle.Quand elle est réduite n'a pas de sens d'utiliser la carte frontière X . Mais nous ne pouvons pas nous en débarrasser, car en même temps les régions fusionneront. Lors de la réduction, nous pouvons simplement utiliser un RegionBorder .Nous changeons HexMapGenerator.CreateRegions
, en remplaçant dans tous les cas mapBorderX
par borderX
. Cette nouvelle variable sera égale à ou regionBorder
, ou mapBorderX
, selon la valeur de l'option de réduction. Ci-dessous, j'ai montré les changements uniquement pour le premier cas. int borderX = grid.wrapping ? regionBorder : mapBorderX; MapRegion region; switch (regionCount) { default: region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; … }
Dans le même temps, les régions restent séparées, mais cela n'est nécessaire que s'il existe différentes régions sur les côtés est et ouest de la carte. Il y a deux cas où cela n'est pas respecté. Le premier, c'est quand nous n'avons qu'une seule région. La seconde est lorsque deux régions divisent la carte horizontalement. Dans ces cas, nous pouvons attribuer une borderX
valeur de zéro, ce qui permettra aux masses terrestres de traverser la couture est-ouest. switch (regionCount) { default: if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); break; case 2: if (Random.value < 0.5f) { … } else { if (grid.wrapping) { borderX = 0; } region.xMin = borderX; region.xMax = grid.cellCountX - borderX; region.zMin = mapBorderZ; region.zMax = grid.cellCountZ / 2 - regionBorder; regions.Add(region); region.zMin = grid.cellCountZ / 2 + regionBorder; region.zMax = grid.cellCountZ - mapBorderZ; regions.Add(region); } break; … }
Une région s'effondre.À première vue, il semble que tout fonctionne correctement, mais il y a en fait un espace le long de la couture. Cela devient plus visible si vous définissez le pourcentage d'érosion sur zéro.Lorsque l'érosion est désactivée, une couture sur le relief devient perceptible.L'écart se produit parce que la couture empêche la croissance des fragments de relief. Pour déterminer ce qui est ajouté en premier, la distance entre la cellule et le centre du fragment est utilisée, et les cellules de l'autre côté de la carte peuvent être très éloignées, de sorte qu'elles ne s'allument presque jamais. Bien sûr, c'est faux. Nous devons nous assurer que nous HexCoordinates.DistanceTo
connaissons la carte minimisée.Nous calculons la distance entre HexCoordinates
, en additionnant les distances absolues le long de chacun des trois axes et en divisant par deux le résultat. La distance le long de Z est toujours vraie, mais le pliage le long peut affecter les distances X et Y. Commençons donc par un calcul séparé de X + Y. public int DistanceTo (HexCoordinates other) {
Déterminer si le pliage crée une distance plus courte pour les cellules arbitraires n'est pas une tâche facile, alors calculons simplement X + Y pour les cas où nous plions une autre coordonnée du côté ouest. Si la valeur est inférieure au X + Y d'origine, utilisez-la. int xy = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } }
Si cela ne conduit pas à une distance plus courte, il est possible de tourner plus court dans l'autre sens, nous allons donc le vérifier. if (HexMetrics.Wrapping) { other.x += HexMetrics.wrapSize; int xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } else { other.x -= 2 * HexMetrics.wrapSize; xyWrapped = (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y); if (xyWrapped < xy) { xy = xyWrapped; } } }
Maintenant, nous obtenons toujours la distance la plus courte sur la carte pliable. Les fragments de terrain ne sont plus bloqués par une couture, ce qui permet aux masses terrestres de se recroqueviller.Relief repliable correctement sans érosion ni érosion.paquet d'unitéVoyager dans le monde
Après avoir envisagé la génération et la triangulation de cartes, passons maintenant à la vérification des escouades, de l'exploration et de la visibilité.Couture d'essai
Le premier obstacle que nous rencontrons lors du déplacement d'une équipe dans le monde est le bord de la carte, qui ne peut pas être exploré.La couture de la carte ne peut pas être examinée.Les cellules le long du bord de la carte sont rendues inexplorées pour masquer l'achèvement brutal de la carte. Mais lorsque la carte est minimisée, seules les cellules nord et sud doivent être marquées, mais pas l'est et l'ouest. Modifiez HexGrid.CreateCell
pour en tenir compte. if (wrapping) { cell.Explorable = z > 0 && z < cellCountZ - 1; } else { cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; }
Visibilité des reliefs
Vérifions maintenant si la visibilité fonctionne le long de la couture. Cela fonctionne pour le terrain, mais pas pour les objets de terrain. Il semble que les objets qui se replient obtiennent la visibilité de la dernière cellule qui n'a pas été réduite.Visibilité incorrecte des objets.Cela se produit car le mode de HexCellShaderData
serrage est défini pour le mode de pliage de texture utilisé . Pour résoudre le problème, changez simplement son mode de serrage pour répéter. Mais nous devons faire est seulement de coordonner U, nous à Initialize
demander wrapModeU
et wrapModeV
individuellement. public void Initialize (int x, int z) { if (cellTexture) { cellTexture.Resize(x, z); } else { cellTexture = new Texture2D( x, z, TextureFormat.RGBA32, false, true ); cellTexture.filterMode = FilterMode.Point;
Escouades et colonnes
Un autre problème est que les unités ne s'effondrent pas encore. Après avoir déplacé la colonne dans laquelle ils se trouvent, les unités restent au même endroit.L'unité n'est pas transférée et est du mauvais côté.Ce problème peut être résolu en faisant des escouades des éléments enfants de colonnes, comme nous l'avons fait avec des fragments. Premièrement, nous ne ferons plus d'eux les enfants immédiats du réseau HexGrid.AddUnit
. public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.Grid = this;
Étant donné que les unités se déplacent, elles peuvent apparaître dans une autre colonne, c'est-à-dire qu'il sera nécessaire de changer leur parent. Pour rendre cela possible, nous ajoutons à la HexGrid
méthode générale MakeChildOfColumn
, et comme paramètres nous lui passons le composant de l' Transform
élément enfant et l'index de la colonne. public void MakeChildOfColumn (Transform child, int columnIndex) { child.SetParent(columns[columnIndex], false); }
Nous appellerons cette méthode lorsque la propriété est définie HexUnit.Location
. public HexCell Location { … set { … Grid.MakeChildOfColumn(transform, value.ColumnIndex); } }
Cela résout le problème de la création d'unités. Mais nous devons également les faire passer à la colonne souhaitée lors du déplacement. Pour ce faire, vous devez suivre HexUnit.TravelPath
la colonne actuelle dans l' index. Au début de cette méthode, il s'agit de l'index de la colonne de cellules au début du chemin, ou de l'actuel si le déplacement a été interrompu par recompilation. IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position);
Lors de chaque itération du déplacement, nous vérifierons si l'index de la colonne suivante est différent, et si c'est le cas, nous changerons le parent de la commande. int currentColumn = currentTravelLocation.ColumnIndex; float t = Time.deltaTime * travelSpeed; for (int i = 1; i < pathToTravel.Count; i++) { … Grid.IncreaseVisibility(pathToTravel[i], VisionRange); int nextColumn = currentTravelLocation.ColumnIndex; if (currentColumn != nextColumn) { Grid.MakeChildOfColumn(transform, nextColumn); currentColumn = nextColumn; } … }
Cela permettra aux unités de se déplacer de la même manière que les fragments. Cependant, lors du déplacement à travers la couture de la carte, les unités ne s'effondrent pas encore. Au lieu de cela, ils commencent soudainement à se déplacer dans la mauvaise direction. Cela se produit quel que soit l'emplacement de la couture, mais surtout quand ils sautent sur toute la carte.Courses de chevaux sur la carte.Ici, nous pouvons utiliser la même approche que celle utilisée pour la côte, mais cette fois, nous allons tourner la courbe le long de laquelle le détachement se déplace. Si la colonne suivante est tournée vers l'est, nous téléporterons également la courbe vers l'est, de même pour l'autre direction. Vous devez modifier les points de contrôle de la courbe a
et b
, ce qui affectera également le point de contrôle c
. for (int i = 1; i < pathToTravel.Count; i++) { currentTravelLocation = pathToTravel[i]; a = c; b = pathToTravel[i - 1].Position;
Mouvement avec pliage.La dernière chose à faire est de changer le tour initial de l'escouade lorsqu'elle regarde la première cellule dans laquelle elle se déplacera. Si cette cellule se trouve de l'autre côté de la couture est-ouest, l'unité regardera dans la mauvaise direction.Lors de la réduction d'une carte, il existe deux façons de regarder un point qui n'est pas exactement au nord ou au sud. Vous pouvez regarder à l'est ou à l'ouest. Il sera logique de regarder dans la direction correspondant à la distance la plus proche du point, car c'est également la direction du mouvement, alors utilisons-la LookAt
.Lors de la réduction, nous vérifierons la distance relative le long de l'axe X. Si elle est inférieure à la moitié négative de la largeur de la carte, alors nous devrions regarder vers l'ouest, ce qui peut être fait en tournant le point vers l'ouest. Sinon, si la distance est supérieure à la moitié de la largeur de la carte, alors nous devons nous effondrer vers l'est. IEnumerator LookAt (Vector3 point) { if (HexMetrics.Wrapping) { float xDistance = point.x - transform.localPosition.x; if (xDistance < -HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x += HexMetrics.innerDiameter * HexMetrics.wrapSize; } else if (xDistance > HexMetrics.innerRadius * HexMetrics.wrapSize) { point.x -= HexMetrics.innerDiameter * HexMetrics.wrapSize; } } … }
Nous avons donc une carte minimisée entièrement fonctionnelle. Et cela conclut la série de tutoriels sur les cartes hexagonales. Comme mentionné dans les sections précédentes, d'autres sujets peuvent être considérés, mais ils ne sont pas spécifiques aux cartes hexagonales. Je les considérerai peut-être dans de futures séries de tutoriels.J'ai téléchargé le dernier package et j'obtiens des erreurs de tours en mode Play, Rotation . . . 5.
J'ai téléchargé le dernier package et les graphismes ne sont pas aussi beaux que dans les captures d'écran. - .
J'ai téléchargé le dernier paquet et il génère constamment la même carteseed (1208905299), . , Use Fixed Seed .
paquet d'unité