Il prototipo consiste in un piccolo gioco multigiocatore in cui ogni giocatore muove il proprio personaggio
capsula e può interagire con i vari oggetti di scena. È stato realizzato interamente con i package forniti
da DOTS.
L'obbiettivo del prototipo è quello di realizzare un gameplay basilare, con movimento dei personaggi e piccole interazioni con l'ambiente di gioco, sfruttando i package forniti dall'architettura Unity DOTS. In particolare, sono stati utilizzati i seguenti package:
- Entities, per realizzare il modello basato su Entità, Componenti e Sistemi (ECS).
- NetCode, per realizzare la parte legata al networking, ovvero le connessioni dei giocatori (client) al server di gioco e la comunicazione fra questi.
- Physics, per realizzare le meccaniche legate alla fisica (entità statiche/dinamiche, sistema delle collisioni, ecc.).
Funzionalità | File | Dimostrazione |
Accumulo degli input e movimento personaggio capsula | PlayerMovementSpeed PlayerInputSystem.cs PlayerMovementSystem.cs |
![]() |
Camera follow in terza persona | PlayerCameraFollowComponent.cs CameraFollowSystem.cs |
![]() |
Portali cambia-colore | PersistentChangeMaterialOnTriggerComponent.cs PersistentChangeMaterialOnTriggerSystem.cs TemporaryChangeMaterialOnTriggerComponent.cs TemporaryChangeMaterialOnTriggerSystem.cs |
![]() |
Teletrasporti | TeleportComponent.cs TeleportSystem.cs |
![]() |
Spawn entità in punti randomici di un volume | SpawnRandomObjectsAuthoring.cs | ![]() ![]() |
Raccolta oggetti "collectibles" | PlayerScoreComponent CollectibleTagComponent.cs DeleteTagComponent.cs PickUpSystem.cs DeleteCollectibleSystem.cs |
![]() |
Per eseguire la build di un'applicazione Unity realizzata usando DOTS, è necessario utilizzare i package Platforms (com.unity.platforms.*). In particolare, nel caso di un'applicazione multiplayer, NetCode utilizza la proprietà Server Build in Build Settings e gli scripting define symbols in Project Settings > Player per capire che tipo di applicazione buildare (solo client, solo server o scelta a runtime).
- Controllare che il package com.unity.platforms.windows sia presente nel PackageManager.
- Creare una Configurazione di Build: Project Window > Create > Build > Windows Classic Build Configuration.
- Selezionare la configurazione ed eseguire la build.
Il flusso di esecuzione del prototipo è il seguente:
- All'avvio dell'applicazione viene eseguito il sistema Game, contenuto in Game.cs. Questo sistema realizza la connessione dei client al server, differenziando l'esecuzione del server, il quale si mette in ascolto per le connessioni, da quella dei client, che si connettono al server.
- I client eseguono il sistema GoInGameClientSystem, che invia una richiesta al server per entrare in gioco e iniziare a inviare comandi e ricevere snapshot (ovvero gli aggiornamenti dello stato di gioco).
- Il server esegue il sistema GoInGameServerSystem, che riceve le richieste di entrare in gioco e per ciascun giocatore abilita la comunicazione tramite comandi e snapshot, e genera un personaggio capsula.
- L'applicazione, tramite il sistema PlayerInputSystem, controlla continuamente se il giocatore immette input da tastiera e in caso li accumula in un buffer e li invia al server.
- Il sistema PlayerMovementSystem applica gli input alle varie capsule, utilizzando la predizione lato client per rendere l'esecuzione più fluida e far percepire il meno possibile la latenza della rete.
- Nel mentre si aggiornano anche gli altri sistemi che realizzano le varie funzionalità.
NB: i pezzi di codice mostrati sono stati ritagliati per mostrare solo le parti fondamentali spiegate nel testo.
Una volta che client e server sono connessi, è necessario indicare a NetCode che i client sono pronti a inviare comandi e ricevere snapshot dal server: appena la connessione viene stabilita, lato client inizia ad eseguire il sistema GoInGameClientSystem, che invia una RPC al server; lato server inizia ad eseguire il sistema GoInGameServerSystem che riceve la RPC e marchia il client come "in gioco", aggiungendo il componente NetworkStreamInGame all'entità che rappresenta la connessione e creando una personaggio capsula per il giocatore corrispondente al client.
Dichiariamo la struttura EnableGame che ci servirà più avanti per indicare che i client o il server sono pronti a stabilire la connessione ed entrare in gioco.
[UpdateInWorld(UpdateInWorld.TargetWorld.Default)] indica che il sistema Game dev'essere eseguito nel mondo di default, in quanto questo mondo è sempre presente perché istanziato automaticamente da Unity.
Poiché il codice del sistema Game realizza la connessione, dev'essere eseguito una sola volta per run dell'applicazione. Dunque, utilizziamo un ulteriore singleton InitGameComponent, per indicare quando il codice di questo sistema è già stato eseguito una volta: nella OnCreate() usiamo il metodo RequireSingletonForUpdate<>() per indicare l'entità che dev'essere presente affinché OnUpdate() venga chiamata; dopodiché creiamo l'entità avente questo componente; infine nella OnUpdate() rimuoviamo tale entità, così Unity non chiama più OnUpdate() del sistema Game.
protected override void OnCreate()
{
RequireSingletonForUpdate<InitGameComponent>();
EntityManager.CreateEntity(typeof(InitGameComponent));
}
La OnUpdate() itera su tutti i mondi presenti nell'applicazione e, dopo aver ottenuto il sistema NetworkStreamReceiveSystem (che espone i metodi Connect e Listen), controlliamo se ci troviamo in un client o in un server:
- Nel caso l'applicazione sia un client, sarà presente il mondo ClientWorld, al cui interno vi sarà il gruppo di sistemi ClientSimulationSystemGroup. Dunque creiamo l'entità singleton EnableGame e facciamo una connect a localhost:7979.
- Nel caso l'applicazione sia un server, sarà presente il mondo ServerWorld, al cui interno vi sarà il gruppo di sistemi ServerSimulationSystemGroup. Dunque creiamo l'entità singleton EnableGame e facciamo una listen sulla porta 7979.
protected override void OnUpdate()
{
EntityManager.DestroyEntity(GetSingletonEntity<InitGameComponent>());
foreach (var world in World.All)
{
var network = world.GetExistingSystem<NetworkStreamReceiveSystem>();
if (world.GetExistingSystem<ClientSimulationSystemGroup>() != null)
{
world.EntityManager.CreateEntity(typeof(EnableGame));
NetworkEndPoint ep = NetworkEndPoint.LoopbackIpv4;
ep.Port = 7979;
ep = NetworkEndPoint.Parse(ClientServerBootstrap.RequestedAutoConnect, 7979);
network.Connect(ep);
}
else if (world.GetExistingSystem<ServerSimulationSystemGroup>() != null)
{
world.EntityManager.CreateEntity(typeof(EnableGame));
NetworkEndPoint ep = NetworkEndPoint.AnyIpv4;
ep.Port = 7979;
network.Listen(ep);
}
}
}
Poiché non è stato aggiunto il componente NetworkStreamInGame all'entità che rappresenta la connessione
fra un client ed il server, questi non possono comunicare inviando comandi o snapshot. Quindi, utilizziamo
una RPC NetCode (IRpcCommand) per notificare al server che il client è pronto ad entrare in gioco, così il
server può marcare la connessione e avviare la comunicazione.
Come spiegato nella documentazione di NetCode,
per inviare una RPC è necessario creare un entità ed aggiungervi il comando RPC creato ed il componente
SendRpcCommandRequestComponent, che innesca il sistema di invio della RPC di Unity.
L'attributo [UpdateInGroup(typeof(ClientSimulationSystemGroup))] indica che questo sistema dev'essere
aggiornato solo nei client, all'interno del gruppo ClientSimulationSystemGroup.
Vogliamo che questo sistema esegua una sola volta, quando il client deve entrare in gioco, per la precisione
dopo la connessione con il server, ma prima che venga avviata la comunicazione via comandi e snapshot.
Dunque, richiediamo che sia presente il singleton EnableGame e che l'entità rappresentante la connessione
(che possiede il componente NetworkIdComponent), non abbia NetworkStreamInGame.
protected override void OnCreate()
{
RequireSingletonForUpdate<EnableGame>();
RequireForUpdate(GetEntityQuery(ComponentType.ReadOnly<NetworkIdComponent>(), ComponentType.Exclude<NetworkStreamInGame>()));
}
Dopodiché nella OnUpdate(), iteriamo su tutte le entità che possiedono NetworkIdComponent ma non hanno NetworkStreamInGame, ovvero l'entità della connessione. Dunque, utilizzando un command buffer, seguiamo la procedura per inviare la RPC: creiamo un'entità, vi aggiungiamo il comando RPC, ed infine aggiungiamo il componente SendRpcCommandRequestComponent indicando la connessione target.
protected override void OnUpdate()
{
var commandBuffer = new EntityCommandBuffer(Allocator.Temp);
Entities.WithNone<NetworkStreamInGame>().ForEach((Entity ent, in NetworkIdComponent id) =>
{
commandBuffer.AddComponent<NetworkStreamInGame>(ent);
var req = commandBuffer.CreateEntity();
commandBuffer.AddComponent<GoInGameRequest>(req);
commandBuffer.AddComponent(req, new SendRpcCommandRequestComponent { TargetConnection = ent });
}).Run();
commandBuffer.Playback(EntityManager);
commandBuffer.Dispose();
}
L'attributo [UpdateInGroup(typeof(ServerSimulationSystemGroup))] indica che questo sistema dev'essere
aggiornato solo nel server.
Vogliamo che questo sistema esegua solo quando, dopo che è stato aggiunto il singleton EnableGame, arriva
una richiesta di RPC da un client. Dunque, richiediamo che sia presente EnableGame e che vi sia un'entità
avente come componenti il nostro comando RPC e ReceiveRpcCommandRequestComponent.
protected override void OnCreate()
{
RequireSingletonForUpdate<EnableGame>();
RequireForUpdate(GetEntityQuery(ComponentType.ReadOnly<GoInGameRequest>(), ComponentType.ReadOnly<ReceiveRpcCommandRequestComponent>()));
}
Nella OnUpdate() otteniamo la lista dei ghost prefab, ovvero i networked object. Nel nostro prototipo
l'unico ghost presente è la PlayerCapsule, ovvero il personaggio che ciascun giocatore controlla e muove per
la mappa. Poiché in futuro questa lista potrebbe essere ampliata, controlliamo comunque che il ghost sia
quello della capsula, ovvero se possiede il componente PlayerMovementSpeed, e lo salviamo in una
variabile.
Dopodiché otteniamo la lista dei NetworkIdComponent delle connessioni, salvandola in networkIdFromEntity,
ovvero un container dictionary-like. Tramite questo container possiamo assegnare il rispettivo id della
connessione al componente GhostOwnerComponent del ghost di ciascun client. Questa è un'operazione
fondamentale che bisogna fare "a runtime", in quanto prima non è possibile conoscere a chi apparterrà un certo
ghost.
Dunque iteriamo su tutte le entità che corrispondono a richieste RPC (aventi dunque GoInGameRequest e
ReceiveRpcCommandRequestComponent). Poiché nella richiesta è presente l'entità della connessione sorgente
da cui è partita la RPC, possiamo utilizzarla per aggiungervi il componente NetworkStreamInGame e
iniziare la comunicazione via comandi e snapshot.
Fatto questo, istanziamo la capsula del giocatore e aggiorniamo il NetworkId del proprietario di tale ghost.
Infine, aggiungiamo alla capsula il buffer su cui verranno accumulati gli input del giocatore, ed alla
connessione il componente CommandTargetComponent che servirà al sistema di gestione degli input
per capire a quale ghost applicare gli input ricevuti dal giocatore. Inoltre, distruggiamo l'entità della
richiesta RPC altrimenti il sistema continua ad eseguire all'infinito.
Ora è tutto impostato correttamente per permettere al client di accumulare input ed inviarli al server
sottoforma di comandi, ed al server di ricevere questi comandi, applicarli nella propria simulazione ed
inviare le snapshot (ovvero gli aggiornamenti dello stato di gioco) al client.
protected override void OnUpdate()
{
var ghostCollection = GetSingletonEntity<GhostPrefabCollectionComponent>();
var prefab = Entity.Null;
var prefabs = EntityManager.GetBuffer<GhostPrefabBuffer>(ghostCollection);
for (int ghostId = 0; ghostId < prefabs.Length; ++ghostId)
{
if (EntityManager.HasComponent<PlayerMovementSpeed>(prefabs[ghostId].Value))
prefab = prefabs[ghostId].Value;
}
var commandBuffer = new EntityCommandBuffer(Allocator.Temp);
var networkIdFromEntity = GetComponentDataFromEntity<NetworkIdComponent>(true);
Entities.WithReadOnly(networkIdFromEntity).ForEach((Entity reqEnt, in GoInGameRequest req, in ReceiveRpcCommandRequestComponent reqSrc) =>
{
commandBuffer.AddComponent<NetworkStreamInGame>(reqSrc.SourceConnection);
UnityEngine.Debug.Log(String.Format("Server setting connection {0} to in game", networkIdFromEntity[reqSrc.SourceConnection].Value));
var player = commandBuffer.Instantiate(prefab); // spawn capsula per il giocatore
commandBuffer.SetComponent(player, new GhostOwnerComponent { NetworkId = networkIdFromEntity[reqSrc.SourceConnection].Value });
commandBuffer.AddBuffer<PlayerInput>(player);
commandBuffer.SetComponent(reqSrc.SourceConnection, new CommandTargetComponent { targetEntity = player });
commandBuffer.DestroyEntity(reqEnt);
}).Run();
commandBuffer.Playback(EntityManager);
commandBuffer.Dispose();
}
La struttura PlayerInput implementa l'interfaccia ICommandData, ovvero l'interfaccia necessaria per realizzare un comando in NetCode. Questa non è altro che un buffer dinamico utilizzato per accumulare comandi da trasmettere attraverso una connessione. Infatti, questa interfaccia espone la proprietà Tick, che dev'essere specificata, in quanto indica il tick di esecuzione della simulazione in cui è stato campionato l'input, così che il server, quando lo riceverà, potrà applicarlo nello stesso momento del client, indipendentemente dalla latenza della rete. Il tick permette anche di sfruttare la predizione lato client fornita da NetCode.
Nel nostro caso questa struttura contiene, oltre al tick, i campi horizontal e vertical, che indicano rispettivamente il movimento sull'asse x e sull'asse y.
public struct PlayerInput : ICommandData
{
public uint Tick { get; set; }
public int horizontal;
public int vertical;
}
La raccolta degli input avviene tramite il sistema PlayerInputSystem, che esegue solo lato client. All'interno di questo, in OnCreate(), innanzitutto richiediamo che, affinché il sistema venga aggiornato, siano presenti il singleton NetworkIdComponent (che identifica una connessione, dunque un client) ed EnableGame (che indica che il gioco è iniziato). Poi salviamo in una variabile il gruppo di sistema ClientSimulationSystemGroup, in quanto da questo possiamo ottenere il tick del server (che aggiorna sempre ad un timestep fisso).
ClientSimulationSystemGroup m_ClientSimulationSystemGroup;
protected override void OnCreate()
{
RequireSingletonForUpdate<NetworkIdComponent>();
RequireSingletonForUpdate<EnableGame>();
m_ClientSimulationSystemGroup = World.GetExistingSystem<ClientSimulationSystemGroup>();
}
La parte più complicata di questo sistema risiede nel metodo OnUpdate(): dopo aver ottenuto il singleton CommandTargetComponent, controlliamo che contenga il riferimento all'entità capsula del giocatore. Poiché a runtime ci possono essere diversi giocatori, e di conseguenza diversi personaggi capsula, è necessario distinguere il client a cui appartengono. Il componente CommandTargetComponent serve proprio a questo: è un singleton, diverso per ogni client. Infatti, quando l'applicazione viene messa in esecuzione normalmente, è presente solo il World del client specifico, di conseguenza il singleton rappresenta l'entità ghost della capsula associata al client del mondo. Però, poiché alla prima esecuzione questo componente non è inizializzato, dobbiamo gestire anche il caso in cui non contenga ancora l'entità. In questo caso semplicemente otteniamo l'id della connessione dal singleton NetworkIdComponent e, iterando su tutte le entità capsula, cerchiamo quella con il NetworkId (nel componente GhostOwner, che avevamo inizializzato in Game.cs) corrispondente. Una volta trovata, impostiamo il valore di targetEntity di CommandTargetComponent.
var localInput = GetSingleton<CommandTargetComponent>().targetEntity;
if (localInput == Entity.Null)
{
var localPlayerId = GetSingleton<NetworkIdComponent>().Value;
var commandBuffer = new EntityCommandBuffer(Allocator.Temp);
var commandTargetEntity = GetSingletonEntity<CommandTargetComponent>();
Entities.WithAll<PlayerMovementSpeed>().WithNone<PlayerInput>()
.ForEach((Entity ent, ref GhostOwnerComponent ghostOwner) =>
{
if (ghostOwner.NetworkId == localPlayerId)
{
commandBuffer.AddBuffer<PlayerInput>(ent);
commandBuffer.SetComponent(commandTargetEntity, new CommandTargetComponent { targetEntity = ent });
}
}).Run();
commandBuffer.Playback(EntityManager);
return;
}
Fatto ciò possiamo finalmente campionare gli input: dopo aver aggiornato il tick del comando, con quello del server, ottenuto da ClientSimulationSystemGroup, impostiamo il valore di horizontal e vertical in base all'input ricevuto dall'utente. Infine, aggiungiamo l'input al buffer di comandi PlayerInput.
var input = default(PlayerInput);
input.Tick = m_ClientSimulationSystemGroup.ServerTick;
if (Input.GetKey("a"))
input.horizontal -= 1;
if (Input.GetKey("d"))
input.horizontal += 1;
if (Input.GetKey("s"))
input.vertical -= 1;
if (Input.GetKey("w"))
input.vertical += 1;
var inputBuffer = EntityManager.GetBuffer<PlayerInput>(localInput);
inputBuffer.AddCommandData(input);
È associato a un'entità capsula e ne indica la velocità di movimento.
Questo sistema viene aggiornato all'interno del gruppo GhostPredictionSystemGroup, che permette di implementare la predizione lato client dei ghost. In particolare, nella OnUpdate() otteniamo il tick di predizione da tale gruppo e iteriamo su tutte le entità capsule, inserendo nella lambda i componenti che ci serviranno. Come prima cosa controlliamo se il codice della predizione dovrebbe eseguire, utilizzando il metodo ShouldPredict() per sapere se all'entità dev'essere applicata la predizione per il tick in questione. In caso affermativo, dal buffer PlayerInput otteniamo il comando relativo a tale tick, e applichiamo il movimento in base ai dati contenuti nel comando.
var tick = m_GhostPredictionSystemGroup.PredictingTick;
var deltaTime = Time.DeltaTime;
Entities.ForEach((DynamicBuffer<PlayerInput> inputBuffer, ref PhysicsVelocity pv, in PredictedGhostComponent prediction, in PlayerMovementSpeed pms) =>
{
if (!GhostPredictionSystemGroup.ShouldPredict(tick, prediction))
return;
PlayerInput input;
inputBuffer.GetDataAtTick(tick, out input);
var speed = pms.speed;
if (input.horizontal > 0)
pv.Linear.x += speed * deltaTime;
if (input.horizontal < 0)
pv.Linear.x -= speed * deltaTime;
if (input.vertical > 0)
pv.Linear.z += speed * deltaTime;
if (input.vertical < 0)
pv.Linear.z -= speed * deltaTime;
}).ScheduleParallel();
Come per PlayerInputSystem, questo sistema esegue nel gruppo ClientSimulationSystemGroup, in quanto la logica che realizza mostra un risultato diverso a seconda del client che esegue. Il metodo OnUpdate() semplicemente salva in una variabile la posizione della camera principale Camera.main e, dopo aver ottenuto il singleton CommandTargetComponent contenente l'entità della capsula corrispondente al client, si cicla su tutte le entità capsule attualmente presenti a tempo di esecuzione. Dunque, si cerca l'entità corrispondente a quella contenuta in CommandTargetComponent, e si aggiorna la posizione della camera con quella della capsula, aggiungendovi un offset per avere una visuale completa. L'offset è ottenuto da un componente PlayerCameraFollowComponent, allegato all'entità capsula.
var position = Camera.main.transform.position;
var commandTargetComponentEntity = GetSingletonEntity<CommandTargetComponent>();
var commandTargetComponent = GetComponent<CommandTargetComponent>(commandTargetComponentEntity);
Entities.WithAll<PlayerScoreComponent>().ForEach((Entity entity, in Translation translation, in PlayerCameraFollowComponent pcf) =>
{
if (entity == commandTargetComponent.targetEntity && !pcf.fixedCamera)
{
position.x = translation.Value.x + pcf.xOffset;
position.y = translation.Value.y + pcf.yOffset;
position.z = translation.Value.z + pcf.zOffset;
}
}).Run();
Camera.main.transform.position = position;
Questo sistema itera sulle entità aventi un buffer di componenti StatefulTriggerEvent ed il componente TemporaryChangeMaterialOnTriggerComponent:
- StatefulTriggerEvent è contenuto nel file DynamicBufferTriggerEventAuthoring.cs e permette di accumulare eventi di tipo Trigger (lanciati quando una oggetto attraversa un portale, tramite le proprietà del componente PhysicsShape di quest'ultimo). Tramite questo possiamo sapere il frame esatto di entrata ed uscita dal portale, oltre che i frame in cui un'entità rimane all'interno di esso, poiché vengono bufferizzati gli eventi Trigger singoli e si controlla lo stato del frame precedente. Tale file è stato preso dalla sub-repository UnityPhysicsSamples di Unity, in cui vi sono diversi esempi per l'utilizzo del package Physics.
- TemporaryChangeMaterialOnTriggerComponent contiene l'entità di cui il portale cambierà il materiale, ogni volta che questa entrerà nel portale.
All'interno del ForEach, iteriamo sugli eventi trigger del buffer, che contengono l'entità con la quale il portale ha avuto la collisione, controllando in particolare quando questa entra o esce:
- Quando entra, si aggiorna il materiale dell'entità con quello del portale.
- Quando esce, si ripristina il materiale originale dell'entità. In questo modo, viene resettato il materiale originale dell'entità, non quello precedente all'ingresso nel portale.
Entities.WithoutBurst().ForEach((Entity e, ref DynamicBuffer<StatefulTriggerEvent> triggerEventBuffer, ref TemporaryChangeMaterialOnTriggerComponent changeMaterial) =>
{
for (int i = 0; i < triggerEventBuffer.Length; i++)
{
var triggerEvent = triggerEventBuffer[i];
var otherEntity = triggerEvent.GetOtherEntity(e);
// exclude other triggers and processed events
if (triggerEvent.State == EventOverlapState.Stay || !nonTriggerMask.Matches(otherEntity))
{
continue;
}
if (triggerEvent.State == EventOverlapState.Enter)
{
var volumeRenderMesh = EntityManager.GetSharedComponentData<RenderMesh>(e);
var overlappingRenderMesh = EntityManager.GetSharedComponentData<RenderMesh>(otherEntity);
overlappingRenderMesh.material = volumeRenderMesh.material;
commandBuffer.SetSharedComponent(otherEntity, overlappingRenderMesh);
}
else
{
// State == PhysicsEventState.Exit
if (changeMaterial.ReferenceEntity == Entity.Null)
{
continue;
}
var overlappingRenderMesh = EntityManager.GetSharedComponentData<RenderMesh>(otherEntity);
var referenceRenderMesh = EntityManager.GetSharedComponentData<RenderMesh>(changeMaterial.ReferenceEntity);
overlappingRenderMesh.material = referenceRenderMesh.material;
commandBuffer.SetSharedComponent(otherEntity, overlappingRenderMesh);
}
}
}).Run();
Il sistema PersistentChangeMaterialOnTriggerSystem è una versione semplificata di quello temporaneo. A differenza del precedente, non si gestisce la casistica in cui l'entità che attraversa il portale esca. Per questo motivo si utilizza il componente PersistentChangeMaterialOnTriggerTagComponent che non contiene alcun tipo di informazione, ma serve solo per indicare che un'entità è un portale.
Questo componente indica che l'entità a cui è attaccato è un portale per il teletrasporto e contiene due proprietà:
- L'entità del portale compagno, che serve per identificare da dove usciranno le entità che attraversano il portale a cui è allegato il componente.
- Il numero di teletrasporti, che serve invece per evitare che l'entità che attraversa il portale non venga immediatamente riportata indietro.
[GenerateAuthoringComponent]
public struct TeleportComponent : IComponentData
{
public Entity Companion;
public int TransferCount;
}
Questo sistema, nel metodo OnCreate() inizializza il contatore dei trasferimenti a 0.
Entities.ForEach((ref TeleportComponent tc) =>
{
tc.TransferCount = 0;
}).Run();
Nel metodo OnUpdate() iteriamo su tutte le entità aventi il componente TeleportComponent ed un buffer di StatefulTriggerEvent. Dentro al ForEach, salviamo in una variabile l'entità del portale compagno, quindi iteriamo su tutti gli eventi trigger contenuti nel buffer. All'interno del ciclo for salviamo l'entità che ha innescato il trigger con il portale in una variabile (otherEntity), dopodiché controlliamo se questa è appena stata teletrasportata e, in questo caso, decrementiamo di 1 il valore di TransferCount. Physics utilizza RigidTransform per indicare la posizione di un'entità nel "world-space" e applicarvi la simulazione fisica. Il portale (come nel nostro caso, in cui il teletrasporto effettivo è solo la superficie interna di un prefab) può essere parte di una gerarchia, dunque i componenti Translation e Rotation, che ne indicano la posizione, potrebbero non essere in coordinate globali (rispetto al world-space), ma relative all'entità padre nella gerarchia. Per questo motivo, salviamo in delle variabili le posizioni dei portali come RigidTransform, controllando se l'entità fa match con la maschera hierarchyChildMask (la quale contiene una query che controlla se ha i componenti Parent e LocalToWorld):
- in caso affermativo, significa che l'entità del portale è parte di una gerarchia, dunque utilizziamo il metodo DecomposeRigidBodyTransform() per ottenere un RigidTransform che ne indica la posizione nel "world-space", così che Physics possa utilizzarlo correttamente;
- in caso negativo, utilizziamo semplicemente la posizione corrente del portale (Translation e Rotation) per creare RigidTransform.
var portalTransform = hierarchyChildMask.Matches(portalEntity)
? Math.DecomposeRigidBodyTransform(GetComponent<LocalToWorld>(portalEntity).Value)
: new RigidTransform(GetComponent<Rotation>(portalEntity).Value, GetComponent<Translation>(portalEntity).Value);
var companionTransform = hierarchyChildMask.Matches(companionEntity)
? Math.DecomposeRigidBodyTransform(GetComponent<LocalToWorld>(companionEntity).Value)
: new RigidTransform(GetComponent<Rotation>(companionEntity).Value, GetComponent<Translation>(companionEntity).Value);
Dopodiché calcoliamo l'offset di posizione e rotazione fra i portali, otteniamo i componenti relativi a posizione, rotazione e velocità dell'entità che l'ha attraversato, e vi applichiamo rispettivamente:
- una traslazione, per realizzare il teletrasporto;
- una rotazione, per aggiornare la direzione verso cui è rivolta;
- una rotazione, per aggiornare la direzione della velocità lineare. Infine aggiorniamo il valore di questi e incrementiamo TransferCount di 1.
var portalPositionOffset = companionTransform.pos - portalTransform.pos;
var portalRotationOffset = math.mul(companionTransform.rot, math.inverse(portalTransform.rot));
var entityPositionComponent = GetComponent<Translation>(otherEntity);
var entityRotationComponent = GetComponent<Rotation>(otherEntity);
var entityVelocityComponent = GetComponent<PhysicsVelocity>(otherEntity);
entityVelocityComponent.Linear = math.rotate(portalRotationOffset, entityVelocityComponent.Linear);
entityPositionComponent.Value += portalPositionOffset;
entityRotationComponent.Value = math.mul(entityRotationComponent.Value, portalRotationOffset);
SetComponent(otherEntity, entityPositionComponent);
SetComponent(otherEntity, entityRotationComponent);
SetComponent(otherEntity, entityVelocityComponent);
companionTeleportComponent.TransferCount++;
Per maggiori informazioni su come funziona il sistema che gestisce la posizione delle entità, fare riferimento a TransformSystem, mentre per quanto riguarda i calcoli matematici fatti per le gerarchie di entità Physics.Math.
In questo caso, invece di utilizzare l'attributo [GenerateAuthoringComponent] abbiamo utilizzato il conversion workflow di ECS, che ci permette di convertire un MonoBehaviour nel rispettivo componente. Utilizzando l'interfaccia IConvertGameObjectToEntity ed il relativo metodo Convert convertiamo il MonoBehaviour in un componente SpawnSettings, creando e inizializzando la rispettiva struttura, aggiungendolo all'entità convertita dal GameObject.
public void Convert(Entity entity, EntityManager dstManager, GameObjectConversionSystem conversionSystem)
{
var spawnSettings = new T
{
Prefab = conversionSystem.GetPrimaryEntity(prefab),
Position = transform.position,
Rotation = transform.rotation,
Range = range,
Count = count
};
Configure(ref spawnSettings);
dstManager.AddComponentData(entity, spawnSettings);
}
Questo sistema si occupa dello spawn delle entità: all'interno del metodo OnUpdate(), utilizziamo un ciclo ForEach "rustico" (in quanto il sistema è di tipo generico T) per iterare sulle entità che hanno il componente SpawnSettings. All'interno di questo ciclo creiamo un NativeArray della dimensione di count, ovvero il numero di entità da spawnare, per contenere le istanze delle entità, e altri due NativeArray per le posizioni e le rotazioni. Dunque utilizziamo il metodo RandomPointsInRange() per inizializzare questi ultimi due NativeArray ed iteriamo per il numero di entità da spawnare e per ciascuna impostiamo il componente Traslation e Rotation, così che possa comparire all'interno del mondo. Infine rimuoviamo il componente SpawnSettings dall'entità spawner, per impedire che il metodo OnUpdate() esegua di nuovo.
var instances = new NativeArray<Entity>(count, Allocator.Temp);
EntityManager.Instantiate(spawnSettings.Prefab, instances);
var positions = new NativeArray<float3>(count, Allocator.Temp);
var rotations = new NativeArray<quaternion>(count, Allocator.Temp);
RandomPointsInRange(spawnSettings.Position, spawnSettings.Rotation, spawnSettings.Range, ref positions, ref rotations, GetRandomSeed(spawnSettings));
for (int i = 0; i < count; i++)
{
var instance = instances[i];
EntityManager.SetComponentData(instance, new Translation { Value = positions[i] });
EntityManager.SetComponentData(instance, new Rotation { Value = rotations[i] });
ConfigureInstance(instance, spawnSettings);
}
EntityManager.RemoveComponent<T>(entity);
Il metodo RandomPointsInRange() prende i valori del volume passati come parametri e genera un punto radomico all'interno di questo, aggiornando i valori di Translation e Rotation per ciascun elemento dei rispettivi array.
protected static void RandomPointsInRange(
float3 center, quaternion orientation, float3 range,
ref NativeArray<float3> positions, ref NativeArray<quaternion> rotations, int seed = 0)
{
var count = positions.Length;
var random = new Unity.Mathematics.Random((uint)seed);
for (int i = 0; i < count; i++)
{
positions[i] = center + math.mul(orientation, random.NextFloat3(-range, range));
rotations[i] = math.mul(random.NextQuaternionRotation(), orientation);
}
}
È assegnato al personaggio capsula di un giocatore e ne indica il punteggio corrente.
Indica che un'entità è raccoglibile e vi assegna un punteggio.
È un componente vuoto che indica che un'entità dev'essere eliminata.
Questo sistema si occupa di rilevare gli eventi trigger tra un player capsula e un'entità collectible. Il funzionamento è simile a quello dei portali: all'interno del metodo OnUpdate() iteriamo su tutte le entità aventi il buffer di StatefulTriggerEvent e CollectibleTagComponent.
Entities.WithoutBurst().ForEach(
(Entity e, ref DynamicBuffer<StatefulTriggerEvent> triggerEventBuffer, ref CollectibleTagComponent collectibleComponent) => {
Dopodiché eseguiamo un ciclo sugli eventi trigger contenuti nel buffer:
- Otteniamo l'entità con cui è avvenuta la collisione.
- Controlliamo che lo stato di StatefulTriggerEvent sia "Enter" e che l'entità con cui è avvenuta la collisione sia un personaggio capsula (ovvero abbia il componente PlayerMovementSpeed).
- Otteniamo il componente PlayerScoreComponent, in cui si trova il punteggio attuale del giocatore e lo incrementiamo del valore contenuto in CollectibleTagComponent.
- Aggiorniamo il valore di PlayerScoreComponent del giocatore e aggiungiamo il componente DeleteTagComponent all'entità collectible.
var otherEntity = triggerEvent.GetOtherEntity(e);
if (triggerEvent.State == EventOverlapState.Enter && EntityManager.HasComponent<PlayerMovementSpeed>(otherEntity))
{
var punteggioPlayer = EntityManager.GetComponentData<PlayerScoreComponent>(otherEntity);
punteggioPlayer.score += collectibleComponent.points;
commandBuffer.SetComponent(otherEntity, punteggioPlayer);
commandBuffer.AddComponent<DeleteTagComponent>(e, new DeleteTagComponent());
}
Questo sistema si occupa dell'eliminazione delle entità marcate col componente DeleteTagComponent. Semplicemente itera su tutte le entità aventi tale componente e, utilizzando un command buffer, le elimina.
Entities.WithAll<DeleteTagComponent>().ForEach((Entity entity) =>
{
commandBuffer.DestroyEntity(entity);
}).Run();
Questa funzionalità poteva essere realizzata direttamente utilizzando solo CollectibleTagComponent e PickUpSystem, realizzando l'eliminazione dell'entità direttamente all'interno di quest'ultimo, ma abbiamo voluto separare le competenze mostrando un'ulteriore possibilità.
- Unity Manual
- Unity Entities
- Unity Physics
- Unity NetCode
- Unity Copenhagen 2019 - DOTS (YouTube Playlist)
- DOTS Sample - uno sparatutto multigiocatore completo realizzato utilizzando DOTS.
- EntityComponentSystemSamples - contiene delle sub-repository, fra cui anche UnityPhysicsSamples, con esempi, demo e use cases molto utili.
- DOTS training samples - contiene piccole simulazioni e giochi implementati utilizzando l'architettura classica (senza DOTS) di Unity. È una repository utile per allenarsi a usare DOTS.
- Unity Real-time Multiplayer Alpha - contiene un progetto d'esempio che utilizza NetCode per realizzare la parte di networking.
- FPS Sample - obsoleto ma è un progetto interessante.
- AngryBots ECS - semplice gioco in terza persona che mostra in modo semplice alcuni vantaggi dell'utilizzo di DOTS.
Clicca qui per visitare il forum.