Архитектура в Unity-разработке: паттерн «Спагетти»

от автора

Полгода назад меня повысили до ведущего 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 модели и анимации

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

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

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


ссылка на оригинал статьи https://habr.com/ru/articles/815841/