Clicker on Unity using a neural network to generate graphics

via the link and enjoy my 17 year old voice, enjoy watching 🙂

Banana is just a banana

Banana is just a banana

A short plan of action if you still remain on the article

However, before we begin, a small disclaimer: You must realize that at the present moment in time, neural networks are not a panacea. For now, a neural network is a powerful tool for speeding up the work process, and not a magic wand that creates full-fledged projects at the click of a finger, so in this and future devlogs I do not highlight the neural network as the basis of the project. I will talk about mechanics, concepts and ideas, along the way giving comments on them or on ways to implement them. The neural network only acts as a convenient tool.

This part of the devlog can be divided into several main topics:

  1. Character design

  2. Sound design

  3. Basic Mechanics

  4. Creating a pseudo-environment (Let's call it that)

  5. Neural network for creating pixel art

So, we’ve decided on some plan, let’s get started.

Character design

Since we're creating a clicker, we'll start with character design, because what's the coolest thing about a clicker?

  • Visual (although getting ahead of myself, I’ll say that our clicker will have something like a development system and a store).

I use PhotoShop (licensed, of course ;D) because of its functionality (and I’m already used to just working in it). You can use Aseprite or another graphic editor that is convenient for you.

I’d like to note right away that if you don’t know how to draw well, then you shouldn’t immediately turn off the video (we’re all great artists here, and we’ll draw in the style of pixel art, and then your skill will develop very quickly, and even if you don’t want to draw , then you can glean interesting information from the subsequent blocks of the article)

Straight to the point.

First, imagine who you would like to beat (We have a clicker) – it may depend on the location or atmosphere: a ghost, a mummy, a dragon or something else.

Introduced? – Great. Now sketch out an approximate outline with black lines (it doesn’t have to be perfectly smooth, do as best you can) – So, this is our sketch

Creating a Sketch

Creating a Sketch

Now let's start choosing colors. This again is up to you: adapt to the atmosphere of your monster, for example, if you are drawing someone who has something to do with poison, then I would draw such a character in pungent green or yellow colors.
Filled the character with color – Excellent. You can add some details (eyes, teeth, scars or something like that, and then throw in some colors as well)

Since we draw in the pixel art style, we won’t get far without dithering –
without it, it doesn’t look so nice (Dithering is a technique for transitioning colors in pixel art; it means that we draw with a checkerboard pattern, as if overlaying
one color to another – this creates a transition that is pleasing to the eye)

For some reason I decided to throw on a gradient… I don’t know if you’ll like it, but why not
(for those who work in FS and want to also experiment, then select the layer, press RMB, select: “overlay options”, look for “gradient” and smartly turn all the sliders)

Now let's remember our black sketch again. The fact is that the black color does not look very good in pixel art (there are, of course, exceptions, but still) – it is better to replace all the black elements with dark blue or dark brown (you can, like me, hang them on them gradient).

Great! Now we save it in a format convenient for you (for me it’s the FS format – that is, PSD) and upload it to Unity.

Well just look at him

Well just look at him

Sound design

Let me tell you right away that I’ve been using it to search for music and sounds for a very long time. Freesound.org – it has a lot of high-quality sound, for which you don’t have to pay (if you’re too lazy to use its browser version, then look at SoundQ). I also use a site to edit sounds – https://mp3cut.net/

This block is easier, because essentially you just need to find the ready-made content you need (for this, Freesound has a convenient tag system and a search engine – go ahead and sing)
Again, select the sounds to match the theme of your character (If your character is associated with fire, select the sounds of flame, if with ice, the sounds of snow and breaking glass/ice will do)

By the way, yes, save sounds in MP3 and Wav formats (Wav is better – they will have better sound quality, and they will be easier to process, but the difference between them is small, so if you are not so worried about the details, then don’t worry about it) . That's probably it – let's move on.

Code and mechanics

Let me say right away that I am not a cruel programmer with 10 years of experience, my own studio and an author’s book, and if you are a senior tomato whose eyes are melting from my code, then I ask you to be patient – your suffering will not last long (usually 3 minutes is enough, so that the senor is completely acidic).

If you are new and don’t understand anything in this matter, then don’t worry (we all started with this), we ourselves don’t understand what we are writing) Naturally, I will give comments to my code, but if you still don’t understand, then just copy the code (and very important: not just CTRL+C – CTRL-V), but rewrite it by hand (your hands and brain must get used to this, otherwise in the future you will not be able to write code yourself)

Let's start with the script for the enemy:

[RequireComponent(typeof(Animator), typeof(AudioSource), typeof(BoxCollider))]

RequireComponent automatically adds the listed components to the object when you throw this script on the object (useful thing, saves time)

[Header("Main values")]
public UserData UserData;
public EnemiesController EnemiesController;

[SerializeField] protected int _healthCount;
[SerializeField] protected int _damageCount;
[SerializeField] protected int _protectionCount;
private float _startHealthCount;

public DamageType Weakness;
public DamageType Resistance;

public AnimationClip DeathAnimation;

[Header("Take damage animation values")]
public float TakeDamageMovingSpread;
public float TakeDamageAnimationTime;
public Color TakeDamageColor;

protected Animator _animator;
protected SpriteRenderer _spriteRenderer;

[Header("Particles")]
public ParticleSystem TakeDamageParticles;
public ParticleSystem DeathParticles;
public ParticleSystem PassiveParticles;

[Header("Sounds")]
public bool RandomizeSounds;
public AudioClip[] TakeDamageSounds;
public AudioClip[] DeathSounds;
protected AudioSource _audioSource;
private int _soundIndex;
private int _lastSoundIndex = -1;

[Header("Health Bar")]
public EnemyHealthBar EnemyHealthBar;

private Vector3 _startPosition;
private Color _startColor;

[HideInInspector] public bool _isDead;
private bool _isAttacking;

The Header attribute simply creates a header in the inspector
We haven’t written UserData yet, but we’ll add the field anyway (If your syntax is highlighted, don’t pay attention). Also variable amounts of health, damage and defense

Then come:
The type of damage to which the enemy is vulnerable, and then the type of damage to which the enemy, on the contrary, is resistant.
(We haven’t written an enum with damage types yet, but we’ll do that a little later).
We’ll also write a field for the character’s death animation, so that we can then get its length from there, so we can know when to spawn a new enemy.

Then we have variables for animations:
TakeDamageMovingSpread is responsible for how much the enemy will swing to the side when you click on it.
TakeDamageAnimationTime is responsible for how quickly it will wobble
Well, TakeDamageColor is responsible for what color the enemy will become when hit. We’ll also add an animator and a sprite renderer here.

Now the particles:

  1. Damage Receipt Particles

  2. Enemy Death Particles

  3. And passive particles that appear simply during the life of our enemy

Let's move on to sounds, here we have:

  1. Array with different sounds when hitting an enemy

  2. Array with different sounds of enemy death

  3. AudioSource itself

  4. And indexes for playing sounds from arrays (LastIndex is needed to avoid playing the same strike sound twice)

Then our enemy's health bar

Then come:

  1. Initial position, which allows the enemy to return to its original position after being hit

  2. Initial color, allowing the enemy to return to normal color after being hit

  3. Variable for invulnerability

  4. Variable indicating that the enemy was clicked

private void Awake()
{
    _animator = GetComponent<Animator>();
    _audioSource = GetComponent<AudioSource>();
    _spriteRenderer = GetComponent<SpriteRenderer>();

    _startPosition = transform.localPosition;
    _startColor = _spriteRenderer.color;
    _startHealthCount = _healthCount;
}
public void OnMouseDown()
{
    if (!_isDead && !_isAttacking)
    {
        StartCoroutine(Reloading());
        TakeDamage();
    }
}

Let's get started with the methods:
In Awake we get components for animation, sound and sprite drawing, as well as the starting position, color and amount of health.
In OnMouseDown we write the logic for clicking on an enemy:
If the enemy is vulnerable and not attacked, then we launch a coroutine, which puts our clicker on rollback, and also inflicts damage on the enemy.

protected virtual void TakeDamage()
{
    AnimateTakeDamage();

    int resultProtection = (_protectionCount - UserData.ClickWeapon._protectDamage);
    if (resultProtection < 0)
    {
        resultProtection = 0;
    }

    int resultDamage = (UserData.ClickWeapon._clickDamage - resultProtection);

    if (Weakness.HasFlag(UserData.ClickWeapon.DamageType))
    {
        resultDamage *= 2;
    }

    if (Resistance.HasFlag(UserData.ClickWeapon.DamageType))
    {
        resultDamage /= 2;
    }

    if (resultDamage <= 0)
    {
        resultDamage = 1;
    }

    _healthCount -= resultDamage;
    EnemyHealthBar.HealthDecrease(resultDamage / _startHealthCount, TakeDamageAnimationTime);

    if (_healthCount <= 0)
    {
        StartCoroutine(Dead());
    }
}

Now let’s describe the process of causing damage:

  1. Start the enemy strike animation

  2. We calculate the enemy’s defense, which will absorb some of your damage, and based on this we already calculate the final damage

  3. We check the class of the weapon (if the class of the weapon matches the enemy’s weakness, then we double the final damage) – the same with resistance (if the enemy is resistant to the player’s weapon, then we cut the damage by half)

  4. We play it safe a little so that the damage is not less than 1 (one click should still cause at least 1 unit of damage, otherwise it may be a situation where it will be impossible to kill the enemy at all)

  5. Deal calculated damage to the enemy

  6. We turn to the enemy’s health bar and report that we have caused damage

  7. If we see that the enemy’s health has dropped below 0, then we trigger the death of the enemy

protected virtual void AnimateTakeDamage()
{
    if (RandomizeSounds)
    {
        do
        {
            _soundIndex = Random.Range(0, TakeDamageSounds.Length);
        } while (_soundIndex == _lastSoundIndex);
        _lastSoundIndex = _soundIndex;

        _audioSource.PlayOneShot(TakeDamageSounds[_soundIndex], _audioSource.volume);
    }
    else
    {
        if (_soundIndex >= TakeDamageSounds.Length)
        {
            _soundIndex = 0;
            _audioSource.PlayOneShot(TakeDamageSounds[_soundIndex], _audioSource.volume);
        }
        else
        {
            _audioSource.PlayOneShot(TakeDamageSounds[_soundIndex], _audioSource.volume);
            _soundIndex++;
        }
    }

    TakeDamageParticles.Play();

    transform.DOLocalMove(CalculateMovingVector(), TakeDamageAnimationTime).SetEase(Ease.OutElastic);
    _spriteRenderer.DOColor(TakeDamageColor, TakeDamageAnimationTime / 20);

    transform.DOLocalMove(_startPosition, TakeDamageAnimationTime).SetEase(Ease.OutElastic);
    _spriteRenderer.DOColor(_startColor, TakeDamageAnimationTime * 3);
}

In AnimateTakeDamage, we first use the Do While loop to assign a random value to the sound index, and then check whether the index is the same as the previous value; if not, we leave this value, and also write it to the Last index for later checking the future sound index , then we play the sound of the impact on the enemy (I don’t know that I forgot the sound of the impact in the animation – what was in my head then no one knows…), then we spawn the particles of the impact, and then using DOTWeen we animate the shaking of the enemy.

protected virtual IEnumerator Dead()
{
    _isDead = true;
    _animator.SetTrigger("Death");
    PassiveParticles.Stop();

    yield return new WaitForSeconds(DeathAnimation.length);

    _spriteRenderer.enabled = false;
    DeathParticles.Play();

    yield return new WaitForSeconds(DeathParticles.main.startLifetime.constantMax);

    EnemiesController.SpawnNewEnemy(DeathAnimation.length);
    gameObject.SetActive(false);
}

protected Vector3 CalculateMovingVector()
{
    Vector3 movingVector = new Vector3(Random.Range(transform.localPosition.x - TakeDamageMovingSpread, transform.localPosition.x + TakeDamageMovingSpread), Random.Range(transform.localPosition.y - TakeDamageMovingSpread, transform.localPosition.y + TakeDamageMovingSpread), transform.localPosition.z);
    return movingVector;
}

private IEnumerator Reloading()
{
    _isAttacking = true;

    yield return new WaitForSeconds(UserData.ClickWeapon._clickReloading);

    _isAttacking = false;
}

In the Dead coroutine we say that the enemy is invulnerable, thereby prohibiting the player from causing damage to him during the animation, we launch the death animation itself, stop the passive particles (the Enemy is dead), wait until the death animation is completed, and then make the enemy invisible by disabling the SpriteRenderer. We play the particles of death, wait until the last particle of death disappears, and then turn off the enemy completely.

CalculateMovingVector is a helper function that allows you to randomize enemy shaking for animation.

The Reloading coroutine reloads our clicker (puts it on rollback, as I put it earlier). Something like reloading for weapons in shooters, but we will have it in a clicker.

Now let's write UserData – the same one that was mentioned at the beginning
I ask beginners to note that this is not just a MonoBehavior script – it is a ScriptableObject.

If you don’t know how to create ScrObj, then here it is – https://habr.com/ru/articles/421523

So here it is inside:

[CreateAssetMenu(fileName = "User Data", menuName = "Create User Data")]

Attribute for adding this ScrObj to the drop-down menu and giving it a name

public ClickWeapon ClickWeapon;

private List<ClickWeapon> _unlockedClickWeapons;

private List<Location> _unlockedLocations; 

public void UnlockLocation(Location unlockingLocation)
{
    _unlockedLocations.Add(unlockingLocation);
}

public void UnlockWeapon(ClickWeapon unlockingWeapon)
{
    _unlockedClickWeapons.Add(unlockingWeapon);
}
  1. The ClickWeapon field is responsible for exactly what clicker the player currently has (we will call it a clicker – a weapon that we use to kill an enemy)

  1. Then we write down the lists of weapons and locations available to the player

  2. Let's write two methods:

    3.1) to unlock new weapons 3.2) and to unlock new locations, respectively

We've sorted out the enemy code – there's still a little code left, just be patient

Pseudo-environment

Let's start surrounding the locations. The idea is this: Surely, many of you have heard about the parallax effect (this is when objects that are further from the viewer move at a slower speed than those that are closer – this creates the illusion of depth).
So now we will write such a parallax controller in order to create the impression of an endlessly moving space.

Let's look under the hood of Background Pattern:

[CreateAssetMenu(fileName = "Background Pattern", menuName = "Create New Background Pattern")]
public class BackgroundPattern : ScriptableObject
{
    public PatternBehavoiur patternBehavoiur;

    public List<GameObject> Layers;

    public float PatternMovingDistance;
    public float MovingTime;
}

public enum PatternBehavoiur
{
    classic = 1,
    agressive = 2,
}
  1. Here we again have attributes for ScrObj

  2. PatternBenaviour is responsible for the behavior of the background movement (for now I will only use one movement option)

  3. Then there is a list of backgrounds that will spawn

  4. Then the variables responsible for the speed of movement and the distance that the backgrounds will travel

Next is an enum with movement behavior options (we will only use the 1st, since I have not yet made suitable locations for the 2nd)

Let's move on to BackGroundController:

private List<GameObject> _layers;

public BackgroundPattern BackgroundPattern;

public void CreateLayers()
{
    _layers = new List<GameObject>();

    for (int i = 0; i < BackgroundPattern.Layers.Count; i++)
    {
        _layers.Add(Instantiate(BackgroundPattern.Layers[i], BackgroundPattern.Layers[i].transform.localPosition, Quaternion.identity));
    }
}

private void Start()
{
    switch (BackgroundPattern.patternBehavoiur)
    {
        case PatternBehavoiur.classic:

            for(int i = 0; i < _layers.Count; i++)
            {
                _layers[i].transform.DOMoveX(BackgroundPattern.PatternMovingDistance, BackgroundPattern.MovingTime * (i + 1)).SetEase(Ease.Linear).SetLoops(-1, LoopType.Restart);
            }

            break;

        case PatternBehavoiur.agressive:

            break;
    }

}

public void ClearLayers()
{
    for (int i = 0; i < _layers.Count; i++)
    {
        Destroy(_layers[i]);
    }

    _layers.Clear();
}
  1. List of backgrounds that will be moved by the controller

  2. Then the pattern we just wrote

  3. In Awake we will spawn backgrounds that we took from the pattern

  4. In Start we define the behavior of the pattern and, depending on this, we give instructions to the controller how it should move the backgrounds

This is all. Now let's figure out how to work with this system:

  1. Create an empty object and place a controller there

  2. Create a pattern instance using the drop-down menu and customize it as you wish

  3. Add the created and configured pattern to the controller

If you did everything correctly, then when you start the project, your location will load without problems.

Creating patterns

Creating patterns

Neural network for pixel art

I found this neural network by accident, and I thought it was very cool and high quality.
3 months have passed since I started preparing material for this devlog, so perhaps there is already a better neural network, but 3 months ago this neural network had no free analogues. The neural network does a good job, but it still requires a little correction of small touches (website with neural networkpromt – “identical pixels at the edges so that you can seamlessly duplicate the image, jointless pattern”)

website with neural network

website with neural network

The scheme is simple:

  1. Generating the pixel background we need

  2. Save

  3. We throw it into a graphic editor and duplicate it a couple of times

  4. After that, we make a little adjustment at the borders behind the neural network so that you can duplicate it endlessly, and you get a seamless pattern (In FS, by the way, there is an auto-fill on RMB, which copes with this perfectly)

  5. Then save it in the required format and upload it to Unity

Patterns I made

Patterns I made

sprite settings

sprite settings

  1. Going to Unity, unload our background, and then configure it (you can just copy my settings)

  2. We create an empty object, throw SpriteRenderer on it, adjust it by size, color and depth,
    save as a prefab (If you don’t know what a prefab is, then again welcome to the description
    video, there is a link to watch it)

  3. Throw all the background prefabs into the pattern, end!

If everything worked out for you, then you are God (I missed a lot here). And if not, then you should follow the link and watch the video for a better understanding – https://youtu.be/Cb_Y4LBO4MQ

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *