Hi, Habr. It's me again, Ilya Kudinov, QA-engineer from the company Badoo. But today I’ll tell you not about testing (I’ve already done this
on Monday ), but about game devs. No, we don’t do that at Badoo, developing computer games is my hobby.
Industry professionals, do not judge strictly. All the tips in this article are for beginner developers who decide to try their hand at Unity. Many tips can be attributed to the development as a whole, but I will try to add Unity-specificity to them. If you can advise something better than I suggest - write in the comments, I will update the article.I dreamed of developing toys since childhood. Probably already back in 1994, when they gave me my first Dendy, I thought: “
How would it be if there were more cool things in this igluchka ... ” In high school, I began to learn programming and together with He made his first playable crafts (oh, how we loved them!). At the institute, my friends and I made Napoleonic plans for a fundamental change in the industry with the help of our completely new topic ...
')
And in 2014, I started to learn Unity and finally, REALLY started to make games. However, the trouble is: I have never
worked as a programmer. I had no experience of real corporate development (before that I did everything “on my knee”, and, apart from me, no one would understand my code). I could program, but I couldn’t do it well. All my knowledge of Unity and C # was limited to the official tutorials that were scarce at that time. And my favorite way to learn about the world is to make mistakes and learn from them. And I have done plenty of them.
Today I will talk about some of them and show you how to avoid them (oh, if I knew all this three years ago!)
In order to understand all the terms used in the material, it is enough to first go through one or two official Unity tutorials. Well, and have at least some idea of programming. Do not thrust all the logic of the object into one MonoBehaviour
Ah, my class
MonsterBehaviour
in our debut game! 3200 lines of spaghetti code in his worst days. Every need to return to this class gave me a slight shiver, and I always tried to postpone this work as long as I could. When I got to his refactoring a little more than a year after its creation, I not only broke it into a base class and several heirs, but also made several blocks of functionality into separate classes, which I added to objects directly from the code using
gameObject.AddComponent()
, so I did not have to change the already accumulated prefabs.
It was:
the monster class
MonsterBehaviour
, which kept in itself all the personal settings of the monsters, which determined their behavior, animation, leveling, finding the way and everything.
It became:
MonsterComponent
abstract class, from which all other components are inherited and which binds them and, for example, basic optimization in the form of caching the results of the gameObject.GetComponent<T>();
call gameObject.GetComponent<T>();
MonsterStats
class in which game designer enters the parameters of monsters. He stores them, changes them with the level and gives them to other classes upon request;- the
MonsterPathFinder
class, which is searching for paths and stores the generated data in static fields to optimize the algorithm; MonsterAttack
abstract class with heirs for different types of attack (weapons, claws, magic ...), which control everything related to the monster's combat behavior - timings, animation, the use of special techniques;- There are still many additional classes that implement all kinds of specific logic.
In just a few hours of work, I was able to cut a few hundred lines of hard-to-maintain code and save hours of nervous digging in go-bad code.
What, the essence of my advice is not to write giant classes,
thank you, cap ? Not. My advice: split your logic into atomic classes before they become large. First, let your objects have three or four meaningful components with ten lines in each code, but it will be no more difficult to navigate them than in one of the 50 lines, but with the further development of logic you will not find yourself in a situation like me. At the same time there are more opportunities to reuse the code - for example, the component responsible for health and taking damage, you can stick to the player, and opponents, and even obstacles.
Clever term - Interface segregation principle .Do not forget about OOP
No matter how simple at first glance the design of objects in Unity (
“Programming with the mouse, fuuuu” ) may seem, one should not underestimate this component of the development. Yes, yes, I underestimated. Straight by points:
- Inheritance . It is always nice to bring some common logic of several classes into a common base class. Sometimes it makes sense to do this in advance if the objects are “ideologically” similar, even if they do not yet have common methods. For example, level chests and decorative torches on the walls initially had nothing in common. But when we began to develop the mechanics of extinguishing and igniting torches, we had to take out from the chests into the general class the mechanics of interaction with the player and the display of prompts in the interface. And I could have guessed right away. And I also have a common base class for all objects, which is a superstructure over MonoBehaviour, with a bunch of useful new features.
- Encapsulation . I will not even explain how useful the installation of the correct scopes can be. It simplifies the work, reduces the likelihood of a stupid mistake, makes it easier to debug ... It is still useful to know about two directives -
[HideInInspector]
, which hides the public component fields in the inspector that should not be edited in objects (however, it makes sense to avoid public fields at all) bad practice - it’s better to use property instead, thanks to Charoplet for the reminder), and [SerializeField]
, on the contrary, displays private fields in the inspector (which is very useful for more convenient debugging). - Polymorphism . Here the question is exclusively in the beauty and brevity of the code. One of my favorite pieces to support polymorphism in C # is universal templates. For example, I wrote such simple and convenient methods for pulling out a random element of an arbitrary class from L
ist<T>
(and I do it very often):
protected T GetRandomFromList<T>(List<T> list) { return list[Random.Range(0, list.Count)]; } protected T PullRandomFromList<T>(ref List<T> list) { int i = Random.Range(0, list.Count); T result = list[i]; list.RemoveAt(i); return result; }
At the same time, C # is such a sweetheart that allows you not to produce these parameters, and these two calls will work identically:
List<ExampleClass> list = new List<ExampleClass>(); ExampleClass a = GetRandomFromList<ExampleClass>(list); ExampleClass a = GetRandomFromList(list);
Clever term - Single responsibility principle .Explore the GUI Editor
I did it
much later than it was worth. I have already written
an article on how this can help both a programmer and a game designer in developing. In addition to custom inspectors for individual attributes and whole components, the GUI Editor can be used for a huge number of things. Create separate editor tabs to view and edit SAVE files of the game, to edit scenarios, to create levels ... The possibilities are endless! And the potential time savings are just amazing.
Think localization right from the start.
Even if you are not sure that you will translate the game into other languages. To force localization into an already formed project is intolerable pain. You can think of a variety of ways to localize and store translations. It is a pity that Unity is not able to independently carry out all the lines in a separate file that can be localized out of the box and without access to the rest of the application code (as, for example, in Android Studio). You will have to write such a system yourself. Personally, I use two solutions for this, albeit not very elegant ones.
Both are based on my own
TranslatableString
class:
[System.Serializable] public class TranslatableString { public const int LANG_EN = 0; public const int LANG_RU = 1; public const int LANG_DE = 2; [SerializeField] private string english; [SerializeField] private string russian; [SerializeField] private string german; public static implicit operator string(TranslatableString translatableString) { int languageId = PlayerPrefs.GetInt("language_id"); switch (languageId) { case LANG_EN: return translatableString.english; case LANG_RU: return translatableString.russian; case LANG_DE: return translatableString.german; } Debug.LogError("Wrong languageId in config"); return translatableString.english(); } }
It still has a bunch of lines with error protection and field completeness checks, now I have removed them for readability. You can store translations as an array, but for a number of reasons I still chose separate fields.All the “magic” is in the method of implicit conversion to a string. Thanks to him, you can call something like this anywhere in the code:
TranslatableString lexeme = new TranslatableString(); string text = lexeme;
- and immediately receive the necessary translation in the text string, depending on the current language in the player’s settings. That is, in most places when adding localization, you don’t even have to change the code - it will just continue to work with strings, as before!
The first localization option is very simple and suitable for games where there are very few lines, and all of them are located in the UI. We simply add each object with a translatable
UnityEngine.UI.Text
component.
public class TranslatableUIText : MonoBehaviour { public TranslatableString translatableString; public void Start() { GetComponent<UnityEngine.UI.Text>().text = translatableString; } }
We fill in all the translation lines in the inspector - and voila, that's it!
For games where there are more tokens, I use a different approach. I have a
Singleton
LexemeLibrary
LexemeLibrary
that stores a map of the form “id tokens” => “serialized
TranslatableString
”, from which I get the tokens in the right places. You can fill this library in any convenient way: with pens in the inspector, through a custom interface (hello, Editor GUI) or by exporting / importing CSV files. The latter option works great with outsourced translators, but it takes a little more work to avoid mistakes.
By the way, a useful thing - the language of the player’s system (in fact, its localization preferences) can be obtained using, for example, the following code:
void SetLanguage(int language_id) { PlayerPrefs.SetInt("language_id", language_id); } public void GuessLanguage() { switch (Application.systemLanguage) { case SystemLanguage.English: SetLanguage(TranslatableString.LANG_EN); return; case SystemLanguage.Russian: SetLanguage(TranslatableString.LANG_RU); return; case SystemLanguage.German: SetLanguage(TranslatableString.LANG_DE); return; } }
Clever term - Dependency inversion principle .Write detailed logs!
This may seem redundant, but now some of my games write to the log almost every sneeze. On the one hand, this wildly clutters the Unity console (which, unfortunately, does not know how to do any convenient filtering), on the other hand, you can open the source log files in any convenient software for viewing logs and compile any reports convenient to you, which will help to be engaged both in application optimization, and search of anomalies and their reasons.
Create self-contained entities
I did stupid things. Suppose we want to somehow store the settings of the various levels of a game:
public struct Mission { public int duration; public float enemyDelay; public float difficultyMultiplier; } public class MissionController : Singleton<MissionController> { public Mission[] missions; public int currentMissionId; }
The
MissionController
component sits in an object, contains settings for all game missions, and is accessible from anywhere in the code via
MissionController.Instance
.
About my class Singleton can be read in the already mentioned article .My initial approach was this:
Mission
stores only parameters, and
MissionController
handles all other requests. For example, to get a better player account at a certain level, I used methods like
MissionController.GetHighScore(int missionId) { return PlayerPrefs.GetInt("MissionScore" + missionId); }
It would seem that everything is working properly. But then such methods became more and more, entities grew, proxy methods appeared in other classes ... In general, spaghetti-hell came. Therefore, in the end, I decided to put all the methods for working with missions into the
Mission
structure itself and began to receive mission records, for example, in the following way:
MissionController.GetCurrentMission().GetHighScore();
which made the code much more readable and easy to maintain.
Do not be afraid to use PlayerPrefs
Many sources say that
PlayerPrefs
should be used very carefully and on every possible occasion, instead, serialize the data yourself and write them into your own files. Previously, I diligently prepared my own binary file format for each saved entity. Now I do not do that.
The
PlayerPrefs
class is
PlayerPrefs
to storing “key => value” pairs in the file system, and it works the same on all platforms, it simply stores its files in different places.
Constantly writing data to the
PlayerPrefs
fields (and reading them) is bad: regular disk queries do not do any good to anyone. However, you can write a simple but reasonable system that will help to avoid this.
For example, you can create a single SAVE object that stores all the settings and player data:
[System.Serializable] public struct Save { public string name; public int exp; public int[] highScores; public int languageId; public bool muteMusic; }
We write a simple system that does lazy initialization of this object (reads it from
PlayerPrefs
on the first request, puts it in a variable and on further requests already uses this variable), writes all changes to this object and saves it back to
PlayerPrefs
only when necessary ( when you exit the game and change key data).
To manipulate such an object as a string for
PlayerPrefs.GetString()
and
PlayerPrefs.SetString()
, it is enough to use JSON serialization:
Save save = newSave; string serialized = JsonUtility.ToJson(newSave); Save unserialized = JsonUtility.FromJson<Save>(serialized);
Watch out for objects in the scene.
Here you have launched your game. It works, you rejoice. Played it for about 15 minutes, paused to check this curious vorning in the console ... VALUE, WHY IN 717 OBJECTS IN THE CORN ??? HOW TO ME ANYTHING TO FIND ???
Understanding this garbage is very difficult. Therefore, try to follow two rules:
Put all objects created through
Instantiate()
into some object structures. For example, I now always have a
GameObjects
object in a scene with category sub-objects into which I put everything I create. In order to avoid human error, in most cases I have add-ins over
Instantiate()
like
InstantiateDebris()
, which immediately put the object in the desired category.
Delete objects that are no longer needed. For example, some of my add-ins have a call
Destroy(gameObject, timeout);
with pre-assigned timeout for each category. Thanks to this, I don’t have to worry about cleaning things like blood stains on the walls, bullet holes, projectiles flying into infinity ...
Avoid GameObject.Find ()
Very expensive in terms of resources function to search for objects. Yes, and it is tied to the name of the object, which must be changed every time at least in two places (in the scene and in the code). The same can be said about
GameObject.FindWithTag()
(I would generally suggest not using tags - you can always find more convenient ways to determine the type of object).
If it is very impatient, be sure to cache each call into a variable in order not to do it more than once. Or even make the connection of objects through the inspector.
But you can do and more elegantly. You can use the class - the repository of references to objects, into which each potentially necessary object is registered, save the
GameObjects
meta-object from the previous board and search for the necessary objects in it via
transform.Find()
. All this is much better than polling each object in the scene about its name in search of the necessary, and then still fall with an error, because you recently renamed this object.
By the way, the Transform component implements the
IEnumerable
interface, which means that you can conveniently bypass all the child objects of the object in this way:
foreach (Transform child in transform) { child.gameObject.setActive(true); }
Important: unlike most other functions for searching for objects, transform.Find () returns even disabled (gameObject.active == false) objects at the moment.
Agree with the artist about the image format
Especially if the artist is yourself. Especially if the artist has never worked on games and IT projects in general before.
I will not be able to give a lot of texture advice for 3D games - I haven’t dug myself deep into it yet. It is important to teach the artist to save all the pictures with POT dimensions (Power Of Two, so that each side of the picture is a power of two, for example, 512x512 or 1024x2048), so that they are more efficiently compressed by the engine and do not occupy precious megabytes (which is especially important for mobile games).
But I can tell a lot of sad stories about sprites for 2D games.
- Combine the same type of sprites (and especially individual sprites of the same animation) in the overall picture. If you need 12 sprites of 256x256 pixels in size, then you do not need to save 12 pictures - it is much more convenient to make one 1024x1024 pixel image, and in it spread out sprites on a grid with a side of 256 pixels and use the automatic texture splitting into sprites. There will be four empty seats - it does not matter, suddenly you will need to add more pictures of this type. Important: if the slots for sprites become missed, then tell your artist to increase the canvas to new powers of two only to the right and upwards; in this case, you will not have to edit the meta-data for existing sprites - they will remain at the same coordinates. UPD by KonH : instead of manual placement of sprites, it is more convenient to use the built-in utility SpritePacker . I didn’t touch her myself yet, so I’m not able to tell you more details (:
- Be sure to draw all the project's sprites at the same scale, even if they still turn out to be on different textures. You can not imagine how much time I spent on adjusting the values of Pixels per unit for different sprites of monsters, so that in the game world they were of appropriate sizes. Now on each texture I have an unused image of the main character, so that you can compare the correspondence of the scales. Nothing complicated - and so much time and nerves save!
- Align all sprites of the same type to one common Pivot. Ideally, the center of the picture or the middle of any side. For example, all sprites of a player’s weapons should be placed in the slot (or in a separate image) so that the point at which the player will hold these weapons is exactly in the center. Otherwise you have to put this Pivot in the editor; it will be inconvenient, you can forget about it - and the character will hold a spear at the very tip or ax for the base of the blade. Very stupid character.
Install Milestone
What it is? For good, Mylston (milestones - stones that used to set every mile along the road to mark distances) is a certain state of the project when it has reached its current goals and can move on to further development. Or maybe not go.
Perhaps it was our main mistake when working on a debut project. We set a lot of goals and went to all at once. Something was always left unfinished, and we could not say: “But now the project is really ready!” Because we constantly wanted to add something else to the existing functionality.
Don't do that. The best way to develop a game is to know the exact set of features and not to deviate from it. But it hurts rarely when it comes to large-scale industrial development. Games are often developed and upgraded right in the development process. So how to stay in time?
Make a plan of versions (Miles). So that each version was a complete game: so that there were no temporary stubs, crutches and unrealized functionality. So that it would be no shame on any Mailtown to say: “We’ll finish it!” And release (or close forever in the closet) a quality product.
Conclusion
I was stupid three years ago, right? I hope you will not repeat my mistakes and save a lot of time and nerves. , , .
PS “ ”, Unity . . , ?