Знакомство

Здравствуй, читатель. Это моя первая статья на Хабре. Меня зовут Игорь, и я уже относительно давно знаком с областью разработки игр (Ну как давно, с Unity я познакомился лет в 14 примерно, так что что-то в голове у меня есть).
И в этой статье я расскажу тебе, как начать создавать интересный кликер,
попутно применяя классную нейросеть для его создания

"Лучше один раз увидеть, чем прочитать много букав!"

Да, это статья - не основной контент. Она является частью моего видео на Ютуб. Если вам больше нравится слушать и смотреть на всё под приятный Lo-fi на фоне, то прошу перейти по ссылочке и наслаждаться моим 17-летним голосом, приятного просмотра :)

Банан  - просто банан
Банан - просто банан

Краткий план действий, если вы все же остались на статье

Однако перед началом небольшой дисклеймер: Вы должны осознавать, что в настоящий момент времени, нейросети - это не панацея. Пока что нейросеть - это мощный инструмент для ускорения рабочего процесса, а не волшебная палочка, создающая полноценные проекты по щелчку пальцев, поэтому в этом и дальнейших девлогах я не выделяю нейросеть как основу проекта. Я буду рассказывать о механиках, концепциях и идеях, попутно давая комментарии по ним или по способам их реализации. Нейросеть лишь выступает в роли удобного инструмента.

Эту часть девлога можно разделить на несколько основных тем:

  1. Дизайн персонажей

  2. Звукодизайн

  3. Основные механики

  4. Создание псевдо-окружения (Назовём это так)

  5. Нейросеть для создания пиксель арта

Итак, с некоторым планом определились, приступаем

Дизайн персонажей

Так как мы создаем кликер, то начнем с дизайна персонажей, ведь в кликере самое классное что?

  • Визуал (хотя забегая на перед, скажу, что в нашем с вами кликере будет присутствовать что-то наподобие системы развития и магазина).

Я использую PhotoShop (Естественно, лицензионный ;D) из-за своего функционала (да и я привык уже просто в нем работать). Вы можете использовать Aseprite или другой удобный вам графический редактор.

Сразу хочу отметить, что если вы не умеете хорошо рисовать, то не стоит сразу закрывать статью (мы тут все горе художники, да и рисовать будем в стиле пиксель-арт, а там ваш навык очень быстро нарабатывается, да и даже если не хотите рисовать, то можете черпнуть интересной информации из последующих блоков статьи)

Непосредственно к делу.

Для начала представьте, кого бы вы хотели бить (У нас ведь кликер) - это может зависеть от локации или атмосферы: призрак, мумия, дракон или нечто иное.

Представили? - отлично. Теперь набросайте черными линиями примерный контур (он не должен быть идеально ровным, делайте, как можете) - Итак, это наш набросок

Создаем набросок
Создаем набросок

Теперь приступаем к выбору цветов. Это опять зависит от вас: подстраивайтесь под атмосферу вашего монстра, например, если вы рисуете кого‑то, кто как‑то связан с ядом, то такого персонажа я бы рисовал в едких зелёных или желтых цветах.
Залили персонажа цветом — Отлично. Можно добавить немного деталей (глаза, зубы, шрамы или нечто подобное, а затем также накинуть цвета)

Так как мы рисуем в стиле пиксель‑арт, то без дизеринга мы далеко не уедем —
без него не так симпатично выглядит (Дизеринг — это такой прием перехода цветов в пиксель‑арте, заключается в том, что мы рисуем шахматным полем как бы накладывая
один цвет на другой — таким образом получается приятный глазу переход)

Я почему‑то решил накинуть градиента... Не знаю, понравится ли вам, но почему бы и нет
(для тех, кто работает в ФШ и хочет тоже поэкспериментировать, то выбираем слой, жмем ПКМ, выбираем: «параметры наложения», ищем «градиент» и с умным видом крутим все ползунки)

Теперь снова вспоминаем про наш черный набросок. Дело в том, что черный цвет не очень хорошо смотрится в пиксель‑арте (есть, конечно, исключения, но все же) — лучше заменить все черные элементы на темно‑синие или темно‑коричневые (можете, также как и я, навесить на них градиент).

Отлично! Теперь сохраняем в удобном для вас формате (у меня это формат ФШ — то есть PSD) и закидываем в юнити.

Ну вы только посмотрите на него
Ну вы только посмотрите на него

Звукодизайн

Сразу обговорю, что для поиска музыки и звуков я использую уже очень давно Freesound.org - на нем много качественного звука, за который не нужно платить (если лень использовать его браузерную версию, то посмотрите, что такое SoundQ). Я также пользуюсь сайтом для редактирования звуков - https://mp3cut.net/

Этот блок полегче, ведь по сути вам просто требуется найти нужный вам готовый контент (для этого на Freesound есть удобная система тэгов и поисковик - вперед и с песней)
Опять таки, звуки подбирайте под тематику вашего персонажа (Если ваш персонаж связан с огнем - подбирайте звуки пламени, если со льдом - подойдут звуки снега и бьющегося стекла\льда)

Кстати да, сохраняйте звуки в форматах MP3 и Wav (лучше Wav - они по качеству звука будут покруче, да и обрабатывать их легче, однако разница между ними невелика, так что если вы не настолько паритесь по поводу деталей, то не берите в голову). Тут, пожалуй, все - идем дальше.

Код и механики

Сразу обмолвлюсь, что я не жесточайший программист с 10-летним стажем, своей студией и авторской книгой, и если вы сеньор-помидор, у которого плавятся глаза от моего кода, то прошу потерпеть - ваши страдания будут не долгими (обычно хватает 3 минуты, чтобы сеньор полностью откис).

Если же вы новичок и в этом деле нифига не понимаете, то не парьтесь (все мы с этого начинали), мы и сами не понимаем, что пишем) Естественно, я буду давать комментарии к своему коду, но если вы все еще ничего не будете понимать, то просто копируйте код (и очень важно: не просто CTRL+C - CTRL-V), а именно переписывайте от руки (ваши руки и мозг должны привыкать к этому, иначе в будущем самостоятельно код писать не сможете)

Начнем со скрипта для врага:

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

RequireComponent автоматически добавляет перечисленные компоненты на объект при накидывании этого скрипта на объект (полезная штука, экономит время)

[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;

Атрибут Header просто создает заголовок в инспекторе
UserData у нас пока не написан, но поле мы все равно добавим (Если у вас подсвечивается синтаксис, то не обращайте внимания). Также переменные количества здоровья, урона и защиты

Затем идут:
Вид урона, к которому враг уязвим, а после - вид урона, к которому враг наоборот - устойчив.
(Мы пока не написали enum с видами урона, но чуть позже это сделаем).
Также пропишем поле под анимацию смерти персонажа, чтобы потом доставать оттуда ее длину, вследствие чего знать, когда спавнить нового врага.

Потом у нас переменные для анимаций:
TakeDamageMovingSpread отвечает за то, насколько сильно враг будет колбаситься в сторону при нажатии на него.
TakeDamageAnimationTime отвечает за то, насколько быстро он будет колбаситься
Ну а TakeDamageColor отвечает за то, какого цвета враг будет становиться при ударе. Также добавим сюда аниматор и спрайт рендерер

Теперь частицы:

  1. Частицы получения урона

  2. Частицы смерти врага

  3. И пассивные частицы, которые появляются просто во время жизни нашего врага

Переходим к звукам, тут у нас:

  1. Массив с разными звуками при ударе врага

  2. Массив с разными звуками смерти врага

  3. Сам AudioSource

  4. И индексы для воспроизведения звуков из массивов (LastIndex нужен, чтобы не воспроизводить один и тот же звук удара дважды)

Затем шкала здоровья нашего врага

После идут:

  1. Изначальная позиция, которая позволяет врагу вернуться в изначальное положение после удара

  2. Изначальный цвет, позволяющий врагу вернуться к нормальному цвету после удара

  3. Переменная для неуязвимости

  4. Переменная, показывающая, что на врага нажали

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();
    }
}

Приступаем к методам:
В Awake получаем компоненты для анимации, звука и отрисовки спрайта, а также стартовую позицию, цвет и количество здоровья.
В OnMouseDown прописываем логику клика по врагу:
Если враг уязвим и не атакован, то запускаем корутину, которая ставит наш кликер на откат, а также наносим урон врагу.

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());
    }
}

А вот теперь пропишем сам процесс нанесения урона:

  1. Запускаем анимацию удара врага

  2. Рассчитываем защиту врага, которая погасит некоторую часть вашего урона, а на основе этого уже рассчитываем итоговый урон

  3. Проверяем класс оружия (если класс оружия сходится со слабостью врага, то увеличиваем итоговый урон в два раза) - тоже самое с устойчивостью (если враг устойчив к оружию игрока, то срезаем урон в два раза)

  4. Немного подстраховываемся, чтобы урон не был меньше 1 (один клик все таки должен хотя бы 1 единицу урона наносить, иначе может получиться ситуация, что врага убить будет невозможно вовсе)

  5. Наносим рассчитанный урон по врагу

  6. Обращаемся к шкале здоровья врага и сообщаем, что нанесли урон

  7. Если видим, что здоровье врага опустилось ниже 0, то запускаем смерть врага

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);
}

В AnimateTakeDamage мы сначала при помощи цикла Do While присваиваем звуковому индексу рандомное из возможных значений, а затем проверяем, не совпадает ли индекс с предыдущим значением, если нет, то оставляем это значение, а также записываем его в Last index для последующей проверки будущего звукового индекса, далее проигрываем звук удара по врагу (Не знаю, что звук удара забыл в анимации - что тогда было в моей голове никому неизвестно...), затем спавним частицы удара, а потом при помощи DOTWeen анимируем тряску врага.

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;
}

В корутине Dead говорим, что враг неуязвим, тем самым запрещая игроку наносить ему урон во время анимации, запускаем саму анимацию смерти, останавливаем пассивные частицы (Враг ведь умер), ждем пока завершится анимация смерти, а потом делаем врага невидимым, отключая SpriteRenderer. Проигрываем частицы смерти, ждем пока последняя частица смерти исчезнет, а потом отключаем врага вовсе.

CalculateMovingVector - вспомогательная функция, позволяющая рандомизировать тряску врага для анимации.

Корутина Reloading перезаряжает наш кликер (ставит на откат, как я раньше выразился). Что-то типа перезарядки у оружия в шутерах, а у нас будет в кликере.

Теперь напишем UserData - тот самый, о котором в начале упоминалось
Прошу новичков обратить внимание, что это не просто скрипт MonoBehavior - это ScriptableObject.

Если вы не знаете, как создавать ScrObj, то вот - https://habr.com/ru/articles/421523

Итак, тут внутри:

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

Атрибут для добавления данного ScrObj в выпадающее меню и добавления ему названия

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. Поле ClickWeapon отвечает как раз за то, какой кликер сейчас у игрока (будем называть кликером - оружие, которое используем для убийства врага)

  1. Затем прописываем листы доступных игроку оружий и локаций

  2. Пропишем два метода:

    3.1) для разблокировки нового оружия 3.2) и для разблокировки новой локации соответственно

С кодом врага разобрались - осталось еще немного кода, чуть-чуть потерпите

Псевдоокружение

Займемся окружением локаций. Задумка такая: Наверняка, многие из вас слышали об эффекте параллакса (это когда объекты, которые находятся дальше от зрителя, двигаются с меньшей скоростью, чем те, что находятся ближе - это создает иллюзию глубины).
Так вот мы сейчас напишем такой параллакс-контроллер для того, чтобы создать впечатление бесконечно движущегося пространства.

Заглянем под капот 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. Тут у нас опять атрибуты для ScrObj

  2. PatternBenaviour отвечает за поведение движения заднего фона (пока что я буду использовать только один вариант движения)

  3. Потом идет список фонов, которые будут спавниться

  4. Затем переменные, отвечающие за скорость движения и расстояние, которые будут проходить фоны

Дальше enum с вариантами поведения движения (мы будем использовать только 1-ый, так как для 2-ого я еще не сделал подходящих локаций)

Переходим к 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. Список фонов, которые будут двигаться контроллером

  2. Потом паттерн, который мы только что писали

  3. В Awake мы спавним фоны, которые достали из паттерна

  4. В Start мы определяем поведение паттерна и в зависимости от этого даем контроллеру указания, как он должен двигать фоны

Это все. Теперь разберемся, как работать с этой системой:

  1. Создаете пустой объект, вешаете туда контроллер

  2. Создаете экземпляр паттерна при помощи выпадающего меню и настраиваете его как вам угодно

  3. Созданный и настроенный паттерн добавляете в контроллер

Если вы все сделали правильно, то при запуске проекта, ваша локация загрузиться без проблем.

Создание паттернов
Создание паттернов

Нейросеть для пиксель-арта

Эту нейросеть я нашел случайно, и мне она показалось очень классной и качественной.
Прошло 3 месяца с того момента, как я начал готовить материал для этого девлога, так что, возможно, уже есть нейросеть покруче, но 3 месяца назад бесплатных аналогов у этой нейросети не было. Нейросеть делает неплохо, но за ней все равно требуется немного исправлять небольшие штришки (сайт с нейросетью, промт - "identical pixels at the edges so that you can seamlessly duplicate the image, jointless pattern")

сайт с нейросетью
сайт с нейросетью

Схема проста:

  1. Генерируем нужный нам пиксельный фон

  2. Сохраняем

  3. Закидываем в графический редактор и пару раз дублируем

  4. После чего на границах немного подравниваем за нейросетью, чтобы можно было дублировать бесконечно, и получался бесшовный узор (В ФШ есть кстати авто-заливка на ПКМ, которая прекрасно с этим справляется)

  5. Затем сохраняем в нужном формате и закидываем в Unity

Паттерны, которые делал я
Паттерны, которые делал я
настройки спрайтов
настройки спрайтов
  1. Перейдя в Unity, выгружаем наш фон, а затем настраиваем его (можно просто скопировать мои настройки)

  2. Создаем пустой объект, забрасываем на него SpriteRenderer, настраиваем по размеру, цвету и глубине,
    сохраняем как префаб (Если вы не знаете, что такое префаб, то опять таки добро пожаловать в описание к
    видео, там есть ссылка на ознакомление)

  3. Забрасываем все префабы фонов в паттерн, конец!

Если у вас все получилось, то вы бог (я тут много чего упустил). А если нет, то стоит перейти по ссылочке и посмотреть видео для большего понимая - https://youtu.be/Cb_Y4LBO4MQ

Комментарии (38)


  1. jarkevithwlad
    22.06.2024 16:17

    "Сразу хочу отметить, что если вы не умеете хорошо рисовать, то не стоит сразу вырубать видео"

    выглядит как будто выдрали озвучку из видео и не все места подтёрли

    p.s. видео не смотрел (мало трафика)


    1. PiRaMiDeON Автор
      22.06.2024 16:17

      Да, моя вина. У меня есть текстовый документ со сценарием (там весь материал упорядочен и разложен, так что я решил перенести его сюда, немного редактируя). Не заметил несостыковку


  1. joe_krelli
    22.06.2024 16:17
    +3

    Ммм psd на прямую в юнити ( вот они, избалованное поколение большим кол-во памяти на устройстве )

    Мм публичные поля, забиваем на инкапсуляцию, а о разделении бизнес логики и логики отображения, я совсем промолчу.

    P.S Да, автор сказал, что он не программист с 10 летним стажем, но даже джунов от такого кода уже отучивать не приходится.

    P.S.S Ну и писать статью с обучением как делать и учить делать плохо, это не есть хорошо.


    1. jarkevithwlad
      22.06.2024 16:17

      да бывает такое, я даже встречал когда в юнити проекты закидывают не .3ds или .obj, а .blend сам даже пытался после этого такое провернуть, но не вышло, наверно нужно что бы блендер был не портабл


      1. joe_krelli
        22.06.2024 16:17

        Ну ради эксперимента пофиг, можно, но сырые файлы лучше не сувать, они содержат кучу бесполезной инфы для движка и раздувают размер сборки и самого проекта


        1. jarkevithwlad
          22.06.2024 16:17

          да это понятно, просто было интересно понять сам процесс, предпологал что оно при добавлении .blend просто запускает блендер и конвертирует в .3ds и куда то его в кэш кидает, но так и не получилось, а ставить вместо портабла сток не хотелось))


        1. Tirarex
          22.06.2024 16:17
          +4

          Только что проверил, PSD конвертируется в стандартный unity ассет и в билде выглядит как обычная текстура которая жрет так же как текстура с альфа каналом.


          1. joe_krelli
            22.06.2024 16:17

            Build Report не прикладывается, для пруфов ?


            1. Tirarex
              22.06.2024 16:17
              +12

              Ну вообще это вы придумали какую то бесполезную теорию о весе PSD и вам бы ее доказать, но раз вам так хочется то вот.

              В PC билде все выглядит одинаково

              А вот эти же текстуры в проекте

              Ну и раз докапываемся, расскажите насколько приложение стало хуже работать от пары паблик полей в скриптах. И как разбитие логики улучшит геймплей. Сейчас очень модно сходить с ума по клинкоду, ооп, солиду итд. Но кажется забыли что конечному юзеру важен результат а не то что у кого то в скриптах разделена логика и поля не публичные.

              И пока вы занимаетесь всем этим непотребством с придуманными правилами ради правил, всякие создатели цундере симуляторов и банана кликеров в стиме, делают миллионы.