Полгода назад меня повысили до ведущего Unity-разработчика, и тогда я почувствовал, что должен поднять уровень навыка написания кода. Мне надоело создавать прототипы, которые впечатляли клиентов и работодателей, в то время как через год кодовая база превращалась в адское месиво, изобилующее ошибками и горами технического долга.

Однако, куда бы я ни обращался и кого бы ни спрашивал, мнение было одним: я не должен гнаться за такими глупыми мечтами, Singleton — это всё, что мне нужно для создания приложений на Unity.

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

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

  1. Спагетти-код не соответствует (духу) SOLID. Методы и классы — длинные и сложные; их нельзя легко изменить, не сломав другие части приложения или не вызвав странных багов. Нельзя изолировать кусок кода, не потянув за собой остальное приложение.

  2. Второе, менее распространённое определение — это размышления о графике зависимостей вашей кодовой базы. Хороший граф зависимостей выглядит как рождественская ёлка или пирамида: он начинается с корня композиции и идёт вниз и только вниз, по иерархии. Не слишком глубоко и не слишком широко. Кодовая база вида «спагетти» не имеет такой структуры, в ней много пересекающихся зависимостей — и если провести линии между всеми классами, указывающие на их зависимости, это будет выглядеть как большой беспорядок — одним словом, спагетти.

Спагетти-код vs «чистый» код
Спагетти-код vs «чистый» код

Я сделал ещё шаг вперед. Я не только создам игру в виде прототипа; я попробую вернуться в то время, когда я был начинающим разработчиком, и буду использовать только те методы, которые мне были доступны в то время в силу опыта. Это значит, что никаких событий, никаких интерфейсов или причудливых фреймворков. Только монобехавиоры, корутины, синглтоны и префабы.

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

Для этого эксперимента я решил создать клон Vampire Survivors, так как он прост в создании и в него интересно играть. Взглянем на конечный продукт.

Я назвал его «Выжившие в слизи». Оригинал GIF-ки
Я назвал его «Выжившие в слизи». Оригинал GIF-ки

Работа над ним заняла 53 часа, причём каждая минута разработки тщательно фиксировалась.

Дисциплина
Дисциплина

Давайте посмотрим, какой путь мы прошли.

Первые 30 минут

Одно из преимуществ паттерна «Спагетти» в том, что он позволяет сразу приступить к созданию игры. Этапа планирования нет. Всего за полчаса у нас уже есть подобие ядра игры.

В принципе, можно и заканчивать. Оригинал GIF-ки
В принципе, можно и заканчивать. Оригинал GIF-ки

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

Всё чётко
Всё чётко

Каждый кружок представляет класс, а стрелки — прямую связь.

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

Час 6

Пиу-пиу. Оригинал GIF-ки

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

Следуя правилам Спагетти паттерна и рекомендациям моих коллег-разработчиков на Unity, мы нашли класс, который нам был нужен для достижения этой цели, — Singleton Game Manager. В настоящее время выглядит следующим образом.

Ol’ Reliable
Ol’ Reliable

А вот обновлённый график зависимостей.

С этим я могу иметь дело
С этим я могу иметь дело

Светло-голубой цвет указывает на то, что объект использует паттерн Singleton, ромбовидная форма указывает на скриптуемый объект (данные), а прямоугольник со скруглёнными краями — это элемент пользовательского интерфейса.

Нам потребовалось всего 6 часов, чтобы превратить наш граф зависимостей в Спагетти. Впрочем, для небольших проектов это вполне нормально. Мы даже можем простить круговые зависимости между Game Manager и Player Controller.

28-й час 

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

Примерно на 16-м часу я исправил свой первый баг. Но до сих пор всё шло гладко. Из последних 28 часов на исправление багов было потрачено только 1,5 часа — что составляет 5,6 % от общего времени.

Давайте проверим обновлённый график зависимостей.

Органический рост
Органический рост

Даже на этом этапе, вероятно, нет причин для беспокойства. Мы наблюдаем несколько багов, но ничего такого, с чем мы не могли бы справиться.

53-й час 

53 часа. Оригинал GIF-ки

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

На исправление багов было потрачено 6,3 часа. Это составляет 25 % времени разработки с момента нашего последнего обновления, что значительно превышает предыдущие 5,3 %.

Вот снимок финальной версии Game Manager.

Дань уважения проектам Unity повсюду
Дань уважения проектам Unity повсюду

И наш график зависимостей.

Паттерн «Спагетти», пример А
Паттерн «Спагетти», пример А

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

Прежде чем мы продолжим, я хочу отметить одну фичу, которая была реализована одной из последних.

Просто маленький симпатичный индикатор прогресса
Просто маленький симпатичный индикатор прогресса

Эта шкала прогресса призвана просто показывать, как далеко игрок продвинулся в игре. На её создание также ушло 5 часов, или 10 % от общего времени разработки.

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

Время данных

Давайте посмотрим на распределение задач за 53 часа.

Как это выглядит в пределе?
Как это выглядит в пределе?

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

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

Заключение

Это здорово, когда кодовая база масштабируема и производительна, но нам всё ещё нужно закончить игру. Для этого паттерн Спагетти отлично подходит, ведь в конце концов пользователю всё равно, насколько красив ваш код.

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

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

А пока, почему бы вам самим не попробовать игру.

Если вы хотите ещё один пример спагетти-кода, посмотрите декомпиляцию Vampire Survivors.

Полный исходный код этого паттерна, а также других паттернов можно посмотреть на github.

Наконец, я хотел оставить вам исходный код PlayerController.cs, чтобы вы могли оценить, как выглядит настоящий спагетти-код.

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

[DefaultExecutionOrder(10)]
public class PlayerController : MonoBehaviour
{
        private Transform _transform;

        public Camera camera;
        private Vector3 _cameraOffset;

        public Vector3 targetDirection;

        [Header("References")]
        public GameManager gameManager;
        public EnemyManager enemyManager;
        
        
        [Header("Pistol")]
        public GameObject projectilePrefab;
        private float _timeSinceLastFire = 0.0f;
        private Transform _closestTarget = null;

        [Header("Sword")]
        public Transform swordPivot;
        private float _timeSinceLastSwing = 0.0f;
        private bool _isSwingingLeftToRight = true;
        private bool _isSwordAttacking = false;

        [Header("UI")]
        public TextMeshProUGUI healthText;
        public Image healthBar;
        public Transform localCanvas;
        public GameObject dodgeTextGo;
        public GameObject damageTextGo;
        private Quaternion _startRotation;

        private Coroutine _swordCoroutine;
        private Coroutine _dodgeTextCoroutine;
        private Coroutine _damageTextCoroutine;
        private Coroutine _dashCoroutine;
        private bool _isDashing;
        private bool _canTakeDamage = true;
        
        [Header("Dash")]
        public float dashDistance = 5f;
        public float dashTime = 0.2f;

        [Header("Effects")]
        public ParticleSystem shootEffect;
        public ParticleSystem dashParticle;
        public ParticleSystem reviveParticle;

        [Header("Debug")] public bool isDebugMode = false;
        
        [Header("Sound")]
        public SoundDefinition swordSound;
        public SoundDefinition shootSound;
        public SoundDefinition dashSound;
        public SoundDefinition deathSound;
        public SoundDefinition healthPackSound;
        public SoundDefinition takeDamageSound;
        public SoundDefinition blockSound;

        private void Awake()
        {
            _transform = transform;
            _cameraOffset = camera.transform.position - _transform.position;
        }

        private void Start()
        {
            swordPivot.transform.parent = null;
            swordPivot.gameObject.SetActive(false);
            _startRotation = localCanvas.rotation;
            SetUI();
        }

        private IEnumerator Dash()
        {
            _isDashing = true;
            gameManager.dashes.value--;
            
            var elapsedTime = 0f;
            var startPosition = transform.position;
            var dashDestination = transform.forward * dashDistance + transform.position;
            
            while (elapsedTime < dashTime)
            {
                elapsedTime += Time.deltaTime;

                var normalizedTime = elapsedTime / dashTime;
                var inverseQuadraticTime = 1 - Mathf.Pow(1 - normalizedTime, 2);
                
                var desiredPos = Vector3.Lerp(startPosition, dashDestination, inverseQuadraticTime);
                
                // clamp the position to the level bounds
                transform.position = new Vector3(Mathf.Clamp(desiredPos.x, -gameManager.levelBounds.x, gameManager.levelBounds.x),
                    transform.position.y,
                    Mathf.Clamp(desiredPos.z, -gameManager.levelBounds.y, gameManager.levelBounds.y));
                
                if (!gameManager.isGameActive)
                {
                    _isDashing = false;
                    yield break;
                }
                
                yield return new WaitForEndOfFrame();
            }
            
            _isDashing = false;

        }

        private void Update()
        {
            if(!GameManager.instance.isGameActive) return;

            if (Input.GetKeyDown(KeyCode.Space) && (int)gameManager.dashes.value > 0 && !_isDashing)
            {
                AudioManager.instance.PlaySound(dashSound);
                dashParticle.Play();
                _dashCoroutine = StartCoroutine(Dash());
            }
            
            swordPivot.transform.position = _transform.position;

            if (_isDashing) return;
            
            // Get Closest enemy target.
            var closestDistance = Mathf.Infinity;
            var targetIsNull = true;
            _closestTarget = null;
            foreach (var enemy in enemyManager.enemies)
            {
                var distance = Vector3.Distance(_transform.position, enemy.transform.position);
                // now we minus the radius of the enemy from the distance, so that we get the distance to its edge.
                distance -= enemy.transform.localScale.x * 0.5f;
                if (distance < closestDistance)
                {
                    closestDistance = distance;
                    _closestTarget = enemy.transform;
                    targetIsNull = false;
                }
            }

            if (!targetIsNull)
            {
                targetDirection = Vector3.ProjectOnPlane(_closestTarget.position - _transform.position, Vector3.up).normalized;
                if(targetDirection.magnitude < 0.1f) targetDirection = transform.forward;
            }
            else
            {
                targetDirection = transform.forward;
            }

            // Fire Pistol if possible.
            _timeSinceLastFire += Time.deltaTime;
            if (_timeSinceLastFire > 1 / gameManager.pistolFireRate.value)
            {
                if (!targetIsNull)
                {
                    if (closestDistance <= gameManager.pistolRange.value)
                    {
                        var isKnockBack = false;
                        if(_closestTarget.TryGetComponent<EnemyController>(out var enemyController))
                        {
                            isKnockBack = enemyController.isKnockedBack;
                        }

                        AudioManager.instance.PlaySound(shootSound);
                        shootEffect.Play();
                        if (isKnockBack)
                        {
                            Shoot();
                        }
                        else
                        {
                            ShootPredictive();    
                        }
                        
                    }
                }
            }

            // Swing sword if possible.
            _timeSinceLastSwing += Time.deltaTime;
            if (_timeSinceLastSwing > 1 / gameManager.swordAttackSpeed.value && !_isSwordAttacking)
            {
                if (!targetIsNull)
                {
                    if (closestDistance <= gameManager.swordRange.value)
                    {
                        if(_swordCoroutine != null) StopCoroutine(_swordCoroutine);
                        AudioManager.instance.PlaySound(swordSound);
                        _swordCoroutine = StartCoroutine(SwordAttack());
                        _timeSinceLastSwing = 0.0f;
                    }
                }
            }

            var dir = Vector3.zero;
            
            if (Input.GetKey(KeyCode.A))
                dir += Vector3.left;
            if (Input.GetKey(KeyCode.D))
                dir += Vector3.right;
            if (Input.GetKey(KeyCode.W))
                dir += Vector3.forward;
            if (Input.GetKey(KeyCode.S))
                dir += Vector3.back;

            // Check if the player is at the level bounds, if they are, make sure they cant move in the direction of the bound
            if (_transform.position.x <= -gameManager.levelBounds.x && dir.x < 0)
                dir.x = 0;
            if (_transform.position.x >= gameManager.levelBounds.x && dir.x > 0)
                dir.x = 0;
            if (_transform.position.z <= -gameManager.levelBounds.y && dir.z < 0)
                dir.z = 0;
            if (_transform.position.z >= gameManager.levelBounds.y && dir.z > 0)
                dir.z = 0;
            
            // Apply movement
            if (dir.magnitude > 0)
            {
                _transform.position += dir.normalized * (Time.deltaTime * gameManager.playerSpeed.value);
                _transform.rotation = Quaternion.LookRotation(dir);
            }
        }

        private void Shoot()
        {
            var directionToTarget = Vector3
                .ProjectOnPlane(_closestTarget.position - _transform.position, Vector3.up).normalized;
            var projectileGo = Instantiate(projectilePrefab, _transform.position,
                Quaternion.LookRotation(directionToTarget));
            var projectile = projectileGo.GetComponent<Projectile>();
            projectile.damage = Mathf.RoundToInt(gameManager.pistolDamage.value);
            projectile.knockBackIntensity = gameManager.pistolKnockBack.value;
            projectile.pierceCount = (int)gameManager.pistolPierce.value;
            _timeSinceLastFire = 0.0f;
        }

        private void ShootPredictive()
        {
                var projectileGo = Instantiate(projectilePrefab, _transform.position, Quaternion.identity);
                var projectile = projectileGo.GetComponent<Projectile>();
    
                // Calculate the time it would take for the projectile to reach the target's current position
                var distanceToTarget = Vector3.Distance(_transform.position, _closestTarget.position);
                var timeToTarget = distanceToTarget / projectile.projectileSpeed;

                // Predict the target's position after the time it would take for the projectile to reach it
                var enemy = _closestTarget.GetComponent<EnemyController>();
                var enemyVelocity = _closestTarget.forward * enemy.moveSpeed;
                var predictedTargetPosition = _closestTarget.position + enemyVelocity * timeToTarget;
                
                // now get the distance to that position
                distanceToTarget = Vector3.Distance(_transform.position, predictedTargetPosition);
                timeToTarget = distanceToTarget / projectile.projectileSpeed;
                predictedTargetPosition = _closestTarget.position + enemyVelocity * timeToTarget;
                
                // iterate again
                distanceToTarget = Vector3.Distance(_transform.position, predictedTargetPosition);
                timeToTarget = distanceToTarget / projectile.projectileSpeed;
                predictedTargetPosition = _closestTarget.position + enemyVelocity * timeToTarget;

                // Aim the projectile towards the predicted position
                var shootDirection = (predictedTargetPosition - _transform.position).normalized;

                if (isDebugMode)
                {
                    // draw a line from the player to the predicted position
                    Debug.DrawLine(_transform.position, predictedTargetPosition, Color.green,
                        1 / gameManager.pistolFireRate.value);

                }
                
                projectileGo.transform.forward = shootDirection;
                projectile.damage = Mathf.RoundToInt(gameManager.pistolDamage.value);
                projectile.knockBackIntensity = gameManager.pistolKnockBack.value;
                projectile.pierceCount = (int)gameManager.pistolPierce.value;
                _timeSinceLastFire = 0.0f;
        }

        private void LateUpdate()
        {
            localCanvas.rotation = _startRotation;
            Camera position.
            var cameraWishPosition = _transform.position + _cameraOffset;
            
            // We want the same level bound logic for the camera, but it stops its position if the player is within 5m of the level bounds
            if (_transform.position.x <= -gameManager.levelBounds.x + 5 ||
                _transform.position.x >= gameManager.levelBounds.x - 5)
            {
                cameraWishPosition =
                    new Vector3(camera.transform.position.x, cameraWishPosition.y, cameraWishPosition.z);
            }
            
            if (_transform.position.z <= -gameManager.levelBounds.y + 5 ||
                _transform.position.z >= gameManager.levelBounds.y - 5)
            {
                cameraWishPosition =
                    new Vector3(cameraWishPosition.x, cameraWishPosition.y, camera.transform.position.z);
            }
            
            camera.transform.position = cameraWishPosition;
            SetUI();
        }

        private IEnumerator SwordAttack()
    {
        _isSwordAttacking = true;
        var swordArc = gameManager.swordArc.value;
        // Enable the sword gameobject.
        swordPivot.gameObject.SetActive(true);
        swordPivot.localScale = new Vector3(1f, 1f, gameManager.swordRange.value);
    
        // Base rotation values.
        var leftRotation = Quaternion.Euler(0, swordArc * -0.5f, 0);
        var rightRotation = Quaternion.Euler(0, swordArc * 0.5f, 0);
    
        // The start rotation needs to be directed to the closest target.
        var directionToTarget = Vector3.ProjectOnPlane( _closestTarget.transform.position - transform.position, Vector3.up).normalized;
        swordPivot.forward = directionToTarget;
    
        // Determine the start and end rotation based on the current swing direction.
        Quaternion startRotation, endRotation;
        if (_isSwingingLeftToRight)
        {
            startRotation = Quaternion.LookRotation(directionToTarget) * leftRotation;
            endRotation = Quaternion.LookRotation(directionToTarget) * rightRotation;
        }
        else
        {
            startRotation = Quaternion.LookRotation(directionToTarget) * rightRotation;
            endRotation = Quaternion.LookRotation(directionToTarget) * leftRotation;
        }
        
        var total180Arcs = Mathf.FloorToInt(swordArc / 180f);
        var swingTime = gameManager.swordRange.value * 0.08f;

        if (total180Arcs > 0)
        {
            var lastStart = startRotation;
            var directionSign = _isSwingingLeftToRight ? 1 : -1;
            var lastEnd = startRotation * Quaternion.Euler(0, 179.9f * directionSign, 0);
            
            for (var i = 0; i < total180Arcs; i++)
            {
                var t = 0.0f;
                var swing = true;
                while (swing)
                {
                    t += Time.deltaTime;
                    swordPivot.rotation = Quaternion.Lerp(lastStart, lastEnd, t / swingTime);
                    yield return null;
                    if (!(t >= swingTime)) continue;
                    lastStart = swordPivot.rotation;
                    lastEnd = lastStart * Quaternion.Euler(0, 179.9f * directionSign, 0);
                    swing = false;

                }
            }
        }
        else
        {
            // Lerp the sword rotation from start to end over 0.5 seconds.
            var t = 0.0f;

            while (t < swingTime)
            {
                t += Time.deltaTime;
                swordPivot.rotation = Quaternion.Lerp(startRotation, endRotation, t / swingTime);
                yield return null;
            }
        }

        _isSwordAttacking = false;
    
        // Toggle the swing direction for the next attack.
        _isSwingingLeftToRight = !_isSwingingLeftToRight;
    
        // Disable the sword gameobject.
        swordPivot.gameObject.SetActive(false);
    }
        
        public void TakeDamage(int damageAmount)
        {
            if (!_canTakeDamage) return;
            if (_isDashing) return;
            // Check if damage is dodged.
            var hitChance = Random.Range(0, 100);

            if (hitChance < gameManager.dodge.value)
            {
                if(_dodgeTextCoroutine != null) StopCoroutine(_dodgeTextCoroutine);
                _dodgeTextCoroutine = StartCoroutine(ShowDodgeText());
                return;
            }
            
            damageAmount -= (int)gameManager.block.value;
            // We should never be invincible imo.
            if (damageAmount <= 0)
            {
                damageAmount = 0;
            }

            if (_damageTextCoroutine != null)
            {
                StopCoroutine(_damageTextCoroutine);
            }

            _damageTextCoroutine = StartCoroutine(ShowDamageText(damageAmount));

            AudioManager.instance.PlaySound(damageAmount > 0 ? takeDamageSound : blockSound);
            
            AccountManager.instance.statistics.totalDamageTaken += damageAmount;
            
            gameManager.playerCurrentHealth -= damageAmount;

            if (gameManager.playerCurrentHealth <= 0)
            {
                gameManager.playerCurrentHealth = 0;

                if ((int)gameManager.revives.value > 0)
                {
                    reviveParticle.Play();
                    gameManager.revives.value--;
                    
                    var enemyCount = enemyManager.enemies.Count;
                    var enemies = enemyManager.enemies.ToArray();
                    for (var i = 0; i < enemyCount - 1; i++)
                    {
                        var controller = enemies[i].GetComponent<EnemyController>();
                        if (controller != null)
                        {
                            controller.TakeDamage(9999);
                        }
                            
                    }
                    
                    gameManager.playerCurrentHealth = (int)gameManager.playerMaxHealth.value;
                    StartCoroutine(InvincibilityFrames());

                    return;
                }
                
                AccountManager.instance.statistics.totalDeaths++;
                AudioManager.instance.PlaySound(deathSound);
                gameManager.LoseGame();
                
                List<Achievement> dieAchievements = AccountManager.instance.achievementSave.achievements
                    .Where(a => a.name == AchievementName.Die ||
                                a.name == AchievementName.Die50Times ||
                                a.name == AchievementName.Die100Times).ToList();
                foreach (var a in dieAchievements)
                {
                    if (a.isCompleted) return;
                    a.progress++;
                    if (a.progress >= a.goal)
                    {
                        a.isCompleted = true;
                        AccountManager.instance.AchievementUnlocked(a);
                    }
                }
            }
            SetUI();
        }
        
        private IEnumerator InvincibilityFrames()
        {
            _canTakeDamage = false;
            yield return new WaitForSeconds(0.5f);
            _canTakeDamage = true;
        }

        private IEnumerator ShowDodgeText()
        {
            dodgeTextGo.SetActive(true);
            var elapsedTime = 0f;
            var t = dodgeTextGo.transform;
        
            var startPosition = Vector3.right;
            var targetPosition = Vector3.up + Vector3.right * 1f;

            var startScale = Vector3.one * 0.25f;
            var targetScale = Vector3.one;
        
            while (elapsedTime < 0.6f)
            {
                elapsedTime += Time.deltaTime;
                var normalizedTime = elapsedTime / 0.4f;
                var inversedQuadraticTime = 1 - Mathf.Pow(1 - normalizedTime, 2);
                t.position = Vector3.Lerp(startPosition + transform.position, targetPosition + transform.position, inversedQuadraticTime);
                t.localScale = Vector3.Lerp(startScale, targetScale, inversedQuadraticTime);
                yield return new WaitForEndOfFrame();
            }
            dodgeTextGo.SetActive(false);
            yield return null;
        }

        private IEnumerator ShowDamageText(int damageAmount)
        {
            var dmgText = damageTextGo.GetComponent<TextMeshProUGUI>();
            damageTextGo.SetActive(true);
            dmgText.text = damageAmount.ToString();
            var elapsedTime = 0f;
            var t = damageTextGo.transform;
        
            var startPosition = Vector3.right;
            var targetPosition = Vector3.up + Vector3.right * 1f;

            var startScale = Vector3.one * 0.25f;
            var targetScale = Vector3.one;
        
            while (elapsedTime < 0.6f)
            {
                elapsedTime += Time.deltaTime;
                var normalizedTime = elapsedTime / 0.4f;
                var quadraticTime = normalizedTime * normalizedTime;
                var inversedQuadraticTime = 1 - Mathf.Pow(1 - normalizedTime, 2);
                t.position = Vector3.Lerp(startPosition + transform.position, targetPosition + transform.position, inversedQuadraticTime);
                t.localScale = Vector3.Lerp(startScale, targetScale, inversedQuadraticTime);
                yield return new WaitForEndOfFrame();
            }
            damageTextGo.SetActive(false);
            yield return null;
        }

        public void OnTriggerEnter(Collider other)
        {
            
            if(other.CompareTag("Spawn Indicator"))
            {
                Destroy(other.gameObject);   
            }

            if (other.CompareTag("Health Pack"))
            {
                AudioManager.instance.PlaySound(healthPackSound);
                var healthGained = (int)Mathf.Clamp( (GameManager.instance.playerCurrentHealth + GameManager.instance.playerMaxHealth.value * 0.1f + 1), 
                    0f, 
                    GameManager.instance.playerMaxHealth.value);
                
                AccountManager.instance.statistics.totalDamageHealed += healthGained;
                GameManager.instance.playerCurrentHealth = healthGained;
                
                Destroy(other.gameObject);
            }
        }
        
        private void SetUI()
        {
            healthText.text = $"{gameManager.playerCurrentHealth}/{(int)gameManager.playerMaxHealth.value}";
            healthBar.fillAmount =  (float)gameManager.playerCurrentHealth / (float)gameManager.playerMaxHealth.value;
        }

        public void ResetPlayer()
        {
            transform.SetPositionAndRotation(Vector3.up, Quaternion.identity);
            camera.transform.position = _transform.position + _cameraOffset;
            SetUI();
        }
}

Приглашаем всех желающих на открытый урок 23 мая «Тестирование звука и изображения в играх Baldur's Gate 3, Kingdom Come: Deliverance и Hellsinger». Основные темы:

  • Как создаются 3D модели и анимации

  • В чем отличие музыки и звуков в играх, от обычной музыки или звука в кино

  • Самые распространенные баги для аудио и изображения

Записаться бесплатно можно по ссылке.

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


  1. MrBrooks
    21.05.2024 21:15

    ... Эту запись оставил НЛО


  1. contr4s
    21.05.2024 21:15
    +2

    *Джун прочитал Game Programming Patterns*
    Джун: лид, может будем использовать эти паттерны для написания чистого, понятного и оптимизированного кода?
    Лид: нет, у нас уже есть паттерн в проекте
    Паттерн в проекте: паттерн «Спагетти»