Partes 1-3: malla, colores y alturas de celdaPartes 4-7: baches, ríos y caminosPartes 8-11: agua, accidentes geográficos y murallasPartes 12-15: guardar y cargar, texturas, distanciasPartes 16-19: encontrar el camino, escuadrones de jugadores, animacionesPartes 20-23: niebla de guerra, investigación de mapas, generación de procedimientosPartes 24-27: ciclo del agua, erosión, biomas, mapa cilíndricoParte 24: regiones y erosión
- Agrega un borde de agua alrededor del mapa.
- Dividimos el mapa en varias regiones.
- Usamos la erosión para cortar los acantilados.
- Movimos la tierra para suavizar el alivio.
En la parte anterior, sentamos las bases para la generación de mapas de procedimientos. Esta vez limitaremos los lugares de posible ocurrencia de tierras y actuaremos sobre ellas con erosión.
Este tutorial fue creado en Unity 2017.1.0.
Separar y suavizar la tierra.Borde del mapa
Dado que levantamos áreas de tierra al azar, puede suceder que la tierra toque el borde del mapa. Esto puede ser indeseable. El mapa con límite de agua contiene una barrera natural que impide que los jugadores se acerquen al borde. Por lo tanto, sería bueno si prohibiéramos que la tierra se eleve por encima del nivel del agua cerca del borde del mapa.
Tamaño del borde
¿Qué tan cerca debe estar la tierra del borde del mapa? No hay una respuesta correcta a esta pregunta, por lo que haremos que este parámetro sea personalizable.
HexMapGenerator
dos controles deslizantes al componente
HexMapGenerator
, uno para los bordes a lo largo de los bordes a lo largo del eje X, el otro para los bordes a lo largo del eje Z. Por lo tanto, podemos usar un borde más ancho en una de las dimensiones, o incluso crear un borde en una sola dimensión. Usemos un intervalo de 0 a 10 con un valor predeterminado de 5.
[Range(0, 10)] public int mapBorderX = 5; [Range(0, 10)] public int mapBorderZ = 5;
Mapa de bordes deslizantes.Limitamos los centros de las áreas terrestres.
Sin bordes, todas las celdas son válidas. Cuando hay límites, las coordenadas mínimas de desplazamiento permitidas aumentan y las coordenadas máximas permitidas disminuyen. Dado que para generar las parcelas necesitaremos conocer el intervalo permitido, rastreemos usando cuatro campos enteros.
int xMin, xMax, zMin, zMax;
Inicializamos las restricciones en
GenerateMap
antes de crear sushi. Utilizamos estos valores como parámetros para
Random.Range
llamadas
Random.Range
, por lo que los máximos son realmente excepcionales. Sin un borde, son iguales al número de celdas de medición, por lo tanto, no menos 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(); … }
No prohibiremos estrictamente la aparición de tierra más allá del borde de la frontera, ya que esto crearía bordes muy cortados. En cambio, limitaremos solo las celdas utilizadas para comenzar la generación de gráficos. Es decir, los centros aproximados de los sitios serán limitados, pero partes de los sitios podrán ir más allá del área fronteriza. Esto se puede hacer modificando
GetRandomCell
para que seleccione una celda en el rango de desplazamientos permitidos.
HexCell GetRandomCell () {
Los bordes del mapa son 0 × 0, 5 × 5, 10 × 10 y 0 × 10.Cuando todos los parámetros del mapa se establecen en sus valores predeterminados, un borde de tamaño 5 protegerá de manera confiable el borde del mapa de tocar tierra. Sin embargo, esto no está garantizado. La tierra a veces puede acercarse al borde y, a veces, tocarla en varios lugares.
La probabilidad de que la tierra cruce la frontera entera depende del tamaño de la frontera y del tamaño máximo del sitio. Sin dudarlo, las secciones siguen siendo hexágonos. Hexágono completo con radio
contiene
células Si hay hexágonos con un radio igual al tamaño del borde, entonces pueden cruzarlo. Un hexágono completo con un radio de 5 contiene 91 celdas. Como por defecto el máximo es de 100 celdas por sección, esto significa que la tierra podrá tender un puente a través de 5 celdas, especialmente si hay vibraciones. Para evitar que esto suceda, reduzca el tamaño máximo de la trama o aumente el tamaño del borde.
¿Cómo se deriva la fórmula para el número de celdas en la región hexagonal?Con un radio de 0, estamos tratando con una sola celda. Vino de 1. Con un radio de 1 alrededor del centro, hay seis celdas adicionales, es decir . Estas seis celdas pueden considerarse los extremos de seis triángulos que tocan el centro. Con un radio de 2, se agrega una segunda fila a estos triángulos, es decir, se obtienen dos celdas más en el triángulo, y en total . Con un radio de 3, se agrega una tercera fila, es decir, tres celdas más por triángulo, y en total . Y así sucesivamente. Es decir, en términos generales, la fórmula se ve como .
Para ver esto más claramente, podemos establecer el tamaño del borde en 200. Dado que un hexágono completo con un radio de 8 contiene 217 celdas, es probable que la tierra toque el borde del mapa. Al menos si usa el valor de tamaño de borde predeterminado (5). Si aumenta el borde a 10, la probabilidad disminuirá considerablemente.
La parcela tiene un tamaño constante de 200, los bordes del mapa son 5 y 10.Pangea
Tenga en cuenta que cuando aumenta el borde del mapa y mantiene el mismo porcentaje de tierra, forzamos la tierra a formar un área más pequeña. Como resultado de esto, un mapa grande por defecto es muy probable que cree una gran masa de tierra, el supercontinente Pangea, posiblemente con varias islas pequeñas. Con un aumento en el tamaño del borde, la probabilidad de que esto ocurra aumenta y, a ciertos valores, casi estamos garantizados de obtener un supercontinente. Sin embargo, cuando el porcentaje de tierra es demasiado grande, la mayoría de las áreas disponibles se llenan y como resultado obtenemos una masa de tierra casi rectangular. Para evitar que esto suceda, debe reducir el porcentaje de tierra.
40% de sushi con borde de tarjeta 10.¿De dónde viene el nombre Pangea?Ese era el nombre del último supercontinente conocido que existió en la Tierra hace muchos años. El nombre se compone de las palabras griegas pan y Gaia, que significa algo así como "toda la naturaleza" o "toda la tierra".
Protegemos de cartas imposibles
Generamos la cantidad correcta de tierra simplemente continuando elevando la tierra hasta que alcancemos la masa de tierra deseada. Esto funciona porque tarde o temprano elevaremos cada celda al nivel del agua. Sin embargo, cuando usamos el borde del mapa, no podemos llegar a cada celda. Cuando se requiere un porcentaje demasiado alto de tierra, esto conducirá a interminables "intentos y fallas" del generador para obtener más tierra, y se quedará estancado en un ciclo interminable. En este caso, la aplicación se congelará, pero esto no debería suceder.
No podemos encontrar de manera confiable configuraciones imposibles de antemano, pero podemos protegernos de ciclos interminables. Simplemente realizaremos un seguimiento del número de ciclos ejecutados en
CreateLand
. Si hay demasiadas iteraciones, lo más probable es que estemos atascados y debamos parar.
Para un mapa grande, mil iteraciones parecen aceptables, y diez mil iteraciones ya parecen absurdas. Entonces usemos este valor como punto de terminación.
void CreateLand () { int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
Si obtenemos un mapa dañado, 10,000 iteraciones no tomarán mucho tiempo, porque muchas celdas alcanzarán rápidamente la altura máxima, lo que evitará que crezcan nuevas áreas.
Incluso después de romper el ciclo, todavía obtenemos el mapa correcto. Simplemente no tiene la cantidad correcta de sushi y no se verá muy interesante. Muestremos una notificación sobre esto en la consola, informándonos qué tierras restantes no pudimos gastar.
void CreateLand () { … if (landBudget > 0) { Debug.LogWarning("Failed to use up " + landBudget + " land budget."); } }
El 95% de la tierra con un borde de tarjeta de 10 no podría gastar la cantidad total.¿Por qué una carta fallida todavía tiene variación?La costa tiene variabilidad, porque cuando las alturas dentro del área de creación se vuelven demasiado altas, las nuevas áreas no les permiten crecer hacia afuera. El mismo principio no permite que las parcelas crezcan en pequeñas áreas de tierra, hasta que alcancen la altura máxima y simplemente se pierdan. Además, la variabilidad aumenta al bajar las parcelas.
paquete de la unidadParticionar una tarjeta
Ahora que tenemos el borde del mapa, esencialmente dividimos el mapa en dos regiones separadas: la región del borde y la región donde se crearon las parcelas. Como solo la región de la creación es importante para nosotros, podemos considerar este caso como una situación en una región. La región simplemente no cubre todo el mapa. Pero si esto es imposible, entonces nada nos impide dividir el mapa en varias regiones no conectadas de creación de tierras. Esto permitirá que las masas de tierra se formen independientemente entre sí, designando diferentes continentes.
Región del mapa
Comencemos describiendo una región del mapa como una estructura. Esto simplificará nuestro trabajo con varias regiones.
MapRegion
una estructura
MapRegion
para esto, que simplemente contiene los campos de borde de la región. Como no utilizaremos esta estructura fuera de
HexMapGenerator
, podemos definirla dentro de esta clase como una estructura interna privada. Luego, cuatro campos enteros se pueden reemplazar por un campo
MapRegion
.
Para que todo funcione, debemos agregar el prefijo de
region.
a los campos mínimo-máximo en
GenerateMap
region.
.
region.xMin = mapBorderX; region.xMax = x - mapBorderX; region.zMin = mapBorderZ; region.zMax = z - mapBorderZ;
Y también en
GetRandomCell
.
HexCell GetRandomCell () { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
Varias regiones
Para admitir varias regiones, reemplace un campo
MapRegion
lista de regiones.
En este punto, sería bueno agregar un método separado para crear regiones. Debe crear la lista deseada o borrarla si ya existe. Después de eso, determinará una región, como lo hicimos antes, y la agregará a la lista.
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); }
Llamaremos a este método en
GenerateMap
y no crearemos la región directamente.
Para que
GetRandomCell
pueda trabajar con una región arbitraria, dele el parámetro
MapRegion
.
HexCell GetRandomCell (MapRegion region) { return grid.GetCell( Random.Range(region.xMin, region.xMax), Random.Range(region.zMin, region.zMax) ); }
Ahora los
SinkTerrain
RaiseTerraion
y
SinkTerrain
deberían pasar la región correspondiente a
GetRandomCell
. Para hacer esto, cada uno de ellos también necesita un parámetro de región.
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); … }
El método
CreateLand
debe determinar para cada región subir o bajar las secciones. Para equilibrar la tierra entre las regiones, simplemente recorreremos repetidamente la lista de regiones en el ciclo.
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."); } }
Sin embargo, todavía tenemos que hacer que la reducción de las parcelas se distribuya uniformemente. Esto se puede hacer al decidir para todas las regiones si se deben omitir.
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);
Finalmente, para usar exactamente la cantidad total de tierra, necesitamos detener el proceso tan pronto como la cantidad llegue a cero. Esto puede suceder en cualquier etapa del ciclo de la región. Por lo tanto, movemos la verificación de suma cero al bucle interno. De hecho, solo podemos realizar esta verificación después de levantar la tierra, porque al bajar, la cantidad nunca se gasta. Si hemos terminado, podemos salir inmediatamente del método
CreateLand
.
Dos regiones
Aunque ahora contamos con el apoyo de varias regiones, todavía solicitamos solo una. Cambiemos
CreateRegions
para que divida el mapa a la mitad verticalmente. Para hacer esto,
xMax
la mitad el valor
xMax
de la región agregada. Luego usamos el mismo valor para
xMin
y nuevamente usamos el valor original para
xMax
, usándolo como la segunda región.
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);
Generar cartas en esta etapa no hará ninguna diferencia. Aunque hemos identificado dos regiones, ocupan la misma región que una región antigua. Para separarlos, debe dejar un espacio vacío entre ellos. Esto se puede hacer agregando un control deslizante al borde de la región, usando el mismo intervalo y valor predeterminado que para los bordes del mapa.
[Range(0, 10)] public int regionBorder = 5;
Control deslizante de borde de región.Dado que se puede formar tierra a ambos lados del espacio entre regiones, aumentará la probabilidad de crear puentes terrestres en los bordes del mapa. Para evitar esto, usamos el borde de la región para definir una zona libre de tierra entre la línea divisoria y la región en la que pueden comenzar las parcelas. Esto significa que la distancia entre las regiones vecinas es dos veces mayor que el tamaño del borde de la región.
Para aplicar este límite de región, restarlo del
xMax
primera región y agregar la segunda región a
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);
El mapa se divide verticalmente en dos regiones.Con la configuración predeterminada, se crearán dos regiones notablemente separadas, sin embargo, como en el caso de una región y un borde de mapa grande, no se garantiza que recibamos exactamente dos masas de tierra. Muy a menudo serán dos grandes continentes, posiblemente con varias islas. Pero a veces se pueden crear dos o más islas grandes en una región. Y a veces dos continentes pueden estar conectados por un istmo.
Por supuesto, también podemos dividir el mapa horizontalmente, cambiando los enfoques para medir X y Z. Elija al azar una de las dos orientaciones posibles.
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); }
Mapa horizontalmente dividido en dos regiones.Como usamos un mapa ancho, se crearán regiones más anchas y más delgadas con separación horizontal. Como resultado, es más probable que estas regiones formen varias masas de tierra divididas.
Cuatro regiones
Hagamos que el número de regiones sea personalizable, creemos soporte de 1 a 4 regiones.
[Range(1, 4)] public int regionCount = 1;
Control deslizante para el número de regiones.Podemos usar la
switch
para seleccionar la ejecución del código de región correspondiente. Comenzamos repitiendo el código de una región, que se usará por defecto, y dejamos el código de dos regiones para el caso 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; }
¿Cuál es la declaración de cambio?Esta es una alternativa a escribir una secuencia de declaraciones if-else-if-else. El interruptor se aplica a la variable y las etiquetas se usan para indicar qué código debe ejecutarse. También hay una etiqueta
default
, que se usa como el último bloque
else
. Cada opción debe terminar con una declaración de
break
o una
return
.
Para mantener legible el bloque de
switch
, generalmente es mejor mantener todos los casos cortos, idealmente con una sola declaración o llamada al método. No haré esto como un ejemplo de código de región, pero si desea crear regiones más interesantes, le recomiendo que utilice métodos separados. Por ejemplo:
switch (regionCount) { default: CreateOneRegion(); break; case 2: CreateTwoRegions(); break; case 3: CreateThreeRegions(); break; case 4: CreateFourRegions(); break; }
Tres regiones son similares a dos, solo se utilizan tercios en lugar de la mitad. En este caso, la división horizontal creará regiones demasiado estrechas, por lo que creamos soporte solo para la división vertical. Tenga en cuenta que, como resultado, hemos duplicado el área del borde de la región, por lo que el espacio para crear nuevas secciones es menor que en el caso de dos regiones.
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; }
Tres regionesSe pueden crear cuatro regiones combinando la separación horizontal y vertical y agregando una región a cada esquina del mapa.
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; } }
Cuatro regionesEl enfoque utilizado aquí es la forma más sencilla de dividir un mapa. Genera aproximadamente las mismas regiones por masa de tierra, y su variabilidad está controlada por otros parámetros de generación de mapas. Sin embargo, siempre será bastante obvio que la tarjeta se dividió en líneas rectas. Cuanto más control necesitemos, menos orgánico se verá el resultado. Por lo tanto, esto es normal si necesita regiones aproximadamente iguales para el juego. Pero si necesita la tierra más variada e ilimitada, tendrá que hacerlo con la ayuda de una región.
Además, hay otras formas de dividir el mapa. No podemos limitarnos solo a líneas rectas. Ni siquiera tenemos que usar regiones del mismo tamaño, así como cubrir todo el mapa con ellas. Podemos dejar agujeros. También puede permitir intersecciones de regiones o cambiar la distribución de la tierra entre regiones. Incluso puede establecer sus propios parámetros de generador para cada región (aunque esto es más complicado), por ejemplo, para tener un continente grande y un archipiélago en el mapa.
paquete de la unidadErosión
Hasta ahora, todas las cartas que hemos generado parecían bastante groseras y rotas.
Un alivio real puede verse así, pero con el tiempo se vuelve más y más suave, sus partes afiladas se opacan debido a la erosión. Para mejorar los mapas, podemos aplicar este proceso de erosión. Haremos esto después de crear un terreno accidentado, en un método separado. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); SetTerrainType(); … } … void ErodeLand () {}
Porcentaje de erosión
Cuanto más tiempo pasa, más erosión aparece. Por lo tanto, queremos que la erosión no sea permanente, sino personalizable. Como mínimo, la erosión es cero, lo que corresponde a los mapas creados anteriormente. Al máximo, la erosión es integral, es decir, la aplicación adicional de las fuerzas de erosión ya no cambiará el terreno. Es decir, el parámetro de erosión debe ser un porcentaje de 0 a 100, y por defecto tomaremos 50. [Range(0, 100)] public int erosionPercentage = 50;
Control deslizante de erosión.Buscar células destructoras de la erosión
La erosión hace que el alivio sea más suave. En nuestro caso, las únicas partes afiladas son los acantilados. Por lo tanto, serán el objetivo del proceso de erosión. Si existe un acantilado, la erosión debería reducirlo hasta que finalmente se convierta en una pendiente. No suavizaremos las pendientes, ya que esto conducirá a un terreno aburrido. Para hacer esto, necesitamos determinar qué celdas están en la parte superior de los acantilados y bajar su altura. Estas serán células propensas a la erosión.Creemos un método que determine si una célula puede ser propensa a la erosión. Él determina esto comprobando a los vecinos de la celda hasta que encuentre una diferencia de altura suficientemente grande. Dado que los acantilados requieren una diferencia de al menos uno o dos niveles de altura, la celda está sujeta a erosión si uno o más de sus vecinos están al menos dos pasos debajo de ella. Si no existe tal vecino, entonces la célula no puede sufrir erosión. 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; }
Podemos usar este método ErodeLand
para recorrer todas las celdas y escribir todas las celdas propensas a la erosión en una lista temporal. 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); }
Una vez que conocemos el número total de células propensas a la erosión, podemos usar el porcentaje de erosión para determinar el número de células restantes propensas a la erosión. Por ejemplo, si el porcentaje es 50, entonces debemos erosionar las células hasta que quede la mitad de la cantidad original. Si el porcentaje es 100, entonces no nos detendremos hasta que destruyamos todas las células propensas a la erosión. 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); }
¿No deberíamos considerar solo las células propensas a la erosión de la tierra?. , , .
Reducción celular
Comencemos con un enfoque ingenuo y supongamos que una simple reducción en la altura de las células destruidas por erosión hará que ya no sea más propenso a la erosión. Si esto fuera cierto, entonces podríamos tomar celdas aleatorias de la lista, reducir su altura y luego eliminarlas de la lista. Repetiríamos esta operación hasta alcanzar el número deseado de células susceptibles a la erosión. 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);
Para evitar la búsqueda requerida erodibleCells.Remove
, sobrescribiremos la última celda actual de la lista y luego eliminaremos el último elemento. Todavía no nos importa su orden.
Disminución ingenua de 0% y 100% de células propensas a la erosión, mapa de semillas 1957632474.Seguimiento de la erosión
Nuestro enfoque ingenuo nos permite aplicar erosión, pero no en el grado correcto. Esto sucede porque la célula después de una disminución en la altura puede seguir siendo propensa a la erosión. Por lo tanto, eliminaremos una celda de la lista solo cuando ya no esté sujeta a erosión. if (!IsErodible(cell)) { erodibleCells[index] = erodibleCells[erodibleCells.Count - 1]; erodibleCells.RemoveAt(erodibleCells.Count - 1); }
100% de erosión mientras se mantienen las células propensas a la erosión en la lista.Por lo tanto, tenemos una erosión mucho más fuerte, pero cuando usamos el 100% todavía no nos deshacemos de todos los acantilados. La razón es que después de reducir la altura de la celda, uno de sus vecinos puede volverse propenso a la erosión. Por lo tanto, como resultado, podemos tener más células propensas a la erosión de lo que era originalmente.Después de bajar la celda, debemos verificar todos sus vecinos. Si ahora son propensos a la erosión, pero aún no están en la lista, entonces debe agregarlos allí. 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); } }
Se omiten todas las células erosionadas.Ahorramos mucha tierra
Ahora el proceso de erosión puede continuar hasta que desaparezcan todos los acantilados. Esto afecta mucho la tierra. La mayor parte de la masa de tierra desapareció y obtuvimos mucho menos que el porcentaje de tierra necesaria. Sucedió porque estamos eliminando tierras del mapa.La verdadera erosión no destruye la materia. Ella lo toma de un lugar y lo coloca en otro lugar. Nosotros podemos hacer lo mismo. Con una disminución en una celda, debemos criar a uno de sus vecinos. De hecho, un nivel de altura se transfiere a una celda inferior. Esto ahorra la cantidad total de alturas del mapa, mientras que simplemente lo suaviza.Para darnos cuenta de esto, debemos decidir dónde transferir los productos de erosión. Este será nuestro objetivo de erosión. Creemos un método para determinar el punto objetivo de una celda que se erosionará. Dado que esta celda contiene un salto, sería lógico seleccionar la celda ubicada debajo de este salto como el objetivo. Pero una celda propensa a la erosión puede tener varios descansos, por lo que verificaremos a todos los vecinos y colocaremos a todos los candidatos en una lista temporal, y luego elegiremos uno de ellos al azar. 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; }
En ErodeLand
definimos la celda objetivo inmediatamente después de seleccionar la celda de erosión. Luego disminuimos y aumentamos las alturas de las células inmediatamente una tras otra. En este caso, la celda objetivo en sí misma puede volverse susceptible a la erosión, pero esta situación se resuelve cuando verificamos a los vecinos de la celda recién erosionada. 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); }
Desde que elevamos la celda objetivo, parte de los vecinos de esta celda ya no pueden estar sujetos a la erosión. Es necesario rodearlos y verificar si son propensos a la erosión. Si no, pero están en la lista, debe eliminarlos de ella. 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% de erosión mientras se mantiene la masa de la tierra.La erosión ahora puede suavizar el terreno mucho mejor, bajando algunas áreas y elevando otras. Como resultado, la masa de tierra puede aumentar y reducirse. Esto puede cambiar el porcentaje de tierra en varios por ciento en una dirección u otra, pero rara vez se producen desviaciones graves. Es decir, cuanto más erosión apliquemos, menos control tendremos sobre el porcentaje resultante de tierra.Erosión acelerada
Aunque no necesitamos preocuparnos realmente por la efectividad del algoritmo de erosión, podemos hacer mejoras simples. Primero, tenga en cuenta que verificamos explícitamente si la celda que erosionamos puede ser erosionada. Si no, esencialmente lo eliminamos de la lista. Por lo tanto, puede omitir la comprobación de esta celda al atravesar los vecinos de la celda de destino. 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); } }
En segundo lugar, necesitábamos verificar a los vecinos de la celda objetivo solo cuando había una ruptura entre ellos, pero ahora esto no es necesario. Esto solo ocurre cuando el vecino ahora está un paso más arriba que la celda objetivo. Si es así, se garantiza que el vecino estará en la lista, por lo que no necesitamos verificar esto, es decir, podemos omitir la búsqueda innecesaria. HexCell neighbor = targetCell.GetNeighbor(d); if ( neighbor && neighbor != cell && neighbor.Elevation == targetCell.Elevation + 1 && !IsErodible(neighbor)
En tercer lugar, podemos usar un truco similar al verificar a los vecinos de una célula propensa a la erosión. Si ahora hay un acantilado entre ellos, entonces el vecino es propenso a la erosión. Para averiguarlo, no necesitamos llamar IsErodible
. HexCell neighbor = cell.GetNeighbor(d); if ( neighbor && neighbor.Elevation == cell.Elevation + 2 &&
Sin embargo, aún debemos verificar si la celda objetivo es susceptible a la erosión, pero el ciclo que se muestra arriba ya no lo hace. Por lo tanto, realizamos esto explícitamente para la celda objetivo. 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++) { … }
Ahora podemos aplicar la erosión lo suficientemente rápido y al porcentaje deseado en relación con el número inicial de acantilados generados. Tenga en cuenta que debido al hecho de que cambiamos ligeramente el lugar donde se agrega la celda objetivo a la lista propensa a la erosión, el resultado ha cambiado ligeramente del resultado antes de las optimizaciones.25%, 50%, 75% y 100% de erosión.También tenga en cuenta que a pesar de la forma cambiada de la costa, la topología no ha cambiado fundamentalmente. Las masas de tierra generalmente permanecen conectadas o separadas. Solo las islas pequeñas pueden ahogarse por completo. Los detalles en relieve se suavizan, pero las formas generales siguen siendo las mismas. Una articulación estrecha puede desaparecer o crecer un poco. Un pequeño espacio puede llenarse o expandirse ligeramente. Por lo tanto, la erosión no se adherirá fuertemente a las regiones divididas.Cuatro regiones completamente erosionadas aún permanecen separadas.paquete de la unidadParte 25: El ciclo del agua
- Mostrar datos de mapa sin procesar.
- Formamos un clima de células.
- Crea una simulación parcial del ciclo del agua.
En esta parte agregaremos humedad en tierra.Este tutorial fue creado en Unity 2017.3.0.Usamos el ciclo del agua para determinar los biomas.Las nubes
Hasta este punto, el algoritmo de generación de mapas cambió solo la altura de la celda. La mayor diferencia entre las células era si estaban encima o debajo del agua. Aunque podemos definir diferentes tipos de terreno, esto es solo una simple visualización de la altura. Será mejor especificar los tipos de alivio, dado el clima local.El clima de la Tierra es un sistema muy complejo. Afortunadamente, no necesitamos crear simulaciones climáticas realistas. Necesitaremos algo que parezca lo suficientemente natural. El aspecto más importante del clima es el ciclo del agua, porque la flora y la fauna necesitan agua líquida para sobrevivir. La temperatura también es muy importante, pero por ahora, nos enfocamos en el agua, esencialmente dejando la temperatura global constante y cambiando solo la humedad.El ciclo del agua describe el movimiento del agua en el medio ambiente. En pocas palabras, los estanques se evaporan, lo que conduce a la creación de nubes que llueven, que nuevamente fluye hacia los estanques. Hay muchos más aspectos del sistema, pero simular estos pasos ya puede ser suficiente para crear una distribución natural del agua en el mapa.Visualización de datos
Antes de entrar en esta simulación, será útil ver directamente los datos relevantes. Para hacer esto, cambiaremos el sombreador de terreno . Le agregamos una propiedad conmutable, que se puede cambiar al modo de visualización de datos, que muestra datos de mapas sin procesar en lugar de las texturas de relieve habituales. Esto se puede implementar utilizando una propiedad flotante con un atributo conmutable que define la palabra clave. Debido a esto, aparecerá en el inspector de materiales como una bandera que controla la definición de una palabra clave. El nombre de la propiedad en sí no es importante, solo nos interesa la palabra clave. Estamos utilizando 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 }
Cambie para mostrar los datos del mapa.Agregue una función de sombreador para habilitar el soporte de palabras clave. #pragma multi_compile _ GRID_ON #pragma multi_compile _ HEX_MAP_EDIT_MODE #pragma shader_feature SHOW_MAP_DATA
Haremos que muestre un único flotador, como es el caso con el resto de los datos de alivio. Para implementar esto, agregaremos un Input
campo a la estructura mapData
cuando se defina la palabra clave. struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; float4 visibility;
En el programa de vértices, usamos el canal Z de estas celdas para completar mapData
, como siempre interpolado entre celdas. 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 }
Cuando necesite mostrar datos de celdas, úselos directamente como un fragmento de albedo en lugar del color habitual. Multiplíquelo por la cuadrícula para que la cuadrícula todavía esté activada al representar los datos. 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 … }
Para transferir datos a un sombreador. necesitamos agregar al HexCellShaderData
método que escribe algo en el canal de datos de textura azul. Los datos son un valor flotante único limitado a 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; }
Sin embargo, esta decisión afecta el sistema de investigación. Se utiliza un valor de datos de canal azul 255 para indicar que la visibilidad de la celda está en transición. Para que este sistema continúe funcionando, necesitamos utilizar el valor de byte 254 como máximo. Tenga en cuenta que el movimiento del destacamento borrará todos los datos de la tarjeta, pero esto nos conviene, ya que se utilizan para depurar la generación de tarjetas. cellTextureData[cell.Index].b = data < 0f ? (byte)0 : (data < 1f ? (byte)(data * 254f) : (byte)254);
Agregue un método con el mismo nombre y en HexCell
. Transferirá la solicitud a sus datos de sombreador. public void SetMapData (float data) { ShaderData.SetMapData(this, data); }
Para verificar el funcionamiento del código, cámbielo HexMapGenerator.SetTerrainType
para que establezca los datos de cada celda del mapa. Visualicemos la altura convertida de entero a flotante en el intervalo 0-1. Esto se hace restando la altura mínima de la altura de la celda, seguido de dividir por la altura máxima menos el mínimo. Hagamos la división en coma flotante. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData( (cell.Elevation - elevationMinimum) / (float)(elevationMaximum - elevationMinimum) ); } }
Ahora podemos cambiar entre el terreno normal y la visualización de datos utilizando la casilla de verificación Mostrar datos del mapa del activo de material del terreno .Mapa 1208905299, terreno normal y visualización de alturas.Creación del clima
Para simular el clima, necesitamos rastrear los datos climáticos. Dado que el mapa consta de celdas discretas, cada una de ellas tiene su propio clima local. Cree una estructura ClimateData
para almacenar todos los datos relevantes. Por supuesto, puede agregar datos a las celdas, pero los usaremos solo cuando generemos el mapa. Por lo tanto, los guardaremos por separado. Esto significa que podemos definir esta estructura internamente HexMapGenerator
, como MapRegion
. Comenzaremos solo rastreando nubes, que pueden implementarse usando un solo campo flotante. struct ClimateData { public float clouds; }
Agregue una lista para rastrear los datos climáticos de todas las celdas. List<ClimateData> climate = new List<ClimateData>();
Ahora necesitamos un método para crear un mapa climático. Debería comenzar borrando la lista de zonas climáticas y luego agregar un elemento para cada celda. Los datos climáticos iniciales son simplemente cero, esto se puede lograr utilizando un constructor estándar ClimateData
. void CreateClimate () { climate.Clear(); ClimateData initialData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); } }
El clima debe crearse después de la exposición a la erosión de la tierra antes de establecer los tipos de alivio. En realidad, la erosión es causada principalmente por el movimiento del aire y el agua, que son parte del clima, pero no simularemos esto. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); SetTerrainType(); … }
Cambie SetTerrainType
para que podamos ver los datos de la nube en lugar de la altura de la celda. Inicialmente, se verá como una tarjeta negra. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … cell.SetMapData(climate[i].clouds); } }
Clima cambiante
El primer paso en la simulación climática es la evaporación. ¿Cuánta agua debe evaporarse? Controlemos este valor usando el control deslizante. Un valor de 0 significa que no hay evaporación, 1 - evaporación máxima. Por defecto, usamos 0.5. [Range(0f, 1f)] public float evaporation = 0.5f;
Control deslizante de evaporación.Creemos otro método específicamente para dar forma al clima de una celda. Le damos el índice de celda como parámetro y lo usamos para obtener la celda correspondiente y sus datos climáticos. Si la celda está bajo el agua, entonces estamos tratando con un depósito que debe evaporarse. Inmediatamente convertimos el vapor en nubes (ignorando los puntos de rocío y la condensación), por lo que agregaremos directamente la evaporación al valor de las nubes celulares. Cuando haya terminado con esto, copie los datos climáticos nuevamente a la lista. void EvolveClimate (int cellIndex) { HexCell cell = grid.GetCell(cellIndex); ClimateData cellClimate = climate[cellIndex]; if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } climate[cellIndex] = cellClimate; }
Llame a este método para cada celda en CreateClimate
. void CreateClimate () { … for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
Pero esto no es suficiente. Para crear una simulación compleja, necesitamos dar forma al clima de las células varias veces. Cuanto más lo hagamos, mejor será el resultado. Simplemente elijamos un valor constante. Yo uso 40 ciclos. for (int cycle = 0; cycle < 40; cycle++) { for (int i = 0; i < cellCount; i++) { EvolveClimate(i); } }
Dado que solo aumentamos el valor de las nubes sobre las celdas inundadas con agua, como resultado obtenemos tierra negra y depósitos blancos.Evaporación sobre el agua.Dispersión de nubes
Las nubes no están constantemente en un lugar, especialmente cuando se evapora más y más agua. La diferencia de presión hace que el aire se mueva, lo que se manifiesta en forma de viento, lo que también hace que las nubes se muevan.Si no hay una dirección dominante del viento, entonces, en promedio, las nubes de las células se dispersarán uniformemente en todas las direcciones, apareciendo en las células vecinas. Al generar nuevas nubes en el próximo ciclo, distribuyamos todas las nubes en la celda en sus vecinos. Es decir, cada vecino recibe un sexto de las nubes celulares, después de lo cual hay una disminución local a cero. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float cloudDispersal = cellClimate.clouds * (1f / 6f); cellClimate.clouds = 0f; climate[cellIndex] = cellClimate;
Para agregar nubes a sus vecinos, debe rodearlos en un bucle, obtener sus datos climáticos, aumentar el valor de las nubes y copiarlos nuevamente en la lista. 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;
Nubes dispersas.Esto crea un mapa casi blanco, porque en cada ciclo, las células submarinas agregan más y más nubes al clima global. Después del primer ciclo, las células terrestres próximas al agua también tendrán nubes que deben dispersarse. Este proceso continúa hasta que la mayor parte del mapa está cubierto de nubes. En el caso del mapa 1208905299 con los parámetros predeterminados, solo la parte interior de la gran masa de tierra en el noreste permaneció completamente descubierta.Tenga en cuenta que los estanques pueden generar un número infinito de nubes. El nivel del agua no es parte de la simulación climática. En realidad, los reservorios se conservan solo porque el agua fluye hacia ellos aproximadamente a la velocidad de evaporación. Es decir, solo simulamos un ciclo parcial del agua. Esto es normal, pero debemos entender que cuanto más tiempo tenga lugar la simulación, más agua se agrega al clima. Hasta ahora, la pérdida de agua ocurre solo en los bordes del mapa, donde las nubes dispersas se pierden debido a la falta de vecinos.Puede ver la pérdida de agua en la parte superior del mapa, especialmente en las celdas en la parte superior derecha. En la última celda no hay nubes en absoluto, porque sigue siendo la última en la que se forma el clima. Todavía no ha recibido nubes de un vecino.¿No debería formarse el clima de todas las células en paralelo?, . - , . 40 . - , .
Precipitación
El agua no permanece fría para siempre. En algún momento, debería caer al suelo otra vez. Esto generalmente ocurre en forma de lluvia, pero a veces puede ser nieve, granizo o nieve húmeda. Todo esto generalmente se llama precipitación. La magnitud y la tasa de desaparición de las nubes varían mucho, pero solo usamos una tasa de lluvia global personalizada. Un valor de 0 significa que no hay precipitación, un valor de 1 significa que todas las nubes desaparecen instantáneamente. El valor predeterminado es 0.25. Esto significa que en cada ciclo desaparecerá una cuarta parte de las nubes. [Range(0f, 1f)] public float precipitationFactor = 0.25f;
Control deslizante del coeficiente de precipitación.Simularemos precipitación después de la evaporación y antes de la dispersión de las nubes. Esto significará que parte del agua evaporada de los depósitos precipita inmediatamente, por lo que disminuye el número de nubes dispersas. Sobre la tierra, la precipitación conducirá a la desaparición de las nubes. if (cell.IsUnderwater) { cellClimate.clouds += evaporation; } float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; float cloudDispersal = cellClimate.clouds * (1f / 6f);
Nubes que desaparecen.Ahora, cuando destruimos el 25% de las nubes en cada ciclo, la tierra vuelve a ser casi negra. Las nubes logran moverse tierra adentro solo unos pocos pasos, después de lo cual se vuelven invisibles.paquete de la unidadHumedad
Aunque la lluvia destruye las nubes, no deberían eliminar el agua del clima. Después de caer al suelo, el agua se guarda, solo en un estado diferente. Puede existir en muchas formas, que generalmente consideraremos humedad.Seguimiento de la humedad
Vamos a mejorar el modelo climático mediante el seguimiento de dos condiciones del agua: nubes y humedad. Para implementar esto, agregue en el ClimateData
campo moisture
. struct ClimateData { public float clouds, moisture; }
En su forma más generalizada, la evaporación es el proceso de convertir la humedad en nubes, al menos en nuestro modelo climático simple. Esto significa que la evaporación no debe ser un valor constante, sino otro factor. Por lo tanto, realizamos refactorización-cambio de nombre evaporation
a evaporationFactor
. [Range(0f, 1f)] public float evaporationFactor = 0.5f;
Cuando la celda está bajo el agua, simplemente anunciamos que el nivel de humedad es 1. Esto significa que la evaporación es igual al coeficiente de evaporación. Pero ahora también podemos evaporarnos de las celdas de sushi. En este caso, necesitamos calcular la evaporación, restarla de la humedad y agregar el resultado a las nubes. Después de eso, la precipitación se agrega a la humedad. 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;
Dado que las nubes ahora están respaldadas por la evaporación desde la tierra, podemos moverlas más tierra adentro. Ahora la mayor parte de la tierra se ha vuelto gris.Nubes con evaporación de humedad.Cambiémoslo SetTerrainType
para que muestre humedad en lugar de nubes, porque lo usaremos para determinar los tipos de alivio. cell.SetMapData(climate[i].moisture);
Pantalla de humedad.En este punto, la humedad se ve bastante similar a las nubes (excepto que todas las células submarinas son blancas), pero eso cambiará pronto.Escorrentía de lluvia
La evaporación no es la única forma en que la humedad puede salir de la célula. El ciclo del agua nos dice que la mayor parte de la humedad agregada a la tierra de alguna manera termina en el agua. El proceso más notable es el flujo de agua sobre la tierra bajo la influencia de la gravedad. No simularemos ríos reales, pero usaremos un coeficiente de escorrentía de lluvia personalizado. Indicará el porcentaje de agua que drena a las áreas más bajas. Por defecto, el stock será igual al 25%. [Range(0f, 1f)] public float runoffFactor = 0.25f;
Control deslizante de drenaje.¿No vamos a generar ríos?.
La escorrentía del agua actúa como una dispersión de nubes, pero con tres diferencias. En primer lugar, no se elimina toda la humedad de la célula. En segundo lugar, lleva humedad, no nubes. En tercer lugar, baja, es decir, solo a los vecinos con una altura más baja. El coeficiente de escorrentía describe la cantidad de humedad que se derramaría de la celda si todos los vecinos fueran más bajos, pero a menudo son menos. Esto significa que reduciremos la humedad de la celda solo cuando encontremos un vecino debajo. 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; }
Agua que drena a una altura más baja.Como resultado, tenemos una distribución de humedad más diversa, porque las células altas transmiten su humedad a las bajas. También vemos mucha menos humedad en las células costeras, porque drenan la humedad en las células submarinas. Para debilitar este efecto, también necesitamos usar el nivel del agua para determinar si la celda es más baja, es decir, tomar la altura aparente. int elevationDelta = neighbor.ViewElevation - cell.ViewElevation;
Usa la altura visible.Filtración
El agua no solo fluye hacia abajo, se extiende, se filtra a través de la topografía llana, y es absorbida por la tierra adyacente a los cuerpos de agua. Este efecto puede tener poco efecto, pero es útil para suavizar la distribución de humedad, así que vamos a agregarlo a la simulación. Creemos su propio coeficiente personalizado, por defecto igual a 0.125. [Range(0f, 1f)] public float seepageFactor = 0.125f;
Control deslizante de fugas.La filtración es similar a un drenaje, excepto que se usa cuando el vecino tiene la misma altura visible que la celda misma. 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; }
Se agregó una pequeña fuga.paquete de la unidadSombras de lluvia
Aunque ya hemos creado una simulación digna del ciclo del agua, no parece muy interesante, ya que no tiene sombras de lluvia, lo que demuestra claramente las diferencias climáticas. Las sombras de lluvia son áreas en las que hay una falta significativa de lluvia en comparación con las áreas vecinas. Tales áreas existen porque las montañas evitan que las nubes las alcancen. Su creación requiere altas montañas y una dirección dominante del viento.El viento
Comencemos agregando una dirección dominante del viento a la simulación. Aunque las direcciones dominantes del viento varían mucho en la superficie de la Tierra, nos las arreglaremos con una dirección del viento global personalizable. Usemos el noroeste por defecto. Además, hagamos que la fuerza del viento sea ajustable de 1 a 10 con un valor predeterminado de 4. public HexDirection windDirection = HexDirection.NW; [Range(1f, 10f)] public float windStrength = 4f;
La dirección y la fuerza del viento.La fuerza del viento dominante se expresa en relación con la dispersión total de las nubes. Si la fuerza del viento es 1, la dispersión es la misma en todas las direcciones. Cuando es 2, la dispersión es dos más alta en la dirección del viento que en otras direcciones, y así sucesivamente. Podemos hacer esto cambiando el divisor en la fórmula de dispersión de la nube. En lugar de seis, será igual a cinco más energía eólica. float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
Además, la dirección del viento determina la dirección desde la cual sopla el viento. Por lo tanto, necesitamos usar la dirección opuesta como la dirección principal de dispersión. HexDirection mainDispersalDirection = windDirection.Opposite(); float cloudDispersal = cellClimate.clouds * (1f / (5f + windStrength));
Ahora podemos verificar si el vecino está en la dirección principal de dispersión. Si es así, entonces debemos multiplicar la dispersión de las nubes por la fuerza del viento. ClimateData neighborClimate = climate[neighbor.Index]; if (d == mainDispersalDirection) { neighborClimate.clouds += cloudDispersal * windStrength; } else { neighborClimate.clouds += cloudDispersal; }
Viento del noroeste, fuerza 4.El viento dominante agrega direccionalidad a la distribución de humedad sobre la tierra. Cuanto más fuerte es el viento, más poderoso se vuelve el efecto.Altura absoluta
El segundo ingrediente para obtener sombras de lluvia son las montañas. No tenemos una clasificación estricta de lo que es una montaña, así como la naturaleza tampoco la tiene. Solo la altura absoluta es importante. De hecho, cuando el aire se mueve sobre la montaña, se ve obligado a elevarse, se enfría y puede contener menos agua, lo que conduce a la precipitación antes de que el aire pase sobre la montaña. Como resultado, del otro lado obtenemos aire seco, es decir, una sombra de lluvia.Lo más importante, cuanto más alto sube el aire, menos agua puede contener. En nuestra simulación, podemos imaginar esto como una restricción forzada del valor máximo de nube para cada celda. Cuanto mayor sea la altura de la celda visible, menor debería ser este máximo. La forma más fácil de hacer esto es establecer el máximo en 1 menos la altura aparente, dividido por la altura máxima. Pero, de hecho, dividamos por un máximo de menos 1. Esto permitirá que una pequeña fracción de las nubes atraviese incluso las celdas más altas. Asignamos este máximo después de calcular la precipitación y antes de la dispersión. float precipitation = cellClimate.clouds * precipitationFactor; cellClimate.clouds -= precipitation; cellClimate.moisture += precipitation; float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); HexDirection mainDispersalDirection = windDirection.Opposite();
Si como resultado obtenemos más nubes de lo aceptable, simplemente convertimos el exceso de nubes en humedad. De hecho, así es como agregamos precipitaciones adicionales, como sucede en montañas reales. float cloudMaximum = 1f - cell.ViewElevation / (elevationMaximum + 1f); if (cellClimate.clouds > cloudMaximum) { cellClimate.moisture += cellClimate.clouds - cloudMaximum; cellClimate.clouds = cloudMaximum; }
Sombras de lluvia causadas por la gran altitud.paquete de la unidadCompletamos la simulación
En esta etapa, ya tenemos una simulación parcial de muy alta calidad del ciclo del agua. Vamos a ponerlo en orden un poco y luego aplicarlo para determinar el tipo de alivio de las células.Computación paralela
Como se mencionó anteriormente bajo el spoiler, el orden en que se forman las celdas afecta el resultado de la simulación. Idealmente, esto no debería ser y, en esencia, formamos todas las células en paralelo. Esto se puede hacer aplicando todos los cambios de la etapa actual de formación a la segunda lista de clima nextClimate
. List<ClimateData> climate = new List<ClimateData>(); List<ClimateData> nextClimate = new List<ClimateData>();
Borre e inicialice esta lista, como todos los demás. Luego intercambiaremos listas en cada ciclo. En este caso, la simulación utilizará alternativamente las dos listas y aplicará los datos climáticos actuales y futuros. 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; } }
Cuando una célula afecta el clima de su vecino, debemos cambiar los siguientes datos climáticos, no el actual. 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; }
Y en lugar de copiar los siguientes datos climáticos a la lista climática actual, obtenemos los siguientes datos climáticos, les agregamos la humedad actual y los copiamos a la siguiente lista. Después de eso, restablecemos los datos en la lista actual para que se actualicen para el próximo ciclo.
Mientras hacemos esto, también establezcamos el nivel de humedad en un máximo de 1 para que las células terrestres no puedan estar más húmedas que bajo el agua. nextCellClimate.moisture += cellClimate.moisture; if (nextCellClimate.moisture > 1f) { nextCellClimate.moisture = 1f; } nextClimate[cellIndex] = nextCellClimate;
Computación paralela.Humedad original
Existe la posibilidad de que la simulación produzca demasiada tierra seca, especialmente con un alto porcentaje de tierra. Para mejorar la imagen, podemos agregar un nivel de humedad inicial personalizado con un valor predeterminado de 0.1. [Range(0f, 1f)] public float startingMoisture = 0.1f;
Arriba está el control deslizante de la humedad original.Utilizamos este valor para la humedad de la lista climática inicial, pero no para lo siguiente. ClimateData initialData = new ClimateData(); initialData.moisture = startingMoisture; ClimateData clearData = new ClimateData(); for (int i = 0; i < cellCount; i++) { climate.Add(initialData); nextClimate.Add(clearData); }
Con humedad original.Definiendo biomas
Concluimos usando humedad en lugar de altura para especificar el tipo de alivio celular. Usemos nieve para tierras completamente secas, para regiones áridas usamos nieve, luego hay piedra, pasto para lo suficientemente húmedo y tierra para células saturadas de agua y bajo el agua. La forma más fácil es usar cinco intervalos en incrementos 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); } }
BiomasCuando se usa una distribución uniforme, el resultado no es muy bueno y no parece natural. Es mejor usar otros umbrales, por ejemplo 0.05, 0.12, 0.28 y 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; }
Biomas modificados.paquete de la unidadParte 26: biomas y ríos
- Creamos los ríos que se originan a partir de celdas altas con humedad.
- Creamos un modelo de temperatura simple.
- Usamos la matriz de bioma para las células y luego la cambiamos.
En esta parte, complementaremos el ciclo del agua con ríos y temperatura, así como asignaremos biomas más interesantes a las células.El tutorial fue creado usando Unity 2017.3.0p3.El calor y el agua animan el mapa.Generación fluvial
Los ríos son consecuencia del ciclo del agua. De hecho, están formados por escorrentías que se rompen con la ayuda de la erosión del canal. Esto implica que puede agregar ríos según el valor de los drenajes celulares. Sin embargo, esto no garantiza que obtendremos algo que se asemeje a ríos reales. Cuando comencemos el río, tendrá que fluir lo más lejos posible, potencialmente a través de muchas celdas. Esto no es consistente con nuestra simulación del ciclo del agua, que procesa las células en paralelo. Además, generalmente es necesario controlar la cantidad de ríos en un mapa.Como los ríos son muy diferentes, los generaremos por separado. Utilizamos los resultados de la simulación del ciclo del agua para determinar la ubicación de los ríos, pero los ríos, a su vez, no afectarán la simulación.¿Por qué el flujo del río a veces es incorrecto?TriangulateWaterShore
, . , . , , . , . , , . («»).
void TriangulateWaterShore ( HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center ) { … if (cell.HasRiverThroughEdge(direction)) { TriangulateEstuary( e1, e2, cell.HasIncomingRiver && cell.IncomingRiver == direction, indices ); } … }
Celdas de alta humedad
En nuestros mapas, una celda puede o no tener un río. Además, pueden ramificarse o conectarse. En realidad, los ríos son mucho más flexibles, pero tenemos que pasar con esta aproximación, que crea solo ríos grandes. Lo más importante, necesitamos determinar la ubicación del comienzo de un río grande, que se elige al azar.Como los ríos requieren agua, la fuente del río debe estar en una celda con alta humedad. Pero esto no es suficiente. Los ríos fluyen por las laderas, por lo que idealmente la fuente debería tener una gran altura. Cuanto mayor sea la celda por encima del nivel del agua, mejor candidato es para el papel de la fuente del río. Podemos visualizar esto como datos del mapa dividiendo la altura de la celda por la altura máxima. Para que el resultado se obtenga en relación con el nivel del agua, lo restaremos de ambas alturas antes de dividirlo. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { … float data = (float)(cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data); } }
Humedad y altitud. Gran número de mapa 1208905299 con la configuración predeterminada.Los mejores candidatos son aquellas células que tienen alta humedad y altura. Podemos combinar estos criterios multiplicándolos. El resultado será el valor de la condición física o el peso de las fuentes de los ríos. float data = moisture * (cell.Elevation - waterLevel) / (elevationMaximum - waterLevel); cell.SetMapData(data);
Pesos para las fuentes de los ríos.Idealmente, usaríamos estos pesos para rechazar la selección aleatoria de la celda fuente. Aunque podemos crear una lista con los pesos correctos y elegir, este es un enfoque no trivial y ralentiza el proceso de generación. Una clasificación más simple de importancia dividida en cuatro niveles será suficiente para nosotros. Los primeros candidatos serán pesos con valores superiores a 0,75. Los buenos candidatos tienen pesos de 0.5. Los candidatos elegibles son mayores que 0.25. Todas las demás células se descartan. Vamos a mostrar cómo se ve gráficamente. 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); }
Categorías de pesos de las fuentes fluviales.Con este esquema de clasificación, es probable que obtengamos ríos con fuentes en las áreas más altas y húmedas del mapa. Sin embargo, la probabilidad de crear ríos en áreas relativamente secas o bajas permanece, lo que aumenta la variabilidad.Agregue un método CreateRivers
que llene una lista de celdas según estos criterios. Las celdas elegibles se agregan a esta lista una vez, las buenas dos veces y las principales candidatas cuatro veces. Las celdas submarinas siempre se descartan, por lo que no puede verificarlas. 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); }
Este método debe llamarse después CreateClimate
para que los datos de humedad estén disponibles para nosotros. public void GenerateMap (int x, int z) { … CreateRegions(); CreateLand(); ErodeLand(); CreateClimate(); CreateRivers(); SetTerrainType(); … }
Una vez completada la clasificación, puede deshacerse de la visualización de sus datos en el mapa. void SetTerrainType () { for (int i = 0; i < cellCount; i++) { …
Puntos de río
¿Cuántos ríos necesitamos? Este parámetro debe ser personalizable. Dado que la longitud de los ríos varía, será más lógico controlarlo con la ayuda de puntos fluviales, que determinan la cantidad de celdas terrestres en las que deben estar contenidos los ríos. Expresémoslos como un porcentaje con un máximo del 20% y un valor predeterminado del 10%. Al igual que el porcentaje de sushi, este es un valor objetivo, no garantizado. Como resultado, podemos tener muy pocos candidatos o ríos que sean demasiado cortos para cubrir la cantidad de tierra requerida. Es por eso que el porcentaje máximo no debe ser demasiado grande. [Range(0, 20)] public int riverPercentage = 10;
Control deslizante por ciento de ríos.Para determinar los puntos de los ríos, expresados como el número de celdas, debemos recordar cuántas celdas terrestres se generaron 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; } }
En el interior, la CreateRivers
cantidad de puntos de río ahora se puede calcular de la misma manera que nosotros 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); }
Además, continuaremos tomando y eliminando celdas aleatorias de la lista original, mientras todavía tengamos puntos y celdas de origen. En caso de completar el número de puntos, mostraremos una advertencia en la consola. 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."); }
Además, agregamos un método para crear ríos directamente. Como parámetro, necesita una celda inicial y, una vez finalizado, debe devolver la longitud del río. Comenzamos almacenando un método que devuelve longitud cero. int CreateRiver (HexCell origin) { int length = 0; return length; }
Llamaremos a este método al final del ciclo que acabamos de agregar CreateRivers
, utilizando para reducir el número de puntos restantes. Nos aseguramos de que se cree un nuevo río solo si la celda seleccionada no tiene un río que fluya a través de él. while (riverBudget > 0 && riverOrigins.Count > 0) { … if (!origin.HasRiver) { riverBudget -= CreateRiver(origin); } }
Ríos actuales
Es lógico crear ríos que fluyan hacia el mar u otro cuerpo de agua. Cuando comenzamos desde la fuente, obtenemos inmediatamente la longitud 1. Después de eso, seleccionamos un vecino aleatorio y aumentamos la longitud. Continuamos moviéndonos hasta llegar a la celda submarina. 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; }
Ríos al azar.Como resultado de un enfoque tan ingenuo, obtenemos fragmentos de ríos dispersos al azar, principalmente debido al reemplazo de ríos generados previamente. Esto incluso puede conducir a errores, porque no verificamos si el vecino realmente existe. Necesitamos verificar todas las direcciones en el bucle y asegurarnos de que haya un vecino allí. Si es así, entonces agregamos esta dirección a la lista de posibles direcciones de flujo, pero solo si el río aún no fluye a través de este vecino. Luego seleccione un valor aleatorio de esta lista. 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 =
Con este nuevo enfoque, podemos tener cero direcciones de flujo disponibles. Cuando esto sucede, el río ya no puede fluir más y debe terminar. Si en este momento la longitud es 1, esto significa que no podríamos escapar de la celda original, es decir, no puede haber ningún río. En este caso, la longitud del río es cero. flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … } if (flowDirections.Count == 0) { return length > 1 ? length : 0; }
Rios preservados.Correr hacia abajo
Ahora salvamos los ríos ya creados, pero aún podemos obtener fragmentos aislados de los ríos. Esto sucede porque mientras ignoramos las alturas. Cada vez que forzamos el flujo del río a una altura mayor, HexCell.SetOutgoingRiver
interrumpimos este intento, lo que provocó rupturas en los ríos. Por lo tanto, también debemos omitir las direcciones que hacen que los ríos fluyan hacia arriba. if (!neighbor || neighbor.HasRiver) { continue; } int delta = neighbor.Elevation - cell.Elevation; if (delta > 0) { continue; } flowDirections.Add(d);
Ríos que fluyen hacia abajo.Así que nos deshacemos de muchos fragmentos de ríos, pero aún quedan algunos. A partir de este momento, deshacerse de los ríos más feos se convierte en una cuestión de refinamiento. Para empezar, los ríos prefieren descender lo más rápido posible. No elegirán necesariamente la ruta más corta posible, pero la probabilidad de que esto sea grande. Para simular esto, agregaremos instrucciones tres veces a la lista. if (delta > 0) { continue; } if (delta < 0) { flowDirections.Add(d); flowDirections.Add(d); flowDirections.Add(d); } flowDirections.Add(d);
Evite giros bruscos
Además de fluir hacia abajo, el agua también tiene inercia. Es más probable que un río fluya en línea recta o se doble ligeramente que en un giro brusco repentino. Podemos agregar esta distorsión rastreando la última dirección del río. Si la dirección potencial de la corriente no se desvía demasiado de esta dirección, agréguela nuevamente a la lista. Esto no es un problema para la fuente, por lo que siempre lo agregaremos nuevamente. 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; }
Esto reduce en gran medida la probabilidad de que los ríos zigzagueen con un aspecto feo.Menos giros bruscos.Confluencia fluvial
A veces resulta que el río fluye justo al lado de la fuente del río creado anteriormente. Si la fuente de este río no está a una altitud más alta, entonces podemos decidir que el nuevo río desemboca en el antiguo. Como resultado, obtenemos un río largo, y no dos vecinos.Para hacer esto, dejaremos pasar al vecino solo si hay un río entrante o si es la fuente del río actual. Habiendo determinado que esta dirección no está arriba, verificamos si hay un río saliente. Si lo hay, encontramos nuevamente el viejo río. Dado que esto ocurre con bastante poca frecuencia, no participaremos en la verificación de otras fuentes vecinas e inmediatamente combinaremos los ríos. HexCell neighbor = cell.GetNeighbor(d);
Ríos antes y después de la agrupación.Mantener distancia
Dado que los buenos candidatos para el rol fuente generalmente se agrupan, obtendremos grupos de ríos. Además, podemos tener ríos que toman la fuente justo al lado del embalse, lo que da como resultado ríos de longitud 1. Podemos distribuir las fuentes, descartando las que están cerca del río o embalse. Hacemos esto sin pasar por los vecinos de la fuente seleccionada en un bucle dentro CreateRivers
. Si encontramos un vecino que viola las reglas, entonces la fuente no nos conviene y debemos omitirla. 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); } }
Y aunque los ríos seguirán fluyendo uno al lado del otro, tienden a cubrir un área más grande.Sin distancia y con ello.Terminamos el rio con un lago
No todos los ríos llegan al embalse, algunos quedan atrapados en los valles o son bloqueados por otros ríos. Este no es un problema particular, porque a menudo los ríos reales también parecen desaparecer. Esto puede suceder, por ejemplo, si fluyen bajo tierra, se dispersan en un área pantanosa o se secan. Nuestros ríos no pueden visualizar esto, por lo que simplemente terminan.Sin embargo, podemos intentar minimizar el número de tales casos. Aunque no podemos unir los ríos o hacer que fluyan hacia arriba, podemos hacer que terminen en lagos, que a menudo se encuentran en la realidad y se ven bien. Para estoCreateRiver
debería elevar el nivel del agua en la celda si se atasca. La posibilidad de esto depende de la altura mínima de los vecinos de esta celda. Por lo tanto, para rastrear esto cuando se estudian vecinos, se requiere una pequeña alteración del código. while (!cell.IsUnderwater) { int minNeighborElevation = int.MaxValue; flowDirections.Clear(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = cell.GetNeighbor(d);
Si estamos atascados, primero debemos comprobar si todavía estamos en la fuente. En caso afirmativo, simplemente cancele el río. De lo contrario, verificamos si todos los vecinos son al menos tan altos como la celda actual. Si es así, entonces podemos elevar el agua a este nivel. Esto creará un lago a partir de una celda, a menos que la altura de la celda permanezca en el mismo nivel. Si es así, simplemente asigne la altura un nivel por debajo del nivel del agua. if (flowDirections.Count == 0) {
Los extremos de ríos sin lagos y con lagos. En este caso, el porcentaje de ríos es 20.Tenga en cuenta que ahora podemos tener células submarinas por encima del nivel de agua utilizado para generar el mapa. Denotarán lagos sobre el nivel del mar.Lagos adicionales
También podemos crear lagos, incluso si no estamos atascados. Esto puede resultar en un río que fluye dentro y fuera del lago. Si no estamos atascados, se puede crear un lago elevando el nivel del agua y luego la altura actual de la celda, y luego reduciendo la altura de la celda. Esto se aplica solo cuando la altura mínima del vecino es al menos igual a la altura de la celda actual. Hacemos esto al final del ciclo del río y antes de pasar a la siguiente celda. while (!cell.IsUnderwater) { … if (minNeighborElevation >= cell.Elevation) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; } cell = cell.GetNeighbor(direction); }
Sin lagos adicionales y con ellos.Varios lagos son hermosos, pero sin límites podemos crear demasiados lagos. Por lo tanto, agreguemos una probabilidad personalizada para lagos adicionales, con un valor predeterminado de 0.25. [Range(0f, 1f)] public float extraLakeProbability = 0.25f;
Ella controlará la probabilidad de generar un lago adicional, si es posible. if ( minNeighborElevation >= cell.Elevation && Random.value < extraLakeProbability ) { cell.WaterLevel = cell.Elevation; cell.Elevation -= 1; }
Lagos adicionales.¿Qué hay de crear lagos con más de una celda?, , , . . : . , . , , , .
paquete de la unidadTemperatura
El agua es solo uno de los factores que pueden determinar el bioma de una célula. Otro factor importante es la temperatura. Aunque podemos simular el flujo y la difusión de temperaturas como la simulación del agua, para crear un clima interesante, solo necesitamos un factor complejo. Por lo tanto, mantengamos la temperatura simple y configurémosla para cada celda.Temperatura y latitud
La mayor influencia en la temperatura es la latitud. Hace calor en el ecuador, frío en los polos y hay una transición suave entre ellos. Creemos un método DetermineTemperature
que devuelva la temperatura de una celda determinada. Para comenzar, simplemente usamos la coordenada Z de la celda dividida por la dimensión Z como la latitud, y luego usamos este valor como la temperatura. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; return latitude; }
Definimos la temperatura en SetTerrainType
y la usamos como datos del mapa. 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; … } }
Latitud como temperatura, hemisferio sur.Obtenemos un gradiente de temperatura lineal que aumenta de abajo hacia arriba. Puede usarlo para simular el hemisferio sur, con un polo en la parte inferior y un ecuador en la parte superior. Pero no necesitamos describir todo el hemisferio. Con una diferencia de temperatura más pequeña o ninguna diferencia, podemos describir un área más pequeña. Para hacer esto, haremos que las temperaturas bajas y altas sean personalizables. Estableceremos estas temperaturas en el rango 0-1, y utilizaremos los valores extremos como valores predeterminados. [Range(0f, 1f)] public float lowTemperature = 0f; [Range(0f, 1f)] public float highTemperature = 1f;
Deslizadores de temperatura.Aplicamos el rango de temperatura usando interpolación lineal, usando la latitud como interpolador. Como expresamos la latitud como un valor de 0 a 1, podemos usarlo Mathf.LerpUnclamped
. float DetermineTemperature (HexCell cell) { float latitude = (float)cell.coordinates.Z / grid.cellCountZ; float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); return temperature; }
Tenga en cuenta que las bajas temperaturas no son necesariamente más bajas que las altas. Si lo desea, puede entregarlos.Hemisferio
Ahora podemos simular el hemisferio sur, y posiblemente el hemisferio norte, si tomamos las temperaturas primero. Pero es mucho más conveniente usar una opción de configuración separada para cambiar entre hemisferios. Vamos a crear una enumeración y un campo para ello. Por lo tanto, también agregaremos la opción de crear ambos hemisferios, que es aplicable por defecto. public enum HemisphereMode { Both, North, South } public HemisphereMode hemisphere;
La elección del hemisferio.Si necesitamos el hemisferio norte, entonces simplemente podemos voltear la latitud, restándola de 1. Para simular ambos hemisferios, los polos deben estar debajo y encima del mapa, y el ecuador debe estar en el medio. Puede hacer esto duplicando la latitud, mientras que el hemisferio inferior se procesará correctamente, y el superior tendrá una latitud de 1 a 2. Para arreglar esto, restamos la latitud de 2 cuando excede 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; }
Ambos hemisferios.Vale la pena señalar que esto crea la posibilidad de crear un mapa exótico en el que el ecuador está frío y los polos están calientes.Cuanto más alto es el más frío
Además de la latitud, la temperatura también se ve significativamente afectada por la altitud. En promedio, cuanto más subimos, más frío se pone. Podemos convertir esto en un factor, como hicimos con los candidatos del river. En este caso, usamos la altura de la celda. Además, este indicador disminuye con la altura, es decir, igual a 1 menos la altura dividida por el máximo relativo al nivel del agua. Para que el indicador en el nivel más alto no caiga a cero, agregamos al divisor. Luego use este indicador para escalar la temperatura. float temperature = Mathf.LerpUnclamped(lowTemperature, highTemperature, latitude); temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); return temperature;
La altura afecta la temperatura.Fluctuaciones de temperatura
Podemos hacer que la simplicidad del gradiente de temperatura sea menos notable agregando fluctuaciones aleatorias de temperatura. Una pequeña posibilidad de hacerlo más realista, pero con demasiada fluctuación, se verán arbitrarios. Hagamos que el poder de las fluctuaciones de temperatura sea personalizable y expresémoslo como la desviación de temperatura máxima con un valor predeterminado de 0.1. [Range(0f, 1f)] public float temperatureJitter = 0.1f;
Control deslizante de fluctuación de temperatura.Tales fluctuaciones deberían ser suaves con ligeros cambios locales. Puedes usar nuestra textura de ruido para esto. Llamaremos HexMetrics.SampleNoise
y usaremos como argumento la posición de la celda, escalada en 0.1. Tomemos el canal W, centremos y escalemos por el coeficiente de oscilación. Luego agregamos este valor a la temperatura calculada previamente. temperature *= 1f - (cell.ViewElevation - waterLevel) / (elevationMaximum - waterLevel + 1f); temperature += (HexMetrics.SampleNoise(cell.Position * 0.1f).w * 2f - 1f) * temperatureJitter; return temperature;
Fluctuaciones de temperatura con valores de 0.1 y 1.Podemos agregar una ligera variabilidad a las fluctuaciones en cada mapa, eligiendo aleatoriamente de los cuatro canales de ruido. Establezca el canal una vez SetTerrainType
y luego indexe los canales de color 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; }
Diferentes fluctuaciones de temperatura con fuerza máxima.paquete de la unidadBiomas
Ahora que tenemos datos sobre humedad y temperatura, podemos crear una matriz de bioma. Al indexar esta matriz, podemos asignar biomas a todas las celdas, creando un paisaje más complejo que usar solo una dimensión de datos.Matriz de bioma
Existen muchos modelos climáticos, pero no utilizaremos ninguno de ellos. Lo haremos muy simple, solo nos interesa la lógica. Seco significa desierto (frío o calor), para ello utilizamos arena. Frío y húmedo significa nieve. Caliente y húmedo significa mucha vegetación, es decir, hierba. Entre ellos tendremos una taiga o tundra, que designaremos como una textura grisácea de la tierra. Una matriz 4 × 4 será suficiente para crear transiciones entre estos biomas.Anteriormente, asignamos tipos de elevación basados en cinco intervalos de humedad. Simplemente bajamos la tira más seca a 0.05 y guardamos el resto. Para las bandas de temperatura usamos 0.1, 0.3, 0.6 y más. Por conveniencia, estableceremos estos valores en matrices estáticas. static float[] temperatureBands = { 0.1f, 0.3f, 0.6f }; static float[] moistureBands = { 0.12f, 0.28f, 0.85f };
Aunque especificamos solo el tipo de relieve en función del bioma, podemos usarlo para determinar otros parámetros. Por lo tanto, definamos en una HexMapGenerator
estructura Biome
que describa la configuración de un bioma individual. Hasta ahora, contiene solo el índice de relieve más el método del constructor correspondiente. struct Biome { public int terrain; public Biome (int terrain) { this.terrain = terrain; } }
Utilizamos esta estructura para crear una matriz estática que contiene datos de matriz. Usamos la humedad como la coordenada X y la temperatura como Y. Llenamos la línea con la temperatura más baja con nieve, la segunda línea con tundra y las otras dos con hierba. Luego reemplazamos la columna más seca con el desierto, redefiniendo la elección de temperatura. 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) };
Matriz de biomas con índices de una matriz unidimensional.Definición de bioma
Para determinar las SetTerrainType
células en el bioma, recorreremos los rangos de temperatura y humedad en el ciclo para determinar los índices de matriz que necesitamos. Los usamos para obtener el bioma deseado y especificar el tipo de topografía celular. void SetTerrainType () { temperatureJitterChannel = Random.Range(0, 4); for (int i = 0; i < cellCount; i++) { HexCell cell = grid.GetCell(i); float temperature = DetermineTemperature(cell);
Alivio basado en una matriz de bioma.Configuración de bioma
Podemos ir más allá de los biomas definidos en la matriz. Por ejemplo, en la matriz, todos los biomas secos se definen como desiertos de arena, pero no todos los desiertos secos están llenos de arena. Hay muchos desiertos rocosos que se ven muy diferentes. Por lo tanto, reemplacemos algunas de las células del desierto con piedras. Haremos esto simplemente en función de la altura: la arena se encuentra a baja altitud, y las rocas desnudas generalmente se encuentran arriba.Suponga que la arena se convierte en piedra cuando la altura de la celda está más cerca de la altura máxima que del nivel del agua. Esta es la línea de altura de los desiertos rocosos que podemos calcular al principio SetTerrainType
. Cuando nos encontramos con una celda con arena, y su altura es lo suficientemente grande, cambiamos el relieve del bioma a piedra. 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; } } }
Desiertos arenosos y rocosos.Otro cambio basado en la altura es forzar a las celdas a la altura máxima a convertirse en picos de nieve, independientemente de su temperatura, solo si no están demasiado secas. Esto aumentará la probabilidad de picos de nieve cerca del ecuador cálido y húmedo. if (cellBiome.terrain == 0) { if (cell.Elevation >= rockDesertElevation) { cellBiome.terrain = 3; } } else if (cell.Elevation == elevationMaximum) { cellBiome.terrain = 4; }
Gorros de nieve a la altura máxima.Plantas
Ahora hagamos que los biomas determinen el nivel de células vegetales. Para hacer esto, agregue al Biome
campo de plantas e inclúyalo en el constructor. struct Biome { public int terrain, plant; public Biome (int terrain, int plant) { this.terrain = terrain; this.plant = plant; } }
En los biomas más fríos y secos no habrá plantas. En todos los demás aspectos, cuanto más cálido y húmedo sea el clima, más plantas. La segunda columna de humedad recibe solo el primer nivel de plantas para la hilera más caliente, por lo tanto [0, 0, 0, 1]. La tercera columna aumenta los niveles en uno, con la excepción de la nieve, es decir, [0, 1, 1, 2]. Y la columna más húmeda los vuelve a aumentar, es decir, resulta [0, 2, 2, 3]. Cambie la matriz biomes
agregando la configuración de la planta. 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) };
Matriz de biomas con niveles de plantas.Ahora podemos establecer el nivel de plantas para la célula. cell.TerrainTypeIndex = cellBiome.terrain; cell.PlantLevel = cellBiome.plant;
Biomas con plantas.¿Las plantas ahora se ven diferentes?, . (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).
Podemos cambiar el nivel de plantas para biomas. Primero debemos asegurarnos de que no aparezcan en el terreno nevado, que ya podríamos configurar. En segundo lugar, aumentemos el nivel de las plantas a lo largo de los ríos, si aún no está al máximo. 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;
Plantas modificadas.Biomas submarinos
Hasta ese momento, ignoramos por completo las células submarinas. Agreguemos una pequeña variación a ellos, y no usaremos la textura de la tierra para todos ellos. Una solución simple basada en la altura ya será suficiente para crear una imagen más interesante. Por ejemplo, usemos pasto para las celdas un paso por debajo del nivel del agua. También usemos pasto para celdas sobre el nivel del agua, es decir, para lagos creados por ríos. Las celdas con una altura negativa son áreas de aguas profundas, por lo que usamos piedra para ellas. Todas las demás células permanecen molidas. 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; } } }
Variabilidad submarina.Agreguemos algunos detalles más para las células submarinas a lo largo de la costa. Estas son células con al menos un vecino sobre el agua. Si dicha celda es poco profunda, crearemos una playa. Y si está al lado del acantilado, será el detalle visual dominante, y usaremos la piedra.Para determinar esto, verificaremos los vecinos de las celdas ubicadas un paso por debajo del nivel del agua. Cuentemos el número de conexiones por acantilados y pendientes con celdas terrestres vecinas. 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; }
Ahora podemos usar esta información para clasificar las celdas. En primer lugar, si más de la mitad de los vecinos son terrestres, entonces estamos tratando con un lago o una bahía. Para estas células usamos una textura de hierba. De lo contrario, si tenemos acantilados, entonces usamos piedra. De lo contrario, si tenemos pendientes, entonces usamos arena para crear una playa. La única opción restante es un área poco profunda frente a la costa, para la cual todavía usamos césped. 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; } }
Variabilidad de la costa.Como toque final, verifiquemos que no tengamos células verdes bajo el agua en el rango de temperatura más frío. Para tales células usamos la tierra. if (terrain == 1 && temperature < temperatureBands[0]) { terrain = 2; } cell.TerrainTypeIndex = terrain;
Tuvimos la oportunidad de generar tarjetas aleatorias que parecen bastante interesantes y naturales, con muchas opciones de configuración.paquete de la unidadParte 27: doblar una tarjeta
- Dividimos las tarjetas en columnas que se pueden mover.
- Centre la tarjeta en la cámara.
- Derrumbamos todo.
En esta última parte, agregaremos soporte para minimizar el mapa, conectando los bordes este y oeste.El tutorial fue creado usando Unity 2017.3.0p3.El plegado hace que el mundo gire.Tarjetas plegables
Nuestros mapas se pueden usar para modelar áreas de diferentes tamaños, pero siempre se limitan a una forma rectangular. Podemos crear un mapa de una isla o un continente entero, pero no todo el planeta. Los planetas son esféricos, no tienen límites rígidos que impidan el movimiento en su superficie. Si continúa moviéndose en una dirección, tarde o temprano volverá al punto de partida.No podemos envolver una cuadrícula de hexágonos alrededor de una esfera; tal superposición es imposible. En las mejores aproximaciones, se utiliza la topología icosaédrica, en la que las doce células deben ser pentágonos. Sin embargo, sin ninguna distorsión o excepción, la malla se puede enrollar alrededor del cilindro. Para hacer esto, solo conecta los bordes este y oeste del mapa. Con la excepción de la lógica de ajuste, todo lo demás permanece igual.Un cilindro es una aproximación pobre de una esfera, porque no podemos modelar polos. Pero esto no impidió que los desarrolladores de muchos juegos usaran el plegado de este a oeste para modelar mapas planetarios. Las regiones polares simplemente no son parte de la zona de juego.¿Qué hay de girar hacia el norte y el sur?, . , , . -, -. .
Hay dos formas de implementar el plegado cilíndrico. La primera es hacer que el mapa sea cilíndrico doblando su superficie y todo lo que está sobre él para que los bordes este y oeste estén en contacto. Ahora jugarás no en una superficie plana, sino en un cilindro real. El segundo enfoque es guardar un mapa plano y usar teletransportación o duplicación para colapsar. La mayoría de los juegos usan el segundo enfoque, así que lo tomaremos.Plegable opcional
La necesidad de colapsar el mapa depende de su escala: local o planetaria. Podemos usar el soporte de ambos haciendo que el plegado sea opcional. Para hacer esto, agregue un nuevo interruptor al menú Crear nuevo mapa con el colapso activado de forma predeterminada.El menú del nuevo mapa con la opción de colapsar.Agregue al NewMapMenu
campo para rastrear la selección, así como un método para cambiarla. Hagamos que se invoque este método cuando cambie el estado del interruptor. bool wrapping = true; … public void ToggleWrapping (bool toggle) { wrapping = toggle; }
Cuando se solicita un nuevo mapa, pasamos el valor de la opción minimizar. void CreateMap (int x, int z) { if (generateMaps) { mapGenerator.GenerateMap(x, z, wrapping); } else { hexGrid.CreateMap(x, z, wrapping); } HexMapCamera.ValidatePosition(); Close(); }
Cámbielo HexMapGenerator.GenerateMap
para que acepte este nuevo argumento y luego lo pase a HexGrid.CreateMap
. public void GenerateMap (int x, int z, bool wrapping) { … grid.CreateMap(x, z, wrapping); … }
code> HexGrid debería saber si estamos colapsando, así que agréguele un campo y configúrelo CreateMap
. Otras clases deberían cambiar su lógica dependiendo de si la cuadrícula está minimizada, por lo que haremos que el campo sea general. Además, le permite establecer el valor predeterminado a través del inspector. 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
llamadas propias CreateMap
en dos lugares. Simplemente podemos usar su propio campo para el argumento de colapso. void Awake () { … CreateMap(cellCountX, cellCountZ, wrapping); } … public void Load (BinaryReader reader, int header) { … if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z, wrapping)) { return; } } … }
El interruptor de rejilla plegable está activado de forma predeterminada.Guardar y cargar
Como el plegado está configurado para cada tarjeta, debe guardarse y cargarse. Esto significa que debe cambiar el formato de guardado del archivo, así que aumente la versión constante en SaveLoadMenu
. const int mapFileVersion = 5;
Al guardar, deje HexGrid
que escriba el valor de plegado booleano después del tamaño del mapa. public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); writer.Write(wrapping); … }
Al cargar, lo leeremos solo con la versión correcta del archivo. Si es diferente, entonces esta es una tarjeta vieja y no debe minimizarse. Guarde esta información en una variable local y compárela con el estado actual de plegado. Si es diferente, entonces no podemos reutilizar la topología de mapa existente de la misma manera que lo haría al cargar un mapa con otros tamaños. 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; } } … }
Métricas plegables
Minimizar el mapa requerirá cambios importantes en la lógica, por ejemplo, al calcular distancias. Por lo tanto, pueden tocar el código que no tiene un enlace directo a la cuadrícula. En lugar de pasar esta información como argumentos, añádala a HexMetrics
. Agregue un número entero estático que contenga el tamaño de plegado que coincida con el ancho del mapa. Si es mayor que cero, entonces estamos tratando con una tarjeta plegable. Para verificar esto, agregue una propiedad. public static int wrapSize; public static bool Wrapping { get { return wrapSize > 0; } }
Necesitamos establecer el tamaño de plegado para cada llamada HexGrid.CreateMap
. public bool CreateMap (int x, int z, bool wrapping) { … this.wrapping = wrapping; HexMetrics.wrapSize = wrapping ? cellCountX : 0; … }
Dado que estos datos no sobrevivirán a la compilación en el modo Play, lo configuraremos OnEnable
. void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexUnit.unitPrefab = unitPrefab; HexMetrics.wrapSize = wrapping ? cellCountX : 0; ResetVisibility(); } }
Ancho de la celda
Cuando trabajamos con tarjetas plegables, a menudo tenemos que lidiar con posiciones a lo largo del eje X, medidas en el ancho de las celdas. Aunque se puede usar para esto HexMetrics.innerRadius * 2f
, sería más conveniente si no tuviéramos que multiplicar cada vez. Entonces agreguemos una constante HexMetrics.innerDiameter
. public const float innerRadius = outerRadius * outerToInner; public const float innerDiameter = innerRadius * 2f;
Ya podemos usar el diámetro en tres lugares. En primer lugar, HexGrid.CreateCell
al posicionar una nueva celda. void CreateCell (int x, int z, int i) { Vector3 position; position.x = (x + z * 0.5f - z / 2) * HexMetrics.innerDiameter; … }
En segundo lugar, en HexMapCamera
limitar la posición de la cámara. Vector3 ClampPosition (Vector3 position) { float xMax = (grid.cellCountX - 0.5f) * HexMetrics.innerDiameter; position.x = Mathf.Clamp(position.x, 0f, xMax); … }
Y también en la HexCoordinates
conversión de posición a coordenadas. public static HexCoordinates FromPosition (Vector3 position) { float x = position.x / HexMetrics.innerDiameter; … }
paquete de la unidadCentrado de tarjeta
Cuando el mapa no colapsa, tiene claramente definidos los bordes este y oeste, y por lo tanto un centro horizontal claro. Pero en el caso de una tarjeta plegable, todo es diferente. No tiene el borde oriental ni occidental ni el centro. Como alternativa, podemos suponer que el centro está donde está la cámara. Esto será útil porque queremos que el mapa siempre esté centrado en nuestro punto de vista. Entonces, donde sea que estemos, no veremos los bordes este u oeste del mapa.Columnas de fragmentos de mapa
Para que la visualización del mapa se centre en relación con la cámara, debemos cambiar la ubicación de los elementos según el movimiento de la cámara. Si se mueve hacia el oeste, entonces debemos tomar lo que está actualmente en el borde de la parte oriental y moverlo al borde de la parte occidental. Lo mismo se aplica a la dirección opuesta.Idealmente, tan pronto como la cámara se mueva a la columna de celdas vecina, debemos mover inmediatamente la columna de celdas más alejada al otro lado. Sin embargo, no necesitamos ser tan precisos. En cambio, podemos transferir fragmentos de mapas completos. Esto nos permite mover partes del mapa sin tener que modificar las mallas.Como estamos moviendo columnas enteras de fragmentos al mismo tiempo, agrupémoslas creando un objeto de columna principal para cada grupo. Agregue una matriz para estos objetos HexGrid
y la inicializaremos CreateChunks
. Los usaremos solo como contenedores, por lo que solo necesitamos rastrear el enlace a sus componentes Transform
. Como en el caso de los fragmentos, sus posiciones iniciales se ubican en el origen local de las coordenadas de la cuadrícula. 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); } … }
Ahora el fragmento debería convertirse en hijo de la columna correspondiente, no de la cuadrícula. 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); } } }
Fragmentos agrupados en columnas.Dado que todos los fragmentos se han convertido en hijos de las columnas, CreateMap
es suficiente que destruyamos directamente todas las columnas, no los fragmentos. Entonces nos desharemos de los fragmentos de hija. public bool CreateMap (int x, int z, bool wrapping) { … if (columns != null) { for (int i = 0; i < columns.Length; i++) { Destroy(columns[i].gameObject); } } … }
Columnas de teletransporte
Agregue al HexGrid
nuevo método CenterMap
con la posición X como parámetro. Convierta la posición al índice de la columna, dividiéndola por el ancho del fragmento en unidades Unity. Este será el índice de la columna en la que se encuentra actualmente la cámara, es decir, será la columna central del mapa. public void CenterMap (float xPosition) { int centerColumnIndex = (int) (xPosition / (HexMetrics.innerDiameter * HexMetrics.chunkSizeX)); }
Es suficiente para nosotros cambiar la visualización del mapa solo cuando cambia el índice de la columna central. Así que vamos a seguirlo en el campo. Usamos el valor predeterminado −1 cuando creamos un mapa para que los nuevos mapas siempre estén centrados. 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; }
Ahora que conocemos el índice de la columna central, podemos determinar los índices mínimo y máximo simplemente restando y sumando la mitad del número de columnas. Como utilizamos valores enteros, con un número impar de columnas, esto funciona perfectamente. En el caso de un número par, no puede haber una columna perfectamente centrada, por lo que uno de los índices estará un paso más allá de lo necesario. Esto crea un desplazamiento de una columna en la dirección del borde más alejado del mapa, pero para nosotros esto no es un problema. currentCenterColumnIndex = centerColumnIndex; int minColumnIndex = centerColumnIndex - chunkCountX / 2; int maxColumnIndex = centerColumnIndex + chunkCountX / 2;
Tenga en cuenta que estos índices pueden ser negativos o mayores que el índice de columna máximo natural. El mínimo es cero solo cuando la cámara está cerca del centro natural del mapa. Nuestra tarea es mover las columnas para que se correspondan con estos índices relativos. Esto se puede hacer cambiando la coordenada X local de cada columna en el bucle. 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; }
Para cada columna, verificamos si el índice del índice mínimo es menor. Si es así, entonces está demasiado lejos a la izquierda del centro. Debe teletransportarse al otro lado del mapa. Esto se puede hacer haciendo que su coordenada X sea igual al ancho del mapa. Del mismo modo, si el índice de la columna es mayor que el índice máximo, entonces está demasiado lejos a la derecha del centro y debería teletransportarse al otro lado. 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; }
Movimiento de la cámara
Cambie HexMapCamera.AdjustPosition
para que cuando trabaje con una tarjeta plegable, en lugar de eso, ClampPosition
llame WrapPosition
. Primero, simplemente haga que el nuevo método sea un WrapPosition
duplicado ClampPosition
, pero con la única diferencia: al final, llamará 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; }
Para que la tarjeta se centre inmediatamente, llamamos al OnEnable
método ValidatePosition
. void OnEnable () { instance = this; ValidatePosition(); }
Muévase hacia la izquierda y hacia la derecha al centrar la cámara.Aunque todavía restringimos el movimiento de la cámara, el mapa ahora intenta centrarse en relación con la cámara, teletransportando columnas de fragmentos de mapa si es necesario. Con un mapa pequeño y una cámara remota, esto es claramente visible, pero en un mapa grande, los fragmentos teletransportados están fuera del alcance de visión de la cámara. Obviamente, solo los bordes iniciales este y oeste del mapa son notables, porque todavía no hay triangulación entre ellos., Eliminar la restricción de su coordenada X con el fin de reducir al mínimo y la cámara WrapPosition
. En cambio, continuaremos aumentando la coordenada X en el ancho del mapa mientras esté por debajo de cero, y la reduciremos mientras sea más grande que el ancho del mapa. Vector3 WrapPosition (Vector3 position) {
La cámara enrollable se mueve a lo largo del mapa.Texturas de sombreador plegables
Con la excepción del espacio de triangulación, minimizar la cámara en el modo de juego debería ser imperceptible. Sin embargo, cuando esto sucede, se produce un cambio visual en la mitad de la topografía y el agua. Esto sucede porque usamos una posición en el mundo para muestrear estas texturas. Una teletransportación aguda del fragmento cambia la ubicación de las texturas.Podemos resolver este problema haciendo que las texturas aparezcan en mosaicos que son múltiplos del tamaño del fragmento. El tamaño del fragmento se calcula a partir de las constantes en HexMetrics
, así que creemos el archivo de inclusión del sombreador HexMetrics.cginc y peguemos las definiciones correspondientes en él. La escala de mosaico básica se calcula a partir del tamaño del fragmento y el radio exterior de la celda. Si utiliza otras métricas, deberá modificar el archivo en consecuencia. #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))
Esto nos da una escala de mosaico de 0.00866025404. Si usamos un múltiplo entero de este valor, la textura no se verá afectada por la teletransportación de fragmentos. Además, las texturas en los bordes este y oeste del mapa se unirán perfectamente después de que triangulemos correctamente su conexión. Usamos 0.02como la escala UV en el sombreador de terreno . En cambio, podemos usar la escala de mosaico duplicado, que es 0.01732050808. La escala se obtiene un poco menos de lo que era, y la escala de la textura ha aumentado ligeramente, pero visualmente es 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] ); … }
En el sombreador Roads para ruido UV, utilizamos una escala de 0.025. En su lugar, puede usar la escala de mosaico triple. Esto nos da 0.02598076212, que está bastante cerca. #include "HexMetrics.cginc" #include "HexCellData.cginc" … void surf (Input IN, inout SurfaceOutputStandardSpecular o) { float4 noise = tex2D(_MainTex, IN.worldPos.xz * (3 * TILING_SCALE)); … }
Finalmente, en Water.cginc utilizamos 0.015 para espuma y 0.025 para olas. Aquí podemos reemplazar nuevamente estos valores con una escala de mosaico duplicada y triplicada. #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)); … }
paquete de la unidadLa unión de oriente y occidente
En esta etapa, la única evidencia visual de minimizar el mapa es una pequeña brecha entre las columnas más orientales y más occidentales. Esta brecha ocurre porque todavía no hemos triangulado las conexiones de bordes y ángulos entre celdas en lados opuestos del mapa sin plegar.Espacio en el borde.Vecinos plegables
Para triangular la conexión este-oeste, necesitamos hacer que las celdas en lados opuestos sean vecinas entre sí. Hasta ahora no estamos haciendo esto, porque la HexGrid.CreateCell
conexión E - W se establece con la celda anterior solo si su índice en X es mayor que cero. Para contraer esta conexión, debemos conectar la última celda de la fila con la primera celda de la misma fila cuando se pliega el mapa. 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]); } } … }
Una vez establecida la conexión de los vecinos E - W, obtenemos una triangulación parcial de la brecha. La conexión de bordes no es ideal, porque la distorsión está oculta incorrectamente. Nos ocuparemos de esto más tarde.Compuestos E - W.También necesitamos colapsar los enlaces NE - SW. Esto se puede hacer conectando la primera celda de cada fila par con las últimas celdas de la fila anterior. Será solo la celda anterior. 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 { … } }
NE - Conexiones SW.Finalmente, las conexiones SE - NW se establecen al final de cada línea impar debajo de la primera. Estas celdas deben estar conectadas a la primera celda de la fila anterior. 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] ); } } }
Compuestos SE - NO.Ruido plegable
Para ocultar perfectamente la brecha, debemos asegurarnos de que los bordes este y oeste del mapa coincidan con el ruido que se usa perfectamente para distorsionar las posiciones de los vértices. Podemos usar el mismo truco que se usó para los sombreadores, pero se usó una escala de ruido de 0.003 para la distorsión. Para garantizar el mosaico, debe aumentar significativamente la escala, lo que conducirá a una distorsión más caótica de los vértices.Una solución alternativa no es el ruido tayl, sino hacer una atenuación suave del ruido en los bordes del mapa. Si realiza una atenuación suave a lo largo del ancho de una celda, la distorsión creará una transición suave sin espacios. El ruido en esta área se suavizará ligeramente, y desde una larga distancia el cambio parecerá agudo, pero esto no es tan obvio cuando se usa una ligera distorsión de los vértices.¿Qué pasa con las fluctuaciones de temperatura?. , . , . , .
Si no colapsamos la tarjeta, podemos pasar con una HexMetrics.SampleNoise
sola muestra. Pero al plegar es necesario agregar atenuación. Por lo tanto, antes de devolver la muestra, guárdela en una variable. public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample; }
Al minimizar, necesitamos mezclar con la segunda muestra. Realizaremos la transición en la parte este del mapa, por lo que la segunda muestra debe moverse hacia el oeste. 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 ); }
La atenuación se realiza utilizando una interpolación lineal simple de la parte occidental a la oriental, sobre el ancho de una celda. 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) ); }
Mezcla de ruido, una solución imperfectaComo resultado, no obtenemos una coincidencia exacta, porque algunas de las celdas en el lado este tienen coordenadas X negativas. Para no acercarnos a esta área, muevamos la región de transición a la mitad oeste del ancho de la celda. 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 ); }
Atenuación correcta.Edición de celda
Ahora que la triangulación parece correcta, asegurémonos de que podemos editar todo en el mapa y en la costura de plegado. Resulta que, en fragmentos teletransportados, las coordenadas son erróneas y grandes pinceles están cortados por una costura.El cepillo está recortado.Para solucionar esto, debemos informar el HexCoordinates
plegamiento. Podemos hacer esto haciendo coincidir la coordenada X en el método del constructor. Sabemos que la coordenada axial X se obtiene de la coordenada X del desplazamiento restando la mitad de la coordenada Z. Puede usar esta información para realizar la transformación inversa y verificar si la coordenada cero es menor que cero. Si es así, entonces tenemos la coordenada más allá del lado este del mapa desplegado. Como en cada dirección teletransportamos no más de la mitad del mapa, será suficiente para nosotros agregar el tamaño de plegado a X una vez. Y cuando la coordenada de desplazamiento es mayor que el tamaño de plegado, debemos realizar una resta. 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; }
A veces, al editar la parte inferior o superior del mapa, obtengo errores.Esto sucede cuando, debido a la distorsión de los vértices, el cursor aparece en la fila de celdas fuera del mapa. Este es un error que ocurre porque no hacemos coincidir las coordenadas HexGrid.GetCell
con el parámetro vectorial. Esto se puede solucionar aplicando un método GetCell
con coordenadas como parámetros que realizarán las verificaciones necesarias. public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position);
Plegamiento costero
La triangulación funciona bien para el terreno, pero a lo largo de la costura este-oeste no hay bordes de la costa del agua. De hecho, lo son, simplemente no colapsan. Se voltean y se estiran al otro lado del mapa.Falta el borde del agua.Esto sucede, porque al triangular el agua de la costa, usamos la posición de un vecino. Para solucionar esto, necesitamos determinar a qué nos enfrentamos, ubicado en el otro lado de la tarjeta. Para simplificar la tarea, agregaremos una HexCell
columna de celda a la propiedad para el índice. public int ColumnIndex { get; set; }
Asigne este índice a HexGrid.CreateCell
. Es simplemente igual a la coordenada de desplazamiento X dividida por el tamaño del fragmento. void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … }
Ahora podemos HexGridChunk.TriangulateWaterShore
determinar qué se minimiza comparando el índice de columna de la celda actual y su vecino. Si el índice de la columna del vecino es menos de un paso menos, entonces estamos en el lado occidental y el vecino está en el lado este. Por lo tanto, necesitamos girar a nuestro vecino hacia el oeste. Lo mismo y en la dirección opuesta. 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; }
Costillas de la costa, pero sin rincones.Así que nos ocupamos de las costillas de la costa, pero hasta ahora no nos ocupamos de los rincones. Necesitamos hacer lo mismo con el próximo vecino. 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())); … }
Costa bien acortada.Generación de tarjeta
La opción de conectar los lados este y oeste afecta la generación de mapas. Al minimizar el mapa, el algoritmo de generación también debe minimizarse. Esto conducirá a la creación de otro mapa, pero cuando se utiliza un borde de mapa X distinto de cero , el plegado no es obvio.Mapa grande 1208905299 con la configuración predeterminada. Con plegado y sin él.Cuando está minimizado no tiene sentido usar el mapa de la frontera de la X . Pero no podemos deshacernos de él, porque al mismo tiempo las regiones se fusionarán. Al minimizar, en su lugar, podemos usar un RegionBorder .Cambiamos HexMapGenerator.CreateRegions
, reemplazando en todos los casos mapBorderX
por borderX
. Esta nueva variable será igual o regionBorder
, o mapBorderX
, dependiendo del valor de la opción de colapso. A continuación mostré los cambios solo para el primer caso. 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; … }
Al mismo tiempo, las regiones permanecen separadas, pero esto es necesario solo si hay diferentes regiones en los lados este y oeste del mapa. Hay dos casos en que esto no se respeta. La primera es cuando tenemos solo una región. El segundo es cuando hay dos regiones que dividen el mapa horizontalmente. En estos casos, podemos asignar un borderX
valor de cero, lo que permitirá que las masas de tierra crucen la costura este-oeste. 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; … }
Una región está colapsando.A primera vista, parece que todo funciona correctamente, pero en realidad hay una brecha a lo largo de la costura. Esto se vuelve más notable si establece el porcentaje de erosión en cero.Cuando se deshabilita la erosión, se nota una costura en el relieve.La brecha ocurre porque la costura evita el crecimiento de fragmentos en relieve. Para determinar qué se agrega primero, se usa la distancia desde la celda hasta el centro del fragmento, y las celdas en el otro lado del mapa pueden estar muy lejos, por lo que casi nunca se encienden. Por supuesto, esto está mal. Necesitamos asegurarnos de que HexCoordinates.DistanceTo
conocemos el mapa minimizado.Calculamos la distancia entre HexCoordinates
, sumando las distancias absolutas a lo largo de cada uno de los tres ejes y reduciendo a la mitad el resultado. La distancia a lo largo de Z siempre es verdadera, pero plegarla puede afectar las distancias X e Y. Entonces, comencemos con un cálculo separado de X + Y. public int DistanceTo (HexCoordinates other) {
Determinar si el plegado crea una distancia más corta para las celdas arbitrarias no es una tarea fácil, así que calculemos X + Y para los casos en los que estamos plegando otra coordenada hacia el lado oeste. Si el valor es menor que el X + Y original, úselo. 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 esto no conduce a una distancia más corta, entonces es posible doblar en la otra dirección, por lo que lo revisaremos. 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; } } }
Ahora siempre obtenemos la distancia más corta en el mapa plegable. Los fragmentos de terreno ya no están bloqueados por una costura, lo que permite que las masas de tierra se enrosquen.Relieve plegado correctamente sin erosión y erosión.paquete de la unidadViajando por el mundo
Después de considerar la generación de mapas y la triangulación, pasemos ahora a revisar escuadrones, exploración y visibilidad.Prueba de costura
El primer obstáculo que encontramos al mover un escuadrón alrededor del mundo es el borde del mapa, que no se puede explorar.La costura de la tarjeta no puede ser examinada.Las celdas a lo largo del borde del mapa se hacen inexploradas para ocultar la finalización abrupta del mapa. Pero cuando el mapa se minimiza, solo se deben marcar las celdas norte y sur, pero no el este y el oeste. Cambie HexGrid.CreateCell
para tener esto en cuenta. if (wrapping) { cell.Explorable = z > 0 && z < cellCountZ - 1; } else { cell.Explorable = x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1; }
Visibilidad de las características de relieve.
Ahora verifiquemos si la visibilidad funciona a lo largo de la costura. Funciona para terreno, pero no para objetos de terreno. Parece que los objetos que colapsan obtienen la visibilidad de la última celda que no se colapsó.Visibilidad incorrecta de los objetos.Esto sucede porque el modo de HexCellShaderData
sujeción está configurado para el modo de plegado de textura utilizado . Para resolver el problema, simplemente cambie su modo de sujeción para repetir. Pero necesitamos hacer esto solo para las coordenadas de U, por lo Initialize
que lo configuraremos wrapModeU
por wrapModeV
separado. 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;
Escuadrones y columnas
Otro problema es que las unidades aún no están colapsando. Después de mover la columna en la que se encuentran, las unidades permanecen en el mismo lugar.La unidad no se transfiere y está en el lado equivocado.Este problema se puede resolver haciendo que los escuadrones sean elementos secundarios de las columnas, como hicimos con los fragmentos. Primero, ya no los convertiremos en los hijos inmediatos de la cuadrícula HexGrid.AddUnit
. public void AddUnit (HexUnit unit, HexCell location, float orientation) { units.Add(unit); unit.Grid = this;
Como las unidades se están moviendo, pueden aparecer en otra columna, es decir, será necesario cambiar sus padres. Para hacer esto posible, agregamos al HexGrid
método general MakeChildOfColumn
y, como parámetros, le pasamos el componente del Transform
elemento secundario y el índice de la columna. public void MakeChildOfColumn (Transform child, int columnIndex) { child.SetParent(columns[columnIndex], false); }
Llamaremos a este método cuando se establezca la propiedad HexUnit.Location
. public HexCell Location { … set { … Grid.MakeChildOfColumn(transform, value.ColumnIndex); } }
Esto resuelve el problema de crear unidades. Pero también debemos hacer que se muevan a la columna deseada cuando se mueven. Para hacer esto, debe rastrear HexUnit.TravelPath
la columna actual en el índice. Al comienzo de este método, este es el índice de la columna de celda al comienzo de la ruta, o la actual si el movimiento fue interrumpido por la compilación. IEnumerator TravelPath () { Vector3 a, b, c = pathToTravel[0].Position; yield return LookAt(pathToTravel[1].Position);
Durante cada iteración del movimiento, verificaremos si el índice de la siguiente columna es diferente, y si es así, cambiaremos el padre del orden. 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; } … }
Esto permitirá que las unidades se muevan de manera similar a los fragmentos. Sin embargo, cuando se mueven a través de la costura de la tarjeta, las unidades aún no colapsan. En cambio, de repente comienzan a moverse en la dirección equivocada. Esto sucede independientemente de la ubicación de la costura, pero más notablemente cuando saltan por todo el mapa.Carreras de caballos en el mapa.Aquí podemos usar el mismo enfoque que se usó para la costa, solo que esta vez giraremos la curva a lo largo de la cual se mueve el desprendimiento. Si la siguiente columna se gira hacia el este, entonces teletransportaremos la curva también hacia el este, de manera similar para la otra dirección. Debe cambiar los puntos de control de la curva a
y b
, lo que también afectará el punto de control c
. for (int i = 1; i < pathToTravel.Count; i++) { currentTravelLocation = pathToTravel[i]; a = c; b = pathToTravel[i - 1].Position;
Movimiento con plegado.Lo último que debe hacer es cambiar el turno inicial del escuadrón cuando mira la primera celda a la que se moverá. Si esta celda está al otro lado de la costura este-oeste, la unidad mirará en la dirección incorrecta.Al minimizar un mapa, hay dos formas de mirar un punto que no está exactamente en el norte o el sur. Puedes mirar hacia el este o el oeste. Será lógico mirar en la dirección correspondiente a la distancia más cercana al punto, porque también es la dirección del movimiento, así que utilicémoslo LookAt
.Al minimizar, verificaremos la distancia relativa a lo largo del eje X. Si es menor que la mitad negativa del ancho del mapa, entonces debemos mirar hacia el oeste, lo que se puede hacer girando el punto hacia el oeste. De lo contrario, si la distancia es más de la mitad del ancho del mapa, entonces debemos colapsar hacia el este. 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; } } … }
Entonces, tenemos un mapa minimizado completamente funcional. Y esto concluye la serie de tutoriales sobre mapas hexagonales. Como se mencionó en las secciones anteriores, se pueden considerar otros temas, pero no son específicos de los mapas hexagonales. Quizás los consideraré en futuras series de tutoriales.Descargué el último paquete y recibo errores de turno en el modo Play, Rotation . . . 5.
Descargué el último paquete y los gráficos no son tan hermosos como en las capturas de pantalla. - .
Descargué el último paquete y genera constantemente la misma tarjetaseed (1208905299), . , Use Fixed Seed .
paquete de la unidad