Skip to main content

Boundary

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

In many games, objects need to stay within certain areas or zones. These boundaries can define the limits of a game screen, specific regions of a level, or even abstract areas where certain behaviors or rules apply. To manage this effectively, boundaries should announce when they've been hit, allowing objects to respond accordingly. For example, if a character or NPC moves outside a designated area, the game can react by stopping movement, reversing direction, or triggering other events.

Observer Pattern

The observer pattern is ideal for this scenario, as it allows objects to listen for boundary events without tight dependencies. In our case, the boundary will act as the subject, broadcasting an event that notifies observers (e.g., movable objects) when a boundary is breached. This decoupling ensures that the boundary system remains flexible and reusable across various parts of the game.

Boundary Abstract

Since we may need to account for various types of boundaries (e.g., screen boundaries, zone boundaries), we need a solid foundation. Should this be a parent class, abstract class, or interface?

While an interface could enforce a method like HitBoundary, it doesn't allow shared logic between different boundary types. Since boundaries may share common behaviors, like notifying when an object exits, an abstract class is a better choice.

A regular class wouldn't work well because it would tightly couple all boundary types, limiting flexibility. Abstract classes, on the other hand, allow for shared logic while still enabling specific boundary types to have their own unique implementations.

We've chosen an abstract class for the following reasons:

  • Shared Logic: The abstract class can hold common boundary behavior (e.g., checking if an object is within a boundary).

  • Flexible Implementation: Subclasses can customize specific boundary types (e.g., screen boundaries, circular boundaries) by extending or overriding the shared functionality.

An abstract class will provides both flexibility and the ability to share logic across different boundary types, making it the best option for this scenario.

Implementation

BoundaryBase Class

BoundaryBase.cs
using UnityEngine;

public abstract class BoundaryBase : MonoBehaviour
{
// Event to announce when the boundary is hit
public event System.Action OnBoundaryHit;

// Method to check if the boundary is hit (to be implemented by subclasses)

public abstract void CheckBoundary(GameObject obj);

// Notify observers when the boundary is hit
protected void NotifyBoundaryHit()
{
if (OnBoundaryHit != null)
{
OnBoundaryHit.Invoke();
}//end if (OnBoundaryHit)
}//end NotifyBoundaryHit()
}

MovableObjectBase Class

Our MovableObjectBase will need to react when a boundary is hit by using the observer pattern. This allows the boundary class to notify the MovableObjectBase when the object breaches the boundary, without tightly coupling the two classes. To implement this we will need to subscribe to the BoundaryBase event in the Start(). By subscribing to the BoundaryBase class, we do not have to worry about what type of boundary the object has just as long as it is a child of the BoundaryBase.

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

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

//Try to get boundary
if (gameObject.TryGetComponent<BoundaryBase>(out BoundaryBase _boundary))
{
//Subscribing to event, which will set-off said method
_boundary.BoundaryHit += ReverseDirection;
}//end if(boundary)

}//end Start()

//Continue....
}

Creating a Screen Boundary

To illustrate how boundaries work in practice, let's implement a Screen Boundary as an example. The ScreenBoundary class extends the BoundaryBase class, leveraging the observer pattern to notify movable objects when they breach the boundary of the game screen.

using CSG.Utilities;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ScreenBoundary : BoundaryBase
{
// Default boundary checking method in the abstract class, accepting padding as a parameter; can be overridden by subclasses
protected override void CheckBoundary()
{
// Default horizontal boundary check
Vector3 screenLeftWorldPos = CoordinateUtilities.ScreenToWorldPoints(new Vector2(0, 0));
Vector3 screenRightWorldPos = CoordinateUtilities.ScreenToWorldPoints(new Vector2(Screen.width, 0));

if (transform.position.x >= screenRightWorldPos.x - _padding ||
transform.position.x <= screenLeftWorldPos.x + _padding)
{
OnBoundaryHit(); // Base class sends notification
Debug.Log("Boundary Hit");
}
} // end CheckBoundary()
}
CoordinateUtilities Implementation

Note that in our ScreenBoundary we are using CSG.Utilities; which enables us to leverage the CoordinateUtilities.ScreenToWorldPoints() method. This method simplifies the conversion of screen coordinates to world coordinates, eliminating the need to implement the calculation directly in the base class.

In this implementation, the ScreenBoundary class defines the specific logic for detecting when an object crosses the screen's edges. When an object moves beyond the defined limits, the OnBoundaryHit method is called, which triggers any subscribed observers, in this case the MovableObjectBase to respond appropriately. This decoupling allows us to reuse the ScreenBoundary class in different contexts without modifying the behavior of the movable objects directly.

By implementing the ScreenBoundary class, we ensure that our game can effectively manage object movement within the constraints of the game screen, enhancing both gameplay experience and technical robustness.

Summary

The implementation of the observer pattern allows for movable objects to respond to boundary events without creating tight dependencies. By establishing a foundation with the BoundaryBase abstract class, we enabled shared logic across various boundary types, such as the ScreenBoundary class.

In the next chapter add further enhancements allowing objects to react dynamically within the game space, creating more unpredictable and engaging gameplay experiences.