Полгода назад меня повысили до ведущего Unity-разработчика, и тогда я почувствовал, что должен поднять уровень навыка написания кода. Мне надоело создавать прототипы, которые впечатляли клиентов и работодателей, в то время как через год кодовая база превращалась в адское месиво, изобилующее ошибками и горами технического долга.
Однако, куда бы я ни обращался и кого бы ни спрашивал, мнение было одним: я не должен гнаться за такими глупыми мечтами, Singleton — это всё, что мне нужно для создания приложений на Unity.
Мне однако этот ответ не понравился, и я надеюсь, что он не является окончательным. Поэтому я решил бросить себе вызов и сделать игру — не ради славы или денег, а чтобы исследовать саму природу того, как создавать игры. В надежде найти архитектурный паттерн, который сможет привести меня к чему-то большему, чем гора спагетти-кода.
По иронии судьбы, я начал с единственного известного мне способом — с создания прототипа. Ведь для того, чтобы избежать спагетти, нужно знать, что это такое. Но что именно представляет собой спагетти-код? Единого определения не существует, и я обычно представляю себе это так:
Спагетти-код не соответствует (духу) SOLID. Методы и классы — длинные и сложные; их нельзя легко изменить, не сломав другие части приложения или не вызвав странных багов. Нельзя изолировать кусок кода, не потянув за собой остальное приложение.
Второе, менее распространённое определение — это размышления о графике зависимостей вашей кодовой базы. Хороший граф зависимостей выглядит как рождественская ёлка или пирамида: он начинается с корня композиции и идёт вниз и только вниз, по иерархии. Не слишком глубоко и не слишком широко. Кодовая база вида «спагетти» не имеет такой структуры, в ней много пересекающихся зависимостей — и если провести линии между всеми классами, указывающие на их зависимости, это будет выглядеть как большой беспорядок — одним словом, спагетти.
![Спагетти-код vs «чистый» код Спагетти-код vs «чистый» код](https://habrastorage.org/getpro/habr/upload_files/8b6/47a/191/8b647a191da98a85497ded5415c147bd.png)
Я сделал ещё шаг вперед. Я не только создам игру в виде прототипа; я попробую вернуться в то время, когда я был начинающим разработчиком, и буду использовать только те методы, которые мне были доступны в то время в силу опыта. Это значит, что никаких событий, никаких интерфейсов или причудливых фреймворков. Только монобехавиоры, корутины, синглтоны и префабы.
Почему я так поступил? Потому что этот прототип станет фундаментом, опираясь на который мы сможем перейти к новым, более респектабельным архитектурам. Мы начнём с контрпримера масштабируемого, производительного кода, чтобы лучше понять, как его избежать.
Для этого эксперимента я решил создать клон Vampire Survivors, так как он прост в создании и в него интересно играть. Взглянем на конечный продукт.
![Я назвал его «Выжившие в слизи». Оригинал GIF-ки Я назвал его «Выжившие в слизи». Оригинал GIF-ки](https://habrastorage.org/getpro/habr/upload_files/255/a10/456/255a10456fe623994f6f6bf0b24acdcb.gif)
Работа над ним заняла 53 часа, причём каждая минута разработки тщательно фиксировалась.
![Дисциплина Дисциплина](https://habrastorage.org/getpro/habr/upload_files/535/572/2a2/5355722a2367f409f42374a4fbf47d03.jpeg)
Давайте посмотрим, какой путь мы прошли.
Первые 30 минут
Одно из преимуществ паттерна «Спагетти» в том, что он позволяет сразу приступить к созданию игры. Этапа планирования нет. Всего за полчаса у нас уже есть подобие ядра игры.
![В принципе, можно и заканчивать. Оригинал GIF-ки В принципе, можно и заканчивать. Оригинал GIF-ки](https://habrastorage.org/getpro/habr/upload_files/05b/9a3/324/05b9a332464a1aeeeb411edf7f4e586a.gif)
Давайте воспользуемся этой возможностью, чтобы начать визуализировать график зависимостей, о котором я говорил ранее.
![Всё чётко Всё чётко](https://habrastorage.org/getpro/habr/upload_files/6d1/332/da8/6d1332da8f9efe6d56e6249fc2d9c97e.png)
Каждый кружок представляет класс, а стрелки — прямую связь.
На данный момент граф зависимостей выглядит хорошо структурированным и отличается ясной направленностью.
Час 6
![Пиу-пиу. Оригинал GIF-ки Пиу-пиу. Оригинал GIF-ки](https://habrastorage.org/getpro/habr/upload_files/5e4/512/4e0/5e45124e04be4571ee8c211a60ed6e62.gif)
Спустя 6 часов мы добавили пользовательский интерфейс, атаки игроков и сундуки для сбора новых предметов.
Следуя правилам Спагетти паттерна и рекомендациям моих коллег-разработчиков на Unity, мы нашли класс, который нам был нужен для достижения этой цели, — Singleton Game Manager. В настоящее время выглядит следующим образом.
![Ol’ Reliable Ol’ Reliable](https://habrastorage.org/getpro/habr/upload_files/560/876/653/560876653f66ec7774f4166562da6bf8.png)
А вот обновлённый график зависимостей.
![С этим я могу иметь дело С этим я могу иметь дело](https://habrastorage.org/getpro/habr/upload_files/bc9/05d/305/bc905d305e4cece22b077135d00710f6.png)
Светло-голубой цвет указывает на то, что объект использует паттерн Singleton, ромбовидная форма указывает на скриптуемый объект (данные), а прямоугольник со скруглёнными краями — это элемент пользовательского интерфейса.
Нам потребовалось всего 6 часов, чтобы превратить наш граф зависимостей в Спагетти. Впрочем, для небольших проектов это вполне нормально. Мы даже можем простить круговые зависимости между Game Manager и Player Controller.
28-й час
![](https://habrastorage.org/getpro/habr/upload_files/b32/f65/d32/b32f65d32275d1ba6123ef3aa5f00290.gif)
Я добавил поддержку постоянных данных, чтобы можно было получить данные о достижениях, постоянных апгрейдах и статистику.
Примерно на 16-м часу я исправил свой первый баг. Но до сих пор всё шло гладко. Из последних 28 часов на исправление багов было потрачено только 1,5 часа — что составляет 5,6 % от общего времени.
Давайте проверим обновлённый график зависимостей.
![Органический рост Органический рост](https://habrastorage.org/getpro/habr/upload_files/3c3/3f1/a24/3c33f1a244722448aec4f96d0c7b0b11.png)
Даже на этом этапе, вероятно, нет причин для беспокойства. Мы наблюдаем несколько багов, но ничего такого, с чем мы не могли бы справиться.
53-й час
![53 часа. Оригинал GIF-ки 53 часа. Оригинал GIF-ки](https://habrastorage.org/getpro/habr/upload_files/98e/4a5/223/98e4a522301b882bd266a8db0b3c9d93.gif)
Кроме очевидных изменений в визуальном оформлении, о которых я расскажу в одном из следующих постов, в игру не было добавлено каких-то важных функций. В основном это полировка, контент и балансировка.
На исправление багов было потрачено 6,3 часа. Это составляет 25 % времени разработки с момента нашего последнего обновления, что значительно превышает предыдущие 5,3 %.
Вот снимок финальной версии Game Manager.
![Дань уважения проектам Unity повсюду Дань уважения проектам Unity повсюду](https://habrastorage.org/getpro/habr/upload_files/96f/bd6/689/96fbd66899c8d22e7939a07a775b3721.jpeg)
И наш график зависимостей.
![Паттерн «Спагетти», пример А Паттерн «Спагетти», пример А](https://habrastorage.org/getpro/habr/upload_files/0f9/19d/a95/0f919da9541cf5b58d4ef0c18494b5f3.png)
Если вы дошли до того, что не можете представить себе график зависимостей без пересечения линий, значит, вы действительно приняли Спагетти-паттерн.
Прежде чем мы продолжим, я хочу отметить одну фичу, которая была реализована одной из последних.
![Просто маленький симпатичный индикатор прогресса Просто маленький симпатичный индикатор прогресса](https://habrastorage.org/getpro/habr/upload_files/5fe/8ec/fc9/5fe8ecfc973ec450b48a6177ec77cf4f.png)
Эта шкала прогресса призвана просто показывать, как далеко игрок продвинулся в игре. На её создание также ушло 5 часов, или 10 % от общего времени разработки.
Код стал настолько сложным на этом этапе, настолько хрупким, что я почувствовал, что мне было проще построить систему прогресса поверх существующей игры, а не просто сделать её частью игры, как это должно было быть.
Время данных
Давайте посмотрим на распределение задач за 53 часа.
![Как это выглядит в пределе? Как это выглядит в пределе?](https://habrastorage.org/getpro/habr/upload_files/fb5/c46/1e2/fb5c461e2a93fa294dbcee56b8f419aa.png)
Мы отслеживаем общее время, потраченное на создание новых фич, рефакторинг и багфиксинг. Мы поддерживаем максимальную скорость до 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)
contr4s
21.05.2024 21:15+2*Джун прочитал Game Programming Patterns*
Джун: лид, может будем использовать эти паттерны для написания чистого, понятного и оптимизированного кода?
Лид: нет, у нас уже есть паттерн в проекте
Паттерн в проекте: паттерн «Спагетти»
MrBrooks
... Эту запись оставил НЛО