Hexagon maps in Unity: save and load, textures, distances

Original author: Jasper Flick
  • Transfer
Part 1-3: mesh, color and height of the cell

part 4-7: bumps, rivers and roads

Parts 8-11: water, relief items and walls

Part 12-15: saving and loading, texture, distance

Part 16-19: search for the path, player squads, animations

Parts 20-23: fog of war, map exploration, procedural generation

Parts 24-27: water cycle, erosion, biomes, cylindrical map

Part 12: Save and Load


  • We track the type of relief instead of color.
  • Create a file.
  • Write the data to a file, and then read it.
  • Serialize the data cells.
  • Reduce the file size.

We already know how to create quite interesting maps. Now you need to learn how to save them.


Loaded from test.map file .

Terrain type


When you save the map, we do not need to store all the data that we track during the execution of the application. For example, we only need to memorize the level of the height of the cell. Its very vertical position is taken from this data, so it is not necessary to store it. In fact, it is better if we do not store these calculated metrics. This way the map data will remain correct, even if we later decide to change the height offset. The data is separated from their presentation.

Similarly, we do not need to store the exact color of the cell. You can write that the cell is green. But the exact shade of green can change when you change the visual style. For this, we can save the color index, not the colors themselves. In fact, it may be enough for us to store this index in the cells instead of real colors even at run time. This will allow later to move to a more complex visualization of the relief.

Moving an array of colors


If the cells no longer have color data, then it should be stored somewhere else. The most convenient way to store it in HexMetrics. So let's add an array of colors to it.

publicstatic Color[] colors;

Like all other global data, such as noise, we can initialize these colors with HexGrid.

public Color[] colors;
	…
	voidAwake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexMetrics.colors = colors;
		…
	}
	…
	voidOnEnable () {
		if (!HexMetrics.noiseSource) {
			HexMetrics.noiseSource = noiseSource;
			HexMetrics.InitializeHashGrid(seed);
			HexMetrics.colors = colors;
		}
	}

And since now we do not assign colors directly to the cells, we will get rid of the default color.

//	public Color defaultColor = Color.white;voidCreateCell (int x, int z, int i) {
		…
		HexCell cell = cells[i] = Instantiate<HexCell>(cellPrefab);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
//		cell.Color = defaultColor;
		…
	}

Adjust the new colors to match the general array of the hexagon map editor.


Colors added to the grid.

Refactoring cells


Remove from the HexCellfield of color. Instead, we will store the index. And instead of the color index, we use a more general relief type index.

//	Color color;int terrainTypeIndex;

The color property can use this index only to get the corresponding color. It is no longer specified directly, so we will remove this part. In this case, we get a compilation error, which we will fix soon.

public Color Color {
		get {
			return HexMetrics.colors[terrainTypeIndex];
		}
//		set {//			…//		}
	}

Add a new property to get and set a new index of the type of relief.

publicint TerrainTypeIndex {
		get {
			return terrainTypeIndex;
		}
		set {
			if (terrainTypeIndex != value) {
				terrainTypeIndex = value;
				Refresh();
			}
		}
	}

Editor refactoring


Inside, we HexMapEditorwill remove all the code regarding the colors. This will fix the compilation error.

//	public Color[] colors;//	Color activeColor;//	bool applyColor;//	public void SelectColor (int index) {//		applyColor = index >= 0;//		if (applyColor) {//			activeColor = colors[index];//		}//	}//	void Awake () {//		SelectColor(0);//	}voidEditCell (HexCell cell) {
		if (cell) {
//			if (applyColor) {//				cell.Color = activeColor;//			}
			…
		}
	}

Now we will add a field and a method for managing the active index of the relief type.

int activeTerrainTypeIndex;
	…
	publicvoidSetTerrainTypeIndex (int index) {
		activeTerrainTypeIndex = index;
	}

We use this method as a replacement for the now missing method SelectColor. Connect the color widgets in the UI with SetTerrainTypeIndex, leaving everything else unchanged. This means that a negative index is still in use and indicates that the color should not change.

Let's change it EditCellso that the relief type index is assigned to the cell being edited.

voidEditCell (HexCell cell) {
		if (cell) {
			if (activeTerrainTypeIndex >= 0) {
				cell.TerrainTypeIndex = activeTerrainTypeIndex;
			}
			…
		}
	}

Although we removed the colors from the cells, the map should work the same way as before. The only difference is that the default color is now first in the array. In my case it is yellow.


Yellow is the new default color.

unitypackage

Saving data in a file


To manage the saving and loading of the map we use HexMapEditor. We will create two methods that will deal with this, and for the time being we will leave them empty.

publicvoidSave () {
	}
	publicvoidLoad () {
	}

Add two buttons to the UI ( GameObject / UI / Button ). Connect them to the buttons and give the appropriate labels. I put them in the bottom of the right pane.


Save and Load buttons.

File location


To store the card you need to save it somewhere. As done in most games, we will store the data in a file. But where in the file system to put this file? The answer depends on which operating system the game is running on. Each OS has its own standards for storing files related to applications.

We do not need to know these standards. Unity knows the proper path we can get with Application.persistentDataPath. You can check how it will be with you in the method of Savedisplaying it in the console and pressing the button in the Play mode.

publicvoidSave () {
		Debug.Log(Application.persistentDataPath);
	}

On desktop systems, the path will contain the name of the company and product. This path is used and the editor, and assembly. Names can be configured in Edit / Project Settings / Player .


Name of company and product.

Why can't I find the Library folder on a Mac?
Library folder is often hidden. The way in which it can be displayed depends on the version of OS X. If you are not old, select the home folder in Finder and go to Show View Options . There is a checkbox for the Library folder .

What about webgl?
WebGL games cannot access the user's file system. Instead, all file operations are redirected to the in-memory file system. It is transparent to us. However, to save the data, you will need to manually order a web page to reset the data in the browser storage.

File creation


To create a file, we need to use classes from the namespace System.IO. Therefore, we add an operator usingfor it above the class HexMapEditor.

using UnityEngine;
using UnityEngine.EventSystems;
using System.IO;
publicclassHexMapEditor : MonoBehaviour {
	…
}

First we need to create the full path to the file. We use test.map as the file name . It must be added to the stored data path. Whether to insert a slash or backslash (slash or backslash) depends on the platform. This will deal with the method Path.Combine.

publicvoidSave () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
	}

Next we need to access the file at this location. We do this with a method File.Open. Since we want to write data to this file, we need to use its create mode. In this case, the specified path will either create a new file or replace an existing file.

string path = Path.Combine(Application.persistentDataPath, "test.map");
		File.Open(path, FileMode.Create);

The result of calling this method will be an open data stream associated with this file. We can use it to write data to a file. And you need to remember to close the stream when we no longer need it.

string path = Path.Combine(Application.persistentDataPath, "test.map");
		Stream fileStream = File.Open(path, FileMode.Create);
		fileStream.Close();

At this stage, when you click on the Save button , a test.map file will be created in the folder specified as the path to the stored data. If you examine this file, it will be empty and have a size of 0 bytes, because so far we have not recorded anything in it.

Write to file


To write data to a file, we need a way to stream data to it. The easiest way to do this is through BinaryWriter. These objects allow you to write primitive data to any stream.

Create a new object BinaryWriter, and its argument will be our file stream. Closing the writer closes the stream that it uses. Therefore, we no longer need to store a direct link to the stream.

string path = Path.Combine(Application.persistentDataPath, "test.map");
		BinaryWriter writer =
			new BinaryWriter(File.Open(path, FileMode.Create));
		writer.Close();

We can use the method to transfer data to a stream BinaryWriter.Write. There is a variant of the method Writefor all primitive types, such as integer and float. It can also write strings. Let's try write integer 123.

		BinaryWriter writer =
			new BinaryWriter(File.Open(path, FileMode.Create));
		writer.Write(123);
		writer.Close();

Click the Save button and examine test.map again . Now its size is 4 bytes, because the size of the integer is 4 bytes.

Why does my file manager show that the file takes up more space?
Потому что файловые системы разделяют пространство на блоки байтов. Они не отслеживают отдельные байты. Так как test.map занимает пока только четыре байта, для него требуется один блок пространства накопителя.

Notice that we store binary data, not human readable text. Therefore, if we open the file in a text editor, we will see a set of vague characters. You will probably see the { character , for which there is nothing or there are several placeholders.

You can open the file in a hex editor. In this case, we will see 7b 00 00 00 . These are four bytes of our integer, displayed in hexadecimal notation. In ordinary decimal numbers this is 123 0 0 0 . In binary notation, the first byte looks like 01111011 .

The ASCII code for { equals 123, so this symbol can be displayed in a text editor. ASCII 0 is a null character that does not match any visible characters.

The remaining three bytes are zero, because we wrote down a number less than 256. If we recorded 256, then we would see 00 01 00 00 in the hex editor .

Shouldn't 123 be stored as 00 00 00 7b?
Для сохранения чисел BinaryWriter использует формат little-endian. Это значит, что первыми записываются наименее значимые байты. Этот формат использовала Microsoft при разработке фреймворка .Net. Вероятно, он был выбран потому, что в ЦП Intel используется формат little-endian.

Альтернативой ему является big-endian, в котором первыми хранятся самые значимые байты. Это соответствует обычному порядку цифр в числах. 123 — это сто двадцать три, потому что мы подразумеваем запись big-endian. Если бы это была little-endian, то 123 обозначало бы триста двадцать один.

Make it so that resources are released.


It is important that we close the writer. While it is open, the file system locks the file, preventing other processes from writing to it. If we forget to close it, then we block ourselves too. If we press the save button twice, then the second time we will not be able to open the stream.

Instead of closing the writer manually, we can create a block for this using. It defines the scope within which the writer is valid. When the executed code goes beyond this scope, the writer is deleted and the stream is closed.

using (
			BinaryWriter writer =
				new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			writer.Write(123);
		}
//		writer.Close();

This works because the writer and file stream classes implement the interface IDisposable. These objects have a method Disposethat is indirectly invoked when they go out of scope using.

The big advantage usingis that it works no matter how the execution of the program goes out of scope. Early returns, exceptions and errors do not interfere with it. In addition, it is very concise.

Data retrieval


To read previously recorded data, we need to insert the code into the method Load. As in the case of saving, we need to create a path and open the file stream. The difference is that now we open the file for reading, not for writing. And instead of writer we need BinaryReader.

publicvoidLoad () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (
			BinaryReader reader =
				new BinaryReader(File.Open(path, FileMode.Open))
		) {
		}
	}

In this case, we can use the method File.OpenReadto open the file for reading.

using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
		}

Why we can not use when writing File.OpenWrite?
Этот метод создаёт поток, который добавляет данные к существующим файлам, а не заменяет их.

When reading, we need to explicitly specify the type of data received. To read integer from a stream, we need to use BinaryReader.ReadInt32. This method reads a 32-bit integer, that is, four bytes.

using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			Debug.Log(reader.ReadInt32());
		}

It is necessary to consider that when receiving 123 it will be enough for us to count one byte. But at the same time in the stream will remain three bytes belonging to this integer. In addition, it does not work for numbers outside the range of 0–255. So do not do this.

unitypackage

Write and read card data


When saving data, the important question is whether you need to use a human-readable format. Usually, JSON, XML and simple ASCII with some structure are used as human-readable formats. Such files can be opened, interpreted and edited in text editors. In addition, they simplify the exchange of data between different applications.

However, such formats have their own requirements. Files will take up more space (sometimes much more) than when using binary data. They can also greatly increase the cost of encoding and decoding data, in terms of both runtime and memory usage.

In contrast, binary data is compact and fast. This is important when writing large amounts of data. For example, when autosaving a large card in each course of the game. Therefore,
we will use the binary format. If you can handle it, you can work with more detailed formats.

What about automatic serialization?
Сразу же в процессе сериализации данных Unity мы можем непосредственно записывать сериализованные классы в поток. Подробности записи отдельных полей будут скрыты от нас. Однако мы не сможем непосредственно сериализовать ячейки. Они являются классами MonoBehaviour, в которых есть данные, которые нам сохранять не нужно. Поэтому нам нужно использовать отдельную иерархию объектов, которая уничтожает простоту автоматической сериализации. Кроме того, так сложнее будет поддерживать будущие изменения кода. Поэтому мы будем придерживаться полного контроля с помощью сериализации вручную. К тому же она заставит нас по-настоящему разобраться в том, что происходит.

To serialize the map, we need to store the data of each cell. To save and load a separate cell, add to the and HexCellmethods . Since they need a writer or reader to work, we add them as parameters.SaveLoad

using UnityEngine;
using System.IO;
publicclassHexCell : MonoBehaviour {
	…
	publicvoidSave (BinaryWriter writer) {
	}
	publicvoidLoad (BinaryReader reader) {
	}
}

Add methods Saveand Loadand in HexGrid. These methods simply bypass all the cells, calling their methods Loadand Save.

using UnityEngine;
using UnityEngine.UI;
using System.IO;
publicclassHexGrid : MonoBehaviour {
	…
	publicvoidSave (BinaryWriter writer) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Save(writer);
		}
	}
	publicvoidLoad (BinaryReader reader) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader);
		}
	}
}

If we load a map, it needs to be updated after the cell data has been changed. To do this, just update all the fragments.

publicvoidLoad (BinaryReader reader) {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader);
		}
		for (int i = 0; i < chunks.Length; i++) {
			chunks[i].Refresh();
		}
	}

Finally, we replace our test code HexMapEditorwith calls to methods Saveand Loadgrids, passing with them a writer or reader.

publicvoidSave () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (
			BinaryWriter writer =
				new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			hexGrid.Save(writer);
		}
	}
	publicvoidLoad () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			hexGrid.Load(reader);
		}
	}

Saving terrain type


At the current stage, re-saving creates an empty file, and the download does nothing. Let's start gradually, by writing and loading only the relief type index HexCell.

Directly assign the value to the field terrainTypeIndex. We will not use properties. Since we are explicitly updating all fragments, Refreshproperty calls are not needed. In addition, since we only keep the correct maps, we will assume that all the maps that are loaded are also correct. Therefore, for example, we will not check whether the river or the road is permissible.

publicvoidSave (BinaryWriter writer) {
		writer.Write(terrainTypeIndex);
	}
	publicvoidLoad (BinaryReader reader) {
		terrainTypeIndex = reader.ReadInt32();
	}

When saving to this file one after another, the index of the type of relief of all cells will be recorded. Since the index is an integer, its size is equal to four bytes. My map contains 300 cells, that is, the file size will be 1200 bytes.

Download reads the indexes in the order in which they are written. If you changed the colors of the cells after saving, then loading the map will return the colors to the state when they were saved. Since we do not save anything else, the rest of the cell data will remain the same. That is, the load will change the type of relief, but not its height, water level, relief objects, etc.

Saving All Integer


Preserving the relief type index is not enough for us. It is necessary to save all other data. Let's start with all the fields integer. These are terrain type index, cell height, water level, city level, farm level, vegetation level and index of particular objects. They will need to be read in the same order in which they were recorded.

publicvoidSave (BinaryWriter writer) {
		writer.Write(terrainTypeIndex);
		writer.Write(elevation);
		writer.Write(waterLevel);
		writer.Write(urbanLevel);
		writer.Write(farmLevel);
		writer.Write(plantLevel);
		writer.Write(specialIndex);
	}
	publicvoidLoad (BinaryReader reader) {
		terrainTypeIndex = reader.ReadInt32();
		elevation = reader.ReadInt32();
		waterLevel = reader.ReadInt32();
		urbanLevel = reader.ReadInt32();
		farmLevel = reader.ReadInt32();
		plantLevel = reader.ReadInt32();
		specialIndex = reader.ReadInt32();
	}

Try to save and load the map now, making changes between these operations. Everything that we included in the stored data was restored as much as we can, except for the height of the cell. This happened because when you change the height level, you need to update the vertical position of the cell. This can be done by assigning it to the property, and not the field, the value of the loaded height. But this property does extra work that we don’t need. So let's extract from the setter a Elevationcode that updates the position of the cell and insert it into a separate method RefreshPosition. The only change that needs to be made here is to replace the valuelink in the field elevation.

voidRefreshPosition () {
		Vector3 position = transform.localPosition;
		position.y = elevation * HexMetrics.elevationStep;
		position.y +=
			(HexMetrics.SampleNoise(position).y * 2f - 1f) *
			HexMetrics.elevationPerturbStrength;
		transform.localPosition = position;
		Vector3 uiPosition = uiRect.localPosition;
		uiPosition.z = -position.y;
		uiRect.localPosition = uiPosition;
	}

Now we can call the method when setting the property, as well as after loading the height data.

publicint Elevation {
		…
		set {
			if (elevation == value) {
				return;
			}
			elevation = value;
			RefreshPosition();
			ValidateRivers();
			…
		}
	}
	…
	publicvoidLoad (BinaryReader reader) {
		terrainTypeIndex = reader.ReadInt32();
		elevation = reader.ReadInt32();
		RefreshPosition();
		…
	}

After this change, the cells will correctly change their apparent height when loading.

Saving all data


The presence in the cell walls and incoming / outgoing rivers is stored in Boolean fields. We can write them simply as integer. In addition, road data is an array of six boolean values ​​that we can write using a loop.

publicvoidSave (BinaryWriter writer) {
		writer.Write(terrainTypeIndex);
		writer.Write(elevation);
		writer.Write(waterLevel);
		writer.Write(urbanLevel);
		writer.Write(farmLevel);
		writer.Write(plantLevel);
		writer.Write(specialIndex);
		writer.Write(walled);
		writer.Write(hasIncomingRiver);
		writer.Write(hasOutgoingRiver);
		for (int i = 0; i < roads.Length; i++) {
			writer.Write(roads[i]);
		}
	}

Directions of incoming and outgoing rivers are stored in the fields HexDirection. A type HexDirectionis an enumeration that is internally stored as multiple integer values. Therefore, we can serialize them too as an integer using an explicit conversion.

		writer.Write(hasIncomingRiver);
		writer.Write((int)incomingRiver);
		writer.Write(hasOutgoingRiver);
		writer.Write((int)outgoingRiver);

Boolean values ​​are read using the method BinaryReader.ReadBoolean. The directions of the rivers are integer, which we must convert back to HexDirection.

publicvoidLoad (BinaryReader reader) {
		terrainTypeIndex = reader.ReadInt32();
		elevation = reader.ReadInt32();
		RefreshPosition();
		waterLevel = reader.ReadInt32();
		urbanLevel = reader.ReadInt32();
		farmLevel = reader.ReadInt32();
		plantLevel = reader.ReadInt32();
		specialIndex = reader.ReadInt32();
		walled = reader.ReadBoolean();
		hasIncomingRiver = reader.ReadBoolean();
		incomingRiver = (HexDirection)reader.ReadInt32();
		hasOutgoingRiver = reader.ReadBoolean();
		outgoingRiver = (HexDirection)reader.ReadInt32();
		for (int i = 0; i < roads.Length; i++) {
			roads[i] = reader.ReadBoolean();
		}
	}

Now we save all the data cells, which are necessary for the complete preservation and restoration of the card. This requires nine integer and nine boolean values ​​per cell. Each boolean value occupies one byte, so we use a total of 45 bytes per cell. That is, a card with 300 cells requires a total of 13,500 bytes.

unitypackage

Reduce file size


Although it seems that 13,500 bytes is not very much for 300 cells, perhaps we can do with less. In the end, we have complete control over how the data is serialized. Let's see, maybe there is a more compact way to store them.

Decrease in numeric interval


Different levels and cell indices are stored as integer. However, they use only a small range of values. Each of them will definitely stay in the range of 0–255. This means that only the first byte of each integer will be used. The remaining three will always be zero. There is no point in storing these empty bytes. We can drop them by converting an integer to byte before writing to a stream.

		writer.Write((byte)terrainTypeIndex);
		writer.Write((byte)elevation);
		writer.Write((byte)waterLevel);
		writer.Write((byte)urbanLevel);
		writer.Write((byte)farmLevel);
		writer.Write((byte)plantLevel);
		writer.Write((byte)specialIndex);
		writer.Write(walled);
		writer.Write(hasIncomingRiver);
		writer.Write((byte)incomingRiver);
		writer.Write(hasOutgoingRiver);
		writer.Write((byte)outgoingRiver);

Now in order to return these numbers, we will have to use BinaryReader.ReadByte. The conversion from byte to integer is implicit, so we don’t need to add explicit conversions.

		terrainTypeIndex = reader.ReadByte();
		elevation = reader.ReadByte();
		RefreshPosition();
		waterLevel = reader.ReadByte();
		urbanLevel = reader.ReadByte();
		farmLevel = reader.ReadByte();
		plantLevel = reader.ReadByte();
		specialIndex = reader.ReadByte();
		walled = reader.ReadBoolean();
		hasIncomingRiver = reader.ReadBoolean();
		incomingRiver = (HexDirection)reader.ReadByte();
		hasOutgoingRiver = reader.ReadBoolean();
		outgoingRiver = (HexDirection)reader.ReadByte();

So we get rid of three bytes on integer, which gives a saving of 27 bytes per cell. Now we spend 18 bytes per cell, and only 5,400 bytes per 300 cells.

It is worth noting that the old map data becomes meaningless at this stage. When the old save is loaded, the data is mixed up and we get messed up cells. This is because now we read less data. If we read more data than before, we would get an error when trying to read beyond the end of the file.

The impossibility of processing old data suits us, because we are in the process of defining the format. But when we decide on the format of the save, we will need to ensure that future code can always read it. Even if we change the format, then ideally we should still be able to read the old format.

River Byte Consolidation


At this stage, we use four bytes for storing the data of rivers, two for each direction. For each direction we keep the presence of the river and the direction in which it flows

It seems obvious that we do not need to keep the direction of the river if it is not there. This means that cells without a river need two bytes less. In fact, it will be enough for us one byte per direction of the river, regardless of its existence.

We have six possible directions that are stored as numbers in the range of 0–5. Three bits are enough for this, because in binary form, numbers from 0 to 5 look like 000, 001, 010, 011, 100, 101 and 110. That is, five more bits remain unused in one byte. We can use one of them to indicate whether a river exists. For example, you can use the eighth bit corresponding to the number 128.

To do this, we will add 128 to it before converting the direction to byte. That is, if we have a river flowing to the north-west, we will write 133, which is equal to 10000101 in binary form. And if there is no river, then we simply write a zero byte.

At the same time, we still have four unused bits, but this is normal. We can combine both directions of the river into one byte, but this will be too confusing.

//		writer.Write(hasIncomingRiver);//		writer.Write((byte)incomingRiver);if (hasIncomingRiver) {
			writer.Write((byte)(incomingRiver + 128));
		}
		else {
			writer.Write((byte)0);
		}
//		writer.Write(hasOutgoingRiver);//		writer.Write((byte)outgoingRiver);if (hasOutgoingRiver) {
			writer.Write((byte)(outgoingRiver + 128));
		}
		else {
			writer.Write((byte)0);
		}

To decode river data, we first need to count back bytes. If its value is not less than 128, then this means that there is a river. To get its direction, subtract 128, and then convert to HexDirection.

//		hasIncomingRiver = reader.ReadBoolean();//		incomingRiver = (HexDirection)reader.ReadByte();byte riverData = reader.ReadByte();
		if (riverData >= 128) {
			hasIncomingRiver = true;
			incomingRiver = (HexDirection)(riverData - 128);
		}
		else {
			hasIncomingRiver = false;
		}
//		hasOutgoingRiver = reader.ReadBoolean();//		outgoingRiver = (HexDirection)reader.ReadByte();
		riverData = reader.ReadByte();
		if (riverData >= 128) {
			hasOutgoingRiver = true;
			outgoingRiver = (HexDirection)(riverData - 128);
		}
		else {
			hasOutgoingRiver = false;
		}

As a result, we got 16 bytes per cell. The improvement is not great, but it is one of those tricks used to reduce the size of binary data.

Saving roads in one byte


We can use a similar trick to compress road data. We have six boolean values ​​that can be stored in the first six bits of a byte. That is, each direction of the road is represented by a number that is a power of two. These are 1, 2, 4, 8, 16 and 32, or in binary form 1, 10, 100, 1000, 10000 and 100000.

To create a finished byte, we need to set the bits corresponding to the used directions of the roads. We can use the operator to get the right direction for the direction <<. Then combine them using the bitwise OR operator. For example, if the first, second, third and sixth roads are used, then the finished byte will be equal to 100111.

int roadFlags = 0;
		for (int i = 0; i < roads.Length; i++) {
//			writer.Write(roads[i]);if (roads[i]) {
				roadFlags |= 1 << i;
			}
		}
		writer.Write((byte)roadFlags);

How does << work?
Это оператор побитового сдвига влево. Он берёт integer слева и сдвигает всего биты влево. Переполнение отбрасывается. Количество шагов сдвига определяется integer справа. Так как числа двоичные, сдвиг всех битов на один шаг влево удваивает значение числа. То есть 1 << n даёт 2n, что нам и нужно.

To get the boolean value of the road back, you need to check if the bit is set. If so, then mask all other bits using the bitwise AND operator with the corresponding number. If the result is not zero, then the bit is set and the road exists.

int roadFlags = reader.ReadByte();
		for (int i = 0; i < roads.Length; i++) {
			roads[i] = (roadFlags & (1 << i)) != 0;
		}

Compressing six bytes into one, we got 11 bytes per cell. At 300 cells, this is only 3,300 bytes. That is, having worked a little with the bytes, we reduced the file size by 75%.

Preparing for the future


Before declaring our save format complete, add one more detail. Before saving the card data, make the HexMapEditorrecord an integer zero.

publicvoidSave () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (
			BinaryWriter writer =
				new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			writer.Write(0);
			hexGrid.Save(writer);
		}
	}

This will add four empty bytes to the beginning of our data. That is, before loading the card, we will have to read these four bytes.

publicvoidLoad () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			reader.ReadInt32();
			hexGrid.Load(reader);
		}
	}

Although these bytes are useless for now, they are used as a header that will provide backward compatibility in the future. If we did not add these zero bytes, the contents of the first few bytes depended on the first cell of the map. Therefore, in the future it would be more difficult for us to figure out which version of the preservation format we are dealing with. Now we can just check the first four bytes. If they are empty, then we are dealing with the version of format 0. In future versions, it will be possible to add something else to it.

That is, if the header is non-zero, we are dealing with some unknown version. Since we cannot find out what data there is, we must refuse to load the map.

using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header == 0) {
				hexGrid.Load(reader);
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}


unitypackage

Part 13: Card Management


  • Create new maps in Play mode.
  • Add support for various card sizes.
  • Add the size of the map to the saved data.
  • Save and load arbitrary maps.
  • Display a list of maps.

In this part we will add support for various map sizes, as well as saving different files.

Starting from this part, tutorials will be created in Unity 5.5.0.


Start a library of cards.

Creating new maps


Up to this point, the grid of hexagons, we created only once - when loading the scene. Now we will make it possible to start a new card at any time. A new card will simply replace the current one.

In Awake HexGrid, some metrics are initialized, then the number of cells is determined and the necessary fragments and cells are created. Creating a new set of fragments and cells, we create a new map. Let's divide HexGrid.Awakeinto two parts - the initialization source code and the general method CreateMap.

voidAwake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexMetrics.colors = colors;
		CreateMap();
	}
	publicvoidCreateMap () {
		cellCountX = chunkCountX * HexMetrics.chunkSizeX;
		cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ;
		CreateChunks();
		CreateCells();
	}

Add a button to the UI to create a new map. I made it big and placed it under the save and load buttons.


New Map Button.

Connect the On Click event of this button with the method of CreateMapour object HexGrid. That is, we will not go through the Hex Map Editor , but directly call the method of the Hex Grid object .


Create a map by pressing.

Clear old data


Now when you click on the New Map button , a new set of fragments and cells will be created. However, old ones are not automatically deleted. Therefore, as a result, we will have several meshes of maps superimposed on each other. To avoid this, we first need to get rid of old objects. This can be done by destroying all the current fragments at the beginning CreateMap.

publicvoidCreateMap () {
		if (chunks != null) {
			for (int i = 0; i < chunks.Length; i++) {
				Destroy(chunks[i].gameObject);
			}
		}
		…
	}

Can we reuse existing objects?
Это возможно, но начинать с новых фрагментов и ячеек проще всего. Это будет особенно справедливо тогда, когда мы добавим поддержку разных размеров карт. Кроме того, создание новой карты — это относительно редкое действие, и оптимизация здесь не очень важна.

Is it possible to destroy child elements in a loop?
Конечно. Само уничтожение откладывается до завершения фазы обновления текущего кадра.

Specify the size in the cells instead of fragments


For now we set the size of the map through the fields chunkCountXand the chunkCountZobject HexGrid. But it will be much more convenient to indicate the size of the map in the cells. At the same time, we can even further change the size of the fragment without changing the size of the maps. So let's swap the field for the number of cells and the number of fragments.

//	public int chunkCountX = 4, chunkCountZ = 3;publicint cellCountX = 20, cellCountZ = 15;
	…
//	int cellCountX, cellCountZ;int chunkCountX, chunkCountZ;
	…
	publicvoidCreateMap () {
		…
//		cellCountX = chunkCountX * HexMetrics.chunkSizeX;//		cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ;
		chunkCountX = cellCountX / HexMetrics.chunkSizeX;
		chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
		CreateChunks();
		CreateCells();
	}

This will lead to a compilation error, because it HexMapCamerauses fragment sizes to limit its position . Let's change it HexMapCamera.ClampPositionso that it uses directly the number of cells that it still needs.

Vector3 ClampPosition (Vector3 position) {
		float xMax = (grid.cellCountX - 0.5f) * (2f * HexMetrics.innerRadius);
		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);
		return position;
	}

The fragment has a size of 5 to 5 cells, and the default maps are 4 to 3 fragments. Therefore, in order for the cards to remain the same, we will have to use a size of 20 by 15 cells. And although we assigned default values ​​in the code, the grid object will still not use them automatically, because the fields already existed and were set to 0 by default.


The default map size is 20 by 15.

Arbitrary card sizes


The next step is to support the creation of maps of any size, not just the default size. To do this, add to the HexGrid.CreateMapparameters X and Z. They will replace the existing number of cells. Inside, Awakewe will simply call them with the current numbers of cells.

voidAwake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexMetrics.colors = colors;
		CreateMap(cellCountX, cellCountZ);
	}
	publicvoidCreateMap (int x, int z) {
		…
		cellCountX = x;
		cellCountZ = z;
		chunkCountX = cellCountX / HexMetrics.chunkSizeX;
		chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
		CreateChunks();
		CreateCells();
	}

However, this will only work correctly with the number of cells multiple of the fragment size. Otherwise, integer division will create too few fragments. Although we can add support for fragments partially filled with cells, let's just forbid the use of dimensions that do not correspond to the fragments.

We can use the operator %to calculate the remainder of dividing the number of cells by the number of fragments. If it is not equal to zero, then there is a discrepancy and we will not create a new map. And while we are doing this, let's add protection from zero and negative sizes.

publicvoidCreateMap (int x, int z) {
		if (
			x <= 0 || x % HexMetrics.chunkSizeX != 0 ||
			z <= 0 || z % HexMetrics.chunkSizeZ != 0
		) {
			Debug.LogError("Unsupported map size.");
			return;
		}
		…
	}

Menu of new maps


At the current stage, the New Map button no longer works, because the method HexGrid.CreateMapnow has two parameters. We cannot directly connect Unity events with such methods. In addition, to support different sizes of maps we need a few buttons. Instead of adding all these buttons to the main UI, let's create a separate popup menu.

Add a new canvas to the scene ( GameObject / UI / Canvas ). We use the same settings as the existing canvas, except that its Sort Order should be equal to 1. Due to this, it will be on top of the UI of the main editor. I made both the canvas and the event system the children of a new UI object so that the scene hierarchy remains clean.



Canvas menu New Map.

Add to New Map Menu panel, covering the entire screen. It is needed to darken the background and not allow the cursor to interact with everything else when the menu is open. I gave it a uniform color, clearing its Source Image , and set it as Color (0, 0, 0, 200).


Background Image Settings.

Add a menu bar to the canvas center, similar to the Hex Map Editor panels . Create a clear label and buttons for small, medium and large maps. We will also add a cancel button to it in case the player changes his mind. Having finished creating the design, we deactivate the whole New Map Menu .



Menu New Map.

To manage the menu, create a component NewMapMenuand add it to the canvas New Map Menu object . To create a new map, we need access to a Hex Grid object . Therefore, we add a common field to it and connect it.

using UnityEngine;
publicclassNewMapMenu : MonoBehaviour {
	public HexGrid hexGrid;
}


Component New Map Menu.

Opening and closing


We can open and close the popup menu by simply activating and deactivating the canvas object. Let's add to the NewMapMenutwo general methods that will deal with this.

publicvoidOpen () {
		gameObject.SetActive(true);
	}
	publicvoidClose () {
		gameObject.SetActive(false);
	}

Now connect the New Map UI button of the editor to the method Openin the New Map Menu object .


Opening the menu by pressing.

Also connect the Cancel button with the method Close. This will allow us to open and close the popup menu.

Creating new maps


To create new maps, we need to call a method in the Hex Grid object CreateMap. In addition, after that we need to close the pop-up menu. Add to the NewMapMenumethod that deals with this, taking into account an arbitrary size.

voidCreateMap (int x, int z) {
		hexGrid.CreateMap(x, z);
		Close();
	}

This method should not be general, because we still cannot connect it directly to button events. Instead, create one method per button that will be called CreateMapwith the specified size. For a small map, I used a size of 20 by 15, corresponding to the size of the map by default. For an average card, I decided to double this size, getting 40 by 30, and double it again for a large card. Connect the buttons with the appropriate methods.

publicvoidCreateSmallMap () {
		CreateMap(20, 15);
	}
	publicvoidCreateMediumMap () {
		CreateMap(40, 30);
	}
	publicvoidCreateLargeMap () {
		CreateMap(80, 60);
	}

Camera lock


Now we can use the pop-up menu to create new maps with three different sizes! Everything works well, but we need to take care of a little detail. When the New Map Menu is active, we can no longer interact with the UI editor and edit the cells. However, we can still control the camera. Ideally, when the menu is open, the camera should be blocked.

Since we only have one camera, a quick and pragmatic solution would be to simply add a static property to it Locked. This solution is not very suitable for widespread use, but it is enough for our simple interface. This requires that we track the static instance inside HexMapCamera, which is set when the camera is awake.

static HexMapCamera instance;
	…
	voidAwake () {
		instance = this;
		swivel = transform.GetChild(0);
		stick = swivel.GetChild(0);
	}

A property Lockedcan be a simple static boolean property only with a setter. All it does is disable the instance HexMapCamerawhen it is locked, and turn it on when it is unlocked.

publicstaticbool Locked {
		set {
			instance.enabled = !value;
		}
	}

Now NewMapMenu.Opencan block the camera, and NewMapMenu.Close- unlock it.

publicvoidOpen () {
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}
	publicvoidClose () {
		gameObject.SetActive(false);
		HexMapCamera.Locked = false;
	}

Keeping the right camera position


There is another probable problem with the camera. When creating a new card that is smaller than the current one, the camera may be beyond the boundaries of the map. She will stay there until the player tries to move the camera. And only then will it become limited to the new card.

To solve this problem, we can add to the HexMapCamerastatic method ValidatePosition. Invoking the AdjustPositionzero offset instance method will force the camera to the map boundaries. If the camera is already inside the borders of the new card, then it will remain in place.

publicstaticvoidValidatePosition () {
		instance.AdjustPosition(0f, 0f);
	}

Call the method inside NewMapMenu.CreateMapafter creating a new map.

voidCreateMap (int x, int z) {
		hexGrid.CreateMap(x, z);
		HexMapCamera.ValidatePosition();
		Close();
	}

unitypackage

Saving the map size


Although we can create maps of different sizes, it is not taken into account when saving and loading. This means that loading a map will result in an error or an incorrect map if the size of the current map does not match the size of the map being loaded.

To solve this problem, before loading the data cells, we need to create a new map of the appropriate size. Let's assume that we have a small map saved. In this case, everything will be fine if we create HexGrid.Loada 20 by 15 map at the beginning .

publicvoidLoad (BinaryReader reader) {
		CreateMap(20, 15);
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader);
		}
		for (int i = 0; i < chunks.Length; i++) {
			chunks[i].Refresh();
		}
	}

Card size storage


Of course, we can store a card of any size. Therefore, a generalized solution will be to preserve the size of the map in front of the cells.

publicvoidSave (BinaryWriter writer) {
		writer.Write(cellCountX);
		writer.Write(cellCountZ);
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Save(writer);
		}
	}

Then we can get the true size and use it to create a map with the correct size.

publicvoidLoad (BinaryReader reader) {
		CreateMap(reader.ReadInt32(), reader.ReadInt32());
		…
	}

Since now we can load maps of different sizes, we again face the problem of the camera position. We will solve it by checking its position in HexMapEditor.Loadafter loading the map.

publicvoidLoad () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header == 0) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

New file format


Although this approach works with maps that we will save in the future, it will not work with old ones. And vice versa - the code from the previous part of the tutorial will not be able to correctly load new map files. To distinguish between old and new formats, we will increase the integer header value. The old format of saving without the size of the card was version 0. The new format with the size of the map will be version 1. Therefore, when recording HexMapEditor.Save, instead of 0, it should record 1.

publicvoidSave () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (
			BinaryWriter writer =
				new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			writer.Write(1);
			hexGrid.Save(writer);
		}
	}

From now on, the maps will be saved as version 1. If we try to open them in the assembly from the previous tutorial, they will refuse to load and report an unknown map format. In fact, this will happen if we try to load such a card. You need to change the method HexMapEditor.Loadso that it accepts the new version.

publicvoidLoad () {
		string path = Path.Combine(Application.persistentDataPath, "test.map");
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header == 1) {
				hexGrid.Load(reader);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

backward compatibility


In fact, if you want, we can still load version 0 maps, assuming that they all have the same size of 20 by 15. That is, the title does not have to be 1, it can be zero. Since each version requires its own approach, it HexMapEditor.Loadmust pass the header to the method HexGrid.Load.

if (header <= 1) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}

Add a HexGrid.Loadheader to the parameter and use it to make decisions about further actions. If the header is at least 1, then you need to read the card size data. Otherwise, use the old fixed size card 20 by 15 and skip reading the size data.

publicvoidLoad (BinaryReader reader, int header) {
		int x = 20, z = 15;
		if (header >= 1) {
			x = reader.ReadInt32();
			z = reader.ReadInt32();
		}
		CreateMap(x, z);
		…
	}

version 0 map file

Check card size


As with the creation of a new map, it is theoretically possible that we will have to download a map that is incompatible with the size of the fragment. When this happens, we must interrupt the loading of the map. HexGrid.CreateMapalready refuses to create a map and displays an error to the console. To tell this to the caller, let's return a bool indicating whether a map has been created.

publicboolCreateMap (int x, int z) {
		if (
			x <= 0 || x % HexMetrics.chunkSizeX != 0 ||
			z <= 0 || z % HexMetrics.chunkSizeZ != 0
		) {
			Debug.LogError("Unsupported map size.");
			returnfalse;
		}
		…
		returntrue;
	}

Now, HexGrid.Loadtoo, can stop executing if the map creation fails.

publicvoidLoad (BinaryReader reader, int header) {
		int x = 20, z = 15;
		if (header >= 1) {
			x = reader.ReadInt32();
			z = reader.ReadInt32();
		}
		if (!CreateMap(x, z)) {
			return;
		}
		…
	}

Since the download overwrites all the data in the existing cells, we do not need to create a new map if the map is loaded the same size. Therefore, this step can be skipped.

if (x != cellCountX || z != cellCountZ) {
			if (!CreateMap(x, z)) {
				return;
			}
		}

unitypackage

File management


We can save and load maps of different sizes, but always write and read test.map . Now we will add support for different files.

Instead of directly saving or loading the map, we use another pop-up menu that provides advanced file management. Create another canvas, as in the New Map Menu , but this time we will call it Save Load Menu . This menu will deal with saving and loading maps, depending on the button pressed to open it.

We will create a Save Load Menu design.as if it's a save menu. Later we will dynamically turn it into the boot menu. Like the other menu, it should have a background and menu bar, menu label and cancel button. Then add scroll view ( GameObject / UI / Scroll View ) to the menu to display a list of files. Below we insert the input field ( GameObject / UI / Input Field ) to specify the names of the new maps. We also need an action button to save the map. And finally. add a Delete button to remove unwanted maps.



Design Save Load Menu.

By default, scroll view allows you to perform both horizontal and vertical scrolling, but we only need a list with vertical scrolling. Therefore, turn off the scrolling Horizontal and disconnect the horizontal scroll bar. Also set the value for the Movement Type clamped and disable Inertia to make the list seem more restrictive.


Parameters File List.

Remove the Scrollbar Horizontal child from the File List object , because we don’t need it. Then change the size of the Scrollbar Vertical so that it reaches the bottom of the list.

The placeholder text of the Name Input object can be changed in its Placeholder child element . I used descriptive text, but you can just leave it blank and get rid of the placeholder.


Changed menu design.

We’ve finished with the design, and now we deactivate the menu so that it is hidden by default.

Menu management


To make the menu work, we need another script, in this case - SaveLoadMenu. As well NewMapMenu, it needs a reference to the grid, as well as methods Openand Close.

using UnityEngine;
publicclassSaveLoadMenu : MonoBehaviour {
	public HexGrid hexGrid;
	publicvoidOpen () {
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}
	publicvoidClose () {
		gameObject.SetActive(false);
		HexMapCamera.Locked = false;
	}
}

Add this component to SaveLoadMenu and give it a link to the grid object.


Component SaveLoadMenu.

The menu will open for saving or loading. To simplify the work, add a Openboolean parameter to the method . It determines whether the menu should be in save mode. We will track this mode in the field to know what action to take later.

bool saveMode;
	publicvoidOpen (bool saveMode) {
		this.saveMode = saveMode;
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

Now combine the buttons Save and Load Object Hex Map Editor with the method Openof the object Save Load the Menu . Check the boolean parameter for the Save button only .


Opening the menu in save mode.

If you have not done so already, connect the Cancel button event to the method Close. Now Save Load Menu can open and close.

Change in appearance


We created the menu as a save menu, but its mode is determined by the button pressed to open. We need to change the appearance of the menu depending on the mode. In particular, we need to change the menu label and the action button label. This means that we will need links to these tags.

using UnityEngine;
using UnityEngine.UI;
publicclassSaveLoadMenu : MonoBehaviour {
	public Text menuLabel, actionButtonLabel;
	…
}


Connection with tags.

When the menu opens in save mode, we use the existing labels, that is, Save Map for the menu and Save for the action button. Otherwise, we are in load mode, that is, we use Load Map and Load .

publicvoidOpen (bool saveMode) {
		this.saveMode = saveMode;
		if (saveMode) {
			menuLabel.text = "Save Map";
			actionButtonLabel.text = "Save";
		}
		else {
			menuLabel.text = "Load Map";
			actionButtonLabel.text = "Load";
		}
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

Enter Card Name


Let's leave a list of files for now. The user can specify the file to be saved or loaded by entering the name of the card in the input field. To obtain this data, we need a reference to the component InputFieldof the Name Input object .

public InputField nameInput;


Connection to the input field.

The user does not need to force to enter the full path to the map file. It will be enough just the name of the map without the .map extension . Let's add a method that takes user input and creates the right path for it. This is not possible when the input is empty, so in this case we will return null.

using UnityEngine;
using UnityEngine.UI;
using System.IO;
publicclassSaveLoadMenu : MonoBehaviour {
	…
	stringGetSelectedPath () {
		string mapName = nameInput.text;
		if (mapName.Length == 0) {
			returnnull;
		}
		return Path.Combine(Application.persistentDataPath, mapName + ".map");
	}
}

What happens if a user enters invalid characters?
Если пользователь введёт не поддерживаемые файловой системой символы, то у нас может получиться неверный путь. Кроме того, пользователь может ввести символ разделения пути, что позволит ему сохраняться и загружаться из неконтролируемых мест.

Для контроля допустимости типа ввода мы можем использовать Content Type полей ввода. Например, мы можем ограничить имена карт только алфавитно-цифровыми символами, хотя это и слишком строго. Также можно использовать собственный тип содержимого, чтобы точно описать то, что допустимо и недопустимо.

Save and Load


Now it will be engaged in saving and loading SaveLoadMenu. Therefore we will move methods Saveand Loadfrom HexMapEditorto SaveLoadMenu. They are no longer required to be shared, and will work with the path parameter instead of the fixed path.

voidSave (string path) {
//		string path = Path.Combine(Application.persistentDataPath, "test.map");using (
			BinaryWriter writer =
			new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			writer.Write(1);
			hexGrid.Save(writer);
		}
	}
	voidLoad (string path) {
//		string path = Path.Combine(Application.persistentDataPath, "test.map");using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header <= 1) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

Since now we are loading arbitrary files, it would be nice to check that the file actually exists, and only then try to read it. If not, then we give an error and stop the operation.

voidLoad (string path) {
		if (!File.Exists(path)) {
			Debug.LogError("File does not exist " + path);
			return;
		}
		…
	}

Now add a general method Action. It starts with getting the path selected by the user. If there is a path, then we save or load it. Then close the menu.

publicvoidAction () {
		string path = GetSelectedPath();
		if (path == null) {
			return;
		}
		if (saveMode) {
			Save(path);
		}
		else {
			Load(path);
		}
		Close();
	}

By attaching an Action Button event to this method , we can save and load using arbitrary map names. Since we do not reset the input field, the selected name will be saved until the next save or load. It is convenient to save or load from one file several times in a row, so we will not change anything.

Map List Items


Next, we will fill in the file list with all the cards that are in the data storage path. When you click on one of the items in the list, it will be used as text in the Name Input . Add in SaveLoadMenufor this a common method.

publicvoidSelectItem (string name) {
		nameInput.text = name;
	}

We need something that is a list item. A regular button will do. Create it and reduce the height to 20 units, so that it does not take up much space vertically. It should not look like a button, so clear the Source Image link of its Image component . At the same time it will become completely white. In addition, we will make the label align to the left and that there is space between the text and the left side of the button. Having finished with button design, we will turn it into prefab.



Button-list item.

We cannot directly connect a button event to the New Map Menu , because this is a prefab and does not exist in the scene yet. Therefore, the menu item needs a link to the menu so that it can call the method when pressed SelectItem. He also needs to keep track of the name of the card he represents, and set his text. Create a small component for this SaveLoadItem.

using UnityEngine;
using UnityEngine.UI;
publicclassSaveLoadItem : MonoBehaviour {
	public SaveLoadMenu menu;
	publicstring MapName {
		get {
			return mapName;
		}
		set {
			mapName = value;
			transform.GetChild(0).GetComponent<Text>().text = value;
		}
	}
	string mapName;
	publicvoidSelect () {
		menu.SelectItem(mapName);
	}
}

Add a component to the menu item and make the button call its method Select.


Component item.

Filling the list


To populate the list, you SaveLoadMenuneed a reference to Content inside the Viewport of the File List object . He also needs a link to the prefab point.

public RectTransform listContent;
	public SaveLoadItem itemPrefab;


Combining the contents of the list and prefab.

To fill this list, we use a new method. The first step is to identify the existing map files. To obtain an array of all file paths within the directory, we can use the method Directory.GetFiles. This method has a second parameter that allows filtering files. In our case, only files corresponding to the * .map mask are required .

voidFillList () {
		string[] paths =
			Directory.GetFiles(Application.persistentDataPath, "*.map");
	}

Unfortunately, file order is not guaranteed. To display them in alphabetical order, we need to sort the array with System.Array.Sort.

using UnityEngine;
using UnityEngine.UI;
using System;
using System.IO;
publicclassSaveLoadMenu : MonoBehaviour {
	…
	voidFillList () {
		string[] paths =
			Directory.GetFiles(Application.persistentDataPath, "*.map");
		Array.Sort(paths);
	}
	…
}

Next, we will create instances of the prefab for each element of the array. Bind the item to the menu, set its card name and make it a child of the list.

		Array.Sort(paths);
		for (int i = 0; i < paths.Length; i++) {
			SaveLoadItem item = Instantiate(itemPrefab);
			item.menu = this;
			item.MapName = paths[i];
			item.transform.SetParent(listContent, false);
		}

Since it Directory.GetFilesreturns the full paths to the files, we need to clear them. Fortunately, this is exactly what a convenient method does Path.GetFileNameWithoutExtension.

			item.MapName = Path.GetFileNameWithoutExtension(paths[i]);

Before displaying the menu, we need to fill out the list. And since the files are likely to change, we need to do this each time the menu is opened.

publicvoidOpen (bool saveMode) {
		…
		FillList();
		gameObject.SetActive(true);
		HexMapCamera.Locked = true;
	}

When re-filling the list, we need to remove all old ones before adding new items.

voidFillList () {
		for (int i = 0; i < listContent.childCount; i++) {
			Destroy(listContent.GetChild(i).gameObject);
		}
		…
	}


Items without placement.

Arrangement of points


Now the list will display items, but they will overlap and be in a bad position. To make them turn into a vertical list, we add a Vertical Layout Group ( Component / Layout / Vertical Layout Group ) component to the Content object of the list . In order for the alignment to work correctly, we enable Width of both the Child Control Size and the Child Force Expand . Both options Height must be disabled.





Use the vertical layout group.

We have a beautiful list of items. However, the size of the contents of the list is not adjusted to the true number of items. Therefore, the scroll bar never changes size. We can make Content automatically resize by adding a Content Size Fitter component ( Component / Layout / Content Size Fitter ) to it. His Vertical Fit mode should be set to Preferred Size .



Use content size fitter.

Now, with a small number of points, the scroll bar will disappear. And when there are too many items on the list that do not fit in the viewing window, the scroll bar appears and has the appropriate size.


A scrollbar appears.

Deleting cards


Now we can conveniently work with a variety of map files. However, sometimes it is necessary to get rid of some cards. To do this, you can use the Delete button . Create a method for this and make the button call it. If there is a selected path, simply delete it with File.Delete.

publicvoidDelete () {
		string path = GetSelectedPath();
		if (path == null) {
			return;
		}
		File.Delete(path);
	}

Here we also need to check that we are working with a really existing file. If this is not the case, then we should not try to delete it, but this does not lead to an error.

if (File.Exists(path)) {
			File.Delete(path);
		}

After removing the card, we do not need to close the menu. It is easier to delete several files at once. However, after deleting, we need to clear the Name Input , as well as update the file list.

if (File.Exists(path)) {
			File.Delete(path);
		}
		nameInput.text = "";
		FillList();

unitypackage

Part 14: relief textures


  • Use the vertex colors to create a splat map.
  • Creating an array of textures.
  • Adding terrain indices to meshes.
  • Transitions between relief textures.

Up to this point, we used solid colors for coloring maps. Now we will apply the texture.


Drawing textures.

Three types mixing


Although homogeneous colors are clearly distinguishable and fully cope with the task, they do not look very interesting. The use of textures will greatly increase the appeal of maps. Of course, for this we have to mix textures, not just colors. In the Rendering 3, Combining Textures tutorial , I talked about how to mix multiple textures using a splat map. In our maps of hexagons, you can use a similar approach.

In tutorial Rendering 3Only four textures are mixed, and with one splat map we can support up to five textures. At the moment we are using five different colors, so this is fine for us. However, we can add other types later. Therefore, we need support for an arbitrary number of terrain types. When using explicit texture properties, this is not possible, so you will have to apply an array of textures. Later we will create it.

When using texture arrays, we somehow need to tell the shader which textures to mix. The most complex mixing is necessary for angular triangles, which can be between three cells with their type of relief. Therefore, we need support for mixing between the three types per triangle.

Using vertex colors as splat maps


Assuming that we can tell you which textures to blend, you can use the vertex colors to create a splat map for each triangle. Since in each of the cases a maximum of three textures are used, we need only three channels of color. Red will represent the first texture, green will represent the second, and blue will represent the third.


Triangle Splat map.

Is the sum of the splat map triangle always equal to one?
Да. Три цветовых канала определяют трилинейную интерполяцию по поверхности треугольника. Они используются как барицентрические координаты. Например, у нас может быть три возможные перестановки (1, 0, 0) в углах, варианты (½, ½, 0) в серединах рёбер и (&frac13;, &frac13;, &frac13;) в центре.

If the triangle needs only one texture, we use only the first channel. That is, its color will be completely red. In the case of mixing between two different types, we use the first and second channels. That is, the color of the triangle will be a mixture of red and green. And when all three types are found, it will be a mixture of red, green and blue.


Three splat map configurations.

We will use these splat map configurations regardless of which textures actually blend. That is, the splat map will always be the same. Only the textures will change. How to do this, we will find out later.

We need to change HexGridChunkit to create these splat maps, rather than using the colors of the cells. Since we will often use three colors, we will create static fields for them.

static Color color1 = new Color(1f, 0f, 0f);
	static Color color2 = new Color(0f, 1f, 0f);
	static Color color3 = new Color(0f, 0f, 1f);

Cell Centers


Let's start with replacing the color of the cell centers by default. There is no blending here, so we just use the first color, that is, red.

voidTriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, color1);
		…
	}


Red cell centers.

The cell centers are now red. All of them use the first of the three textures, no matter what the texture is. Their splat maps are the same, regardless of the color with which we paint the cells.

River Neighborhood


We changed the segments only inside the cells without rivers flowing through them. We need to do the same for the segments adjacent to the rivers. In our case, this is a strip of an edge, and a fan of edge triangles. Here we also need only red.

voidTriangulateAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		TriangulateEdgeStrip(m, color1, e, color1);
		TriangulateEdgeFan(center, m, color1);
		…
	}


Red segments next to rivers.

Rivers


Next we need to take care of the geometry of the rivers inside the cells. All of them should also turn red. For a start, let's start the beginning and end of the rivers.

voidTriangulateWithRiverBeginOrEnd (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		TriangulateEdgeStrip(m, color1, e, color1);
		TriangulateEdgeFan(center, m, color1);
		…
	}

And then the geometry that makes up the banks and the river bed. I grouped calls to the color method to make the code easier to read.

voidTriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		TriangulateEdgeStrip(m, color1, e, color1);
		terrain.AddTriangle(centerL, m.v1, m.v2);
//		terrain.AddTriangleColor(cell.Color);
		terrain.AddQuad(centerL, center, m.v2, m.v3);
//		terrain.AddQuadColor(cell.Color);
		terrain.AddQuad(center, centerR, m.v3, m.v4);
//		terrain.AddQuadColor(cell.Color);
		terrain.AddTriangle(centerR, m.v4, m.v5);
//		terrain.AddTriangleColor(cell.Color);
		terrain.AddTriangleColor(color1);
		terrain.AddQuadColor(color1);
		terrain.AddQuadColor(color1);
		terrain.AddTriangleColor(color1);
		…
	}


Red rivers along the cells.

Ribs


All edges are different because they are between cells, which may have different types of relief. We use the first color for the current cell type, and the second color for the neighbor type. As a result, the splat map will become a red-green gradient, even if both cells are of the same type. If both cells use the same texture, it will simply become a mixture of the same texture on both sides.

voidTriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		…
		if (cell.GetEdgeType(direction) == HexEdgeType.Slope) {
			TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad);
		}
		else {
			TriangulateEdgeStrip(e1, color1, e2, color2, hasRoad);
		}
		…
	}


Red-green edges, with the exception of ledges.

Doesn't a sharp transition between red and green cause problems?
Несмотря на то, что переход на ребре происходит от красного до зелёного, центры ячеек с обеих сторон красные. Поэтому с одной стороны ребра должен появиться разрыв. Но это всего лишь splat map, цвета соседних треугольников не обязаны привязываться к одной текстуре. В этом случае соответствующее зелёному с одной стороны соответствует красному с другой.

Стоит заметить, что это не было бы возможно при треугольниках с общими вершинами.

The edges with ledges are slightly more complicated, because they have additional vertices. Fortunately, the existing interpolation code works fine with the splat map colors. Just use the first and second colors, not the colors of the cells of the beginning and end.

voidTriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell,
		bool hasRoad
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color c2 = HexMetrics.TerraceLerp(color1, color2, 1);
		TriangulateEdgeStrip(begin, color1, e2, c2, hasRoad);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color c1 = c2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			c2 = HexMetrics.TerraceLerp(color1, color2, i);
			TriangulateEdgeStrip(e1, c1, e2, c2, hasRoad);
		}
		TriangulateEdgeStrip(e2, c2, end, color2, hasRoad);
	}


Red-green edge ledges.

Corners


The corners of the cells are the most difficult because they have to mix three different textures. We use red for the bottom top, green for the left and blue for the right. Let's start with the corners of one triangle.

voidTriangulateCorner (
		Vector3 bottom, HexCell bottomCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		else {
			terrain.AddTriangle(bottom, left, right);
			terrain.AddTriangleColor(color1, color2, color3);
		}
		features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
	}


Red-green-blue corners, with the exception of ledges.

Here we can again use the existing color interpolation code for ledges. Just interpolation is performed between three, not two colors. First consider the ledges that are not located near the cliffs.

voidTriangulateCornerTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1);
		Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1);
		Color c3 = HexMetrics.TerraceLerp(color1, color2, 1);
		Color c4 = HexMetrics.TerraceLerp(color1, color3, 1);
		terrain.AddTriangle(begin, v3, v4);
		terrain.AddTriangleColor(color1, c3, c4);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v3;
			Vector3 v2 = v4;
			Color c1 = c3;
			Color c2 = c4;
			v3 = HexMetrics.TerraceLerp(begin, left, i);
			v4 = HexMetrics.TerraceLerp(begin, right, i);
			c3 = HexMetrics.TerraceLerp(color1, color2, i);
			c4 = HexMetrics.TerraceLerp(color1, color3, i);
			terrain.AddQuad(v1, v2, v3, v4);
			terrain.AddQuadColor(c1, c2, c3, c4);
		}
		terrain.AddQuad(v3, v4, left, right);
		terrain.AddQuadColor(c3, c4, color2, color3);
	}


Red-green-blue corner ledges, with the exception of ledges along the cliffs.

When it comes to cliffs, we need to use the method TriangulateBoundaryTriangle. This method received as parameters the initial and left cell. However, now we need the appropriate splat colors, which can vary depending on the topology. Therefore, we replace these parameters with colors.

voidTriangulateBoundaryTriangle (
		Vector3 begin, Color beginColor,
		Vector3 left, Color leftColor,
		Vector3 boundary, Color boundaryColor
	) {
		Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1));
		Color c2 = HexMetrics.TerraceLerp(beginColor, leftColor, 1);
		terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary);
		terrain.AddTriangleColor(beginColor, c2, boundaryColor);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color c1 = c2;
			v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i));
			c2 = HexMetrics.TerraceLerp(beginColor, leftColor, i);
			terrain.AddTriangleUnperturbed(v1, v2, boundary);
			terrain.AddTriangleColor(c1, c2, boundaryColor);
		}
		terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary);
		terrain.AddTriangleColor(c2, leftColor, boundaryColor);
	}

Change it TriangulateCornerTerracesCliffso that it uses the right colors.

voidTriangulateCornerTerracesCliff (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		Color boundaryColor = Color.Lerp(color1, color3, b);
		TriangulateBoundaryTriangle(
			begin, color1, left, color2, boundary, boundaryColor
		);
		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, color2, right, color3, boundary, boundaryColor
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleColor(color2, color3, boundaryColor);
		}
	}

And do the same for TriangulateCornerCliffTerraces.

voidTriangulateCornerCliffTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		Color boundaryColor = Color.Lerp(color1, color2, b);
		TriangulateBoundaryTriangle(
			right, color3, begin, color1, boundary, boundaryColor
		);
		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, color2, right, color3, boundary, boundaryColor
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleColor(color2, color3, boundaryColor);
		}
	}


Full splat map of relief.

unitypackage

Texture Arrays


Now that our terrain has a splat map, we can pass on a shader collection of textures. We cannot simply assign a C # texture array to the shader, because the array must exist in the GPU memory as a single entity. We have to use a special object.Texture2DArray which is supported in Unity since version 5.4.

Do all GPUs support texture arrays?
Современные GPU их поддерживают, но старые и некоторые мобильные могут и не поддерживать. Вот список поддерживаемых платформ согласно документации Unity.
  • Direct3D 11/12 (Windows, Xbox One)
  • OpenGL Core (Mac OS X, Linux)
  • Metal (iOS, Mac OS X)
  • OpenGL ES 3.0 (Android, iOS, WebGL 2.0)
  • PlayStation 4


Master


Unfortunately, Unity Editor support for texture arrays in version 5.5 is minimal. We cannot simply create an array of textures and assign textures to it. We have to do it manually. We can either create an array of textures in Play mode, or create an asset in the editor. Let's create an asset.

Why create an asset?
Преимущество использования ассета в том, что нам не придётся тратить время в режиме Play на создание массива текстур. Нам не нужно добавлять отдельные текстуры в сборки, а только скопировать их и больше не использовать.

Недостаток ассетов заключается в том, что пользовательский ассет фиксирован. Unity не меняет автоматически его формат текстур в зависимости от целевой платформы сборки. Поэтому придётся создавать ассет с правильным форматом текстур и вручную переделывать его, если потребуется другой формат. Разумеется, это можно автоматизировать с помощью скрипта сборки.

To create an array of textures, we will assemble our own master. Create a script TextureArrayWizardand place it inside the Editor folder . Instead, MonoBehaviourit must extend the type ScriptableWizardfrom the namespace UnityEditor.

using UnityEditor;
using UnityEngine;
publicclassTextureArrayWizard : ScriptableWizard {
}

We can open the wizard through a generic static method ScriptableWizard.DisplayWizard. Its parameters are the names of the wizard window and its create buttons. We will call this method in a static method CreateWizard.

staticvoidCreateWizard () {
		ScriptableWizard.DisplayWizard<TextureArrayWizard>(
			"Create Texture Array", "Create"
		);
	}

To access the wizard through the editor, we need to add this method to the Unity menu. This can be done by adding an attribute to the method MenuItem. Let's add it to the Assets menu , and more specifically in Assets / Create / Texture Array .

	[MenuItem("Assets/Create/Texture Array")]
	staticvoidCreateWizard () {
		…
	}


Our custom master.

Using the new menu item, you can open the popup menu of our custom wizard. It is not very beautiful, but it is suitable for solving the problem. However, it is still empty. To create an array of textures, we need an array of textures. Add a general field for him to the master. The standard wizard GUI will display it, as the standard inspector does.

public Texture2D[] textures;


Wizard with textures.

Create something


When you click the Create button of the wizard, it will disappear. In addition, Unity will complain that there is no method OnWizardCreate. This is the method that is called when the create button is clicked, so we need to add it to the wizard.

voidOnWizardCreate () {
	}

Here we will create our array of textures. At least, if the user added texture to the master. If not, then there is nothing to create and the work must be stopped.

voidOnWizardCreate () {
		if (textures.Length == 0) {
			return;
		}
	}

The next step is to ask where to save the texture array array. The save file panel can be opened by the method EditorUtility.SaveFilePanelInProject. Its parameters define the panel name, default file name, file extension, and description. For texture arrays, the general asset file extension is used .

if (textures.Length == 0) {
			return;
		}
		EditorUtility.SaveFilePanelInProject(
			"Save Texture Array", "Texture Array", "asset", "Save Texture Array"
		);

SaveFilePanelInProjectreturns the file path selected by the user. If the user clicked cancel on this panel, the path will be an empty string. Therefore, in this case, we must interrupt the work.

string path = EditorUtility.SaveFilePanelInProject(
			"Save Texture Array", "Texture Array", "asset", "Save Texture Array"
		);
		if (path.Length == 0) {
			return;
		}

Creating an array of textures


If we have the right path, then we can move on and create a new object Texture2DArray. His constructor method requires specifying the width and height of the texture, the length of the array, the format of the textures, and the need for mipmapping. These parameters should be the same for all textures in the array. To configure the object we use the first texture. The user himself must check that all textures have the same format.

if (path.Length == 0) {
			return;
		}
		Texture2D t = textures[0];
		Texture2DArray textureArray = new Texture2DArray(
			t.width, t.height, textures.Length, t.format, t.mipmapCount > 1
		);

Since the texture array is a single GPU resource, it uses the same filtering and folding modes for all textures. Here we again use the first texture to adjust all this.

		Texture2DArray textureArray = new Texture2DArray(
			t.width, t.height, textures.Length, t.format, t.mipmapCount > 1
		);
		textureArray.anisoLevel = t.anisoLevel;
		textureArray.filterMode = t.filterMode;
		textureArray.wrapMode = t.wrapMode;

Now we can copy the textures into an array using the method Graphics.CopyTexture. The method copies raw texture data, one mip level at a time. Therefore, we need to loop around all the textures and their mip levels. Method parameters are two sets consisting of a texture resource, an index and a mip-level. Since the original textures are not arrays, their index is always zero.

		textureArray.wrapMode = t.wrapMode;
		for (int i = 0; i < textures.Length; i++) {
			for (int m = 0; m < t.mipmapCount; m++) {
				Graphics.CopyTexture(textures[i], 0, m, textureArray, i, m);
			}
		}

At this stage, we have in memory the correct array of textures, but it is not yet an asset. The final step will be a call AssetDatabase.CreateAssetwith an array and its path. The data will be written to a file in our project, and it will appear in the project window.

for (int i = 0; i < textures.Length; i++) {
			…
		}
		AssetDatabase.CreateAsset(textureArray, path);


Textures


To create a real array of textures, we need the original textures. Here are five textures that match the colors we used up to this point. Yellow becomes sand, green becomes grass, blue becomes earth, orange becomes stone, and white becomes snow.






Textures of sand, grass, earth, stone and snow.

Note that these textures are not photographs of this relief. These are light pseudo-random patterns that I created using NumberFlow . I tried to create recognizable types and relief details that do not conflict with abstract polygonal relief. Photorealism turned out to be inappropriate. In addition, despite the fact that the patterns add variability, they have few distinct features that would make repetitions immediately noticeable.

Add these textures to the wizard array, making sure that their order matches the colors. That is, first sand, then grass, earth, stone, and finally snow.



Create an array of textures.

After creating an asset of the array of textures, select it and consider it in the inspector.


Texture array inspector.

This is the simplest display of a piece of texture array data. Notice that there is an Is Readable switch that is initially enabled. Since we do not need to read the pixel data from the array, turn it off. We cannot do this in the wizard, because there are Texture2DArrayno methods or properties to access this parameter.

(In Unity 5.6 there is a bug that corrupts texture arrays in assemblies on several platforms. You can bypass it without disabling Is Readable .)

It is also worth noting that there is a Color Space field , Which is set to 1. This means that the textures are assumed to be in gamma space, which is true. If they were to be in linear space, then the field had to be assigned the value 0. In fact, the constructorTexture2DArrayThere is an additional parameter for specifying a color space, but it Texture2Ddoes not indicate whether it is in a linear space or not, therefore, in any case, you need to set the value manually.

Shader


Now that we have an array of textures, we need to teach the shader to work with it. So far, for rendering terrain we use the VertexColors shader . Since now instead of colors we use textures, rename it to Terrain . Then turn its _MainTex parameter into an array of textures and assign an asset to it.

Shader"Custom/Terrain" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
	}
	…
}


Material relief with an array of textures.

To enable texture arrays on all platforms supporting them, increase the target level of the shader from 3.0 to 3.5.

#pragma target 3.5

Since the variable _MainTexnow refers to an array of textures, we need to change its type. The type depends on the target platform and the macro will take care of this UNITY_DECLARE_TEX2DARRAY.

//		sampler2D _MainTex;
		UNITY_DECLARE_TEX2DARRAY(_MainTex);

As in other shaders, for sampling the textures of the relief we need the coordinates of the XZ world. Therefore, we will add a position in the world to the input shader structure. We also remove the default UV coordinates, because we don’t need them.

structInput{
//			float2 uv_MainTex;
			float4 color : COLOR;
			float3 worldPos;
		};

To sample an array of textures, we need to use a macro UNITY_SAMPLE_TEX2DARRAY. It requires three coordinates to sample the array. The first two are the usual UV coordinates. We will use the XZ coordinates of the world, scaled to 0.02. This will give you good texture resolution at full magnification. Textures will be repeated approximately every four cells.

The third coordinate is used as an index of an array of textures, as in a regular array. Since the coordinates have float values, before indexing the array, the GPU rounds them. Since we do not know yet what texture is needed, let's always use the first one. Also, the color of the vertex will not affect the final result, because it is a splat map.

void surf (InputIN, inout SurfaceOutputStandard o) {
			float2 uv = IN.worldPos.xz * 0.02;
			fixed4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, float3(uv, 0));
			Albedo = c.rgb * _Color;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


Everything became sand.

unitypackage

Texture selection


We need a splat map of relief, mixing three types per triangle. We have an array of textures with texture for each type of terrain. We have a shader that samples the array of textures. But so far we have no opportunity to tell the shader which textures to choose for each triangle.

Since each triangle mixes up to three types in itself, we need to associate three indices with each triangle. We cannot store information for triangles, so we have to store indexes for vertices. All three vertices of the triangle will simply store the same indices as in the case of solid color.

Mesh data


We can use one of the UV mesh sets for storing indexes. Since three indexes are stored at each vertex, the existing 2D UV sets will not be enough. Fortunately, UV sets can contain up to four coordinates. Therefore, we will add to the HexMeshsecond list Vector3, which we will refer to as relief types.

publicbool useCollider, useColors, useUVCoordinates, useUV2Coordinates;
	publicbool useTerrainTypes;
	[NonSerialized] List<Vector3> vertices, terrainTypes;

Enable terrain types for the Hex Grid Chunk Prefab Sub Terrain child .


We use relief types.

If necessary, we will take another list Vector3for terrain types during mesh cleaning.

publicvoidClear () {
		…
		if (useTerrainTypes) {
			terrainTypes = ListPool<Vector3>.Get();
		}
		triangles = ListPool<int>.Get();
	}

In the process of applying the mesh data, we save the terrain types in the third UV set. Because of this, they will not interfere with the other two sets if we ever decide to use them together.

publicvoidApply () {
		…
		if (useTerrainTypes) {
			hexMesh.SetUVs(2, terrainTypes);
			ListPool<Vector3>.Add(terrainTypes);
		}
		hexMesh.SetTriangles(triangles, 0);
		…
	}

To set the types of relief triangle, we use Vector3. Since the same triangle is the same for the whole triangle, we simply add the same data three times.

publicvoidAddTriangleTerrainTypes (Vector3 types) {
		terrainTypes.Add(types);
		terrainTypes.Add(types);
		terrainTypes.Add(types);
	}

Mixing in quad works similarly. All four vertices have the same types.

publicvoidAddQuadTerrainTypes (Vector3 types) {
		terrainTypes.Add(types);
		terrainTypes.Add(types);
		terrainTypes.Add(types);
		terrainTypes.Add(types);
	}

Fan triangles edges


Now we need to add types to the mesh data in HexGridChunk. Let's start with TriangulateEdgeFan. First, for the sake of better readability, we divide the calls to the vertex and color methods. Recall that with each call of this method we pass to it color1, so we can use this color directly, and not use the parameter.

voidTriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) {
		terrain.AddTriangle(center, edge.v1, edge.v2);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangle(center, edge.v2, edge.v3);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangle(center, edge.v3, edge.v4);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangle(center, edge.v4, edge.v5);
//		terrain.AddTriangleColor(color);
		terrain.AddTriangleColor(color1);
		terrain.AddTriangleColor(color1);
		terrain.AddTriangleColor(color1);
		terrain.AddTriangleColor(color1);
	}

After the colors we add terrain types. Since the types in the triangle can be different, it must be a parameter that replaces the color. Use this simple type to create Vector3. Only the first four channels are important to us, because in this case the splat map is always red. Since all three components of the vector need to be assigned something, let's assign one type to them.

voidTriangulateEdgeFan (Vector3 center, EdgeVertices edge, float type) {
		…
		Vector3 types;
		types.x = types.y = types.z = type;
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);
		terrain.AddTriangleTerrainTypes(types);
	}

Now we need to change all calls to this method, replacing the color argument with the index of the type of the cell relief. Make this change to TriangulateWithoutRiver, TriangulateAdjacentToRiverand TriangulateWithRiverBeginOrEnd.

//		TriangulateEdgeFan(center, e, color1);
		TriangulateEdgeFan(center, e, cell.TerrainTypeIndex);

At this stage, when you start the Play mode, errors will appear, indicating that third sets of UV meshes are out of bounds. It happened because we are not adding relief types to each triangle and quad. So let's continue to change HexGridChunk.

Rib stripes


Now, when creating the edge strip, we need to know what types of relief are on both sides. Therefore, we add them as parameters, and then create a vector of types, the two channels of which are assigned these types. The third channel is not important,

Also popular now: