Skip to main content

Movable Objects Controller

Written by: Akram Taghavi-Burris | © Copyright 2024
Status

In a game development a controller class, much like a manager class is simply a class whose specific responsibility is to controller another class or interface of a specific type. The suffix "Controller" is used for these classes followed by the name of the class or type of game object which the class controls.

Controller Classes

  • Purpose: Generally focus on controlling specific behaviors or entities within the game, like player movement or NPC actions.
    • Examples: PlayerController, EnemyController, MoveableObjectController.
  • Characteristics:
    • Often implements a singletons but could be instantiated multiple times (e.g., one per player or NPC).
    • Directly manipulates the state of a game object (e.g., movement, animations).
    • Often respond to input or other external factors to update an object's behavior.

MovableObjectController

In our game there is already a IMovable interface that defines required movement across different game objects. There is also an abstract MovableObjectBase class that allows us to implement the IMovable interface while defining common functionality. While both the interface and abstract base class provide us with modularity and flexibility, there may be times in which a we might need to control all movable objects at once. Some common use case include:

  • Pausing Gameplay: In many games, players may need to pause the action. The controller can halt all moving objects seamlessly with a single command, providing a smooth user experience.
  • Dynamic Parameter Alteration: During gameplay, developers may wish to alter the movement parameters (e.g., speed or direction) of multiple objects simultaneously. The controller allows for efficient updates across all registered objects.
  • Complex Interactions: In scenarios where multiple movable objects must interact (e.g., racing games or puzzle-solving scenarios), the controller facilitates coordination and event handling, enhancing gameplay mechanics.

Implementing a Controller

The MovableObjectController serves as a centralized management system for all movable objects within the game environment. These features will include:

  • Singleton pattern: The proposed MovableObjectControler class will be a standard class, however, since we do not want multiple controllers overwriting behaviors of movable objects we will implement a singleton pattern. To implement this pattern we will inherit the properties of a generic singleton class, this ensures that the pattern is properly implemented.

  • Register and Unregister objects: we will need a method to reference all movable objects in the scene and when no longer necessary we might want to unregister these objects. To do this we will need a list of sorts to contain all the registered movable objects.

    • IMovable List: For referencing our movable objects, we have chosen to use the IMovable interface rather than the MovableObjectBase class. This choice allows the controller to manage a broader range of objects, as any object implementing IMovable can be registered, enhancing flexibility and modularity.
Choosing a Collection Type

In order to keep track of our registered objects we need some sort of collection. C# offers several different types of collection data types each having their own use case.

  • Arrays add items in a numeric order, but have a fixed size, which make it less than ideal when you need to dynamically add and remove objects, whose count may vary.
  • Lists similar to arrays add items in numeric order, but have not fixed size. They are easy to implement, however their size might slow-down performance.
  • Hash Set are lists with no ordering and no duplications. Objects added to the hash set can only be added once and because they are not indexing the items there is less time complexity for adding the items to the collection as opposed to a list. Which type of collection to use really depends on the needs of the project.

In this Scenario where the MovableObjectController might literally need to control a ton of movable objects a Hash Set is the most efficient option.

  • StopAll: stops the movement and animations of all registered movable objects, providing a smooth pause in gameplay. This method will essentially call the Stop() method defined in the IMovable interface for each registered object.

  • ResumeAll: Restores movement and animations for all previously stopped movable objects, allowing gameplay to continue without interruption. Similar to the stop method, this will call the Move() method of IMovable.

Keep It Simple

While there may be use cases that require specific behaviors when objects resume movement, such functionality is not currently implemented in our IMovable interface or the MovableObjectBase class. We can certainly add these capabilities in the future, but for this project, our primary goal is to ensure that objects can simply move again. Therefore, we can effectively leverage the existing Move() method to achieve this straightforward functionality.

  • ResetAll: Resets the state of all movable objects, including their position and movement parameters, ensuring they return to a predefined state as needed.This method will also call the corresponding Reset() method from the IMovable interface.

MovableObjectConroller

Based on these observations our MovableObjectController class would look something like the following.

MovableObjectController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//Controller will inherent the Singleton pattern
public class MovableObjectController : Singelton<MovableObjectController>
{
// HashSet for all movableObjects (ensures uniqueness and faster lookup)
private HashSet<IMovable> _movableObjectRegistry = new HashSet<IMovable>();

// Registers a movable object (adds it to the HashSet if it's not already registered)
public void RegisterMovableObject(IMovable movableObject)
{
// Check if the object is not null and is not already registered
if (movableObject != null && !_movableObjectRegistry.Contains(movableObject))
{
// Add the object to the HashSet
_movableObjectRegistry.Add(movableObject);
}//end If

}//end RegisterMovableObject()

// Unregisters a movable object (removes it from the HashSet if it's registered)
public void UnregisterMovableObject(IMovable movableObject)
{
// Check if the object is not null and is currently registered
if (movableObject != null && _movableObjectRegistry.Contains(movableObject))
{
// Remove the object from the HashSet
_movableObjectRegistry.Remove(movableObject);
}//end If

}//end UnregisterMovableObject()

// Stops all registered movable objects
public void StopAll()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.Stop();
}//end foreach

}//end StopAllMovableObjects()

// Resumes all registered movable objects
public void ResumeAll()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.Move();
}//end foreach

}//end ResumeAllMovableObjects()

// Resets all registered movable objects to their default state
public void ResetAll()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.Reset();
}//end foreach

}//end ResetAllMovableObjects()


}

Dynamic Updates

In dynamic gameplay scenarios, the ability to adjust the speed and direction of movable objects is crucial for creating responsive and engaging experiences. The MovableObjectController plays a vital role in managing these updates, ensuring that all registered objects can adapt to changes in real-time.

  • UpdateSpeedForAll: This method dynamically alters the speed of all registered movable objects during gameplay. By applying speed adjustments simultaneously, it enhances real-time gameplay control, allowing developers to create fluid and adaptable mechanics.

    • The use of parameters, such as float? speedValue = null, allows for flexibility, if a new speed is provided, it will be applied; otherwise, existing speeds can be modified using the speed multiplier and delta values. The check if (speedValue.HasValue) ensures that only non-null speed values are processed, enabling efficient updates based on game conditions.
  • ReverseDirectionForAll: This method reverses the direction of all registered movable objects, allowing for quick changes in gameplay mechanics, such as turning or retreating. It effectively utilizes the ReverseDirection method defined in the IMovable interface, which provides a consistent way to handle directional changes across different movable object implementations.

Value Types vs. Reference Types

In C#, data types are categorized into value types and reference types. Value types (like int, float, and struct) store the actual data directly. Because of this, they cannot hold a null value, which means they always have a valid value (even if it's the default value, like 0 for integers or 0.0f for floats).

On the other hand, reference types (such as string, class, and object) store a reference to the memory location where the actual data is held. This means they can be null, indicating that they do not point to any valid object instance.

In scenarios where you want to allow a value type to have a null value, indicating the absence of a meaningful value, you can use nullable value types by appending a ? to the type. For instance, declaring float? speedValue allows speedValue to hold either a valid float value or null. This is essential for scenarios where it's possible for a value not to be applicable, enabling more robust and error-free code.

To check if a nullable value type has been assigned a valid number, you can use the HasValue property. For example, speedValue.HasValue returns true if speedValue contains a valid float and false if it is null. This is a safer alternative to using != null, which applies only to reference types. Using HasValue ensures that you are working with a valid float before proceeding with operations that require a number, effectively reducing the risk of runtime errors associated with uninitialized or absent values.

MovableObjectConroller

To implement these dynamic updates our MovableObjectController would be updated as follows:

MovableObjectController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

//Controller will inherent the Singleton pattern
public class MovableObjectController : Singelton<MovableObjectController>
{
// HashSet for all movableObjects (ensures uniqueness and faster lookup)
private HashSet<IMovable> _movableObjectRegistry = new HashSet<IMovable>();

// Registers a movable object (adds it to the HashSet if it's not already registered)
public void RegisterMovableObject(IMovable movableObject)
{
// Check if the object is not null and is not already registered
if (movableObject != null && !_movableObjectRegistry.Contains(movableObject))
{
// Add the object to the HashSet
_movableObjectRegistry.Add(movableObject);
}//end If

}//end RegisterMovableObject()

// Unregisters a movable object (removes it from the HashSet if it's registered)
public void UnregisterMovableObject(IMovable movableObject)
{
// Check if the object is not null and is currently registered
if (movableObject != null && _movableObjectRegistry.Contains(movableObject))
{
// Remove the object from the HashSet
_movableObjectRegistry.Remove(movableObject);
}//end If

}//end UnregisterMovableObject()

// Stops all registered movable objects
public void StopAllMovableObjects()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.Stop();
}//end foreach

}//end StopAllMovableObjects()

// Resumes all registered movable objects
public void ResumeAllMovableObjects()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.Move();
}//end foreach

}//end ResumeAllMovableObjects()

// Resets all registered movable objects to their default state
public void ResetAllMovableObjects()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.Reset();
}//end foreach

}//end ResetAllMovableObjects()

/// <summary>
/// Updates the speed of all registered movable objects.
/// </summary>
/// <param name="speedValue">An optional value to set the speed. If provided, the speed will be updated using the multiplier and delta values.</param>
/// <param name="speedMultiplier">A multiplier to adjust the speed. Values less than 1 will decrease speed, while values greater than 1 will increase it.</param>
/// <param name="speedDelta">An additional value to be added to or subtracted from the speed after applying the multiplier.</param>
public void UpdateSpeedForAll(float? speedValue = null, float speedMultiplier = 1f, float speedDelta = 0f)
{
foreach (var movableObject in _movableObjectRegistry)
{
// If a new speed value is provided, set it
if (speedValue.HasValue)
{
movableObject.Speed = speedValue.Value; // Set new speed
}//end if (speedValue.HasValue)

// Apply the multiplier and delta to the current speed
float currentSpeed = movableObject.Speed;
movableObject.Speed = currentSpeed * speedMultiplier + speedDelta; // Update speed with multiplier and delta
}//end foreach
}//end UpdateSpeedForAll()

// Reverses the direction of all registered movable objects.
public void ReverseDirectionForAll()
{
foreach (var movableObject in _movableObjectRegistry)
{
movableObject.ReverseDirection();

}//end foreach

}//end ReverseDirectionForAll()

}
IDE Notes

XML comments like <summary> and <param> in your code improves clarity in the IDE, allowing developers to easily understand method functionalities and parameter expectations without diving into the implementation details. This practice enhances code readability and maintainability, fostering better collaboration within the development team.

Registering MovableObjects

While we now have a MovableObjectController that can register and manage all movable objects, it's essential to ensure that each movable object registers itself with the controller when it becomes active in the game. By automating the registration process within the MovableObjectBase class, we avoid the need for manual registration, which simplifies the management of movable objects and reduces the likelihood of errors.

To achieve this, we can use Unity's lifecycle methods, such as Start() or OnEnable(), to ensure that every object inheriting from MovableObjectBase automatically registers itself with the MovableObjectController.

Choosing Between Start() and OnEnable()

When deciding between using Start() and OnEnable() for registering movable objects, it's essential to consider the object's lifecycle in the game. Both methods serve specific purposes and are chosen based on how the objects are intended to behave during gameplay.

  • Use Start() when:
    • Objects are registered once after initialization.
    • Objects remain active throughout their lifetime and don't get disabled/re-enabled.
    • You want to ensure registration occurs after initial setup.
    • Typically, if using Start(), you'd unregister objects using Destroy() when they are no longer needed.
  • Use OnEnable() when:
    • Objects may be enabled/disabled multiple times during gameplay.
    • You need to re-register objects each time they become active.
    • It's crucial to handle dynamic activation/deactivation.
    • If using OnEnable(), you'd typically unregister using OnDisable() to handle their deactivation properly. Both methods have their pros and cons, but OnEnable() is generally better for dynamic or frequently toggled objects, while Start() works well for static objects that stay active.

Given that our movable objects could possibly be dynamically activated and deactivated, we have chosen the following implementation:

  • Registering in the OnEnable() Method: The OnEnable() method is called every time the object becomes active. This allows for game objects that might be deactivated and reactivated during gameplay, or if they might be spawned and removed dynamically, to be registered correctly every time they are active.

  • Unregistering on Deactivation: To prevent memory leaks and ensure better game performance, it is also important to unregister the object when it is destroyed or deactivated. In this instance, we have chosen to use OnDisable() instead of OnDestroy() because OnDisable() allows us to unregister objects that may be temporarily deactivated, maintaining a clean registry while optimizing memory usage.

    • To enhance the reliability of our unregistration process, we introduce a boolean _isRegistered, to track whether the object has successfully registered with the MovableObjectController. In the OnDisable() method, we check this flag before attempting to unregister the object. If _isRegistered is false, the object will skip the unregistration process, preventing unnecessary operations and potential errors. This ensures that we only attempt to unregister objects that are indeed registered, maintaining a clean registry of active objects and optimizing memory usage.
  • Implementing Fail-Safes: In addition to the above mechanisms, we introduce fail-safes to handle scenarios where the MovableObjectController might not be present at the time of registration. By implementing a coroutine, in this case RegisterWithController(), that attempts to register the movable object within a specified maximum wait time, we can gracefully handle cases where the controller is not found. If the controller remains absent, we log a warning and take appropriate actions to ensure that the object does not remain in an undefined state.

By adopting these practices, we enhance the robustness of our system, ensuring that movable objects are managed efficiently and reducing the likelihood of runtime errors. This proactive approach not only simplifies our object management but also contributes to overall game stability and performance.

Updating MovableObjectBase

The highlighted lines below demonstrate how the MovableObjectBase has been updated to register and unregister itself to the MovableObjectController.

Note that we do not have to make any direct references to the MovableObjectControlelr since it implements the singleton pattern, there can only be one in existence.

MovableObjectBase.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;

public class MovableObjectBase : MonoBehaviour, IMovable
{
public enum MovableObjectStates
{
Idle,
Moving,
Stopped
}

[SerializeField] private float _speed = 5f; // Speed of the object
[SerializeField] private Vector3 _direction = Vector3.right; // Direction of movement


// Default values for Reset
private float _defaultSpeed;
private Vector3 _defaultPosition;
private Vector3 _defaultDirection;

// State management
private MovableObjectStates _currentState;

// Track registration status with MovableObjectConroller
private bool _isRegistered = false;

// Property to get or set the speed of the object
public float Speed
{
get => _speed;
set => _speed = value;
}

// Property to get or set the direction of movement for the object
public Vector3 Direction
{
get => _direction;
set => _direction = value;
}

protected virtual void Awake()
{
_currentState = MovableObjectStates.Idle; // set initial state
}//end Awake()

//OnEnabled is called when the object becomes active
private void OnEnable()
{
StartCoroutine(RegisterWithController()); //registers with controller

}//end OnEnable()

//Coroutine to register the movable object
private IEnumerator RegisterWithController()
{
// Try to get the controller
MovableObjectController controller = MovableObjectController.Instance;

// Maximum wait time for finding the controller
float maxWaitTime = 1f; // 1 second max wait
float waitTime = 0f;

// Retry until the controller is found, or the max wait time is reached
while (controller == null && waitTime < maxWaitTime)
{
yield return new WaitForSeconds(0.1f);
waitTime += 0.1f;
controller = MovableObjectController.Instance;
}//end while

//If we find the controller
if (controller != null)
{
//Register the movable object
controller.RegisterMovableObject(this);
_isRegistered = true; // Set registered status
}
else
{
Debug.LogWarning("MovableObjectController not found after waiting. The object will not be registered.");
}
}//end IEnumerator RegisterWithController()

//OnDisable is called when the object is disable (i.e. deactivated)
private void OnDisable()
{
// If we are registered then unregister the object when it is deactivated
if (_isRegistered && MovableObjectController.Instance != null)
{
MovableObjectController.Instance.UnregisterMovableObject(this);
_isRegistered = false; // Reset registration status
}//end if

}//end OnDisabled()

//Start called before the first frame
protected virtual void Start()
{
SetDefaults(); //set the default values for reseting movables

_currentState = MovableObjectStates.Moving; // set the state to moving

}//end Start()

//Update called every frame, checks for state
protected virtual void Update()
{
switch (_currentState)
{
case MovableObjectStates.Idle:
break;
case MovableObjectStates.Moving:
Move();
break;
case MovableObjectStates.Stopped:
Stop();
break;
}//end state switch

}//end Update()

//Set the default values
private void SetDefaults()
{
_defaultSpeed = _speed;
_defaultDirection = _direction;
_defaultPosition = transform.position;
}//end SetDefulats()



// Protected method to set direction in derived classes
protected void SetDirection(Vector3 direction)
{
_direction = direction;
}//end SetDirection()

// Virtual method for subclasses to implement their direction calculation logic
protected virtual Vector3 CalculateDirection()
{
return _direction;
}//end CalculateDirection()

// Method to move the object
public virtual void Move()
{
_currentState = MovableObjectStates.Moving; //set the state to Moving

transform.position += _direction * _speed * Time.deltaTime; //default movement
}//end Move()

// Method to reverse the current direction of the object
public virtual void ReverseDirection()
{
_direction = -_direction;
}//end ReverseDirection()

// Method to stop the objects movement
public virtual void Stop()
{
_currentState = MovableObjectStates.Stopped; //set the state to Stopped

// Logic for stopping (e.g., set speed to 0)
}//end Stop()

// Method to rest the object
public virtual void Reset()
{
// Reset position, speed, direction
transform.position = _defaultPosition; // Example reset logic
_speed = _defaultSpeed;
_direction = _defaultDirection;

// Immediately start moving again
_currentState = MovableObjectStates.Moving;
}//end Reset()

}

Summary

The MovableObjectController is a key component in managing the behavior of movable objects within our game environment. The controller utilizes the Singleton pattern to ensure a single, easily accessible instance, enabling efficient management of registered movable objects through a HashSet.

Key features:

  • Registration and Unregistration: Methods for adding and removing movable objects, ensuring unique instances within the registry.
  • Control Methods: Functions to stop, resume, and reset all registered movable objects by calling methods from the IMovable interface, which handles the actual state changes in the base class.
  • Speed and Direction Adjustments: Methods to update the speed and direction of all movable objects collectively, providing flexibility in gameplay mechanics.

As with any development process, the completion of the MovableObjectController is just the beginning. To ensure that our controller functions correctly and meets the intended design specifications, rigorous testing is essential. Next, we will delve into the creation of a dedicated test class, MovableObjectControllerTEST, that will allow us to systematically verify the functionality of the controller. Through this testing phase, we can identify any potential issues early on and refine the behavior of our movable objects, setting a strong foundation for further development in the game.