The absolute best way that I can think of is by using the Command pattern.
But to give you a simple example (sorry it’s gonna be in C# since that’s what I use the most, but you can easily apply it to GDScript), here goes.
You can have an interface that has nothing but a single function, in most cases, called Execute
public interface ICommand
{
public void Execute(double delta);
}
Then, you can have specific commands that will implement that interface, like so:
public class MoveUpCommand : ICommand
{
private readonly Actor _actor;
public MoveUpCommand(Actor actor)
{
_actor = actor;
}
public void Execute(double delta)
{
_actor.MoveUp(delta);
}
}
And this is where the magic happens. Your player / AI object should only have functions for moving around, but nothing to do with Inputs.
public void MoveUp(double delta)
{
GlobalPosition += Vector2.Up * _movementSpeed * (float)delta;
}
public void MoveDown(double delta)
{
GlobalPosition += Vector2.Down * _movementSpeed * (float)delta;
}
public void MoveLeft(double delta)
{
GlobalPosition += Vector2.Left * _movementSpeed * (float)delta;
}
public void MoveRight(double delta)
{
GlobalPosition += Vector2.Right * _movementSpeed * (float)delta;
}
And in another script, you can simply handle JUST inputs / movement requests, for the player specifically, I did this in this example:
public partial class InputController : Node
{
private System.Collections.Generic.Dictionary<StringName, ICommand> _bindings;
public IReadOnlyDictionary<StringName, ICommand> Bindings => _bindings;
private PlayerScript _player;
private bool _initialized;
public override void _Ready()
{
base._Ready();
_bindings = new System.Collections.Generic.Dictionary<StringName, ICommand>();
_player = GetParent().GetNode<PlayerScript>("Player");
SetupDefaultBindings();
}
private void SetupDefaultBindings()
{
if (_player == null)
{
GD.PushWarning("We couldn't find the player, something is wrong!");
return;
}
_initialized = true;
Array<StringName> actions = InputMap.GetActions();
foreach (StringName action in actions)
{
if (action.ToString().Contains("ui"))
{
continue;
}
switch (action)
{
case "Up":
BindInputEventToCommand(action, new MoveUpCommand(_player));
break;
case "Down":
BindInputEventToCommand(action, new MoveDownCommand(_player));
break;
case "Left":
BindInputEventToCommand(action, new MoveLeftCommand(_player));
break;
case "Right":
BindInputEventToCommand(action, new MoveRightCommand(_player));
break;
}
}
GD.Print("Bindings set.");
}
private void BindInputEventToCommand(StringName @event, ICommand command)
{
if (!_bindings.TryAdd(@event, command))
{
GD.PushWarning("Could not bind event, likely duplicate: " + @event);
}
}
public override void _Process(double delta)
{
base._Process(delta);
if (!_initialized)
{
return;
}
foreach (KeyValuePair<StringName, ICommand> kv in _bindings)
{
if (Input.IsActionPressed(kv.Key))
{
kv.Value.Execute(delta);
}
}
}
}
The most important bit is the last _Process function, that will run all the time and check if a given input that we store in the Dictionary matches one of the Input methods. And since the Dictionary contains a key (in this case, that’s a string), and a value (in this case, that’s a Command that has the ICommand interface implemented), we simply call the Execute function when we KNOW that specific input is being pressed that we assigned to that command.
This way, Player or AI actors know absolutely nothing about how the input system work, and the Input system itself has no clue who or what it’s controlling, it just knows there’s an Execute function that needs to be called when X or Y thing happens.