Архитектура в Unity-разработке: паттерн «Спагетти»
Полгода назад меня повысили до ведущего Unity-разработчика, и тогда я почувствовал, что должен поднять уровень навыка написания кода. Мне надоело создавать прототипы, которые впечатляли клиентов и работодателей, в то время как через год кодовая база превращалась в адское месиво, изобилующее ошибками и горами технического долга.
Однако, куда бы я ни обращался и кого бы ни спрашивал, мнение было одним: я не должен гнаться за такими глупыми мечтами, Singleton — это всё, что мне нужно для создания приложений на Unity.
Мне однако этот ответ не понравился, и я надеюсь, что он не является окончательным. Поэтому я решил бросить себе вызов и сделать игру — не ради славы или денег, а чтобы исследовать саму природу того, как создавать игры. В надежде найти архитектурный паттерн, который сможет привести меня к чему-то большему, чем гора спагетти-кода.
По иронии судьбы, я начал с единственного известного мне способом — с создания прототипа. Ведь для того, чтобы избежать спагетти, нужно знать, что это такое. Но что именно представляет собой спагетти-код? Единого определения не существует, и я обычно представляю себе это так:
Спагетти-код не соответствует (духу) SOLID. Методы и классы — длинные и сложные; их нельзя легко изменить, не сломав другие части приложения или не вызвав странных багов. Нельзя изолировать кусок кода, не потянув за собой остальное приложение.
Второе, менее распространённое определение — это размышления о графике зависимостей вашей кодовой базы. Хороший граф зависимостей выглядит как рождественская ёлка или пирамида: он начинается с корня композиции и идёт вниз и только вниз, по иерархии. Не слишком глубоко и не слишком широко. Кодовая база вида «спагетти» не имеет такой структуры, в ней много пересекающихся зависимостей — и если провести линии между всеми классами, указывающие на их зависимости, это будет выглядеть как большой беспорядок — одним словом, спагетти.
Я сделал ещё шаг вперед. Я не только создам игру в виде прототипа; я попробую вернуться в то время, когда я был начинающим разработчиком, и буду использовать только те методы, которые мне были доступны в то время в силу опыта. Это значит, что никаких событий, никаких интерфейсов или причудливых фреймворков. Только монобехавиоры, корутины, синглтоны и префабы.
Почему я так поступил? Потому что этот прототип станет фундаментом, опираясь на который мы сможем перейти к новым, более респектабельным архитектурам. Мы начнём с контрпримера масштабируемого, производительного кода, чтобы лучше понять, как его избежать.
Для этого эксперимента я решил создать клон Vampire Survivors, так как он прост в создании и в него интересно играть. Взглянем на конечный продукт.
Работа над ним заняла 53 часа, причём каждая минута разработки тщательно фиксировалась.
Давайте посмотрим, какой путь мы прошли.
Первые 30 минут
Одно из преимуществ паттерна «Спагетти» в том, что он позволяет сразу приступить к созданию игры. Этапа планирования нет. Всего за полчаса у нас уже есть подобие ядра игры.
Давайте воспользуемся этой возможностью, чтобы начать визуализировать график зависимостей, о котором я говорил ранее.
Каждый кружок представляет класс, а стрелки — прямую связь.
На данный момент граф зависимостей выглядит хорошо структурированным и отличается ясной направленностью.
Час 6
Спустя 6 часов мы добавили пользовательский интерфейс, атаки игроков и сундуки для сбора новых предметов.
Следуя правилам Спагетти паттерна и рекомендациям моих коллег-разработчиков на Unity, мы нашли класс, который нам был нужен для достижения этой цели, — Singleton Game Manager. В настоящее время выглядит следующим образом.
А вот обновлённый график зависимостей.
Светло-голубой цвет указывает на то, что объект использует паттерн Singleton, ромбовидная форма указывает на скриптуемый объект (данные), а прямоугольник со скруглёнными краями — это элемент пользовательского интерфейса.
Нам потребовалось всего 6 часов, чтобы превратить наш граф зависимостей в Спагетти. Впрочем, для небольших проектов это вполне нормально. Мы даже можем простить круговые зависимости между Game Manager и Player Controller.
28-й час
Я добавил поддержку постоянных данных, чтобы можно было получить данные о достижениях, постоянных апгрейдах и статистику.
Примерно на 16-м часу я исправил свой первый баг. Но до сих пор всё шло гладко. Из последних 28 часов на исправление багов было потрачено только 1,5 часа — что составляет 5,6 % от общего времени.
Давайте проверим обновлённый график зависимостей.
Даже на этом этапе, вероятно, нет причин для беспокойства. Мы наблюдаем несколько багов, но ничего такого, с чем мы не могли бы справиться.
53-й час
Кроме очевидных изменений в визуальном оформлении, о которых я расскажу в одном из следующих постов, в игру не было добавлено каких-то важных функций. В основном это полировка, контент и балансировка.
На исправление багов было потрачено 6,3 часа. Это составляет 25 % времени разработки с момента нашего последнего обновления, что значительно превышает предыдущие 5,3 %.
Вот снимок финальной версии Game Manager.
И наш график зависимостей.
Если вы дошли до того, что не можете представить себе график зависимостей без пересечения линий, значит, вы действительно приняли Спагетти-паттерн.
Прежде чем мы продолжим, я хочу отметить одну фичу, которая была реализована одной из последних.
Эта шкала прогресса призвана просто показывать, как далеко игрок продвинулся в игре. На её создание также ушло 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 модели и анимации
В чем отличие музыки и звуков в играх, от обычной музыки или звука в кино
Самые распространенные баги для аудио и изображения
Записаться бесплатно можно по ссылке.