There are always more ways of completing a task, it simply takes time to find the right level of abstraction. We want to break our components down so that they are easier to understand, extend and replace. However, we don’t want to break things down into meaningless atoms so that it’s impossible to get a view of the big picture. As Einstein said, everything should be made as simple as possible, but no simpler.
The trick is to find the correct balance of general vs specific implementation. Personally, I lean heavily towards preferring general solutions because, while they are more difficult to create initially, they tend to be more flexible and extensible in the future. Others lean towards specific solutions because that allows them to move quickly without worrying about problems that might never show up. With practice, you will get faster at creating generic solutions, so you can have the best of both worlds.
Right now, you call each method explicitly but another option to ordering your method calls is to add them to an ordered collection and execute them in the same order they are added.
Maybe it makes sense to implement a two-layer-event system. Each interface is given two opportunities to execute some event. For every component, Event1 is called in the order the components were added. Then for every component, Event2 is called in the order the components were added. An example can be seen in the way Unity implements their MonoBehaviour lifecycle. First, each component has Awake() called, then Start(), then for every frame, Update and LateUpdate. Of course, the actual implementation is quite complex but the idea is there.
Or maybe you need finer control over the method calls, then a sorted collection of method calls could work.
On the other hand, if the order and parameters are complex, a manager with implicit knowledge of the required information and order of events making each call explicitly isn’t so bad. However, we want to separate the concerns here as much as possible. It’s okay for the manager to have knowledge of the order of event calls but let’ say the manager also holds the data needed to execute those events. As much as possible, we want to encapsulate information into the classes that need that information.
Consider this pseudo code.
class Manager
{
public Jumper Jumper;
public DasherAbility DasherAbility;
public double jumpStrength;
public double dashStrength;
public void Execute()
{
Jumper.Jump(jumpStrength);
DasherAbility.Dash(dashStrength);
Jumper.DoubleJump(jumpStrength/2);
}
}
If we move the relevant information into the components that actually need to do the work, our manager gets simpler.
class Jumper
{
public double jumpStrength;
// Implement Jump and DoubleJump..
}
class DashAbility
{
public double dashStrength;
// Implement Dash..
}
class Manager
{
public Jumper Jumper;
public DasherAbility DasherAbility;
public void Execute()
{
Jumper.Jump();
DasherAbility.Dash();
Jumper.DoubleJump();
}
}
Now, you can see the manager is just responsible for calling things in the correct order, the parameter information was moved to the subcomponents. The manager is simpler because instead of controlling the order and the required data, it only knows about the order of events.
Required data and execution order are examples of the different kinds of concerns that we should take into account when designing a class’s “Single Responsibility”. As much as it makes sense, identify and divvy these concerns up so that each class can be responsible for only one thing.
At this point, we’ve actually reached a pretty good balance, so I’m wondering why you say this
Where do you see excess code? What doesn’t feel “proper” to you?
Even though it’s a gimmicky idea, let’s explore one step further with the two-layer-event interface idea just to see how it changes the Manager.
interface IComponent
{
void Event1();
void Event2();
}
class Jumper : IComponent
{
public double jumpStrength { get; }
public void Awake()
{
Manager.Register(this);
}
public void Event1()
{
Jump(jumpStrength);
}
public void Event2()
{
DoubleJump(jumpStrength/2);
}
// ...
}
class DashAbility : IComponent
{
public double dashStrength { get; }
public void Awake()
{
Manager.Register(this);
}
public void Event1()
{
Dash(dashStrength );
}
public void Event2()
{ }
// ...
}
class Manager
{
public Queue<IComponent> components;
public Queue<IComponent> Components
{
get
{
if (components== null)
components= new Queue<IComponent>();
return components;
}
}
private Manager singleton;
private Manager Singleton
{
get
{
if (singleton == null)
singleton = new Manager();
return singleton;
}
}
public static Register(IComponent component)
{
Singleton.Components.Enqueue(component);
}
public void Execute()
{
foreach (var component in Components)
component.Event1();
foreach (var component in Components)
component.Event2();
}
}
Now the Manager class isn’t even responsible for knowing what components exist – there is no explicit reference to Jumper or DashAbility – just that some exist and implement the IComponent interface. This has made the Manager really simple, though it has made the lifetime management of the components a little more complex. This is the balance I’m talking about.