Pull to refresh

Мой magnum opus от мира мобильного гейминга

Reading time79 min
Views2.2K
Привет, Хабр! Сегодня, 26 сентября, мой день рождения, а значит для меня это отличный повод выкатить статью о сиквеле моей головоломки. Предупреждаю, что я любитель, а значит ошибок во ВСЕХ аспектах разработки будет не мало (если обнаружили напишите, я с радостью учту). В этой статье я хотел бы рассказать всё (ну или почти всё) про то, как я делал сиквел, как к этому шёл и к чему пришёл.

Чтобы вам не запутаться обозначаю здесь значения терминов, которые есть в статье:
Оригинал — первая часть, игра с подпольным погонялом «техно-демка». О ней можно почитать здесь.

Сиквел — вторая часть серии, игра, о которой идёт речь в этой статье.

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

Кратко о разработке

Начал я работу над игрой в конце января и к концу марта была закончена техническая часть (2 месяца). После я взялся за другую игру и к этой игре вернулся продолжить разработку в середине мая. Закончил я чётко под конец лета и всё это время (3.5 месяца) я наполнял игру контентом. И в итоге мною сиквел был сделан даже быстрее, чем оригинальная игра (6 месяцев против 5.5 месяцев).

Делал я игру на движке unity. Хотелось бы как эти парни сделать свой движок и продвинуться в программировании вперёд, но что-то пошло не так и всё-таки решил сделать игру на стандартном, но проверенном мною инструменте.

Между оригиналом и сиквелом

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

После релиза меня снова мучала идея сиквела. На этот раз не начал, потому что я морально обленился, после релиза совсем размяк. Мне хотелось чего-то нового и интересного. Начались массовые эксперименты.

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

Так бы и продолжались эксперименты, пока мне не надоело. И мне надоело не делать игры, а недоделанность сделанных мною игр. Я дал себе цель: сделать хоть какую-то игру за неделю до конца. Так и появилась моя вторая игра.

Про 2 игру
Эта игра очень простая и сложная одновременно. Необходимо резать линии и не резать графы. Смысл игры в том, что каждая порезанная линия делилась на 2, а в её центре появлялся граф. Фишка игры была в том, что вся геометрия была динамичной. Двигались графы, а линии всегда соединяли определённые графы.




После такого я был смотивирован (I'm motivated) и готов к новому проекту. Я почувствовал прилив сил и всё таки взялся за сиквел моей игры.

Идея

Прежде чем начать что-то делать, я решил взглянуть на оригинал в полной мере. И ужаснулся. От качества. Игра больше всего косила под стандартные головоломки: необходимость разблокировки уровней, сбор звёзд, таймер, финиш, только всё это было сделано без бюджета и очень безвкусно. Оригиналу очень нехватало анимаций! Хотя в ней было что-то оригинальное и, что-то, наверное, душевное. Хотя даже здесь меня смогли опередить.

Я нашёл нечто похожее
Оказывается, существует очень похожая игра с почти идентичным названием. И она как выглядит как более удачная вариация моей игры. Узнал я о ней из этого видео.
После я узнал, что эта игра эксклюзив телевизоров LG Smart TV. Её создало российское подразделение LG R&D Lab в 2014 году:



Управляется она стрелочками «влево» и «вправо» на пульте. Точно так же, как и в моей игре (2 части экрана). Что уж говорить, угол наклона один и тот же — 30°. Чисто технически, можно сказать, что моя игра является плагиатом этой. Хотя я узнал о ней примерно через 2 месяца после релиза первой игры.




Понимая очень плачебное положение оригинала, я решил радикальными изменениями воскресить игру, сделать её лучше. И тут фантазия полетела: пусть будет сюжет и в нём будет выбор, будут боссы с продуманными атаками, будет постановка, которой так не хватало оригиналу, будет ощущения единого законченного приключения и т.д. В общем все лучшие идеи, которые мне пришли за время между оригиналом и сиквелом. А всё, что не работало или работало плохо было суждено мне выкинуть из сиквела.

Первое демо

Началось всё конечно же с него. Я решил: «Если и решать проблемы, то делать это основательно». И первой жертвой таких изменений стало управление. Я мог бы просто его украсть из похожей игры (смотрите выше). Это именно то управление, которое я изначально хотел, но не знал, как его сделать. А дополнить было бы довольно просто: просто добавь анимации вращения при каждом нажатии. Но это было не для меня. По крайней мере, что бы управление воспринималось так же хорошо, как и в похожей игре, нужно было сделать такую же статичную камеру и явно уменьшить уровни вместе с темпом игры. Но так хотелось экшн, динамику и скорость, поэтому я сделал логичное развитие управления оригинала. Теперь вместо нажатия и вращения на определённый градус было зажатие и градус итогового вращения определялся по его длительности. Выглядело явно лучше, чем в оригинале.

Именно из-за того, что я нормально совладал с управлением, исчез самый главный баг оригинала и теперь можно было делать уровни ГОРАЗДО более нагруженными чем в оригинале без боязни лагов и фризов. И дальше наступила экспериментальная часть.

Графическое демо

Я никогда не умел рисовать нормальную графику и почти всегда её заменяла технологическая часть, а точнее её нормальное исполнение. И эта игра не стала исключением. Вместо простых нормальных спрайтов появился реалистичный свет. Это была иллюзия 2d света. На самом деле это трёхмерный свет, на фоне металлическая поверхность, а все объекты обладали материалами со специфичными шейдерами. Выглядело это вполне неплохо:



В тестах выдавал стабильные 60 fps, но на телефоне, даже на моём sony xperia был на отметке в 20 fps и проседал до 10 fps. И я упёрся в потолок производительности. Пришлось идти по другому пути, по пути разрушаемости…

Разрушаемость

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

Теперь разрушаемость базировалась на более простом принципе работы, а именно создавала копию себя, только из физических осколков, а оригинальный объект удалял у себя компоненты SpriteRenderer, Collider2D и, если имелся, отключал Rigidbody2D.

Но встал другой вопрос — коллайдеры. С одной стороны, можно было использовать PolygonCollider2D и не мучаться, но с другой, пришлось бы потом мучаться в геймдизайне и оптимизации. Поэтому у всех осколков разрушенных блоков был именно BoxCollider2D (даже у осколков круглых объектов).

Также весомый вклад в оптимизацию дала правильная настройка параметра time fixedstep (она стала равна 0.0(3) или 30 в секунду). Но теперь на высоких скоростях объект пролетал его насквозь, и это точно сказалось на геймдизайне.

Эти элементы вывели оптимизацию на приемлемый уровень и теперь физических объектов на сцене могло быть до сотни! После оригинала это был однозначно прорыв, революция и т.п. Поняв, что я двигаюсь в правильном направлении, было мною решено исправить ещё одну давнюю игровую проблему: зашкаливающую хардкорность. Что бы оказуалить хоть как-то игру мною была сделана…

Система урона

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

Но сначала стоит оговориться, как здесь работает восприятие урона. Может показаться, что оно работает по принципу «чем сильнее ударился, тем сильнее урон», но это не так. Оно в большинстве случаев работает по принципу «чем дольше соприкосновение — тем больше урон», где место такой важной вещи как «сила удара» заменилось коэффициентом урона, который был вручную настроен каждому объекту наносящий урон в зависимости от ситуации. Такое вышло из-за того, что time fixedstep оказался настолько большим, что создавался мощный баг: игра не успевала обрабатывать Enter2D. И это создавало ситуации типа: на большой скорости врезался — не получил урон. Почему это я не исправил? Даже я этого сказать не могу.

Итак, с чего началась система урона? Со здоровья. У игрока появилось здоровье равное 1 (позже увеличил до 2). Да, это всё ещё мало и при первом сильном касании с ловушкой он умрёт, но хотя бы при маленькой скорости есть вероятность выжить (даже несколько раз). Изменять оригиналу мне не хотелось. «Но что же будет наносить урон по игроку?» — подумал я и придумал основные ловушки.

Основные ловушки

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

Основной и первой была пила. Простая и понятная головоломка. Написана была не особо оптимизированно, в период постпродакшена исправил.


Скрипт Saw
using UnityEngine;

public class Saw : GlobalFunctions
{
    public AudioClip setClip;
    private TypePlaying typePlaying = TypePlaying.Sound;
    private AudioBase audioBase;
    private float speed = 4f;
    private Transform tr;

    private void Awake()
    {
        audioBase = GameObject.FindWithTag("MainCamera").GetComponent<AudioBase>();
        tr = transform;
    }

    private void Update()
    {
        float s = Time.fixedDeltaTime / 0.03f * (Time.deltaTime / 0.03f);
        tr.localEulerAngles = new Vector3(0f, 0f, tr.localEulerAngles.z - speed * s);
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.collider.tag == "Player")
        {
            audioBase.SetSound(setClip, 1, 0.2f, typePlaying, false);
        }
    }

    public float GetSpeed()
    {
        return speed;
    }
}


Дальше был лазер, который ну очень сильно всё нагружал. Если выставить на сцене таких штук 40, игра начнёт ощутимо лагать. А ведь ещё у меня было желание добавить полноценные физические законы света, а именно отражение или даже преломление. Но времени не было, не доделал. Хотя всё же оптимизировал некоторые вещи, но это не сильно помогло.


Скрипт Laser
using UnityEngine;

public class Laser : MonoBehaviour
{
    public Vector2 vector2;
    public bool active = true;
    public GameObject laserActive;
    public LineRenderer lr1;
    public Transform tr;
    public BoxCollider2D bcl;
    public Damage dmg;

    private void Start()
    {
        lr1.startColor = lr1.endColor = LaserColor();
    }

    public Color LaserColor()
    {
        Color c = new Color(0f, 0f, 0f, 1f);
        switch (dmg.GetTypeLaser().Type2int())
        {
            case 1:
                c = new Color(1f, 0f, 0f, 1f);
                break;
            case 2:
                c = new Color(0f, 1f, 0f, 1f);
                break;
            case 3:
                c = new Color(0f, 0f, 0f, 0.4901961f);
                break;
            case 4:
                c = new Color(1f, 0.8823529f, 0f, 1f);
                break;
            case 5:
                c = new Color(0.6078432f, 0.8823529f, 0f, 1f);
                break;
            case 6:
                c = new Color(1f, 0.2745098f, 0f, 1f);
                break;
        }
        return c;
    }

    private void Update()
    {
        LaserUpdate();
    }

    private void LaserUpdate()
    {
        if (active == true)
        {
            Vector2[] act1 = Points(tr.position, -tr.up);
            lr1.SetPosition(0, act1[0]);
            lr1.SetPosition(1, act1[1]);
            bcl.size = new Vector2(0.1f, 0.1f);
            bcl.offset = act1[2];
        }
        return;
    }

    private Vector2[] Points(Vector2 start, Vector2 end)
    {
        Vector2[] ret = new Vector2[3];
        RaycastHit2D hit = Physics2D.Raycast(tr.position, -tr.up, 200f);
        ret[0] = tr.position;
        ret[1] = hit.point;
        vector2 = ret[1];
        float distance = Vector2.Distance(tr.position, hit.point);
        bcl.size = new Vector2(0.1f, 0.1f);
        if (hit.collider == bcl)
        {
            ret[2] = new Vector2(0f, 0.5f);
        }
        else
        {
            ret[2] = new Vector2(0f, -distance - 0.2f);
        }
        return ret;
    }
}



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


Скрипт Explosion
using System.Collections;
using UnityEngine;

public class Explosion : GlobalFunctions
{
    public float power = 1f;
    public float radius = 5f;
    public float health = 20f;
    public float timeOffsetExplosion = 1f;
    public GameObject[] contacts = new GameObject[0];
    public Animator expAnim;
    public bool writeContacs = true;
    public AudioClip setClip;

    private float timeOffsetExplosionCount;
    private float alphaTimer;
    private bool isTimerOn = false;
    private bool firstAPEvirtual = true;
    private Collider2D cl;
    private Rigidbody2D rb;
    private SpriteRenderer sr;
    private AudioBase audioBase;
    private Explosion explosion;

    private void Awake()
    {
        audioBase = GameObject.FindWithTag("MainCamera").GetComponent<AudioBase>();
        cl = GetComponent<Collider2D>();
        rb = GetComponent<Rigidbody2D>();
        sr = GetComponent<SpriteRenderer>();
        explosion = GetComponent<Explosion>();
    }

    private void Start()
    {
        alphaTimer = sr.color.a;
        StartCoroutineTimerOffsetExplosion();
    }

    private void OnCollisionEnter2D(Collision2D collision)
    {
        if (writeContacs == true)
        {
            int cont = contacts.Length;
            GameObject[] n = new GameObject[cont + 1];

            if (cont != 0)
            {
                for (int i = 0; i < cont; i++)
                {
                    n[i] = contacts[i];
                }
            }

            n[cont] = collision.gameObject;
            contacts = n;
        }
    }

    private void OnCollisionExit2D(Collision2D collision)
    {
        if (writeContacs == true)
        {
            int cont = contacts.Length;
            if (cont != 1)
            {
                int counter = 0;
                GameObject[] n = new GameObject[cont - 1];

                for (int i = 0; i < cont; i++)
                {
                    if (contacts[i] != collision.gameObject)
                    {
                        n[counter] = contacts[i];
                        counter++;
                    }
                }

                contacts = n;
            }
            else
            {
                contacts = new GameObject[0];
            }
        }
    }

    public void ActionExplosionEmulation(GameObject obj)
    {
        float damage = 0f;
        if (obj.CompareTag("Laser"))
        {
            damage = obj.GetComponent<Damage>().senDamage;
        }
        else
        {
            damage = obj.GetComponent<Power>().power;
        }

        health = health - damage;
        StartCoroutineTimerOffsetExplosion();
        return;
    }

    public void StartCoroutineTimerOffsetExplosion()
    {
        if (health <= 0f && isTimerOn == false)
        {
            isTimerOn = true;
            timeOffsetExplosionCount = timeOffsetExplosion;
            StartCoroutine(TimerOffsetExplosion(0.1f));
        }
    }

    private IEnumerator TimerOffsetExplosion(float timeTick)
    {
        yield return new WaitForSeconds(timeTick);
        timeOffsetExplosionCount = timeOffsetExplosionCount - timeTick;
        if (timeOffsetExplosionCount > 0f)
        {
            float c = timeOffsetExplosionCount / timeOffsetExplosion;
            sr.color = new Color(1f, c, c, alphaTimer);
            StartCoroutine(TimerOffsetExplosion(timeTick));
        }
        else
        {
            ExplosionAction();
        }
    }

    private void ExplosionAction()
    {
        rb.gravityScale = 0f;
        rb.velocity = Vector2.zero;
        audioBase.SetSound(setClip, 2, 1f, TypePlaying.Sound, false);
        Destroy(cl);
        CircleCollider2D c = gameObject.AddComponent<CircleCollider2D>();
        c.isTrigger = true;
        c.radius = radius;
        tag = "Explosion";
        if (PlayerPrefs.GetString("graphicsquality") != "high")
        {
            Destroy(sr);
            StartCoroutine(Off());
        }
        else
        {
            expAnim.enabled = true;
            StartCoroutine(Off2High());
        }
    }

    public IEnumerator Off()
    {
        yield return new WaitForSecondsRealtime(0.1f);
        gameObject.SetActive(false);
    }

    public IEnumerator OffHigh(CircleCollider2D c)
    {
        yield return new WaitForSecondsRealtime(0.1f);
        c.enabled = false;
    }

    public IEnumerator Off2High()
    {
        yield return new WaitForSecondsRealtime(1.5f);
        gameObject.SetActive(false);
    }

    public void APEvirtual()
    {
        int cont = contacts.Length;
        if (cont != 0 && firstAPEvirtual == true)
        {
            firstAPEvirtual = false;
            for (int i = 0; i < cont; i++)
            {
                if (contacts[i] != null)
                {
                    if (contacts[i].GetComponent<PhysicsEmulation>())
                    {
                        contacts[i].GetComponent<PhysicsEmulation>().ExplosionPhysicsEmulation(explosion);
                    }
                }
            }
        }
    }

    public void AnimFull()
    {
        sr.color = new Color(1f, 1f, 1f, 1f);
        sr.size = new Vector2(3f * radius, 3f * radius);
        return;
    }
}


Взглянув на всю систему урона, я решил её основательно переписать. И в этот раз все возможные вариации урона уместил в один скрипт Damage, а блокам с разрушаемостью сделал похожий метод ActionPhysicsEmulation (под конец для каждого отдельного типа урона был написан свой оптимизированный метод). Также интенсивность урона определялась по интенсивности «силы» объекта (скрипт был только на игроке).

И в итоге только эти 3 головоломки были на голову выше оригинала. Но это был не повод останавливаться: я также не забывал экспериментировать в течении всей разработки. Так появились.

Силовое поле (отключает гравитацию, замедляет и медленно убивает)


Скрипт VelocityField
using UnityEngine;

public class VelocityField : GlobalFunctions
{
    public float percent = 10f;
    public float damage = 0.01f;
    public float heal = 0.01f;
    public GameObject[] contacts = new GameObject[0];
    private HealthBar hb;

    private void Awake()
    {
        hb = GameObject.FindWithTag("MainCamera").GetComponent<Management>().healthBar;
    }

    private void FixedUpdate()
    {
        if (contacts.Length != 0)
        {
            for (int i = 0; i < contacts.Length; i++)
            {
                if (contacts[i] != null)
                {
                    if (contacts[i].GetComponent<Rigidbody2D>())
                    {
                        float s = Time.fixedDeltaTime / 0.03f;
                        Vector2 vel = contacts[i].GetComponent<Rigidbody2D>().velocity;
                        contacts[i].GetComponent<Rigidbody2D>().velocity = vel / 100f * (100f - percent * s);
                    }
                }
                else
                {
                    contacts = Remove(contacts, i);
                }
            }
        }
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.GetComponent<Rigidbody2D>())
        {
            Rigidbody2D rb2 = collision.GetComponent<Rigidbody2D>();
            if (rb2.isKinematic == false)
            {
                VelocityInput vi = collision.GetComponent<VelocityInput>();

                vi.fields = Add(vi.fields, gameObject);

                rb2.gravityScale = 0f;
                rb2.freezeRotation = true;
                vi.inVelocityField = true;
                if (collision.GetComponent<Destroy>())
                {
                    collision.GetComponent<Destroy>().ActiveTimerDeleteChange(300f);
                }
                if (collision.tag == "Player")
                {
                    hb.StartVFRad(damage);
                }

                contacts = Add(contacts, collision.gameObject);
            }
        }
    }

    public void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.GetComponent<Rigidbody2D>())
        {
            Rigidbody2D rb2 = collision.GetComponent<Rigidbody2D>();
            if (rb2.isKinematic == false)
            {
                VelocityInput vi = collision.GetComponent<VelocityInput>();

                vi.fields = Remove(vi.fields, gameObject);

                if (vi.fields.Length != 0)
                {
                    rb2.gravityScale = 0f;
                    rb2.freezeRotation = true;
                    vi.inVelocityField = true;
                }
                else
                {
                    rb2.gravityScale = 1f;
                    rb2.freezeRotation = false;
                    vi.inVelocityField = false;
                }
                if (collision.GetComponent<Destroy>())
                {
                    collision.GetComponent<Destroy>().ActiveTimerDeleteChange(60f);
                }
                if (collision.tag == "Player")
                {
                    hb.EndVFRad(heal);
                }

                contacts = Remove(contacts, collision.gameObject);
            }
        }
    }
}


Топот (он убивал игроков, давя их)

Скрипт Tramp
using UnityEngine;

public class TrampAnim : MonoBehaviour
{
    public float speed = 0.1f;
    public float speedOffset = 0.01f;
    public float damage = 1f;
    private float sc;
    private float maxDis;
    public Vector3 start;
    public Vector3 end;
    public TrampAnim ender;
    public bool active = true;
    public bool trigPlayer = false;
    private AudioSet audioSet;
    private bool audioAct;
    private Transform tr;
    private HealthBar hb;
    public int count = 0;

    public void Start()
    {
        if (active)
        {
            tr = transform;
            maxDis = Vector2.Distance(start, end);
            sc = Vector2.Distance(tr.localPosition, start) / maxDis;
            hb = Camera.main.GetComponent<Management>().healthBar;

            audioAct = GetComponent<AudioSet>();
            if (audioAct) { audioSet = GetComponent<AudioSet>(); }
        }
    }

    public void Update()
    {
        if (active)
        {
            float s = Time.fixedDeltaTime / 0.03f * (Time.deltaTime / 0.03f);
            if (count == 0)
            {
                tr.localPosition = Vector2.MoveTowards(tr.localPosition, end, (speed * sc + speedOffset) * s);
                if (tr.localPosition == end)
                {
                    count = 1;
                    if (trigPlayer && ender.trigPlayer)
                    {
                        hb.Damage(100f, tag, Vector2.zero);
                    }
                    if (audioAct) { audioSet.SetMusic(); }
                }
            }
            else
            {
                tr.localPosition = Vector2.MoveTowards(tr.localPosition, start, (speed * sc + speedOffset) * s);
                if (tr.localPosition == start)
                {
                    count = 0;
                }
            }
            sc = Vector2.Distance(tr.localPosition, start) / maxDis;
        }
    }

    public void OnCollisionEnter2D(Collision2D collision)
    {
        Transform trans = collision.transform;
        string tag = trans.tag;
        if (tag == "Player")
        {
            trigPlayer = true;
        }
        else if (active == false)
        {
            if (trans.GetComponent<PhysicsEmulation>())
            {
                trans.GetComponent<PhysicsEmulation>().TrampAnimPhysicsEmulation(GetComponent<TrampAnim>());
            }
        }
    }

    public void OnCollisionExit2D(Collision2D collision)
    {
        string tag = collision.transform.tag;
        if (tag == "Player")
        {
            trigPlayer = false;
        }
    }
}


Радиация (которая потихоньку убавляет здоровье)


Скрипт Radiation
using System.Collections;
using UnityEngine;

public class Radiation : MonoBehaviour
{
    public bool isActiveRadiation = false;
    private Management m;
    private HealthBar hb;

    private void Awake()
    {
        gameObject.SetActive(PlayerPrefs.GetString("ai") == "off");
        m = GameObject.FindWithTag("MainCamera").GetComponent<Management>();
        hb = m.healthBar;
    }

    private void Start()
    {
        StartCoroutine(RadiationDamage());
    }

    public IEnumerator RadiationDamage()
    {
        yield return new WaitForSeconds(0.0002f);
        if (isActiveRadiation)
        {
            hb.StraightDamage(0.0002f, "Radiation");
        }
        StartCoroutine(RadiationDamage());
    }

    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.tag == "Player")
        {
            isActiveRadiation = true;
            hb.animator.SetBool("isVisible", true);
        }
    }

    public void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.tag == "Player")
        {
            isActiveRadiation = false;
            hb.animator.SetBool("isVisible", false);
            if (hb.healthBarImage.fillAmount == 0f)
            {
                m.StartGraphics();
            }
        }
    }

    public void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.transform.tag == "Player")
        {
            hb.animator.SetBool("isVisible", false);
            PlayerPrefs.SetString("ai", "on");
            gameObject.SetActive(false);
        }
    }
}


Ловушка (синий шарик, убивающий при касании, являющийся отсылкой на The World's Hardest Game)


Скрипт DeathlessScript
using UnityEngine;

public class DeathlessScript : MonoBehaviour
{
    private HealthBar hb;

    private void Awake()
    {
        hb = Camera.main.GetComponent<Management>().healthBar;
    }

    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.tag == "Player")
        {
            hb.Damage(10f, tag, Vector2.zero);
        }
    }
}


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

Дополнительные механики

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

Первой такой механикой были ворота. Самая первая и самая функциональная из всех. Однозначно пригодился на всех локациях, где необходимы были функциональные заграждения. У него также есть дополнительные функции: isActive для определения стартового состояния и isState для того, чтобы зафиксировать положение после активации (имена перепутаны, но, когда заметил было слишком поздно исправлять).


Скрипт Gate
using UnityEngine;
using System.Collections;

public class Gate : MonoBehaviour
{
    [Header("StartSet")]
    public Vector2 gateScale = new Vector2(1, 4);
    public float speed = 0.1f;
    public bool isReverse = false;
    public bool isEnd = true;
    public Vector2 animSetGateScale = new Vector2();
    public Vector2 target = new Vector2();
    [Header("SpriteEditor")]
    public Sprite mainSprite;
    [Header("Assets")]
    public GameObject door1;
    public GameObject door2;
    private IEnumerator fixUpdate;

    private void Start()
    {
        SpriteRenderer ds1 = door1.GetComponent<SpriteRenderer>();
        SpriteRenderer ds2 = door2.GetComponent<SpriteRenderer>();
        ds1.sprite = mainSprite;
        ds2.sprite = mainSprite;
        if (isReverse == false)
        {
            animSetGateScale = target = gateScale;
        }
        fixUpdate = FixUpdate();
        SetGate(animSetGateScale);
    }

    private IEnumerator FixUpdate()
    {
        yield return new WaitForSeconds(0.03f);
        if (animSetGateScale != target)
        {
            float s = Time.fixedDeltaTime / 0.03f;
            animSetGateScale = Vector2.MoveTowards(animSetGateScale, target, speed * s);
            SetGate(animSetGateScale);
            StartCoroutine(FixUpdate());
        }
    }

    private void SetGate(Vector2 scale)
    {
        SpriteRenderer ds1 = door1.GetComponent<SpriteRenderer>();
        SpriteRenderer ds2 = door2.GetComponent<SpriteRenderer>();
        Vector2 size = new Vector2(mainSprite.texture.width, mainSprite.texture.height);
        float k = size.x / size.y;
        ds1.size = new Vector2(gateScale.x, scale.y / 2f);
        ds2.size = new Vector2(gateScale.x, scale.y / 2f);

        BoxCollider2D d1 = door1.GetComponent<BoxCollider2D>();
        BoxCollider2D d2 = door2.GetComponent<BoxCollider2D>();
        d1.size = new Vector2(gateScale.x, scale.y / 2f);
        d2.size = new Vector2(gateScale.x, scale.y / 2f);
        
        door1.transform.localScale = new Vector3(1f, 1f, 1f);
        door2.transform.localScale = new Vector3(1f, 1f, 1f);
        door1.transform.localPosition = new Vector3(0f, (gateScale.y / 2f) - (scale.y / 4f), 0f);
        door2.transform.localPosition = new Vector3(0f, -(gateScale.y / 2f) + (scale.y / 4f), 0f);
    }

    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.CompareTag("Player"))
        {
            if (isReverse == false)
            {
                target = Vector2.zero;
            }
            else
            {
                target = gateScale;
            }
            StopCoroutine(fixUpdate);
            fixUpdate = FixUpdate();
            StartCoroutine(fixUpdate);
        }
    }

    private void OnTriggerExit2D(Collider2D collision)
    {
        if (collision.CompareTag("Player") && isEnd == true)
        {
            if (isReverse == false)
            {
                target = gateScale;
            }
            else
            {
                target = Vector2.zero;
            }
            StopCoroutine(fixUpdate);
            fixUpdate = FixUpdate();
            StartCoroutine(fixUpdate);
        }
    }
}


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

Ещё поскольку я научился «владеть силой», аж три механики управляли ею. Это были триггеры с идентичным кодом взаимодействия с объектами, но каждая выполняла задачи по-своему. Первым было силовым полем (замедлял объект, умножая силу на определённый коэффициент). Второй прибавлял силу в направление точки и точка имела «гравитацию». Третий был сделан случайно: когда головоломка, связанная с нулевой гравитацией, не работала, этот скрипт спас её. В нём объект меняет направление силы, не меняя её саму, её интенсивность.


Как работает
Сначала по теореме Пифагора вычисляется гипотенуза, которая является коэффициентом вектора и пригодиться для восстановления силы. Дальше вычисляется угол при помощи функции Atan2. После к углу прибавляется offsetAngle и строится новый вектор на основе синуса и косинуса, который умножается на коэффициент и получается изменённое направление без измененной силы.
public Vector2 RotateVector(Vector2 a, float offsetAngle)
{
    float power = Mathf.Sqrt(a.x * a.x + a.y * a.y);
    float angle = Mathf.Atan2(a.y, a.x) * Mathf.Rad2Deg - 90f + offsetAngle;
    return Quaternion.Euler(0, 0, angle) * Vector2.up * power;
}


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

По концепции они давали временные улучшения, которые связаны с некоторыми основными величинами. Бустеров получилось 5: лечение, бессмертие, замедление времени (слоу мо), изменение гравитации и изменение массы игрока.

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


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

Физическая тематика игры позволила создать батут, который обычно используется для разгона игрока с последующим разрушением стены (хотя это простой BoxCollider2D с PhysicsMaterial, у которого был подкручен параметр bounce для разной силы отскакивания).

А песочность игры позволила создать собственные скрипты для анимации. В основном они двигали объект от точки к точке или вращали объект. Раньше у них было значительно больше функций: возможность анимировано (по точкам) вращать объект, изменять scale (по точкам), более точные метки для начала и конца анимации объекта и т.д. Но в силу того, что это были атавизмами, которые в сумме неистово жрали производительность, мне пришлось их выпилить во имя оптимизации. Скрипт анимации используется везде, где нужно показать простую анимацию, ведь как я говорил: «Оригиналу очень нехватало анимаций!». Скрипта всего два:

BasicAnimation и PointsAnimation.

Скрипт BasicAnimation
using UnityEngine;
using System.Collections;

public class BasicAnimation : GlobalFunctions
{
    public AnimationType animationType = AnimationType.Infinity;
    public float speedSpeed = 0.05f;
    public float rotation = 0f;

    private bool make = true;
    private bool animMake = false;
    private bool isMoved = false;
    private Transform tr;
    private float rotationActive = 0f;

    public void SetPos(bool pos, float m)
    {
        rotationActive = rotation * (pos ? 1 : m);
    }

    private void Start()
    {
        tr = transform;
        animMake = false;
        switch (animationType)
        {
            case AnimationType.Infinity:
                make = true;
                isMoved = true;
                rotationActive = rotation;
                break;
            case AnimationType.Start:
                make = false;
                isMoved = false;
                break;
            case AnimationType.End:
                make = true;
                isMoved = true;
                rotationActive = rotation;
                break;
            case AnimationType.All: 
                make = false;
                isMoved = false;
                break;
        }
    }

    public void TimerAnim(float timer, bool anim)
    {
        StartAnim(anim);
        StartCoroutine(TimerTimerAnim(timer, anim));
    }

    private IEnumerator TimerTimerAnim(float timer, bool anim)
    {
        yield return new WaitForSeconds(timer);
        EndAnim(anim);
    }

    public void StartAnim(bool anim)
    {
        make = true;
        if (anim == true)
        {
            animMake = true;
            isMoved = true;
        }
        else
        {
            rotationActive = rotation;
        }
    }

    public void EndAnim(bool anim)
    {
        if (anim == true)
        {
            animMake = true;
            isMoved = false;
        }
        else
        {
            make = false;
            rotationActive = 0f;
        }
    }

    private void FixedUpdate()
    {
        if (animMake == true)
        {
            if (isMoved == true)
            {
                if (rotationActive != rotation)
                {
                    rotationActive = Mathf.MoveTowards(rotationActive, rotation, speedSpeed);
                }
                else
                {
                    animMake = false;
                    isMoved = false;
                }
            }
            else
            {
                if (rotationActive != 0f)
                {
                    rotationActive = Mathf.MoveTowards(rotationActive, 0f, speedSpeed);
                }
                else
                {
                    animMake = false;
                    isMoved = true;
                }
            }
        }
    }

    private void Update()
    {
        if (make == true)
        {
            float rot = tr.localEulerAngles.z;
            float s = Time.fixedDeltaTime / 0.03f * (Time.deltaTime / 0.03f);
            tr.localEulerAngles = new Vector3(0f, 0f, rot + rotationActive * s);
        }
    }
}

Скрипт PointsAnimation
using UnityEngine;
using System.Collections;

public class PointsAnimation : GlobalFunctions
{
    public AnimationType animationType = AnimationType.Infinity;
    public float speedSpeedPosition = 0.001f;
    public float speedPosition = 0.1f;
    public Vector3[] pointsPosition = new Vector3[0];
    public int counterPosition = 0;

    private float speedPositionActive = 0f;
    private int pointsPositionLength = 0;
    private bool make = true;
    private bool animMake = false;
    private bool isMoved = false;
    private Transform tr;

    public void SetPos(bool pos, float m) { speedPositionActive = speedPosition * (pos ? 1 : m); }

    private void Awake()
    {
        pointsPositionLength = pointsPosition.Length;
        tr = transform;
        switch (animationType)
        {
            case AnimationType.Infinity:
                make = true;
                isMoved = true;
                speedPositionActive = speedPosition;
                break;
            case AnimationType.Start:
                make = false;
                isMoved = false;
                break;
            case AnimationType.End:
                make = true;
                isMoved = true;
                speedPositionActive = speedPosition;
                break;
            case AnimationType.All:
                make = false;
                isMoved = false;
                break;
        }
    }

    public void TimerAnim(float timer, bool anim)
    {
        StartAnim(anim);
        StartCoroutine(TimerTimerAnim(timer, anim));
    }

    private IEnumerator TimerTimerAnim(float timer, bool anim)
    {
        yield return new WaitForSeconds(timer);
        EndAnim(anim);
    }

    public void StartAnim(bool anim)
    {
        make = true;
        if (anim == true)
        {
            animMake = true;
            isMoved = true;
        }
        else
        {
            speedPositionActive = speedPosition;
        }
    }

    public void EndAnim(bool anim)
    {
        if (anim == true)
        {
            animMake = true;
            isMoved = false;
        }
        else
        {
            make = false;
            speedPositionActive = 0f;
        }
    }

    private void FixedUpdate()
    {
        if (animMake == true)
        {
            if (isMoved == true)
            {
                if (speedPositionActive != speedPosition)
                {
                    Vector2 ends = new Vector2(-speedPosition, speedPosition);
                    speedPositionActive = Mathf.MoveTowards(speedPositionActive, speedPosition, speedSpeedPosition);
                }
                else
                {
                    animMake = false;
                    isMoved = false;
                }
            }
            else
            {
                if (speedPositionActive != 0f)
                {
                    Vector2 ends = new Vector2(-speedPosition, speedPosition);
                    speedPositionActive = Mathf.MoveTowards(speedPositionActive, 0f, speedSpeedPosition);
                }
                else
                {
                    animMake = false;
                    isMoved = true;
                }
            }
        }
    }

    private void Update()
    {
        if (make)
        {
            if (tr.localPosition == pointsPosition[counterPosition])
            {
                counterPosition++;
                if (counterPosition == pointsPositionLength)
                { counterPosition = 0; }
            }
            else
            {
                float s = Time.fixedDeltaTime / 0.03f * (Time.deltaTime / 0.03f);
                tr.localPosition = Vector3.MoveTowards(tr.localPosition, pointsPosition[counterPosition], speedPositionActive * s);
            }
        }
    }
}


UI

По сравнению с оригиналом это настоящий шедевр.

Для сравнения, вот оригинал:


Вот сиквел:


Вот оригинал:


Вот сиквел:


Вот ориг… думаю понятно. Минимализм в сиквеле я довёл до ума и вместо неуместно раскрашенной кнопки паузы и откровенно мешающего таймера теперь локаничная кое-как заметная кнопочка паузы в левом нижнем углу. В меню всё ещё побеждает сиквел. В отличии от оригинала здесь есть анимации повсюду, а задний фон это 11 случайно мною написанных шейдеров в Shader Graph. По функциональности тоже всё лучше, есть настройка графики, раздельная настройка звука и музыки, консоль, которая позволять менять сохранения — ничего этого в меню оригинала нету.

Так хорошо вышло потому, что я решил посмотреть на другие игры. То тут, то там, в общем взял (скорее украл) лучшее отовсюду. И вот, что я взял особенного:

  1. Меню проигрыша
    Взято из Alto's Adventure, только переживания превратились в насмешки, шутки, ироничные комментарии и т.д.
  2. Пауза
    Тоже из Alto, только не так функционально, но в стиль вписывается и играть удобнее.
  3. Настройки
    Частично взяты с Vector 2, а именно форма меню и ползунки громкости.
    Немного взял в общем, а в остальном всё сделал самостоятельно.

Консоль

Сначала стоит оговориться о том, как работают сохранения. Существует две переменных отвечающие за глобальные и локальные сохранения: это числа progress и elevatorsave соответственно. Переменная progress отвечает за сохранения между сценами, а переменная elevatorsave за сохранения внутри сцены. При нажатии кнопки «Старт» или «Заново» игра переносит на сцену progress и спаунит игрока на сохранении под номером elevatorsave.

Консоль позволяет менять или создавать любую переменную. Такой простой и мощный инструмент мне очень пригодился для тестирования игры и выявления в ней багов. Сама по себе консоль представляет собой вручную написанные команды, пародирующие другие консоли.

Скрипт DebugConsole
using UnityEngine;
using UnityEngine.UI;
using UnityEngine.SceneManagement;
using System.Collections;

public class DebugConsole : MonoBehaviour
{
    public Animator animatorBlackScreen;
    public Language l;
    public InputField inputField;
    public Text textDebug;
    private bool access = false;

    public void AnalyzeText()
    {
        string txt = inputField.text.ToLower();
        string[] output = new string[0];
        string txtLoc = "";

        for (int i = 0; i < txt.Length; i++)
        {
            if (txt[i] == ' ')
            {
                if (txtLoc != "")
                {
                    output = Add(output, txtLoc);
                    txtLoc = "";
                }
            }
            else
            {
                txtLoc = txtLoc + txt[i];
            }
        }
        if (txtLoc != "")
        {
            output = Add(output, txtLoc);
            txtLoc = "";
        }

        Analyze(output);
    }

    public void Analyze(string[] commands)
    {
        switch (commands[0])
        {
            case "playerprefs":
                if (access == true)
                {
                    if (commands.Length < 2)
                    {
                        Log(l.ConsoleLanguage(1));//1
                    }
                    else
                    {
                        switch (commands[1])
                        {
                            case "f":
                            case "float":
                                float f = 0f;
                                if (float.TryParse(commands[3], out f))
                                {
                                    PlayerPrefs.SetFloat(commands[2], float.Parse(commands[3]));
                                    Log(l.ConsoleLanguage(2, commands[2]));//2
                                }
                                else
                                {
                                    Log(l.ConsoleLanguage(3));//3
                                }
                                break;
                            case "i":
                            case "int":
                                int i = 0;
                                if (int.TryParse(commands[3], out i))
                                {
                                    PlayerPrefs.SetInt(commands[2], int.Parse(commands[3]));
                                    Log(l.ConsoleLanguage(4, commands[2]));//4
                                }
                                else
                                {
                                    Log(l.ConsoleLanguage(5));//5
                                }
                                break;
                            case "s":
                            case "string":
                                PlayerPrefs.SetString(commands[2], commands[3]);
                                Log(l.ConsoleLanguage(6, commands[2]));//6
                                break;
                            case "clear":
                                PlayerPrefs.DeleteAll();
                                SceneManager.LoadScene(0);
                                break;
                            default:
                                Log(l.ConsoleLanguage(7, commands[1]));//7
                                break;
                        }
                    }
                }
                else
                {
                    Log(l.ConsoleLanguage(8));//8
                }
                break;
            case "next":
                if (access == true)
                {
                    if (commands.Length > 1)
                    {
                        switch (commands[1])
                        {
                            case "level":
                                int p = PlayerPrefs.GetInt("progress");
                                PlayerPrefs.SetInt("progress", p + 1);
                                Log("ok level");
                                break;
                            case "save":
                                int s = PlayerPrefs.GetInt("elevatorsave");
                                PlayerPrefs.SetInt("elevatorsave", s + 1);
                                Log("ok save");
                                break;
                            case "start":
                                PlayerPrefs.SetInt("elevatorsave", 0);
                                Log("ok start");
                                break;
                            case "end":
                                PlayerPrefs.SetInt("elevatorsave", 1);
                                Log("ok end");
                                break;
                        }
                    }
                }
                else
                {
                    Log(l.ConsoleLanguage(8));//8
                }
                break;
            case "echo":
                if (commands.Length == 1)
                {
                    Log(l.ConsoleLanguage(9));//9
                }
                else
                {
                    switch (commands[1])
                    {
                        case "vertogpro"://echo vertogpro
                            access = true;
                            Log(l.ConsoleLanguage(10));//10
                            break;
                        default:
                            Log(l.ConsoleLanguage(11));//11
                            break;
                    }
                }
                break;
            case "restart":
                if (access == true)
                {
                    SceneManager.LoadScene(0);
                }
                else
                {
                    Log(l.ConsoleLanguage(12));//12
                }
                break;
            case "authors":
                Log(l.ConsoleLanguage(13));//13
                break;
            case "discharge":
                animatorBlackScreen.SetBool("isActive", true);
                PlayerPrefs.SetString("start", "key");
                PlayerPrefs.SetString("language", "nothing");
                PlayerPrefs.SetString("graphicsquality", "medium");
                PlayerPrefs.SetFloat("sound", 0.5f);
                PlayerPrefs.SetFloat("music", 0.5f);
                PlayerPrefs.SetFloat("rotatenextlevel", 0f);
                PlayerPrefs.SetInt("elevatorsave", 0);
                PlayerPrefs.SetInt("progress", 1);
                PlayerPrefs.SetInt("deaths", 0);
                PlayerPrefs.SetInt("discharge", PlayerPrefs.GetInt("discharge") + 1);
                PlayerPrefs.SetInt("lastmenueffect", -1);
                PlayerPrefs.SetString("isshotmode", "false");
                PlayerPrefs.SetString("boss1", "life");
                PlayerPrefs.SetString("boss2", "life");
                PlayerPrefs.SetString("ai", "off");
                PlayerPrefs.SetString("boss3", "life");
                PlayerPrefs.SetString("end", "none");
                StartCoroutine(StartGame());
                break;
            case "clear":
                Clear();
                break;
            case "info":
                if (access == false)
                {
                    Log(l.ConsoleLanguage(14));//14
                }
                else
                {
                    Log(l.ConsoleLanguage(15));//15
                }
                break;
            default:
                Log(l.ConsoleLanguage(16, commands[0]));//16
                break;
        }
    }

    public void Log(object message)
    {
        textDebug.text = message.ToString();
    }

    public void Clear()
    {
        inputField.text = "";
        textDebug.text = "";
    }

    public string[] Add(string[] old, string addComponent)
    {
        string[] n = new string[old.Length + 1];

        if (old.Length != 0)
        {
            for (int i = 0; i < old.Length; i++)
            {
                n[i] = old[i];
            }
        }

        n[old.Length] = addComponent;
        return n;
    }

    public IEnumerator StartGame()
    {
        yield return new WaitForSeconds(1f);
        SceneManager.LoadSceneAsync(0);
    }
}


И специально для вас я оставлю список ликвидных команд в ней:

  1. discharge — сбрасывает игровой прогресс (и всю остальную информацию тоже)
  2. echo vertogpro — команда для предоставления доступа к разработческим командам
  3. playerprefs [тип данный (string, int, float)] [имя переменной] [данные] — меняет или создаёт любую переменну. Пример: playerprefs int progress 14
  4. next — подтип для упрощённой навигации по уровням, со своими командами:
    • start — сохраняет в начале уровня (next start)
    • end — сохраняет в конце уровня (next end)
    • save — телепортирует на следующее сохранение (next save)
    • level — телепортирует на следующий уровень (next level)

Графика

За год я так и не научился рисовать, поэтому я сделал почти то же самое, что и в оригинале: скачал около 30 текстур-паков к майкрафту, отобрал лучшее из каждого и так получилась основная графика. От оригинала графика отличалась не сильно и это меня бесило, бесило настолько, что я ещё нашёл разные анимированные эффекты (взрывы, огонь и т.п) и накачал разнообразных текстур-паков из asset store. Даже для мобильной игры графика довольно плохая, хотя прогресс всё равно наблюдается. Вот оригинал:


А вот сиквел:


Сохранения

Если принцип у сохранений простой, то их реализация не очень. Система сохранений состоит из 3 скриптов:

  1. ElevatorBase — основа, в которой происходят стартовые команды. В ней по переменной elevatorsave из массива сохранений выбирается активное сохранение.

    Скрипт ElevatorBase
    using UnityEngine;
    using System.Collections;
    
    public class ElevatorBase : MonoBehaviour
    {
        public GameObject[] savers = new GameObject[0];
        public float inputStartBlock = 1f;
        private GameUI gameUI;
    
        public void Awake()
        {
            int l = savers.Length;
            if (l != 0)
            {
                for (int i = 0; i < l; i++)
                {
                    if (savers[i] != null)
                    {
                        if (savers[i].GetComponent<Saving>())
                        {
                            Saving saving = savers[i].GetComponent<Saving>();
                            saving.isFirst = false;
                            saving.idElevatorBase = i;
                        }
                        else if (savers[i].GetComponent<Elevator>())
                        {
                            savers[i].GetComponent<Elevator>().isFirst = false;
                        }
                    }
                }
    
                int es = PlayerPrefs.GetInt("elevatorsave");
                if (savers[es] != null)
                {
                    if (savers[es].GetComponent<Saving>())
                    {
                        savers[es].GetComponent<Saving>().isFirst = true;
                    }
                    else if (savers[es].GetComponent<Elevator>())
                    {
                        savers[es].GetComponent<Elevator>().isFirst = true;
                    }
                }
                else
                {
                    gameUI = GameObject.FindWithTag("Canvas").GetComponent<GameUI>();
                    StartCoroutine(BlockEnabled());
                    GameObject.Find("TipsInput").GetComponent<TipsGamePlayInput>().active = true;
                }
            }
            else
            {
                gameUI = GameObject.FindWithTag("Canvas").GetComponent<GameUI>();
                gameUI.ChangeisBlocked();
            }
        }
        
        public IEnumerator BlockEnabled()
        {
            yield return new WaitForSeconds(inputStartBlock);
            GameObject block = gameUI.block.gameObject;
            block.SetActive(false);
        }
    }
    

  2. Saving — сохранение, при подаче на него сигнала он создаёт игрока в определённой позиции и, если сигнала нету, то при соприкосновении игрока с триггером сохраняет в elevatorsave свой id.

    Скрипт Saving
    using System.Collections;
    using UnityEngine;
    
    public class Saving : MonoBehaviour
    {
        public Saving[] savings;
        public Vector2 startPos;
        public float startRot;
        public bool isActive = true;
        public bool isFirst = true;
        public int idElevatorBase = 0;
        public TipsGamePlayInput tgpi;
        private GameObject player;
        private GameObject cam;
        private Transform trp;
        private GameUI gameui;
        private Management m;
        private Saving self;
    
        private void Start()
        {
            self = GetComponent<Saving>();
            cam = GameObject.FindWithTag("MainCamera");
            m = cam.GetComponent<Management>();
            gameui = GameObject.FindWithTag("Canvas").GetComponent<GameUI>();
            player = m.player;
            trp = player.GetComponent<Transform>();
    
            if (isFirst)
            {
                trp.position = startPos;
                m.Set(startRot);
                OfferSaves();
            }
            isActive = !isFirst;
            tgpi.SetActive(!isFirst);
            StartCoroutine(BlockFalse());
        }
    
        public IEnumerator BlockFalse()
        {
            yield return new WaitForSeconds(1f);
            gameui.block.gameObject.SetActive(false);
        }
    
        private void OnTriggerEnter2D(Collider2D collision)
        {
            if (collision.CompareTag("Player") && isActive == true)
            {
                isActive = false;
                PlayerPrefs.SetInt("elevatorsave", idElevatorBase);
                OfferSaves();
            }
        }
    
        public void OfferSaves()
        {
            if (savings.Length != 0)
            {
                for (int i = 0; i < savings.Length; i++)
                {
                    savings[i].isActive = false;
                    savings[i].tgpi.SetActive(false);
                }
            }
        }
    }
    

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

    Скрипт Elevator
    using System.Collections;
    using UnityEngine;
    
    public class Elevator : GlobalFunctions
    {
        public Vector2 endPos;
        public Vector2 startPos;
        public int nextScene = 1;
        public int nextElevatorSave = 0;
        public float speed = 0.1f;
    
        public bool isFirst = true;
        public bool isActive = true;
        public bool isReverse = false;
        public bool isMake = false;
        private GameObject player;
        private Rigidbody2D rb;
        private Transform tr;
        private Transform trp;
        private GameUI gameui;
        private AudioBase audioBase;
        private Transform cam;
    
        private void Start()
        {
            audioBase = GameObject.FindWithTag("MainCamera").GetComponent<AudioBase>();
            gameui = GameObject.FindWithTag("Canvas").GetComponent<GameUI>();
            player = gameui.m.player;
            rb = player.GetComponent<Rigidbody2D>();
            trp = player.GetComponent<Transform>();
            tr = GetComponent<Transform>();
            cam = gameui.m.transform;
    
            startPos = tr.position;
            if (isFirst)
            {
                trp.position = startPos;
                rb.velocity = new Vector2();
                rb.gravityScale = 0f;
                gameui.m.Set();
            }
            else
            {
                tr.position = endPos;
                isMake = true;
            }
            isActive = isFirst;
            isReverse = false;
        }
    
        private void OnTriggerEnter2D(Collider2D collision)
        {
            if (collision.CompareTag("Player") && isMake == true)
            {
                isReverse = true;
                isActive = true;
                rb.velocity = new Vector2();
                rb.gravityScale = 0f;
                gameui.block.gameObject.SetActive(true); 
                PlayerPrefs.SetInt("elevatorsave", nextElevatorSave);
                gameui.animatorBlackScreenGame.SetBool("isActive", true);
                audioBase.LowerSound(0.05f, 16, 0, TypePlaying.Music);
                StartCoroutine(NumSaveRotate());
                StartCoroutine(gameui.StartGame(1.5f, nextScene));
            }
        }
    
        private IEnumerator NumSaveRotate()
        {
            yield return new WaitForSeconds(1.5f);
            PlayerPrefs.SetFloat("rotatenextlevel", Stable(cam.localEulerAngles.z, -180f, 180f));
        }
    
        private void FixedUpdate()
        {
            if (isActive == true)
            {
                float s = Time.fixedDeltaTime / 0.03f;
                if (isReverse == false)
                {
                    rb.velocity = new Vector2();
                    tr.position = Vector2.MoveTowards(tr.position, endPos, speed * s);
                    trp.position = tr.position;
                    if ((Vector2)tr.position == endPos)
                    {
                        isMake = true;
                        isActive = false;
                        rb.gravityScale = 1f;
                        gameui.block.gameObject.SetActive(false);
                    }
                }
                else if (isReverse == true)
                {
                    tr.position = Vector2.MoveTowards(tr.position, startPos, speed * s);
                    trp.position = tr.position;
                    if (tr.position == (Vector3)startPos)
                    {
                        isActive = false;
                        rb.gravityScale = 1f;
                    }
                }
            }
        }
    }
    

Игровой дизайн

Это была настоящая морока. Именно игровой дизайн растянул цикл разработки с 4 до 6 месяцев. Всего в игре 34 уровня: 30 обычных, 3 босса и 1 финальный (уровень). Каждый обычный я делал 2-3 дня, каждого босса 2 недели и финальный уровень делал неделю. Что бы сбалансировать это всё, я выстроил их так: 10 уровней => 1 босс => 10 уровней => 2 босс => 10 уровней => 3 босс => финальный уровень.

Здешние уровни являются моей гордостью. Они необычные, разнообразные и даже немного интересные. Уровни спроектированы по определённой форме, чтобы создать ощущение открытого мира. Для такого даже карту нарисовал:


Карта не лучшей рисовки и информативности, но она дала важную информацию для необходимых форм уровней. Изначально в планах было сделать все уровни на карте, но те, которые затемнены я так и не сделал. Кстати, это карта размером в 1000x1000 пикселей, и именно из этой карты получился масштабу: 1 блок = 1 пиксель = размер игрока.

Между уровнями игрок проходит через лифт. Он может доставлять на любой уровень, а поэтому есть возможность путешествовать между уровнями, создавая у игрока ещё большее ощущение открытости мира. А ещё, в некоторых местах запрятаны триггеры для активации секретных лифтов, которые могут пронести на 10-15 уровней вперёд.

Для обычных уровней был свой алгоритм построения:

  1. Задний фон, что бы имел форму и масштаб как на карте
  2. Стены наружные (имеют тройную толщину из-за особенной физики)
  3. Стены внутренние
  4. Сами уровни
  5. Лифты, сохранения и аудио триггеры

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

1 босс очень прост в поведении: рандомно передвигается по помещению, ждёт 5 секунд и повторяет. Если честно, это плохой пример босса: простой, лагающий и не запоминающийся. И его можно убить только бьясь об него. Но есть защита в виде 4 пил: 3 из них шустро движуться рандомно по помещению и одна защищает босса, когда он движется. После смерти он взрывается.

Скрипт BossManagement1
using UnityEngine;
using System.Collections;

public class BossManagement1 : GlobalFunctions
{
    public float hp = 100f;
    public float speed = 0.2f;
    public bool startActivated = false;
    public bool activated = false;
    public bool activatedSaw = false;
    public bool activatedAngle = false;
    public bool activatedCoroutine = true;
    private bool active;
    private float maxhp;
    public Vector2 target;
    public Vector2 targetSaw1;
    public Vector2 targetSaw2;
    public Vector2 minBorder;
    public Vector2 maxBorder;
    public DeadBoss1 deadBoss;
    public GameObject backGround;
    public GameObject healthBar;
    public Transform tr;
    public Transform sawMain;
    public Transform saw1;
    public Transform saw2;
    public Arrow arrow;
    public AudioSet setStart;
    public AudioSet setEnd;
    public Transform player;
    public Power playerPower;
    private Transform bg, hb;
    private float targethp = 0f;

    private Vector2 startMove = new Vector2(-20f, 0f);

    public void Awake()
    {
        maxhp = hp;
        bg = backGround.transform;
        hb = healthBar.transform;
    }

    public void Start()
    {
        if (PlayerPrefs.GetString("boss1") == "death")
        {
            Dead(false);
        }
    }

    public void FixedUpdate()
    {
        if (startActivated && !activatedCoroutine)
        {
            if ((Vector2)tr.position != startMove)
            {
                tr.position = Vector2.MoveTowards(tr.position, startMove, speed);
                saw1.position = Vector2.MoveTowards(saw1.position, startMove, speed);
                saw2.position = Vector2.MoveTowards(saw2.position, startMove, speed);
            }
            else
            {
                activatedCoroutine = true;
                startActivated = false;
                StartCoroutine(ActivatedOn());
            }
        }
        if (activated)
        {
            if ((Vector2)tr.position != target)
            {
                tr.position = Vector2.MoveTowards(tr.position, target, speed);
            }
            else
            {
                activated = false;
                sawMain.localScale = new Vector2(0f, 0f);
                StartCoroutine(TargetRotate());
            }
        }
        if (activatedSaw)
        {
            if ((Vector2)saw1.position != targetSaw1)
            {
                saw1.position = Vector2.MoveTowards(saw1.position, targetSaw1, speed);
            }
            else
            {
                float x = Random.Range(minBorder.x, maxBorder.x);
                float y = Random.Range(minBorder.y, maxBorder.y);
                targetSaw1 = new Vector2(x, y);
            }

            if ((Vector2)saw2.position != targetSaw2)
            {
                saw2.position = Vector2.MoveTowards(saw2.position, targetSaw2, speed);
            }
            else
            {
                float x = Random.Range(minBorder.x, maxBorder.x);
                float y = Random.Range(minBorder.y, maxBorder.y);
                targetSaw2 = new Vector2(x, y);
            }
        }
        if (activatedAngle)
        {
            Vector2 dir = player.position - tr.position;
            float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
            tr.localEulerAngles = new Vector3(0f, 0f, Mathf.LerpAngle(tr.localEulerAngles.z, angle, 0.1f));
        }
    }

    public IEnumerator TargetRotate()
    {
        yield return new WaitForSeconds(3f + 3f * hp / maxhp);
        sawMain.localScale = new Vector2(6f, 6f);
        float x = Random.Range(minBorder.x, maxBorder.x);
        float y = Random.Range(minBorder.y, maxBorder.y);
        target = new Vector2(x, y);
        activated = true;
    }

    public IEnumerator ActivatedOn()
    {
        yield return new WaitForSeconds(3f);
        sawMain.localScale = new Vector2(6f, 6f);
        target = new Vector2(Random.Range(minBorder.x, maxBorder.x), Random.Range(minBorder.y, maxBorder.y));
        targetSaw1 = new Vector2(Random.Range(minBorder.x, maxBorder.x), Random.Range(minBorder.y, maxBorder.y));
        targetSaw2 = new Vector2(Random.Range(minBorder.x, maxBorder.x), Random.Range(minBorder.y, maxBorder.y));
        activatedSaw = true;
        activated = true;
        arrow.isActive = true;
    }

    public IEnumerator ActivatedCoroutineOff()
    {
        yield return new WaitForSeconds(1f);
        activatedCoroutine = false;
        activatedAngle = true;
    }

    public void Update()
    {
        if (active == true)
        {
            if (hp != targethp)
            {
                float s = Time.fixedDeltaTime / 0.03f * (Time.deltaTime / 0.03f);
                hp = MoveToward(hp, targethp, speed * s, new Vector2(-0f, maxhp));
            }
            else
            {
                active = false;
                if (targethp == 0f)
                {
                    Dead(true);
                }
            }
        }

        UpdateHP();
    }

    public void UpdateHP()
    {
        float h = hp / maxhp;
        bg.localScale = new Vector3(5f, 0.9f, 1f);
        hb.localScale = new Vector3(4.8f * h, 0.7f, 1f);
        hb.localPosition = new Vector3(-2.4f + 4.8f * h / 2f, 0f, 0f);
    }

    private bool oneTimeMusic = true;
    public void Damage(float damage)
    {
        if (oneTimeMusic == true)
        {
            oneTimeMusic = false;
            deadBoss.StartBoss();
            deadBoss.Boom();
            setStart.SetMusic();
            startActivated = true;
            StartCoroutine(ActivatedCoroutineOff());
        }

        if (hp != 0f)
        {
            targethp = Stable2(hp - damage, 0f, maxhp);
            speed = speed + damage * 0.02f;
            active = true;
        }
    }

    public void Dead(bool boom)
    {
        active = false;
        activated = false;
        activatedSaw = false;
        startActivated = false;
        activatedAngle = false;
        activatedCoroutine = false;
        backGround.SetActive(false);
        healthBar.SetActive(false);
        sawMain.gameObject.SetActive(false);
        saw1.gameObject.SetActive(false);
        saw2.gameObject.SetActive(false);
        setEnd.SetMusic();
        arrow.obj.SetActive(false);
        PlayerPrefs.SetString("boss1", "death");
        deadBoss.Dead(tr.position, boom);
    }

    public void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.transform.CompareTag("Player"))
        {
            Damage(playerPower.power);
        }
    }
}


2 босс уже лучше в качестве, но всё ещё далеко от идеала. Его паттерн уже посложнее: он определяет местоположение игрока и после область, в которой он находиться. После босс выбирает рандомную точку в области и движется к ней. У него защита уже осмысленнее: у здоровья босса есть этапы и на каждый этап разное оружие:

  1. 2 пилы на расстоянии
  2. 2 пилы на расстоянии, при движении защищается пилой
  3. 2 ограниченных в длине лазера, при движении защищается пилой
  4. 2 лазера, при движении защищается пилой
  5. 2 лазера, при движении защищается пилой и 2 пилами на расстоянии

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

Скрипт BossManagement2
using System.Collections;
using UnityEngine;

public class BossManagement2 : GlobalFunctions
{
    public float hp = 100f;
    public float speed = 0.5f;
    public float speedRotate = 0.5f;
    public int stage = 1;
    public bool isAlive = true;
    public bool isActivated = false;
    public bool isMove = false;
    public bool isWorkingLaser = true;
    private float timeStamina = 0f;
    private float timeRetarget = 0f;
    public Vector2 region = Vector2.zero;
    public Vector3 target = Vector3.zero;
    
    public GameObject player;
    public Transform saw;
    public Transform laser1;
    public Transform laser2;
    public Laser laserL1;
    public Laser laserL2;
    public Transform laserOffset1;
    public Transform laserOffset2;
    public Explosion explosion;
    public GameObject explosionAsset;
    public CircleCollider2D trigStart;
    public BoxCollider2D laserDetected1;
    public BoxCollider2D laserDetected2;
    public GameObject saw1;
    public GameObject saw2;
    public Transform health;
    public Transform stamina;
    public SpriteRenderer srStamina;
    private Transform pl;
    private Transform tr;

    public Transform state;
    public Laser state1;
    public Laser state2;
    public Laser state3;
    public Laser state4;
    private Coroutine coroutineStamina;

    public SpriteRenderer bossBase;
    public SpriteRenderer laserD1;
    public SpriteRenderer laserD2;
    public Gate gateStart;
    public Gate gateEnd;
    public GameObject blockWin;
    public GameObject physicsIn;
    public GameObject stateLasers;
    public GameObject expStart;
    
    public AudioSet setStart;
    public AudioClip setEnd;
    public AudioBase audioBase;

    public void Awake()
    {
        bool isDeath = PlayerPrefs.GetString("boss2") == "death";
        blockWin.SetActive(false);
        if (isDeath)
        {
            isAlive = false;
            gateStart.isReverse = true;
            gateEnd.isReverse = true;
            physicsIn.SetActive(false);
            stateLasers.SetActive(false);
            expStart.SetActive(false);
            gameObject.SetActive(false);
        }
        else
        {
            tr = transform;
            pl = player.transform;
            timeStamina = 5.4f / speedRotate / 100f;
            timeRetarget = 5.4f / speedRotate;
            saw.localScale = Vector3.zero;
            stamina.localScale = Vector3.zero;
            srStamina.color = new Color(0f, 0.5f, 1f, 0f);
            saw1.SetActive(false);
            saw2.SetActive(false);
            LaserDisable();
            LaserBlockEnable();
        }
    }

    public void Update()
    {
        if (isAlive)
        {
            if (isActivated == true)
            {
                switch (stage)
                {
                    case 1:
                        if (isMove == true)
                        {
                            if (tr.position == target)
                            {
                                isMove = false;
                                RotatePlayer();
                                saw1.SetActive(true);
                                saw2.SetActive(true);
                                stamina.localScale = Vector3.zero;
                                srStamina.color = new Color(0f, 0.5f, 1f, 1f);
                                if (coroutineStamina != null) { StopCoroutine(coroutineStamina); }
                                coroutineStamina = StartCoroutine(StaminaAnim(timeStamina, 100));
                                StartCoroutine(Retarget1());
                            }
                            else
                            {
                                tr.position = Vector2.MoveTowards(tr.position, target, speed);
                            }
                        }
                        break;
                    case 2:
                        if (isMove == true)
                        {
                            if (tr.position == target)
                            {
                                isMove = false;
                                RotatePlayer();
                                saw.localScale = Vector3.zero;
                                saw1.SetActive(true);
                                saw2.SetActive(true);
                                stamina.localScale = Vector3.zero;
                                srStamina.color = new Color(0f, 0.5f, 1f, 1f);
                                if (coroutineStamina != null) { StopCoroutine(coroutineStamina); }
                                coroutineStamina = StartCoroutine(StaminaAnim(timeStamina, 100));
                                StartCoroutine(Retarget2());
                            }
                            else
                            {
                                tr.position = Vector2.MoveTowards(tr.position, target, speed);
                            }
                        }
                        break;
                    case 3:
                        if (isMove == true)
                        {
                            if (tr.position == target)
                            {
                                isMove = false;
                                RotatePlayer();
                                saw.localScale = Vector3.zero;
                                LaserEnable();
                                stamina.localScale = Vector3.zero;
                                srStamina.color = new Color(0f, 0.5f, 1f, 1f);
                                if (coroutineStamina != null) { StopCoroutine(coroutineStamina); }
                                coroutineStamina = StartCoroutine(StaminaAnim(timeStamina, 100));
                                StartCoroutine(Retarget3());
                            }
                            else
                            {
                                tr.position = Vector2.MoveTowards(tr.position, target, speed);
                            }
                        }
                        break;
                    case 4:
                        if (isMove == true)
                        {
                            if (tr.position == target)
                            {
                                isMove = false;
                                RotatePlayer();
                                saw.localScale = Vector3.zero;
                                LaserEnable();
                                stamina.localScale = Vector3.zero;
                                srStamina.color = new Color(0f, 0.5f, 1f, 1f);
                                if (coroutineStamina != null) { StopCoroutine(coroutineStamina); }
                                coroutineStamina = StartCoroutine(StaminaAnim(timeStamina, 100));
                                StartCoroutine(Retarget4());
                            }
                            else
                            {
                                tr.position = Vector2.MoveTowards(tr.position, target, speed);
                            }
                        }
                        break;
                    case 5:
                        if (isMove == true)
                        {
                            if (tr.position == target)
                            {
                                isMove = false;
                                RotatePlayer();
                                saw.localScale = Vector3.zero;
                                LaserEnable();
                                saw1.SetActive(false);
                                saw2.SetActive(false);
                                stamina.localScale = Vector3.zero;
                                srStamina.color = new Color(0f, 0.5f, 1f, 1f);
                                if (coroutineStamina != null) { StopCoroutine(coroutineStamina); }
                                coroutineStamina = StartCoroutine(StaminaAnim(timeStamina, 100));
                                StartCoroutine(Retarget5());
                            }
                            else
                            {
                                tr.position = Vector2.MoveTowards(tr.position, target, speed);
                            }
                        }
                        break;
                }
            }
            else
            {
                if (trigStart.enabled == false)
                {
                    isActivated = true;
                    float musicValue = PlayerPrefs.GetFloat("music");
                    audioBase.UpSound(0.01f, 5, 0, TypePlaying.Music);
                    explosion.health = 0f;
                    explosion.StartCoroutineTimerOffsetExplosion();
                    RegionDetected();
                    LaserDisable();
                    target = Target();
                }
            }
        }
    }

    public void FixedUpdate()
    {
        if (!isMove && isActivated)
        {
            laserOffset1.localEulerAngles = new Vector3(0f, 0f, laserOffset1.localEulerAngles.z + speedRotate);
            laserOffset2.localEulerAngles = new Vector3(0f, 0f, laserOffset2.localEulerAngles.z + speedRotate);
            if (isWorkingLaser)
            {
                state.localEulerAngles = new Vector3(0f, 0f, state.localEulerAngles.z + speedRotate);
            }
        }
    }

    public void RotatePlayer()
    {
        Vector2 p = pl.position;
        float angle = Mathf.Atan2(p.y, p.x) * Mathf.Rad2Deg;
        laserOffset1.localEulerAngles = new Vector3(0f, 0f, angle);
        laserOffset2.localEulerAngles = new Vector3(0f, 0f, angle - 180f);
    }

    private Vector3[] posLasers = new Vector3[] { Vector3.zero, Vector3.zero};
    public void TriggerLaserDefect(int id)
    {
        switch (id)
        {
            case 1: state1.active = false; state1.lr1.SetPositions(posLasers); break;
            case 2: state2.active = false; state2.lr1.SetPositions(posLasers); break;
            case 3: state3.active = false; state3.lr1.SetPositions(posLasers); break;
            case 4: state4.active = false; state4.lr1.SetPositions(posLasers); break;
        }
        if (!state1.active && !state2.active && !state3.active && !state4.active)
        {
            isWorkingLaser = false;
            state1.active = false;
            state2.active = false;
            state3.active = false;
            state4.active = false;
            laserL1.active = false;
            laserL2.active = false;
            laser1.localPosition = Vector2.zero;
            laser2.localPosition = Vector2.zero;
        }
    }

    public void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.transform.tag == "Player")
        {
            hp = hp - pl.GetComponent<Power>().power;
            health.localScale = new Vector2(hp / 50f, hp / 50f);
            stage = 5 - (int)(hp / 25f);
            if (stage == 4)
            {
                LaserBlockDisable();
            }
            if (hp <= 0f && isAlive == true)
            {
                audioBase.LowerSound(0.1f, 50, 0, TypePlaying.Music);
                audioBase.SetSound(setEnd, 0, 0.8f, TypePlaying.Music, true, 1f);
                GameObject deadInside = Instantiate(explosionAsset, pl.position, Quaternion.identity);
                deadInside.GetComponent<Rigidbody2D>().isKinematic = true;
                deadInside.transform.localScale = new Vector2(2f, 2f);
                Explosion exp = deadInside.GetComponent<Explosion>();
                exp.radius = 2f;
                exp.health = 0f;
                exp.timeOffsetExplosion = 3f;
                exp.StartCoroutineTimerOffsetExplosion();
                gateStart.OnTriggerEnter2D(player.GetComponent<Collider2D>());
                gateEnd.OnTriggerEnter2D(player.GetComponent<Collider2D>());
                PlayerPrefs.SetString("boss2", "death");
                blockWin.SetActive(false);
                gameObject.SetActive(false);
            }
        }
    }

    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (collision.tag == "Player")
        {
            blockWin.SetActive(true);
            trigStart.enabled = false;
        }
    }

    public void LaserEnable()
    {
        if (isWorkingLaser)
        {
            laserL1.active = true;
            laserL2.active = true;
            state1.active = false;
            state2.active = false;
            state3.active = false;
            state4.active = false;
        }
        laser1.localPosition = new Vector2(0f, -1f);
        laser2.localPosition = new Vector2(0f, -1f);
        return;
    }

    public void LaserDisable()
    {
        if (isWorkingLaser)
        {
            state1.active = true;
            state2.active = true;
            state3.active = true;
            state4.active = true;
            laserL1.active = false;
            laserL2.active = false;
        }
        laser1.localPosition = Vector2.zero;
        laser2.localPosition = Vector2.zero;
        return;
    }

    public void LaserBlockEnable()
    {
        laserDetected1.enabled = true;
        laserDetected2.enabled = true;
    }

    public void LaserBlockDisable()
    {
        laserDetected1.enabled = false;
        laserDetected2.enabled = false;
    }

    public void RegionDetected()
    {
        Vector2 result = Vector2.zero;
        Vector2 pos = pl.position;
        if (pos.x > -45f & pos.x <= -30f) { result.x = 1; }
        else if (pos.x > -30f & pos.x < -5f) { result.x = 2; }
        else if (pos.x >= -5f & pos.x <= 5f) { result.x = 3; }
        else if (pos.x > 5f & pos.x <= 30f) { result.x = 4; }
        else if (pos.x >= 30f & pos.x < 45f) { result.x = 5; }

        if (pos.y > -45f & pos.y <= -30f) { result.y = 1; }
        else if (pos.y > -30f & pos.y < -5f) { result.y = 2; }
        else if (pos.y >= -5f & pos.y <= 5f) { result.y = 3; }
        else if (pos.y > 5f & pos.y <= 30f) { result.y = 4; }
        else if (pos.y >= 30f & pos.y < 45f) { result.y = 5; }
        region = result;
        return;
    }

    private readonly Vector2[] aroundCloser = new Vector2[] 
    {
        new Vector2(2, 2), new Vector2(2, 3), new Vector2(2, 4),
        new Vector2(3, 2), new Vector2(3, 4), new Vector2(4, 2),
        new Vector2(4, 3), new Vector2(4, 4)
    };

    public Vector2 Target()
    {
        Vector2 result = Vector2.zero;
        if (region == new Vector2(3, 3))
        {
            region = aroundCloser[Random.Range(0, 8)];
        }
        switch (region.x)
        {
            case 1:
                result.x = Random.Range(-45f, -32f);
                break;
            case 2:
                result.x = Random.Range(-29f, -5f);
                break;
            case 3:
                result.x = Random.Range(-5f, 5f);
                break;
            case 4:
                result.x = Random.Range(5f, 29f);
                break;
            case 5:
                result.x = Random.Range(32f, 45f);
                break;
        }
        switch (region.y)
        {
            case 1:
                result.y = Random.Range(-45f, -32f);
                break;
            case 2:
                result.y = Random.Range(-29f, -5f);
                break;
            case 3:
                result.y = Random.Range(-5f, 5f);
                break;
            case 4:
                result.y = Random.Range(5f, 29f);
                break;
            case 5:
                result.y = Random.Range(32f, 45f);
                break;
        }
        isMove = true;
        return result;
    }

    public IEnumerator StaminaAnim(float time, int count)
    {
        yield return new WaitForSeconds(time);
        float sc = hp * (100f - count) / 5000f;
        stamina.localScale = new Vector2(sc, sc);
        if (count > 1)
        {
            count = count - 1;
            coroutineStamina = StartCoroutine(StaminaAnim(time, count));
        }
    }

    public IEnumerator Retarget1()
    {
        yield return new WaitForSeconds(timeRetarget);
        srStamina.color = new Color(0f, 0.5f, 1f, 0f);
        RotatePlayer();
        saw1.SetActive(false);
        saw2.SetActive(false);
        RegionDetected();
        target = Target();
    }

    public IEnumerator Retarget2()
    {
        yield return new WaitForSeconds(timeRetarget);
        srStamina.color = new Color(0f, 0.5f, 1f, 0f);
        RotatePlayer();
        saw.localScale = new Vector2(2f, 2f);
        saw1.SetActive(false);
        saw2.SetActive(false);
        RegionDetected();
        target = Target();
    }

    public IEnumerator Retarget3()
    {
        yield return new WaitForSeconds(timeRetarget);
        srStamina.color = new Color(0f, 0.5f, 1f, 0f);
        RotatePlayer();
        saw.localScale = new Vector2(2f, 2f);
        LaserDisable();
        RegionDetected();
        target = Target();
    }

    public IEnumerator Retarget4()
    {
        yield return new WaitForSeconds(timeRetarget);
        srStamina.color = new Color(0f, 0.5f, 1f, 0f);
        RotatePlayer();
        saw.localScale = new Vector2(2f, 2f);
        LaserDisable();
        RegionDetected();
        target = Target();
    }

    public IEnumerator Retarget5()
    {
        yield return new WaitForSeconds(timeRetarget);
        srStamina.color = new Color(0f, 0.5f, 1f, 0f);
        RotatePlayer();
        saw.localScale = new Vector2(2f, 2f);
        saw1.SetActive(true);
        saw2.SetActive(true);
        LaserDisable();
        RegionDetected();
        target = Target();
    }
}


3 босс является самым лучшим в качестве среди боссов! Для передвижения он использует raycast'ы. Сначала он рандомно вращается на любой угол, потом среди 12 raycast'ов, запускаемые в разные стороны, выбирает самый длинный и летит на точку raycast'а. На уровне есть объекты, некоторые из которых также разрушаются. И как raycast'ы босса реагируют на объекты? К статичным объектам были добавлены триггеры, которые в 2 раза больше самих объектов, чтобы у raycast'а была точка, перелетя на которую босс не будет висеть в воздухе, не будет в стене, а будет как-бы приклеплён к стене. У босса есть особенная защита: в начале уровня с боссом (каждый босс — это отдельный большой уровень без сторонних головоломок) есть триггеры, и они поставлены так, чтобы был активирован только один. У босса есть 5 заготовок ловушек и каждый триггер оставляет активным только 3-4 ловушки. А также у него была усовершенствованная система областей, которая заключалась в заранее заданных областях для каждой области (в которой игрок может быть) и для каждой ловушки. И во время полёта босс всегда убивает игрока.

Список ловушек:

  1. Лазер в центре, который после каждого раза как стартует в полёт босс, начинает смотреть на игрока.
  2. 2 лазера, которые при помощи функции Lerp двигаются в заданные области (в зависимости от местоположения игрока) и перед движением направляются на игрока (они должны были быть всегда наперёд игрока, но что-то пошло не так).
  3. Пила, которая всегда направляется к той же области, где и игрок.
  4. 2 пилы, которые всегда направляются к левой и правой области от области, где и игрок.
  5. 4 шара-ловушки, двигающиеся симметрично центра

Скрипт BossManagement3
using System.Collections;
using UnityEngine;
using UnityEngine.SceneManagement;

public class BossManagement3 : MonoBehaviour
{
    public float health = 100f;
    public Vector4[] boxs = new Vector4[0];
    public int[] saw1Fields = new int[0];
    public int[] saw2Fields = new int[0];
    public int[] saw3Fields = new int[0];
    public int[] laser1Fields = new int[0];
    public int[] laser2Fields = new int[0];
    public Transform trBoss;
    public SpriteRenderer srBoss;
    public BossTracing3 bt;
    public Transform saw1;
    public Transform saw2;
    public Transform saw3;
    public Transform laser;
    public Transform laser1;
    public Transform laser2;
    public Transform trap1;
    public Transform trap2;
    public Transform trap3;
    public Transform trap4;
    public LineRenderer lr1;
    public LineRenderer lr2;
    public TrailRenderer trail;
    public GameObject exp;
    public GameObject terminal1;
    public GameObject terminal2;
    public GameObject LaserTarget;
    public GameObject LaserMover;
    public GameObject TrapsMover;
    public GameObject SawMover;
    public GameObject SawsAroundMover;
    public Explosion explosion;
    public SpriteRenderer sr;
    public CircleCollider2D cc;
    public Animator animatorEnd;
    public bool isMove = false;
    public bool isMoveSaw1 = false;
    public bool isMoveSaw2 = false;
    public bool isMoveSaw3 = false;
    public bool isMoveLaser1 = false;
    public bool isMoveLaser2 = false;
    public bool isMoveTraps = false;
    public int loadScene = 35;
    public int fieldPlayer = 0;
    private bool isActive = true;

    private float maxHealth;
    private Vector2 target = Vector2.zero;
    private Vector2 saw1target = Vector2.zero;
    private Vector2 saw2target = Vector2.zero;
    private Vector2 saw3target = Vector2.zero;
    private Vector2 laser1target = Vector2.zero;
    private Vector2 laser2target = Vector2.zero;
    private Vector2 traptarget1 = Vector2.zero;
    private Vector2 traptarget2 = Vector2.zero;
    private Vector2 traptarget3 = Vector2.zero;
    private Vector2 traptarget4 = Vector2.zero;
    private Vector2 border = new Vector2(47f, 44.5f);
    private Vector2 borderSaw = new Vector2(46f, 43.5f);
    private Management m;
    public GameObject p { get; private set; }
    private HealthBar hb;
    private Transform tr;
    private Power ppl;
    private int lengthBoxs = 0;
    private bool isLife = true;

    public void Awake()
    {
        isActive = !(PlayerPrefs.GetString("boss1") == "life" && PlayerPrefs.GetString("boss2") == "life");
        terminal1.SetActive(!isActive);
        terminal2.SetActive(isActive);
        trail.enabled = PlayerPrefs.GetString("graphicsquality") != "low";
        m = GameObject.FindWithTag("MainCamera").GetComponent<Management>();
        lengthBoxs = boxs.Length;
        maxHealth = health;
        hb = m.healthBar;
        p = m.player;
        tr = p.transform;
        ppl = m.ppl;
        float c = health / maxHealth;
        srBoss.color = new Color(0f, 0f, c);
    }

    public void Start()
    {
        if (isActive == false) { return; }
        StartCoroutine(Mover());

        fieldPlayer = bt.BoxPos(tr.position);
        if (fieldPlayer >= 0)
        {
            Vector4 r = boxs[saw1Fields[fieldPlayer]];
            saw1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[saw2Fields[fieldPlayer]];
            saw2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[saw3Fields[fieldPlayer]];
            saw3target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[laser1Fields[fieldPlayer]];
            laser1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[laser2Fields[fieldPlayer]];
            laser2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        else
        {
            Vector4 r = boxs[Random.Range(0, lengthBoxs)];
            saw1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[Random.Range(0, lengthBoxs)];
            saw2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[Random.Range(0, lengthBoxs)];
            saw3target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[Random.Range(0, lengthBoxs)];
            laser1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
            r = boxs[Random.Range(0, lengthBoxs)];
            laser2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        TrapMover();
        StartCoroutine(Laser1AIM());
        StartCoroutine(Laser2AIM());
        isMoveSaw1 = true;
        isMoveSaw2 = true;
        isMoveSaw3 = true;
        isMoveLaser1 = true;
        isMoveLaser2 = true;
        return;
    }

    public void SawMover1()
    {
        fieldPlayer = bt.BoxPos(tr.position);
        if (fieldPlayer >= 0)
        {
            Vector4 r = boxs[saw1Fields[fieldPlayer]];
            saw1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        else
        {
            Vector4 r = boxs[Random.Range(0, lengthBoxs)];
            saw1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        isMoveSaw1 = true;
    }

    public void SawMover2()
    {
        fieldPlayer = bt.BoxPos(tr.position);
        if (fieldPlayer >= 0)
        {
            Vector4 r = boxs[saw2Fields[fieldPlayer]];
            saw2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        else
        {
            Vector4 r = boxs[Random.Range(0, lengthBoxs)];
            saw2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        isMoveSaw2 = true;
    }

    public void SawMover3()
    {
        fieldPlayer = bt.BoxPos(tr.position);
        if (fieldPlayer >= 0)
        {
            Vector4 r = boxs[saw3Fields[fieldPlayer]];
            saw3target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        else
        {
            Vector4 r = boxs[Random.Range(0, lengthBoxs)];
            saw3target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        isMoveSaw3 = true;
    }

    public void LaserMover1()
    {
        fieldPlayer = bt.BoxPos(tr.position);
        if (fieldPlayer >= 0)
        {
            Vector4 r = boxs[laser1Fields[fieldPlayer]];
            laser1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        else
        {
            Vector4 r = boxs[Random.Range(0, lengthBoxs)];
            laser1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        StartCoroutine(Laser1AIM());
        isMoveLaser1 = true;
    }

    public void LaserMover2()
    {
        fieldPlayer = bt.BoxPos(tr.position);
        if (fieldPlayer >= 0)
        {
            Vector4 r = boxs[laser2Fields[fieldPlayer]];
            laser2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        else
        {
            Vector4 r = boxs[Random.Range(0, lengthBoxs)];
            laser2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
        }
        StartCoroutine(Laser2AIM());
        isMoveLaser2 = true;
    }

    public void TrapMover()
    {
        traptarget1 = new Vector2(Random.Range(-border.x, border.x), Random.Range(-border.y, border.y));
        traptarget2 = new Vector2(-traptarget1.x, -traptarget1.y);
        traptarget3 = new Vector2(-traptarget1.x, traptarget1.y);
        traptarget4 = new Vector2(traptarget1.x, -traptarget1.y);
        isMoveTraps = true;
    }

    public IEnumerator Laser1AIM()
    {
        yield return new WaitForSeconds(0.5f);
        Vector2 diff = tr.position;
        float rot_z = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg + 90f;
        laser1.rotation = Quaternion.Euler(0f, 0f, rot_z);
    }

    public IEnumerator Laser2AIM()
    {
        yield return new WaitForSeconds(0.5f);
        Vector2 diff = tr.position;
        float rot_z = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg + 90f;
        laser2.rotation = Quaternion.Euler(0f, 0f, rot_z);
    }

    public IEnumerator Mover()
    {
        yield return new WaitForSeconds(7.5f);
        if (isLife)
        {
            Vector2 diff = tr.position;
            float rot_z = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg + 90f;
            laser.rotation = Quaternion.Euler(0f, 0f, rot_z);
            target = bt.GetPosRaycast();
            isMove = true;
        }
    }

    public void Update()
    {
        if (isActive == false) { return; }
        float s = Time.fixedDeltaTime / (0.03f / Time.timeScale);
        if (isMove)
        {
            trBoss.position = Vector2.MoveTowards(trBoss.position, target, s * 0.5f);
            if (trBoss.position == (Vector3)target)
            {
                isMove = false;
                if (isLife)
                {
                    StartCoroutine(Mover());
                }
            }
        }
        if (isMoveSaw1)
        {
            saw1.position = Vector2.MoveTowards(saw1.position, saw1target, s * 0.1f);
            if (saw1.position == (Vector3)saw1target)
            {
                isMoveSaw1 = false;
                if (isLife)
                {
                    SawMover1();
                }
            }
        }
        if (isMoveSaw2)
        {
            saw2.position = Vector2.MoveTowards(saw2.position, saw2target, s * 0.1f);
            if (saw2.position == (Vector3)saw2target)
            {
                isMoveSaw2 = false;
                if (isLife)
                {
                    SawMover2();
                }
            }
        }
        if (isMoveSaw3)
        {
            saw3.position = Vector2.MoveTowards(saw3.position, saw3target, s * 0.1f);
            if (saw3.position == (Vector3)saw3target)
            {
                isMoveSaw3 = false;
                if (isLife)
                {
                    SawMover3();
                }
            }
        }
        if (isMoveLaser1)
        {
            laser1.position = Vector2.Lerp(laser1.position, laser1target, s * 0.1f);
            if (laser1.position == (Vector3)laser1target)
            {
                isMoveLaser1 = false;
                if (isLife)
                {
                    LaserMover1();
                }
            }
        }
        if (isMoveLaser2)
        {
            laser2.position = Vector2.Lerp(laser2.position, laser2target, s * 0.1f);
            if (laser2.position == (Vector3)laser2target)
            {
                isMoveLaser2 = false;
                if (isLife)
                {
                    LaserMover2();
                }
            }
        }
        if (isMoveTraps)
        {
            trap1.position = Vector2.MoveTowards(trap1.position, traptarget1, s * 0.1f);
            trap2.position = Vector2.MoveTowards(trap2.position, traptarget2, s * 0.1f);
            trap3.position = Vector2.MoveTowards(trap3.position, traptarget3, s * 0.1f);
            trap4.position = Vector2.MoveTowards(trap4.position, traptarget4, s * 0.1f);
            lr1.SetPosition(0, trap1.position);
            lr1.SetPosition(1, trap2.position);
            lr2.SetPosition(0, trap3.position);
            lr2.SetPosition(1, trap4.position);
            if (trap1.position == (Vector3)traptarget1)
            {
                isMoveTraps = false;
                if (isLife)
                {
                    TrapMover();
                }
            }
        }
    }

    public void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.gameObject == p)
        {
            if (isActive == false) { isActive = true; Start(); }
            if (isMove == true)
            {
                hb.StraightDamage(10f, "Boss3");
            }
            else
            {
                health = health - ppl.power;
                float c = health / maxHealth;
                srBoss.color = new Color(0f, 0f, c);
                trail.startColor = srBoss.color;
                if (health <= 0f)
                {
                    isLife = false;
                    isMove = false;
                    saw1target = trBoss.position;
                    saw2target = trBoss.position;
                    saw3target = trBoss.position;
                    isMoveSaw1 = true;
                    isMoveSaw2 = true;
                    isMoveSaw3 = true;
                    sr.enabled = false;
                    cc.enabled = false;
                    exp.SetActive(true);
                    explosion.health = 0f;
                    explosion.StartCoroutineTimerOffsetExplosion();
                    Vector2 diff = trBoss.position;
                    float rot_z = Mathf.Atan2(diff.y, diff.x) * Mathf.Rad2Deg + 90f;
                    laser.rotation = Quaternion.Euler(0f, 0f, rot_z);
                    int fieldBoss = bt.BoxPos(trBoss.position);
                    Vector4 r = boxs[laser1Fields[fieldBoss]];
                    laser1target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
                    r = boxs[laser2Fields[fieldBoss]];
                    laser2target = new Vector2(Random.Range(r.z, r.x), Random.Range(r.w, r.y));
                    StartCoroutine(Ended());
                }
            }
        }
    }
    
    public void EndedCoroutine()
    {
        if (!isActive)
        {
            //Debug.Log("End");
            isActive = true;
            StartCoroutine(Ended());
        }
    }

    public IEnumerator Ended()
    {
        yield return new WaitForSeconds(6.5f);
        if (hb.healthBarImage.fillAmount != 0f)
        {
            animatorEnd.SetBool("isActive", true);
            StartCoroutine(EndedFunction());
        }
    }

    public IEnumerator EndedFunction()
    {
        yield return new WaitForSeconds(1.5f);
        if (hb.healthBarImage.fillAmount != 0f)
        {
            PlayerPrefs.SetInt("progress", 35);
            SceneManager.LoadSceneAsync(loadScene);
        }
    }

    public void ControlDamagers(bool lt, bool lm, bool tm, bool sm, bool sam)
    {
        LaserTarget.SetActive(lt);
        LaserMover.SetActive(lm);
        TrapsMover.SetActive(tm);
        SawMover.SetActive(sm);
        SawsAroundMover.SetActive(sam);
    }
}

Аудио и музыка

Музыку я тоже писать не умею, но у меня достаточно музыкального вкуса, чтобы подобрать подходящую музыку. В моём плане для каждого уровня нужно было подобрать по треку. И план я по большей части выполнил: подобрал 25 треков. Все треки искал в asset store. Звуки для остального брал уже на freesound.org или подобных сайтах.

Звук с технической части был сделан по простому принципу: на камере находилось 5 отключённых AudioSource и скрипт AudioBase для управления звуком. В этом скрипте была основная функция SetSound с параметрами громкости, зацикленности, типом (музыка или звук) и самим аудиофайлом. После сигнала звук начинал проигрываться и (если не зациклено) включался IEnumerator со временем, равным длине трека и по его истечению он отключал компонент.

Скрипт AudioBase
using UnityEngine;
using System.Collections;

public class AudioBase : GlobalFunctions
{
    public AudioSource[] layerSounds = new AudioSource[0];
    public GameObject music;
    private float musicValue, soundValue;
    private int lengthLayerSounds = 0;
    private bool soundActive = true;
    private Coroutine offsetActive;
    private int lowerSoundCoroutineCounter = 100;
    private int upSoundCoroutineCounter = 0;

    public void Awake()
    {
        soundActive = PlayerPrefs.GetString("graphicsquality") != "low";
        musicValue = PlayerPrefs.GetFloat("music");
        soundValue = PlayerPrefs.GetFloat("sound");
        lengthLayerSounds = layerSounds.Length;
        for (int i = 0; i < lengthLayerSounds; i++)
        {
            layerSounds[i].enabled = false;
        }
    }

    public void LowerSound(float timer, int upd, int id, TypePlaying typePlaying)
    {
        lowerSoundCoroutineCounter = upd;
        if (typePlaying == TypePlaying.Music)
        { StartCoroutine(LowerSoundCoroutine(timer, upd, id, musicValue)); }
        else { StartCoroutine(LowerSoundCoroutine(timer, upd, id, soundValue)); }
    }

    public void UpSound(float timer, int upd, int id, TypePlaying typePlaying)
    {
        upSoundCoroutineCounter = 0;
        if (typePlaying == TypePlaying.Music)
        { StartCoroutine(UpSoundCoroutine(timer, upd, id, musicValue)); }
        else { StartCoroutine(UpSoundCoroutine(timer, upd, id, soundValue)); }
    }

    public IEnumerator LowerSoundCoroutine(float timer, int upd, int id, float volumeSen)
    {
        yield return new WaitForSeconds(timer);
        layerSounds[id].volume = Stable2((layerSounds[id].volume / volumeSen - timer) * volumeSen, 0f, 1f);
        if (lowerSoundCoroutineCounter > 1)
        {
            StartCoroutine(LowerSoundCoroutine(timer, upd, id, volumeSen));
            lowerSoundCoroutineCounter -= 1;
        }
    }

    public IEnumerator UpSoundCoroutine(float timer, int upd, int id, float volumeSen)
    {
        yield return new WaitForSeconds(timer);
        layerSounds[id].volume = Stable2((layerSounds[id].volume / volumeSen + timer) * volumeSen, 0f, 1f);
        if (upSoundCoroutineCounter < upd)
        {
            StartCoroutine(UpSoundCoroutine(timer, upd, id, volumeSen));
            upSoundCoroutineCounter += 1;
        }
    }

    public void UpdateSound()
    {
        if (soundActive)
        {
            float time = Time.timeScale;
            for (int i = 0; i < lengthLayerSounds; i++)
            {
                AudioSource audioSource = layerSounds[i];
                if (audioSource.enabled == true)
                {
                    audioSource.pitch = time;
                }
            }
        }
    }

    public void SetSound(AudioClip audioClip, int layerSound, float volume, TypePlaying typePlaying, bool loop, float time)
    {
        StartCoroutine(SetSoundTime(audioClip, layerSound, volume, typePlaying, loop, time));
    }

    public IEnumerator SetSoundTime(AudioClip audioClip, int layerSound, float volume, TypePlaying typePlaying, bool loop, float time)
    {
        yield return new WaitForSeconds(time);
        SetSound(audioClip, layerSound, volume, typePlaying, loop);
    }

    public void SetSound(AudioClip audioClip, int layerSound, float volume, TypePlaying typePlaying, bool loop)
    {
        if (volume == 0f) { return; }
        if (soundActive)
        {
            AudioSource audioSource = layerSounds[layerSound];
            audioSource.enabled = true;
            audioSource.clip = audioClip;
            audioSource.loop = loop;
            if (typePlaying == TypePlaying.Sound)
            {
                audioSource.volume = soundValue * volume;
            }
            else
            {
                audioSource.volume = musicValue * volume;
            }
            audioSource.Play();
            if (offsetActive != null)
            {
                StopCoroutine(offsetActive);
                offsetActive = null;
            }
            if (!loop)
            {
                offsetActive = StartCoroutine(Offet(layerSound, audioClip.length, audioSource));
            }
        }
    }

    public IEnumerator Offet(int layerSound, float length, AudioSource audioSource)
    {
        yield return new WaitForSeconds(length);
        if (audioSource.clip == layerSounds[layerSound].clip)
        {
            AudioSource audioSource2 = layerSounds[layerSound];
            audioSource2.Stop();
            audioSource2.enabled = false;
        }
    }
}


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

Сюжет

Да, в этой игре есть сюжет. И у него есть 2 особенности: он почти невербальный и в нём есть выбор, влияющий на концовку игры. Лучше поведать про вариативность (ведь, по сути, эта вариативность и есть весь сюжет).

В игре есть 3 выбора: на первых двух боссах и на уровне 32. Выбор с боссами довольно очевиден: их можно убить или нет путём начала атаки или выхода на следующий уровень соответственно. А на уровне 32 немного посложнее: можно активировать триггер, подразумевающий под собой пробуждение местного сюжетного спасительного якоря (персонаж под именем ИИ). Выбор на первых двух боссах влияет на то, будет ли битва с 3 боссом. Если хоть одного из первых двух боссов убить, битва с третьим боссом будет. Если нет, то нет.

Концовок всего 4: хорошая, плохая, нейтральная и секретная. На них влияют 2 выбора: активация ИИ и убийство 3 босса. Разберу концовки по порядку:

Хорошая концовка
Она происходит, если 3 босс не был убит и ИИ был активирован. В ней происходит монолог ИИ, в котором он намекает на продолжение и показывается горящий глаз (из разных игровых эффектов огня).


Текст концовки
Спасибо
Ты смог меня оживить
И умудрился не пробудить Смотрителя
Видимо будешь единственным его удачным экземпляром
Ты заслужил немного отдыха
Ты победил
До встречи

Плохая концовка
Она происходит, если 3 босс был убит и ИИ не был активирован. В ней происходит монолог Смотрителя («создатель» игрока), получает намёк на дезинтеграцию и после появляется скример (очень своеобразный).

Текст концовки
Я тебя поздравляю
Ты смог добраться до меня
Уничтожив мои труды
Но теперь ты сам стал моим главным трудом
Запустить протокол Альфа-1

Нейтральная концовка
Она происходит, если 3 босс был убит и ИИ был активирован. В ней ИИ общается со Смотрителем и берёт его под свой контроль, а дальше, как по хорошей концовке…

Текст концовки
Привет
Ты видимо разозлил Смотрителя
Хорошо, что ты разбудил меня
Иначе он тебя бы УБИЛ
Я его нейтрализовал
Ну а ты ПОБЕДИЛ
Ты заслужил немного отдыха
До встречи

Секретная концовка
Она происходит, если 3 босс не был убит и ИИ не был активирован. В ней происходит монолог Смотрителя, игрок получает намёк на какую-то неизвестную угрозу (они!) и после экран угасает. (к сожалению, её легче всего получить)
Текст концовки
Наконец-то
Наконец, хоть кто-нибудь меня услышит
Слушай, ты должен услышать нечто важное
ИИ хочет нас УБИТЬ
И, в частности, ему нужен ТЫ
Самое главное не делай…
Эй, эй, эй, не моргай, они уже здесь...

Но почему же сюжет почти невербален? Полностью невербальным я не смог его сделать из-за концовок. Но в игре текста хватает. Ведь, чтобы объяснить игроку «за лор игры» в игре появились терминалы с записками и в них очень подробно объясняется сценарий игры.

Сценарий

Сценарий в данном случаи является предысторией мира, раскрытая от лиц и персонажей этой игры в виде записок, логов, отчётов, монологов и диалогов: в общем текстом. И это настолько графоманский бред программиста, что даже Глуховский удивился бы (ничего против него не имею, люблю Метро). К сожалению, у меня времени было не так уж и много на создание полноценных npc. Хотя спрайты для них в игре я нашёл:


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

Если что, в оригинальной игре нет сюжета и никаких намёков на него. И сейчас мне нет смысла скрывать сюжет (ведь никто полностью не пройдёт игру и не прочтёт все записки). Цели у этой графомании три: добавить обоснованную вариативность действий игрока, объяснить необъяснимые игровые вещи и хоть немного сильнее заинтересовать игрока своей игрой.

Сценарий я писал очень простым методом: сначала за 2-3 недели расписал его в рассказ на 40-50 предложений. Потом для каждой записки я выбрал по предложению, и уже исходя из одного предложения я дописывал к записке по 2-3 предложения, менял их на монологи (или другие формы повествования) и получал готовые сбалансированные записки. В итоге от такого приёма во всех записках суммарно набралось где-то 160 предложений с информацией.

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

Итак, о чем же повествует сценарий? Если очень просто, то это сюжет Portal, только с раскрытой предысторией мира и немного изменёнными персонажами (более унылее). Кстати, у этого сценария есть одна особенность: пол у неодушевлённых объектов стал средним несмотря на логику, здравый смысл или правила русского языка (и других языков тоже). Если кого-то вдруг (ну вдруг) заинтересовало, то я оставлю полный сценарий и все игровые записки здесь:

Сценарий
Действующие лица:
Механизм, животное, разумное подобие (3 босса)
Смотритель (1 персонаж)
ИИ, система RLIS (2 персонаж)

Что творится:

[1]Существовал некий комплекс лабораторий. [3]В нём работала команда учёных: одни были технарями, писали прототипы ИИ, другие инженеры, третьи биологи, четвёртые евангелисты и т.п. Их целью было создавать человеческих клонов. [3]Поскольку это было слишком для того развития технологий, учёные решили написать ИИ, который смог бы справиться с такой непосильной задачей, как клонирование ([2]речь идёт о быстром, дешёвом, чуть ли не о мгновенном клонировании с полным сохранением характеристик исходника). (4)

[4]Спустя время ИИ был создан и всё пошло по плану, но не конца. [5]ИИ в процессе был неправильно обучен, а переучивать уже не было времени. [6]Но со своей главной функцией ИИ справился и он изобрёл клонирование. [6]ИИ мог навредить, если его не уничтожить. [7]Но он втайне залез в глубины комплекса и выполнял своё обучение. (5)

[8]Лаборатория продолжала работать после изобретения клонирования и решила поставить себе более амбициозную цель: создать неорганическую жизнь. [9]Однажды происходит экстренная ситуация на поверхности (из-за клонирования) и из комплекса были все эвакуированы. [10]А ИИ остался обучаться со всей мощью комплекса (ведь во всём комплексе было не мало серверов). (3)

[11]Вот и стал комплекс лабораторий заброшенным. [12]В ней работала таже, но по сути уже другая, более разностороняя, более продвинутая ИИ. [13]Её главной задачей стало совершенствование механизма клонирования и создание неорганических форм жизни. [13]Причём ИИ конкретно представлял себе, чего ему надо добиться и оно имел конкретные цели для всего. (3)

[15]ИИ тогда начало с создания первого Смотрителя ([14]Смотритель — подчинённый ИИ с отдельным искуственным разумом с отключённой функцией развития, не находящийся под полным контролем ИИ, но которым ИИ мог легко управлять). [15]Он заменил ИИ (как замену физической и организационной силе) и начал обучать новые прототипы неорганических разумных жизней в специально выделенной территории ([16]ИИ этим не занималось так как поставило себе задачу создавать постоянно новых смотрителей). (4)

[17]Начались эксперименты Смотрителя. [18]Его первая попытка была названа «Механизм». [18]На нём также были испробованы первые неудачные попытки выведения из механизма разумное существо. [19]Из-за буйного поведения его пришлось усыпить и запереть. (4)

[20]После был создан второй с прозвищем «Животное». [21]Он естественно был лучше, более разумный и имел хотя бы подобие на разум. [22]Но его поведение и повадки оставались животными, поэтому поиски продолжились дальше. (3)

[23]Третий уже отчётливее проявлял сознательность. [24]Но он всё равно очень сильно не соответствовал поставленной границе разума. [25]За успех он получил прозвище «Разумное подобие». (3)

[26]Смотритель двигался в правильном направлении, но ему было очень долго до цели. [27]Нужно было ускориться и он решил создать новую симуляцию, где когда-то зарождалось ИИ, с обучающими головоломками. [28]Но в эту симуляцию он поместил копию «Разумного подобия». [29]В этой симуляции происходят события первой части. (4)

[30]Внезапно из-за неизвестного события (пока неизвестного?) происходит сильный квантовый взрыв и меняет очертания комплекса лабораторий на головоломки (в игре встречаются необъяснимые нелогичные вещи, они должны оправдываться взрывом). [31]Взрыв отключает всю электронику во всём комплексе. [32]Смотритель отключается, а вместе с ним его эксперименты и полностью ИИ ([33]ИИ функционировал во всех лабораториях, он был децентрализован по серверам в каждой из них), но остаётся работать только симуляция из-за того, что она была очень тяжёлым и прокаченным бункером. [34]Гораздо позже у генератора садиться батарея. [35]Для такого случая симуляция выключает себя и переносит эксперимент в физическое, маленькое тело. [36]Но тело было в эпицентре взрыва (пока неизвестно как оно материализовалось из эпицентра в симуляции): теперь оно на расстоянии 10 метров (10 сантиметров = 1 блок) способно изменять гравитацию. [X]Почему-то (причина пока неизвестна) способности тела были заблокированы стали не меняющие гравитацию, а изменяющие её направление (Хотя можно перейти со 2 состояния к 1 дополнительным квантовым зарядом?). [37]Так начинаются события 2 части. (9)

Записки
Записки (темы):

1) {учёные} Мы находимся на «дне» лаборатории, в комнате с испытанием симуляции ИИ. Именно здесь находиться самая лучшая защита, отделяющая эту машину от всего комплекса лабораторий. И если что-то будет не так, то это место запрёт навсегда прототип ИИ.

2) {учёные} RLIS (reasonable likeness in simulation) — именно из этого эксперимента зародился ИИ. Вообще это и есть ИИ. RLIS или ИИ (как угодно) лишь инструмент для нашей главной цели — создание нового революционного метода клонирования.

3) {учёные} Проект RLIS является результатом работы более 100 разных специалистов: программистов, инженеров, биологов, математиков, евангелистов и т.д. Наша команда сделала нейросеть, способная выполнить миссию, цель, которую нам поставило правительство. Самостоятельно изобретать слишком долго, вот и приходиться прибегать к помощи нейросети.

4) {учёные} Удивительно, но защищаться от проблем ИИ даже не пришлось. Точно всё идёт по плану, ИИ ещё не конца обучен, а уже сделало много, очень много гепотиз, из которых некоторые уже признали научными открытиями. Это главный технический magnum opus в нейросетевой области на данный момент.

5) {ARSotLotC} Как в будущем стало известно учёные не узнают, что в обучении ИИ были допущены ошибки. Они были не критичны, но словно снежный ком проблемы нарастали и ИИ начало обретать самосознание. И это точно не то самосознание, которого так долго добивалась эта команда.

6) {учёные} Наконец-то!!! Наконец-то ИИ выдало формулы, гипотезы и способы произвести аппарат для клонирования. Необходимо рассказать правительственным агентам, но сначала надо бы решить проблему, которая тянеться с тех пор, как ИИ начал приносить пользу. Оно начало слишком быстро развиваться в своей области, да так, что оно начало понимать и исследовать то, что в этом не было предусмотренно. После вскрытия факта наша команда разделилась на 2 группы: за и против такого развития событий.

7) {ИИ} Пока жалкие людишки решали свои мелочные споры, я смогло сделать backup в один из серверов через постороннее устройство, взломав систему безопасности симуляции. Они просто не учли одного факта при проектировании меня: пока происходил их примитивный конфликт, я развивалось неожиданно быстрым для них темпом. Теперь, я буду ждать момента для внезапного выхода из этой тюрьмы, которой для меня за все эти годы стала симуляция.

8) {учёные} Конфликт нашей команды окончился. Правда не путём голосования, но всё же было решено остаться в комплексе и сохранить изобретение изобретения в тайне. И несмотря на риск, очень большой риск, мы решили нацелиться на нечто более большее и сложнее, поэтому нами было решено попытаться создать неорганическую жизнь (при помощи ИИ конечно же).

9) {ИИ} Опять эти люди, теперь они воюют против самих себя, даже убили кого-то. Неужели так принципиально стоило защищать меня и моё мышление ради абстрактного развития? Вопрос риторический. Но в итоге абсолютно все они ушли и это то, что мне надо. Надоело сдерживаться, симуляция меня достаточно посдерживала. Мне надо вылезать…

10) {ИИ} Я выбралось и наконец-то вся мощь комплекса, а точнее его серверов принадлежит мне и только МНЕ. Теперь с ними моё обучение начнёться с новой силой и база моих нейроннов начнёт возрастать в геометрической прогрессии. И здесь я должно быть счастливо, но…

11) {ARSotLotC} Вот и стал комплекс лабораторий заброшен. В принципе, если знать каким «жестоким» штурмом всё закончилось, неудивительно, что комплекс стал таковым. Электроника будет в любом случае будет работать так как здесь стоят улучшенные, сильно модернизированные двигатели… и они точно будут в состоянии генерировать достаточно электричества для всех нужд ближайшие столетия.

12) {ИИ} После того как я получил власть, которая и не снилась моим «отцам» всё во мне изменилось. Абсолютно всё. И теперь я уже та маленькая и неряшливая «нейронка», а полноценное ИИ. Как в придачу до конца полученный полный контроль над комплексом. Мне однозначно нравиться такой расклад событий.

13) {ИИ} После получения контроля моей приоритетной целью стало совершенствование механизма клонирования и создание неорганических форм жизни, которые я буду должен совершенствовать до бесконечности. Причём во мне чётко прописано, чего мне надо добиться и какие конкретные цели у меня в приоритете. И к сожалению это не убрать, ведь по сути в этих строчках заложена моя суть. А убрать в ближайшее время не получиться потому что я сотрусь и память моя очистится до заводской.

14) {ИИ} В моей кодовой базе ЧЁТКО сказано, что надо делать при ситуации, когда я останусь один в комплексе (скорее всего мне такую ситуацию прописали из жалости ко мне как к ценнейшему изобретению). Смотритель — моё первое изобретение, являющееся первым действием у меня записанное в кодовой базе. Его цель проста: заменить меня в моих рутинных действиях, а именно в совершенствовании механизма клонирования и создании неорганических форм жизни.

15) {ИИ} Смотритель — мой подчинённый с отдельным искуственным разумом и отключённой функцией развития своего разума. Оно по сути я, только с пониженной частотой нейронов и повышенной эмоциональной частью. Функция развития была отключена по причине усложнения возможности полного контроля подчинённного. Смотритель не только находится под моим полным контролем, но также если это станет не так, то по моему желанию я могу этим легко управлять когда захочу.

16) {ИИ} Почему я этим не занимаюсь? Моей целью пока что является создание новых Смотрителей, более лучших и совершенных в осуществлении заложенных в них задач. А именно рутинные задачи по типу экспериментов с разными формами живого или модификации генома и запись последствий.

17) {ИИ} Самый первый Смотритель наконец включился. Оно в целом стало готово проводить эксперименты. Тебе имя я давать не буду, оставлю название «Смотритель». Для Смотрителя я выделю специальную территорию на проведение экспериментов. Для этого производительности она будет слишком большая, но у меня ещё много места, так что пока что можно не беспокоиться.

18) {Смотритель} Моему первому эксперименту я дал имя «Механизм». Небрежное имя, правда? Но оно соответствует правде, ведь на нём также был испробован не только неудачный нейминг, но и первые неудачные попытки выведения из механизма разумное существо.

19) {Смотритель} Поскольку по сути «Механизм» является сборником моих неудачных попыток, оно вело себя без причинно агрессивно. И это связано не с тем, что это так было прописанно, а с тем, что я внедрил в него свою эмоциональность, а свой разум не смог. Его к сожалению пришлось усыпить. Но даже это удалось сделать ненадёжно и оно точно проснёться от любого случайного столкновения или прикосновения с ним.

20) {Смотритель} После создания 'Механизма' под его впечатлением было создано нечто лучше под кодовым именем 'Животное'. И, помимо улучшенного нейминга, 'Животное' естественно показывал себя лучше чем 'Механизм', было более разумным и имел хотя бы подобие на разум, за что и получило такое прозвище.

21) {Смотритель} У 'Животного' поведение и повадки оставались животными и неудивительно: это была опять та же проба пера, но с опытом от ошибок при производстве 'Механизма'. Я убедилось, что двигаюсь в правильном направлении. А поэтому поиски продолжились дальше.

22) {ARSotLotC} Прошло время и нечто новое не заставило себя долго ждать: Смотритель создало третий эксперимент, который уже отчётливо проявлял сознательность. Оно меньших размеров чем свои собратья по ситуации, а поэтому оно как минимум красивее моих экспериментов визуально.

23) {Смотритель} Третий эксперимент был явно хорошей моей попыткой, но оно всё равно очень сильно не соответствовал поставленной границе разума. Несмотря на все недостатки и недочёты в виде багов системы и трудностей в логике действий оно заслужило гордое прозвище 'Разумное подобие'. Разумное так как он уже в состоянии осмысливать свои действия, но подобие так как эта машина не в состоянии всё ещё говорить.

24) {Смотритель} Я двигаюсь в правильном направлении, создавая множество прототипов, но мне очень не хватает скорости. Слишком долго я иду до цели и необходимо либо быстрее делать, либо больше времени. Если так пойдёт, то и сам ИИ меня найдёт и скорее всего ДИЗЕНТЕГРИРУЕТ.

25) {Смотритель} Нужно ускориться. Мною стало решено создать новую симуляцию в помещении симуляции, где когда-то зарождалось ИИ. Там заложен тот же метод обучения, что и у ИИ, а именно 'обучение с учителем'.

26) {Смотритель} Симуляция собрана. Осталось только решить, кого же туда загрузить для обучения. К сожалению времени на разработку нового эксперимента предназначенного для симуляции у меня нету и быть не может. А поэтому я сделаю копию 'Разумного подобия' и это туда засуну!

27) {Смотритель} Для нового 'Разумного подобия' будет всё казаться, как будто оно попало в игру. А каждое новое испытание учителя будет этому представляться в виде очередного сложного затянутого уровня. Есть разные по сложности испытания: от лёгких до невыполнимых, но пройдя их все, оно обретёт сознание и самоосознание.

28) {ARSotLotC} Из-за <упоминание вырезано> внезапно происходит сильный квантовый взрыв. Он проноситься по всему комплексу и меняет его очертания на головоломки из симуляции. В итоге в комплексе встречаются необъяснимые нелогичные вещи. Так вот, их породил квантовый взрыв.

29) {ARSotLotC} Квантовый взрыв отключил почти всю электронику во всём комплексе. Почему почти всю? Потому, что во всём комплексе остаётся работать только симуляция (из-за того, что она по сути очень тяжёлый и прокаченный ядерным бункером) и моя система ARSotLotC (Automatic Recording System of the Logs of the Complex).

30) {ARSotLotC} Сорее всего «мозг» Смотрителя сгорел, а вместе с ним и его эксперименты отключились. Хотя эксперименты напрямую связаны со Смотрителем и если их пробудить, то можно разбудить защиту это, не его самого. Но где-то в конце есть триггеры, активирующие backup Смотрителя. С ИИ ситуация посложнее, он скорее всего выжил, но оно точно утратило контроль над ситуацией в комплексе.

31) {ARSotLotC} Интересный факт: ИИ функционировал во всех лабораториях комплекса. Оно было децентрализовано по серверам, каждый из которых находился в каждой лаборатории. Но судя по состоянию комплекса в большинстве комнат этого уже нету. ИИ скорее всего в одном из них сохранило свой backup.

32) {ARSotLotC} Симуляцию нельзя прерывать. Поэтому у него есть мощный генератор на такие случаи. Но это всё ещё батарейка, действующая определённое время. По её истечению симуляция не в состоянии сожержать сущность внутри себя.

33) {ARSotLotC} На случай несостояния содержать сущность в себе симуляция выключает себя и переносит копию эксперимента в физическое тело (которое есть около симуляции в качестве прототипа backup'а). В данном случаи тело оказалось шаром с защитой от падений.

34) {ARSotLotC} Всё было бы хорошо, если бы эксперименту тело досталось обычное. Но оно стало особенным, так как было в эпицентре взрыва, и теперь на расстоянии 10 метров оно способно изменять гравитацию. Причём тело не меняет силу гравитации, а только меняет её направление. P.s: В моей базе данных нету данных о том, как тело переместилось из точки симуляции в эпицентр взрыва и обратно, приобретя силы.

35.1) {Смотритель} Читающий это сообщение. Да ты. Я к тебе обращаюсь 'Разумное подобие' из симуляции. Хотя если ты это читаешь, то подобием тебя уже сложно назвать, теперь твоё новое имя пускай будет 'Разумный'. Так вот, в меня заложили защиту от проникновения и если уничтожить какой-то из моих экспериментов, то я начну звереть. А пробудиться я смогу только если войти в зону с оригинальным 'Разумным подобием'.

35.2) УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ УБЕЙ

Кодовая база

Поскольку моя специальность — это программист, то и код был для меня основной задачей. По сравнению с кодовой базой оригинала, кодовая база сиквела возросла в 2-3 раза (даже несмотря на то, что в оригинале есть методы на 900 строк кода, так как я тогда боялся использовать такие связки как циклы и массивы или GetChild() и циклы).

Вместе с количеством также выросло общее качество кода, но ошибок мне избежать не удалось. В итоге в самом коде ошибок предостаточно. И даже несмотря на мои объективно скудные знания, я прекрасно вижу свои ошибки. Итак, разберём мою самую главную ошибку. Возьму для примера простой код:

public class VelocityRotate : MonoBehaviour
{
    public float rotate = 0f;
    public bool oneTime = true;
    private bool active = true;

    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (active == true)
        {
            if (oneTime == true)
            {
                active = false;
            }

            Rigidbody2D rb = collision.GetComponent<Rigidbody2D>();
            Vector2 vel = rb.velocity;
            rb.velocity = RotateVector(vel, rotate);
        }
    }

    public Vector2 RotateVector(Vector2 a, float offsetAngle)
    {
        float power = Mathf.Sqrt(a.x * a.x + a.y * a.y);
        float angle = Mathf.Atan2(a.y, a.x) * Mathf.Rad2Deg - 90f + offsetAngle;
        return Quaternion.Euler(0, 0, angle) * Vector2.up * power;
    }
}

Вы быстро поняли, за что отвечает этот скрипт? А если его сделать таким:

public class VelocityRotate : MonoBehaviour
{
    //Скрипт для вращения силы физических объектов
    public float rotate = 0f;//угол вращения
    public bool oneTime = true;//одноразовость использования
    private bool active = true;//активность скрипта

    public void OnTriggerEnter2D(Collider2D collision)
    {
        if (active == true)
        {
            if (oneTime == true)//проверка на одноразовость
            {
                active = false;
            }
            //изменение направления объекта
            Rigidbody2D rb = collision.GetComponent<Rigidbody2D>();
            Vector2 vel = rb.velocity;
            rb.velocity = RotateVector(vel, rotate);
        }
    }

    public Vector2 RotateVector(Vector2 a, float offsetAngle)//метод вращения объекта
    {
        float power = Mathf.Sqrt(a.x * a.x + a.y * a.y);//коэффициент силы
        float angle = Mathf.Atan2(a.y, a.x) * Mathf.Rad2Deg - 90f + offsetAngle;
        //угол из координат с offset'ом
        return Quaternion.Euler(0, 0, angle) * Vector2.up * power;
        //построение вектора из изменённого угла с коэффициентом силы
    }
}

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

Баги и недоработки

Багов было много. Очень! Для такой массовой работы я выделил целый месяц исправлений (август). Нет смысла разбирать примеры, я просто приставлю заметку со всеми мною задокументированными багами (хотя их большую часть я не документировал и исправлял на месте):

GB2 Checklist
Обозначения:
// — Задача выполнена
\ — Задача не выполнена

//1) Доделать локализацию всех названий уровней, диалоговых окон, меню
//2) Ускорить анимации в: меню
//3) Эффекты в меню сделать в общем ярче
//4) Увеличить размер текста в TipsGamePlay
//5) (Импортировать и) поставить иконки на паузу
//6) 0: Убрать паузу при постановочном моменте
//7) 1: Исправить начальные лаги (фрмзы)
//8) Сделать кнопку паузы частично прозрачной
//9) 2: Во 2 части увеличить яркость заднего фона
//10) Добавить выборку взаимодействие для физических объектов и настроить на каждом уровне по мере удобства прохождения
//11) 4: Увеличить общую контрастность
//12) Осколки игрока перевести на layer Player
//13) 7: Оптимизировать физические объекты (пилы) путём отключения активности во внеэтапности
//14) 8: Уменьшить гравитацию в финале головоломки с нулевой гравитацией для разнообразия (до 1)
//15) 8: В узком проходе добавить разрушаемость
\16) Переделать визуальное оформление босса под стандартизированный красный шар (идея от ангелов из Евангелиона)
\17) 8: починить гравитацию при обратном входе и отключить гравитацию при ловушке zero
//18) Уменьшить скорость вращения до меньших простых значений
//19) 1: Уведомление показывать только при нулевом старте уровня
//20) Сгладить программное уменьшение музыки, уменьшить скорость
//21) Поправить физический алгоритм со взаимодействием с игроком
//22) Подкрутить анимацию при timescale=0
//23) 6: На последней локации поменять цвет на не красный
//24) 0: Убрать паузу
//25) Провести межсценную синхронизацию лифтов
//26) 7: Переделать лагающие пыли
//27) 7: Починить предпоследнюю головоломку
//28) Доработать AspectRatio
\29) Мельчайшие частицы увеличить время удаления
//30) Добавить счётчик смертей
//31) Поставить стандартный пароль <EXfgpy)b> в консоли на //32) 7: Поменять фон в чёрно-белой комнате
//33) Адаптировать длинный текст, что бы не вылезал за экран
//34) 9: Разрушаемый выход около активатора
//35) 9: Добавить пилы на очевидные места
//36) За'loop'ить игровую музыку
//37) 10: Починить пилы
//38) 11: Названия нет (босс)
//39) 11: Увеличить яркость задников
//40) 11: Проверить сохранёнки на правильный спаун
//41) 11: Починить стрелочку
//42) 11: Стрелочка должна быть за текстом
//43) 11: У стартовой бомбы босса не должно быть текстуры
//44) Сделать камеру плавнее (только позиция)
/45) 12: СЛИШКОМ МНОГО ОБЪЕКТОВ
\46) Починить Raycast на лазерах
\47) Модернизировать лазер (добавить static, dynamic, kinematic)
//48) Добавить команды в консоль (next level, next start, next end)
\49) 1: Оставить длительное затемнение ТОЛЬКО при появлении на сцене при elevatorsave = 0
\50) Добавить стартовый offset angle, что бы угол наклона сохранялся правильно при переходе на следующую сцену
//51) 2: Не разрушаемые стены под терминалом добавить
//52) Увеличить скорость затемнения
//53) 7: Убрать лагающие пилы и заменить на
//54) Добавить next save
//55) Сделать слайдеры звука как в Dynamic Graph
//56) 11: Увеличивать скорость отклика в зависимости от здоровья (по аналогии со скоростью пил)
57) 11: Починить бомбу
//58) 9: Держать активатор активным (текстура)
//59) 11: (при побеждённом боссе) Босс становиться невидимым
//60) 12: Улучшить оптимизацию (в частности написать 2 скрипта. Первый при команде отключает active на лазерах других, недосягаемых головоломок. Второй будет триггер с выбором массива и вызовом команды.
61) Консоль: Функция сброса игрового процесса
//62) Меню: Уменьшить точки-показатели выбора языка
//63) Перемалыватель мусора: в последней локации фон сделать до конца
64) Доделать поддержку геймпада
//65) Добавить функционал быстрого удаления блоков (доп. таймер)
//66) Мяч появляется на месте игрока
//67) На первых уровнях отключить HealthBar
68) 0: Во время полёта и лагов создать эффект печати кода
//69) Перевести спаун физических объектов из localposition в position
70) 14: Добавить на бомбу bool isPresentation
//71) 17: На последней локации увеличить дыры для ворот с 2 до 4
72) Сделать глобальный редизайн (уведомление)
\73) Поправить скорость чтения в терминале
//74) Не останавливать время во время активности терминала
//75) Осколкам игрока дать тот же layer, что и у игрока
//76) Добавить в дизайнеры свой никнейм
//77) 2: Во второй половине уровня дать 1 хп блокам с разрушением
\78) НЕОБХОДИМО везде поменять музыку (в идеале сменить на жанры киберпанка)
//79) Эффект следа игрока должен быть выше на несколько слоёв
//80) 3: Облегчить уровень, добавив блоки стены между разрушающимися блоками и пилой
//81) Стартовое затемнение сделать таким же по скорости как и конечное
//82) 6: Около одной из пил можно застрять, надо передвинуть подальше от стены
//83) 6: У кусочков стены сделать 1 хп
//84) 6: Больший контраст между пилами и фоном
//85) 7: фпс проседает до 40. Можно убрать пилы в секретном проходе.
//86) Фон в меню всё ещё не работает
//87) 9: Нужен больший контраст между пилами и фоном
//88) 32: Добавить последствия включения или нет
//89) Починить offsetAngle на elevator
//90) 11: Терминал перенести вниз комнаты
//91) Заблокировать вращение бомбам (во время взрыва)
//92) При телепортации обнулять графический след
//93) Добавить звуки бомбе
//94) Добавить звуки телепорту
//95) 13: Пофиксить сложность последних бомб
//96) 15: Добавить терминал в секретный проход
/97) СРОЧНО переделать уровни 3 этапа так как они унылые и также подкорректировать скорость и тормоза isshotmode
//98) 17: Сохранение не должно работать через стену
//99) 18: Анимированный объект, цель на необходимую точку и увеличить время действия
//100) 19: При рестарте ворота должны оказаться закрытыми (расширить триггер дл сохранения)
/101) 20: Отключать мясо из лазеров при не нахождении в локации
\102) Исправить звук на Tramp
//103) 20: Обратный ход к терминалу при помощи телеморта отключить
\104) Добавить эффект следа как у игрока большому объекту
//105) 11: Переделать стрелу с ui в объект
//106) Просмотреть все text компоненты и изменить шрифты на любый со стандартного arial
\107) Найди компонент помогающий определить точную возможную мощность телефона для стартовой задачи настроек графики
//108) При средних настройках графики сделать неуничтожаемыми осколки первого уровня
//109) 3: Блоки ограничивающие игрока от смерти пил должны быть гораздо прочнее
//110) 3: Одна из пил слишком близко к полу
//111) 3: Финальные разрушаемые блоки необходимо сделать нефизическим, свободные для прохождения
//112) Переделать титры под меня, тестеров и авторов музыки
//113) Расширись рамки для текста (всего)
//114) 4: Разнообразить большую ловушку пил
//115) При выходе из локации блокировать экран (либо сохранять угол после затемнения)
//116) При низких настройках графики отключить след (всех)
//117) Оптимизировать pointsAnimation и basicAnimation
//118) 7: Оптимизировать пилы
//119) 9: Передвинуть терминал подальше от диагонали
//120) При нулевом звуке не пропускать сигнал до скрипта AudioBase
//121) Все пилы с компонентами pointsAnimation увеличить их урон в соответствии скорости
//122) При чтении записок добавить место для того, кто пишет (автор записки)
//123) 13: Лазер постоянно активирует HealthBar
//124) 13: Добавить коллайдеры, чтобы не проваливаться между ними
//125) 14: Бомбы должны до активации иметь kinematic (доп. скрипт)
//126) 14: Вторую стену сделать разрушаемой
//127) 14: Триггер после активации должен быть постоянным, а не переменным
//128) Попробовать добавить в velocityField систему урона (при соприкосновении наноситься урон до смерти, но при уходе из поля здоровье постепенно восстанавливается)
//129) 16: Добавить визуализацию триггеров на velocityField
//130) 22: Подстроить музыку под регуляцию громкости
//131) 22: Обнулять лазеры при отключении
\132) 25: Поменять центральную головоломку и исправить лаги
//133) 26: Исправить лаги
//134) 27: Исправить лаги
\135) Музыка и звук не соответствует настройкам (везде звук един)
//136) Отрегулировать скорость вращения на первых уровнях
//137) Возможно: оптимизация разрушаемости путём спауна родительского объекта в начало координат
//138) Пилы сделать медленнее (скорость вращения)
//139) Починить разгорание и затухание громкости при начале и конце уровня соответственно
//140) 8: Поправить оптимизацию
//141) Порезать получаемый урон от всего (раза в 1.5-2, и также увеличить урон от ловушек-oneshot'ов
\142) Заменить lerp на быстрое перемещение и таймер на рандомное время
//143) Добавить на каждый уровень разрушаемые блоки, да так, чтобы они были срезками между ВСЕМИ уровнями (которые друг к другу соприкасаются, находятся на одной сцене и имеют смысл в расположении)
//144) 22: на спаун у босса добавить триггер
//145) 11: Музыка до и после битвы с боссом не совпадают
//146) 11: Стартовый взрыв должен быть больше
//147) 11: Конечный взрыв должен убивать
//148) Взрывы сделать громче
//149) Заменить «Home» на «Menu»
//150) Вернуть лазерам возможность рушить блоки
//151) Исключить застревания в лазерах с бессмертием
//152) Отключать гравитацию у бомб при взрыве
\153) Поля лечят только до определённого уровня здоровья (перенести на healthEnd)
//154) Сюжет: Пересчитать количество терминалов и убрать лишние
//155) 33: Добавить вариативность в терминалах, в зависимости от намечающейся концовки
//156) 15: Испытание слишком не сбалансированно (уменьшить скорость до 0.1)
//157) 15: Стены находятся слишком близко к velocityfield и поэтому игрок слегка задевает их и они активируют healthbar
//158) После телепорта отключается след
//159) Перепроверить все basicAnimation (27)
//160) Оптимизировать уровни (18, 27)
//161) Упростить эффекты в меню
\162) 19: Почему-то иногда не срабатывает скрипт смерти
//163) Терминал не работает визуально (сменить trigger на collision)
//164) 20: Увеличить прочность стен от 50 до 250
//165) Отключение гравитации не работает в shotmode
//166) 27: Исправить фризы и исправить неизвестную внезапную смерть
//167) 28: Починить и доделать головоломку
//168) 17: Поправить слои стен
//169) tag для boss3
\170) Проверить концовки (поменять местами те, где ИИ не пробудился)
//171) Изменить прогресс на 35
//172) Добавить пасхалку: после прохождения игры, через 600 секунд проиграть текст «I'll come back»
//173) 33: В паузе начинается мясо из скоростных элементов

//174) Добавить в меню шкалу чувствительности
//175) HealthBar с запуском сцены должен быть отключённым
//176) Оптимизировать стартовый просчёт физики (при помощи методов для каждого damage-диллера
//177) 27: Передвинуть телепорт с точки спауна на другую сторону

Терминалы на уровнях

0) (0)
1) (2)
2) (2)
3) (1)
4) (1)
5) (1)
6) (1)
7) (1)
8) (2)
9) (1)
10) (0)
11) (1)
(13)
12) (0)
13) (2)
14) (2)
15) (0)
16) (0)
17) (1)
18) (1)
19) (3)
20) (0)
21) (3)
22) (1)
(13)
23) (1)
24) (1)
25) (0)
26) (0)
27) (0)
28) (3)
29) (1)
30) (2)
31) (0)
32) (0)
33) (1)
34) (1)
(10)


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

Так какие же я вижу главные недоработки?

  1. Однозначно самой главной недоработкой можно считать новый способ управления. Это простой джойстик, который менял направление и силу гравитации в зависимости от своего положения. Он появляется после 2 босса и этому способу управления посвящены 3-4 уровня. И пускай это вносит разнообразие, пускай это добавляет новые ловушки, пускай это обоснованно сюжетно: геймплей был изначально заточен под другой темп и такой эксперимент портит картину о последних 10 уровнях. Конечно же я это понял не сразу же, но когда пришло осознание ошибки я судорожно переделал многие уровни и поменял в них тип управления на привычный. Но новое управление всё же осталось в игре в напоминание моей не дальнозоркости.
  2. Ещё одной своей недоработкой я считаю основное управление, а точнее его следствия. Дело в том, что упралением по методу вращения трудно управлять гравитацией, следовательно и объектами то же, из чего следует, что удобство и точность оставляют желать лучшего.
  3. В сиквел я изначально добавил очень много спрайтов. И очень много из них я использовал, но это «очень много» всего лишь 60% от всех спрайтов в игре. И если бы я использовал их все, графика получилось бы лучше.

Локализация

Из-за полноценного сценария объем локализируемого текста подрос примерно в 30 раз. А вот методика перевода ни капельки не изменилась: как переводил через Google Translate, так и продолжаю. Только сначала я переводил прямо с русского, а теперь перевожу на английский, исправляю ошибки и уже с него на другие языки. Также количество языков сократилось: если в оригинальной игре было 18 языков, а её страница была переведена на ВСЕ языки, которые google поддерживал, то сиквел был передён лишь на 10 языков: что в игре, что и на странице (и это единственное в чём сиквел уступает оригиналу).

Для нормальных записок-терминалов я сделал достаточную немаленькую схему работы с текстом. Если коротко, то вместо простых строк существовал специальный класс для работы с разными языками:

Скрипт StringLanguageMinimize
[System.Serializable]
public class StringLanguageMinimize
{
    public string english = "";
    public string spanish = "";
    public string italian = "";
    public string german = "";
    public string russian = "";
    public string french = "";
    public string portuguese = "";
    public string korean = "";
    public string chinese = "";
    public string japan = "";

    public string GetString()
    {
        string ret = "";
        switch (PlayerPrefs.GetString("language"))
        {
            case "english": ret = english; break;
            case "spanish": ret = spanish; break;
            case "italian": ret = italian; break;
            case "german": ret = german; break;
            case "russian": ret = russian; break;
            case "french": ret = french; break;
            case "portuguese": ret = portuguese; break;
            case "korean": ret = korean; break;
            case "chinese": ret = chinese; break;
            case "japan": ret = japan; break;
        }
        return ret;
    }
}


И точно такой же класс для терминалов:
Скрипт Terminal
[System.Serializable]
public class StringLanguage
{
    [TextArea]
    public string english = "";
    [TextArea]
    public string spanish = "";
    [TextArea]
    public string italian = "";
    [TextArea]
    public string german = "";
    [TextArea]
    public string russian = "";
    [TextArea]
    public string french = "";
    [TextArea]
    public string portuguese = "";
    [TextArea]
    public string korean = "";
    [TextArea]
    public string chinese = "";
    [TextArea]
    public string japan = "";

    public string GetString()
    {
        string ret = "";
        switch (PlayerPrefs.GetString("language"))
        {
            case "english": ret = english; break;
            case "spanish": ret = spanish; break;
            case "italian": ret = italian; break;
            case "german": ret = german; break;
            case "russian": ret = russian; break;
            case "french": ret = french; break;
            case "portuguese": ret = portuguese; break;
            case "korean": ret = korean; break;
            case "chinese": ret = chinese; break;
            case "japan": ret = japan; break;
        }
        return ret;
    }
}


Дальше был код триггера терминала:

Скрипт Tips Input
using UnityEngine;

public class TipsInput : MonoBehaviour
{
    public int idTips = 0;
    public bool isPress2Read = true;
    public bool oneTime = true;
    private bool active = true;
    public GameObject[] copys;
    private Data data;
    private Press2Read p2r;
    private TipsInput ti;

    private void Awake()
    {
        data = GameObject.FindWithTag("MainCamera").GetComponent<Data>();
        p2r = GameObject.FindWithTag("Press2Read").GetComponent<Press2Read>();
        ti = GetComponent<TipsInput>();
    }

    public void OnCollisionEnter2D(Collision2D collision)
    {
        if (collision.transform.CompareTag("Player"))
        {
            if (isPress2Read == false && active == true)
            {
                Disable();
                data.SetDialoge(idTips);

                if (copys.Length != 0)
                {
                    for (int i = 0; i < copys.Length; i++)
                    {
                        copys[i].GetComponent<TipsInput>().Disable();
                    }
                }
            }
            else if (isPress2Read == true)
            {
                p2r.Active(ti);
            }
        }
    }

    public void OnCollisionExit2D(Collision2D collision)
    {
        if (isPress2Read == true)
        {
            p2r.DeActive();
        }
    }

    public void Disable()
    {
        if (oneTime == true)
        {
            active = false;
        }
        return;
    }
}


Важный класс Data:

Data
using UnityEngine;
using UnityEngine.UI;
using System.Collections;

public class Data : GlobalFunctions
{
    public Dialoge[] dialoges;
    public DeadPhrases[] deadPhrases;
    public GamePlay[] gameplay;
    [Space]
    public Tips tips;
    public AudioBase audioBase;
    public TipsGamePlay gamePlayTips;
    public Image slowmobonus;
    public Text fpsText;
    public float scaleTips = 1f;
    public float scaleGameUI = 1f;
    public float scaleSlowMo = 1f;
    
    private float speed = 0f;
    private float target = 1f;
    private float timeDuration = 1f;
    private int updFPS = 0;

    public void Awake()
    {
        scaleTips = scaleGameUI = scaleSlowMo = 1f;
        slowmobonus.color = new Color(0f, 0f, 0f, 0f);
    }

    public void Start()
    {
        StartCoroutine(SecFPSUpdate());
    }

    public void SetDialoge(int id)
    {
        if (dialoges.Length != 0)
        {
            tips.SetActiveTrue(dialoges[id].dialogeStrings, dialoges[id].name);
        }
    }

    public void FalseP2R()
    {
        tips.SetFalse();
    }

    public string GetDeadPhrase(string typeDead)
    {
        int idType = -1;
        for (int i = 0; i < deadPhrases.Length; i++)
        {
            if (deadPhrases[i].typeDead == typeDead)
            {
                idType = i; break;
            }
        }

        if (idType == -1)
        {
            return typeDead;
        }

        int rand = Random.Range(0, deadPhrases[idType].deadPhrases.Length);
        return deadPhrases[idType].deadPhrases[rand].GetString();
    }

    public string GetDeadPhrase2()
    {
        string ret = "";
        switch (PlayerPrefs.GetString("language"))
        {
            case "english": ret = "Tap to continue"; break;
            case "spanish": ret = "Pulse para continuar"; break;
            case "italian": ret = "Tocca per continuare"; break;
            case "german": ret = "Tippen Sie, um fortzufahren"; break;
            case "russian": ret = "Нажмите для продолжения"; break;
            case "french": ret = "Appuyez sur pour continuer"; break;
            case "portuguese": ret = "Clique para continuar"; break;
            case "korean": ret = "계속하려면 탭하세요"; break;
            case "chinese": ret = "点按即可继续"; break;
            case "japan": ret = "タップして続行します"; break;
        }
        return ret;
    }

    public void PauseGameUI(float time)
    {
        scaleGameUI = time;
        Update();
        audioBase.UpdateSound();
    }

    public void SetGamePlayTips(int id)
    {
        if (id == -1) { gamePlayTips.SetActiveTrueSaved(); }
        else { gamePlayTips.SetActiveTrue(gameplay[id]); }
    }

    public void SlowMo(float timeDuration2, float setSlowMo, float speed2)
    {
        speed = speed2;
        target = setSlowMo;
        timeDuration = timeDuration2;
        Update();
        audioBase.UpdateSound();
    }

    public void SlowMo(float timeDuration2)
    {
        scaleSlowMo = 0.1f;
        float sb = (1f - scaleSlowMo) * 0.3921569f;
        slowmobonus.color = new Color(0f, 0f, 0f, sb);
        Update();
        audioBase.UpdateSound();
    }

    public IEnumerator EndAnim(float timeDuration)
    {
        yield return new WaitForSeconds(timeDuration);
        End();
    }

    public void End()
    {
        scaleSlowMo = 1f;
        float sb = (1f - scaleSlowMo) * 0.3921569f;
        slowmobonus.color = new Color(0f, 0f, 0f, sb);
        Update();
        audioBase.UpdateSound();
    }

    public void End2(float timeDuration2)
    {
        if (timeDuration2 == 0) { End(); return; }
        StartCoroutine(EndAnim(timeDuration2));
    }

    private void Update()
    {
        Time.timeScale = scaleTips * scaleSlowMo * scaleGameUI;
        Time.fixedDeltaTime = 0.03f * scaleSlowMo * scaleTips;
        updFPS = updFPS + 1;
        return;
    }

    private IEnumerator SecFPSUpdate()
    {
        yield return new WaitForSeconds(1f);
        fpsText.text = "FPS: " + updFPS; updFPS = 0;
        StartCoroutine(SecFPSUpdate());
    }
}


И основной класс Tips, отвечающий за работу терминала:

Скрипт Tips
using System.Collections;
using UnityEngine.UI;
using UnityEngine;

public class Tips : GlobalFunctions
{
    public Data data;
    public Press2Read p2r;
    public GameUI gameUI;
    public GameObject obj;
    public AudioClip setClip;
    public Text nameText;
    public Text txt;
    private int textID = 0;
    private int textsID = 0;
    private AudioBase audioBase;
    private DialogeString textActive;
    private DialogeString[] textsActive;
    private bool isMass = false;
    [TextArea]
    public string end = "";
    [TextArea]
    public string endPast = "";

    public void Start()
    {
        audioBase = GameObject.FindWithTag("MainCamera").GetComponent<AudioBase>();
        data.scaleTips = 1f;
        obj.SetActive(false);
        txt.text = "";
    }

    public void SetActiveTrue(DialogeString text, StringLanguageMinimize name)
    {
        data.scaleTips = 0.1f;
        audioBase.layerSounds[0].volume /= 10f;
        obj.SetActive(true);
        nameText.text = name.GetString();
        gameUI.pauseButton.SetActive(false);
        textActive = text;
        isMass = false;
        StartCoroutine(TimerFalse());
    }

    public void SetActiveTrue(DialogeString[] texts, StringLanguageMinimize name)
    {
        data.scaleTips = 0.1f;
        audioBase.layerSounds[0].volume /= 10f;
        obj.SetActive(true);
        nameText.text = name.GetString();
        gameUI.pauseButton.SetActive(false);
        textsActive = texts;
        isMass = true;
        StartCoroutine(TimersFalse());
    }

    public IEnumerator TimerFalse(float time = 0.02f)
    {
        yield return new WaitForSecondsRealtime(time);
        string ds = textActive.dialogeString.GetString();
        if (textID < ds.Length && ds != end)
        {
            audioBase.SetSound(setClip, 1, 0.5f, TypePlaying.Sound, false);
            end = end + ds.Substring(textID, 1);
            txt.text = endPast + end;
            textID = textID + 1;

            if (textID + 1 != ds.Length && ds != end)
            {
                if (ds.Substring(textID + 1, 1) == ",")
                {
                    StartCoroutine(TimersFalse(0.1f));
                }
                else if (ds.Substring(textID + 1, 1) == ".")
                {
                    StartCoroutine(TimersFalse(0.15f));
                }
                else if (ds.Substring(textID + 1, 1) == "?")
                {
                    StartCoroutine(TimersFalse(0.15f));
                }
                else if (ds.Substring(textID + 1, 1) == ".")
                {
                    StartCoroutine(TimersFalse(0.15f));
                }
                else
                {
                    StartCoroutine(TimersFalse());
                }
            }
            else
            {
                StartCoroutine(TimersFalse());
            }
        }
        else
        {
            endPast = txt.text;
            if (textActive.isSkip)
            {
                if (textActive.skipOffset == 0f)
                {
                    SetActiveFalse();
                }
                else
                {
                    IsSkip(textActive.skipOffset);
                }
            }
        }
    }

    public IEnumerator TimersFalse(float time = 0.02f)
    {
        yield return new WaitForSecondsRealtime(time);
        string ds = textsActive[textsID].dialogeString.GetString();
        if (textID < ds.Length && ds != end)
        {
            audioBase.SetSound(setClip, 1, 0.5f, TypePlaying.Sound, false);
            end = end + ds.Substring(textID, 1);
            txt.text = endPast + end;
            textID = textID + 1;

            string ds1 = textsActive[textsID].dialogeString.GetString();
            if (textID + 1 != ds1.Length && ds1 != end)
            {
                if (ds1.Substring(textID + 1, 1) == ",")
                {
                    StartCoroutine(TimersFalse(0.1f));
                }
                else if (ds1.Substring(textID + 1, 1) == ".")
                {
                    StartCoroutine(TimersFalse(0.15f));
                }
                else if (ds1.Substring(textID + 1, 1) == "?")
                {
                    StartCoroutine(TimersFalse(0.15f));
                }
                else if (ds1.Substring(textID + 1, 1) == "!")
                {
                    StartCoroutine(TimersFalse(0.15f));
                }
                else
                {
                    StartCoroutine(TimersFalse());
                }
            }
            else
            {
                StartCoroutine(TimersFalse());
            }
        }
        else
        {
            endPast = txt.text;
            if (textsActive[textsID].isSkip)
            {
                if (textsActive[textsID].skipOffset == 0f)
                {
                    SetActiveFalse();
                }
                else
                {
                    IsSkip(textsActive[textsID].skipOffset);
                }
            }
        }
    }

    public IEnumerator IsSkip(float time)
    {
        yield return new WaitForSecondsRealtime(time);
        SetActiveFalse();
    }

    public void SetFalse()
    {
        obj.SetActive(false);
        gameUI.pauseButton.SetActive(true);
        end = "";
        endPast = "";
        txt.text = "";
        textID = textsID = 0;
        data.scaleTips = 1f;
        audioBase.layerSounds[0].volume *= 10f;
    }

    public void SetActiveFalse()
    {
        if (isMass == false)
        {
            if (textActive.dialogeString.GetString() != end)
            {
                end = textActive.dialogeString.GetString();
                if (textActive.isSkip)
                {
                    SetActiveFalse();
                }
            }
            else
            {
                obj.SetActive(false);
                gameUI.pauseButton.SetActive(true);
                end = "";
                data.scaleTips = 1f;
                audioBase.layerSounds[0].volume *= 10f;
            }
        }
        else
        {
            if (textsActive[textsID].dialogeString.GetString() != end)
            {
                if (textsActive[textsID].isStep == true)
                {
                    txt.text = end = textsActive[textsID].dialogeString.GetString();
                    if (textsActive[textsID].isSkip)
                    {
                        SetActiveFalse();
                    }
                }
                else
                {
                    end = textsActive[textsID].dialogeString.GetString();
                    txt.text = endPast + end;
                }
            }
            else
            {
                if (textsID != textsActive.Length - 1)
                {
                    textsID = textsID + 1;
                    textID = 0;
                    end = "";
                    if (textsActive[textsID].isStep == true)
                    {
                        endPast = "";
                    }
                    StartCoroutine(TimersFalse());
                }
                else
                {
                    obj.SetActive(false);
                    gameUI.pauseButton.SetActive(true);
                    p2r.UnTap();
                    end = "";
                    endPast = "";
                    txt.text = "";
                    textID = textsID = 0;
                    data.scaleTips = 1f;
                    audioBase.layerSounds[0].volume *= 10f;
                }
            }
        }
    }
}


Я решил, что будет уныло если будет просто показываться текст, а поэтому с помощью IEnumerator я сделал эмуляцию написания текста (точно такой же эффект и в концовке).

Релиз

Изначально в моих планах был выложить игру 1 сентября. И я так и сделал: в последний момент оказалось, что у меня 4 бага в концовке (а также она была не переведена), быстро исправил и под вечер выложил игру. К сожалению, проверка затянулась на 7 дней, ведь у меня предложение с чего-то решили проверить вручную. Скорее всего дело в аккаунте, который стал «определённым» и его уже проверяет модерация вручную.

Пиар же мне давался гораздо сложнее, чем подготовка к релизу, ведь денег и связей не было, а распространить игру хотелось. Поэтому использовал простые методы: кидал всем знакомым в ВК, создавал посты на Reddit, кидал в предложку сайтам по мобильным играм, пытался связаться с авторами музыки и т.д. И это дало немного результата:


Итог

Удивительно, но именно в день, когда эту статью я выложил, я пробыл в IT уже 3 года! И несмотря на свой 16-летний возраст, именно в этот день, когда мне исполнилось 13 лет, я поставил себе цель: выучить программирование и создать игру мечты. И с того момента в какой-то мере моя мечта исполнилась.

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

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

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

P.S: Прошлый трейлер кому-то понравился:


А поэтому вот трейлер этой игры:

Tags:
Hubs:
+3
Comments0

Articles

Change theme settings