Skip to main content

11.4 - Bridging to Unity

Now that you have a solid foundation in C# programming, it's time to bridge that knowledge to Unity game development. Unity uses C# as its primary scripting language, but it introduces its own architecture, patterns, and APIs that you'll need to understand to build games effectively.

In this section, we'll explore how C# is used within Unity and introduce key Unity-specific concepts that build upon your C# knowledge.

From Console to Unity: The Transition

So far, we've been working with console applications to learn C# fundamentals. Unity development differs in several important ways:

Console ApplicationsUnity Applications
Linear execution flowEvent-based and component-driven
Main method as entry pointMonoBehaviour lifecycle methods
Manual memory managementGarbage-collected with special considerations
Direct user input via consoleInput through Unity's Input system
Text-based outputVisual output through Unity's rendering system

The Unity Architecture

Component-Based Design

Unity uses a component-based architecture, which is different from the class inheritance hierarchies you might be familiar with from traditional object-oriented programming.

In Unity:

  • GameObjects are the fundamental objects in your scenes
  • Components are attached to GameObjects to provide specific functionality
  • MonoBehaviour is the base class for most of your custom components

This approach encourages composition over inheritance, allowing you to build complex objects by combining simpler components.

// Traditional OOP approach (less common in Unity)
public class Enemy : Character
{
public void Attack() { /* ... */ }
public void Move() { /* ... */ }
public void TakeDamage(int amount) { /* ... */ }
}

// Unity's component-based approach
// Each behavior is a separate component
public class Health : MonoBehaviour
{
public int currentHealth;
public int maxHealth = 100;

public void TakeDamage(int amount) { /* ... */ }
}

public class Movement : MonoBehaviour
{
public float speed = 5f;

private void Update()
{
// Handle movement
}
}

public class Combat : MonoBehaviour
{
public int damage = 10;

public void Attack() { /* ... */ }
}

// These components can be mixed and matched on different GameObjects

MonoBehaviour Lifecycle

When you create scripts in Unity, they typically inherit from MonoBehaviour, which provides a set of lifecycle methods that Unity calls at specific times:

public class MyGameObject : MonoBehaviour
{
// Called when the script instance is being loaded
private void Awake()
{
Debug.Log("Awake: Initialize essential components");
}

// Called before the first frame update, after Awake
private void Start()
{
Debug.Log("Start: Initialize after all objects are created");
}

// Called once per frame
private void Update()
{
Debug.Log("Update: Handle frame-by-frame logic");
}

// Called at fixed intervals (for physics calculations)
private void FixedUpdate()
{
Debug.Log("FixedUpdate: Handle physics-related updates");
}

// Called after all Update functions have been called
private void LateUpdate()
{
Debug.Log("LateUpdate: Handle camera follow or final adjustments");
}

// Called when the GameObject is destroyed
private void OnDestroy()
{
Debug.Log("OnDestroy: Clean up resources");
}

// Called when the GameObject becomes enabled and active
private void OnEnable()
{
Debug.Log("OnEnable: Object activated");
}

// Called when the GameObject becomes disabled or inactive
private void OnDisable()
{
Debug.Log("OnDisable: Object deactivated");
}
}

Understanding this lifecycle is crucial for proper game development in Unity:

  1. Initialization Phase:

    • Awake(): Initialize components, set up references
    • OnEnable(): Enable functionality when object becomes active
    • Start(): Final setup after all objects are initialized
  2. Execution Phase:

    • FixedUpdate(): Physics calculations (runs at fixed time intervals)
    • Update(): Regular game logic (runs once per frame)
    • LateUpdate(): Final adjustments after all Updates (camera follow, etc.)
  3. Cleanup Phase:

    • OnDisable(): Disable functionality when object becomes inactive
    • OnDestroy(): Clean up resources when object is destroyed

Coroutines

Unity introduces coroutines, which allow you to spread operations over several frames:

public class CoroutineExample : MonoBehaviour
{
private void Start()
{
StartCoroutine(FadeIn(2.0f));
}

private IEnumerator FadeIn(float duration)
{
float startTime = Time.time;
float endTime = startTime + duration;

// Get the renderer component
Renderer renderer = GetComponent<Renderer>();
Color startColor = renderer.material.color;
Color targetColor = new Color(startColor.r, startColor.g, startColor.b, 1.0f);

// Gradually change alpha over time
while (Time.time < endTime)
{
float progress = (Time.time - startTime) / duration;
renderer.material.color = Color.Lerp(startColor, targetColor, progress);

// Wait until the next frame before continuing
yield return null;
}

// Ensure we reach the target color exactly
renderer.material.color = targetColor;

Debug.Log("Fade completed");
}
}

Coroutines are useful for:

  • Animations and transitions
  • Delayed actions
  • Operations that need to wait for something
  • Spreading intensive calculations across multiple frames

Unity-Specific C# Features

SerializeField Attribute

Unity's Inspector can display and modify public fields, but what if you want to keep your field private while still exposing it in the Inspector?

public class Player : MonoBehaviour
{
// Public field - visible in Inspector and accessible from other scripts
public int health = 100;

// Private field with SerializeField - visible in Inspector but not accessible from other scripts
[SerializeField] private float _movementSpeed = 5f;

// Private field - not visible in Inspector and not accessible from other scripts
private int _ammo = 30;
}

Header and Tooltip Attributes

You can organize your Inspector fields with headers and tooltips:

public class Enemy : MonoBehaviour
{
[Header("Basic Stats")]
public int health = 100;
public int damage = 10;

[Header("Movement")]
[Tooltip("How fast the enemy moves in units per second")]
public float movementSpeed = 3f;
[Tooltip("How close the enemy needs to be to attack")]
public float attackRange = 2f;

[Header("Drops")]
public GameObject[] possibleDrops;
[Range(0, 1)] public float dropChance = 0.5f;
}

RequireComponent Attribute

You can ensure that certain components are always added along with your script:

// This will automatically add a Rigidbody component if one doesn't exist
[RequireComponent(typeof(Rigidbody))]
public class PlayerMovement : MonoBehaviour
{
private Rigidbody _rigidbody;

private void Awake()
{
// We can safely get the Rigidbody component without null checks
_rigidbody = GetComponent<Rigidbody>();
}
}

Applying C# Concepts in Unity

Let's see how the C# concepts you've learned apply to Unity development:

Classes and Objects

In Unity, your custom classes typically inherit from MonoBehaviour and are attached to GameObjects:

// A custom component for a health system
public class HealthSystem : MonoBehaviour
{
[SerializeField] private int _maxHealth = 100;
private int _currentHealth;

// Event for health changes
public event Action<int, int> OnHealthChanged; // (current, max)
public event Action OnDeath;

private void Start()
{
_currentHealth = _maxHealth;
}

public void TakeDamage(int amount)
{
_currentHealth -= amount;
_currentHealth = Mathf.Max(0, _currentHealth);

OnHealthChanged?.Invoke(_currentHealth, _maxHealth);

if (_currentHealth <= 0)
{
Die();
}
}

public void Heal(int amount)
{
_currentHealth += amount;
_currentHealth = Mathf.Min(_currentHealth, _maxHealth);

OnHealthChanged?.Invoke(_currentHealth, _maxHealth);
}

private void Die()
{
OnDeath?.Invoke();

// Disable this GameObject
gameObject.SetActive(false);
}
}

Inheritance and Polymorphism

While Unity favors composition, inheritance is still useful for shared functionality:

// Base class for all pickups
public abstract class Pickup : MonoBehaviour
{
[SerializeField] protected float _rotationSpeed = 90f;
[SerializeField] protected AudioClip _pickupSound;

protected virtual void Update()
{
// Rotate the pickup for visual effect
transform.Rotate(0, _rotationSpeed * Time.deltaTime, 0);
}

protected virtual void OnTriggerEnter(Collider other)
{
if (other.CompareTag("Player"))
{
// Play sound
if (_pickupSound != null)
{
AudioSource.PlayClipAtPoint(_pickupSound, transform.position);
}

// Apply pickup effect
ApplyEffect(other.gameObject);

// Destroy this pickup
Destroy(gameObject);
}
}

// Abstract method that derived classes must implement
protected abstract void ApplyEffect(GameObject player);
}

// Health pickup implementation
public class HealthPickup : Pickup
{
[SerializeField] private int _healAmount = 25;

protected override void ApplyEffect(GameObject player)
{
// Get the player's health component
HealthSystem healthSystem = player.GetComponent<HealthSystem>();

if (healthSystem != null)
{
// Heal the player
healthSystem.Heal(_healAmount);
}
}
}

// Ammo pickup implementation
public class AmmoPickup : Pickup
{
[SerializeField] private int _ammoAmount = 10;
[SerializeField] private string _weaponType = "Pistol";

protected override void ApplyEffect(GameObject player)
{
// Get the player's weapon system
WeaponSystem weaponSystem = player.GetComponent<WeaponSystem>();

if (weaponSystem != null)
{
// Add ammo to the specified weapon
weaponSystem.AddAmmo(_weaponType, _ammoAmount);
}
}
}

Interfaces

Interfaces are particularly useful in Unity for creating systems that can interact with various objects:

// Interface for objects that can take damage
public interface IDamageable
{
void TakeDamage(int amount);
bool IsAlive { get; }
}

// Interface for objects that can be interacted with
public interface IInteractable
{
string GetInteractionPrompt();
void Interact(GameObject interactor);
}

// A destructible crate that implements both interfaces
public class DestructibleCrate : MonoBehaviour, IDamageable, IInteractable
{
[SerializeField] private int _health = 50;
[SerializeField] private GameObject _destroyedVersion;
[SerializeField] private GameObject[] _possibleContents;

public bool IsAlive => _health > 0;

public void TakeDamage(int amount)
{
_health -= amount;

if (_health <= 0)
{
// Spawn the destroyed version
if (_destroyedVersion != null)
{
Instantiate(_destroyedVersion, transform.position, transform.rotation);
}

// Spawn random contents
if (_possibleContents.Length > 0)
{
int randomIndex = Random.Range(0, _possibleContents.Length);
Instantiate(_possibleContents[randomIndex], transform.position, Quaternion.identity);
}

// Destroy this crate
Destroy(gameObject);
}
}

public string GetInteractionPrompt()
{
return "Press E to open crate";
}

public void Interact(GameObject interactor)
{
// When interacted with, take damage to simulate opening/breaking
TakeDamage(_health);
}
}

// Player interaction system that works with any IInteractable
public class PlayerInteraction : MonoBehaviour
{
[SerializeField] private float _interactionRange = 2f;
[SerializeField] private LayerMask _interactableLayers;
[SerializeField] private TMPro.TextMeshProUGUI _promptText;

private IInteractable _currentInteractable;

private void Update()
{
// Check for interactable objects in range
CheckForInteractables();

// Handle interaction input
if (Input.GetKeyDown(KeyCode.E) && _currentInteractable != null)
{
_currentInteractable.Interact(gameObject);
}
}

private void CheckForInteractables()
{
// Cast a ray forward from the player
Ray ray = new Ray(transform.position, transform.forward);
RaycastHit hit;

if (Physics.Raycast(ray, out hit, _interactionRange, _interactableLayers))
{
// Check if the hit object is interactable
IInteractable interactable = hit.collider.GetComponent<IInteractable>();

if (interactable != null)
{
// Show interaction prompt
_currentInteractable = interactable;
_promptText.text = interactable.GetInteractionPrompt();
_promptText.gameObject.SetActive(true);
return;
}
}

// No interactable found
_currentInteractable = null;
_promptText.gameObject.SetActive(false);
}
}

Events and Delegates

Unity makes extensive use of events for communication between components:

public class GameManager : MonoBehaviour
{
// Singleton instance
public static GameManager Instance { get; private set; }

// Events for game state changes
public event Action OnGameStart;
public event Action OnGamePause;
public event Action OnGameResume;
public event Action OnGameOver;
public event Action<int> OnScoreChanged;

// Game state
private bool _isGamePaused;
private int _score;

private void Awake()
{
// Singleton pattern
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject);
}
else
{
Destroy(gameObject);
}
}

public void StartGame()
{
_score = 0;
_isGamePaused = false;
OnGameStart?.Invoke();
OnScoreChanged?.Invoke(_score);
}

public void PauseGame()
{
if (!_isGamePaused)
{
_isGamePaused = true;
Time.timeScale = 0f; // Freeze the game
OnGamePause?.Invoke();
}
}

public void ResumeGame()
{
if (_isGamePaused)
{
_isGamePaused = false;
Time.timeScale = 1f; // Unfreeze the game
OnGameResume?.Invoke();
}
}

public void GameOver()
{
OnGameOver?.Invoke();
}

public void AddScore(int points)
{
_score += points;
OnScoreChanged?.Invoke(_score);
}
}

// UI Manager that listens to game events
public class UIManager : MonoBehaviour
{
[SerializeField] private GameObject _mainMenuPanel;
[SerializeField] private GameObject _gameplayPanel;
[SerializeField] private GameObject _pauseMenuPanel;
[SerializeField] private GameObject _gameOverPanel;
[SerializeField] private TMPro.TextMeshProUGUI _scoreText;

private void OnEnable()
{
// Subscribe to game events
GameManager.Instance.OnGameStart += HandleGameStart;
GameManager.Instance.OnGamePause += HandleGamePause;
GameManager.Instance.OnGameResume += HandleGameResume;
GameManager.Instance.OnGameOver += HandleGameOver;
GameManager.Instance.OnScoreChanged += HandleScoreChanged;
}

private void OnDisable()
{
// Unsubscribe from game events
if (GameManager.Instance != null)
{
GameManager.Instance.OnGameStart -= HandleGameStart;
GameManager.Instance.OnGamePause -= HandleGamePause;
GameManager.Instance.OnGameResume -= HandleGameResume;
GameManager.Instance.OnGameOver -= HandleGameOver;
GameManager.Instance.OnScoreChanged -= HandleScoreChanged;
}
}

private void HandleGameStart()
{
_mainMenuPanel.SetActive(false);
_gameplayPanel.SetActive(true);
_pauseMenuPanel.SetActive(false);
_gameOverPanel.SetActive(false);
}

private void HandleGamePause()
{
_pauseMenuPanel.SetActive(true);
}

private void HandleGameResume()
{
_pauseMenuPanel.SetActive(false);
}

private void HandleGameOver()
{
_gameplayPanel.SetActive(false);
_gameOverPanel.SetActive(true);
}

private void HandleScoreChanged(int newScore)
{
_scoreText.text = $"Score: {newScore}";
}
}

Collections

Unity games often use collections to manage game objects and data:

public class EnemyManager : MonoBehaviour
{
[SerializeField] private GameObject[] _enemyPrefabs;
[SerializeField] private Transform[] _spawnPoints;
[SerializeField] private int _maxEnemies = 10;
[SerializeField] private float _spawnInterval = 3f;

private List<GameObject> _activeEnemies = new List<GameObject>();
private Dictionary<string, int> _enemyKillCount = new Dictionary<string, int>();
private float _nextSpawnTime;

private void Update()
{
// Spawn enemies at intervals if below max count
if (Time.time >= _nextSpawnTime && _activeEnemies.Count < _maxEnemies)
{
SpawnRandomEnemy();
_nextSpawnTime = Time.time + _spawnInterval;
}

// Clean up destroyed enemies from the list
_activeEnemies.RemoveAll(enemy => enemy == null);
}

private void SpawnRandomEnemy()
{
if (_enemyPrefabs.Length == 0 || _spawnPoints.Length == 0)
return;

// Select random enemy prefab and spawn point
GameObject enemyPrefab = _enemyPrefabs[Random.Range(0, _enemyPrefabs.Length)];
Transform spawnPoint = _spawnPoints[Random.Range(0, _spawnPoints.Length)];

// Spawn the enemy
GameObject enemy = Instantiate(enemyPrefab, spawnPoint.position, spawnPoint.rotation);
_activeEnemies.Add(enemy);

// Set up enemy death callback
Enemy enemyComponent = enemy.GetComponent<Enemy>();
if (enemyComponent != null)
{
enemyComponent.OnDeath += () => HandleEnemyDeath(enemyComponent.EnemyType);
}
}

private void HandleEnemyDeath(string enemyType)
{
// Update kill count
if (_enemyKillCount.ContainsKey(enemyType))
{
_enemyKillCount[enemyType]++;
}
else
{
_enemyKillCount[enemyType] = 1;
}

// Log kill statistics
Debug.Log($"Enemy killed: {enemyType}. Total kills of this type: {_enemyKillCount[enemyType]}");
}

public int GetTotalKillCount()
{
int total = 0;
foreach (var count in _enemyKillCount.Values)
{
total += count;
}
return total;
}

public int GetKillCount(string enemyType)
{
return _enemyKillCount.TryGetValue(enemyType, out int count) ? count : 0;
}
}

Unity-Specific Programming Patterns

Singleton Pattern

The Singleton pattern ensures only one instance of a class exists and provides global access to it:

public class AudioManager : MonoBehaviour
{
// Static instance accessible from anywhere
public static AudioManager Instance { get; private set; }

[SerializeField] private AudioSource _musicSource;
[SerializeField] private AudioSource _sfxSource;
[SerializeField] private AudioClip[] _musicTracks;
[SerializeField] private AudioClip[] _soundEffects;

private Dictionary<string, AudioClip> _sfxLookup = new Dictionary<string, AudioClip>();

private void Awake()
{
// Singleton implementation
if (Instance == null)
{
Instance = this;
DontDestroyOnLoad(gameObject); // Persist between scenes

// Initialize the sound effect lookup
foreach (AudioClip clip in _soundEffects)
{
_sfxLookup[clip.name] = clip;
}
}
else
{
// Destroy duplicate instances
Destroy(gameObject);
}
}

public void PlayMusic(int trackIndex)
{
if (trackIndex >= 0 && trackIndex < _musicTracks.Length)
{
_musicSource.clip = _musicTracks[trackIndex];
_musicSource.Play();
}
}

public void PlaySoundEffect(string sfxName)
{
if (_sfxLookup.TryGetValue(sfxName, out AudioClip clip))
{
_sfxSource.PlayOneShot(clip);
}
else
{
Debug.LogWarning($"Sound effect not found: {sfxName}");
}
}

public void SetMusicVolume(float volume)
{
_musicSource.volume = Mathf.Clamp01(volume);
}

public void SetSfxVolume(float volume)
{
_sfxSource.volume = Mathf.Clamp01(volume);
}
}

// Usage from any script:
// AudioManager.Instance.PlaySoundEffect("Explosion");

Object Pooling

Object pooling improves performance by reusing objects instead of constantly creating and destroying them:

public class ObjectPool : MonoBehaviour
{
[System.Serializable]
public class Pool
{
public string tag;
public GameObject prefab;
public int size;
}

public List<Pool> pools;

// Dictionary to store pools of objects
private Dictionary<string, Queue<GameObject>> _poolDictionary;

private void Awake()
{
_poolDictionary = new Dictionary<string, Queue<GameObject>>();

// Create pools
foreach (Pool pool in pools)
{
Queue<GameObject> objectPool = new Queue<GameObject>();

// Create objects and add them to the pool
for (int i = 0; i < pool.size; i++)
{
GameObject obj = Instantiate(pool.prefab);
obj.SetActive(false);
objectPool.Enqueue(obj);

// Parent to this object for organization
obj.transform.SetParent(transform);
}

// Add the pool to the dictionary
_poolDictionary.Add(pool.tag, objectPool);
}
}

public GameObject SpawnFromPool(string tag, Vector3 position, Quaternion rotation)
{
// Check if the pool exists
if (!_poolDictionary.ContainsKey(tag))
{
Debug.LogWarning($"Pool with tag {tag} doesn't exist.");
return null;
}

// Get an object from the pool
GameObject objectToSpawn = _poolDictionary[tag].Dequeue();

// If all objects are in use, we might need to expand the pool
if (objectToSpawn == null)
{
Debug.LogWarning($"Pool {tag} is empty. Consider increasing its size.");
return null;
}

// Activate the object and set its position and rotation
objectToSpawn.SetActive(true);
objectToSpawn.transform.position = position;
objectToSpawn.transform.rotation = rotation;

// Get the pooled object component if it exists
IPooledObject pooledObj = objectToSpawn.GetComponent<IPooledObject>();
if (pooledObj != null)
{
pooledObj.OnObjectSpawn();
}

// Add the object back to the queue for later reuse
_poolDictionary[tag].Enqueue(objectToSpawn);

return objectToSpawn;
}

public void ReturnToPool(string tag, GameObject obj)
{
obj.SetActive(false);
}
}

// Interface for objects that need special initialization when spawned
public interface IPooledObject
{
void OnObjectSpawn();
}

// Example of a pooled bullet
public class Bullet : MonoBehaviour, IPooledObject
{
[SerializeField] private float _speed = 10f;
[SerializeField] private float _lifetime = 3f;
[SerializeField] private int _damage = 10;

private Rigidbody _rigidbody;
private string _poolTag;
private float _spawnTime;

private void Awake()
{
_rigidbody = GetComponent<Rigidbody>();
}

private void Update()
{
// Deactivate after lifetime expires
if (Time.time > _spawnTime + _lifetime)
{
gameObject.SetActive(false);
}
}

public void OnObjectSpawn()
{
// Reset the bullet when it's spawned from the pool
_spawnTime = Time.time;

// Apply forward force
_rigidbody.velocity = transform.forward * _speed;
}

private void OnTriggerEnter(Collider other)
{
// Check if we hit something damageable
IDamageable damageable = other.GetComponent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(_damage);
}

// Deactivate the bullet
gameObject.SetActive(false);
}

public void SetPoolTag(string tag)
{
_poolTag = tag;
}
}

// Usage example
public class PlayerWeapon : MonoBehaviour
{
[SerializeField] private Transform _firePoint;
[SerializeField] private string _bulletPoolTag = "Bullet";
[SerializeField] private float _fireRate = 0.2f;

private ObjectPool _objectPool;
private float _nextFireTime;

private void Start()
{
_objectPool = FindObjectOfType<ObjectPool>();
}

private void Update()
{
if (Input.GetButton("Fire1") && Time.time >= _nextFireTime)
{
Shoot();
_nextFireTime = Time.time + _fireRate;
}
}

private void Shoot()
{
// Spawn a bullet from the pool
_objectPool.SpawnFromPool(_bulletPoolTag, _firePoint.position, _firePoint.rotation);
}
}

State Machine Pattern

State machines are useful for managing complex behaviors with distinct states:

// Base state interface
public interface IState
{
void Enter();
void Update();
void Exit();
}

// State machine that manages states
public class StateMachine
{
private IState _currentState;

public void Initialize(IState startingState)
{
_currentState = startingState;
_currentState.Enter();
}

public void ChangeState(IState newState)
{
_currentState.Exit();
_currentState = newState;
_currentState.Enter();
}

public void Update()
{
if (_currentState != null)
{
_currentState.Update();
}
}
}

// Enemy controller using a state machine
public class EnemyController : MonoBehaviour
{
[SerializeField] private float _patrolSpeed = 2f;
[SerializeField] private float _chaseSpeed = 5f;
[SerializeField] private float _attackRange = 2f;
[SerializeField] private float _detectionRange = 10f;
[SerializeField] private Transform[] _patrolPoints;

private StateMachine _stateMachine;
private Transform _playerTransform;
private int _currentPatrolIndex;

// States
private PatrolState _patrolState;
private ChaseState _chaseState;
private AttackState _attackState;

private void Awake()
{
_stateMachine = new StateMachine();

// Find the player
_playerTransform = GameObject.FindGameObjectWithTag("Player").transform;

// Create states
_patrolState = new PatrolState(this, _stateMachine);
_chaseState = new ChaseState(this, _stateMachine);
_attackState = new AttackState(this, _stateMachine);
}

private void Start()
{
// Start in patrol state
_stateMachine.Initialize(_patrolState);
}

private void Update()
{
_stateMachine.Update();
}

// State implementations
private class PatrolState : IState
{
private EnemyController _enemy;
private StateMachine _stateMachine;

public PatrolState(EnemyController enemy, StateMachine stateMachine)
{
_enemy = enemy;
_stateMachine = stateMachine;
}

public void Enter()
{
Debug.Log("Entering Patrol State");
}

public void Update()
{
// Move to patrol point
if (_enemy._patrolPoints.Length > 0)
{
Transform target = _enemy._patrolPoints[_enemy._currentPatrolIndex];
_enemy.transform.position = Vector3.MoveTowards(
_enemy.transform.position,
target.position,
_enemy._patrolSpeed * Time.deltaTime
);

// If reached patrol point, move to next one
if (Vector3.Distance(_enemy.transform.position, target.position) < 0.1f)
{
_enemy._currentPatrolIndex = (_enemy._currentPatrolIndex + 1) % _enemy._patrolPoints.Length;
}
}

// Check if player is in detection range
float distanceToPlayer = Vector3.Distance(_enemy.transform.position, _enemy._playerTransform.position);
if (distanceToPlayer <= _enemy._detectionRange)
{
_stateMachine.ChangeState(_enemy._chaseState);
}
}

public void Exit()
{
Debug.Log("Exiting Patrol State");
}
}

private class ChaseState : IState
{
private EnemyController _enemy;
private StateMachine _stateMachine;

public ChaseState(EnemyController enemy, StateMachine stateMachine)
{
_enemy = enemy;
_stateMachine = stateMachine;
}

public void Enter()
{
Debug.Log("Entering Chase State");
}

public void Update()
{
// Move towards player
_enemy.transform.position = Vector3.MoveTowards(
_enemy.transform.position,
_enemy._playerTransform.position,
_enemy._chaseSpeed * Time.deltaTime
);

// Check distance to player
float distanceToPlayer = Vector3.Distance(_enemy.transform.position, _enemy._playerTransform.position);

if (distanceToPlayer <= _enemy._attackRange)
{
// Close enough to attack
_stateMachine.ChangeState(_enemy._attackState);
}
else if (distanceToPlayer > _enemy._detectionRange)
{
// Player escaped, go back to patrol
_stateMachine.ChangeState(_enemy._patrolState);
}
}

public void Exit()
{
Debug.Log("Exiting Chase State");
}
}

private class AttackState : IState
{
private EnemyController _enemy;
private StateMachine _stateMachine;
private float _attackCooldown = 1f;
private float _nextAttackTime;

public AttackState(EnemyController enemy, StateMachine stateMachine)
{
_enemy = enemy;
_stateMachine = stateMachine;
}

public void Enter()
{
Debug.Log("Entering Attack State");
_nextAttackTime = Time.time + _attackCooldown;
}

public void Update()
{
// Face the player
_enemy.transform.LookAt(_enemy._playerTransform);

// Attack when cooldown is ready
if (Time.time >= _nextAttackTime)
{
Attack();
_nextAttackTime = Time.time + _attackCooldown;
}

// Check distance to player
float distanceToPlayer = Vector3.Distance(_enemy.transform.position, _enemy._playerTransform.position);

if (distanceToPlayer > _enemy._attackRange)
{
// Player moved out of attack range, chase them
_stateMachine.ChangeState(_enemy._chaseState);
}
}

private void Attack()
{
Debug.Log("Enemy attacks!");

// Implement attack logic here
// For example, raycast to check if player is hit
RaycastHit hit;
if (Physics.Raycast(_enemy.transform.position, _enemy.transform.forward, out hit, _enemy._attackRange))
{
if (hit.collider.CompareTag("Player"))
{
IDamageable damageable = hit.collider.GetComponent<IDamageable>();
if (damageable != null)
{
damageable.TakeDamage(10);
}
}
}
}

public void Exit()
{
Debug.Log("Exiting Attack State");
}
}
}

Practical Example: Inventory System

Let's create a simple inventory system that demonstrates how to apply C# concepts in Unity:

// Item definition
[System.Serializable]
public class ItemDefinition
{
public string id;
public string displayName;
public Sprite icon;
public ItemType type;
public bool isStackable;
public int maxStackSize = 99;
[TextArea] public string description;
}

public enum ItemType
{
Weapon,
Armor,
Consumable,
QuestItem,
Material
}

// Inventory item (instance of an item in the inventory)
[System.Serializable]
public class InventoryItem
{
public ItemDefinition itemDefinition;
public int quantity;

public InventoryItem(ItemDefinition definition, int quantity = 1)
{
this.itemDefinition = definition;
this.quantity = quantity;
}
}

// Inventory manager
public class InventoryManager : MonoBehaviour
{
[SerializeField] private int _inventorySize = 20;
[SerializeField] private ItemDatabase _itemDatabase;

private List<InventoryItem> _items = new List<InventoryItem>();

// Events
public event Action<InventoryItem> OnItemAdded;
public event Action<InventoryItem> OnItemRemoved;
public event Action OnInventoryChanged;

private void Start()
{
// Initialize empty inventory slots
for (int i = 0; i < _inventorySize; i++)
{
_items.Add(null);
}
}

public bool AddItem(string itemId, int quantity = 1)
{
// Get item definition from database
ItemDefinition itemDef = _itemDatabase.GetItem(itemId);
if (itemDef == null)
{
Debug.LogWarning($"Item with ID {itemId} not found in database");
return false;
}

return AddItem(new InventoryItem(itemDef, quantity));
}

public bool AddItem(InventoryItem item)
{
if (item == null || item.quantity <= 0)
return false;

// If item is stackable, try to stack with existing items
if (item.itemDefinition.isStackable)
{
for (int i = 0; i < _items.Count; i++)
{
if (_items[i] != null &&
_items[i].itemDefinition.id == item.itemDefinition.id &&
_items[i].quantity < item.itemDefinition.maxStackSize)
{
// Calculate how much we can add to this stack
int spaceInStack = item.itemDefinition.maxStackSize - _items[i].quantity;
int amountToAdd = Mathf.Min(spaceInStack, item.quantity);

// Add to existing stack
_items[i].quantity += amountToAdd;
item.quantity -= amountToAdd;

// If we've added all items, we're done
if (item.quantity <= 0)
{
OnInventoryChanged?.Invoke();
return true;
}
}
}
}

// If we still have items to add, find an empty slot
if (item.quantity > 0)
{
for (int i = 0; i < _items.Count; i++)
{
if (_items[i] == null)
{
// Add to empty slot
_items[i] = new InventoryItem(item.itemDefinition, item.quantity);
OnItemAdded?.Invoke(_items[i]);
OnInventoryChanged?.Invoke();
return true;
}
}
}

// If we get here, inventory is full
Debug.Log("Inventory is full");
return false;
}

public bool RemoveItem(string itemId, int quantity = 1)
{
if (quantity <= 0)
return false;

int remainingToRemove = quantity;

// Find items matching the ID
for (int i = 0; i < _items.Count; i++)
{
if (_items[i] != null && _items[i].itemDefinition.id == itemId)
{
// Calculate how much we can remove from this stack
int amountToRemove = Mathf.Min(remainingToRemove, _items[i].quantity);

// Remove from stack
_items[i].quantity -= amountToRemove;
remainingToRemove -= amountToRemove;

// If stack is empty, remove it
if (_items[i].quantity <= 0)
{
InventoryItem removedItem = _items[i];
_items[i] = null;
OnItemRemoved?.Invoke(removedItem);
}

// If we've removed all requested items, we're done
if (remainingToRemove <= 0)
{
OnInventoryChanged?.Invoke();
return true;
}
}
}

// If we get here, we couldn't remove all requested items
OnInventoryChanged?.Invoke();
return remainingToRemove == 0;
}

public int GetItemCount(string itemId)
{
int count = 0;

foreach (var item in _items)
{
if (item != null && item.itemDefinition.id == itemId)
{
count += item.quantity;
}
}

return count;
}

public InventoryItem GetItemAt(int slotIndex)
{
if (slotIndex >= 0 && slotIndex < _items.Count)
{
return _items[slotIndex];
}

return null;
}

public bool HasItem(string itemId, int quantity = 1)
{
return GetItemCount(itemId) >= quantity;
}

public void UseItem(int slotIndex)
{
InventoryItem item = GetItemAt(slotIndex);

if (item == null)
return;

// Handle item use based on type
switch (item.itemDefinition.type)
{
case ItemType.Consumable:
// Use consumable item
Debug.Log($"Used consumable: {item.itemDefinition.displayName}");
RemoveItem(item.itemDefinition.id, 1);
break;

case ItemType.Weapon:
case ItemType.Armor:
// Equip item
Debug.Log($"Equipped: {item.itemDefinition.displayName}");
break;

case ItemType.QuestItem:
Debug.Log($"This is a quest item: {item.itemDefinition.displayName}");
break;

default:
Debug.Log($"Item: {item.itemDefinition.displayName}");
break;
}
}
}

// Item database to store all item definitions
public class ItemDatabase : MonoBehaviour
{
[SerializeField] private List<ItemDefinition> _items = new List<ItemDefinition>();
private Dictionary<string, ItemDefinition> _itemLookup = new Dictionary<string, ItemDefinition>();

private void Awake()
{
// Build lookup dictionary for fast access
foreach (var item in _items)
{
_itemLookup[item.id] = item;
}
}

public ItemDefinition GetItem(string id)
{
if (_itemLookup.TryGetValue(id, out ItemDefinition item))
{
return item;
}

return null;
}

public List<ItemDefinition> GetAllItems()
{
return new List<ItemDefinition>(_items);
}
}

// UI for the inventory
public class InventoryUI : MonoBehaviour
{
[SerializeField] private InventoryManager _inventory;
[SerializeField] private Transform _slotsContainer;
[SerializeField] private GameObject _slotPrefab;
[SerializeField] private ItemTooltip _tooltip;

private List<InventorySlotUI> _slots = new List<InventorySlotUI>();

private void Start()
{
// Create inventory slots
for (int i = 0; i < 20; i++)
{
GameObject slotObj = Instantiate(_slotPrefab, _slotsContainer);
InventorySlotUI slot = slotObj.GetComponent<InventorySlotUI>();
slot.Initialize(i, _inventory, this);
_slots.Add(slot);
}

// Subscribe to inventory events
_inventory.OnInventoryChanged += UpdateUI;

// Initial UI update
UpdateUI();
}

private void OnDestroy()
{
// Unsubscribe from events
if (_inventory != null)
{
_inventory.OnInventoryChanged -= UpdateUI;
}
}

private void UpdateUI()
{
for (int i = 0; i < _slots.Count; i++)
{
InventoryItem item = _inventory.GetItemAt(i);
_slots[i].UpdateSlot(item);
}
}

public void ShowTooltip(InventoryItem item, Vector2 position)
{
if (item != null)
{
_tooltip.ShowTooltip(item, position);
}
}

public void HideTooltip()
{
_tooltip.HideTooltip();
}
}

// Individual inventory slot UI
public class InventorySlotUI : MonoBehaviour, IPointerClickHandler, IPointerEnterHandler, IPointerExitHandler
{
[SerializeField] private Image _iconImage;
[SerializeField] private TMPro.TextMeshProUGUI _quantityText;
[SerializeField] private GameObject _highlightObject;

private int _slotIndex;
private InventoryManager _inventory;
private InventoryUI _inventoryUI;
private InventoryItem _currentItem;

public void Initialize(int slotIndex, InventoryManager inventory, InventoryUI inventoryUI)
{
_slotIndex = slotIndex;
_inventory = inventory;
_inventoryUI = inventoryUI;
}

public void UpdateSlot(InventoryItem item)
{
_currentItem = item;

if (item != null)
{
_iconImage.sprite = item.itemDefinition.icon;
_iconImage.enabled = true;

if (item.itemDefinition.isStackable && item.quantity > 1)
{
_quantityText.text = item.quantity.ToString();
_quantityText.gameObject.SetActive(true);
}
else
{
_quantityText.gameObject.SetActive(false);
}
}
else
{
_iconImage.enabled = false;
_quantityText.gameObject.SetActive(false);
}
}

public void OnPointerClick(PointerEventData eventData)
{
if (eventData.button == PointerEventData.InputButton.Left)
{
// Left click - use item
_inventory.UseItem(_slotIndex);
}
else if (eventData.button == PointerEventData.InputButton.Right)
{
// Right click - show context menu (not implemented)
Debug.Log($"Right-clicked slot {_slotIndex}");
}
}

public void OnPointerEnter(PointerEventData eventData)
{
_highlightObject.SetActive(true);

if (_currentItem != null)
{
_inventoryUI.ShowTooltip(_currentItem, eventData.position);
}
}

public void OnPointerExit(PointerEventData eventData)
{
_highlightObject.SetActive(false);
_inventoryUI.HideTooltip();
}
}

// Tooltip for displaying item details
public class ItemTooltip : MonoBehaviour
{
[SerializeField] private TMPro.TextMeshProUGUI _titleText;
[SerializeField] private TMPro.TextMeshProUGUI _typeText;
[SerializeField] private TMPro.TextMeshProUGUI _descriptionText;

private RectTransform _rectTransform;

private void Awake()
{
_rectTransform = GetComponent<RectTransform>();
gameObject.SetActive(false);
}

public void ShowTooltip(InventoryItem item, Vector2 position)
{
_titleText.text = item.itemDefinition.displayName;
_typeText.text = item.itemDefinition.type.ToString();
_descriptionText.text = item.itemDefinition.description;

// Position the tooltip
_rectTransform.position = position;

// Make sure tooltip stays on screen
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas != null)
{
Vector2 size = _rectTransform.sizeDelta;
Vector2 viewportPosition = Camera.main.ScreenToViewportPoint(position);

if (viewportPosition.x + size.x / canvas.pixelRect.width > 0.95f)
{
position.x -= size.x;
}

if (viewportPosition.y + size.y / canvas.pixelRect.height > 0.95f)
{
position.y -= size.y;
}

_rectTransform.position = position;
}

gameObject.SetActive(true);
}

public void HideTooltip()
{
gameObject.SetActive(false);
}
}

Conclusion

In this section, we've explored how to bridge your C# knowledge to Unity game development. We've covered Unity's component-based architecture, the MonoBehaviour lifecycle, and several Unity-specific programming patterns and features.

As you continue your journey into Unity development, remember that the C# fundamentals you've learned in this course provide a strong foundation. Unity adds its own layer of complexity and specialized APIs, but the core programming principles remain the same.

The next step is to start building small Unity projects that apply these concepts. Begin with simple mechanics and gradually work your way up to more complex systems. The inventory system example we've explored demonstrates how various C# concepts come together in a practical Unity feature.

Unity Relevance

Unity's documentation is an invaluable resource as you continue learning. The Unity Manual and Scripting API provide detailed information on all Unity features and APIs.

Additionally, Unity offers many sample projects and tutorials that demonstrate best practices for game development. The Unity Learn platform provides structured courses for all skill levels.

Remember that Unity development is a vast field, and no one knows everything. Focus on building a strong foundation, and then specialize in the areas that interest you most, whether that's gameplay programming, graphics, AI, or another aspect of game development.

In the next section, we'll explore Unity's .NET profile and libraries, which will help you understand how C# and .NET integrate with the Unity engine.