Parties 1-3: maillage, couleurs et hauteurs de celluleParties 4-7: bosses, rivières et routesParties 8-11: eau, reliefs et rempartsParties 12-15: sauvegarde et chargement, textures, distancesParties 16-19: trouver le chemin, équipes de joueurs, animationsParties 20-23: Brouillard de guerre, recherche cartographique, génération procéduraleParties 24-27: cycle de l'eau, érosion, biomes, carte cylindriquePartie 12: enregistrer et charger
- Suivez le type de terrain au lieu de la couleur.
- Créez un fichier.
- Nous écrivons les données dans un fichier, puis les lisons.
- Nous sérialisons les données des cellules.
- Réduisez la taille du fichier.
Nous savons déjà comment créer des cartes assez intéressantes. Vous devez maintenant apprendre à les enregistrer.
Chargé à partir du fichier test.map .Type de terrain
Lors de l'enregistrement d'une carte, nous n'avons pas besoin de stocker toutes les données que nous suivons pendant l'application. Par exemple, nous devons seulement nous souvenir du niveau de hauteur de cellule. Sa position verticale elle-même est prise à partir de ces données, vous n'avez donc pas besoin de les stocker. En fait, c'est mieux si nous ne stockons pas ces statistiques calculées. Ainsi, les données cartographiques resteront correctes, même si plus tard nous décidons de changer le décalage de hauteur. Les données sont distinctes de leur présentation.
De même, nous n'avons pas besoin de stocker la couleur exacte de la cellule. Vous pouvez écrire que la cellule est verte. Mais la nuance exacte du vert peut changer avec un changement de style visuel. Pour ce faire, nous pouvons enregistrer l'index des couleurs, pas les couleurs elles-mêmes. En fait, il peut être suffisant pour nous de stocker cet index au lieu de vraies couleurs dans les cellules lors de l'exécution. Cela permettra plus tard de passer à une visualisation plus complexe du relief.
Déplacer un tableau de couleurs
Si les cellules n'ont plus de données de couleur, elles doivent être stockées ailleurs. Il est plus pratique de le stocker dans
HexMetrics
. Ajoutons-y donc un tableau de couleurs.
public static Color[] colors;
Comme toutes les autres données globales, telles que le bruit, nous pouvons initialiser ces couleurs avec
HexGrid
.
public Color[] colors; … void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; … } … void OnEnable () { if (!HexMetrics.noiseSource) { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; } }
Et puisque maintenant nous n'affectons pas de couleurs directement aux cellules, nous nous débarrasserons de la couleur par défaut.
Définissez les nouvelles couleurs pour qu'elles correspondent au tableau général de l'éditeur de carte hexagonal.
Couleurs ajoutées à la grille.Refactoring cellulaire
Supprimez le champ de couleur de
HexCell
. Au lieu de cela, nous stockons l'index. Au lieu d'un indice de couleur, nous utilisons un indice de type de relief plus général.
La propriété color ne peut utiliser cet index que pour obtenir la couleur correspondante. Maintenant, il n'est pas défini directement, supprimez cette partie. Dans ce cas, nous obtenons une erreur de compilation, que nous corrigerons bientôt.
public Color Color { get { return HexMetrics.colors[terrainTypeIndex]; }
Ajoutez une nouvelle propriété pour obtenir et définir un nouvel index de type d'élévation.
public int TerrainTypeIndex { get { return terrainTypeIndex; } set { if (terrainTypeIndex != value) { terrainTypeIndex = value; Refresh(); } } }
Refactorisation de l'éditeur
Dans
HexMapEditor
tout le code concernant les couleurs. Cela corrigera l'erreur de compilation.
Ajoutez maintenant un champ et une méthode pour contrôler l'index de type d'élévation actif.
int activeTerrainTypeIndex; … public void SetTerrainTypeIndex (int index) { activeTerrainTypeIndex = index; }
Nous utilisons cette méthode en remplacement de la méthode
SelectColor
manque maintenant. Connectez les widgets de couleur dans l'interface utilisateur avec
SetTerrainTypeIndex
, en laissant tout le reste inchangé. Cela signifie qu'un indice négatif est toujours utilisé et que la couleur ne doit pas changer.
Modifiez
EditCell
pour que l'index de type d'élévation soit affecté à la cellule en cours de modification.
void EditCell (HexCell cell) { if (cell) { if (activeTerrainTypeIndex >= 0) { cell.TerrainTypeIndex = activeTerrainTypeIndex; } … } }
Bien que nous ayons supprimé les données de couleur des cellules, la carte devrait fonctionner de la même manière qu'auparavant. La seule différence est que la couleur par défaut est désormais la première du tableau. Dans mon cas, c'est jaune.
Le jaune est la nouvelle couleur par défaut.paquet d'unitéEnregistrement de données dans un fichier
Pour contrôler l'enregistrement et le chargement de la carte, nous utilisons
HexMapEditor
. Nous allons créer deux méthodes pour ce faire et les laisser pour l'instant vides.
public void Save () { } public void Load () { }
Ajoutez deux boutons à l'interface utilisateur (
GameObject / UI / Button ). Connectez-les aux boutons et donnez les étiquettes appropriées. Je les ai placés en bas du panneau de droite.
Boutons Enregistrer et Charger.Emplacement du fichier
Pour stocker une carte, vous devez l'enregistrer quelque part. Comme c'est le cas dans la plupart des jeux, nous stockons les données dans un fichier. Mais où placer ce fichier dans le système de fichiers? La réponse dépend du système d'exploitation sur lequel le jeu fonctionne. Chaque système d'exploitation a ses propres normes de stockage des fichiers liés aux applications.
Nous n'avons pas besoin de connaître ces normes. Unity connaît le bon chemin que nous pouvons obtenir avec
Application.persistentDataPath
. Vous pouvez vérifier comment ce sera avec vous, dans la méthode
Save
, l'afficher dans la console et appuyer sur le bouton en mode Play.
public void Save () { Debug.Log(Application.persistentDataPath); }
Sur les systèmes de bureau, le chemin d'accès contiendra le nom de l'entreprise et du produit. Ce chemin est utilisé à la fois par l'éditeur et par l'assembly. Les noms peuvent être configurés dans
Edition / Paramètres du projet / Lecteur .
Nom de l'entreprise et du produit.Pourquoi ne puis-je pas trouver le dossier Bibliothèque sur Mac?Le dossier Bibliothèque est souvent masqué. La façon dont il peut être affiché dépend de la version d'OS X. Si vous ne disposez pas d'une ancienne version, sélectionnez le dossier de départ dans le Finder et accédez à Afficher les options d'affichage . Il y a une case à cocher pour le dossier Bibliothèque .
Et WebGL?Les jeux WebGL ne peuvent pas accéder au système de fichiers de l'utilisateur. Au lieu de cela, toutes les opérations sur les fichiers sont redirigées vers un système de fichiers situé en mémoire. Elle est transparente pour nous. Cependant, pour enregistrer les données, vous devrez commander manuellement la page Web pour vider les données dans le stockage du navigateur.
Création de fichiers
Pour créer un fichier, nous devons utiliser des classes de l'espace de noms
System.IO
. Par conséquent, nous ajoutons une instruction
using
pour cela sur la classe
HexMapEditor
.
using UnityEngine; using UnityEngine.EventSystems; using System.IO; public class HexMapEditor : MonoBehaviour { … }
Nous devons d'abord créer le chemin d'accès complet au fichier. Nous utilisons
test.map comme
nom de fichier. Il doit être ajouté au chemin des données stockées. Que vous ayez besoin d'insérer une barre oblique ou une barre oblique inverse (barre oblique ou barre oblique inversée) dépend de la plate-forme. La méthode
Path.Combine
fera
Path.Combine
.
public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); }
Ensuite, nous devons accéder au fichier à cet emplacement. Nous le faisons en utilisant la méthode
File.Open
. Puisque nous voulons écrire des données dans ce fichier, nous devons utiliser son mode de création. Dans ce cas, soit un nouveau fichier sera créé sur le chemin spécifié, soit un fichier existant sera remplacé.
string path = Path.Combine(Application.persistentDataPath, "test.map"); File.Open(path, FileMode.Create);
Le résultat de l'appel de cette méthode sera un flux de données ouvert associé à ce fichier. Nous pouvons l'utiliser pour écrire des données dans un fichier. Et nous ne devons pas oublier de fermer le flux lorsque nous n'en avons plus besoin.
string path = Path.Combine(Application.persistentDataPath, "test.map"); Stream fileStream = File.Open(path, FileMode.Create); fileStream.Close();
À ce stade, lorsque vous cliquez sur le bouton
Enregistrer , le fichier
test.map est créé dans le dossier spécifié comme chemin d'accès aux données stockées. Si vous étudiez ce fichier, il sera vide et aura une taille de 0 octet, car jusqu'à présent nous n'y avons rien écrit.
Écrire dans un fichier
Pour écrire des données dans un fichier, nous avons besoin d'un moyen pour y diffuser des données. La façon la plus simple de le faire est d'
BinaryWriter
. Ces objets vous permettent d'écrire des données primitives dans n'importe quel flux.
Créez un nouvel objet
BinaryWriter
, et notre flux de fichiers sera son argument. L'écrivain de fermeture ferme le flux qu'il utilise. Par conséquent, nous n'avons plus besoin de stocker un lien direct vers le flux.
string path = Path.Combine(Application.persistentDataPath, "test.map"); BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Close();
Pour transférer des données vers un flux, nous pouvons utiliser la méthode
BinaryWriter.Write
. Il existe une variante de la méthode
Write
pour tous les types primitifs, tels que entier et flottant. Il peut également enregistrer des lignes. Essayons d'écrire un entier 123.
BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)); writer.Write(123); writer.Close();
Cliquez sur le bouton
Enregistrer et examinez à nouveau
test.map . Maintenant, sa taille est de 4 octets, car la taille entière est de 4 octets.
Pourquoi mon gestionnaire de fichiers montre-t-il que le fichier occupe plus d'espace?Parce que les systèmes de fichiers divisent l'espace en blocs d'octets. Ils ne suivent pas les octets individuels. Comme test.map ne prend que quatre octets jusqu'à présent, il nécessite un bloc d'espace de stockage.
Notez que nous stockons des données binaires, pas du texte lisible par l'homme. Par conséquent, si nous ouvrons le fichier dans un éditeur de texte, nous verrons un ensemble de caractères indistincts. Vous verrez probablement le symbole
{ suivi de rien ou de quelques espaces réservés.
Vous pouvez ouvrir le fichier dans un éditeur hexadécimal. Dans ce cas, nous verrons
7b 00 00 00 . Ce sont quatre octets de notre entier, mappés en notation hexadécimale. En nombres décimaux ordinaires, c'est
123 0 0 0 . En binaire, le premier octet ressemble à
01111011 .
Le code ASCII pour
{ est 123, ce caractère peut donc être affiché dans un éditeur de texte. ASCII 0 est un caractère nul qui ne correspond à aucun caractère visible.
Les trois octets restants sont égaux à zéro, car nous avons écrit un nombre inférieur à 256. Si nous écrivions 256, nous verrions
00 01 00 00 dans l'éditeur hexadécimal.
Le 123 ne doit-il pas être enregistré sous 00 00 00 7b?BinaryWriter
utilise le format little-endian pour enregistrer les nombres. Cela signifie que les octets les moins significatifs sont écrits en premier. Ce format a été utilisé par Microsoft dans le développement du framework .Net. Il a probablement été choisi car le processeur Intel utilise le format little-endian.
Une alternative est le big-endian, dans lequel les octets les plus significatifs sont stockés en premier. Cela correspond à l'ordre habituel des nombres en nombres. 123 est cent vingt-trois parce que nous voulons dire le record du big-endian. S'il s'agissait d'un petit endian, alors 123 signifierait trois cent vingt et un.
Nous rendons les ressources gratuites
Il est important que nous fermions l'auteur. Lorsqu'il est ouvert, le système de fichiers verrouille le fichier, empêchant d'autres processus d'y écrire. Si nous oublions de le fermer, nous nous bloquerons aussi. Si nous appuyons deux fois sur le bouton Enregistrer, la deuxième fois, nous ne pourrons pas ouvrir le flux.
Au lieu de fermer le rédacteur manuellement, nous pouvons créer un bloc
using
pour cela. Il définit la portée dans laquelle le rédacteur est valide. Lorsque le code exécutable dépasse cette portée, le rédacteur est supprimé et le thread est fermé.
using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(123); }
Cela fonctionnera car les classes d'écriture et de flux de fichiers implémentent l'interface
IDisposable
. Ces objets ont une méthode
Dispose
, qui est indirectement appelée lorsqu'ils dépassent le cadre de l'
using
.
Le gros avantage de l'
using
est qu'elle fonctionne quelle que soit la façon dont le programme est hors de portée. Les retours précoces, les exceptions et les erreurs ne le dérangent pas. De plus, il est très concis.
Récupération de données
Pour lire des données écrites précédemment, nous devons insérer le code dans la méthode
Load
. Comme dans le cas de l'enregistrement, nous devons créer un chemin d'accès et ouvrir le flux de fichiers. La différence est que maintenant nous ouvrons le fichier en lecture et non en écriture. Et au lieu d'écrivain, nous avons besoin de
BinaryReader
.
public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryReader reader = new BinaryReader(File.Open(path, FileMode.Open)) ) { } }
Dans ce cas, nous pouvons utiliser la méthode
File.OpenRead
pour ouvrir le fichier en lecture.
using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { }
Pourquoi ne pouvons-nous pas utiliser File.OpenWrite lors de l'écriture?Cette méthode crée un flux qui ajoute des données aux fichiers existants, plutôt que de les remplacer.
Lors de la lecture, nous devons indiquer explicitement le type de données reçues. Pour lire un entier dans un flux, nous devons utiliser
BinaryReader.ReadInt32
. Cette méthode lit un entier 32 bits, c'est-à-dire quatre octets.
using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { Debug.Log(reader.ReadInt32()); }
Il est à noter que lors de la réception de
123, il nous suffira de lire un octet. Mais en même temps, trois octets appartenant à cet entier resteront dans le flux. De plus, cela ne fonctionnera pas pour les nombres en dehors de l'intervalle 0–255. Par conséquent, ne le faites pas.
paquet d'unitéÉcriture et lecture de données cartographiques
Lors de l'enregistrement des données, une question importante est de savoir s'il faut utiliser un format lisible par l'homme. En règle générale, les formats lisibles par l'homme sont JSON, XML et ASCII ordinaire avec une sorte de structure. Ces fichiers peuvent être ouverts, interprétés et modifiés dans des éditeurs de texte. De plus, ils simplifient l'échange de données entre différentes applications.
Cependant, ces formats ont leurs propres exigences. Les fichiers prendront plus d'espace (parfois beaucoup plus) que l'utilisation de données binaires. Ils peuvent également augmenter considérablement le coût de l'encodage et du décodage des données, tant en termes d'exécution que d'empreinte mémoire.
En revanche, les données binaires sont compactes et rapides. Ceci est important lors de l'enregistrement de grandes quantités de données. Par exemple, lors de l'enregistrement automatique d'une grande carte à chaque tour de jeu. Par conséquent
nous utiliserons le format binaire. Si vous pouvez gérer cela, vous pouvez travailler avec des formats plus détaillés.
Qu'en est-il de la sérialisation automatique?Immédiatement pendant le processus de sérialisation des données Unity, nous pouvons directement écrire des classes sérialisées dans le flux. Les détails de l'enregistrement des champs individuels nous seront cachés. Cependant, nous ne pouvons pas sérialiser directement les cellules. Ce sont des classes MonoBehaviour
qui contiennent des données que nous n'avons pas besoin de sauvegarder. Par conséquent, nous devons utiliser une hiérarchie d'objets distincte, ce qui détruit la simplicité de la sérialisation automatique. En outre, il sera plus difficile de prendre en charge les modifications de code futures. Par conséquent, nous conserverons un contrôle total avec la sérialisation manuelle. De plus, cela nous fera vraiment comprendre ce qui se passe.
Pour sérialiser la carte, nous devons stocker les données de chaque cellule. Pour enregistrer et charger une seule cellule, ajoutez les méthodes
Save
et
Load
à
HexCell
. Puisqu'ils ont besoin d'un écrivain ou d'un lecteur pour fonctionner, nous les ajouterons comme paramètres.
using UnityEngine; using System.IO; public class HexCell : MonoBehaviour { … public void Save (BinaryWriter writer) { } public void Load (BinaryReader reader) { } }
Ajoutez des méthodes
Save
et
Load
à
HexGrid
. Ces méthodes contournent simplement toutes les cellules en appelant leurs méthodes
Load
et
Save
.
using UnityEngine; using UnityEngine.UI; using System.IO; public class HexGrid : MonoBehaviour { … public void Save (BinaryWriter writer) { for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } } public void Load (BinaryReader reader) { for (int i = 0; i < cells.Length; i++) { cells[i].Load(reader); } } }
Si nous téléchargeons une carte, elle doit être mise à jour après la modification des données de la cellule. Pour ce faire, il suffit de mettre à jour tous les fragments.
public void Load (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(); } }
Enfin, nous remplaçons notre code de test dans
HexMapEditor
par des appels aux méthodes
Save
et
Load
de la grille, en passant l'écrivain ou le lecteur avec eux.
public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { hexGrid.Save(writer); } } public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { hexGrid.Load(reader); } }
Enregistrement d'un type de relief
Au stade actuel, la réenregistrement crée un fichier vide et le téléchargement ne fait rien. Commençons progressivement en enregistrant et en chargeant uniquement l'index de type d'élévation
HexCell
.
Attribuez la valeur directement au champ terrainTypeIndex. Nous n'utiliserons pas de propriétés. Comme nous mettons à jour explicitement tous les fragments, les appels aux propriétés
Refresh
ne sont pas nécessaires. De plus, puisque nous enregistrons uniquement les cartes correctes, nous supposerons que toutes les cartes téléchargées sont également correctes. Par conséquent, par exemple, nous ne vérifierons pas si la rivière ou la route est autorisée.
public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); }
Lors de l'enregistrement dans ce fichier, l'un après l'autre, l'index du type de relief de toutes les cellules sera écrit. Étant donné que l'index est un entier, sa taille est de quatre octets. Ma carte contient 300 cellules, c'est-à-dire que la taille du fichier sera de 1200 octets.
La charge lit les index dans le même ordre dans lequel ils sont écrits. Si vous avez changé les couleurs des cellules après l'enregistrement, le chargement de la carte ramènera les couleurs à l'état lors de l'enregistrement. Puisque nous n'enregistrons plus rien, le reste des données de cellule restera le même. Autrement dit, le chargement changera le type de terrain, mais pas sa hauteur, le niveau de l'eau, les caractéristiques du terrain, etc.
Sauver tout entier
Enregistrer un index de type de relief ne nous suffit pas. Vous devez enregistrer toutes les autres données. Commençons par tous les champs entiers. Il s'agit d'un indice du type de relief, de la hauteur des cellules, du niveau de l'eau, du niveau de la ville, du niveau de la ferme, du niveau de végétation et de l'indice des objets spéciaux. Ils devront être lus dans le même ordre dans lequel ils ont été enregistrés.
public void Save (BinaryWriter writer) { writer.Write(terrainTypeIndex); writer.Write(elevation); writer.Write(waterLevel); writer.Write(urbanLevel); writer.Write(farmLevel); writer.Write(plantLevel); writer.Write(specialIndex); } public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); waterLevel = reader.ReadInt32(); urbanLevel = reader.ReadInt32(); farmLevel = reader.ReadInt32(); plantLevel = reader.ReadInt32(); specialIndex = reader.ReadInt32(); }
Essayez maintenant d'enregistrer et de charger la carte, en apportant des modifications entre ces opérations. Tout ce que nous avons inclus dans les données stockées a été restauré du mieux que nous pouvions, à l'exception de la hauteur de la cellule. Cela est dû au fait que lorsque vous modifiez le niveau de hauteur, vous devez mettre à jour la position verticale de la cellule. Cela peut être fait en l'attribuant à la propriété, et non au champ, la valeur de la hauteur chargée. Mais cette propriété fait un travail supplémentaire dont nous n'avons pas besoin. Par conséquent, extrayons le code mettant à jour la position de la cellule à partir du
RefreshPosition
Elevation
et insérons-le dans une méthode
RefreshPosition
distincte. La seule modification que vous devez effectuer ici est de remplacer la
value
référence au champ d'
elevation
.
void RefreshPosition () { 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; }
Nous pouvons maintenant appeler la méthode lors de la définition de la propriété, ainsi qu'après le chargement des données de hauteur.
public int Elevation { … set { if (elevation == value) { return; } elevation = value; RefreshPosition(); ValidateRivers(); … } } … public void Load (BinaryReader reader) { terrainTypeIndex = reader.ReadInt32(); elevation = reader.ReadInt32(); RefreshPosition(); … }
Après ce changement, les cellules changeront correctement leur hauteur apparente lors du chargement.
Sauvegarde de toutes les données
La présence de murs et de rivières entrantes / sortantes dans la cellule est stockée dans des champs booléens. Nous pouvons les écrire simplement sous forme d'entier. De plus, les données routières sont un tableau de six valeurs booléennes que nous pouvons écrire avec une boucle.
public void Save (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]); } }
Les directions des rivières entrantes et sortantes sont stockées dans les champs
HexDirection
. Le type
HexDirection
est une énumération qui est stockée en interne sous forme de plusieurs valeurs entières. Par conséquent, nous pouvons également les sérialiser sous forme d'entier à l'aide d'une conversion explicite.
writer.Write(hasIncomingRiver); writer.Write((int)incomingRiver); writer.Write(hasOutgoingRiver); writer.Write((int)outgoingRiver);
Les valeurs booléennes sont lues à l'aide de la méthode
BinaryReader.ReadBoolean
. Les directions des rivières sont entières, que nous devons reconvertir en
HexDirection
.
public void Load (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(); } }
Nous sauvegardons maintenant toutes les données de cellule nécessaires à la sauvegarde et à la restauration complètes de la carte.
Cela nécessite neuf entiers et neuf valeurs booléennes par cellule. Chaque valeur booléenne prend un octet, nous utilisons donc un total de 45 octets par cellule. Autrement dit, une carte avec 300 cellules nécessite un total de 13 500 octets.paquet d'unitéRéduisez la taille du fichier
Bien qu'il semble que 13 500 octets ne soit pas beaucoup pour 300 cellules, nous pouvons peut-être faire avec une plus petite quantité. En fin de compte, nous avons un contrôle total sur la façon dont les données sont sérialisées. Voyons s'il existe un moyen plus compact de les stocker.Réduction de l'intervalle numérique
Différents niveaux et indices de cellule sont stockés sous forme d'entier. Cependant, ils n'utilisent qu'une petite plage de valeurs. Chacun d'eux restera certainement dans la plage de 0 à 255. Cela signifie que seul le premier octet de chaque entier sera utilisé. Les trois autres seront toujours nuls. Cela n'a aucun sens de stocker ces octets vides. Nous pouvons les éliminer en écrivant un entier en octets avant d'écrire dans le flux. 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);
Maintenant, pour renvoyer ces numéros, nous devons utiliser BinaryReader.ReadByte
. La conversion d'octet en entier se fait implicitement, nous n'avons donc pas besoin d'ajouter de conversions explicites. 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();
On se débarrasse donc de trois octets par entier, ce qui économise 27 octets par cellule. Maintenant, nous dépensons 18 octets par cellule et seulement 5 400 octets pour 300 cellules.Il convient de noter que les anciennes données de la carte perdent leur sens à ce stade. Lors du chargement de l'ancienne sauvegarde, les données sont mélangées et nous obtenons des cellules confuses. C'est parce que nous lisons maintenant moins de données. Si nous lisons plus de données qu'auparavant, nous obtiendrons une erreur en essayant de lire au-delà de la fin du fichier.L'incapacité à traiter les anciennes données nous convient, car nous sommes en train de déterminer le format. Mais lorsque nous déciderons du format de sauvegarde, nous devrons nous assurer que le futur code pourra toujours le lire. Même si nous changeons le format, nous devrions idéalement pouvoir lire l'ancien format.River Byte Union
À ce stade, nous utilisons quatre octets pour stocker les données fluviales, deux par direction. Pour chaque direction, nous mémorisons la présence de la rivière et la direction dans laquelle elle coule,il semble évident que nous n'avons pas besoin de mémoriser la direction de la rivière sinon. Cela signifie que les cellules sans rivière ont besoin de deux octets de moins. En fait, un octet en direction de la rivière nous suffira, quelle que soit son existence.Nous avons six directions possibles, qui sont stockées sous forme de nombres dans l'intervalle 0–5. Trois bits suffisent pour cela, car sous forme binaire, les nombres de 0 à 5 ressemblent à 000, 001, 010, 011, 100, 101 et 110. Autrement dit, un octet de plus reste inutilisé cinq bits de plus. Nous pouvons utiliser l'un d'eux pour indiquer s'il existe une rivière. Par exemple, vous pouvez utiliser le huitième bit, correspondant au nombre 128.Pour ce faire, nous y ajouterons 128 avant de convertir la direction en octets. Autrement dit, si une rivière coule vers le nord-ouest, nous écrirons 133, qui sous la forme binaire est 10000101. Et s'il n'y a pas de rivière, alors nous écrivons simplement un octet zéro.Dans le même temps, quatre bits supplémentaires restent inutilisés, mais cela est normal. Nous pouvons combiner les deux directions de la rivière en un seul octet, mais cela sera déjà trop déroutant.
Pour décoder les données fluviales, nous devons d'abord lire l'octet. Si sa valeur n'est pas inférieure à 128, cela signifie qu'il y a une rivière. Pour obtenir sa direction, soustrayez 128, puis convertissez en HexDirection
.
En conséquence, nous avons obtenu 16 octets par cellule. L'amélioration ne semble pas grande, mais c'est l'une de ces astuces qui sont utilisées pour réduire la taille des données binaires.Enregistrer les routes en un octet
Nous pouvons utiliser une astuce similaire pour compresser les données routières. Nous avons six valeurs booléennes qui peuvent être stockées dans les six premiers bits d'un octet. Autrement dit, chaque direction de la route est représentée par un nombre qui est une puissance de deux. Ce sont 1, 2, 4, 8, 16 et 32, ou sous forme binaire 1, 10, 100, 1000, 10000 et 100000.Pour créer un octet fini, nous devons définir les bits qui correspondent aux directions utilisées des routes. Pour obtenir la bonne direction pour la direction, nous pouvons utiliser l'opérateur <<
. Combinez-les ensuite à l'aide de l'opérateur OR au niveau du bit. Par exemple, si les première, deuxième, troisième et sixième routes sont utilisées, alors l'octet fini sera 100111. int roadFlags = 0; for (int i = 0; i < roads.Length; i++) {
Comment fonctionne <<. integer . . integer . , . 1 << n
2 n , .
Pour récupérer la valeur booléenne de la route, vous devez vérifier si le bit est défini. Si c'est le cas, masquez tous les autres bits à l'aide de l'opérateur ET au niveau du bit avec le numéro approprié. Si le résultat n'est pas égal à zéro, alors le bit est activé et la route existe. int roadFlags = reader.ReadByte(); for (int i = 0; i < roads.Length; i++) { roads[i] = (roadFlags & (1 << i)) != 0; }
Après avoir comprimé six octets en un, nous avons reçu 11 octets par cellule. Avec 300 cellules, cela ne représente que 3 300 octets. Autrement dit, après avoir travaillé un peu avec les octets, nous avons réduit la taille du fichier de 75%.Se préparer pour l'avenir
Avant de déclarer notre format de sauvegarde terminé, nous ajoutons un détail supplémentaire. Avant d'enregistrer les données de la carte, nous allons forcer à HexMapEditor
écrire un zéro entier. public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(0); hexGrid.Save(writer); } }
Cela ajoutera quatre octets vides au début de nos données. Autrement dit, avant de charger la carte, nous devons lire ces quatre octets. public void Load () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) { reader.ReadInt32(); hexGrid.Load(reader); } }
Bien que ces octets soient jusqu'à présent inutiles, ils sont utilisés comme en-tête qui fournira une compatibilité descendante à l'avenir. Si nous n'avions pas ajouté ces octets nuls, le contenu des premiers octets dépendait de la première cellule de la carte. Par conséquent, à l'avenir, il nous sera plus difficile de déterminer la version du format de sauvegarde avec laquelle nous avons affaire. Maintenant, nous pouvons simplement vérifier les quatre premiers octets. S'ils sont vides, alors nous avons affaire à une version de format 0. Dans les futures versions, il sera possible d'y ajouter autre chose.Autrement dit, si le titre est différent de zéro, nous avons affaire à une version inconnue. Comme nous ne pouvons pas savoir quelles données il y a, nous devons refuser de télécharger la carte. 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); } }
paquet d'unitéPartie 13: gestion des cartes
- Nous créons de nouvelles cartes en mode Play.
- Ajoutez la prise en charge de différentes tailles de cartes.
- Ajoutez la taille de la carte aux données enregistrées.
- Enregistrez et chargez des cartes arbitraires.
- Affichez une liste de cartes.
Dans cette partie, nous ajouterons la prise en charge de différentes tailles de cartes, ainsi que l'enregistrement de différents fichiers.À partir de cette partie, des didacticiels seront créés dans Unity 5.5.0.Le début de la bibliothèque de cartes.Créer de nouvelles cartes
Jusqu'à présent, nous n'avons créé la grille hexagonale qu'une seule fois - lors du chargement de la scène. Nous allons maintenant pouvoir démarrer une nouvelle carte à tout moment. La nouvelle carte remplacera simplement la carte actuelle.Dans Awake HexGrid
, certaines métriques sont initialisées, puis le nombre de cellules est déterminé et les fragments et cellules nécessaires sont créés. En créant un nouvel ensemble de fragments et de cellules, nous créons une nouvelle carte. Divisons-nous HexGrid.Awake
en deux parties - le code source d'initialisation et la méthode générale CreateMap
. void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(); } public void CreateMap () { cellCountX = chunkCountX * HexMetrics.chunkSizeX; cellCountZ = chunkCountZ * HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); }
Ajoutez un bouton dans l'interface utilisateur pour créer une nouvelle carte. Je l'ai fait grand et l'ai placé sous les boutons d'enregistrement et de chargement.Nouveau bouton de carte.Connectons l'événement On Click de ce bouton avec la méthode de CreateMap
notre objet HexGrid
. Autrement dit, nous ne passerons pas par l' éditeur de carte hexadécimale , mais nous appellerons directement la méthode objet Grille hexadécimale .Créez une carte en cliquant sur.Effacement des anciennes données
Maintenant, lorsque vous cliquez sur le bouton Nouvelle carte , un nouvel ensemble de fragments et de cellules sera créé. Cependant, les anciens ne sont pas supprimés automatiquement. Par conséquent, nous obtenons plusieurs maillages de carte superposés les uns aux autres. Pour éviter cela, nous devons d'abord nous débarrasser des vieux objets. Cela peut être fait en détruisant tous les fragments actuels au début CreateMap
. public void CreateMap () { if (chunks != null) { for (int i = 0; i < chunks.Length; i++) { Destroy(chunks[i].gameObject); } } … }
Peut-on réutiliser des objets existants?, . , . , — , .
Est-il possible de détruire des éléments enfants comme celui-ci en boucle?Bien sûr. .
Spécifiez la taille en cellules au lieu de fragments
Alors que nous définissons la taille de la carte à travers les champs chunkCountX
et l' chunkCountZ
objet HexGrid
. Mais il sera beaucoup plus pratique d'indiquer la taille de la carte en cellules. Dans le même temps, nous pouvons même changer la taille du fragment à l'avenir sans changer la taille des cartes. Par conséquent, échangeons les rôles du nombre de cellules et du nombre de champs de fragments.
Cela entraînera une erreur de compilation, car il HexMapCamera
utilise des tailles de fragments pour limiter sa position . Changez HexMapCamera.ClampPosition
pour qu'il utilise directement le nombre de cellules dont il a encore besoin. 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; }
Un fragment a une taille de 5 par 5 cellules, et les cartes par défaut ont une taille de 4 par 3 fragments. Par conséquent, pour garder les cartes identiques, nous devrons utiliser une taille de 20 par 15 cellules. Et bien que nous ayons attribué des valeurs par défaut dans le code, l'objet de grille ne les utilisera toujours pas automatiquement, car les champs existaient déjà et par défaut à 0.Par défaut, la carte a une taille de 20 par 15.Tailles de cartes personnalisées
La prochaine étape consistera à prendre en charge la création de cartes de toute taille, pas seulement la taille par défaut. Pour ce faire, ajoutez HexGrid.CreateMap
X et Z aux paramètres qui remplaceront le nombre de cellules existant. À l'intérieur, Awake
nous les appellerons simplement avec le nombre actuel de cellules. void Awake () { HexMetrics.noiseSource = noiseSource; HexMetrics.InitializeHashGrid(seed); HexMetrics.colors = colors; CreateMap(cellCountX, cellCountZ); } public void CreateMap (int x, int z) { … cellCountX = x; cellCountZ = z; chunkCountX = cellCountX / HexMetrics.chunkSizeX; chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ; CreateChunks(); CreateCells(); }
Cependant, cela ne fonctionnera correctement qu'avec le nombre de cellules qui est un multiple de la taille du fragment. Sinon, la division entière créera trop peu de fragments. Bien que nous puissions ajouter la prise en charge de fragments partiellement remplis de cellules, interdisons simplement l'utilisation de tailles qui ne correspondent pas à des fragments.Nous pouvons utiliser l'opérateur %
pour calculer le reste de la division du nombre de cellules par le nombre de fragments. S'il n'est pas égal à zéro, alors il y a un écart et nous ne créerons pas de nouvelle carte. Et pendant ce temps, ajoutons une protection contre les tailles nulles et négatives. public void CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return; } … }
Nouveau menu de carte
À l'étape actuelle, le bouton Nouvelle carte ne fonctionne plus, car la méthode a HexGrid.CreateMap
maintenant deux paramètres. Nous ne pouvons pas connecter directement les événements Unity à de telles méthodes. De plus, pour prendre en charge différentes tailles de cartes, nous avons besoin de quelques boutons. Au lieu d'ajouter tous ces boutons à l'interface utilisateur principale, créons un menu contextuel distinct.Ajoutez un nouveau canevas à la scène ( GameObject / UI / Canvas ). Nous utiliserons les mêmes paramètres que le canevas existant, sauf que son ordre de tri doit être égal à 1. Grâce à cela, il sera au-dessus de l'interface utilisateur de l'éditeur principal. J'ai fait à la fois le canevas et le système d'événements un enfant du nouvel objet d'interface utilisateur afin que la hiérarchie des scènes reste propre.Menu Canvas Nouvelle carte.Ajoutez un panneau au menu Nouvelle carte qui ferme tout l'écran. Il est nécessaire d'assombrir l'arrière-plan et de ne pas permettre au curseur d'interagir avec tout le reste lorsque le menu est ouvert. Je lui ai donné une couleur uniforme, effaçant son image source et défini (0, 0, 0, 200) comme couleur .Paramètres d'image d'arrière-plan.Ajoutez une barre de menus au centre du canevas, semblable aux panneaux de l' éditeur de cartes hexadécimales . Créons une étiquette claire et des boutons pour ses petites, moyennes et grandes cartes. Nous lui ajouterons également un bouton d'annulation au cas où le joueur changerait d'avis. Une fois la création du dessin terminée, désactivez l'intégralité du menu Nouvelle carte .Nouveau menu de carte.Pour gérer le menu, créez un composant NewMapMenu
et ajoutez-le à l'objet Canvas New Map Menu . Pour créer une nouvelle carte, nous devons avoir accès à l'objet Hex Grid . Par conséquent, nous lui ajoutons un champ commun et le connectons. using UnityEngine; public class NewMapMenu : MonoBehaviour { public HexGrid hexGrid; }
Composant du nouveau menu de carte.Ouverture et fermeture
Nous pouvons ouvrir et fermer le menu contextuel en activant et désactivant simplement l'objet de canevas. Ajoutons NewMapMenu
deux méthodes courantes pour ce faire. public void Open () { gameObject.SetActive(true); } public void Close () { gameObject.SetActive(false); }
Connectez maintenant le bouton New Map UI de l' éditeur à la méthode Open
de l'objet New Map Menu .Ouverture du menu en appuyant sur.Connectez également le bouton Annuler à la méthode Close
. Cela nous permettra d'ouvrir et de fermer le menu contextuel.Créer de nouvelles cartes
Pour créer de nouvelles cartes, nous devons appeler la méthode dans l'objet Hex GridCreateMap
. De plus, après cela, nous devons fermer le menu contextuel. Ajoutez à la NewMapMenu
méthode qui traitera cela, en tenant compte d'une taille arbitraire. void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); Close(); }
Cette méthode ne doit pas être générale, car nous ne pouvons toujours pas la connecter directement aux événements de bouton. À la place, créez une méthode par bouton qui appellera CreateMap
avec la taille spécifiée. Pour une petite carte, j'ai utilisé une taille de 20 par 15, correspondant à la taille par défaut de la carte. Pour la carte du milieu, j'ai décidé de doubler cette taille, obtenant 40 par 30, et de la doubler à nouveau pour la grande carte. Connectez les boutons avec les méthodes appropriées. public void CreateSmallMap () { CreateMap(20, 15); } public void CreateMediumMap () { CreateMap(40, 30); } public void CreateLargeMap () { CreateMap(80, 60); }
Verrouillage de l'appareil photo
Maintenant, nous pouvons utiliser le menu local pour créer de nouvelles cartes avec trois tailles différentes! Tout fonctionne bien, mais nous devons prendre soin d'un petit détail. Lorsque le nouveau menu de carte est actif, nous ne pouvons plus interagir avec l'interface utilisateur de l'éditeur et modifier les cellules. Cependant, nous pouvons toujours contrôler la caméra. Idéalement, avec le menu ouvert, l'appareil photo devrait se verrouiller.Comme nous n'avons qu'une seule caméra, une solution rapide et pragmatique consiste simplement à lui ajouter une propriété statique Locked
. Pour une utilisation généralisée, cette solution n'est pas très adaptée, mais pour notre interface simple elle suffit. Cela nécessite que nous suivions l'instance statique à l'intérieur HexMapCamera
, qui est définie lorsque la caméra Awake. static HexMapCamera instance; … void Awake () { instance = this; swivel = transform.GetChild(0); stick = swivel.GetChild(0); }
Une propriété Locked
ne peut être une simple propriété booléenne statique qu'avec un setter. Il ne fait que désactiver l'instance HexMapCamera
lorsqu'elle est verrouillée et l'activer lorsqu'elle est déverrouillée. public static bool Locked { set { instance.enabled = !value; } }
Maintenant, il NewMapMenu.Open
peut bloquer l'appareil photo et NewMapMenu.Close
- le déverrouiller. public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; }
Maintenir la position correcte de la caméra
Il y a un autre problème probable avec l'appareil photo. Lors de la création d'une nouvelle carte plus petite que la carte actuelle, la caméra peut apparaître en dehors des bordures de la carte. Elle y restera jusqu'à ce que le joueur essaie de déplacer la caméra. Et c'est seulement alors qu'il sera limité par les limites de la nouvelle carte.Pour résoudre ce problème, nous pouvons ajouter à la HexMapCamera
méthode statique ValidatePosition
. L'appel d'une méthode d' AdjustPosition
instance avec un décalage nul forcera la caméra à se déplacer vers les limites de la carte. Si la caméra est déjà à l'intérieur des limites de la nouvelle carte, elle restera en place. public static void ValidatePosition () { instance.AdjustPosition(0f, 0f); }
Appelez la méthode à l'intérieur NewMapMenu.CreateMap
après avoir créé une nouvelle carte. void CreateMap (int x, int z) { hexGrid.CreateMap(x, z); HexMapCamera.ValidatePosition(); Close(); }
paquet d'unitéEnregistrement de la taille de la carte
Bien que nous puissions créer des cartes de différentes tailles, elles ne sont pas prises en compte lors de l'enregistrement et du chargement. Cela signifie que le chargement d'une carte entraînera une erreur ou une carte incorrecte si la taille de la carte actuelle ne correspond pas à la taille de la carte chargée.Pour résoudre ce problème, avant de charger les données de cellule, nous devons créer une nouvelle carte de la taille appropriée. Disons que nous avons enregistré une petite carte. Dans ce cas, tout ira bien si nous créons HexGrid.Load
une carte 20 x 15 au début . public void Load (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(); } }
Stockage de la taille de la carte
Bien sûr, nous pouvons stocker une carte de n'importe quelle taille. Par conséquent, une solution généralisée consistera à enregistrer la taille de la carte devant ces cellules. public void Save (BinaryWriter writer) { writer.Write(cellCountX); writer.Write(cellCountZ); for (int i = 0; i < cells.Length; i++) { cells[i].Save(writer); } }
Ensuite, nous pouvons obtenir la vraie taille et l'utiliser pour créer une carte avec les bonnes tailles. public void Load (BinaryReader reader) { CreateMap(reader.ReadInt32(), reader.ReadInt32()); … }
Puisque nous pouvons maintenant charger des cartes de différentes tailles, nous sommes de nouveau confrontés au problème de la position de la caméra. Nous allons le résoudre en vérifiant sa position HexMapEditor.Load
après avoir chargé la carte. public void Load () { 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); } } }
Nouveau format de fichier
Bien que cette approche fonctionne avec les cartes que nous conserverons à l'avenir, elle ne fonctionnera pas avec les anciennes. Et vice versa - le code de la partie précédente du tutoriel ne pourra pas charger correctement les nouveaux fichiers de carte. Pour distinguer les anciens et les nouveaux formats, nous augmenterons la valeur entière de l'en-tête. L'ancien format de sauvegarde sans taille de carte avait la version 0. Le nouveau format avec une taille de carte aura la version 1. Par conséquent, lors de l'enregistrement, il HexMapEditor.Save
devrait écrire 1 au lieu de 0. public void Save () { string path = Path.Combine(Application.persistentDataPath, "test.map"); using ( BinaryWriter writer = new BinaryWriter(File.Open(path, FileMode.Create)) ) { writer.Write(1); hexGrid.Save(writer); } }
Dorénavant, les cartes seront enregistrées en version 1. Si nous essayons de les ouvrir dans l'assemblage du tutoriel précédent, elles refuseront de charger et de signaler un format de carte inconnu. En fait, cela se produira si nous essayons déjà de charger une telle carte. Vous devez modifier la méthode HexMapEditor.Load
afin qu'elle accepte la nouvelle version. public void Load () { 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); } } }
Compatibilité descendante
En fait, si nous le voulons, nous pouvons toujours télécharger des cartes de la version 0, en supposant qu'elles ont toutes la même taille 20 par 15. Autrement dit, le titre n'a pas besoin d'être 1, il peut également être zéro. Étant donné que chaque version nécessite sa propre approche, elle HexMapEditor.Load
doit transmettre l'en-tête à la méthode HexGrid.Load
. if (header <= 1) { hexGrid.Load(reader, header); HexMapCamera.ValidatePosition(); }
Ajoutez un HexGrid.Load
titre au paramètre et utilisez-le pour prendre des décisions sur d'autres actions. Si l'en-tête n'est pas inférieur à 1, vous devez lire les données de taille de carte. Sinon, nous utilisons l'ancienne taille de carte fixe de 20 par 15 et sautons la lecture des données de taille. public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } CreateMap(x, z); … }
version 0 du fichier de carteVérification de la taille de la carte
Comme pour la création d'une nouvelle carte, il est théoriquement possible de devoir charger une carte incompatible avec la taille du fragment. Lorsque cela se produit, nous devons interrompre le téléchargement de la carte. HexGrid.CreateMap
refuse déjà de créer une carte et affiche une erreur dans la console. Pour le dire à l'appelant de la méthode, retournons un booléen indiquant si la carte a été créée. public bool CreateMap (int x, int z) { if ( x <= 0 || x % HexMetrics.chunkSizeX != 0 || z <= 0 || z % HexMetrics.chunkSizeZ != 0 ) { Debug.LogError("Unsupported map size."); return false; } … return true; }
Désormais, HexGrid.Load
il peut également arrêter l'exécution lorsque la création de la carte échoue. public void Load (BinaryReader reader, int header) { int x = 20, z = 15; if (header >= 1) { x = reader.ReadInt32(); z = reader.ReadInt32(); } if (!CreateMap(x, z)) { return; } … }
Étant donné que le chargement écrase toutes les données dans les cellules existantes, nous n'avons pas besoin de créer une nouvelle carte si une carte de la même taille est chargée. Par conséquent, cette étape peut être ignorée. if (x != cellCountX || z != cellCountZ) { if (!CreateMap(x, z)) { return; } }
paquet d'unitéGestion des fichiers
Nous pouvons enregistrer et charger des cartes de différentes tailles, mais toujours écrire et lire test.map . Nous allons maintenant ajouter la prise en charge de différents fichiers.Au lieu d'enregistrer ou de charger directement la carte, nous utilisons un autre menu local qui fournit une gestion avancée des fichiers. Créez un autre canevas, comme dans le menu Nouvelle carte , mais cette fois nous l'appellerons Enregistrer le menu de chargement . Ce menu enregistre et charge les cartes, selon le bouton enfoncé pour l'ouvrir.Nous allons créer la conception du menu Save Load .comme si c'était un menu de sauvegarde. Plus tard, nous le transformerons dynamiquement en un menu de démarrage. Comme un autre menu, il doit avoir un arrière-plan et une barre de menus, une étiquette de menu et un bouton d'annulation. Ajoutez ensuite une vue de défilement ( GameObject / UI / Scroll View ) au menu pour afficher une liste de fichiers. Ci-dessous, nous insérons le champ de saisie ( GameObject / UI / Champ de saisie ) pour indiquer les noms des nouvelles cartes. Nous avons également besoin d'un bouton d'action pour enregistrer la carte. Et enfin. ajoutez un bouton Supprimer pour supprimer les cartes inutiles.Design Save Load Menu.Par défaut, la vue de défilement permet le défilement horizontal et vertical, mais nous n'avons besoin que d'une liste avec défilement vertical. Par conséquent, le défilement disable horizontal et débranchez la barre de défilement horizontale. Nous avons également défini le type de mouvement sur bloqué et désactivé l' inertie pour rendre la liste plus restrictive.Options de liste de fichiers.Nous allons supprimer l'enfant horizontal de la barre de défilement de l'objet Liste de fichiers , car nous n'en avons pas besoin. Redimensionnez ensuite la barre de défilement verticale pour qu'elle atteigne le bas de la liste.Placeholder objet texte Nom d' entrée peut être modifiée dans ses enfants Placeholder . J'ai utilisé un texte descriptif, mais vous pouvez simplement le laisser vide et vous débarrasser de l'espace réservé.Conception de menu modifiée.Nous en avons fini avec le design, et désactivons maintenant le menu pour qu'il soit masqué par défaut.Gestion des menus
Pour que le menu fonctionne, nous avons besoin d'un autre script, dans ce cas - SaveLoadMenu
. Comme NewMapMenu
, il a besoin d'un lien vers la grille, ainsi que des méthodes Open
et Close
. using UnityEngine; public class SaveLoadMenu : MonoBehaviour { public HexGrid hexGrid; public void Open () { gameObject.SetActive(true); HexMapCamera.Locked = true; } public void Close () { gameObject.SetActive(false); HexMapCamera.Locked = false; } }
Ajoutez ce composant à SaveLoadMenu et donnez-lui un lien vers l'objet de grille.Composant SaveLoadMenu.Un menu s'ouvrira pour enregistrer ou charger. Pour simplifier le travail, ajoutez un Open
paramètre booléen à la méthode . Il détermine si le menu doit être en mode sauvegarde. Nous allons suivre ce mode sur le terrain pour savoir quelle action effectuer plus tard. bool saveMode; public void Open (bool saveMode) { this.saveMode = saveMode; gameObject.SetActive(true); HexMapCamera.Locked = true; }
Maintenant , combiner les boutons Enregistrer et Charger l' objet Hex Map Editor avec la méthode Open
de l'objet Enregistrer Charger le menu . Vérifiez le paramètre booléen pour le bouton Enregistrer uniquement .Ouverture du menu en mode sauvegarde.Si vous ne l'avez pas déjà fait, connectez l'événement du bouton Annuler à la méthode Close
. Maintenant Enregistrer Menu Charger peut être ouvert et fermé.Changement d'apparence
Nous avons créé le menu comme un menu de sauvegarde, mais son mode est déterminé par le bouton enfoncé pour ouvrir. Nous devons changer l'apparence du menu en fonction du mode. En particulier, nous devons changer l'étiquette du menu et l'étiquette du bouton d'action. Cela signifie que nous aurons besoin de liens vers ces balises. using UnityEngine; using UnityEngine.UI; public class SaveLoadMenu : MonoBehaviour { public Text menuLabel, actionButtonLabel; … }
Connexion avec des balises.Lorsque le menu s'ouvre en mode d'enregistrement, nous utilisons les étiquettes existantes, c'est-à-dire Enregistrer la carte pour le menu et Enregistrer pour le bouton d'action. Sinon, nous sommes en mode chargement, c'est-à-dire que nous utilisons Load Map et Load . public void Open (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; }
Entrez le nom de la carte
Laissons pour l'instant la liste des fichiers. L'utilisateur peut spécifier le fichier enregistré ou téléchargé en entrant le nom de la carte dans le champ de saisie. Pour obtenir ces données, nous avons besoin d'une référence au composant InputField
de l'objet Name Input . public InputField nameInput;
Connexion au champ de saisie.L'utilisateur n'a pas besoin d'être obligé de saisir le chemin d'accès complet au fichier de carte. Il suffira seulement du nom de la carte sans l'extension .map . Ajoutons une méthode qui prend la saisie de l'utilisateur et crée le bon chemin pour cela. Ce n'est pas possible lorsque l'entrée est vide, donc dans ce cas, nous reviendrons null
. using UnityEngine; using UnityEngine.UI; using System.IO; public class SaveLoadMenu : MonoBehaviour { … string GetSelectedPath () { string mapName = nameInput.text; if (mapName.Length == 0) { return null; } return Path.Combine(Application.persistentDataPath, mapName + ".map"); } }
Que se passe-t-il si l'utilisateur entre des caractères invalides?, . , , .
Content Type . , - , . , , .
Sauvegarde et chargement
Maintenant, il sera engagé dans la sauvegarde et le chargement SaveLoadMenu
. Par conséquent, nous déplaçons les méthodes Save
et Load
de HexMapEditor
dans SaveLoadMenu
. Ils ne doivent plus être partagés et fonctionneront avec le paramètre path au lieu du chemin fixe. void Save (string path) {
Puisque nous téléchargeons maintenant des fichiers arbitraires, il serait bon de vérifier que le fichier existe réellement et d'essayer de le lire ensuite. Si ce n'est pas le cas, nous lançons une erreur et terminons l'opération. void Load (string path) { if (!File.Exists(path)) { Debug.LogError("File does not exist " + path); return; } … }
Ajoutez maintenant la méthode générale Action
. Cela commence par l'obtention du chemin sélectionné par l'utilisateur. S'il existe un chemin, enregistrez-le ou chargez-le. Fermez ensuite le menu. public void Action () { string path = GetSelectedPath(); if (path == null) { return; } if (saveMode) { Save(path); } else { Load(path); } Close(); }
En attachant un événement de bouton d'action à cette méthode , nous pouvons enregistrer et charger en utilisant des noms de carte arbitraires. Comme nous ne réinitialisons pas le champ de saisie, le nom sélectionné restera jusqu'à la prochaine sauvegarde ou charge. C'est pratique pour enregistrer ou charger un fichier plusieurs fois de suite, donc nous ne changerons rien.Éléments de la liste des cartes
Ensuite, nous remplirons la liste des fichiers avec toutes les cartes qui se trouvent sur le chemin de stockage des données. Lorsque vous cliquez sur l'un des éléments de la liste, il sera utilisé comme texte dans la saisie de nom . Ajoutez une SaveLoadMenu
méthode générale pour cela. public void SelectItem (string name) { nameInput.text = name; }
Nous avons besoin d'un élément de liste. Le bouton habituel fera l'affaire. Créez-le et réduisez la hauteur à 20 unités afin qu'il n'occupe pas beaucoup d'espace verticalement. Il ne doit pas ressembler à un bouton. Nous allons donc supprimer le lien Image source de son composant Image . Dans ce cas, il deviendra complètement blanc. De plus, nous nous assurerons que l'étiquette est alignée à gauche et qu'il y a de l'espace entre le texte et le côté gauche du bouton. Après avoir terminé la conception du bouton, nous le transformons en un préfabriqué.Le bouton est un élément de liste.Nous ne pouvons pas connecter directement l'événement de bouton au menu Nouvelle carte , car il s'agit d'un préfabriqué et il n'existe pas encore dans la scène. Par conséquent, un élément de menu a besoin d'un lien vers le menu pour pouvoir invoquer une méthode lorsqu'il est cliqué SelectItem
. Il doit également garder une trace du nom de la carte qu'il représente et définir son texte. Créons un petit composant pour cela SaveLoadItem
. using UnityEngine; using UnityEngine.UI; public class SaveLoadItem : MonoBehaviour { public SaveLoadMenu menu; public string MapName { get { return mapName; } set { mapName = value; transform.GetChild(0).GetComponent<Text>().text = value; } } string mapName; public void Select () { menu.SelectItem(mapName); } }
Ajoutez un composant à l'élément de menu et faites en sorte que le bouton appelle sa méthode Select
.Composant d'article.Remplir la liste
Pour remplir la liste, vous avez SaveLoadMenu
besoin d'un lien vers Contenu dans la fenêtre d' affichage de l'objet Liste de fichiers . Il a également besoin d'un lien vers l'élément préfabriqué. public RectTransform listContent; public SaveLoadItem itemPrefab;
Mélangez le contenu d'une liste et d'un préfabriqué.Nous utilisons une nouvelle méthode pour remplir cette liste. La première étape consiste à identifier les fichiers de carte existants. Pour obtenir un tableau de tous les chemins de fichiers à l'intérieur du répertoire, nous pouvons utiliser la méthode Directory.GetFiles
. Cette méthode a un deuxième paramètre qui vous permet de filtrer les fichiers. Dans notre cas, seuls les fichiers correspondant au masque * .map sont requis . void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); }
Malheureusement, l'ordre des fichiers n'est pas garanti. Pour les afficher par ordre alphabétique, nous devons trier le tableau avec System.Array.Sort
. using UnityEngine; using UnityEngine.UI; using System; using System.IO; public class SaveLoadMenu : MonoBehaviour { … void FillList () { string[] paths = Directory.GetFiles(Application.persistentDataPath, "*.map"); Array.Sort(paths); } … }
Ensuite, nous allons créer des instances préfabriquées pour chaque élément du tableau. Liez l'élément au menu, définissez son nom de carte et faites-en un enfant du contenu de la liste. 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); }
Puisqu'il Directory.GetFiles
renvoie les chemins d'accès complets aux fichiers, nous devons les effacer. Heureusement, c'est exactement ce qui rend la méthode pratique Path.GetFileNameWithoutExtension
. item.MapName = Path.GetFileNameWithoutExtension(paths[i]);
Avant d'afficher le menu, nous devons remplir une liste. Et comme les fichiers sont susceptibles de changer, nous devons le faire chaque fois que nous ouvrons le menu. public void Open (bool saveMode) { … FillList(); gameObject.SetActive(true); HexMapCamera.Locked = true; }
Lors du remplissage de la liste, nous devons supprimer tous les anciens avant d'ajouter de nouveaux éléments. void FillList () { for (int i = 0; i < listContent.childCount; i++) { Destroy(listContent.GetChild(i).gameObject); } … }
Articles sans arrangement.Disposition des points
Maintenant, la liste affichera des éléments, mais ils se chevaucheront et seront dans une mauvaise position. Pour les transformer en liste verticale, ajoutez le composant Groupe de disposition verticale ( Composant / Présentation / Groupe de présentation verticale ) à l'objet Contenu de la liste . Pour que la disposition fonctionne correctement, activez la largeur de la taille du contrôle enfant et de l' expansion de la force enfant . Les deux options de hauteur doivent être désactivées.Utilisation du groupe de disposition verticale.Nous avons obtenu une belle liste d'articles. Cependant, la taille du contenu de la liste ne s'adapte pas au nombre réel d'éléments. Par conséquent, la barre de défilement ne change jamais de taille. Nous pouvons forcer le contenu à se redimensionner automatiquement en lui ajoutant un composant d' ajustement de la taille du contenu ( composant / mise en page / ajustement de la taille du contenu ). Son mode d' ajustement vertical doit être défini sur Taille préférée .Utilisation d'un ajusteur de taille de contenu.Maintenant, avec un petit nombre de points, la barre de défilement disparaîtra. Et lorsqu'il y a trop d'éléments dans la liste qui ne tiennent pas dans la fenêtre, la barre de défilement apparaît et a une taille appropriée.Une barre de défilement apparaît.Suppression de la carte
Maintenant, nous pouvons facilement travailler avec de nombreux fichiers de carte. Cependant, il est parfois nécessaire de se débarrasser de certaines cartes. Pour ce faire, vous pouvez utiliser le bouton Supprimer . Créons une méthode pour cela et faisons que le bouton l'appelle. S'il y a un chemin sélectionné, supprimez-le simplement avec File.Delete
. public void Delete () { string path = GetSelectedPath(); if (path == null) { return; } File.Delete(path); }
Ici, nous devons également vérifier que nous travaillons avec un fichier réellement existant. Si ce n'est pas le cas, nous ne devons pas essayer de le supprimer, mais cela ne conduit pas à une erreur. if (File.Exists(path)) { File.Delete(path); }
Après avoir retiré la carte, nous n'avons pas besoin de fermer le menu. Cela facilite la suppression de plusieurs fichiers à la fois. Cependant, après la suppression, nous devons effacer l' entrée de nom , ainsi que mettre à jour la liste des fichiers. if (File.Exists(path)) { File.Delete(path); } nameInput.text = ""; FillList();
paquet d'unitéPartie 14: textures en relief
- Utilisez les couleurs des sommets pour créer une carte splat.
- Création d'une ressource de texture de tableau.
- Ajout d'indices d'élévation aux maillages.
- Transitions entre textures en relief.
Jusqu'à ce moment, nous utilisions des couleurs unies pour colorier les cartes. Nous allons maintenant appliquer la texture.Dessin de textures.Un mélange de trois types
Bien que les couleurs uniformes soient clairement reconnaissables et assez adaptées à la tâche, elles ne semblent pas très intéressantes. L'utilisation de textures augmentera considérablement l'attrait des cartes. Bien sûr, pour cela, nous devons mélanger les textures, pas seulement les couleurs. Dans le didacticiel Rendu 3, Combinaison de textures, j'ai expliqué comment mélanger plusieurs textures à l'aide de la carte de splat. Dans nos cartes hexagonales, vous pouvez utiliser une approche similaire.Dans le didacticiel Rendu 3seules quatre textures sont mélangées, et avec une carte splat, nous pouvons prendre en charge jusqu'à cinq textures. Pour le moment, nous utilisons cinq couleurs différentes, donc cela nous convient parfaitement. Cependant, plus tard, nous pouvons ajouter d'autres types. Par conséquent, la prise en charge d'un nombre arbitraire de types de relief est nécessaire. Lorsque vous utilisez des propriétés de texture définies explicitement, cela n'est pas possible, vous devez donc utiliser un tableau de textures. Plus tard, nous le créerons.Lorsque vous utilisez des tableaux de textures, nous devons en quelque sorte dire au shader quelles textures mélanger. Le mélange le plus difficile est nécessaire pour les triangles angulaires, qui peuvent être entre trois cellules avec leur propre type de terrain. Par conséquent, nous devons mélanger le support entre les trois types par triangle.Utilisation des couleurs de sommets comme Splat Maps
En supposant que nous pouvons vous dire quelles textures mélanger, nous pouvons utiliser les couleurs des sommets pour créer une carte splat pour chaque triangle. Puisque dans chaque cas, un maximum de trois textures est utilisé, nous n'avons besoin que de trois canaux de couleur. Le rouge représentera la première texture, le vert - la seconde et le bleu - la troisième.Carte Triangle Splat.La somme de la carte splat triangle est-elle toujours égale à un?Oui . . , (1, 0, 0) , (½, ½, 0) (⅓, ⅓, ⅓) .
Si un triangle n'a besoin que d'une seule texture, nous utilisons uniquement le premier canal. Autrement dit, sa couleur sera complètement rouge. Dans le cas d'un mixage entre deux types différents, nous utilisons les premier et deuxième canaux. Autrement dit, la couleur du triangle sera un mélange de rouge et de vert. Et lorsque les trois types seront trouvés, ce sera un mélange de rouge, de vert et de bleu.Trois configurations de carte splat.Nous utiliserons ces configurations de splat map quelles que soient les textures réellement mélangées. Autrement dit, la carte splat sera toujours la même. Seules les textures changeront. Comment faire cela, nous le saurons plus tard.Nous devons changer HexGridChunk
pour qu'il crée ces cartes splat, plutôt que d'utiliser des couleurs de cellule. Comme nous utiliserons souvent trois couleurs, nous leur créerons des champs statiques. static Color color1 = new Color(1f, 0f, 0f); static Color color2 = new Color(0f, 1f, 0f); static Color color3 = new Color(0f, 0f, 1f);
Centres cellulaires
Commençons par remplacer la couleur du centre des cellules par défaut. Aucun mélange n'est effectué ici, nous utilisons donc uniquement la première couleur, c'est-à-dire le rouge. void TriangulateWithoutRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { TriangulateEdgeFan(center, e, color1); … }
Centres rouges des cellules.Les centres cellulaires deviennent maintenant rouges. Ils utilisent tous la première des trois textures, quelle que soit la texture. Leurs splat maps sont les mêmes, quelle que soit la couleur avec laquelle nous colorisons les cellules.Quartier de la rivière
Nous avons changé les segments uniquement à l'intérieur des cellules sans que des rivières ne coulent le long de celles-ci. Nous devons faire de même pour les segments adjacents aux rivières. Dans notre cas, il s'agit à la fois d'une bande de côtes et d'un éventail de triangles de la côte. Ici aussi, seul le rouge nous suffit. void TriangulateAdjacentToRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … }
Segments rouges adjacents aux rivières.Rivières
Ensuite, nous devons prendre soin de la géométrie des rivières à l'intérieur des cellules. Tous devraient également devenir rouges. Pour commencer, regardons le début et la fin des rivières. void TriangulateWithRiverBeginOrEnd ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); TriangulateEdgeFan(center, m, color1); … }
Et puis la géométrie qui compose les berges et le lit de la rivière. J'ai regroupé les appels de méthode de couleur pour rendre le code plus facile à lire. void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … TriangulateEdgeStrip(m, color1, e, color1); terrain.AddTriangle(centerL, m.v1, m.v2);
Rivières rouges le long des cellules.Côtes levées
Tous les bords sont différents car ils sont entre des cellules qui peuvent avoir différents types de terrain. Nous utilisons la première couleur pour le type de cellule actuel et la deuxième couleur pour le type voisin. En conséquence, la carte splat deviendra un dégradé rouge-vert, même si les deux cellules sont du même type. Si les deux cellules utilisent la même texture, cela devient simplement un mélange de la même texture des deux côtés. void TriangulateConnection ( 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); } … }
Côtes rouge-vert, à l'exclusion des rebords.La transition abrupte entre le rouge et le vert ne causerait-elle pas des problèmes?, , . . splat map, . .
, .
Les bords avec les rebords sont un peu plus compliqués, car ils ont des sommets supplémentaires. Heureusement, le code d'interpolation existant fonctionne très bien avec les couleurs de la carte splat. Utilisez simplement les première et deuxième couleurs, pas les couleurs des cellules du début et de la fin. void TriangulateEdgeTerraces ( 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); }
Rebords de côtes rouge-vert.Angles
Les angles de cellule sont les plus difficiles car ils doivent mélanger trois textures différentes. Nous utilisons du rouge pour le pic du bas, du vert pour la gauche et du bleu pour la droite. Commençons par les coins d'un triangle. void TriangulateCorner ( 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); }
Coins rouge-vert-bleu, à l'exception des rebords.Ici, nous pouvons à nouveau utiliser le code d'interpolation de couleur existant pour les coins avec des rebords. L'interpolation se fait uniquement entre trois, pas deux couleurs. Tout d'abord, considérez les corniches qui ne sont pas près des falaises. void TriangulateCornerTerraces ( 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); }
Corniches d'angle rouge-vert-bleu, à l'exception des corniches le long des falaises.En ce qui concerne les falaises, nous devons utiliser une méthode TriangulateBoundaryTriangle
. Cette méthode a reçu les cellules de départ et de gauche comme paramètres. Cependant, nous avons maintenant besoin des couleurs de splat appropriées, qui peuvent varier en fonction de la topologie. Par conséquent, nous remplaçons ces paramètres par des couleurs. void TriangulateBoundaryTriangle ( 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); }
Changez-le TriangulateCornerTerracesCliff
pour qu'il utilise les bonnes couleurs. void TriangulateCornerTerracesCliff ( 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); } }
Et faites de même pour TriangulateCornerCliffTerraces
. void TriangulateCornerCliffTerraces ( 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); } }
Carte en relief pleine splat.paquet d'unitéTableaux de texture
Maintenant que notre terrain a une carte splat, nous pouvons passer la collection de textures au shader. Nous ne pouvons pas simplement affecter un shader à un tableau de textures C #, car le tableau doit exister dans la mémoire du GPU en tant qu'entité unique. Nous devrons utiliser un objet spécial Texture2DArray
qui est pris en charge dans Unity depuis la version 5.4.Tous les GPU prennent-ils en charge les tableaux de textures?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
Le maître
Malheureusement, la prise en charge par Unity des tableaux de texture dans la version 5.5 est minime. Nous ne pouvons pas simplement créer un élément de tableau de textures et lui affecter des textures. Nous devons le faire manuellement. Nous pouvons soit créer un tableau de textures en mode Play, soit créer un élément dans l'éditeur. Créons un atout.Pourquoi créer un atout?, Play . , .
, . Unity . , . , .
Pour créer un tableau de textures, nous assemblerons notre propre maître. Créez un script TextureArrayWizard
et placez-le dans le dossier Editor . Au lieu de cela, MonoBehaviour
il doit étendre le type à ScriptableWizard
partir de l'espace de noms UnityEditor
. using UnityEditor; using UnityEngine; public class TextureArrayWizard : ScriptableWizard { }
Nous pouvons ouvrir l'assistant via une méthode statique généralisée ScriptableWizard.DisplayWizard
. Ses paramètres sont les noms de la fenêtre de l'assistant et son bouton de création. Nous appellerons cette méthode dans une méthode statique CreateWizard
. static void CreateWizard () { ScriptableWizard.DisplayWizard<TextureArrayWizard>( "Create Texture Array", "Create" ); }
Pour accéder à l'assistant via l'éditeur, nous devons ajouter cette méthode au menu Unity. Cela peut être fait en ajoutant un attribut à la méthode MenuItem
. Ajoutons-le au menu Assets , et plus spécifiquement au tableau Assets / Create / Texture . [MenuItem("Assets/Create/Texture Array")] static void CreateWizard () { … }
Notre assistant personnalisé.En utilisant le nouvel élément de menu, vous pouvez ouvrir le menu contextuel de notre assistant personnalisé. Ce n'est pas très beau, mais adapté pour résoudre le problème. Cependant, il est toujours vide. Pour créer un tableau de textures, nous avons besoin d'un tableau de textures. Ajoutez-y un champ général pour le maître. L'interface graphique standard de l'assistant l'affiche comme le fait un inspecteur standard. public Texture2D[] textures;
Maîtrisez les textures.Créons quelque chose
Lorsque vous cliquez sur le bouton Créer de l' assistant, il disparaît. De plus, Unity se plaint de l'absence de méthode OnWizardCreate
. Il s'agit de la méthode qui est appelée lorsque le bouton de création est cliqué, nous devons donc l'ajouter à l'assistant. void OnWizardCreate () { }
Ici, nous allons créer notre tableau de textures. Au moins si l'utilisateur a ajouté des textures au maître. Sinon, il n'y a rien à créer et le travail doit être arrêté. void OnWizardCreate () { if (textures.Length == 0) { return; } }
L'étape suivante consiste à demander l'emplacement pour enregistrer l'actif du tableau de textures. Le panneau d'enregistrement de fichier peut être ouvert à l'aide de la méthode EditorUtility.SaveFilePanelInProject
. Ses paramètres définissent le nom du panneau, le nom de fichier par défaut, l'extension de fichier et la description. Les tableaux de texture utilisent l'extension de fichier d' actif générale . if (textures.Length == 0) { return; } EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" );
SaveFilePanelInProject
renvoie le chemin du fichier sélectionné par l'utilisateur. Si l'utilisateur a cliqué sur Annuler sur ce panneau, le chemin d'accès sera une chaîne vide. Par conséquent, dans ce cas, nous devons interrompre le travail. string path = EditorUtility.SaveFilePanelInProject( "Save Texture Array", "Texture Array", "asset", "Save Texture Array" ); if (path.Length == 0) { return; }
Création d'un tableau de textures
Si nous avons le bon chemin, nous pouvons continuer et créer un nouvel objet Texture2DArray
. Sa méthode constructeur nécessite de spécifier la largeur et la hauteur de la texture, la longueur du tableau, le format des textures et la nécessité de texturer le mip. Ces paramètres doivent être les mêmes pour toutes les textures du tableau. Pour configurer l'objet, nous utilisons la première texture. L'utilisateur doit vérifier que toutes les textures ont le même 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 );
Étant donné que le tableau de textures est une ressource GPU unique, il utilise les mêmes modes de filtrage et de pliage pour toutes les textures. Ici, nous utilisons à nouveau la première texture pour tout configurer. 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;
Maintenant, nous pouvons copier les textures dans un tableau en utilisant la méthode Graphics.CopyTexture
. La méthode copie les données brutes de texture, un niveau de mip à la fois. Par conséquent, nous devons parcourir toutes les textures et leurs niveaux de mip. Les paramètres de méthode sont deux ensembles composés d'une ressource de texture, d'un index et d'un niveau mip. Comme les textures d'origine ne sont pas des tableaux, leur index est toujours nul. 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); } }
À ce stade, nous avons en mémoire la bonne gamme de textures, mais ce n'est pas encore un atout. La dernière étape consistera à appeler AssetDatabase.CreateAsset
avec le tableau et son chemin. Dans ce cas, les données seront écrites dans un fichier de notre projet et elles apparaîtront dans la fenêtre du projet. for (int i = 0; i < textures.Length; i++) { … } AssetDatabase.CreateAsset(textureArray, path);
Textures
Pour créer un véritable tableau de textures, nous avons besoin des textures d'origine. Voici cinq textures qui correspondent aux couleurs que nous utilisions jusqu'à présent. Le jaune devient sable, le vert devient herbe, le bleu devient terre, l'orange devient pierre et le blanc devient neige.Textures de sable, d'herbe, de terre, de pierre et de neige.Notez que ces textures ne sont pas des photographies de ce relief. Ce sont les modèles pseudo-aléatoires faciles que j'ai créés à l'aide de NumberFlow . Je me suis efforcé de créer des types et des détails de relief reconnaissables qui n'entrent pas en conflit avec le relief polygonal abstrait. Le photoréalisme s'est révélé inadapté à cela. En outre, bien que les modèles ajoutent de la variabilité, ils contiennent peu de caractéristiques distinctes qui rendraient les répétitions immédiatement perceptibles.Ajoutez ces textures au tableau principal, en vous assurant que leur ordre correspond aux couleurs. C'est-à-dire d'abord du sable, puis de l'herbe, de la terre, de la pierre et enfin de la neige.Création d'un tableau de textures.Après avoir créé l'élément de tableau de textures, sélectionnez-le et examinez-le dans l'inspecteur.Inspecteur de tableau de texture.Il s'agit de l'affichage le plus simple d'un morceau de données de tableau de texture. Notez qu'il existe un commutateur Is Readable qui est initialement activé. Comme nous n'avons pas besoin de lire les données de pixels du tableau, désactivez-le. Nous ne pouvons pas le faire dans l'assistant car il n'existe Texture2DArray
aucune méthode ou propriété pour accéder à ce paramètre.(Dans Unity 5.6, il y a un bogue qui gâche les tableaux de texture dans les assemblys sur plusieurs plates-formes. Vous pouvez le contourner sans désactiver Est lisible .)Il convient également de noter qu'il existe un champ Espace colorimétriqueauquel est affectée la valeur 1. Cela signifie que les textures sont supposées être dans l'espace gamma, ce qui est vrai. S'ils étaient censés être dans un espace linéaire, le champ devait être défini sur 0. En fait, le concepteur Texture2DArray
a un paramètre supplémentaire pour spécifier l'espace colorimétrique, mais il Texture2D
ne montre pas s'il se trouve dans l'espace linéaire ou non, par conséquent, dans tous les cas, vous devez définir manuellement.Shader
Maintenant que nous avons un éventail de textures, nous devons apprendre au shader à travailler avec. Pour l'instant, nous utilisons le shader VertexColors pour rendre le terrain . Puisque maintenant nous utiliserons des textures au lieu de couleurs, renommez-le en Terrain . Ensuite, nous transformons son paramètre _MainTex en un tableau de textures et lui attribuons un atout. 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 } … }
Matériau en relief avec un éventail de textures.Pour activer les tableaux de textures sur toutes les plates-formes les prenant en charge, vous devez augmenter le niveau cible du shader de 3,0 à 3,5. #pragma target 3.5
Étant donné que la variable _MainTex
fait désormais référence à un tableau de textures, nous devons changer son type. Le type dépend de la plate-forme cible et la macro s'en chargera UNITY_DECLARE_TEX2DARRAY
.
Comme dans d'autres shaders, pour échantillonner la texture du relief, nous avons besoin des coordonnées du monde XZ. Par conséquent, nous ajouterons une position dans le monde à la structure d'entrée du shader de surface. Nous supprimons également les coordonnées UV par défaut, car nous n'en avons pas besoin. struct Input {
Pour échantillonner un tableau de textures, nous devons utiliser une macro UNITY_SAMPLE_TEX2DARRAY
. Pour échantillonner un tableau, il a besoin de trois coordonnées. Les deux premiers sont des coordonnées UV régulières. Nous utiliserons les coordonnées du monde XZ mises à l'échelle à 0,02. Nous obtenons donc une bonne résolution de texture à plein grossissement. Les textures seront répétées environ toutes les quatre cellules.La troisième coordonnée est utilisée comme index du tableau de textures, comme dans un tableau régulier. Comme les coordonnées sont flottantes, avant d'indexer le tableau GPU les arrondit. Puisque jusqu'à ce que nous sachions quelle texture est nécessaire, utilisons toujours la première. De plus, la couleur du sommet n'affectera pas le résultat final, car il s'agit d'une carte splat. void surf (Input IN, 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 = ca; }
Tout est devenu sable.paquet d'unitéSélection de texture
Nous avons besoin d'une carte de relief en relief qui mélange les trois types dans un triangle. Nous avons une gamme de textures avec une texture pour chaque type de terrain. Nous avons un shader qui échantillonne un tableau de textures. Mais pour l'instant, nous n'avons aucun moyen de dire au shader quelles textures choisir pour chaque triangle.Étant donné que chaque triangle mélange jusqu'à trois types, nous devons associer trois indices à chaque triangle. Nous ne pouvons pas stocker d'informations pour les triangles, nous devons donc stocker des index pour les sommets. Les trois sommets du triangle enregistreront simplement les mêmes indices qu'avec la couleur unie.Données de maillage
Nous pouvons utiliser l'un des ensembles du maillage UV pour stocker des indices. Étant donné que trois index sont stockés sur chaque sommet, les ensembles UV 2D existants ne seront pas suffisants. Heureusement, les ensembles UV peuvent contenir jusqu'à quatre coordonnées. Par conséquent, nous ajoutons à la HexMesh
deuxième liste Vector3
, à laquelle nous ferons référence en tant que types de relief. public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates; public bool useTerrainTypes; [NonSerialized] List<Vector3> vertices, terrainTypes;
Activez les types de terrain pour l' enfant Terrain du préfabriqué Hex Grid Chunk .Nous utilisons des types de relief.Si nécessaire, nous prendrons une autre liste Vector3
pour les types de relief lors du nettoyage de la maille. public void Clear () { … if (useTerrainTypes) { terrainTypes = ListPool<Vector3>.Get(); } triangles = ListPool<int>.Get(); }
Lors de l'application des données de maillage, nous enregistrons les types de relief dans le troisième ensemble UV. Pour cette raison, ils n'entreront pas en conflit avec deux autres ensembles, si jamais nous décidons de les utiliser ensemble. public void Apply () { … if (useTerrainTypes) { hexMesh.SetUVs(2, terrainTypes); ListPool<Vector3>.Add(terrainTypes); } hexMesh.SetTriangles(triangles, 0); … }
Pour définir les types de relief du triangle, nous utiliserons Vector3
. Puisque ceux-ci sont les mêmes pour tout le triangle, nous ajoutons simplement les mêmes données trois fois. public void AddTriangleTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); }
Le mixage en quad fonctionne de la même manière. Les quatre sommets sont du même type. public void AddQuadTerrainTypes (Vector3 types) { terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); terrainTypes.Add(types); }
Ils Aiment Triangles of Ribs
Maintenant, nous devons ajouter des types aux données de maillage dans HexGridChunk
. Commençons par TriangulateEdgeFan
. Tout d'abord, pour une meilleure lisibilité, nous séparerons les appels aux méthodes vertex et color. Rappelons qu'à chaque appel à cette méthode, nous la lui transmettons color1
, afin que nous puissions utiliser cette couleur directement, et non appliquer le paramètre. void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, Color color) { terrain.AddTriangle(center, edge.v1, edge.v2);
Après les couleurs, nous ajoutons des types de relief. Étant donné que les types dans le triangle peuvent être différents, cela devrait être un paramètre qui remplace la couleur. Utilisez ce type simple pour créer Vector3
. Seuls les quatre premiers canaux sont importants pour nous, car dans ce cas, la carte de répartition est toujours rouge. Étant donné que les trois composants du vecteur doivent être attribués, attribuons-leur un type. void TriangulateEdgeFan (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); }
Maintenant, nous devons changer tous les appels à cette méthode, en remplaçant l'argument couleur par un index du type de terrain de la cellule. Nous allons effectuer ce changement dans TriangulateWithoutRiver
, TriangulateAdjacentToRiver
et TriangulateWithRiverBeginOrEnd
.
À ce stade, lorsque vous démarrez le mode Lecture, des erreurs apparaîtront vous informant que les troisièmes ensembles de maillages UV sont hors limites. Cela s'est produit parce que nous n'ajoutons pas encore de types de relief à chaque triangle et quadruple. Continuons donc à changer HexGridChunk
.Rayures côtelées
Maintenant, lors de la création d'une bande de chant, nous devons savoir quels types de terrain se trouvent des deux côtés. Par conséquent, nous les ajoutons en tant que paramètres, puis créons un vecteur de types dont les deux canaux sont affectés à ces types. Le troisième canal n'est pas important, il suffit donc de l'égaler au premier. Après avoir ajouté les couleurs, ajoutez les types au quad. void TriangulateEdgeStrip ( EdgeVertices e1, Color c1, float type1, EdgeVertices e2, Color c2, float type2, bool hasRoad = false ) { terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2); terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3); terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4); terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); terrain.AddQuadColor(c1, c2); Vector3 types; types.x = types.z = type1; types.y = type2; terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); if (hasRoad) { TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4); } }
Maintenant, nous devons changer les défis TriangulateEdgeStrip
. Tout d'abord TriangulateAdjacentToRiver
, TriangulateWithRiverBeginOrEnd
et TriangulateWithRiver
doit utiliser le type de cellule pour les deux côtés de la bande de côtes.
Ensuite, le cas le plus simple d'une arête TriangulateConnection
doit utiliser le type de cellule pour l'arête la plus proche et le type voisin pour l'arête éloignée. Ils peuvent être identiques ou différents. void TriangulateConnection ( HexDirection direction, HexCell cell, EdgeVertices e1 ) { … if (cell.GetEdgeType(direction) == HexEdgeType.Slope) { TriangulateEdgeTerraces(e1, cell, e2, neighbor, hasRoad); } else {
La même chose s'applique à TriangulateEdgeTerraces
ce qui déclenche trois fois TriangulateEdgeStrip
. Les types de corniches sont les mêmes. void TriangulateEdgeTerraces ( EdgeVertices begin, HexCell beginCell, EdgeVertices end, HexCell endCell, bool hasRoad ) { EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1); Color c2 = HexMetrics.TerraceLerp(color1, color2, 1); float t1 = beginCell.TerrainTypeIndex; float t2 = endCell.TerrainTypeIndex; TriangulateEdgeStrip(begin, color1, t1, e2, c2, t2, 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, t1, e2, c2, t2, hasRoad); } TriangulateEdgeStrip(e2, c2, t1, end, color2, t2, hasRoad); }
Angles
Le cas le plus simple d'un angle est un simple triangle. La cellule du bas transfère le premier type, celle de gauche la seconde et celle de droite le troisième. En les utilisant, créez un vecteur de types et ajoutez-le au triangle. void TriangulateCorner ( Vector3 bottom, HexCell bottomCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { … else { terrain.AddTriangle(bottom, left, right); terrain.AddTriangleColor(color1, color2, color3); Vector3 types; types.x = bottomCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); } features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell); }
Nous utilisons la même approche dans TriangulateCornerTerraces
, seulement ici nous créons un groupe de quads. void TriangulateCornerTerraces ( 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); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; terrain.AddTriangle(begin, v3, v4); terrain.AddTriangleColor(color1, c3, c4); terrain.AddTriangleTerrainTypes(types); 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.AddQuadTerrainTypes(types); } terrain.AddQuad(v3, v4, left, right); terrain.AddQuadColor(c3, c4, color2, color3); terrain.AddQuadTerrainTypes(types); }
Lors du mélange des rebords et des falaises, nous devons utiliser TriangulateBoundaryTriangle
. Donnez-lui simplement un paramètre de type vecteur et ajoutez-le à tous ses triangles. void TriangulateBoundaryTriangle ( Vector3 begin, Color beginColor, Vector3 left, Color leftColor, Vector3 boundary, Color boundaryColor, Vector3 types ) { 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); terrain.AddTriangleTerrainTypes(types); 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.AddTriangleTerrainTypes(types); } terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary); terrain.AddTriangleColor(c2, leftColor, boundaryColor); terrain.AddTriangleTerrainTypes(types); }
Dans TriangulateCornerTerracesCliff
créer un vecteur de types basé sur les cellules transférées. Ajoutez-le ensuite à un triangle et passez TriangulateBoundaryTriangle
. void TriangulateCornerTerracesCliff ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (rightCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b ); Color boundaryColor = Color.Lerp(color1, color3, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( begin, color1, left, color2, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } }
Il en va de même TriangulateCornerCliffTerraces
. void TriangulateCornerCliffTerraces ( Vector3 begin, HexCell beginCell, Vector3 left, HexCell leftCell, Vector3 right, HexCell rightCell ) { float b = 1f / (leftCell.Elevation - beginCell.Elevation); if (b < 0) { b = -b; } Vector3 boundary = Vector3.Lerp( HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b ); Color boundaryColor = Color.Lerp(color1, color2, b); Vector3 types; types.x = beginCell.TerrainTypeIndex; types.y = leftCell.TerrainTypeIndex; types.z = rightCell.TerrainTypeIndex; TriangulateBoundaryTriangle( right, color3, begin, color1, boundary, boundaryColor, types ); if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) { TriangulateBoundaryTriangle( left, color2, right, color3, boundary, boundaryColor, types ); } else { terrain.AddTriangleUnperturbed( HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary ); terrain.AddTriangleColor(color2, color3, boundaryColor); terrain.AddTriangleTerrainTypes(types); } }
Rivières
La dernière méthode à changer est la suivante TriangulateWithRiver
. Puisque nous sommes ici au centre de la cellule, nous ne traitons que du type de cellule actuelle. Par conséquent, créez-en un vecteur et ajoutez-le aux triangles et aux quadruples. void TriangulateWithRiver ( HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e ) { … terrain.AddTriangleColor(color1); terrain.AddQuadColor(color1); terrain.AddQuadColor(color1); terrain.AddTriangleColor(color1); Vector3 types; types.x = types.y = types.z = cell.TerrainTypeIndex; terrain.AddTriangleTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddQuadTerrainTypes(types); terrain.AddTriangleTerrainTypes(types); … }
Mélange de types
À ce stade, les mailles contiennent les indices d'élévation nécessaires. Il ne nous reste plus qu'à forcer le shader Terrain à les utiliser. Pour que les indices tombent dans le shader de fragments, nous devons d'abord les faire passer dans le vertex shader. Nous pouvons le faire dans notre propre fonction de sommet, comme nous l'avons fait dans le shader Estuaire . Dans ce cas, nous ajoutons un champ à la structure d'entrée float3 terrain
et le copions dedans v.texcoord2.xyz
. #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 … struct Input { float4 color : COLOR; float3 worldPos; float3 terrain; }; void vert (inout appdata_full v, out Input data) { UNITY_INITIALIZE_OUTPUT(Input, data); data.terrain = v.texcoord2.xyz; }
Nous devons échantillonner le tableau de texture trois fois par fragment. Par conséquent, créons une fonction pratique pour créer des coordonnées de texture, échantillonner un tableau et moduler un échantillon avec une carte splat pour un index. float4 GetTerrainColor (Input IN, int index) { float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]); float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw); return c * IN.color[index]; } void surf (Input IN, inout SurfaceOutputStandard o) { … }
Pouvons-nous travailler avec un vecteur comme un tableau?Oui - color[0]
color.r
. color[1]
color.g
, .
En utilisant cette fonction, nous pouvons simplement échantillonner le tableau de texture trois fois et combiner les résultats. void surf (Input IN, inout SurfaceOutputStandard o) { // float2 uv = IN.worldPos.xz * 0.02; fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); o.Albedo = c.rgb * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Relief texturé.Maintenant, nous pouvons peindre le relief avec des textures. Ils se mélangent comme des couleurs unies. Puisque nous utilisons les coordonnées du monde comme coordonnées UV, elles ne changent pas avec la hauteur. En conséquence, le long des falaises acérées, les textures sont étirées. Si les textures sont assez neutres et très variables, les résultats seront acceptables. Sinon, nous obtenons de grosses vergetures laides. Vous pouvez essayer de le masquer avec une géométrie ou une texture supplémentaire des falaises, mais dans le tutoriel, nous ne le ferons pas.Balayer
Maintenant, lorsque nous utilisons des textures au lieu de couleurs, il sera logique de changer le panneau de l'éditeur. Nous pouvons créer une belle interface qui peut même afficher des textures en relief, mais je me concentrerai sur les abréviations qui correspondent au style du schéma existant.Options de secours.De plus, la HexCell
propriété color n'est plus nécessaire, supprimez-la donc.
Vous HexGrid
pouvez également supprimer un tableau de couleurs et le code associé.
Enfin, un tableau de couleurs n'est également pas nécessaire dans HexMetrics
.
paquet d'unitéPartie 15: distances
- Affichez les lignes de la grille.
- Basculez entre les modes d'édition et de navigation.
- Calculez la distance entre les cellules.
- Nous trouvons des moyens de contourner les obstacles.
- Nous prenons en compte les coûts variables de déménagement.
Après avoir créé des cartes de haute qualité, nous commencerons la navigation.Le chemin le plus court n'est pas toujours rectiligne.Affichage de la grille
La navigation sur la carte s'effectue en se déplaçant de cellule en cellule. Pour arriver quelque part, vous devez passer par une série de cellules. Pour faciliter l'estimation des distances, ajoutons l'option d'afficher la grille hexagonale sur laquelle notre carte est basée.Texture de maille
Malgré les irrégularités du maillage de la carte, le maillage sous-jacent est parfaitement plat. Nous pouvons le montrer en projetant un motif de grille sur une carte. Ceci peut être réalisé en utilisant une texture de maille répétée.Texture de maille répétée.La texture montrée ci-dessus contient une petite partie de la grille hexagonale couvrant 2 par 2 cellules. Cette zone est rectangulaire et non carrée. Comme la texture elle-même est un carré, le motif semble étiré. Lors de l'échantillonnage, nous devons compenser cela.Projection de grille
Pour projeter un motif de maillage, nous devons ajouter une propriété de texture au shader Terrain . 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 _Metallic ("Metallic", Range(0,1)) = 0.0 }
Matériau en relief avec texture en maille.Échantillonnez la texture en utilisant les coordonnées XZ du monde, puis multipliez-la par l'albédo. Étant donné que les lignes de la grille sur la texture sont grises, cela entrelacera le motif dans le relief. sampler2D _GridTex; … void surf (Input IN, inout SurfaceOutputStandard o) { fixed4 c = GetTerrainColor(IN, 0) + GetTerrainColor(IN, 1) + GetTerrainColor(IN, 2); fixed4 grid = tex2D(_GridTex, IN.worldPos.xz); o.Albedo = c.rgb * grid * _Color; o.Metallic = _Metallic; o.Smoothness = _Glossiness; o.Alpha = ca; }
Albédo multiplié par un maillage fin.Nous devons mettre le modèle à l'échelle afin qu'il corresponde aux cellules de la carte. La distance entre les centres des cellules voisines est de 15, elle doit être doublée pour remonter de deux cellules. Autrement dit, nous devons diviser les coordonnées de la grille V par 30. Le rayon intérieur des cellules est 5√3, et pour déplacer deux cellules vers la droite, nous avons besoin de quatre fois plus. Par conséquent, il est nécessaire de diviser les coordonnées de la grille U par 20√3. float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); fixed4 grid = tex2D(_GridTex, gridUV);
La taille de maille correcte.Maintenant, les lignes de la grille correspondent aux cellules de la carte. Comme les textures en relief, ils ignorent la hauteur, donc les lignes seront étirées le long des falaises.Projection sur cellules à hauteur.La déformation du maillage n'est généralement pas si mauvaise, surtout lorsque vous regardez une carte à longue distance.Maille au loin.Inclusion de grille
Bien que l'affichage d'une grille soit pratique, il n'est pas toujours nécessaire. Par exemple, vous devez le désactiver lorsque vous prenez une capture d'écran. De plus, tout le monde ne préfère pas voir la grille en permanence. Rendons-le donc facultatif. Nous allons ajouter la directive multi_compile au shader pour créer des options avec et sans grille. Pour ce faire, nous utiliserons le mot-clé GRID_ON
. La compilation des shaders conditionnels est décrite dans le didacticiel Rendu 5, Lumières multiples . #pragma surface surf Standard fullforwardshadows vertex:vert #pragma target 3.5 #pragma multi_compile _ GRID_ON
Lors de la déclaration d'une variable, nous lui grid
attribuons d'abord une valeur de 1. Par conséquent, la grille sera désactivée. Ensuite, nous échantillonnerons la texture de la grille uniquement pour la variante avec un mot clé spécifique GRID_ON
. fixed4 grid = 1; #if defined(GRID_ON) float2 gridUV = IN.worldPos.xz; gridUV.x *= 1 / (4 * 8.66025404); gridUV.y *= 1 / (2 * 15.0); grid = tex2D(_GridTex, gridUV); #endif o.Albedo = c.rgb * grid * _Color;
Comme le mot-clé n'est GRID_ON
pas inclus dans le shader de terrain, la grille disparaîtra. Pour le réactiver, nous allons ajouter un commutateur à l'interface utilisateur de l'éditeur de carte. Pour rendre cela possible, je HexMapEditor
dois obtenir un lien vers le matériau du terrain et une méthode pour activer ou désactiver le mot-clé GRID_ON
. public Material terrainMaterial; … public void ShowGrid (bool visible) { if (visible) { terrainMaterial.EnableKeyword("GRID_ON"); } else { terrainMaterial.DisableKeyword("GRID_ON"); } }
Editeur March hexagones en référence au matériau.Ajoutez un commutateur Grid à l'interface utilisateur et connectez-le à la méthode ShowGrid
.Interrupteur de réseau.Enregistrer l'état
Maintenant en mode Play, nous pouvons changer l'affichage de la grille. Lors du premier test, la grille est initialement désactivée et devient visible lorsque nous allumons l'interrupteur. Lorsque vous le désactivez, la grille disparaîtra à nouveau. Cependant, si nous quittons le mode Play lorsque la grille est visible, la prochaine fois que vous lancerez le mode Play, il sera réactivé, bien que le commutateur soit désactivé.En effet, nous modifions le mot-clé pour le matériau Terrain général . Nous modifions l'actif matériel, la modification est donc enregistrée dans l'éditeur Unity. Il ne sera pas enregistré dans l'assemblage.Pour toujours démarrer le jeu sans grille, nous désactiverons le mot-clé GRID_ON
dans Awake HexMapEditor
. void Awake () { terrainMaterial.DisableKeyword("GRID_ON"); }
paquet d'unitéMode édition
Si nous voulons contrôler le mouvement sur la carte, nous devons interagir avec elle. Au minimum, nous devons sélectionner la cellule comme point de départ du chemin. Mais lorsque vous cliquez sur une cellule, elle sera modifiée. Nous pouvons désactiver toutes les options d'édition manuellement, mais cela n'est pas pratique. De plus, nous ne voulons pas que les calculs de déplacement soient effectués pendant l'édition de la carte. Ajoutons donc un commutateur qui détermine si nous sommes en mode édition.Modifier le commutateur
Ajoutez au HexMapEditor
champ booléen editMode
, ainsi qu'à la méthode qui le définit. Ajoutez ensuite un autre commutateur à l'interface utilisateur pour le contrôler. Commençons par le mode de navigation, c'est-à-dire que le mode d'édition sera désactivé par défaut. bool editMode; … public void SetEditMode (bool toggle) { editMode = toggle; }
Commutateur de mode d'édition.Pour désactiver l' édition en fait, faire un appel EditCells
dépendant editMode
. void HandleInput () { Ray inputRay = Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(inputRay, out hit)) { HexCell currentCell = hexGrid.GetCell(hit.point); if (previousCell && previousCell != currentCell) { ValidateDrag(currentCell); } else { isDrag = false; } if (editMode) { EditCells(currentCell); } previousCell = currentCell; } else { previousCell = null; } }
Étiquettes de débogage
Jusqu'à présent, nous n'avons pas d'unités pour se déplacer sur la carte. Au lieu de cela, nous visualisons les distances de mouvement. Pour ce faire, vous pouvez utiliser des étiquettes de cellule existantes. Par conséquent, nous les rendrons visibles lorsque le mode d'édition est désactivé. public void SetEditMode (bool toggle) { editMode = toggle; hexGrid.ShowUI(!toggle); }
Puisque nous commençons par le mode de navigation, les étiquettes par défaut doivent être activées. Actuellement HexGridChunk.Awake
, il les désactive, mais il ne devrait plus le faire. void Awake () { gridCanvas = GetComponentInChildren<Canvas>(); cells = new HexCell[HexMetrics.chunkSizeX * HexMetrics.chunkSizeZ];
Étiquettes de coordonnées.Les coordonnées des cellules deviennent désormais visibles immédiatement après le lancement du mode Lecture. Mais nous n'avons pas besoin de coordonnées, nous utilisons des étiquettes pour afficher les distances. Comme cela ne nécessite qu'un seul numéro par cellule, vous pouvez augmenter la taille de la police pour une meilleure lecture. Modifiez le préfabriqué de l' étiquette de cellule hexadécimale afin qu'il utilise une police en gras de taille 8.Balises avec une taille de police en gras 8.Maintenant, après avoir lancé le mode Lecture, nous verrons de grandes balises. Seules les premières coordonnées de la cellule sont visibles, les autres ne sont pas placées dans l'étiquette.Grands tags.Comme nous n'avons plus besoin des coordonnées, nous supprimerons la HexGrid.CreateCell
valeur dans l' affectation label.text
. void CreateCell (int x, int z, int i) { … Text label = Instantiate<Text>(cellLabelPrefab); label.rectTransform.anchoredPosition = new Vector2(position.x, position.z);
Vous pouvez également supprimer le commutateur Labels et sa méthode associée de l'interface utilisateur HexMapEditor.ShowUI
.
Le changement de méthode n'est plus.paquet d'unitéTrouver des distances
Maintenant que nous avons le mode de navigation balisé, nous pouvons commencer à afficher les distances. Nous allons sélectionner une cellule, puis afficher la distance de cette cellule à toutes les cellules de la carte.Affichage de la distance
Pour suivre la distance à la cellule, ajoutez au HexCell
champ entier distance
. Il indiquera la distance entre cette cellule et celle sélectionnée. Par conséquent, pour la cellule sélectionnée elle-même, elle sera nulle, pour le voisin immédiat, elle est 1, et ainsi de suite. int distance;
Lorsque la distance est définie, nous devons mettre à jour le libellé de la cellule pour afficher sa valeur. HexCell
a une référence à l' RectTransform
objet UI. Nous devrons l'appeler GetComponent<Text>
pour se rendre à la cellule. Considérez ce qui se Text
trouve dans l'espace de noms UnityEngine.UI
, utilisez-le donc au début du script. void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance.ToString(); }
Ne devrions-nous pas garder un lien direct vers le composant Texte?, . , , , . , .
Définissons la propriété générale pour recevoir et définir la distance à la cellule, ainsi que mettre à jour son étiquette. public int Distance { get { return distance; } set { distance = value; UpdateDistanceLabel(); } }
Ajoutez à la HexGrid
méthode générale FindDistancesTo
avec le paramètre de cellule. Pour l'instant, nous allons simplement définir la distance zéro pour chaque cellule. public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = 0; } }
Si le mode d'édition n'est pas activé, nous HexMapEditor.HandleInput
appelons une nouvelle méthode avec la cellule actuelle. if (editMode) { EditCells(currentCell); } else { hexGrid.FindDistancesTo(currentCell); }
Distances entre coordonnées
Maintenant en mode navigation, après avoir touché l'un d'eux, toutes les cellules affichent zéro. Mais, bien sûr, ils devraient afficher la vraie distance de la cellule. Pour calculer leur distance, nous pouvons utiliser les coordonnées de la cellule. Par conséquent, supposons qu'il HexCoordinates
possède une méthode DistanceTo
et utilisez-la dans HexGrid.FindDistancesTo
. public void FindDistancesTo (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } }
Maintenant, ajoutez à la HexCoordinates
méthode DistanceTo
. Il doit comparer ses propres coordonnées avec les coordonnées d'un autre ensemble. Commençons seulement par mesurer X, et nous soustraireons les coordonnées X les unes des autres. public int DistanceTo (HexCoordinates other) { return x - other.x; }
En conséquence, nous obtenons un décalage le long de X par rapport à la cellule sélectionnée. Mais les distances ne peuvent pas être négatives, vous devez donc renvoyer la différence de coordonnées X modulo. return x < other.x ? other.x - x : x - other.x;
Distances le long de X.Nous obtenons donc les distances correctes uniquement si nous ne prenons en compte qu'une seule dimension. Mais il y a trois dimensions dans une grille d'hexagones. Alors additionnons les distances pour les trois dimensions et voyons ce que cela nous donne. return (x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y) + (z < other.z ? other.z - z : z - other.z);
Somme des distances XYZ.Il s'avère que nous obtenons deux fois la distance. Autrement dit, pour obtenir la distance correcte, ce montant doit être divisé en deux. return ((x < other.x ? other.x - x : x - other.x) + (Y < other.Y ? other.Y - Y : Y - other.Y) + (z < other.z ? other.z - z : z - other.z)) / 2;
Distances réelles.Pourquoi la somme est-elle égale au double de la distance?, . , (1, −3, 2). . , . . , . .
. paquet d'unitéTravailler avec des obstacles
Les distances que nous calculons correspondent aux plus courts trajets de la cellule sélectionnée à l'autre cellule. Nous ne pouvons pas trouver un moyen plus court. Mais ces chemins sont garantis pour être corrects si l'itinéraire ne bloque rien. Les falaises, l'eau et d'autres obstacles peuvent nous faire faire le tour. Peut-être que certaines cellules ne peuvent pas être atteintes du tout.Pour trouver un moyen de contourner les obstacles, nous devons utiliser une approche différente au lieu de simplement calculer la distance entre les coordonnées. Nous ne pouvons plus examiner chaque cellule individuellement. Nous devrons rechercher la carte jusqu'à ce que nous trouvions toutes les cellules accessibles.Visualisation de la recherche
La recherche cartographique est un processus itératif. Pour comprendre ce que nous faisons, il serait utile de voir chaque étape de la recherche. Nous pouvons le faire en transformant l'algorithme de recherche en coroutine, pour lequel nous avons besoin d'un espace de recherche System.Collections
. Le taux de rafraîchissement de 60 itérations par seconde est suffisamment petit pour que nous puissions voir ce qui se passe, et la recherche sur une petite carte n'a pas pris trop de temps. public void FindDistancesTo (HexCell cell) { StartCoroutine(Search(cell)); } IEnumerator Search (HexCell cell) { WaitForSeconds delay = new WaitForSeconds(1 / 60f); for (int i = 0; i < cells.Length; i++) { yield return delay; cells[i].Distance = cell.coordinates.DistanceTo(cells[i].coordinates); } }
Nous devons nous assurer qu'une seule recherche est active à un moment donné. Par conséquent, avant de commencer une nouvelle recherche, nous arrêtons toutes les coroutines. public void FindDistancesTo (HexCell cell) { StopAllCoroutines(); StartCoroutine(Search(cell)); }
De plus, nous devons terminer la recherche lors du chargement d'une nouvelle carte. public void Load (BinaryReader reader, int header) { StopAllCoroutines(); … }
Recherche en largeur
Avant même de commencer la recherche, nous savons que la distance jusqu'à la cellule sélectionnée est nulle. Et, bien sûr, la distance à tous ses voisins est de 1, s'ils peuvent être atteints. Ensuite, nous pouvons jeter un oeil à l'un de ces voisins. Cette cellule a très probablement ses propres voisins accessibles et pour lesquels la distance n'a pas encore été calculée. Si c'est le cas, alors la distance à ces voisins devrait être de 2. Nous pouvons répéter ce processus pour tous les voisins à une distance de 1. Après cela, nous le répétons pour tous les voisins à une distance de 2. Et ainsi de suite, jusqu'à ce que nous atteignions toutes les cellules.Autrement dit, nous trouvons d'abord toutes les cellules à une distance de 1, puis nous trouvons tout à une distance de 2, puis à une distance de 3, et ainsi de suite, jusqu'à ce que nous ayons terminé. Cela garantit que nous trouvons la plus petite distance à chaque cellule accessible. Cet algorithme est appelé recherche en largeur.Pour que cela fonctionne, nous devons savoir si nous avons déjà déterminé la distance à la cellule. Souvent, pour cela, les cellules sont placées dans une collection appelée ensemble prêt à l'emploi ou fermé. Mais nous pouvons définir la distance jusqu'à la cellule int.MaxValue
pour indiquer que nous ne l'avons pas encore visitée. Nous devons le faire pour toutes les cellules juste avant d'effectuer une recherche. IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } … }
Vous pouvez également l'utiliser pour masquer toutes les cellules non visitées en les modifiant HexCell.UpdateDistanceLabel
. Après cela, nous commencerons chaque recherche sur une carte vierge. void UpdateDistanceLabel () { Text label = uiRect.GetComponent<Text>(); label.text = distance == int.MaxValue ? "" : distance.ToString(); }
Ensuite, nous devons suivre les cellules qui doivent être visitées et l'ordre dans lequel elles sont visitées. Une telle collection est souvent appelée une bordure ou un ensemble ouvert. Nous avons juste besoin de traiter les cellules dans le même ordre dans lequel nous les avons rencontrées. Pour ce faire, vous pouvez utiliser la file d'attente Queue
, qui fait partie de l'espace de noms System.Collections.Generic
. La cellule sélectionnée sera la première à être placée dans cette file d'attente et aura une distance de 0. IEnumerator Search (HexCell cell) { for (int i = 0; i < cells.Length; i++) { cells[i].Distance = int.MaxValue; } WaitForSeconds delay = new WaitForSeconds(1 / 60f); Queue<HexCell> frontier = new Queue<HexCell>(); cell.Distance = 0; frontier.Enqueue(cell);
A partir de ce moment, l'algorithme exécute la boucle pendant qu'il y a quelque chose dans la file d'attente. À chaque itération, la cellule la plus en avant est récupérée de la file d'attente. frontier.Enqueue(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); }
Nous avons maintenant la cellule actuelle, qui peut être à n'importe quelle distance. Ensuite, nous devons ajouter tous ses voisins à la file d'attente un peu plus loin de la cellule sélectionnée. while (frontier.Count > 0) { yield return delay; HexCell current = frontier.Dequeue(); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor != null) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); } } }
Mais nous ne devons ajouter que les cellules qui n'ont pas encore reçu de distance. if (neighbor != null && neighbor.Distance == int.MaxValue) { neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); }
Recherche large.Évitez l'eau
Après avoir vérifié que la recherche en largeur d'abord trouve les bonnes distances sur la carte monotone, nous pouvons commencer à ajouter des obstacles. Cela peut être fait en refusant d'ajouter des cellules à la file d'attente si certaines conditions sont remplies.En fait, nous sautons déjà certaines cellules: celles qui n'existent pas et celles auxquelles nous avons déjà indiqué la distance. Réécrivons le code afin que dans ce cas, nous ignorions explicitement les voisins. for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { HexCell neighbor = current.GetNeighbor(d); if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } neighbor.Distance = current.Distance + 1; frontier.Enqueue(neighbor); }
Sautons également toutes les cellules qui sont sous l'eau. Cela signifie que lors de la recherche des distances les plus courtes, nous ne considérons que le mouvement au sol. if (neighbor == null || neighbor.Distance != int.MaxValue) { continue; } if (neighbor.IsUnderwater) { continue; }
Distances sans se déplacer dans l'eau.L'algorithme trouve toujours les distances les plus courtes, mais évite désormais toute eau. Par conséquent, les cellules sous-marines ne prennent jamais de distance, comme les zones de terre isolées. La cellule sous-marine ne reçoit la distance que si elle est sélectionnée.Évitez les falaises
De plus, pour déterminer la possibilité de visiter un voisin, nous pouvons utiliser le type de côte. Par exemple, vous pouvez faire en sorte que les falaises bloquent le chemin. Si vous autorisez le mouvement sur les pentes, les cellules de l'autre côté de la falaise peuvent toujours être atteintes, uniquement sur d'autres chemins. Par conséquent, ils peuvent être à des distances très différentes. if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; }
Distances sans traverser les falaises.paquet d'unitéFrais de voyage
Nous pouvons éviter les cellules et les bords, mais ces options sont binaires. On peut imaginer qu’il est plus facile de naviguer dans certaines directions que dans d’autres. Dans ce cas, la distance est mesurée en travail ou en temps.Routes rapides
Il sera logique qu'il soit plus facile et plus rapide de se déplacer sur les routes, alors rendons moins coûteuse l'intersection des bords avec les routes. Puisque nous utilisons des valeurs entières pour définir la distance de déplacement, nous laisserons le coût du déplacement le long des routes égal à 1, et le coût du franchissement d'autres bords, nous augmenterons à 10. C'est une grande différence qui nous permet de voir immédiatement si nous obtenons les bons résultats. int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } neighbor.Distance = distance;
Routes avec de mauvaises distances.Tri par bordure
Malheureusement, il s'avère que la recherche en priorité ne peut pas fonctionner avec des frais de déménagement variables. Il suppose que les cellules sont ajoutées à la frontière dans l'ordre croissant de distance, et pour nous, ce n'est plus pertinent. Nous avons besoin d'une file d'attente prioritaire, c'est-à-dire d'une file d'attente qui se trie d'elle-même. Il n'y a pas de files d'attente prioritaires standard, car vous ne pouvez pas les programmer de telle manière qu'elles conviennent à toutes les situations.Nous pouvons créer notre propre file d'attente prioritaire, mais optimisons-la pour le futur tutoriel. Pour l'instant, nous remplaçons simplement la file d'attente par une liste qui aura une méthode Sort
. List<HexCell> frontier = new List<HexCell>(); cell.Distance = 0; frontier.Add(cell); while (frontier.Count > 0) { yield return delay; HexCell current = frontier[0]; frontier.RemoveAt(0); for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) { … neighbor.Distance = distance; frontier.Add(neighbor); } }
Je ne peux pas utiliser ListPool <HexCell>?, , . , , .
Pour que la bordure soit correcte, nous devons la trier après y avoir ajouté une cellule. En fait, nous pouvons reporter le tri jusqu'à ce que tous les voisins de la cellule soient ajoutés, mais, je le répète, jusqu'à ce que les optimisations ne nous intéressent pas.Nous voulons trier les cellules par distance. Pour ce faire, nous devons appeler la méthode de tri de liste avec un lien vers la méthode qui effectue cette comparaison. frontier.Add(neighbor); frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance));
Comment fonctionne cette méthode de tri?. , . .
frontier.Sort(CompareDistances); … static int CompareDistances (HexCell x, HexCell y) { return x.Distance.CompareTo(y.Distance); }
La bordure triée est toujours incorrecte.Mise à jour de la frontière
Après avoir commencé à trier la frontière, nous avons commencé à obtenir de meilleurs résultats, mais il y a encore des erreurs. En effet, lorsqu'une cellule est ajoutée à la bordure, nous ne trouvons pas nécessairement la distance la plus courte à cette cellule. Cela signifie que maintenant nous ne pouvons plus ignorer les voisins qui ont déjà reçu une distance. Au lieu de cela, nous devons vérifier si nous avons trouvé un chemin plus court. Si c'est le cas, nous devons modifier la distance par rapport au voisin, au lieu de l'ajouter à la frontière. HexCell neighbor = current.GetNeighbor(d); if (neighbor == null) { continue; } if (neighbor.IsUnderwater) { continue; } if (current.GetEdgeType(neighbor) == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += 10; } if (neighbor.Distance == int.MaxValue) { neighbor.Distance = distance; frontier.Add(neighbor); } else if (distance < neighbor.Distance) { neighbor.Distance = distance; } frontier.Sort((x, y) => x.Distance.CompareTo(y.Distance));
Les bonnes distances.Maintenant que nous avons les bonnes distances, nous allons commencer à considérer les coûts de déplacement. Vous pouvez remarquer que les distances à certaines cellules sont initialement trop grandes, mais sont corrigées lorsqu'elles sont retirées de la frontière. Cette approche est appelée l'algorithme de Dijkstra, elle doit son nom au premier inventé par Edsger Dijkstra.Pistes
Nous ne voulons pas nous limiter à des coûts différents uniquement pour les routes. Par exemple, vous pouvez réduire le coût du franchissement des bords plats sans routes à 5, en laissant les pentes sans routes une valeur de 10. HexEdgeType edgeType = current.GetEdgeType(neighbor); if (edgeType == HexEdgeType.Cliff) { continue; } int distance = current.Distance; if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; }
Pour surmonter les pentes, vous devez faire plus de travail et les routes sont toujours rapides.Objets en relief
Nous pouvons ajouter des coûts en présence d'objets de secours. Par exemple, dans de nombreux jeux, il est plus difficile de naviguer dans les forêts. Dans ce cas, nous ajoutons simplement tous les niveaux d'objets à la distance. Et là encore la route accélère tout. if (current.HasRoadThroughEdge(d)) { distance += 1; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; }
Les objets ralentissent s'il n'y a pas de route.Les murs
Enfin, prenons en compte les murs. Les murs doivent bloquer le mouvement si la route ne les traverse pas. if (current.HasRoadThroughEdge(d)) { distance += 1; } else if (current.Walled != neighbor.Walled) { continue; } else { distance += edgeType == HexEdgeType.Flat ? 5 : 10; distance += neighbor.UrbanLevel + neighbor.FarmLevel + neighbor.PlantLevel; }
Les murs ne nous laissent pas passer, il faut chercher la porte.paquet d'unité