Skip to content
This repository has been archived by the owner on Oct 11, 2019. It is now read-only.

Latest commit

 

History

History
293 lines (210 loc) · 11.8 KB

command.md

File metadata and controls

293 lines (210 loc) · 11.8 KB

Implementing a command with the SpatialOS SDK for Unity

This recipe shows how to define and implement a command (SpatialOS documentation) with the SpatialOS SDK for Unity.

We'll use the example of a game where players can take damage from the spells of wizard characters. The taking of damage is implemented by wizards invoking a command that applies damage to the player's entity.

1. Define the schema

Commands are defined as part of a component, in schema.

The first component to define is Combat, in the file Combat.schema, which wizard entities have:

package improbable.wizard;

component Combat {
    id = 1050;
    uint32 attack_power = 1;
}

The second component to define is Health, in the file Health.schema, which the player entities have:

package improbable.player;

component Health {
    id = 1051;
    uint32 amount = 1;
    command DamageResponse take_damage(DamageRequest);
}

This Health component defines a take_damage command which takes an argument of type DamageRequest as input and returns an object of type DamageResponse. Like declaring a function in a public interface, this means that any entity can invoke the take_damage command on an entity containing the Health component.

The input and response types aren't defined yet, so you need to define those in schema too. You can do this at the bottom of the file Health.schema:

type DamageRequest {
    uint32 amount = 1;
}
type DamageResponse {
    uint32 dealt_damage = 1;
}

Now run spatial worker codegen (SpatialOS documentation).

You should always run this after you've changed your schema, to generate the code that workers use to interact with components.

2. Implement the command

The entry in the schema file is effectively just a function signature. What's next is to actually write what the behaviour of the take_damage command will be.

Which worker should implement the command? This depends on the entity's access control list (ACL) (SpatialOS documentation). Whichever worker has write access to the component should implement the command.

In this example, let's say that'll be the Unity worker. So, to write what the command will do:

2.1. Create a TakeDamage MonoBehaviour

Create a new MonoBehaviour, TakeDamage.cs, and attach it to the prefab used for the player.

This MonoBehaviour will listen for incoming take_damage command requests, execute some logic, and send back a response.

2.2. Add required imports

You need to import:

  • the package for the component (defined earlier as improbable.player)
  • Improbable.Unity.Visualizer, so you can use [Require]
  • Improbable.Entity.Component, for various bits of the commands syntax
using Improbable.Player;
using Improbable.Unity.Visualizer;
using Improbable.Entity.Component;

2.3. Inject a component writer for the Health component

The [Require] syntax lets you specify that a MonoBehaviour should only be enabled if an object is available. In this case, you only want to enable this MonoBehaviour on workers that have write access to Health.

In the TakeDamage class, require a writer:

[Require] private Health.Writer healthWriter;

Health.Writer, and all the methods available through it, are generated by spatial worker codegen (SpatialOS documentation).

2.4. Add a callback for the TakeDamage command

If a component includes a command, the component writer contains a CommandReceiver member. This exposes callbacks, one for each command, that run when the command is invoked. You should add a callback for each command to listen for incoming command requests.

You can add either a synchronous or an asynchronous callback. This section will cover both options.

2.4a. Add a synchronous callback

This example adds a synchronous callback to CommandReceiver.OnTakeDamage.

Synchronous callbacks are a straightforward way to respond to commands. When an incoming command request is received, a command response is generated using your callback and sent back to the sender immediately.

To respond to a command synchronously, implement a function with the following signature <TResponse> <Callback_Function_Name>(TRequest request, ICommandCallerInfo callerInfo). The callerInfo can be used to access information about the command caller (its WorkerId and attributes (SpatialOS documentation)) in CallerDetails.

Register the callback to CommandReceiver.OnTakeDamage in OnEnable, and deregister it in OnDisable:

public void OnEnable() 
{
    healthWriter.CommandReceiver.OnTakeDamage.RegisterResponse(TakeDamage);
}

private DamageResponse TakeDamage(DamageRequest request, ICommandCallerInfo callerInfo)
{
    uint desiredDamage = request.amount;
    uint dealtDamage = System.Math.Min(desiredDamage, healthWriter.Data.amount);

    healthWriter.Send(new Health.Update().SetAmount(healthWriter.Data.amount - dealtDamage));
    return new DamageResponse(dealtDamage);
}

public void OnDisable()
{
    healthWriter.CommandReceiver.OnTakeDamage.DeregisterResponse();
}

2.4b. Add an asynchronous callback

Alternatively, you can add an asynchronous callback to CommandReceiver.OnTakeDamage.

When handling commands asynchronously, the callback is passed a ResponseHandle object containing the method Respond() which you use to respond to the command.

Unlike synchronous command responses, a response is not sent immediately. Instead, you can cache the ResponseHandle object and call ResponseHandle.Respond() when you like. Make sure that:

  • you respond to a command within its timeout period
  • all code paths following the callback method eventually lead to a ResponseHandle.Respond() call.

To respond to a command synchronously, implement a function with the following signature void <Callback_Function_Name>(ResponseHandle<Command_Name, TRequest, TResponse> responseHandle). The responseHandle object contains additional information about the command sender in callerInfo.

Register the callback to CommandReceiver.OnTakeDamage in OnEnable, and deregister it in OnDisable:

public void OnEnable() 
{
    healthWriter.CommandReceiver.OnTakeDamage.RegisterAsyncResponse(TakeDamageAsync);
}

private void TakeDamageAsync(ResponseHandle<Health.Commands.TakeDamage, DamageRequest, DamageResponse> responseHandle)
{
    uint desiredDamage = responseHandle.Request.amount;
    uint dealtDamage = System.Math.Min(desiredDamage, healthWriter.Data.amount);

    healthWriter.Send(new Health.Update().SetAmount(healthWriter.Data.amount - dealtDamage));
    responseHandle.Respond(new DamageResponse(dealtDamage));
}

public void OnDisable()
{
    healthWriter.CommandReceiver.OnTakeDamage.DeregisterResponse();
}

3. Invoke the command

You've defined the signature of take_damage in schema, and implemented the logic in a MonoBehaviour on the Unity worker. Next, the wizard should invoke this command on the player.

3.1. Create a DealDamage MonoBehaviour

Create a new a MonoBehaviour, DealDamage.cs, to the prefab you're using for the wizard.

3.2. Add required imports

You need to import:

  • the package for the component (defined earlier as improbable.wizard)
  • Improbable.Unity.Visualizer, so you can use [Require]
  • Improbable.Unity.Core, so you can use the SpatialOS utility class
  • Improbable.Entity.Component, for various bits of the commands syntax
  • Improbable, so you can refer to EntityId
using Improbable.Wizard;
using Improbable.Unity.Visualizer;
using Improbable.Unity.Core;
using Improbable.Entity.Component;
using Improbable;

3.3. Inject a component writer for the Combat component

Require a component writer for the Combat component:

[Require] private Combat.Writer combatWriter;

The SendCommand method requires you to pass in a component writer. This is for the same reason that the CommandReceiver object is defined on component writers: since an instance of this MonoBehaviour will be enabled on at most one worker across your simulation, it prevents sending the same command more than once.

Note that you can have multiple MonoBehaviours on the same entity pointing to the same instance of the writer.

3.4. Send the TakeDamage command

You use the SendCommand method on SpatialOS.Commands to invoke commands.

The SendCommand method looks like this:

ICommandResponseHandler<TResponse> SendCommand<TCommand, TRequest, TResponse>(IComponentWriter writer,
ICommandDescriptor<TCommand, TRequest, TResponse> commandDescriptor, TRequest request, EntityId entityId,
TimeSpan? timeout = null, CommandDelivery commandDelivery = CommandDelivery.RoundTrip)
where TCommand : ICommandMetaclass

Let's break that down a bit.

  • SendCommand() returns an ICommandResponseHandler object which exposes the following methods:

    • OnSuccess(CommandSuccessCallback<TResponse> successCallback) is the callback triggered if the command succeeds.

    • OnFailure(CommandFailureCallback failureCallback) is the callback triggered if the StatusCode != StatusCode.Success.

      This means SpatialOS can't guarantee that the command succeeded, rather than it necessarily failed.

    You don't have to specify either of these callbacks if you don't want to.

    • TResponse Response is the response object, of the type defined in the schema. In this case, it's DamageResponse Response.
  • writer is the component writer you injected, for the reasons explained above.

  • commandDescriptor is a special object identifying a command. Command descriptors are generated for each command defined in your schema, and are named as {ComponentName}.Commands.{CommandName}.Descriptor. So in this case, it's Health.Commands.TakeDamage.Descriptor.

    To access the available list of commands you can invoke on a component, try typing "{ComponentName}.Commands." and looking at your code completion results. (This will only work once you've run spatial worker codegen).

  • request is the request argument to the command, of the type specified in the schema. Here, that's DamageRequest.

  • entityId is the ID of the entity you are sending the command to.

  • timeout (optional, default is null) is the timeout period for a command response. It's a TimeSpan.

  • commandDelivery (optional) denotes whether the worker will attempt to short-circuit entity commands if possible. CommandDelivery is an enum: the available values are CommandDelivery.RoundTrip and CommandDelivery.ShortCircuit.

    If you don't specify, the default value (CommandDelivery.RoundTrip) will be used: no short-circuiting.

Use this method to invoke the take_damage command on the entity with the given entity ID:

void AttackPlayer(EntityId playerEntityId) 
{
    SpatialOS.Commands
    .SendCommand(combatWriter, Health.Commands.TakeDamage.Descriptor, new DamageRequest(combatWriter.Data.attackPower), playerEntityId)
    .OnSuccess(OnDamageRequestSuccess)
    .OnFailure(OnDamageRequestFailure);
}

void OnDamageRequestSuccess(DamageResponse response)
{
    Debug.Log("Take damage command succeeded; dealt damage: " + response.dealtDamage);
}

void OnDamageRequestFailure(ICommandErrorDetails response)
{
    Debug.LogError("Failed to send take damage command with error: " + response.ErrorMessage);
}

To find the player's entity ID, you could send a query.