diff --git a/data/HFSM.TrueDecision.cs b/data/HFSM.TrueDecision.cs new file mode 100644 index 0000000000000000000000000000000000000000..2c39a065c2db760702a0506cdff3580d394d8d73 --- /dev/null +++ b/data/HFSM.TrueDecision.cs @@ -0,0 +1,15 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + [Serializable] + [AssetObjectConfig(GenerateLinkingScripts = true, GenerateAssetCreateMenu = false, GenerateAssetResetMethod = false)] + public partial class TrueDecision : HFSMDecision + { + public override unsafe bool Decide(Frame frame, EntityRef entity) + { + return true; + } + } +} diff --git a/data/IBotDebug.cs b/data/IBotDebug.cs new file mode 100644 index 0000000000000000000000000000000000000000..9c28637234a575e1f8292b6db216566646603de9 --- /dev/null +++ b/data/IBotDebug.cs @@ -0,0 +1,15 @@ +namespace Quantum +{ + // Interface to make the communication between the three solutions involved: quantum_unity, quantum_code and quantum.ai.editor + // Basically, these are the information that the quantum.ai.editor needs to know that are meant to be filed from Unity + public interface IBotDebug + { + EntityRef EntityRef { get; } + Frame Frame { get; } + + // The asset names on Unity + string GetHFSMRootName(); + string GetBTRootName(); + string GetUTRootName(); + } +} \ No newline at end of file diff --git a/data/IdleAction.cs b/data/IdleAction.cs new file mode 100644 index 0000000000000000000000000000000000000000..3d8c9325247cf5f2a2854ce5803c4269f29419ec --- /dev/null +++ b/data/IdleAction.cs @@ -0,0 +1,15 @@ +using System; +using System.Runtime.InteropServices; +using Photon.Deterministic; + +namespace Quantum +{ + [Serializable] + [AssetObjectConfig(GenerateLinkingScripts = true, GenerateAssetCreateMenu = false, GenerateAssetResetMethod = false)] + public partial class IdleAction : AIAction + { + public override unsafe void Update(Frame frame, EntityRef entity) + { + } + } +} diff --git a/data/IncreaseBlackboardInt.cs b/data/IncreaseBlackboardInt.cs new file mode 100644 index 0000000000000000000000000000000000000000..2814bc29cb39e62c66ecda18cc41493450887316 --- /dev/null +++ b/data/IncreaseBlackboardInt.cs @@ -0,0 +1,27 @@ +using System; + +namespace Quantum +{ + [Serializable] + [AssetObjectConfig(GenerateLinkingScripts = true, GenerateAssetCreateMenu = false, GenerateAssetResetMethod = false)] + public unsafe partial class IncreaseBlackboardInt : AIAction + { + public AIBlackboardValueKey Key; + public AIParamInt IncrementAmount; + + public override unsafe void Update(Frame frame, EntityRef entity) + { + var blackboard = frame.Unsafe.GetPointer(entity); + + var agent = frame.Unsafe.GetPointer(entity); + var aiConfig = agent->GetConfig(frame); + + var incrementValue = IncrementAmount.Resolve(frame, entity, blackboard, aiConfig); + + var currentAmount = blackboard->GetInteger(frame, Key.Key); + currentAmount += incrementValue; + + blackboard->Set(frame, Key.Key, currentAmount); + } + } +} \ No newline at end of file diff --git a/data/Input.User.cs b/data/Input.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..7a6b32ec8a636a0af8c164fb87abb1bfe260f297 --- /dev/null +++ b/data/Input.User.cs @@ -0,0 +1,29 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + unsafe partial struct Input + { + // input bandwidth trick by Erick Passos + // using a single Byte to encode a 2D direction with 2 degrees of accuracy + public FPVector2 Direction + { + get { + if (EncodedDirection == default) return default; + Int32 angle = ((Int32)EncodedDirection - 1) * 2; + return FPVector2.Rotate(FPVector2.Up, angle * FP.Deg2Rad); + } + set { + if (value == default) + { + EncodedDirection = default; + return; + } + var angle = FPVector2.RadiansSigned(FPVector2.Up, value) * FP.Rad2Deg; + angle = (((angle + 360) % 360) / 2) + 1; + EncodedDirection = (Byte)(angle.AsInt); + } + } + } +} diff --git a/data/InputHandler.cs b/data/InputHandler.cs new file mode 100644 index 0000000000000000000000000000000000000000..e9d02b9f27065f99126fc746346c8a81b5c3ac2c --- /dev/null +++ b/data/InputHandler.cs @@ -0,0 +1,74 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using Quantum; +using Photon.Deterministic; + +public unsafe class InputHandler : MonoBehaviour +{ + + public int InitialIndex; + public int TargetIndex; + public bool HasInitial; + public bool DebugMoves = false; + public MovesIndicatorManager MovesIndicator; + public GameObject SelectedPieceIndicator; + + public UnityEngine.LayerMask BoardsRaycastMask; + + private void Update() + { + HandleClick(); + + if (HasInitial) + { + ChessViewUpdater.Instance.SetObjectByIndex(SelectedPieceIndicator, InitialIndex); + } + else { + SelectedPieceIndicator.SetActive(false); + } + } + + public void HandleClick() + { + if (UnityEngine.Input.GetMouseButtonDown(0)) + { + // Perform the Unity raycast + Ray ray = Camera.main.ScreenPointToRay(UnityEngine.Input.mousePosition); + RaycastHit hit; + Physics.Raycast(ray, out hit, 100, BoardsRaycastMask); + + // If the raycast hit the Board... + if (hit.collider != null) + { + var position = new FPVector2(FP.FromFloat_UNSAFE(hit.point.x), FP.FromFloat_UNSAFE(hit.point.z)); + if (HasInitial == false || DebugMoves) + { + InitialIndex = BoardHelper.GetIndexByPosition(position); + HasInitial = true; + MovesIndicator.UpdatePossibleMovements(InitialIndex); + } + else + { + TargetIndex = BoardHelper.GetIndexByPosition(position); + Frame f = QuantumRunner.Default.Game.Frames.Verified; + if (MoveValidatorHelper.IsValidMove(ref f.Global->Board, InitialIndex, TargetIndex, true) == false) + { + InitialIndex = TargetIndex; + MovesIndicator.UpdatePossibleMovements(InitialIndex); + } + else + { + HasInitial = false; + //sendcommand + var c = new MoveCommand(); + c.Data.InitialIndex = InitialIndex; + c.Data.TargetIndex = TargetIndex; + QuantumRunner.Default.Game.SendCommand(c); + MovesIndicator.ResetPrefabs(); + } + } + } + } + } +} diff --git a/data/KCCSettings.txt b/data/KCCSettings.txt new file mode 100644 index 0000000000000000000000000000000000000000..43612dea2257a75c28a0130cae68a6712c9425ee --- /dev/null +++ b/data/KCCSettings.txt @@ -0,0 +1,194 @@ +using System; +using Photon.Deterministic; +using Quantum.Core; + +namespace Quantum +{ + public enum KCCMovementType + { + None, + Free, + Tangent + } + + public struct KCCMovementData + { + public KCCMovementType Type; + public FPVector2 Correction; + public FPVector2 Direction; + public FP MaxPenetration; + } + + unsafe partial class KCCSettings : AssetObject + { + // This is the KCC actual radius (non penetrable) + public FP Radius = FP._0_50; + public Int32 MaxContacts = 2; + public FP AllowedPenetration = FP._0_10; + public FP CorrectionSpeed = FP._10; + public FP BaseSpeed = FP._2; + public FP Acceleration = FP._10; + public Boolean Debug = false; + public FP Brake = 1; + + public void Init(ref KCC kcc) + { + kcc.Settings = this; + kcc.MaxSpeed = BaseSpeed; + kcc.Acceleration = Acceleration; + } + + public void SteerAndMove(FrameBase f, EntityRef entity, in KCCMovementData movementData) + { + KCC* kcc = null; + if (f.Unsafe.TryGetPointer(entity, out kcc) == false) + { + return; + } + + Transform2D* transform = null; + if (f.Unsafe.TryGetPointer(entity, out transform) == false) + { + return; + } + + if (movementData.Type != KCCMovementType.None) + { + kcc->Velocity += kcc->Acceleration * f.DeltaTime * movementData.Direction; + if (kcc->Velocity.SqrMagnitude > kcc->MaxSpeed * kcc->MaxSpeed) + { + kcc->Velocity = kcc->Velocity.Normalized * kcc->MaxSpeed; + } + //transform->Rotation = FPVector2.RadiansSigned(FPVector2.Up, movementData.Direction);// FPMath.Atan2(kcc->Velocity.Y, kcc->Velocity.X); + } + else + { + // brake instead? + kcc->Velocity = FPVector2.MoveTowards(kcc->Velocity, FPVector2.Zero, f.DeltaTime * Brake); + } + + if (movementData.MaxPenetration > AllowedPenetration) + { + if (movementData.MaxPenetration > AllowedPenetration * 2) + { + transform->Position += movementData.Correction; + } + else + { + transform->Position += movementData.Correction * f.DeltaTime * CorrectionSpeed; + } + + } + + + transform->Position += kcc->Velocity * f.DeltaTime; + + + +#if DEBUG + if (Debug) + { + Draw.Circle(transform->Position, Radius, ColorRGBA.Blue); + Draw.Ray(transform->Position, transform->Forward * Radius, ColorRGBA.Red); + } +#endif + } + + public KCCMovementData ComputeRawMovement(FrameBase f, EntityRef entity, FPVector2 direction) + { + KCC* kcc = null; + if (f.Unsafe.TryGetPointer(entity, out kcc) == false) + { + return default; + } + + Transform2D* transform = null; + if (f.Exists(entity) == false || f.Unsafe.TryGetPointer(entity, out transform) == false) + { + return default; + } + + KCCMovementData movementPack = default; + + + movementPack.Type = direction != default ? KCCMovementType.Free : KCCMovementType.None; + movementPack.Direction = direction; + Shape2D shape = Shape2D.CreateCircle(Radius); + + var layer = f.Layers.GetLayerMask("Static"); + var hits = f.Physics2D.OverlapShape(transform->Position, FP._0, shape, layer, options: QueryOptions.HitStatics | QueryOptions.ComputeDetailedInfo); + int count = Math.Min(MaxContacts, hits.Count); + + if (hits.Count > 0) + { + Boolean initialized = false; + hits.Sort(transform->Position); + for (int i = 0; i < hits.Count && count > 0; i++) + { + // ignore triggers + if (hits[i].IsTrigger) + { + // callback here... + continue; + } + + // ignoring "self" contact + if (hits[i].Entity == entity) + { + continue; + } + + var contactPoint = hits[i].Point; + var contactToCenter = transform->Position - contactPoint; + var localDiff = contactToCenter.Magnitude - Radius; + + +#if DEBUG + if (Debug) + { + Draw.Circle(contactPoint, FP._0_10, ColorRGBA.Red); + } +#endif + + var localNormal = contactToCenter.Normalized; + + count--; + + // define movement type + if (!initialized) + { + initialized = true; + + if (direction != default) + { + var angle = FPVector2.RadiansSkipNormalize(direction.Normalized, localNormal); + if (angle >= FP.Rad_90) + { + var d = FPVector2.Dot(direction, localNormal); + var tangentVelocity = direction - localNormal * d; + if (tangentVelocity.SqrMagnitude > FP.EN4) + { + movementPack.Direction = tangentVelocity.Normalized; + movementPack.Type = KCCMovementType.Tangent; + } + else + { + movementPack.Direction = default; + movementPack.Type = KCCMovementType.None; + } + + } + } + movementPack.MaxPenetration = FPMath.Abs(localDiff); + } + + // any real contact contributes to correction and average normal + var localCorrection = localNormal * -localDiff; + movementPack.Correction += localCorrection; + } + } + + return movementPack; + } + } +} \ No newline at end of file diff --git a/data/Lists.txt b/data/Lists.txt new file mode 100644 index 0000000000000000000000000000000000000000..94fadd2d76270be7c3ab6587750bedb3f2d076b3 --- /dev/null +++ b/data/Lists.txt @@ -0,0 +1,12 @@ + +In quantum we can to list as metioned below. +we can to list in Quantum by the following + +component Targets { + list Enemies; +} +The basic API methods for dealing with these Lists are: + +Frame.AllocateList() +Frame.FreeList(QListPtr ptr) +Frame.ResolveList(QListPtr ptr) diff --git a/data/Map.cs b/data/Map.cs new file mode 100644 index 0000000000000000000000000000000000000000..5f282702bb03ef11d7184d19c80927b47f919764 --- /dev/null +++ b/data/Map.cs @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/MovesIndicatorManager.cs b/data/MovesIndicatorManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..07aaddcf15ecf0d01a60aa9652888553ece48f9e --- /dev/null +++ b/data/MovesIndicatorManager.cs @@ -0,0 +1,60 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using Quantum; + +public unsafe class MovesIndicatorManager : QuantumCallbacks +{ + + public GameObject HighlightPrefab; + + private List _prefabs = new List(); + + public void UpdatePossibleMovements(int index) + { + var f = QuantumRunner.Default.Game.Frames.Verified; + ResetPrefabs(); + for (int i = 0; i < f.Global->Board.Cells.Length; i++) + { + if (MoveValidatorHelper.IsValidMove(ref f.Global->Board, index, i, true)) + { + var FPPosition = BoardHelper.GetCordinatesByIndex(i); + Vector3 position = new Vector3((float)FPPosition.X + .5f, .5f, (float)FPPosition.Y + .5f); + var prefab = GetPrefab(); + if (prefab == null) + { + var go = Instantiate(HighlightPrefab, position, Quaternion.identity, transform); + go.SetActive(true); + _prefabs.Add(go); + } + else + { + prefab.SetActive(true); + prefab.transform.position = position; + } + } + } + } + private GameObject GetPrefab() + { + for (int i = 0; i < _prefabs.Count; i++) + { + if (_prefabs[i] != null && _prefabs[i].activeSelf == false) + { + return _prefabs[i]; + } + } + return null; + } + + public void ResetPrefabs() + { + for (int i = 0; i < _prefabs.Count; i++) + { + if (_prefabs[i] != null) + { + _prefabs[i].SetActive(false); + } + } + } +} diff --git a/data/PickupSpawnerSystem.cs b/data/PickupSpawnerSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..0eeb2e2bd953ba07ba8b09415fd4d11adcec44d5 --- /dev/null +++ b/data/PickupSpawnerSystem.cs @@ -0,0 +1,35 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + public unsafe class PickupSpawnerSystem : SystemMainThread + { + public override void Update(Frame frame) + { + if (frame.Unsafe.TryGetPointerSingleton(out var spawner)) + { + int frameInterval = spawner->SpawnInterval * frame.UpdateRate; + //if ((frameInterval % f.Number) == 0 && spawner->Count < spawner->MaxCount) + if (spawner->Count < spawner->MaxCount) + { + EntityRef pickup = frame.Create(spawner->PickupPrototype); + if (frame.Unsafe.TryGetPointer(pickup, out var transform)) + { + FP angle = frame.RNG->Next(0, 360); + FPQuaternion rotation = FPQuaternion.Euler(0, angle, 0); + transform->Position = (rotation * FPVector3.Right) * frame.RNG->Next(FP._0, spawner->RandomRadius); + } + spawner->Count++; + } + + } + + ComponentIterator chainItems = frame.GetComponentIterator(); + foreach (var item in chainItems) + { + if (item.Component.Destroy) frame.Destroy(item.Entity); + } + } + } +} diff --git a/data/PieceView.cs b/data/PieceView.cs new file mode 100644 index 0000000000000000000000000000000000000000..565e5b5655c656046854ca239f782a9afd9e43e3 --- /dev/null +++ b/data/PieceView.cs @@ -0,0 +1,28 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using Quantum; + +public class PieceView : MonoBehaviour { + public PieceType Type; + public PieceColor Color; + public int IndexOnBoard = -1; + public bool Initialized = false; + + [SerializeField] + private Vector3 _targetPosition; + + public void SetTargetPosition(Vector3 target) + { + Initialized = true; + _targetPosition = target; + } + + void Update() + { + if (Vector3.Distance(transform.position, _targetPosition) > 0.01f && Initialized) { + transform.position = Vector3.Lerp(transform.position, _targetPosition, Time.deltaTime * 10); + } + } + +} diff --git a/data/PiecesMapBaker.cs b/data/PiecesMapBaker.cs new file mode 100644 index 0000000000000000000000000000000000000000..0edb65c8dc3e769997ab2d0bee8c2569504671e5 --- /dev/null +++ b/data/PiecesMapBaker.cs @@ -0,0 +1,59 @@ +using UnityEngine; +using Quantum; +using Photon.Deterministic; +#if UNITY_EDITOR +using UnityEditor; +#endif + +//When a class implements MapDataBakerCallback, it can handle the activation of the "OnBake" event. +//This event is called when you hit the "Bake Data" at the Unity scene +//So this is useful whenever the developer needs to be into the bake process +public class PiecesMapBaker : MapDataBakerCallback +{ + public override void OnBake(MapData data) + { + PieceView[] pieces = GameObject.FindObjectsOfType(); + + var boardSpec = UnityDB.FindAsset(data.Asset.Settings.UserAsset.Id); + boardSpec.Settings.Pieces = new BoardSpec.PieceMap[pieces.Length]; + + FillBoardInformation(boardSpec.Settings, pieces); +#if UNITY_EDITOR + EditorUtility.SetDirty(boardSpec.Settings.GetUnityAsset()); +#endif + + } + + public override void OnBeforeBake(MapData data) + { + } + + private void FillBoardInformation(BoardSpec targetSpec, PieceView[] pieces) + { + for (int i = 0; i < targetSpec.Pieces.Length; i++) + { + targetSpec.Pieces[i].InitialIndex = -1; + + targetSpec.Pieces[i].Type = PieceType.None; + targetSpec.Pieces[i].Color = PieceColor.None; + } + + for (int i = 0; i < pieces.Length; i++) + { + FP positionX = FP.FromFloat_UNSAFE(pieces[i].transform.position.x); + FP positionY = FP.FromFloat_UNSAFE(pieces[i].transform.position.z); + var index = BoardHelper.GetIndexByPosition(new FPVector2(positionX, positionY)); + if (index >= 0) + { + targetSpec.Pieces[i].InitialIndex = index; + pieces[i].IndexOnBoard = index; + targetSpec.Pieces[i].Type = pieces[i].Type; + targetSpec.Pieces[i].Color = pieces[i].Color; + } + else + { + pieces[i].IndexOnBoard = -1; + } + } + } +} diff --git a/data/PlayCommand.cs b/data/PlayCommand.cs new file mode 100644 index 0000000000000000000000000000000000000000..0934198a6af7da08b62cb1b167ad4712bee6f1be --- /dev/null +++ b/data/PlayCommand.cs @@ -0,0 +1,26 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + [Serializable] + public struct PlayCommandData + { + public FP Force; + public FPVector3 Direction; + public FPVector2 Spin; + } + + public class PlayCommand : DeterministicCommand + { + public PlayCommandData Data; + + public override void Serialize(BitStream stream) + { + // serialize command data here + stream.Serialize(ref Data.Force); + stream.Serialize(ref Data.Direction); + stream.Serialize(ref Data.Spin); + } + } +} \ No newline at end of file diff --git a/data/PlayerSystem.cs b/data/PlayerSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..9b1c40964537e2c45a525aaf2e530be4c0392952 --- /dev/null +++ b/data/PlayerSystem.cs @@ -0,0 +1,25 @@ +using System; +using Photon.Deterministic; +using Quantum.Task; + +namespace Quantum +{ + public unsafe class PlayerSystem : SystemSignalsOnly, ISignalOnPlayerDataSet + { + public void OnPlayerDataSet(Frame frame, PlayerRef player) + { + RuntimePlayer data = frame.GetPlayerData(player); + EntityRef traktorEntity = frame.Create(data.TraktorPrototype); + Traktor* traktor = frame.Unsafe.GetPointer(traktorEntity); + if (frame.Unsafe.TryGetPointer(traktorEntity, out var transform) && frame.Unsafe.TryGetPointer(traktor->Sphere, out var sphereTransform)) + { + transform->Position = new FPVector3(player * 2, 0, 0); + sphereTransform->Position = transform->Position; + } + if (frame.Unsafe.TryGetPointer(traktorEntity, out var controller)) + { + controller->Player = player; + } + } + } +} diff --git a/data/Pool.cs b/data/Pool.cs new file mode 100644 index 0000000000000000000000000000000000000000..597041c14c592f30d19b071e8e16085e5531fbb3 --- /dev/null +++ b/data/Pool.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Runtime.CompilerServices; + +namespace Quantum +{ + public static class Pool where T : new() + { + private const int POOL_CAPACITY = 4; + + private static readonly List _pool = new List(POOL_CAPACITY); + + public static int Count => _pool.Count; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T Get() + { + bool found = false; + T item = default; + + lock (_pool) + { + int index = _pool.Count - 1; + if (index >= 0) + { + found = true; + item = _pool[index]; + + _pool.RemoveAt(index); + } + } + + if (found == false) + { + item = new T(); + } + + return item; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Return(T item) + { + if (item == null) + return; + + lock (_pool) + { + _pool.Add(item); + } + } + } + + public static class Pool + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static T Get() where T : new() + { + return Pool.Get(); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void Return(T item) where T : new() + { + Pool.Return(item); + } + } +} \ No newline at end of file diff --git a/data/QuantumConsoleRunner.cs b/data/QuantumConsoleRunner.cs new file mode 100644 index 0000000000000000000000000000000000000000..83db4befc0106c73721883ae05efac16476f0658 --- /dev/null +++ b/data/QuantumConsoleRunner.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; + +namespace Quantum { + class QuantumConsoleRunner { + static void Main(string[] args) { + + Log.InitForConsole(); + + var pathToLUT = Path.GetFullPath(args[0]); + var pathToDatabaseFile = Path.GetFullPath(args[1]); + var pathToReplayFile = Path.GetFullPath(args[2]); + var pathToChecksumFile = args.Length > 3 ? Path.GetFullPath(args[3]) : null; + var maxIterations = args.Length > 4 ? long.Parse(args[4]) : 1; + + // Demonstration of a sample runner. Please duplicate the ReplayRunnerSample class to modify, because it may get overwritten in the future. + long iteration = 0; + while (iteration < maxIterations && ReplayRunnerSample.Run(pathToLUT, pathToDatabaseFile, pathToReplayFile, pathToChecksumFile)) { + if (++iteration < maxIterations) { + Console.ForegroundColor = ConsoleColor.Blue; + Console.WriteLine($"Iteration {iteration + 1}"); + Console.ForegroundColor = ConsoleColor.Gray; + } + } + + //Console.ReadKey(); + } + } +} diff --git a/data/QuantumJsonSerializer.cs b/data/QuantumJsonSerializer.cs new file mode 100644 index 0000000000000000000000000000000000000000..f807eaa1415a86d5aaf4c322d596145515be7d27 --- /dev/null +++ b/data/QuantumJsonSerializer.cs @@ -0,0 +1,112 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; + +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Quantum { + + public class QuantumJsonSerializer : Quantum.JsonAssetSerializerBase { + private readonly JsonSerializer _serializer = CreateSerializer(); + + public static JsonSerializer CreateSerializer() { + return JsonSerializer.Create(CreateSettings()); + } + + protected override object FromJson(string json, Type type) { + using (var reader = new StringReader(json)) { + var result = _serializer.Deserialize(reader, type); + return result; + } + } + + protected override string ToJson(object obj) { + using (var writer = new StringWriter()) { + _serializer.Serialize(writer, obj); + return writer.ToString(); + } + } + + private static JsonSerializerSettings CreateSettings() { + return new JsonSerializerSettings { + ContractResolver = new WritablePropertiesOnlyResolver(), + Formatting = Formatting.Indented, + TypeNameHandling = TypeNameHandling.Auto, + NullValueHandling = NullValueHandling.Ignore, + Converters = { new ByteArrayConverter() }, + }; + } + private class ByteArrayConverter : JsonConverter { + + public override bool CanConvert(Type objectType) { + return objectType == typeof(byte[]); + } + + public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) { + if (reader.TokenType == JsonToken.StartArray) { + var byteList = new List(); + + while (reader.Read()) { + switch (reader.TokenType) { + case JsonToken.Integer: + byteList.Add(Convert.ToByte(reader.Value)); + break; + + case JsonToken.EndArray: + return byteList.ToArray(); + + case JsonToken.Comment: + // skip + break; + + default: + throw new Exception(string.Format("Unexpected token when reading bytes: {0}", reader.TokenType)); + } + } + + throw new Exception("Unexpected end when reading bytes."); + } else { + throw new Exception(string.Format("Unexpected token parsing binary. Expected StartArray, got {0}.", reader.TokenType)); + } + } + + public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) { + if (value == null) { + writer.WriteNull(); + return; + } + + byte[] data = (byte[])value; + + // compose an array + writer.WriteStartArray(); + + for (var i = 0; i < data.Length; i++) { + writer.WriteValue(data[i]); + } + + writer.WriteEndArray(); + } + } + + private class WritablePropertiesOnlyResolver : DefaultContractResolver { + + protected override IList CreateProperties(Type type, MemberSerialization memberSerialization) { + IList props = base.CreateProperties(type, memberSerialization); + return props.Where(p => p.Writable).ToList(); + } + + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) { + if (member is FieldInfo) { + // just fields + return base.CreateProperty(member, memberSerialization); + } else { + return null; + } + } + } + } +} \ No newline at end of file diff --git a/data/ReplayJsonSerializerSettings.cs b/data/ReplayJsonSerializerSettings.cs new file mode 100644 index 0000000000000000000000000000000000000000..c0e91cc0bac2420404e66cee32d992ce2f725507 --- /dev/null +++ b/data/ReplayJsonSerializerSettings.cs @@ -0,0 +1,13 @@ +using Newtonsoft.Json; + +namespace Quantum { + public static class ReplayJsonSerializerSettings { + public static JsonSerializerSettings GetSettings() { + return new JsonSerializerSettings { + Formatting = Formatting.Indented, + TypeNameHandling = TypeNameHandling.Auto, + NullValueHandling = NullValueHandling.Ignore, + }; + } + } +} \ No newline at end of file diff --git a/data/ReplayRunnerSample.cs b/data/ReplayRunnerSample.cs new file mode 100644 index 0000000000000000000000000000000000000000..c1c7f3198f216b1540326fb05c85a656f789a72d --- /dev/null +++ b/data/ReplayRunnerSample.cs @@ -0,0 +1,63 @@ +using Photon.Deterministic; +using System; +using System.IO; +using System.Threading; + +namespace Quantum { + public class ReplayRunnerSample { + + public static bool Run(string pathToLUT,string pathToDatabaseFile, string pathToReplayFile, string pathToChecksumFile) { + + FPLut.Init(pathToLUT); + + Console.WriteLine($"Loading replay from file: '{Path.GetFileName(pathToReplayFile)}' from folder '{Path.GetDirectoryName(pathToReplayFile)}'"); + + if (!File.Exists(pathToDatabaseFile)) { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"File not found: '{pathToReplayFile}'"); + Console.ForegroundColor = ConsoleColor.Gray; + return false; + } + + var serializer = new QuantumJsonSerializer(); + var callbackDispatcher = new CallbackDispatcher(); + var replayFile = serializer.DeserializeReplay(File.ReadAllBytes(pathToReplayFile)); + var inputProvider = new InputProvider(replayFile.DeterministicConfig); + inputProvider.ImportFromList(replayFile.InputHistory); + + var resourceManager = new ResourceManagerStatic(serializer.DeserializeAssets(File.ReadAllBytes(pathToDatabaseFile)), SessionContainer.CreateNativeAllocator()); + + var container = new SessionContainer(replayFile); + container.StartReplay(new QuantumGame.StartParameters { + AssetSerializer = serializer, + CallbackDispatcher = callbackDispatcher, + EventDispatcher = null, + ResourceManager = resourceManager, + }, inputProvider); + + var numberOfFrames = replayFile.Length; + var checksumVerification = String.IsNullOrEmpty(pathToChecksumFile) ? null : new ChecksumVerification(pathToChecksumFile, callbackDispatcher); + + while (container.Session.FramePredicted == null || container.Session.FramePredicted.Number < numberOfFrames) { + Thread.Sleep(1); + container.Service(dt: 1.0f); + + if (Console.KeyAvailable) { + if (Console.ReadKey().Key == ConsoleKey.Escape) { + Console.WriteLine("Stopping replay"); + return false; + } + } + } + + Console.WriteLine($"Ending replay at frame {container.Session.FramePredicted.Number}"); + + checksumVerification?.Dispose(); + container.Destroy(); + + resourceManager.Dispose(); + + return true; + } + } +} diff --git a/data/ResponseCurve.cs b/data/ResponseCurve.cs new file mode 100644 index 0000000000000000000000000000000000000000..64d1ebe9c1c3813c8420b5735465de9991ea661c --- /dev/null +++ b/data/ResponseCurve.cs @@ -0,0 +1,33 @@ +using Photon.Deterministic; +using System; + +namespace Quantum +{ + [BotSDKHidden] + [System.Serializable] + public unsafe partial class ResponseCurve : AIFunctionFP + { + public AIParamFP Input; + + [BotSDKHidden] + public FPAnimationCurve Curve; + + public override void Loaded(IResourceManager resourceManager, Native.Allocator allocator) + { + base.Loaded(resourceManager, allocator); + } + + public override FP Execute(Frame frame, EntityRef entity = default) + { + if (Input.FunctionRef == default) return 0; + + FP input = Input.ResolveFunction(frame, entity); + FP result = Curve.Evaluate(input); + + if (result > 1) result = 1; + else if (result < 0) result = 0; + + return result; + } + } +} diff --git a/data/RuntimeConfig.User.cs b/data/RuntimeConfig.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..418135fc4d703414b5e4a0477d3ec90ce1ca3988 --- /dev/null +++ b/data/RuntimeConfig.User.cs @@ -0,0 +1,38 @@ +using Photon.Deterministic; +using System; + +namespace Quantum +{ + partial class RuntimeConfig + { + public bool ShowIntroduction; + + public AssetRefHFSMRoot GameManagerHFSM; + + public enum GameMode { CoinGrab, BossBattle }; + public GameMode ConfigType = GameMode.CoinGrab; + + public AssetRefEntityPrototype[] RoomFillBots; + public FP RoomFillInterval = 2; + public bool FillWithBots = true; + + partial void SerializeUserData(BitStream stream) + { + stream.Serialize(ref ShowIntroduction); + stream.Serialize(ref GameManagerHFSM); + + stream.SerializeArrayLength(ref RoomFillBots); + for (var i = 0; i < RoomFillBots.Length; i++) + { + stream.Serialize(ref RoomFillBots[i]); + } + stream.Serialize(ref RoomFillInterval); + + Int32 current = (Int32)ConfigType; + stream.Serialize(ref current); + ConfigType = (GameMode)current; + + stream.Serialize(ref FillWithBots); + } + } +} \ No newline at end of file diff --git a/data/RuntimePlayer.User.cs b/data/RuntimePlayer.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..0a8ce8d484a9b927ba60c8c973ebc624c4a5814a --- /dev/null +++ b/data/RuntimePlayer.User.cs @@ -0,0 +1,24 @@ +using Photon.Deterministic; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Quantum +{ + partial class RuntimePlayer + { + public AssetRefEntityPrototype SelectedCharacter; + public string PlayerName; + public bool ForceAI; + public int TeamIndex; + + partial void SerializeUserData(BitStream stream) + { + stream.Serialize(ref SelectedCharacter); + stream.Serialize(ref PlayerName); + stream.Serialize(ref ForceAI); + stream.Serialize(ref TeamIndex); + } + } +} diff --git a/data/SelfDestroy.cs b/data/SelfDestroy.cs new file mode 100644 index 0000000000000000000000000000000000000000..3046e88bf91dfd3df4052476af6a954051bbb367 --- /dev/null +++ b/data/SelfDestroy.cs @@ -0,0 +1,14 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +public class SelfDestroy : MonoBehaviour +{ + public float TTL = 2; + + void Update() + { + if (TTL < 0) Destroy(gameObject); + TTL -= Time.deltaTime; + } +} \ No newline at end of file diff --git a/data/SetBlackboardInt.cs b/data/SetBlackboardInt.cs new file mode 100644 index 0000000000000000000000000000000000000000..1b6c1a0c2c17fbad8b732e11f29e67b3710eff8c --- /dev/null +++ b/data/SetBlackboardInt.cs @@ -0,0 +1,23 @@ +using System; + +namespace Quantum +{ + [Serializable] + [AssetObjectConfig(GenerateLinkingScripts = true, GenerateAssetCreateMenu = false, GenerateAssetResetMethod = false)] + public unsafe partial class SetBlackboardInt : AIAction + { + public AIBlackboardValueKey Key; + public AIParamInt Value; + + public override unsafe void Update(Frame frame, EntityRef entity) + { + var blackboard = frame.Unsafe.GetPointer(entity); + + var agent = frame.Unsafe.GetPointer(entity); + var aiConfig = agent->GetConfig(frame); + + var value = Value.Resolve(frame, entity, blackboard, aiConfig); + blackboard->Set(frame, Key.Key, value); + } + } +} \ No newline at end of file diff --git a/data/SimulationConfig.User.cs b/data/SimulationConfig.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..262679851baee19af89cc5f28594ac0e404cc7cd --- /dev/null +++ b/data/SimulationConfig.User.cs @@ -0,0 +1,13 @@ +using Photon.Deterministic; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Quantum +{ + partial class SimulationConfig : AssetObject + { + + } +} diff --git a/data/SkipCommand.cs b/data/SkipCommand.cs new file mode 100644 index 0000000000000000000000000000000000000000..a27d3075c621463770d27508ea8abd7d56886acb --- /dev/null +++ b/data/SkipCommand.cs @@ -0,0 +1,21 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + [Serializable] + public struct SkipCommandData + { + // game-specific command data here + } + + public class SkipCommand : DeterministicCommand + { + public SkipCommandData Data; + + public override void Serialize(BitStream stream) + { + // serialize command data here + } + } +} diff --git a/data/SpawnFX.cs b/data/SpawnFX.cs new file mode 100644 index 0000000000000000000000000000000000000000..b39a8392bf0d1667e0449871fef8f45a8cfd2562 --- /dev/null +++ b/data/SpawnFX.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +public class SpawnFX : MonoBehaviour +{ + public Transform VFXPrefab; + + private void OnDestroy() + { + Instantiate(VFXPrefab, transform.position, Quaternion.identity); + } +} \ No newline at end of file diff --git a/data/StopwatchBlock.cs b/data/StopwatchBlock.cs new file mode 100644 index 0000000000000000000000000000000000000000..2f63b83a5e224fe5a060154b645652d760ef8275 --- /dev/null +++ b/data/StopwatchBlock.cs @@ -0,0 +1,24 @@ +using System; +using System.Diagnostics; + +namespace Quantum +{ + public class StopwatchBlock : IDisposable + { + private Stopwatch _stopwatch; + private string _blockName; + + public StopwatchBlock(string blockName) + { + _blockName = blockName; + _stopwatch = new Stopwatch(); + _stopwatch.Start(); + } + + void IDisposable.Dispose() + { + _stopwatch.Stop(); + Log.Info($"{_blockName}: {_stopwatch.Elapsed.TotalMilliseconds} ms"); + } + } +} \ No newline at end of file diff --git a/data/SystemSetup.cs b/data/SystemSetup.cs new file mode 100644 index 0000000000000000000000000000000000000000..205fefabecfdca359c1ad8ce61be6134c18acf8f --- /dev/null +++ b/data/SystemSetup.cs @@ -0,0 +1,56 @@ +using Photon.Deterministic; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Quantum +{ + public static class SystemSetup + { + public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig) + { + return new SystemBase[] { + // pre-defined core systems + new Core.CullingSystem2D(), + new Core.CullingSystem3D(), + + new Core.PhysicsSystem2D(), + new Core.PhysicsSystem3D(), + +#if DEBUG + Core.DebugCommand.CreateSystem(), +#endif + + new Core.NavigationSystem(), + new Core.EntityPrototypeSystem(), + new Core.PlayerConnectedSystem(), + + new BotSDKDebuggerSystem(), + + new GameplaySystemsGroup("Gameplay Systems", + new MatchSystem(), + new PlayerJoiningSystem(), + new CommandsSystem(), + + new GameManagerSystem(), + new MemorySystem(), + new AISystem(), + new TeamDataSystem(), + + new AttributesSystem(), + new HealthSystem(), + new ImmuneSystem(), + new VisibilitySystem(), + new InputSystem(), + new AttackSystem(), + new RespawnSystem(), + new SkillSystem() + ), + + // We don't add it to the group as it is a SignalsOnly system; no need to be enabled/disabled with the rest + new InventorySystem(), + }; + } + } +} diff --git a/data/Traktor.User.cs b/data/Traktor.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..9c6b4383e0994bc590b5ef340eb67b09642b5ee9 --- /dev/null +++ b/data/Traktor.User.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using Photon.Deterministic; + +namespace Quantum +{ + + unsafe partial struct Traktor + { + public void Update(FrameThreadSafe frame, ref TraktorInputSystem.Filter filter, Input input) + { + TraktorConfig config = frame.FindAsset(Config.Id); + + ChainSystem.Filter sphereData = default; + var getter = frame.ComponentGetter(); + ; + + if (getter.TryGet(frame, Sphere, &sphereData)) + { + FPVector2 direction = input.Direction; + + FPVector3 forward = filter.Transform->Forward; + FPVector3 right = filter.Transform->Right; + FP forwardSpeed = FPVector3.Dot(forward, sphereData.Body->Velocity); + + // now rotate + FP lateralForce = config.LateralForce.Evaluate(forwardSpeed / config.MaxSpeed); + + if (direction.X != 0) + { + FP rotationFactor = lateralForce * config.RotationMultiplier * direction.X; + filter.Transform->Rotate(FP._0, rotationFactor * frame.DeltaTime, FP._0); + } + + FPVector3 force = default; + if (direction.Y > 0) + { + force += direction.Y * forward * config.Acceleration; + } + FP slipSpeed = FPVector3.Dot(right, sphereData.Body->Velocity); + force += right * slipSpeed * config.LateralForceMultiplier; + // swizzle from 2D to 3D + sphereData.Body->AddForce(force); + + // snap to sphere position + filter.Transform->Position = sphereData.Transform->Position; + filter.Transform->Rotation = filter.Transform->Rotation.Normalized; + } + } + } +} diff --git a/data/TraktorConfig.cs b/data/TraktorConfig.cs new file mode 100644 index 0000000000000000000000000000000000000000..04867a7acc1817f19a223960709eacb4e4cd9513 --- /dev/null +++ b/data/TraktorConfig.cs @@ -0,0 +1,15 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + partial class TraktorConfig : AssetObject + { + public FP Acceleration; + public FP Drag; + public FP MaxSpeed; + public FPAnimationCurve LateralForce; + public FP LateralForceMultiplier = 1; + public FP RotationMultiplier = 1; + } +} diff --git a/data/TraktorInputSystem.cs b/data/TraktorInputSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..b0587e9f6705db62ca511b33be644096fba34f82 --- /dev/null +++ b/data/TraktorInputSystem.cs @@ -0,0 +1,33 @@ +using System; +using Photon.Deterministic; +using Quantum.Task; + +namespace Quantum +{ + public unsafe class TraktorInputSystem : SystemThreadedFilter, ISignalOnComponentAdded + { + public struct Filter + { + public EntityRef Entity; + public Transform3D* Transform; + public Traktor* Traktor; + } + + public void OnAdded(Frame frame, EntityRef entity, Traktor* traktor) + { + EntityRef sphere = frame.Create(traktor->SpherePrototype); + traktor->Sphere = sphere; + + } + + public override void Update(FrameThreadSafe frame, ref Filter filter) + { + Input input = default; + if (frame.TryGetPointer(filter.Entity, out var controller)) + { + input = *((Frame)frame).GetPlayerInput(controller->Player); + } + filter.Traktor->Update(frame, ref filter, input); + } + } +} diff --git a/data/TraktorView.cs b/data/TraktorView.cs new file mode 100644 index 0000000000000000000000000000000000000000..9e34c851c17ebe3faaf6384fb801549a052d19b7 --- /dev/null +++ b/data/TraktorView.cs @@ -0,0 +1,50 @@ +using Quantum; +using UnityEngine; +using Photon.Deterministic; + +public class TraktorView : MonoBehaviour +{ + Vector3 _lastPosition; + public float SpeedThreshold = 0.1f; + public float RaycastDistance = 0.51f; + public float RotationFactor = 8f; + public Transform TraktorBody; + Quaternion _desiredRotation; + private bool _wasGrounded; + private EntityView _view; + private EntityRef _sphere; + void Start() + { + _view = GetComponent(); + var frame = QuantumRunner.Default.Game.Frames.Verified; + var controller = frame.Get(_view.EntityRef); + if (QuantumRunner.Default.Session.IsLocalPlayer(controller.Player)) + { + Camera.main.GetComponent().Target = transform; + } + + if (frame.TryGet(_view.EntityRef, out var traktor)) + { + _sphere = traktor.Sphere; + } + _lastPosition = transform.position; + _desiredRotation = transform.rotation; + } + + private void LateUpdate() + { + var frame = QuantumRunner.Default.Game.Frames.Predicted; + if (frame.TryGet(_sphere, out var drivable) && drivable.Grounded && frame.TryGet(_sphere, out var body)) + { + var direction = body.Velocity.ToUnityVector3(); + _desiredRotation = Quaternion.LookRotation(direction.normalized, drivable.SurfaceNormal.ToUnityVector3()); + var euler = _desiredRotation.eulerAngles; + euler.y = 0; + _desiredRotation = Quaternion.Euler(euler); + } + TraktorBody.localRotation = Quaternion.Slerp(TraktorBody.localRotation, _desiredRotation, RotationFactor * Time.deltaTime); + _lastPosition = transform.position; + } + + +} diff --git a/data/TurnConfig.cs b/data/TurnConfig.cs new file mode 100644 index 0000000000000000000000000000000000000000..9bddc135e398d6c5f8c7042cb6d1e1c2855002ee --- /dev/null +++ b/data/TurnConfig.cs @@ -0,0 +1,12 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + public partial class TurnConfig + { + public Boolean UsesTimer; + public Int32 TurnDurationInTicks; + public Boolean IsSkippable; + } +} \ No newline at end of file diff --git a/data/TurnData.cs b/data/TurnData.cs new file mode 100644 index 0000000000000000000000000000000000000000..fdc381704cc1e2d94c91bf3e92f05c3ac55c0402 --- /dev/null +++ b/data/TurnData.cs @@ -0,0 +1,115 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + partial struct TurnData + { + public void Update(Frame f) + { + var config = f.FindAsset(ConfigRef.Id); + if (config == null || !config.UsesTimer || Status != TurnStatus.Active) + { + return; + } + Ticks++; + if (Ticks >= config.TurnDurationInTicks) + { + f.Signals.OnTurnEnded(this, TurnEndReason.Time); + } + } + + public void AccumulateStats(TurnData from) + { + Ticks += from.Ticks; + Number++; + } + + public void SetType(TurnType newType, Frame f = null) + { + if (Type == newType) + { + return; + } + var previousType = Type; + Type = newType; + f?.Events.TurnTypeChanged(this, previousType); + } + + public void SetStatus(TurnStatus newStatus, Frame f = null) + { + if (Status == newStatus) + { + return; + } + var previousStatus = Status; + Status = newStatus; + f?.Events.TurnStatusChanged(this, previousStatus); + if (Status == TurnStatus.Active) + { + f?.Events.TurnActivated(this); + } + } + + // frame is only necessary if caller wants to raise events + public void ResetTicks(Frame f = null) + { + ResetData(Type, Status, Entity, Player, ConfigRef, f); + } + + public void Reset(TurnConfig config, TurnType type, TurnStatus status, Frame f = null) + { + ResetData(type, status, Entity, Player, config, f); + } + + public void Reset(EntityRef entity, PlayerRef owner, Frame f = null) + { + ResetData(Type, Status, entity, owner, ConfigRef, f); + } + + public void Reset(TurnConfig config, TurnType type, TurnStatus status, EntityRef entity, PlayerRef owner, Frame f = null) + { + ResetData(type, status, entity, owner, config, f); + } + + private void ResetData(TurnType type, TurnStatus status, EntityRef entity, PlayerRef owner, AssetRefTurnConfig config, Frame f = null) + { + if (entity != EntityRef.None) + { + Entity = entity; + } + if (owner != PlayerRef.None) + { + Player = owner; + } + + if (config != null) + { + ConfigRef = config; + } + + var previousType = Type; + Type = type; + var previousStatus = Status; + Status = status; + + Ticks = 0; + + if (Type != previousType) + { + f?.Events.TurnTypeChanged(this, previousType); + } + + if (Status != previousStatus) + { + f?.Events.TurnStatusChanged(this, previousStatus); + if (Status == TurnStatus.Active) + { + f?.Events.TurnActivated(this); + } + } + + f?.Events.TurnTimerReset(this); + } + } +} diff --git a/data/TurnData.qtn b/data/TurnData.qtn new file mode 100644 index 0000000000000000000000000000000000000000..efb460fb320bbb7b3477713a0a6419a001f06434 --- /dev/null +++ b/data/TurnData.qtn @@ -0,0 +1,40 @@ +import PlayCommandData; +import SkipCommandData; + +asset TurnConfig; + +enum TurnType { Play, Countdown } +enum TurnStatus { Inactive, Active, Resolving } +enum TurnEndReason { Time, Skip, Play, Resolved } + +struct TurnData +{ + player_ref Player; + entity_ref Entity; + asset_ref ConfigRef; + TurnType Type; + TurnStatus Status; + Int32 Number; + Int32 Ticks; +} + +global { + TurnData CurrentTurn; +} + +// ------------------ Signals ------------------ +signal OnTurnEnded (TurnData data, TurnEndReason reason); +signal OnPlayCommandReceived (PlayerRef player, PlayCommandData data); +signal OnSkipCommandReceived (PlayerRef player, SkipCommandData data); + +// ------------------ Events ------------------ +abstract event TurnEvent { TurnData Turn; } +synced event TurnTypeChanged : TurnEvent { TurnType PreviousType; } +synced event TurnStatusChanged : TurnEvent { TurnStatus PreviousStatus; } +synced event TurnEnded : TurnEvent { TurnEndReason Reason; } +synced event TurnTimerReset : TurnEvent { } +synced event TurnActivated : TurnEvent { } + +abstract event CommandEvent { player_ref Player; } +event PlayCommandReceived : CommandEvent { PlayCommandData Data; } +event SkipCommandReceived : CommandEvent { SkipCommandData Data; } \ No newline at end of file diff --git a/data/TurnData.txt b/data/TurnData.txt new file mode 100644 index 0000000000000000000000000000000000000000..fdc381704cc1e2d94c91bf3e92f05c3ac55c0402 --- /dev/null +++ b/data/TurnData.txt @@ -0,0 +1,115 @@ +using System; +using Photon.Deterministic; + +namespace Quantum +{ + partial struct TurnData + { + public void Update(Frame f) + { + var config = f.FindAsset(ConfigRef.Id); + if (config == null || !config.UsesTimer || Status != TurnStatus.Active) + { + return; + } + Ticks++; + if (Ticks >= config.TurnDurationInTicks) + { + f.Signals.OnTurnEnded(this, TurnEndReason.Time); + } + } + + public void AccumulateStats(TurnData from) + { + Ticks += from.Ticks; + Number++; + } + + public void SetType(TurnType newType, Frame f = null) + { + if (Type == newType) + { + return; + } + var previousType = Type; + Type = newType; + f?.Events.TurnTypeChanged(this, previousType); + } + + public void SetStatus(TurnStatus newStatus, Frame f = null) + { + if (Status == newStatus) + { + return; + } + var previousStatus = Status; + Status = newStatus; + f?.Events.TurnStatusChanged(this, previousStatus); + if (Status == TurnStatus.Active) + { + f?.Events.TurnActivated(this); + } + } + + // frame is only necessary if caller wants to raise events + public void ResetTicks(Frame f = null) + { + ResetData(Type, Status, Entity, Player, ConfigRef, f); + } + + public void Reset(TurnConfig config, TurnType type, TurnStatus status, Frame f = null) + { + ResetData(type, status, Entity, Player, config, f); + } + + public void Reset(EntityRef entity, PlayerRef owner, Frame f = null) + { + ResetData(Type, Status, entity, owner, ConfigRef, f); + } + + public void Reset(TurnConfig config, TurnType type, TurnStatus status, EntityRef entity, PlayerRef owner, Frame f = null) + { + ResetData(type, status, entity, owner, config, f); + } + + private void ResetData(TurnType type, TurnStatus status, EntityRef entity, PlayerRef owner, AssetRefTurnConfig config, Frame f = null) + { + if (entity != EntityRef.None) + { + Entity = entity; + } + if (owner != PlayerRef.None) + { + Player = owner; + } + + if (config != null) + { + ConfigRef = config; + } + + var previousType = Type; + Type = type; + var previousStatus = Status; + Status = status; + + Ticks = 0; + + if (Type != previousType) + { + f?.Events.TurnTypeChanged(this, previousType); + } + + if (Status != previousStatus) + { + f?.Events.TurnStatusChanged(this, previousStatus); + if (Status == TurnStatus.Active) + { + f?.Events.TurnActivated(this); + } + } + + f?.Events.TurnTimerReset(this); + } + } +} diff --git a/data/TurnTimerSystem.cs b/data/TurnTimerSystem.cs new file mode 100644 index 0000000000000000000000000000000000000000..d9b88ba7fa78778f178ad8646824f7796baa0e41 --- /dev/null +++ b/data/TurnTimerSystem.cs @@ -0,0 +1,12 @@ +using Photon.Deterministic; + +namespace Quantum +{ + public unsafe class TurnTimerSystem : SystemMainThread + { + public override void Update(Frame f) + { + f.Global->CurrentTurn.Update(f); + } + } +} \ No newline at end of file diff --git a/data/UT.qtn b/data/UT.qtn new file mode 100644 index 0000000000000000000000000000000000000000..f1d3d2f9a2b9b020ab3926a4cfad12c669af203c --- /dev/null +++ b/data/UT.qtn @@ -0,0 +1,30 @@ +asset UTRoot; +asset Consideration; + +component UTAgent +{ + UtilityReasoner UtilityReasoner; + AssetRefAIConfig Config; +} + +struct UtilityReasoner +{ + AssetRefUTRoot UTRoot; + [HideInInspector] list Considerations; + [HideInInspector] list MomentumList; + [HideInInspector] FP TimeToTick; + [HideInInspector] dictionary CooldownsDict; + [HideInInspector] list PreviousExecution; +} + +struct UTMomentumPack +{ + AssetRefConsideration ConsiderationRef; + UTMomentumData MomentumData; +} + +struct UTMomentumData +{ + Int32 Value; + Byte DecayAmount; +} diff --git a/data/UTAgent.User.cs b/data/UTAgent.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..785c1cd87c8d3d8497ed1abc901caf155e5957ad --- /dev/null +++ b/data/UTAgent.User.cs @@ -0,0 +1,13 @@ +namespace Quantum +{ + public partial struct UTAgent + { + // Used to setup info on the Unity debugger + public string GetRootAssetName(Frame frame) => frame.FindAsset(UtilityReasoner.UTRoot.Id).Path; + + public AIConfig GetConfig(Frame frame) + { + return frame.FindAsset(Config.Id); + } + } +} diff --git a/data/UTManager.cs b/data/UTManager.cs new file mode 100644 index 0000000000000000000000000000000000000000..765ee864da698469be46f23fdc1635561a5960e2 --- /dev/null +++ b/data/UTManager.cs @@ -0,0 +1,48 @@ +using System; + +namespace Quantum +{ + public unsafe static class UTManager + { + public static Action SetupDebugger; + + public static Action ConsiderationChosen; + public static Action OnUpdate; + + /// + /// Initializes the Utility Reasoner, allocating all frame data needed. + /// If no UTRoot asset is passed by parameter, it will try to initialize with one already set on the Component, if any. + /// + public static void Init(Frame frame, UtilityReasoner* reasoner, AssetRefUTRoot utRootRef = default, EntityRef entity = default) + { + reasoner->Initialize(frame, utRootRef, entity); + } + + public static void Free(Frame frame, UtilityReasoner* reasoner) + { + reasoner->Free(frame); + } + + /// + /// Ticks the UtilityReasoner. The Considerations will be evaluated and the most useful will be executed. + /// It can be agnostic to entities, meaning that it is possible to have a UtilityReasoner as part of Global + /// + /// + /// + /// + public static void Update(Frame frame, UtilityReasoner* reasoner, EntityRef entity = default) + { + if (entity != default) + { + OnUpdate?.Invoke(entity); + } + + if (reasoner == default && entity != default) + { + reasoner = &frame.Unsafe.GetPointer(entity)->UtilityReasoner; + } + + reasoner->Update(frame, reasoner, entity); + } + } +} diff --git a/data/UTMomentumData.User.cs b/data/UTMomentumData.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..23fdeb20a2ce5d590c76f2934ea8bc5daeff436d --- /dev/null +++ b/data/UTMomentumData.User.cs @@ -0,0 +1,7 @@ +namespace Quantum +{ + [System.Serializable] + public partial struct UTMomentumData + { + } +} diff --git a/data/UTRoot.cs b/data/UTRoot.cs new file mode 100644 index 0000000000000000000000000000000000000000..ea3178fea3ff513a14cb9f592a22f02d13a059c5 --- /dev/null +++ b/data/UTRoot.cs @@ -0,0 +1,7 @@ +namespace Quantum +{ + public partial class UTRoot + { + public AssetRefConsideration[] ConsiderationsRefs; + } +} diff --git a/data/UseCardCommand.cs b/data/UseCardCommand.cs new file mode 100644 index 0000000000000000000000000000000000000000..41d87c969f94583a0da89ab76bcb5d36637fa29e --- /dev/null +++ b/data/UseCardCommand.cs @@ -0,0 +1,16 @@ +namespace Quantum +{ + using Photon.Deterministic; + + public class UseCardCommand : DeterministicCommand + { + public byte CardIndex; + public FPVector2 Position; + + public override void Serialize(BitStream stream) + { + stream.Serialize(ref CardIndex); + stream.Serialize(ref Position); + } + } +} diff --git a/data/UtilityReasoner.User.cs b/data/UtilityReasoner.User.cs new file mode 100644 index 0000000000000000000000000000000000000000..2a18635e3b270e0b2efc33f78fd16c242932de9c --- /dev/null +++ b/data/UtilityReasoner.User.cs @@ -0,0 +1,352 @@ +using Photon.Deterministic; +using Quantum.Collections; + +namespace Quantum +{ + public unsafe partial struct UtilityReasoner + { + public void Initialize(Frame frame, AssetRefUTRoot utRootRef = default, EntityRef entity = default) + { + // If we don't receive the UTRoot as parameter, we try to find it on the component itself + // Useful for pre-seting the UTRoot on a Prototype + UTRoot utRootInstance; + if (utRootRef == default) + { + utRootInstance = frame.FindAsset(UTRoot.Id); + } + else + { + UTRoot = utRootRef; + utRootInstance = frame.FindAsset(utRootRef.Id); + } + + // Initialize the Reasoner's considerations. + // Can be useful further when creating dynamically added Considerations to the Agent (runtime) + QList considerationsList = frame.AllocateList(); + for (int i = 0; i < utRootInstance.ConsiderationsRefs.Length; i++) + { + considerationsList.Add(utRootInstance.ConsiderationsRefs[i]); + } + Considerations = considerationsList; + + CooldownsDict = frame.AllocateDictionary(); + + QList previousExecution = frame.AllocateList(); + for (int i = 0; i < 6; i++) + { + previousExecution.Add(default); + } + PreviousExecution = previousExecution; + + MomentumList = frame.AllocateList(); + + if (entity != default) + { + UTManager.SetupDebugger?.Invoke(entity, utRootInstance.Path); + } + } + + public void Free(Frame frame) + { + UTRoot = default; + frame.FreeList(Considerations); + frame.FreeDictionary(CooldownsDict); + frame.FreeList(PreviousExecution); + frame.FreeList(MomentumList); + } + + public void Update(Frame frame, UtilityReasoner* reasoner, EntityRef entity = default) + { + Consideration[] considerations = SolveConsiderationsList(frame, Considerations, reasoner, entity); + Consideration chosenConsideration = SelectBestConsideration(frame, considerations, 1, reasoner, entity); + + if (chosenConsideration != default) + { + chosenConsideration.OnUpdate(frame, reasoner, entity); + UTManager.ConsiderationChosen?.Invoke(entity, chosenConsideration.Identifier.Guid.Value); + } + + TickMomentum(frame, entity); + } + + public Consideration[] SolveConsiderationsList(Frame frame, QListPtr considerationsRefs, UtilityReasoner* reasoner, EntityRef entity = default) + { + QList solvedRefs = frame.ResolveList(considerationsRefs); + Consideration[] considerationsArray = new Consideration[solvedRefs.Count]; + for (int i = 0; i < solvedRefs.Count; i++) + { + considerationsArray[i] = frame.FindAsset(solvedRefs[i].Id); + } + + return considerationsArray; + } + + public Consideration SelectBestConsideration(Frame frame, Consideration[] considerations, byte depth, UtilityReasoner* reasoner, EntityRef entity = default) + { + if (considerations == default) + return null; + + QList momentumList = frame.ResolveList(MomentumList); + + // We get the Rank of every Consideration Set + // This "filters" the Considerations with higher absolute utility + AssetRefConsideration[] highRankConsiderations = new AssetRefConsideration[considerations.Length]; + int highestRank = -1; + int counter = 0; + QDictionary cooldowns = frame.ResolveDictionary(CooldownsDict); + + for (int i = 0; i < considerations.Length; i++) + { + Consideration consideration = considerations[i]; + + // Force low Rank for Considerations in Cooldown + if (cooldowns.Count > 0 && cooldowns.ContainsKey(considerations[i]) == true) + { + cooldowns[considerations[i]] -= frame.DeltaTime; + if (cooldowns[considerations[i]] <= 0) + { + cooldowns.Remove(considerations[i]); + } + { + continue; + } + } + + // If the Consideration has Momentum, then it's Rank should is defined by it + // Otherwise, we calculate the Rank dynamically + int rank; + if (ContainsMomentum(momentumList, consideration, out var momentum) == true) + { + rank = momentum.Value; + } + else + { + rank = consideration.GetRank(frame, entity); + } + + if (rank > highestRank) + { + counter = 0; + + highestRank = rank; + highRankConsiderations[counter] = considerations[i]; + } + else if (highestRank == rank) + { + counter++; + highRankConsiderations[counter] = considerations[i]; + } + } + + // We clean the indices on the high rank sets that were not selected + for (int i = counter + 1; i < highRankConsiderations.Length; i++) + { + if (highRankConsiderations[i] == default) + break; + + highRankConsiderations[i] = default; + } + + // Based on the higher rank, we check which Considerations sets have greater utility + // Then we choose that set this frame + Consideration chosenConsideration = default; + FP highestScore = FP.UseableMin; + for (int i = 0; i <= counter; i++) + { + if (highRankConsiderations[i] == default) + continue; + + Consideration consideration = frame.FindAsset(highRankConsiderations[i].Id); + + FP score = consideration.Score(frame, entity); + if (highestScore < score) + { + highestScore = score; + chosenConsideration = consideration; + } + } + + if (chosenConsideration != default) + { + // If the chosen Consideration and it is not already under Momentum, + // we add add it there, replacing the previous Momentum (if any) + if (chosenConsideration.MomentumData.Value > 0 && ContainsMomentum(momentumList, chosenConsideration, out var momentum) == false) + { + InsertMomentum(frame, momentumList, chosenConsideration); + } + + // If the chosen Consideration has cooldown and it is not yet on the cooldowns dictionary, + // we add it there + if (chosenConsideration.Cooldown > 0 && cooldowns.ContainsKey(chosenConsideration) == false) + { + cooldowns.Add(chosenConsideration, chosenConsideration.Cooldown); + } + + // Add the chosen set to the choices history + OnConsiderationChosen(frame, reasoner, chosenConsideration, entity); + } + else + { + OnNoConsiderationChosen(frame, reasoner, depth, entity); + } + + // We return the chosen set so it can be executed + return chosenConsideration; + } + + #region Momentum + private bool ContainsMomentum(QList momentumList, Consideration consideration, out UTMomentumData momentum) + { + for (int i = 0; i < momentumList.Count; i++) + { + if (momentumList[i].ConsiderationRef == consideration) + { + momentum = momentumList[i].MomentumData; + return true; + } + } + + momentum = default; + return false; + } + + private void InsertMomentum(Frame frame, QList momentumList, AssetRefConsideration considerationRef) + { + Consideration newConsideration = frame.FindAsset(considerationRef.Id); + + // First, we check if this should be a replacement, which happens if: + // . The momentum list already have that same Depth added + // . Or when it have a higher Depth added + bool wasReplacedment = false; + for (int i = 0; i < momentumList.Count; i++) + { + Consideration currentConsideration = frame.FindAsset(momentumList[i].ConsiderationRef.Id); + if (currentConsideration.Depth == newConsideration.Depth || currentConsideration.Depth > newConsideration.Depth) + { + momentumList.GetPointer(i)->ConsiderationRef = considerationRef; + momentumList.GetPointer(i)->MomentumData = frame.FindAsset(considerationRef.Id).MomentumData; + + // We clear the rightmost momentum entries + if (i < momentumList.Count - 1) + { + for (int k = i + 1; k < momentumList.Count; k++) + { + momentumList.RemoveAt(k); + } + } + + wasReplacedment = true; + break; + } + } + + // If there was no replacement, we simply add it to the end of the list as this + // consideration probably has higher Depth than the others currently on the list + // which can also mean that the list was empty + if (wasReplacedment == false) + { + UTMomentumPack newMomentum = new UTMomentumPack() + { + ConsiderationRef = considerationRef, + MomentumData = frame.FindAsset(considerationRef.Id).MomentumData, + }; + momentumList.Add(newMomentum); + } + } + + private void TickMomentum(Frame frame, EntityRef entity = default) + { + QList momentumList = frame.ResolveList(MomentumList); + + // We decrease the timer and check if it is time already to decay all of the current Momentums + TimeToTick -= frame.DeltaTime; + bool decay = false; + if (TimeToTick <= 0) + { + decay = true; + TimeToTick = 1; + } + + for (int i = 0; i < momentumList.Count; i++) + { + UTMomentumPack* momentum = momentumList.GetPointer(i); + + // If we currently have a commitment, we check if it is done already + // If it is done, that Consideration's Rank shall be re-calculated + // If it is not done, then the Consideration's Rank will be kept due to the commitment + // unless some other Consideration has greater Rank and replaces the current commitment + Consideration momentumConsideration = frame.FindAsset(momentum->ConsiderationRef.Id); + + if (momentum->MomentumData.Value > 0 && momentumConsideration.MomentumData.DecayAmount > 0) + { + if (decay) + { + momentum->MomentumData.Value -= momentumConsideration.MomentumData.DecayAmount; + } + } + + + bool isDone = false; + if (momentumConsideration.Commitment != default) + { + isDone = momentumConsideration.Commitment.Execute(frame, entity); + } + if (isDone == true || momentum->MomentumData.Value <= 0) + { + momentum->MomentumData.Value = 0; + momentumList.RemoveAt(i); + } + } + } + #endregion + + #region ConsiderationsChoiceReactions + private static void OnConsiderationChosen(Frame frame, UtilityReasoner* reasoner, AssetRefConsideration chosenConsiderationRef, EntityRef entity = default) + { + Consideration chosenConsideration = frame.FindAsset(chosenConsiderationRef.Id); + + QList previousExecution = frame.ResolveList(reasoner->PreviousExecution); + + if (previousExecution[chosenConsideration.Depth - 1] != chosenConsideration) + { + // Exit the one that we're replacing + var replacedSet = frame.FindAsset(previousExecution[chosenConsideration.Depth - 1].Id); + if (replacedSet != default) + { + replacedSet.OnExit(frame, reasoner, entity); + } + + // Exit the consecutive ones + for (int i = chosenConsideration.Depth; i < previousExecution.Count; i++) + { + var cs = frame.FindAsset(previousExecution[i].Id); + if (cs == default) + break; + + cs.OnExit(frame, reasoner, entity); + previousExecution[i] = default; + } + + // Insert and Enter on the new chosen consideration + previousExecution[chosenConsideration.Depth - 1] = chosenConsideration; + chosenConsideration.OnEnter(frame, reasoner, entity); + } + } + + private static void OnNoConsiderationChosen(Frame frame, UtilityReasoner* reasoner, byte depth, EntityRef entity = default) + { + QList previousExecution = frame.ResolveList(reasoner->PreviousExecution); + + for (int i = depth - 1; i < previousExecution.Count; i++) + { + var cs = frame.FindAsset(previousExecution[i].Id); + if (cs == default) + break; + + cs.OnExit(frame, reasoner, entity); + previousExecution[i] = default; + } + } + #endregion + } +} diff --git a/data/WaitLeaf.cs b/data/WaitLeaf.cs new file mode 100644 index 0000000000000000000000000000000000000000..9bbe9d60877b76b2fe5034dfac2aa937d179537e --- /dev/null +++ b/data/WaitLeaf.cs @@ -0,0 +1,60 @@ +using Photon.Deterministic; +using System; + +namespace Quantum +{ + [Serializable] + public unsafe partial class WaitLeaf : BTLeaf + { + // How many time shall be waited + // This is measured in seconds + public FP Duration; + + // Indexer for us to store the End Time value on the BTAgent itself + public BTDataIndex EndTimeIndex; + + public override void Init(Frame frame, AIBlackboardComponent* blackboard, BTAgent* agent) + { + base.Init(frame, blackboard, agent); + + // We allocate space for the End Time on the Agent so we can change it in runtime + agent->AddFPData(frame, 0); + } + + public override void OnEnter(BTParams btParams) + { + base.OnEnter(btParams); + + FP currentTime; + FP endTime; + + // Get the current time + currentTime = btParams.Frame.BotSDKGameTime; + // Add the Duration value so we know when the Leaf will stop running + endTime = currentTime + Duration; + + // Store the final value on the Agent data + btParams.Agent->SetFPData(btParams.Frame, endTime, EndTimeIndex.Index); + } + + protected override BTStatus OnUpdate(BTParams btParams) + { + FP currentTime; + FP endTime; + + currentTime = btParams.Frame.BotSDKGameTime; + endTime = btParams.Agent->GetFPData(btParams.Frame, EndTimeIndex.Index); + + // If waiting time isn't over yet, then we need more frames executing this Leaf + // So we say that we're still Running + if (currentTime < endTime) + { + return BTStatus.Running; + } + + // If the waiting time is over, then we succeeded on waiting that amount of time + // Then we return Success + return BTStatus.Success; + } + } +} diff --git a/data/input.txt b/data/input.txt new file mode 100644 index 0000000000000000000000000000000000000000..14268004224852696b241f81dff4b6c0975748cf --- /dev/null +++ b/data/input.txt @@ -0,0 +1,120 @@ +Introduction +Input is a crucial component of Quantum's core architecture. In a deterministic networking library, the output of the system is fixed and predetermined given a certain input. This means that as long as the input is the same across all clients in the network, the output will also be the same. + +Back To Top + + +Defining In DSL +Input can be defined in any dsl file. For example, an input struct where you have a movement direction and a singluar jump button would look something like this: + +input +{ + button Jump; + FPVector3 Direction; +} +The server is responsible for batching and sending down input confirmations for full tick-sets (all player's input). For this reason, this struct should be kept to a minimal size as much as possible. + +Back To Top + + +Commands +deterministic commands are another input path for Quantum, and can have arbitrary data and size, which make them ideal for special types of inputs, like "buy this item", "teleport somewhere", etc. + +Back To Top + + +Polling In Unity +To send input to the Quantum simulation, you must poll for it inside of unity. To do this, we subscribe to the PollInput callback inside of a MonoBehaviour in our gameplay scene. + +private void OnEnable() +{ + QuantumCallback.Subscribe(this, (CallbackPollInput callback) => PollInput(callback)); +} +Then, in the callback, we read from out input source and populate our struct. + +public void PollInput(CallbackPollInput callback) + { + Quantum.Input i = new Quantum.Input(); + + var direction = new Vector3(); + direction.x = UnityEngine.Input.GetAxisRaw("Horizontal"); + direction.y = UnityEngine.Input.GetAxisRaw("Vertical"); + + i.Jump = UnityEngine.Input.GetKeyDown(KeyCode.Space); + + // convert to fixed point. + i.Direction = direction.ToFPVector3(); + + callback.SetInput(i, DeterministicInputFlags.Repeatable); +} +NOTE: The float to fixed point conversion here is deterministic because it is done before it is shared with the simulation. + +Back To Top + + +Optimization +It is genrally best practice to make sure that your Input definition consumes as little bandwidth as possible in order to maximize the amount of players your game can handle. Below are a few ways to optimize it. + +Back To Top + + +Buttons +Instead of using booleans or similar data types to represent key presses, the Button type is used inside the Input DSL definition. This is because it only uses one bit per instance, so it is favorable to use where possible. Although they only use one bit over the network, locally they will contain a bit more game state. This is because the single bit is only representative of whether or not the button was pressed during the current frame, the rest of the information is computed locally. Buttons are defined as follows: + +input +{ + button Jump; +} +Back To Top + + +Encoded Direction +In a typical setting, movement is often represented using a direction vector, often defined in a DSL file as such: + +input +{ + FPVector2 Direction; +} +However, FPVector2 is comprised of two 'FP', which takes up 16 bytes of data, which can be a lot of data sent, especially with many clients in the same room. One such way of optimizing it, is by extending the Input struct and encoding the directional vector into a Byte instead of sending the full vector every single time. One such implemetation is as follows: + +First, we define our input like normal, but instead of including an FPVector2 for direction, we replace it with a Byte where we will store the encoded version. + +input +{ + Byte EncodedDirection; +} +Next, we extend the input struct the same way a component is extended (see: adding functionality): + +namespace Quantum +{ + partial struct Input + { + public FPVector2 Direction + { + get + { + if (EncodedDirection == default) + return default; + + Int32 angle = ((Int32)EncodedDirection - 1) * 2; + + return FPVector2.Rotate(FPVector2.Up, angle * FP.Deg2Rad); + } + set + { + if (value == default) + { + EncodedDirection = default; + return; + } + + var angle = FPVector2.RadiansSigned(FPVector2.Up, value) * FP.Rad2Deg; + + angle = (((angle + 360) % 360) / 2) + 1; + + EncodedDirection = (Byte) (angle.AsInt); + } + } + } +} +This implementation allows for the same usage as before, but it only takes up a singular byte instead of 16 bytes. It does this by utilzing a Direction property, which encodes and decodes the value from EncodedDirection automatically. No newline at end of file \ No newline at end of file diff --git a/data/kinematic character.txt b/data/kinematic character.txt new file mode 100644 index 0000000000000000000000000000000000000000..f9b0a516d2f15b33511f7c66013bb2e53152d544 --- /dev/null +++ b/data/kinematic character.txt @@ -0,0 +1,397 @@ +Introduction +A Kinematic Character Controller, KCC for short, is used to move a character within the world according to its own set of rules. Using a KCC rather than physics/force based movement allows for tighter control and snappy movement. Although those concepts are core to every game, they vary tremendously in their definition as they are related to the overall gameplay. Therefore the KCCs included in the Quantum SDK are to be considered a starting point; however, game developers will likely have to create their own in order to get the best possible results for their specific context. + +Quantum comes with two pre-build KCCs, one for 2D (side-scrolling) and one for 3D movement. The API allows characters to move through terrains, climb steps, slide down slopes, and use moving platforms. + +The KCCs take physics data of both static and dynamic objects into consideration when calculating the movement vectors. Objects will block and define the character's movement. Collision callbacks with the environment objects will be trigged as well. + +Back To Top + + +Requirements +To use or add a KCC to an entity, the entity has to already have a Transform component. A PhysicsBody can be used but is not necessary; it is generally advised against using a PhysicsBody with a KCC as the physics system may affect it and result in unintended movement. + +If you are not familiar with Quantum's Physics yet, please review the Physics documentation first. + +Back To Top + + +Raycasts & ShapeOverlap +The KCC only uses ShapeOverlaps - circle for 2D and sphere for 3D - to calculate its movement. Thus an entity with only a KKC component will be ignored by raycasts. Should the entity be subject to raycasting, it has to also carry a PhysicsCollider. + +Back To Top + + +Note +This page covers both the 2D and 3D KCCs. + +Back To Top + + +The Character Controller Component +You can add the CharacterController component to your entity by either: + +adding the "Character Controller" component to the Entity Prototype in Unity; or, +adding the "Character Controller" component via code. +KCC 2D and 2D Components in Unity +The Character Controller 2D and 3D components attached to an Entity Prototype in the Unity Editor. +To add the Character Controller via code, follow the examples below. + +// 2D KCC +var kccConfig = FindAsset(KCC_CONFIG_PATH); +var kcc = new CharacterController2D(); +kcc.Init(f, kccConfig) +f.Add(entity, kcc); + +// 3D KCC +var kccConfig = FindAsset(KCC_CONFIG_PATH); +var kcc = new CharacterController3D(); +kcc.Init(f, kccConfig) +f.Add(entity, kcc); +Back To Top + + +Note +The component has to be initiliazed after being created. The available initializing options are: + +(code) the Init() method without parameter, it will load the DefaultCharacterController from Assets/Resources/DB/Configs. +(code) the Init() method with parameter, it will load the passed in CharacterControllerConfig. +(editor) add the CharacterControllerConfig to the Config slot in the Character Controller component. +Back To Top + + +The Character Controller Config +Create your own KCC config asset via the context menu under Create > Quantum > Assets > Physics > CharacterController2D/3D. + + +Default Config Assets +The default 2D and 3D KCC Config assets are located inside the Assets/Resources/DB/Configs folder. Here is how the 3D KCC config looks like: + +KCC 3D Default Config +The DefaultCharacterController3D Config Asset. +Back To Top + + +A Brief Explanation Into The Config Fields +Offset is used to define the KCC local position based into the entity position. It is commonly used to position the center of the KCC at the feet of the character. Remember: the KCC is used to move the character, so it does not necessarily have to encapsulate the character's whole body. +Radius defines the boundaries of the character and should encompass the character horizontal size. This is used to know whether a character can move in a certain direction, a wall is blocking the movement, a step is to be climbed, or a slope to be slid on. +Max Penetration smoothens the movement when a character penetrates other physics objects. If the character passes the Max Penetration, a hard fix will be applied and snap it into the correct position. Reducing this value to zero will apply all corrections fully and instantly; this may result in jagged movement. +Extent defines a radius in which collisions are detected preemptively. +Max Contacts is used to select the amount of contact points computed by the KCC. 1 will usually work fine and is the most performant option. If you experience jerky movement, try setting this to 2; the additional overhead is negligible. +Layer Mask defines which collider layers should be taken into consideration by the physics query performed by the KCC. +Air Control toggle to True and the KCC is able to perform movement adjustments when it not touching the ground. +Acceleration defines how fast the character accelerates. +Base Jump Impulse defines the strength of the impulse when calling the KCC Jump() method. If no value is passed to the method, this value will be used. +Max Speed caps the character's maximal horizontal speed. +Gravity applies a gravity force to the KCC. +Max Slope defines the maximal angle, in degrees, the character can walk up and down. +Max Slope Speed limits the speed at which the character slides down a slope when the movement type is Slope Fall instead of Horizontal Fall. +Back To Top + + +Character Controller API +The API shown below focuses on the 3D KCC. The 2D and 3D APIs are very similar though. + + +Properties And Fields +Each CharacterController component has these fields. + +public FP MaxSpeed { get; set;} +public FPVector3 Velocity { get; set;} +public bool Grounded { get; set;} +public FP CurrentSpeed { get;} +public AssetGUID ConfigId { get;} +Back To Top + + +Tip +The MaxSpeed is a cached value after initialization. It can therefore be modified at runtime, e.g. when performing dashes. + +Back To Top + + +API +Each KCC components has the following methods: + +// Initialization +public void Init(FrameBase frame, CharacterController3DConfig config = null); + +// Jump +public void Jump(FrameBase frame, bool ignoreGrounded = false, FP? impulse = null); + +// Move +public void Move(FrameBase frame, EntityRef entity, FPVector3 direction, IKCCCallbacks3D callback = null, int? layerMask = null, Boolean? useManifoldNormal = null, FP? deltaTime = null); + +// Raw Information +public static CharacterController3DMovement ComputeRawMovement(Frame frame, EntityRef entity, Transform3D* transform, CharacterController3D* kcc, FPVector3 direction, IKCCCallbacks3D callback = null, int? layerMask = null, bool? useManifoldNormal = null); +The Jump and Move methods are convenient for prototyping, while ComputeRawMovement provides the key information for creating your own custom movement. In the example KCC's provided by Quantum, the information from ComputeRawMovement is used by the internal steering method ComputeRawSteer to compute the steering used in Move. + +IMPORTANT: The implementations of Jump(), Move() and ComputeRawSteer() are presented below for fostering understanding and help create custom implementations specific to the game's requirements. + +Back To Top + + +CharacterController3DMovement +ComputeRawMovement() computes the environmental data necessary for the steering by performing a ShapeOverlap and processing the data. The method returns a CharacterController3DMovement struct which can then be applied to the character movement. The movement data provided can also be used to create a custom steering implementation. + +The CharacterController3DMovement struct holds the following information: + +public enum CharacterMovementType +{ + None, // grounded with no desired direction passed + FreeFall, // no contacts within the Radius + SlopeFall, // there is at least 1 ground contact within the Radius, specifically a contact with a normal angle vs -gravity <= maxSlopeAngle). It is possible to be "grounded" without this type of contact (see Grounded property in the CharacterController3DMovement) + Horizontal, // there is NO ground contact, but there is at least one lateral contact (normal angle vs -gravity > maxSlopeAngle) +} + +public struct CharacterController3DMovement +{ + public CharacterMovementType Type; + + // the surface normal of the closest unique contact + public FPVector3 NearestNormal; + + // the average normal from all contacts + public FPVector3 AvgNormal; + + // the normal of the closest contact that qualifies as ground + public FPVector3 GroundNormal; + + // the surface tangent (from GroundNormal and the derived direction) for Horizontal move, or the normalized desired direction when in CharacterMovementType.FreeFall + public FPVector3 Tangent; + + // surface tangent computed from closest the contact normal vs -gravity (does not consider current velocity of CC itself). + public FPVector3 SlopeTangent; + + // accumulated projected correction from all contacts within the Radius. It compensates with dot-products to NOT overshoot. + public FPVector3 Correction; + + // max penetration of the closest contact within the Radius + public FP Penetration; + + // uses the EXTENDED radius to assign this Boolean AND the GroundedNormalas to avoid oscilations of the grounded state when moving over slightly irregular terrain + public Boolean Grounded; + + // number of contacts within Radius + public int Contacts; +} +ComputeRawMovement() is used by the Move() method. + +Back To Top + + +Jump() +This is only a reference implementation. +The Jump simply adds an impulse to the KCC's current Velocity and toggles the Jumped boolean which will be processed by the internal ComputeRawSteer Method. + +public void Jump(FrameBase frame, bool ignoreGrounded = false, FP? impulse = null) { + + if (Grounded || ignoreGrounded) { + + if (impulse.HasValue) + Velocity.Y.RawValue = impulse.Value.RawValue; + else { + var config = frame.FindAsset(Config); + Velocity.Y.RawValue = config.BaseJumpImpulse.RawValue; + } + + Jumped = true; + } +} +Back To Top + + +Move() +This is only a reference implementation. +Move() takes the following things by taking into consideration when calculating the character's new position: + +the current position +the direction +the gravity +jumps +slopes +and more +All these aspects can be defined in the config asset passed to the Init() method. This is convenient for prototyping FPS/TPS/Action games which have terrains, mesh colliders and primitives. + +NOTE: Since it calculates everything and returns a final FPVector3 result, it does not give you much control over the movement itself. For tighter control over the movement, you should use ComputeRawMovement() and create your own custom steering + movement. + +public void Move(Frame frame, EntityRef entity, FPVector3 direction, IKCCCallbacks3D callback = null, int? layerMask = null, Boolean? useManifoldNormal = null, FP? deltaTime = null) { + Assert.Check(frame.Has(entity)); + + var transform = frame.GetPointer(entity); + var dt = deltaTime ?? frame.DeltaTime; + + CharacterController3DMovement movementPack; + fixed (CharacterController3D* thisKcc = &this) { + movementPack = ComputeRawMovement(frame, entity, transform, thisKcc, direction, callback, layerMask, useManifoldNormal); + } + + ComputeRawSteer(frame, ref movementPack, dt); + + var movement = Velocity * dt; + if (movementPack.Penetration > FP.EN3) { + var config = frame.FindAsset(Config.Id); + if (movementPack.Penetration > config.MaxPenetration) { + movement += movementPack.Correction; + } else { + movement += movementPack.Correction * config.PenetrationCorrection; + } + } + + transform->Position += movement; +} +Back To Top + + +ComputeRawSteer() +Steering involves computing the movement based on the position, radius and velocity of the character, and corrects the movement if necessary. + +This is only a reference implementation. +ComputeRawSteer is an internal method that does the bulk of the movement calculations based on the type of movement the character is currently performing. In the example KCCs, Move requests the movementPack values from ComputeRawMovement and passes them to ComputeRawSteer. + +private void ComputeRawSteer(FrameThreadSafe f, ref CharacterController3DMovement movementPack, FP dt) { + + Grounded = movementPack.Grounded; + var config = f.FindAsset(Config); + var minYSpeed = -FP._100; + var maxYSpeed = FP._100; + + switch (movementPack.Type) { + + // FreeFall + case CharacterMovementType.FreeFall: + + Velocity.Y -= config._gravityStrength * dt; + + if (!config.AirControl || movementPack.Tangent == default(FPVector3)) { + Velocity.X = FPMath.Lerp(Velocity.X, FP._0, dt * config.Braking); + Velocity.Z = FPMath.Lerp(Velocity.Z, FP._0, dt * config.Braking); + } else { + Velocity += movementPack.Tangent * config.Acceleration * dt; + } + + break; + + // Grounded movement + case CharacterMovementType.Horizontal: + + // apply tangent velocity + Velocity += movementPack.Tangent * config.Acceleration * dt; + var tangentSpeed = FPVector3.Dot(Velocity, movementPack.Tangent); + + // lerp current velocity to tangent + var tangentVel = tangentSpeed * movementPack.Tangent; + var lerp = config.Braking * dt; + Velocity.X = FPMath.Lerp(Velocity.X, tangentVel.X, lerp); + Velocity.Z = FPMath.Lerp(Velocity.Z, tangentVel.Z, lerp); + + // we only lerp the vertical velocity if the character is not jumping in this exact frame, + // otherwise it will jump with a lower impulse + if (Jumped == false) { + Velocity.Y = FPMath.Lerp(Velocity.Y, tangentVel.Y, lerp); + } + + // clamp tangent velocity with max speed + var tangentSpeedAbs = FPMath.Abs(tangentSpeed); + if (tangentSpeedAbs > MaxSpeed) { + Velocity -= FPMath.Sign(tangentSpeed) * movementPack.Tangent * (tangentSpeedAbs - MaxSpeed); + } + + break; + + // Sliding due to excessively steep slope + case CharacterMovementType.SlopeFall: + + Velocity += movementPack.SlopeTangent * config.Acceleration * dt; + minYSpeed = -config.MaxSlopeSpeed; + + break; + + // No movement, only deceleration + case CharacterMovementType.None: + + var lerpFactor = dt * config.Braking; + + if (Velocity.X.RawValue != 0) { + Velocity.X = FPMath.Lerp(Velocity.X, default, lerpFactor); + if (FPMath.Abs(Velocity.X) < FP.EN1) { + Velocity.X.RawValue = 0; + } + } + + if (Velocity.Z.RawValue != 0) { + Velocity.Z = FPMath.Lerp(Velocity.Z, default, lerpFactor); + if (FPMath.Abs(Velocity.Z) < FP.EN1) { + Velocity.Z.RawValue = 0; + } + } + + // we only lerp the vertical velocity back to 0 if the character is not jumping in this exact frame, + // otherwise it will jump with a lower impulse + if (Velocity.Y.RawValue != 0 && Jumped == false) { + Velocity.Y = FPMath.Lerp(Velocity.Y, default, lerpFactor); + if (FPMath.Abs(Velocity.Y) < FP.EN1) { + Velocity.Y.RawValue = 0; + } + } + + minYSpeed = 0; + + break; + } + + // horizontal is clamped elsewhere + if (movementPack.Type != CharacterMovementType.Horizontal) { + var h = Velocity.XZ; + + if (h.SqrMagnitude > MaxSpeed * MaxSpeed) { + h = h.Normalized * MaxSpeed; + } + + Velocity.X = h.X; + Velocity.Y = FPMath.Clamp(Velocity.Y, minYSpeed, maxYSpeed); + Velocity.Z = h.Y; + } + + // reset jump state + Jumped = false; +} +Back To Top + + +Collision Callbacks +Whenever the KCC detects intersections with colliders a callback is triggered. + +public interface IKCCCallbacks2D +{ + bool OnCharacterCollision2D(FrameBase f, EntityRef character, Physics2D.Hit hit); + void OnCharacterTrigger2D(FrameBase f, EntityRef character, Physics2D.Hit hit); +} + +public interface IKCCCallbacks3D +{ + bool OnCharacterCollision3D(FrameBase f, EntityRef character, Physics3D.Hit3D hit); + void OnCharacterTrigger3D(FrameBase f, EntityRef character, Physics3D.Hit3D hit); +} +To receive the callbacks and use its information implement the corresponding IKCCCallbacks interface in a system. + +Important Note that the collision callbacks return a Boolean value. This is allows you decide whether a collision should be ignored. Returning false makes the character pass through physics object it collided with. + +Besides implementing the callbacks the movement methods should also pass the IKCCCallbacks object; below is a code snippet using the collision callbacks. + +public unsafe class SampleSystem : SystemMainThread, IKCCCallbacks3D +{ + public bool OnCharacterCollision3D(FrameBase f, EntityRef character, Physics3D.Hit3D hit) { + // read the collision information to decide if this should or not be ignored + return true; + } + + public void OnCharacterTrigger3D(FrameBase f, EntityRef character, Physics3D.Hit3D hit) { + } + + public override void Update(Frame f) { + // [...] + // adding the IKCCCallbacks3D as the last parameter (this system, in this case) + var movement = CharacterController3D.Move((Entity*)character, input->Direction, this); + // [...] +} \ No newline at end of file diff --git a/data/list1.txt b/data/list1.txt new file mode 100644 index 0000000000000000000000000000000000000000..81f81b2a1c3e60298871359b6357cb09b47094f5 --- /dev/null +++ b/data/list1.txt @@ -0,0 +1,10 @@ +In quantum we can to list as metioned below. + +component Targets { + list Enemies; +} +The basic API methods for dealing with these Lists are: + +Frame.AllocateList() +Frame.FreeList(QListPtr ptr) +Frame.ResolveList(QListPtr ptr) diff --git a/data/materialization.txt b/data/materialization.txt new file mode 100644 index 0000000000000000000000000000000000000000..285452ed9d4c2f95f43ba6b66923da84dab269d3 --- /dev/null +++ b/data/materialization.txt @@ -0,0 +1,335 @@ +Introduction +The process of creating an entity or component instance from a Component Prototype or Entity Prototype is called Materialization . + +The materialization of scene prototypes baked into the map asset follow the same rules and execution flow as the materialization of code created instances using the Frame.Create API. + +Back To Top + + +Prototype Vs Instance +The component instances and entity instances are part of the game state; in other words they can be manipulated at runtime. Components declared in the DSL are used to generate their corresponding Component Prototypes. The code generated prototypes follow the naming convention MyComponent_Prototype. + +Component Prototypes and Entity Prototypes are both assets; this means they are not part of the game state, immutable at runtime and have to be identical for all clients at all time. Each Component Prototype has a ComponentPrototypeRef which can be used to find it the corresponding asset using the Frame.FindPrototype(MyComponentPrototypeRef). + +Back To Top + + +Component Prototypes +It is possible to extend a Component Prototype to include data which may not be directly used in materialization. This allows, for example, to have shared data between instances of a particular component or exclude read-only data from the frame to keep the game state slim. + +Code generated Component Prototypes are partial classes which can be easily extended: + +Create a C# file called MyComponentName_Prototype.cs; +Place the body of the script into the Quantum.Prototypes namespace; +( Optional ) Add using Quantum.Inspector; to have access to the inspector attributes presented in the Attributes section of the Manual \ ECS page. +It is then possible to add extra data to the Component Prototype asset and implement the partial MaterializeUser() method to add custom materialization logic. + +Back To Top + + +Example +The following example presents the materialization of the Vehicle component as found in the Arcade Racing Template. + +The Vehicle component holds mainly dynamic values computed at runtime. Since these cannot be initialized, the component definition in the DSL uses the ExcludeFromPrototype attribute on those parameters to exclude them from the Vehicle_Prototype asset designers can manipulate in the Unity editor. The Nitro parameter is only part that can be edited to allow designers to decide with how much nitro a specific Vehicle is initialized. + +component Vehicle +{ + [ExcludeFromPrototype] + ComponentPrototypeRef Prototype; + + [ExcludeFromPrototype] + Byte Flags; + [ExcludeFromPrototype] + FP Speed; + [ExcludeFromPrototype] + FP ForwardSpeed; + [ExcludeFromPrototype] + FPVector3 EngineForce; + [ExcludeFromPrototype] + FP WheelTraction; + + [ExcludeFromPrototype] + FPVector3 AvgNormal; + + [ExcludeFromPrototype] + array[4] Wheels; + + FP Nitro; +} +The Vehicle_Prototype asset is extended to provide designers with customizable read-only parameters. The Vehicle_Prototype asset can thus hold shared values for all instances of a specific vehicle entity prototype "type". The Prototype parameter in the Vehicle component is of type ComponentPrototypeRef which is the component specific equivalent to AssetRef. To populate it, the partial MaterializeUser() method is used to assign the reference of the Vehicle_Prototype. + +using Photon.Deterministic; +using Quantum.Inspector; +using System; + +namespace Quantum.Prototypes +{ +public unsafe partial class Vehicle_Prototype +{ + // PUBLIC METHODS + + [Header("Engine")] + public FP EngineForwardForce = 130; + public FP EngineBackwardForce = 120; + public FPVector3 EngineForcePosition; + public FP ApproximateMaxSpeed = 20; + + [Header("Hand Brake")] + public FP HandBrakeStrength = 10; + public FP HandBrakeTractionMultiplier = 1; + + [Header("Resistances")] + public FP AirResistance = FP._0_02; + public FP RollingResistance = FP._0_10 * 6; + public FP DownForceFactor = 0; + public FP TractionGripMultiplier = 10; + public FP AirTractionDecreaseSpeed = FP._0_50; + + [Header("Axles")] + public AxleSetup FrontAxle = new AxleSetup(); + public AxleSetup RearAxle = new AxleSetup(); + + [Header("Nitro")] + public FP MaxNitro = 100; + public FP NitroForceMultiplier = 2; + + // PARTIAL METHODS + partial void MaterializeUser(Frame frame, ref Vehicle result, in PrototypeMaterializationContext context) + { + result.Prototype = context.ComponentPrototypeRef; + } + + [Serializable] + public class AxleSetup + { + public FPVector3 PositionOffset; + public FP Width = 1; + public FP SpringForce = 120; + public FP DampingForce = 175; + public FP SuspensionLength = FP._0_10 * 6; + public FP SuspensionOffset = -FP._0_25; + } +} +} +The parameters in the Vehicle_Prototype hold values necessary to compute the dynamic values found in the component instance which impact the behaviour of the entity to which the Vehicle component is attached. For example, when a player picks up additional Nitro, the value held in the Vehicle component is clamped to the MaxNitro value found in the Vehicle_Prototype. This enforces the limits under penality of desynchronization and keeps the game state slim. + +namespace Quantum +{ + public unsafe partial struct Vehicle + { + public void AddNitro(Frame frame, EntityRef entity, FP amount) + { + var prototype = frame.FindPrototype(Prototype); + Nitro = FPMath.Clamp(Nitro + amount, 0, prototype.MaxNitro); + } + } +} +Back To Top + + +Materialization Order +Every Entity Prototype, including the scene prototypes, the materialization executes the following steps in order: + +An empty entity is created. +For each Component Prototype contained in the Entity Prototype: +the component instance is created on the stack; +the Component Prototype is materialized into the component instance; +( Optional ) MaterializeUser() is called; and, +the component is added to the entity which triggers the ISignalOnComponentAdded signal. +ISignalOnEntityPrototypeMaterialized is invoked for each materialized entity. +Load Map / Scene: the signal is invoked for all entity & Entity Prototype pair after all scene prototypes have been materialized. +Created with Frame.Create(): the signal is invoked immediately after the prototype has been materialized. +The Component Prototype materialization step materializes default components in a predetermined order. + +Transform2D +Transform3D +Transform2DVertical +PhysicsCollider2D +PhysicsBody2D +PhysicsCollider3D +PhysicsBody3D +PhysicsJoints2D +PhysicsJoints3D +PhysicsCallbacks2D +PhysicsCallbacks3D +CharacterController2D +CharacterController3D +NavMeshPathfinder +NavMeshSteeringAgent +NavMeshAvoidanceAgent +NavMeshAvoidanceObstacle +View +MapEntityLink +Once all default components have been materialized, the user defined components are materialized in alphabetically order. + +MyComponentAA +MyComponentBB +MyComponentCC +...ntroduction +The process of creating an entity or component instance from a Component Prototype or Entity Prototype is called Materialization . + +The materialization of scene prototypes baked into the map asset follow the same rules and execution flow as the materialization of code created instances using the Frame.Create API. + +Back To Top + + +Prototype Vs Instance +The component instances and entity instances are part of the game state; in other words they can be manipulated at runtime. Components declared in the DSL are used to generate their corresponding Component Prototypes. The code generated prototypes follow the naming convention MyComponent_Prototype. + +Component Prototypes and Entity Prototypes are both assets; this means they are not part of the game state, immutable at runtime and have to be identical for all clients at all time. Each Component Prototype has a ComponentPrototypeRef which can be used to find it the corresponding asset using the Frame.FindPrototype(MyComponentPrototypeRef). + +Back To Top + + +Component Prototypes +It is possible to extend a Component Prototype to include data which may not be directly used in materialization. This allows, for example, to have shared data between instances of a particular component or exclude read-only data from the frame to keep the game state slim. + +Code generated Component Prototypes are partial classes which can be easily extended: + +Create a C# file called MyComponentName_Prototype.cs; +Place the body of the script into the Quantum.Prototypes namespace; +( Optional ) Add using Quantum.Inspector; to have access to the inspector attributes presented in the Attributes section of the Manual \ ECS page. +It is then possible to add extra data to the Component Prototype asset and implement the partial MaterializeUser() method to add custom materialization logic. + +Back To Top + + +Example +The following example presents the materialization of the Vehicle component as found in the Arcade Racing Template. + +The Vehicle component holds mainly dynamic values computed at runtime. Since these cannot be initialized, the component definition in the DSL uses the ExcludeFromPrototype attribute on those parameters to exclude them from the Vehicle_Prototype asset designers can manipulate in the Unity editor. The Nitro parameter is only part that can be edited to allow designers to decide with how much nitro a specific Vehicle is initialized. + +component Vehicle +{ + [ExcludeFromPrototype] + ComponentPrototypeRef Prototype; + + [ExcludeFromPrototype] + Byte Flags; + [ExcludeFromPrototype] + FP Speed; + [ExcludeFromPrototype] + FP ForwardSpeed; + [ExcludeFromPrototype] + FPVector3 EngineForce; + [ExcludeFromPrototype] + FP WheelTraction; + + [ExcludeFromPrototype] + FPVector3 AvgNormal; + + [ExcludeFromPrototype] + array[4] Wheels; + + FP Nitro; +} +The Vehicle_Prototype asset is extended to provide designers with customizable read-only parameters. The Vehicle_Prototype asset can thus hold shared values for all instances of a specific vehicle entity prototype "type". The Prototype parameter in the Vehicle component is of type ComponentPrototypeRef which is the component specific equivalent to AssetRef. To populate it, the partial MaterializeUser() method is used to assign the reference of the Vehicle_Prototype. + +using Photon.Deterministic; +using Quantum.Inspector; +using System; + +namespace Quantum.Prototypes +{ +public unsafe partial class Vehicle_Prototype +{ + // PUBLIC METHODS + + [Header("Engine")] + public FP EngineForwardForce = 130; + public FP EngineBackwardForce = 120; + public FPVector3 EngineForcePosition; + public FP ApproximateMaxSpeed = 20; + + [Header("Hand Brake")] + public FP HandBrakeStrength = 10; + public FP HandBrakeTractionMultiplier = 1; + + [Header("Resistances")] + public FP AirResistance = FP._0_02; + public FP RollingResistance = FP._0_10 * 6; + public FP DownForceFactor = 0; + public FP TractionGripMultiplier = 10; + public FP AirTractionDecreaseSpeed = FP._0_50; + + [Header("Axles")] + public AxleSetup FrontAxle = new AxleSetup(); + public AxleSetup RearAxle = new AxleSetup(); + + [Header("Nitro")] + public FP MaxNitro = 100; + public FP NitroForceMultiplier = 2; + + // PARTIAL METHODS + partial void MaterializeUser(Frame frame, ref Vehicle result, in PrototypeMaterializationContext context) + { + result.Prototype = context.ComponentPrototypeRef; + } + + [Serializable] + public class AxleSetup + { + public FPVector3 PositionOffset; + public FP Width = 1; + public FP SpringForce = 120; + public FP DampingForce = 175; + public FP SuspensionLength = FP._0_10 * 6; + public FP SuspensionOffset = -FP._0_25; + } +} +} +The parameters in the Vehicle_Prototype hold values necessary to compute the dynamic values found in the component instance which impact the behaviour of the entity to which the Vehicle component is attached. For example, when a player picks up additional Nitro, the value held in the Vehicle component is clamped to the MaxNitro value found in the Vehicle_Prototype. This enforces the limits under penality of desynchronization and keeps the game state slim. + +namespace Quantum +{ + public unsafe partial struct Vehicle + { + public void AddNitro(Frame frame, EntityRef entity, FP amount) + { + var prototype = frame.FindPrototype(Prototype); + Nitro = FPMath.Clamp(Nitro + amount, 0, prototype.MaxNitro); + } + } +} +Back To Top + + +Materialization Order +Every Entity Prototype, including the scene prototypes, the materialization executes the following steps in order: + +An empty entity is created. +For each Component Prototype contained in the Entity Prototype: +the component instance is created on the stack; +the Component Prototype is materialized into the component instance; +( Optional ) MaterializeUser() is called; and, +the component is added to the entity which triggers the ISignalOnComponentAdded signal. +ISignalOnEntityPrototypeMaterialized is invoked for each materialized entity. +Load Map / Scene: the signal is invoked for all entity & Entity Prototype pair after all scene prototypes have been materialized. +Created with Frame.Create(): the signal is invoked immediately after the prototype has been materialized. +The Component Prototype materialization step materializes default components in a predetermined order. + +Transform2D +Transform3D +Transform2DVertical +PhysicsCollider2D +PhysicsBody2D +PhysicsCollider3D +PhysicsBody3D +PhysicsJoints2D +PhysicsJoints3D +PhysicsCallbacks2D +PhysicsCallbacks3D +CharacterController2D +CharacterController3D +NavMeshPathfinder +NavMeshSteeringAgent +NavMeshAvoidanceAgent +NavMeshAvoidanceObstacle +View +MapEntityLink +Once all default components have been materialized, the user defined components are materialized in alphabetically order. + +MyComponentAA +MyComponentBB +MyComponentCC +... \ No newline at end of file diff --git a/data/materials.txt b/data/materials.txt new file mode 100644 index 0000000000000000000000000000000000000000..b8006c0d1b09d8cf92aa8271ed0fea55ac99ef5b --- /dev/null +++ b/data/materials.txt @@ -0,0 +1,54 @@ +Overview +Every PhysicsBody requires a PhysicsMaterial (a quantum data-asset). The PhysicsMaterial holds properties necessary for the physics engine to resolve collisions, integration of forces and velocities. + +Back To Top + + +PhysicsMaterial Data-Asset +The PhysicsMaterial holds the parameters for: + +Restitution (sometimes referred to as "bounciness", or "bounce") +Restiution Combine Function +Friction Static +Friction Dynamic +Friction Combine Function +If no PhysicsMaterial asset is slotted, the default physics material will be assigned; the default physics material is the one linked in the SimulationConfig physics settings. + +Adjusting Properties to Physics Materials +Adjusting Properties to Physics Materials. +A PhysicsMaterial asset can be assigned to a PhysicsCollider directly: + +var material = f.FindAsset("steel"); +collider.Material = material; + +f.Set(entity, collider); +Back To Top + + +Important Note +A PhysicsMaterial is a data asset and lives in the Quantum Asset Database. As assets are not part of the rollback-able game state, every PhysicsMaterial is therefore to be considered immutable at runtime. Changing its properties while the game running leads to non-deterministic behaviour. + +PhysicsMaterials follow the same rules as other data-assets. + +// this is NOT safe and cannot be rolled-back: +collider->Material.Restitution = FP._0; + +// switching a reference is safe and can be rolled back: +var newMaterial = f.FindAsset("ice"); +collider->Material = newMaterial; +Back To Top + + +Combine Functions +The Combine Function used to resolve the restitution and friction for each collision manifold (a collision pair) is based on the combine functions' precedence order. The Physics system will chose the function with the highest precedent from the two colliders. The precedence order is: + +Max +Min +Average +Multiply +For instance: take a collision manifold with a Collider A and Collider B. Collider A's physics material has a Restitution Combine Function set to Max, while Collider B's physics material has its set to Average. Since Max has a higher priority than Average, the restitution for this collision will be solved using the Max function. + +The same logic applies to the Friction Combine Function. + +N.B.: The Friction Combine Function and Restitution Combine Function are resolved separately and thus carry different settings. + diff --git a/data/multi-client-runner.txt b/data/multi-client-runner.txt new file mode 100644 index 0000000000000000000000000000000000000000..e4d61d8c1ad95955ec08509c124f0b027b58f19e --- /dev/null +++ b/data/multi-client-runner.txt @@ -0,0 +1,90 @@ +Introduction +Quantum's Multi-Client Runner is a powerful tool that allows multiple local players to play together in the same Quantum room. This tool is especially useful for developers who wants to test and debug their game without having to build the game every time. + +Back To Top + + +Required Settings +The Multi-Client Runner requires a few pre-requisites in order to operate correctly. + +You need to make sure that you have the following: + +A valid AppId: You can get this by registering your game on the dashboard on the photon website. +Correctly configured Photon Server Settings. You can adjust and check these values by navigating to the scriptable object in your game's project files. +Ensuring the QuantumMultiClientRunner prefab is in your game scene. +Back To Top + + +Setup +To get started, navigate to the QuantumMultiClientRunner prefab and drag it into your game scene. This prefab is an example implementation of the Multi-Client Runner. + +Runner Search Screenshot +Once the prefab has been put into your game scene, select it and view the QuantumMultiClientRunner component. + +Multi Client Runner +In this component, there are several configurable values: + +DisableOnStart: When utilizing the MultiClientRunner, it is necessary to disable quantum scripts that are typically included in the standard game setup, such as EntityViewUpdater, Input, and CustomCallbacks. Please ensure that you add these scripts to the list of disabled scripts. +EditorSettings: You have the option to provide non-default editor settings for all additional clients after the first one. For example, changing the gizmo color. +AppSettings: Optionally provide different non-default server app settings. For example, a different region than normal. +RuntimeConfig: Optional custom runtime config settings. +PlayerCount: Max player count. +InitialPlayerCount: How many players to start the game with. +RuntimePlayer[]: Optional custom runtime player settings. +PlayerInputTemplate: A player input template that is instantiated for each client. This must contain a Unity script that implements the Unity message/method void PollInput(CallbackPollInput c). An example of a script that correctly implements this would look like the following: +public void PollInput(CallbackPollInput callback) + { + Quantum.Input i = new Quantum.Input(); + + var direction = new Vector3(); + direction.x = UnityEngine.Input.GetAxisRaw("Horizontal"); + direction.y = UnityEngine.Input.GetAxisRaw("Vertical"); + + i.Jump = UnityEngine.Input.GetKeyDown(KeyCode.Space); + + // convert to fixed point. + i.Direction = direction.ToFPVector3(); + + callback.SetInput(i, DeterministicInputFlags.Repeatable); +} +EntityViewUpdater An optional custom EntityViewUpdater game object that is instantiated for each client. Otherwise a new instance of the default EntityViewUpdater is created for each client. +Back To Top + + +Playing +After setup, you are now ready to use the MultiClient Runner. + +Once the game is running, you will notice a menu in the top left of your game's window. + +Runner Runtime Screenshot +This menu consists of several toggles that allow you to control each locally connected client: + +New Client Add additional online clients +I Toggle input of the client +V Toggle view of the client +G Toggle gizmos of the client +X Disconnects the client +You can also toggle multiple at the same time to control multiple clients at once. + +Back To Top + + +Code Example +The Multi-Client Runner's methods can also be controlled via user code. + +public void CreateNewLocalClient() +{ + var multiclient = FindObjectOfType(); + + // initializes a new local player + multiclient.CreateNewPlayer(); +} + +public void ShutDownLocalClient() +{ + // find the instance you want to shut down + var player = FindObjectOfType(); + + // stops the local player instance + player.Stop(); +} \ No newline at end of file diff --git a/data/nav-agent .txt b/data/nav-agent .txt new file mode 100644 index 0000000000000000000000000000000000000000..ba67ff221dfbc95f14df224df0d8bed8303ccfd8 --- /dev/null +++ b/data/nav-agent .txt @@ -0,0 +1,178 @@ +Creating Navmesh Agents +Since Quantum 2.0 navmesh agents are split into multiple components. We noticed that developers working with navmesh and steering want to control the final movement result, which makes a lot of sense, because it often is so vital to the game experience. The new navmesh agent parts should help developers to pick a combination of navmesh support without losing multi-threaded performance and without executing unneeded parts or wasting unneeded memory. + +Agent components are NavMeshPathfinder, NavMeshSteeringAgent and NavMeshAvoidanceAgent. A stand-alone component is NavMeshAvoidanceObstacle. + +Agent entities can be created in two ways: using Entity Prototypes in Unity or assembling the entity in code. They still use the NavMeshAgentConfig Quantum asset. + +Creating Agents With Entity Prototypes In Unity +Creating Agents With Components In Code +Important Agent Settings +Pathfinder +Steering Agent +Update Interval +Using Navmesh Agent Callbacks +Common Navmesh Agent Setups + +Creating Agents With Entity Prototypes In Unity +Create an empty Quantum prototype via the Unity menu: GameObject/Quantum/Empty Entity +Select the entity and set Transform to 2D +Toggle NavMeshPathfinder component +Select the default NavMeshAgentConfig +Toggle Initial Target and select a transform from the Unity scene to provide an initial position to move to +Select the baked Quantum navmesh (see Navmesh workflow) +Toggle on NavMeshSteeringAgent +To see the path gizmos either: +Activate Show Debug Steering on the default NavMeshAgentConfig or +Activate the Navmesh Gizmo Draw Pathfinder Funnel in QuantumEditorSettings +Press play +Navmesh Agent Prototype +Back To Top + + +Creating Agents With Components In Code +Alternatively agent entities can be assembled in code. + +Initially the entity requires a Transform2D or Transform3D component and adding a View component will make it have a prefab rendered in the scene. + +The most important component is the NavMeshPathfinder. It performs path-finding, stores the target position and a user-defined number of waypoints and detects the waypoint progression. This component needs to be created over the NavMeshPathfinder.Create() Factory method passing in a NavMeshAgentConfig. + +The NavMeshSteeringAgent component is optional and requires a NavMeshPathfinder. It has max speed, acceleration and rotation speed variables that can be changed during run-time and it steers the entity along the path. Apart from not using this component developers can change the MovementType to Callback and inject their own movement while having up-to-date avoidance data. Disable rotation speed and acceleration by setting them to 0. + +The NavMeshAvoidanceAgent requires both the NavMeshPathfinder and the NavMeshSteeringAgent components which need to be Set() on an entity prior to this component. This agent performs avoidance computations to avoid other moving agents (HRVO) by using priorities and filtering with masks and layers. Initially set by the NavMeshAgentConfig priority, mask and layer can be changed during run-time on the component. + +If you want the agent to be steered by a physics body, which could for example prevent the agent from penetrating static collision, the entity requires a PhysicsCollider2D/3D and a PhysicsBody2D. To enable this you need to set the MovementType to DynamicBody in its NavMeshAgentConfig. + +public override void OnInit(Frame f) { + base.OnInit(f); + + var entity = f.Create(); + f.Set(entity, new Transform3D() { Position = FPVector3.Zero, Rotation = FPQuaternion.Identity }); + var config = f.FindAsset(NavMeshAgentConfig.DEFAULT_ID); + var pathfinder = NavMeshPathfinder.Create(f, entity, config); + + // find a random point to move to + var navmesh = f.Map.NavMeshes["Navmesh"]; + if (navmesh.FindRandomPointOnNavmesh(FPVector2.Zero, FP._10, f.RNG, *f.NavMeshRegionMask, out FPVector2 randomPoint)) { + pathfinder.SetTarget(f, randomPoint, navmesh); + } + + f.Set(entity, pathfinder); + f.Set(entity, new NavMeshSteeringAgent()); +} +Activate the NavMesh Agent Gizmos Draw Nav Mesh Agents to enable the agent gizmo drawing in the scene windows. + +Back To Top + + +Important Agent Settings + +Pathfinder +NavMeshPathfinder.SetConfig() can be executed during the component creation and during run-time. If the agent is currently following a path and the waypoint count from the new config is different the path is reset. The config is automatically updated on the NavMeshSteeringAgent and NavMeshAvoidanceAgent components of the entity and values for Speed, Acceleration, AvoidancePriority, Layer and Mask are reset to the config values. + +NavMeshAgentConfig.MaxRepathTimeout is the time in seconds that will trigger a agent path-finding when a waypoint is not reached in this time. This is more of a fail-safe to mitigate stuck agents. Set the value to 0 to disable. + +NavMeshAgentConfig.LineOfSightFunneling should be activated when navmesh regions are used that are located inside the middle of the main navmesh. For example building that can be destroyed. The extra triangles introduced by the regions can sometimes result is slightly odd paths near active regions. This option will remove unnecessary waypoint near the regions. + +NavMeshAgentConfig.DynamicLineOfSight makes the agent check if waypoints can be skipped each tick. This option is costly but will remove any unnecessary waypoints on its path. + +If NavMeshAgentConfig.DynamicLineOfSightWaypointRange is set on the other hand the line of sight check is executed each tick only when close to a waypoint (range). This works without DynamicLineOfSight being enabled. + +NavMeshAgentConfig.FindValidTargetCellRange is helping when during SetTarget() a position outside the navmesh is chosen. The range parameter will search neighbouring cells for an optimal replacement of the target that is inside the navmesh. + +Consider the target marked with a yellow x in the following image. The cells searched for a specific cell range are marked with numbers. Cell range 0 for example will fail to find a destination. Range 1 on the other hand would find the closes position on the navmesh as depicted with the yellow dotted line. Be aware the increasing the cell range to unreasonable high numbers will have a big impact on the performance. + +Navmesh Agent Waypoint Reached Detection Axis +Set the number of waypoints that are cached on the NavMeshPathfinder using NavMeshAgentConfig.CachedWaypointCount. Remember that storing more non-transient data will slow down the simulation. The first waypoint stored in the cache is the current position the agent has when SetTarget() was called and is used to enhance the waypoint reached detection. When the agent starts to steer towards the last waypoint it will automatically run path-finding again to mitigate situations where the agent does not have a valid waypoint when calculating a frame. + +Enable the waypoint reached detection help NavMeshAgentConfig.EnableWaypointDetection when you notice agents are having trouble reaching waypoints (for example due to slow rotation speed or avoidance). The subsequent parameter Axis Extend and Axis Offset are defining the waypoint reached detection axis (black line). If an agents enters the yellow zone, the waypoint is considered to be reached. + +Navmesh Agent Waypoint Reached Detection Axis +Waypoint Reached Detection Axis +If the pathfinder component has no accompanied steering component DefaultWaypointDetectionDistance is used to perform waypoint reached detection and should set to the agent max speed * delta time. See the "Useful Navmesh Agent Configurations" section how to enhance the waypoint reached detection. + +Back To Top + + +Steering Agent +NavMeshAgentConfig.StoppingDistance and AutoBraking are applied at the agent that is approaching the final target. StoppingDistance is the absolute distance that the agent stops in front of the destination, setting this value helps the agent to stabilize and not overshoot. The agent always stops when the remaining distance is less then the agents current movement distance per tick. + +AutoBraking is more of a visual feature that slows the agent down before reaching the destination and also can be used to stabilize the agent stopping behavior. The AutoBrakingDistance defines the radius around the destination in which the agent starts to slow down. Internally a square root is used to smooth the braking. + +If the navmesh agent is not repelled by geometry using the MovementType PhysicsBody, especially when using avoidance, the agent will move outside the navmesh. To completely prevent this and make the agent slide along the borders of the navmesh enable NavMeshAgentConfig.ClampAgentToNavmesh. + +Agents can potentially have a bigger radius then the MinAgentRadius of the navmesh. Quantum supports this by moving the agent waypoints further away from the border but this makes clamping the agent to the navmesh much more complicated and the parameter ClampAgentToNavmeshRadiusThreshold helps to chose which technique should be chosen. Increase the radius when smaller agents tend to move outside the navmesh. + +To stabilize the correction the agent is only moved a percentage (ClampAgentToNavmeshCorrection) of its whole penetration depth. + +Back To Top + + +Update Interval +For performance optimization reasons each individual agent (config) can be configured to run path-finding and avoidance not every simulation tick. Set NavMeshAgentConfig.UdpateInterval to a value higher than 1 to reduce the amount of updates it gets. This will make the agent less responsive but also saves CPU time. The agent entity index is used to define the exact tick to update, so not all entities are updated at the same tick. + +The formula is: + +updateAgent = entity.Index % agentConfig.UpdateInterval == f.Number % agentConfig.UpdateInterval +1 = update every tick +2 = update every other tick +8 = update every 8th tick, etc +Back To Top + + +Using Navmesh Agent Callbacks +All callbacks from the agent are called from the main thread and do not cause multi-threading issues when accessing an writing other components and entities. + +Navigation agent callbacks have to be opted in. Open your simulation config and toggle Enable Navigation Callbacks. + +Simulation Config +Enable Navigation Agent Callbacks in the Simulation Config +The following signals will provide imminent feedback that can be used to further control the agent. + +namespace Quantum { + public unsafe partial class NavMeshAgentTestSystem : SystemMainThread, + ISignalOnNavMeshSearchFailed, + ISignalOnNavMeshWaypointReached, + ISignalOnNavMeshMoveAgent { + } +} +ISignalOnNavMeshSearchFailed is called when the agent could not create a path between its current position and the target set in SetTarget(). For example the destination cannot be matched to the navmesh. Set the resetAgent parameter to false when you run SetTarget() during this callback. + +ISignalOnNavMeshWaypointReached is called when the agent reached a waypoint on its path to the target. Check out the WaypointFlags enum for more information about the waypoint: Target, LinkStart, LinkEnd. + +ISignalOnNavMeshMoveAgent is only called when the NavMeshAgentConfig.MovementType is set to Callback and the agent has a NavMeshSteeringAgent component. The desiredDirection parameter is the normalized direction that the internal steering and avoidance thinks the agent movement vector should be. + +public void OnNavMeshMoveAgent(Frame f, EntityRef entity, FPVector2 desiredDirection) { + var agent = f.Unsafe.GetPointer(entity); + + // simple demonstration how to move the agent. + if (f.Has(entity)) { + var transform = f.Unsafe.GetPointer(entity); + transform->Position.X.RawValue = transform->Position.X.RawValue + ((desiredDirection.X.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + transform->Position.Y.RawValue = transform->Position.Y.RawValue + ((desiredDirection.Y.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + transform->Rotation = FPVector2.RadiansSignedSkipNormalize(FPVector2.Up, desiredDirection); + } else if (f.Has(entity)) { + var transform = f.Unsafe.GetPointer(entity); + transform->Position.X.RawValue = transform->Position.X.RawValue + ((desiredDirection.X.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + transform->Position.Z.RawValue = transform->Position.Z.RawValue + ((desiredDirection.Y.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + var desiredRotation = FPVector2.RadiansSignedSkipNormalize(FPVector2.Up, desiredDirection); + transform->Rotation = FPQuaternion.AngleAxis(desiredRotation * FP.Rad2Deg, -FPVector3.Up); + } +} +Back To Top + + +Common Navmesh Agent Setups +Path-find Only + +Use the MapNavMeshPathfinder component to perform the multi-threaded path-finding, storing the target and waypoints and perform the waypoint index progression. Control the steering, avoidance and movement yourself in your own system. + +For the waypoint progression to work the pathfinder component requires information about how fast it is approaching the waypoint. Set the WaypointDetectionDistanceSqr property each frame. + +No Avoidance + +Only use Pathfinder and SteeringAgent components. No avoidance code will be executed and the components do not store any avoidance relevant data. Toggle off SimulationConfig.Navigation.EnableAvoidance to save CPU time. + +Custom Movement But With Quantum Avoidance + +Use all three components (Pathfinder, SteeringAgent and AvoidanceAgent). The AvoidanceAgents depends on parts of the SteeringAgent although you want to override it. In the NavMeshAgentConfig set the MovementType to Callback and implement the ISignalOnNavMeshMoveAgent signal (see previous section). The desiredDirection parameter includes the avoidance altered movement direction. \ No newline at end of file diff --git a/data/nav-agent-avoidance.txt b/data/nav-agent-avoidance.txt new file mode 100644 index 0000000000000000000000000000000000000000..770e42ba4b7b9834e1ff8fa7a601a5326f91220a --- /dev/null +++ b/data/nav-agent-avoidance.txt @@ -0,0 +1,77 @@ +Agent Avoidance +Quantum implements a variation of the collision avoidance technique called Hybrid Reciprocal Velocity Obstacles. Here is an article that gives a glimpse at what is going on under the hood: Reciprocal Collision Avoidance and Navigation for Video Games. + +Navmesh Agent Prototype +Quantum Agent Avoidance +Setting Up Avoidance Agents +Setting Up Avoidance Obstacles +Jittering Agents + +Setting Up Avoidance Agents +Open and review your simulation config: AvoidanceRange will be crucial to the quality and performance cost of the avoidance system. It defines the range in which agents start to influence each other. The range is measured between the radii of two agents. + +MaxAvoidanceCandidates defines the maximum number of avoidance candidates used by each agent. More candidates requires more memory and CPU time but also increase the quality when using lots of agents. First try to reduce the number and see with how little you can actually get away with. The higher the AvoidanceQuality and the higher the amount of agents that are meeting each other the higher this number needs to be. + +Set EnableAvoidance to off, if you want to have pathfinder and steering agents but never use the avoidance. This will optimize the performance because avoidance tasks are not scheduled. This is not required if the NavigationSystem has been removed from SystemSetup.cs completely. + +Simulation Config +To activate avoidance add a NavMeshAvoidanceAgent component to an entity that already has a NavMeshPathfinder and a NavMeshSteeringAgent component. + +Then set up the avoidance section of its NavMeshAgentConfig: + +AvoidanceType.None will have the effect that the agent will not avoid others but others will avoid it. Much like a NavMeshAvoidanceObstacle component. + +Priority works as in Unity. Most important = 0. Least important = 99. Default = 50. Because the avoidance system relies on reciprocity the avoiding-work (who will avoid whom and how much) is always split between the agents. Higher priority agents do only 25% of the work while agents of the the same priority split the work 50/50. + +Use the Unity layers and set AvoidanceLayer and AvoidanceMask to filter agents. For example set some agents to be on the Heroes layer while others are on the Minions layer and set the mask for Heroes to ignore Minions. + +Toggle OverrideRadiusForAvoidance if you want a different Radius for avoidance than the one used for path-finding and steering. + +Solving avoidance while also trying to follow waypoints to steer around corners or through narrow passages can be hard. To mitigate the problem and to accept visual overlapping in favor of agents blocking each other toggle ReduceAvoidanceAtWaypoints. The avoidance applied when an agent is getting close to a waypoint is reduced. The ReduceAvoidanceFactor value is multiplied with the agent radius and then represents the distance in which the avoidance influence is reduced quadratically. + +Toggle DebugAvoidance to render gizmos into the scene view. The avoidance agent on the top is avoiding the moving agent on his right and the obstacle on his left. The red cones are the Velocity Obstacles of the agent on the top and green lines are the candidates while finally the white dot is the chosen candidate. The VO of a stationary object is truncated (see Navigation.Constants.VelocityObstacleTruncationFactor to play with that). + +Avoidance Agent Debug Gizmos +Back To Top + + +Setting Up Avoidance Obstacles +Avoidance Obstacles are static or moving entities that influence the avoidance behavior of Navmesh agents but are no agents themselves. They do not influence the path finder and should not be used to block parts of the game level. + +A NavMeshAvoidanceObstacle component requires a Transform2/3D component to work properly. + +If the entity that has a NavMeshAvoidanceObstacle component is moving, other agents require its velocity information to predict its future position. Be sure to regularly update the property NavMeshAvoidanceObstacle.Velocity manually. + +Set up an Avoidance Obstacle: + +as an EntityPrototype component in Unity: +Avoidance Obstacle Prototype +or as a Quantum component in source code: +var c = f.Create(); +f.Set(c, new Transform2D { Position = position }); +var obstacle = new NavMeshAvoidanceObstacle(); +obstacle.AvoidanceLayer = 0; +obstacle.Radius = FP._0_50; +obstacle.Velocity = FPVector2.Up; +f.Set(c, obstacle); +Back To Top + + +Jittering Agents +Solving avoidance with multiple agents moving different direction can cause the agents to switch their target direction very rapidly until finding a good course. To mitigate this the Angular Speed of the agents can be tuned down or additional smoothing is applied in the view by overriding the EntityView like this. The blending math is just one proposal. + +using UnityEngine; + +public class SmoothRotationEntityView : EntityView { + private Quaternion rotation; + public float Blending; + + protected override void ApplyTransform(ref UpdatePostionParameter param) { + // Override this in subclass to change how the new position is applied to the transform. + transform.position = param.NewPosition + param.ErrorVisualVector; + + // Unity's quaternion multiplication is equivalent to applying rhs then lhs (despite their doc saying the opposite) + rotation = param.ErrorVisualQuaternion * param.NewRotation; + transform.rotation = Quaternion.Lerp(transform.rotation, rotation, Time.deltaTime * Blending); + } +} \ No newline at end of file diff --git a/data/nav-creating mesh links.txt b/data/nav-creating mesh links.txt new file mode 100644 index 0000000000000000000000000000000000000000..b6c543e47e9c27cba02f70ab9fcec9bd1824f489 --- /dev/null +++ b/data/nav-creating mesh links.txt @@ -0,0 +1,48 @@ +Using Navmesh Off Mesh Links +Quantum exports the Unity Off Mesh links into its own data structure and gives minimal support to work with navmesh links. + +Creating Navmesh Links +Toggle Navmesh Links +Hooking-In Gameplay +Link Traversing Exception + +Creating Navmesh Links +Create a Unity Off Mesh Link. Quantum ignores the Activated, Auto Update Positions and Navigation Area properties. +Off Mesh Link Setup +Bake the map and check the resulting link using the MapNavMeshDebugDrawer script. The links are rendered as blue arrows. +Off Mesh Link Debug +The agent now already automatically uses the navmesh link during its path-finding. +Off Mesh Link Path Gizmo +Back To Top + + +Toggle Navmesh Links +Links can be toggled on and off and restrict what agents can use them by using Quantum navmesh regions. Attach a MapNavMeshRegion script to the Off Mesh Link set the Id and Cast Region to No Region. + +Off Mesh Link Regions +Back To Top + + +Hooking-In Gameplay +With no alteration the agent will traverse the link with its normal speed. You can take over control of the agent when the link has been reached by listening to the ISignalOnNavMeshWaypointReached signal. Then either disable the agent until your animation has completed or override the movement code in the ISignalOnNavMeshMoveAgent signal (this requires a change of the navmesh config to toggle the MovementType to Callback). + +This code sample performs a teleport when stepping on the link start waypoint. + +public void OnNavMeshWaypointReached(Frame f, EntityRef entity, FPVector2 waypoint, Navigation.WaypointFlag waypointFlags, ref bool resetAgent) { + var agent = f.Get(entity); + var waypointIndex = agent.WaypointIndex; + // btw HasFlag() is convenient but slow + if ((waypointFlags & Navigation.WaypointFlag.LinkStart) == Navigation.WaypointFlag.LinkStart) { + // we can be pretty sure that there always is a next waypoint for a link start + var linkDestination = agent.GetWaypoint(f, waypointIndex + 1); + f.Unsafe.GetPointer(entity)->Position = linkDestination; + } +} +Back To Top + + +Link Traversing Exception +Query if an agent is currently traversing a link by using NavMeshPathfinder.IsOnLink(FrameBase). +When setting a new target while the agent is traversing a link the agent will finish the current link before executing the path-finding. This is done by setting the WaypointFlag.RepathWhenReached. +If the waypoint before the last waypoint is a LinkStart a re-path is triggered. This helps to prematurely mitigate problems with running a re-path when the link start waypoint has already been reached. +No automatic re-pathing (NavMeshAgentConfig.MaxRepathTimeout) will be executed as long as the agent is traversing a link.. \ No newline at end of file diff --git a/data/nav-creating navmesh.txt b/data/nav-creating navmesh.txt new file mode 100644 index 0000000000000000000000000000000000000000..ba67ff221dfbc95f14df224df0d8bed8303ccfd8 --- /dev/null +++ b/data/nav-creating navmesh.txt @@ -0,0 +1,178 @@ +Creating Navmesh Agents +Since Quantum 2.0 navmesh agents are split into multiple components. We noticed that developers working with navmesh and steering want to control the final movement result, which makes a lot of sense, because it often is so vital to the game experience. The new navmesh agent parts should help developers to pick a combination of navmesh support without losing multi-threaded performance and without executing unneeded parts or wasting unneeded memory. + +Agent components are NavMeshPathfinder, NavMeshSteeringAgent and NavMeshAvoidanceAgent. A stand-alone component is NavMeshAvoidanceObstacle. + +Agent entities can be created in two ways: using Entity Prototypes in Unity or assembling the entity in code. They still use the NavMeshAgentConfig Quantum asset. + +Creating Agents With Entity Prototypes In Unity +Creating Agents With Components In Code +Important Agent Settings +Pathfinder +Steering Agent +Update Interval +Using Navmesh Agent Callbacks +Common Navmesh Agent Setups + +Creating Agents With Entity Prototypes In Unity +Create an empty Quantum prototype via the Unity menu: GameObject/Quantum/Empty Entity +Select the entity and set Transform to 2D +Toggle NavMeshPathfinder component +Select the default NavMeshAgentConfig +Toggle Initial Target and select a transform from the Unity scene to provide an initial position to move to +Select the baked Quantum navmesh (see Navmesh workflow) +Toggle on NavMeshSteeringAgent +To see the path gizmos either: +Activate Show Debug Steering on the default NavMeshAgentConfig or +Activate the Navmesh Gizmo Draw Pathfinder Funnel in QuantumEditorSettings +Press play +Navmesh Agent Prototype +Back To Top + + +Creating Agents With Components In Code +Alternatively agent entities can be assembled in code. + +Initially the entity requires a Transform2D or Transform3D component and adding a View component will make it have a prefab rendered in the scene. + +The most important component is the NavMeshPathfinder. It performs path-finding, stores the target position and a user-defined number of waypoints and detects the waypoint progression. This component needs to be created over the NavMeshPathfinder.Create() Factory method passing in a NavMeshAgentConfig. + +The NavMeshSteeringAgent component is optional and requires a NavMeshPathfinder. It has max speed, acceleration and rotation speed variables that can be changed during run-time and it steers the entity along the path. Apart from not using this component developers can change the MovementType to Callback and inject their own movement while having up-to-date avoidance data. Disable rotation speed and acceleration by setting them to 0. + +The NavMeshAvoidanceAgent requires both the NavMeshPathfinder and the NavMeshSteeringAgent components which need to be Set() on an entity prior to this component. This agent performs avoidance computations to avoid other moving agents (HRVO) by using priorities and filtering with masks and layers. Initially set by the NavMeshAgentConfig priority, mask and layer can be changed during run-time on the component. + +If you want the agent to be steered by a physics body, which could for example prevent the agent from penetrating static collision, the entity requires a PhysicsCollider2D/3D and a PhysicsBody2D. To enable this you need to set the MovementType to DynamicBody in its NavMeshAgentConfig. + +public override void OnInit(Frame f) { + base.OnInit(f); + + var entity = f.Create(); + f.Set(entity, new Transform3D() { Position = FPVector3.Zero, Rotation = FPQuaternion.Identity }); + var config = f.FindAsset(NavMeshAgentConfig.DEFAULT_ID); + var pathfinder = NavMeshPathfinder.Create(f, entity, config); + + // find a random point to move to + var navmesh = f.Map.NavMeshes["Navmesh"]; + if (navmesh.FindRandomPointOnNavmesh(FPVector2.Zero, FP._10, f.RNG, *f.NavMeshRegionMask, out FPVector2 randomPoint)) { + pathfinder.SetTarget(f, randomPoint, navmesh); + } + + f.Set(entity, pathfinder); + f.Set(entity, new NavMeshSteeringAgent()); +} +Activate the NavMesh Agent Gizmos Draw Nav Mesh Agents to enable the agent gizmo drawing in the scene windows. + +Back To Top + + +Important Agent Settings + +Pathfinder +NavMeshPathfinder.SetConfig() can be executed during the component creation and during run-time. If the agent is currently following a path and the waypoint count from the new config is different the path is reset. The config is automatically updated on the NavMeshSteeringAgent and NavMeshAvoidanceAgent components of the entity and values for Speed, Acceleration, AvoidancePriority, Layer and Mask are reset to the config values. + +NavMeshAgentConfig.MaxRepathTimeout is the time in seconds that will trigger a agent path-finding when a waypoint is not reached in this time. This is more of a fail-safe to mitigate stuck agents. Set the value to 0 to disable. + +NavMeshAgentConfig.LineOfSightFunneling should be activated when navmesh regions are used that are located inside the middle of the main navmesh. For example building that can be destroyed. The extra triangles introduced by the regions can sometimes result is slightly odd paths near active regions. This option will remove unnecessary waypoint near the regions. + +NavMeshAgentConfig.DynamicLineOfSight makes the agent check if waypoints can be skipped each tick. This option is costly but will remove any unnecessary waypoints on its path. + +If NavMeshAgentConfig.DynamicLineOfSightWaypointRange is set on the other hand the line of sight check is executed each tick only when close to a waypoint (range). This works without DynamicLineOfSight being enabled. + +NavMeshAgentConfig.FindValidTargetCellRange is helping when during SetTarget() a position outside the navmesh is chosen. The range parameter will search neighbouring cells for an optimal replacement of the target that is inside the navmesh. + +Consider the target marked with a yellow x in the following image. The cells searched for a specific cell range are marked with numbers. Cell range 0 for example will fail to find a destination. Range 1 on the other hand would find the closes position on the navmesh as depicted with the yellow dotted line. Be aware the increasing the cell range to unreasonable high numbers will have a big impact on the performance. + +Navmesh Agent Waypoint Reached Detection Axis +Set the number of waypoints that are cached on the NavMeshPathfinder using NavMeshAgentConfig.CachedWaypointCount. Remember that storing more non-transient data will slow down the simulation. The first waypoint stored in the cache is the current position the agent has when SetTarget() was called and is used to enhance the waypoint reached detection. When the agent starts to steer towards the last waypoint it will automatically run path-finding again to mitigate situations where the agent does not have a valid waypoint when calculating a frame. + +Enable the waypoint reached detection help NavMeshAgentConfig.EnableWaypointDetection when you notice agents are having trouble reaching waypoints (for example due to slow rotation speed or avoidance). The subsequent parameter Axis Extend and Axis Offset are defining the waypoint reached detection axis (black line). If an agents enters the yellow zone, the waypoint is considered to be reached. + +Navmesh Agent Waypoint Reached Detection Axis +Waypoint Reached Detection Axis +If the pathfinder component has no accompanied steering component DefaultWaypointDetectionDistance is used to perform waypoint reached detection and should set to the agent max speed * delta time. See the "Useful Navmesh Agent Configurations" section how to enhance the waypoint reached detection. + +Back To Top + + +Steering Agent +NavMeshAgentConfig.StoppingDistance and AutoBraking are applied at the agent that is approaching the final target. StoppingDistance is the absolute distance that the agent stops in front of the destination, setting this value helps the agent to stabilize and not overshoot. The agent always stops when the remaining distance is less then the agents current movement distance per tick. + +AutoBraking is more of a visual feature that slows the agent down before reaching the destination and also can be used to stabilize the agent stopping behavior. The AutoBrakingDistance defines the radius around the destination in which the agent starts to slow down. Internally a square root is used to smooth the braking. + +If the navmesh agent is not repelled by geometry using the MovementType PhysicsBody, especially when using avoidance, the agent will move outside the navmesh. To completely prevent this and make the agent slide along the borders of the navmesh enable NavMeshAgentConfig.ClampAgentToNavmesh. + +Agents can potentially have a bigger radius then the MinAgentRadius of the navmesh. Quantum supports this by moving the agent waypoints further away from the border but this makes clamping the agent to the navmesh much more complicated and the parameter ClampAgentToNavmeshRadiusThreshold helps to chose which technique should be chosen. Increase the radius when smaller agents tend to move outside the navmesh. + +To stabilize the correction the agent is only moved a percentage (ClampAgentToNavmeshCorrection) of its whole penetration depth. + +Back To Top + + +Update Interval +For performance optimization reasons each individual agent (config) can be configured to run path-finding and avoidance not every simulation tick. Set NavMeshAgentConfig.UdpateInterval to a value higher than 1 to reduce the amount of updates it gets. This will make the agent less responsive but also saves CPU time. The agent entity index is used to define the exact tick to update, so not all entities are updated at the same tick. + +The formula is: + +updateAgent = entity.Index % agentConfig.UpdateInterval == f.Number % agentConfig.UpdateInterval +1 = update every tick +2 = update every other tick +8 = update every 8th tick, etc +Back To Top + + +Using Navmesh Agent Callbacks +All callbacks from the agent are called from the main thread and do not cause multi-threading issues when accessing an writing other components and entities. + +Navigation agent callbacks have to be opted in. Open your simulation config and toggle Enable Navigation Callbacks. + +Simulation Config +Enable Navigation Agent Callbacks in the Simulation Config +The following signals will provide imminent feedback that can be used to further control the agent. + +namespace Quantum { + public unsafe partial class NavMeshAgentTestSystem : SystemMainThread, + ISignalOnNavMeshSearchFailed, + ISignalOnNavMeshWaypointReached, + ISignalOnNavMeshMoveAgent { + } +} +ISignalOnNavMeshSearchFailed is called when the agent could not create a path between its current position and the target set in SetTarget(). For example the destination cannot be matched to the navmesh. Set the resetAgent parameter to false when you run SetTarget() during this callback. + +ISignalOnNavMeshWaypointReached is called when the agent reached a waypoint on its path to the target. Check out the WaypointFlags enum for more information about the waypoint: Target, LinkStart, LinkEnd. + +ISignalOnNavMeshMoveAgent is only called when the NavMeshAgentConfig.MovementType is set to Callback and the agent has a NavMeshSteeringAgent component. The desiredDirection parameter is the normalized direction that the internal steering and avoidance thinks the agent movement vector should be. + +public void OnNavMeshMoveAgent(Frame f, EntityRef entity, FPVector2 desiredDirection) { + var agent = f.Unsafe.GetPointer(entity); + + // simple demonstration how to move the agent. + if (f.Has(entity)) { + var transform = f.Unsafe.GetPointer(entity); + transform->Position.X.RawValue = transform->Position.X.RawValue + ((desiredDirection.X.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + transform->Position.Y.RawValue = transform->Position.Y.RawValue + ((desiredDirection.Y.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + transform->Rotation = FPVector2.RadiansSignedSkipNormalize(FPVector2.Up, desiredDirection); + } else if (f.Has(entity)) { + var transform = f.Unsafe.GetPointer(entity); + transform->Position.X.RawValue = transform->Position.X.RawValue + ((desiredDirection.X.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + transform->Position.Z.RawValue = transform->Position.Z.RawValue + ((desiredDirection.Y.RawValue * f.DeltaTime.RawValue) >> FPLut.PRECISION); + var desiredRotation = FPVector2.RadiansSignedSkipNormalize(FPVector2.Up, desiredDirection); + transform->Rotation = FPQuaternion.AngleAxis(desiredRotation * FP.Rad2Deg, -FPVector3.Up); + } +} +Back To Top + + +Common Navmesh Agent Setups +Path-find Only + +Use the MapNavMeshPathfinder component to perform the multi-threaded path-finding, storing the target and waypoints and perform the waypoint index progression. Control the steering, avoidance and movement yourself in your own system. + +For the waypoint progression to work the pathfinder component requires information about how fast it is approaching the waypoint. Set the WaypointDetectionDistanceSqr property each frame. + +No Avoidance + +Only use Pathfinder and SteeringAgent components. No avoidance code will be executed and the components do not store any avoidance relevant data. Toggle off SimulationConfig.Navigation.EnableAvoidance to save CPU time. + +Custom Movement But With Quantum Avoidance + +Use all three components (Pathfinder, SteeringAgent and AvoidanceAgent). The AvoidanceAgents depends on parts of the SteeringAgent although you want to override it. In the NavMeshAgentConfig set the MovementType to Callback and implement the ISignalOnNavMeshMoveAgent signal (see previous section). The desiredDirection parameter includes the avoidance altered movement direction. \ No newline at end of file diff --git a/data/nav-custom navmesh.txt b/data/nav-custom navmesh.txt new file mode 100644 index 0000000000000000000000000000000000000000..ec942ab997976575d10e3afb588a44f24db979cd --- /dev/null +++ b/data/nav-custom navmesh.txt @@ -0,0 +1,235 @@ +Generating BakeData +The recommended process is to generate the intermediate navmesh format MapNavMesh.BakeData and run this through MapNavMeshBaker.BakeNavMesh(MapData data, MapNavMesh.BakeData navmeshBakeData) which uses the triangle information from the BakeData to fill out all required data structures of a Quantum navmesh. + +MapNavMeshBaker.BakeNavMesh() was developed to be used during edit time and replacing it entirely could yield performance improvements, but also would be a much more elaborate task. + +Back To Top + + +MapNavMesh.BakeData Class +Type Field Description +String Name The name of the navmesh accessible inside the simulation by f.Map.NavMeshes[name] +Vector3 Position The position of the navmesh. Final navmesh vertices are stored in global space and their positions are translated by this during baking. +FP AgentRadius The radius of the largest agents that the navmesh is created for. Older versions of Quantum were permitting different agent radii, but that has been abolished. Now, agents can walk up until their pivot is on the edge of the navmesh. This way the margin agents should keep away from walls is baked into the triangles. This value is only used to render debug graphics. +List Regions All regions ids that are used in this navmesh. During baking the region ids will be added to the Region list of the Map asset and their index is baked into the navmesh triangles region mask (NavMeshTriangle.Regions). The regions are aggregated on the map because a map can have multiple navmeshes that share the region ids. +MapNavMeshVertex[] Vertices The vertices of the navmesh. +MapNavMeshTriangle[] Triangles The triangle of the navmesh. This is a regular mesh data structure where the triangles and vertices are kept in two separate arrays and the triangle points into the vertex array to mark their 3 vertices. +MapNavMeshLink[] Links Link between positions on the same navmesh. +enum ClosestTriangleCalculation The Quantum navmesh uses a grid for spatial partitioning. Each grid cell will have a fallback triangle assigned. The default search is quite slow (BruteForce) while SpiralOut more efficient is but it could result in empty fallback triangles. +int ClosestTriangleCalculationDepth The number of grid cells to expand the SpiralOut search. +bool EnableQuantum_XY When enabled the navmesh baking will flip Y and Z components of the vertex positions to support navmeshes generated in the XY plane. +bool LinkErrorCorrection Automatically correct navmesh link positions to the closest triangle during baking. +Back To Top + + +MapNavMeshTriangle Class +Triangles are expected to have clock-wise winding order. Not all fields have to be filled out. Some of them are only needed for the legacy navmesh drawing tool. + +Type Field Description +String Id Not required +String[] VertexIds Must have length of 3. The referenced vertices as ids. Required for SDK 2.1. or earlier. +Int32[] VertexIds2 Must have length of 3. The referenced vertices as indices into the vertex array. Required for SDK 2.2. +Int32 Area Not required +String RegionId The region that this triangle belongs to. Default is null. +FP Cost he cost of the triangle. Default should be FP._1. +Back To Top + + +MapNavMeshVertex Class +The types of Position has been replaced by FPVector3 SDK 2.2. + +Type Field Description +String Id Required for SDK 2.1 or earlier +Vector3 Position The position of the vertex +List Neighbors Not required +List Triangles Not required +Back To Top + + +MapNavMeshLink Class +The types of Start, End and CostOveride have been replaced by FPVector3 and FP respectively in SDK 2.2. + +Type Field Description +Vector3 Start Start position of the link. Must be on the same navmesh. +Vector3 End End position of the link. Must be on the same navmesh. +bool Bidirectional Can the link be traversed from both directions. +float CostOverride The cost of the connection. +String RegionId The region id that the link belongs to. Default is null. +String Name The name of the link. Can be queried by navmesh.Links[NavMeshPathfinder.CurrentLink()].Name. +Back To Top + + +Snippet +// Generate simple navmesh BakeData +var bakeData = new MapNavMesh.BakeData() { + AgentRadius = FP._0_20, + ClosestTriangleCalculation = MapNavMesh.FindClosestTriangleCalculation.SpiralOut, + ClosestTriangleCalculationDepth = 1, + Name = "DynamicNavmesh", + PositionFP = FPVector3.Zero, + Regions = new System.Collections.Generic.List(), + Vertices = new MapNavMeshVertexFP[] { + new MapNavMeshVertexFP { Position = FPVector3.Forward }, + new MapNavMeshVertexFP { Position = FPVector3.Right }, + new MapNavMeshVertexFP { Position = -FPVector3.Forward}, + new MapNavMeshVertexFP { Position = -FPVector3.Right}, + }, + Triangles = new MapNavMeshTriangle[] { + new MapNavMeshTriangle { VertexIds2 = new int[] { 0, 1, 2}, Cost = FP._1 }, + new MapNavMeshTriangle { VertexIds2 = new int[] { 0, 2, 3}, Cost = FP._1 } + } +}; +Back To Top + + +Replacing Navmesh Assets Before Starting The Simulation +Replacing the content of an existing Unity Quantum navmesh asset before starting the simulation. All clients and late-joiners have to perform this. + +Requires: + +navmesh-deterministic-baking branch for SDK 2.1 (please contact us) +The BakeData generation is deterministic +Replacing the asset must be done before the navmesh is loaded through the UnityDB and before the simulation has started. In this snippet the Unity navmesh asset is loaded to replace the Quantum asset inside it (.Settings) and the Guid and Path values are copied. Invalidating the .DataAsset will prevent the deserialization of the binary navmesh asset (_data asset) when it is finally loaded by Quantum. + +var navmesh = MapNavMeshBaker.BakeNavMesh(mapdata.Asset.Settings, bakeData, null); +var navmeshAsset = UnityEngine.Resources.Load("PathToNavmeshAsset"); +navmesh.Guid = navmeshAsset.Settings.Guid; +navmesh.Path = navmeshAsset.Settings.Path; +navmeshAsset.Settings = navmesh; +navmeshAsset.Settings.DataAsset.Id = AssetGuid.Invalid; + +// QuantumRunner.StartGame() +Back To Top + + +Injecting Navmeshes During Runtime + +Restrictions By The Map Asset +The Map asset carries two look up for convenient navmesh and Region name lookups that are populated when the map and associated navmeshes is loaded. Both lookups will not work with dynamic navmesh assets. + +public Dictionary NavMeshes; +public Dictionary RegionMap; +public NavMesh GetNavMesh(String name) {} +Back To Top + + +GetNavMesh() Alternative +The navmesh lookup in Map (f.Map.GetNavMesh(name)) cannot be used for dynamic navmeshes because modifying the dictionary on Map does not work for late-joiner or reconnecting players. Instead use this snippet to search for all navmeshes: + +public static NavMesh FindNavmeshByName(Frame f, string name) { + var result = f.Map.GetNavMesh(name); + if (result != null) { + return result; + } + + foreach (var a in f.DynamicAssetDB.Assets) { + if (a is NavMesh navmeshAsset) { + if (navmeshAsset.Name == name) { + return navmeshAsset; + } + } + } + + return null; +} +Alternatively create and manage a navmesh lookup saved on f.Globals: + +global { + dictionary, asset_ref> Navmeshes; +} +Back To Top + + +Using Regions On Dynamic Navmeshes +If a dynamic navmesh has regions itself it has to reuse all regions loaded by the static map and other dynamic regions. If it has new regions they are not allowed to use the same regions ids already used. Toggling would not work properly. + +Best to create one static RegionMap offline and use it inside dynamic generated navmeshes. The RegionMap is created during Map.Loaded() from the maps Region member. + +public string[] Regions; +Back To Top + + +Injecting Navmeshes Inside The Simulation +Deterministically create NavmeshBakeData and bake a Quantum navmesh inside the simulation during runtime. Uses Quantum dynamic database. + +Requires: + +navmesh-deterministic-baking branch for SDK 2.1 (please contact us) +NavmeshBakeData creation needs to be deterministic +Must be performed during a verified frame +The navmesh baking code has been moved (copied in 2.1) to the quantum.code project to be able to run outside Unity. + +using Quantum.Experimental; + +// May be more buffer required for serialization +private static byte[] _byteStreamData = new byte[1024 * 1024]; + +// Generate bake data +var bakeData = new NavmeshBakeData() { + AgentRadius = FP._0_20, + ClosestTriangleCalculation = NavMeshBakeDataFindClosestTriangle.SpiralOut, + ClosestTriangleCalculationDepth = 1, + Name = "DynamicNavmesh", + PositionFP = FPVector3.Zero, + Regions = new List(), + Vertices = new Experimental.NavmeshBakeDataVertex[] { + new NavmeshBakeDataVertex { Position = FPVector3.Forward }, + new NavmeshBakeDataVertex { Position = FPVector3.Right }, + new NavmeshBakeDataVertex { Position = -FPVector3.Forward}, + new NavmeshBakeDataVertex { Position = -FPVector3.Right}, + }, + Triangles = new NavmeshBakeDataTriangle[] { + new NavmeshBakeDataTriangle { VertexIds = new int[] { 0, 1, 2}, Cost = FP._1 }, + new NavmeshBakeDataTriangle { VertexIds = new int[] { 0, 2, 3}, Cost = FP._1 } + } +}; + +// Bake navmesh asset +var navmesh = NavmeshBaker.BakeNavMesh(f.Map, bakeData); + +// Create and add binary navmesh data asset (to support late joiners) +var byteStream = new ByteStream(_byteStreamData); +navmesh.Serialize(byteStream, true); +var binaryDataAsset = new BinaryData(); +binaryDataAsset.Data = byteStream.ToArray(); +var binaryDataAssetRef = new AssetRefBinaryData(); +binaryDataAssetRef.Id = f.AddAsset(binaryDataAsset); +navmesh.DataAsset = binaryDataAssetRef; + +// Add navmesh to Dynamic DB +f.AddAsset(navmesh); +Also use the FindNavmeshByName() snippet from the next section to correctly find dynamic navmesh assets by name. + +Back To Top + + +Injecting Navmeshes From Unity +Works for late-joiners. Must be initiated by one client. + +Requires: + +Latest dynamic asset injection addon +Create BakeData in Unity on one client and use the AssetInjection command: + +var map = QuantumRunner.Default.Game.Frames.Verified.Map; +// Bake navmesh +var navmesh = MapNavMeshBaker.BakeNavMesh(map, bakeData, null); +var data = AssetInjectionUtility.SerializeAsset(null, navmesh); +// Adjust PlayerRef 0 +AssetInjectionUtility.InjectAsset(QuantumRunner.Default.Game, 0, bakeData.Name, data); +To render the gizmos of the dynamic navmesh change the following line in QuantumGameGizmos.cs: + +// ################## NavMeshes ################## + +if (editorSettings.DrawNavMesh) { + var listOfNavmeshes = new System.Collections.Generic.List(); + if (editorSettings.DrawNavMesh) { + listOfNavmeshes.AddRange(frame.Map.NavMeshes.Values); + } + if (frame.DynamicAssetDB.IsEmpty == false) { + listOfNavmeshes.AddRange(frame.DynamicAssetDB.Assets.Where(a => a is NavMesh).Select(a => (NavMesh)a).ToList()); + } + foreach (var navmesh in listOfNavmeshes) { + // ... + } +} \ No newline at end of file diff --git a/data/nav-importing a unity.txt b/data/nav-importing a unity.txt new file mode 100644 index 0000000000000000000000000000000000000000..1d59284562f8aa70e9b54fe396b124e9d82b5e02 --- /dev/null +++ b/data/nav-importing a unity.txt @@ -0,0 +1,102 @@ +Importing A Unity Navmesh +Setup your Unity scene it generate a Unity Navmesh using either the global Navmesh Baker or the Navmesh Building Components (Navmesh Surfaces) +Create a new GameObject under the map (Quantum MapData script) and add a MapNavMeshUnity script to it. The name of the GameObject will later be the name of the Quantum navmesh. +Create Navmesh Script +Adding the MapNavMeshUnity script to import a Unity navmesh +Select the map and toggle the Bake All Mode to Everything and press Bake All and check the log for errors. We expect a log message similar to: +Imported Unity NavMesh 'Navmesh', cleaned up 1211 vertices, found 7 region(s), found 4 link(s) +Map Baking +Baking the map will import and bake the navmeshes +The Quantum navmesh will show up.. +..under the Quantum map asset NavMeshLinks +..inside your project view next to the map asset file (one Quantum asset file and one binary .bytes file) +Navmesh Project View +The Quantum navmesh files will show up in the project view +To visualize the baked Quantum navmesh add the MapNavMeshDebugDrawer to the navmesh GameObject and link the .bytes file under BinaryAsset. +Navmesh Gizmos +Navmesh gizmos are rendered into the non-running scene using the MapNavMeshDebugDrawer script +To visualize the navmesh during play mode select Draw Nav Mesh in QuantumEditorSettings +All MapNavMeshUnity scripts under the map will be evaluated during map baking. But because the global Unity navmesh baking only produces one navmesh adding multiple navmeshes to the map only makes sense: + +When you are using the surface addon to control multiple navmesh surfaces +When you are drawing the navmeshes manually: use MapNavMeshDefinition instead of MapNavMeshUnity (see Quantum V1 documentation) +Or when you are creating your custom baking logic that enables and disables parts of the map during multiple Unity navmesh baking iterations +Optionally the navmesh baking can be forced to automatically run on every scene saving, playmode change or build event. See Editor Features in QuantumEditorSettings. + +Navmesh Auto Baking +Quantum supports navmeshes being only located in the origin. We encourage that the gameplay takes place close to the origin with reasonable extends because of the precision of the fixed point arithmetic. + +Back To Top + + +Import Settings +Weld Identical Vertices The Unity NavMesh is a collection of non-connected triangles. This option is very important and combines shared vertices. +Weld Vertex Epsilon Don't make the epsilon too small, vertices required to fuse can be missed, also don't make the value too big as it will deform your navmesh. +Delaunay Triangulation This option will post processes the imported Unity navmesh with a Delaunay triangulation to produce more evenly distributed triangles (it reorders long triangles). +Delaunay Triangulation Restrict To Planes On 3D navmeshes the Delaunay triangulation can deform the navmesh on slopes while rearranging the triangles. This behaviour is also noticeable on Unitys navmesh and can affect a game when the navmesh height is used for gameplay (e.g. walking on the Navmesh). Check this option to restrict the triangulation to triangles that lie in the same plane. +Fix Triangles On Edges Imported vertices are sometimes lying on other triangle edges, which leads to unwanted border detection. With this option such triangles are split. +Closest Triangle Calculation Areas in the map grid without a navmesh will need to detect nearest neighbors. This computation is very slow. The SpiralOut option will be much faster but fallback triangles can be null. +Closest Triangle Calculation Depth Number of cells to search triangles into each direction when using SpiralOut. +Enable Quantum_XY Only visible when the QUANTUM_XY define is set. Toggle this on and the navmesh baking will flip Y and Z to support navmeshes generated in the XY plane. +Min Agent Radius The minimum agent radius supported by the navmesh. This value is the margin between the navmesh and a visual border. The value is overwritten by retrieving it from Unity navmesh bake settings (or the surface settings) when baking in the Editor. +Back To Top + + +Using Navmesh Surfaces +Using the Unity navmesh surface addon has benefits: + +Runtime navmesh computation is possible (be aware that this is cannot be done deterministically across clients, the generated navmesh binary data must be send around) +Creating multiple navmeshes is easy +Using the NavMeshModifier script helps to mitigate the Unity navmesh island issue (see Quantum FAQ) +More control over internal settings +You can link multiple surfaces to a Quantum navmesh by adding them to the NavMeshSurfaces list. During the map baking of one navmesh other surfaces in the scene will be temporarily deactivated. + +Navmesh Surfaces +Quantum supports the Unity Navmesh Surface Addon +Back To Top + + +Custom Baking Options +MapNavMeshBaker.BakeNavMesh() is the essential Quantum navmesh baking method and it uses the MapNavMesh.BakeData as input data. In the default configuration the bake data is generated from the imported Unity navmesh triangulation. In a custom setup you can fill that data structure by yourself. It is basically only a triangle soup. + +You can customize the navmesh baking in different ways. + +Adding static code to the baking pipeline by deriving from MapDataBakerCallback: +Implement OnCollectNavMeshBakeData to modify existing or inject new MapNavMesh.BakeData into the pipeline. +Implement OnCollectNavMeshes to modify existing or add new NavMesh objects to be serialized. +Implement OnBeforeBakeNavMesh or OnBakeNavMesh to completely customized the baking or perform pre or post processing. +public abstract class MapDataBakerCallback { + /// + /// Is called before any navmeshes are generated or any bake data is collected. + /// + public virtual void OnBeforeBakeNavMesh(MapData data) { } + + /// + /// Is called during navmesh baking with the current list of bake data retreived from Unity navmeshes flagged for Quantum navmesh baking. + /// Add new BakeData objects to the navMeshBakeData list. + /// + /// Current list of bake data to be baked + public virtual void OnCollectNavMeshBakeData(MapData data, List navMeshBakeData) { } + + /// + /// Is called after navmesh baking before serializing them to assets. + /// Add new NavMesh objects the navmeshes list. + /// + /// Current list of baked navmeshes to be saved to assets. + public virtual void OnCollectNavMeshes(MapData data, List navmeshes) { } + + /// + /// Is called after the navmesh generation has been completed. + /// Navmeshes assets references are stored in data.Asset.Settings.NavMeshLinks. + /// + public virtual void OnBakeNavMesh(MapData data) { } +} +The methods in MapDataBakerCallback are called by reflection during the map baking process. Just fill out the methods in a public class outside any assembly definition. No need to instantiate a GameObject. For more information on the map asset baking pipeline, please refer to the Asset page in the manual. + +Back To Top + + +Visualizing Pathfinding +Activate the Pathfinder Gizmo Draw Pathfinder Funnel on QuantumEditorSettings to see the paths gizmos in the scene view + +Set the Thread Count to 1 in SimulationConfig to make the gizmos work every time because from Unity we only have access to the main thread. \ No newline at end of file diff --git a/data/nav-importing navmesh.txt b/data/nav-importing navmesh.txt new file mode 100644 index 0000000000000000000000000000000000000000..1d59284562f8aa70e9b54fe396b124e9d82b5e02 --- /dev/null +++ b/data/nav-importing navmesh.txt @@ -0,0 +1,102 @@ +Importing A Unity Navmesh +Setup your Unity scene it generate a Unity Navmesh using either the global Navmesh Baker or the Navmesh Building Components (Navmesh Surfaces) +Create a new GameObject under the map (Quantum MapData script) and add a MapNavMeshUnity script to it. The name of the GameObject will later be the name of the Quantum navmesh. +Create Navmesh Script +Adding the MapNavMeshUnity script to import a Unity navmesh +Select the map and toggle the Bake All Mode to Everything and press Bake All and check the log for errors. We expect a log message similar to: +Imported Unity NavMesh 'Navmesh', cleaned up 1211 vertices, found 7 region(s), found 4 link(s) +Map Baking +Baking the map will import and bake the navmeshes +The Quantum navmesh will show up.. +..under the Quantum map asset NavMeshLinks +..inside your project view next to the map asset file (one Quantum asset file and one binary .bytes file) +Navmesh Project View +The Quantum navmesh files will show up in the project view +To visualize the baked Quantum navmesh add the MapNavMeshDebugDrawer to the navmesh GameObject and link the .bytes file under BinaryAsset. +Navmesh Gizmos +Navmesh gizmos are rendered into the non-running scene using the MapNavMeshDebugDrawer script +To visualize the navmesh during play mode select Draw Nav Mesh in QuantumEditorSettings +All MapNavMeshUnity scripts under the map will be evaluated during map baking. But because the global Unity navmesh baking only produces one navmesh adding multiple navmeshes to the map only makes sense: + +When you are using the surface addon to control multiple navmesh surfaces +When you are drawing the navmeshes manually: use MapNavMeshDefinition instead of MapNavMeshUnity (see Quantum V1 documentation) +Or when you are creating your custom baking logic that enables and disables parts of the map during multiple Unity navmesh baking iterations +Optionally the navmesh baking can be forced to automatically run on every scene saving, playmode change or build event. See Editor Features in QuantumEditorSettings. + +Navmesh Auto Baking +Quantum supports navmeshes being only located in the origin. We encourage that the gameplay takes place close to the origin with reasonable extends because of the precision of the fixed point arithmetic. + +Back To Top + + +Import Settings +Weld Identical Vertices The Unity NavMesh is a collection of non-connected triangles. This option is very important and combines shared vertices. +Weld Vertex Epsilon Don't make the epsilon too small, vertices required to fuse can be missed, also don't make the value too big as it will deform your navmesh. +Delaunay Triangulation This option will post processes the imported Unity navmesh with a Delaunay triangulation to produce more evenly distributed triangles (it reorders long triangles). +Delaunay Triangulation Restrict To Planes On 3D navmeshes the Delaunay triangulation can deform the navmesh on slopes while rearranging the triangles. This behaviour is also noticeable on Unitys navmesh and can affect a game when the navmesh height is used for gameplay (e.g. walking on the Navmesh). Check this option to restrict the triangulation to triangles that lie in the same plane. +Fix Triangles On Edges Imported vertices are sometimes lying on other triangle edges, which leads to unwanted border detection. With this option such triangles are split. +Closest Triangle Calculation Areas in the map grid without a navmesh will need to detect nearest neighbors. This computation is very slow. The SpiralOut option will be much faster but fallback triangles can be null. +Closest Triangle Calculation Depth Number of cells to search triangles into each direction when using SpiralOut. +Enable Quantum_XY Only visible when the QUANTUM_XY define is set. Toggle this on and the navmesh baking will flip Y and Z to support navmeshes generated in the XY plane. +Min Agent Radius The minimum agent radius supported by the navmesh. This value is the margin between the navmesh and a visual border. The value is overwritten by retrieving it from Unity navmesh bake settings (or the surface settings) when baking in the Editor. +Back To Top + + +Using Navmesh Surfaces +Using the Unity navmesh surface addon has benefits: + +Runtime navmesh computation is possible (be aware that this is cannot be done deterministically across clients, the generated navmesh binary data must be send around) +Creating multiple navmeshes is easy +Using the NavMeshModifier script helps to mitigate the Unity navmesh island issue (see Quantum FAQ) +More control over internal settings +You can link multiple surfaces to a Quantum navmesh by adding them to the NavMeshSurfaces list. During the map baking of one navmesh other surfaces in the scene will be temporarily deactivated. + +Navmesh Surfaces +Quantum supports the Unity Navmesh Surface Addon +Back To Top + + +Custom Baking Options +MapNavMeshBaker.BakeNavMesh() is the essential Quantum navmesh baking method and it uses the MapNavMesh.BakeData as input data. In the default configuration the bake data is generated from the imported Unity navmesh triangulation. In a custom setup you can fill that data structure by yourself. It is basically only a triangle soup. + +You can customize the navmesh baking in different ways. + +Adding static code to the baking pipeline by deriving from MapDataBakerCallback: +Implement OnCollectNavMeshBakeData to modify existing or inject new MapNavMesh.BakeData into the pipeline. +Implement OnCollectNavMeshes to modify existing or add new NavMesh objects to be serialized. +Implement OnBeforeBakeNavMesh or OnBakeNavMesh to completely customized the baking or perform pre or post processing. +public abstract class MapDataBakerCallback { + /// + /// Is called before any navmeshes are generated or any bake data is collected. + /// + public virtual void OnBeforeBakeNavMesh(MapData data) { } + + /// + /// Is called during navmesh baking with the current list of bake data retreived from Unity navmeshes flagged for Quantum navmesh baking. + /// Add new BakeData objects to the navMeshBakeData list. + /// + /// Current list of bake data to be baked + public virtual void OnCollectNavMeshBakeData(MapData data, List navMeshBakeData) { } + + /// + /// Is called after navmesh baking before serializing them to assets. + /// Add new NavMesh objects the navmeshes list. + /// + /// Current list of baked navmeshes to be saved to assets. + public virtual void OnCollectNavMeshes(MapData data, List navmeshes) { } + + /// + /// Is called after the navmesh generation has been completed. + /// Navmeshes assets references are stored in data.Asset.Settings.NavMeshLinks. + /// + public virtual void OnBakeNavMesh(MapData data) { } +} +The methods in MapDataBakerCallback are called by reflection during the map baking process. Just fill out the methods in a public class outside any assembly definition. No need to instantiate a GameObject. For more information on the map asset baking pipeline, please refer to the Asset page in the manual. + +Back To Top + + +Visualizing Pathfinding +Activate the Pathfinder Gizmo Draw Pathfinder Funnel on QuantumEditorSettings to see the paths gizmos in the scene view + +Set the Thread Count to 1 in SimulationConfig to make the gizmos work every time because from Unity we only have access to the main thread. \ No newline at end of file diff --git a/data/nav-links.txt b/data/nav-links.txt new file mode 100644 index 0000000000000000000000000000000000000000..a6a468e99d36b955d0bede1a510296d7b788b768 --- /dev/null +++ b/data/nav-links.txt @@ -0,0 +1,40 @@ +Creating Navmesh Links +Create a Unity Off Mesh Link. Quantum ignores the Activated, Auto Update Positions and Navigation Area properties. +Off Mesh Link Setup +Bake the map and check the resulting link using the MapNavMeshDebugDrawer script. The links are rendered as blue arrows. +Off Mesh Link Debug +The agent now already automatically uses the navmesh link during its path-finding. +Off Mesh Link Path Gizmo +Back To Top + + +Toggle Navmesh Links +Links can be toggled on and off and restrict what agents can use them by using Quantum navmesh regions. Attach a MapNavMeshRegion script to the Off Mesh Link set the Id and Cast Region to No Region. + +Off Mesh Link Regions +Back To Top + + +Hooking-In Gameplay +With no alteration the agent will traverse the link with its normal speed. You can take over control of the agent when the link has been reached by listening to the ISignalOnNavMeshWaypointReached signal. Then either disable the agent until your animation has completed or override the movement code in the ISignalOnNavMeshMoveAgent signal (this requires a change of the navmesh config to toggle the MovementType to Callback). + +This code sample performs a teleport when stepping on the link start waypoint. + +public void OnNavMeshWaypointReached(Frame f, EntityRef entity, FPVector2 waypoint, Navigation.WaypointFlag waypointFlags, ref bool resetAgent) { + var agent = f.Get(entity); + var waypointIndex = agent.WaypointIndex; + // btw HasFlag() is convenient but slow + if ((waypointFlags & Navigation.WaypointFlag.LinkStart) == Navigation.WaypointFlag.LinkStart) { + // we can be pretty sure that there always is a next waypoint for a link start + var linkDestination = agent.GetWaypoint(f, waypointIndex + 1); + f.Unsafe.GetPointer(entity)->Position = linkDestination; + } +} +Back To Top + + +Link Traversing Exception +Query if an agent is currently traversing a link by using NavMeshPathfinder.IsOnLink(FrameBase). +When setting a new target while the agent is traversing a link the agent will finish the current link before executing the path-finding. This is done by setting the WaypointFlag.RepathWhenReached. +If the waypoint before the last waypoint is a LinkStart a re-path is triggered. This helps to prematurely mitigate problems with running a re-path when the link start waypoint has already been reached. +No automatic re-pathing (NavMeshAgentConfig.MaxRepathTimeout) will be executed as long as the agent is traversing a link.. diff --git a/data/nav-navmesh region.txt b/data/nav-navmesh region.txt new file mode 100644 index 0000000000000000000000000000000000000000..50ae6c171dfb878bc3da84f7b2ef2b20af99d97d --- /dev/null +++ b/data/nav-navmesh region.txt @@ -0,0 +1,60 @@ +Using Navmesh Regions +While respecting performance considerations for deterministic roll-backs in Quantum navmesh Regions are a compromise to Unitys dynamic navmesh carving. They can be used to dynamically toggle pre-defined areas of the navmesh with very little performance overhead. + +Because the regions are encoded as a mask inside the triangles (unsigned long) the maximum number of different region ids per map is 64 (Navigation.Constants.MaxRegions). Reusing the same id for different regions is possible though. + +The main Quantum navmesh (Walkable) is not a region and can not be toggled. + + +Creating Navmesh Regions +Step 1) Quantum Regions piggy-back on the Unity navmesh areas. Create a new area. We chose the name Toggleable in the image below but any name will do. + +To make the detection work when you have toggle-able regions right next to each other they will need to use different area ids. Create multiple areas here in that case. + +Region Areas +Step 2) Add the new area(s) to the MapNavMeshUnity otherwise the baking will not know what areas to look for. + +Add Region Area +Step 3) Unity uses the objects MeshRenderer to project the area onto the navmesh. Create a GameObject with a MeshRenderer and attach the MapNavMeshRegion. + +Region Setup +The Id is a unique string that will be accessible from code via the Map.RegionMap from which you can later get the region id (int, flag). + +CastRegion must be set to CastRegion. The script is re-used for Off Mesh Links for example that do not require to project regions to the navmesh. + +Under NavMeshHelper you can double check if the the GameObject is set up correctly: for example is it set to static and is the selected area is our region area. + +When the navmesh surfaces are installed the inspector look slightly different. You need to add a NavMeshModifier script to set the navmesh area for example. + +During the region import we try to match the triangles back to the original region script and for this use the bounding box of the mesh. Because the triangles generated are not 100% exact the settings on MapNavMeshUnity have a RegionDetectionMargin settings that adds a bit of room during the fitting. Increase this value if regions are not exported but when it becomes too large there may be problems detecting neighbouring regions. + +Btw: The MeshRenderer that generates the regions only has to be active during the baking.. we should add a tool for that.. + +Step 4) Now bake the map and see the coloured area of triangles in the navmesh where the region was placed. + +Active Regions +Step 5) Toggle the region off in code and watch the agent circumventing it. + +public override void OnInit(Frame f) { + var regionId = f.Map.RegionMap["foo"]; + f.NavMeshRegionMask->ToggleRegion(regionId, false); +} +Inactive Regions +Step 6) Region activation is accessible and stored in the frame. + +The mask needs to be reset when a new map is loaded. Run FrameBase.ClearAllNavMeshRegions() for example during the ISignalOnMapChanged signal. + +public class ResetRegionsSystem : SystemSignalsOnly, ISignalOnMapChanged { + public void OnMapChanged(Frame f, AssetRefMap previousMap) { + f.ClearAllNavMeshRegions(); + } +} +Checkout the API of NavMeshRegionMask object. Here is a small overview: + +NavMeshRegionMask.Default has all regions enabled (set to 1) +NavMeshRegionMask.ToggleRegion(int region, bool enabled) toggle a region by its region id. The region id is the offset of the bit-shift and can be retrieved by the name using Map.RegionMap dictionary. +NavMeshRegionMask.IsRegionEnabled(int region) checks if the region is active. +NavMeshRegionMask.IsSubset(NavMeshRegionMask) can be used to check if all regions active in one mask are also enabled in the other. +NavMeshRegionMask.Clear() sets all regions to active. +NavMeshRegionMask.HasValidRegions returns true, when the mask has exactly one valid region set. +NavMeshRegionMask.IsMainArea checks if the mask is zero which will be true for triangles belonging to the main navmesh area and cannot be toggled off. \ No newline at end of file diff --git a/data/nav-overview.txt b/data/nav-overview.txt new file mode 100644 index 0000000000000000000000000000000000000000..b8230cdc07904ba00913eedbedd120e8fe4edabc --- /dev/null +++ b/data/nav-overview.txt @@ -0,0 +1,22 @@ +The Quantum navigation system provides a deterministic Navigation Mesh and a set of Navigation Agent components to navigate and steer entities through the game world. + +N.B.: Core.NavigationSystem() needs to be enabled in SystemSetup.cs for the navigation related functionalities to work. + +More information on the API is available in the documentation included in the SDK docs\PhotonQuantum-Documentation.chm. + +Importing A Unity Navmesh +Creating Navmesh Agents +Agent Avoidance +Using Navmesh Regions +Using Navmesh Off Mesh Links +Custom Navmesh Generation + +Features +A* path-finding algorithm +Autonomous agents navigate to target destination through the navmesh +Parts of the navmesh can be toggled on and off during run-time (regions) +HRVO agent avoidance +Dynamic avoidance obstacles (not navmesh carving) +Off-mesh links +3D Navigation supported (since Quantum 2.1) +Region triangle weights (since Quantum 2.1) diff --git a/data/online-sess.txt b/data/online-sess.txt new file mode 100644 index 0000000000000000000000000000000000000000..9bd49818007fc09deaf6ab0e5c807b4fa5f96e4f --- /dev/null +++ b/data/online-sess.txt @@ -0,0 +1,340 @@ +Overview +The Quantum online services are build on top of the common Photon online infrastructure (Photon Realtime). Connecting to an online session usually goes through three connection phases: + +Custom Authentication: Photon does not offer player accounts and recommends securing the logins using a proprietary or third-party authentication provider and set up photon custom authentication. +Game Server Connection: Before starting the online simulation the clients have to connect to the Photon Cloud and enter a Room using the Photon Realtime library. The process, including rudimentary random matchmaking, is described here, inside the DemoMenu from the SDK and the official Photon Realtime doc photon matchmaking and lobby. +Quantum Simulation Start Sequence: In this phase the Quantum simulation is started, client configuration data is send and the Quantum session is joined and synchronized between clients. +Back To Top + + +How To Start A Quantum Online Session +The diagram shows the typical connection flow to give an overview of the connection handling required to start, restart and stop an online Quantum game. + +Starting Online Session: Connection Sequence +Starting Online Session: Connecting With Photon +Back To Top + + +Further Readings +photon realtime quick start +quantum reconnection manual +Back To Top + + +The Demo Menu +UIConnect +UIConnecting +UIReconnecting +UIRoom +UIGame +The DemoMenu (included in the SDK) demonstrates the complete process. To better understand what's going on the individual UI classes (UIConnect, UIConnecting, UIReconnecting, UIRoom, UIGame) should be seen as a state machine. The similarities to the diagram above are visible. + +The player presses a button to connect to the Photon Cloud and uses random matchmaking to finally join a Photon Room. +After waiting for other players pressing a button signals the game start and the players each will start their Quantum sessions. +The player can press disconnect, stop the Unity editor or restart the application to then use the reconnect button to get back to the same game session. +Demo Menu +Demo Menu Flowchart +Back To Top + + +UIConnect +There are a few extras that are supposed to make the DemoMenu a convenient development tool to start with but also make the code harder to read. For example the RegionDropdown and AppVersionDropdown. + + +RegionDropdown +There is a scriptable asset (Photon/QuantumDemo/Menu/Resources/PhotonRegions.asset) that contains a set of regions selectable by dropdown to quickly change the region when running the menu. The LastSelectedRegion is kept inside the PlayerPrefs. + +Back To Top +Back To "The Demo Menu" + + +AppVersionDropdown +The AppVersion (which is set on AppSettings when connecting) is a way to separate the users when performing matchmaking. This can be used for live games that use the same AppId but also during testing/development. The implementation covers two cases: + +Developers can run the app multiple times on their own machine and not want any others joining their games. For this purpose, it is recommended to use the Private AppVersion which uses a Guid stored in Photon/QuantumDemo/Menu/Resources/PhotonPrivateAppVersion.asset (excluded from version control). Only builds that were build on their machine can play together. +For a focus test or QA tests players should join certain groups. Names listed in the Photon/QuantumDemo/Menu/Resources/PhotonAppVersions.asset will be selectable in the drop down list of the menu. +Back To Top +Back To "The Demo Menu" + + +AppSettings +To connect to the Photon Cloud the configuration called AppSettings is required. Inside the Quantum integration this is usually accessed via a Singleton pattern: PhotonServerSettings.Instance.AppSettings which of course can be exchanged with another way of injecting the correct data. + +Notice that the settings object is copied. Otherwise there would a risk of saving changes to the asset. + +var appSettings = PhotonServerSettings.CloneAppSettings(PhotonServerSettings.Instance.AppSettings); +Back To Top +Back To "The Demo Menu" + + +QuantumLoadBalancingClient +The one instanance of the connection class QuantumLoadBalancingClient is stored as a static reference in UIMain.Client. UI development has lot's of flavors and the Singleton there and state machine here are the simplest way and should challenge developers to change the menu flow to their way of creating UI. + +The QuantumLoadBalancingClient exists only to assign a Nickname more conveniently and to cache the BestRegionSummaryFromStorage (region ping results) to PlayerPrefs. To start Quantum only the inherited class is LoadBalancingClient is required. + +Back To Top +Back To "The Demo Menu" + + +ConnectUsingSettings +The Demo Menu does not use any authentication process instead is will just connect to the Photon Cloud which will generate a user id for the client. + +ConnectUsingSettings() will use the mentioned AppSettings enhanced by Region and AppVersion selection to connect to the Photon Master Server. HideScreen() and UIConnecting.ShowScreen() are used to issue the state progression. + +if (UIMain.Client.ConnectUsingSettings(appSettings, Username.text)) { + HideScreen(); + UIConnecting.ShowScreen(); +} +Back To Top +Back To "The Demo Menu" + + +OnReconnectClicked +The sample demonstrates three reconnection scenarios: + +The connection object (UIMain.Client) is valid and PlayerTtlInSeconds is set +The client may still be considered online in which case it can try to "fast reconnect" using UIMain.Client.ReconnectAndRejoin(). It is not necessary to check for PlayerTtlInSeconds here the reconnection has to be inside a 10 second timeout before the server removes the clients residue from the room and fast reconnect is not possible anymore. + +ReconnectAndRejoin() would transport the client back into their original room. + +The connection object (UIMain.Client) is valid but the client hat been offline for more than 10 sec (or PlayerTtlInSeconds) +The client will reconnect to the master server UIMain.Client.ReconnectToMaster() and form there join back into the room (room info saved on ReconnectInformation). + +The connection object is lost probably because of an app restart +Data to reconnect is saved in Unity's PlayerPrefs using ReconnectInformation. Relevant data to cache is RoomName, Region, AppVersion, UserId (not if the custom authentication runs again any way) and finally the Timeout: when the saved data is not worth to reconnect to. + +UIMain.Client and AppSettings is reconfigured then the connection to the master server is opened and a rejoin or join is executed. Rejoin only works when the UserId is the same. + +if (UIMain.Client == null && ReconnectInformation.Instance.IsValid) { + UIMain.Client = new QuantumLoadBalancingClient(PhotonServerSettings.Instance.AppSettings.Protocol); + UIMain.Client.UserId = ReconnectInformation.Instance.UserId; + + var appSettings = PhotonServerSettings.CloneAppSettings(PhotonServerSettings.Instance.AppSettings); + appSettings.FixedRegion = ReconnectInformation.Instance.Region; + appSettings.AppVersion = ReconnectInformation.Instance.AppVersion; + + if (UIMain.Client.ConnectUsingSettings(appSettings, LastUsername)) { + HideScreen(); + UIReconnecting.ShowScreen(); + } +} +Back To Top +Back To "The Demo Menu" + + +Further Readings +quantum reconnection manual +photon realtime analyzing disconnects +Back To Top +Back To "The Demo Menu" + + +UIConnecting +The state will listen to Photon connection callbacks IConnectionCallbacks and IMatchmakingCallbacks to progress the connection and to perform error handling. + + +OnConnectedToMaster +Once the connection to the master server is successful the random matchmaking is initiated right away and a OpJoinRandomRoomParams object is created. In this example the CustomRoomProperties are used to negotiate the map selection which can be changed after entering the game room. + +RoomOptions.IsVisible indicates that the room is open for matchmaking +RoomOptions.MaxPlayers normally reflects the same number as Quantum players +RoomOptions.Plugins has to be new string[] { "QuantumPlugin" } +RoomOptions.PlayerTtl and +RoomOptions.EmptyRoomTtl reflect the settings on the PhotonServerSettings +var joinRandomParams = new OpJoinRandomRoomParams(); +_enterRoomParams = new EnterRoomParams(); +_enterRoomParams.RoomOptions = new RoomOptions(); +_enterRoomParams.RoomOptions.IsVisible = true; +_enterRoomParams.RoomOptions.MaxPlayers = Input.MAX_COUNT; +_enterRoomParams.RoomOptions.Plugins = new string[] { "QuantumPlugin" }; +_enterRoomParams.RoomOptions.CustomRoomProperties = new Hashtable { + { "HIDE-ROOM", false }, + { "MAP-GUID", defaultMapGuid }, +}; +_enterRoomParams.RoomOptions.PlayerTtl = PhotonServerSettings.Instance.PlayerTtlInSeconds * 1000; +_enterRoomParams.RoomOptions.EmptyRoomTtl = PhotonServerSettings.Instance.EmptyRoomTtlInSeconds * 1000; + +if (!UIMain.Client.OpJoinRandomOrCreateRoom(joinRandomParams, _enterRoomParams)) { + UIMain.Client.Disconnect(); +} +OpJoinRandomOrCreateRoom() initiates the random matchmaking and the connection to the game server. + +Back To Top +Back To "The Demo Menu" + + +OnJoinRandomFailed +There is a way to mitigate ErrorCode.NoRandomMatchFound by just creating a new room. + +Back To Top +Back To "The Demo Menu" + + +OnJoinedRoom +When successfully joined a room the state progresses to UIRoom. + +Other error callbacks report and show a dialog which disconnects the client and returns the players to the main menu. + +Back To Top +Back To "The Demo Menu" + + +UIReconnecting + +OnConnectedToMaster +When running ReconnectAndRejoin() this is skipped and OnJoinedRoom() is called directly. + +In the other cases of reconnecting two paths are possible: + +UIMain.Client.OpJoinRoom() +UIMain.Client.OpRejoinRoom() +The different is that Rejoin requires the client to be still considered to be active on the server (based on the 10 second timeout and the addition PlayerTTL). Both will have error handling in OnJoinRoomFailed(). + +Back To Top +Back To "The Demo Menu" + + +OnJoinedRoom +Success, continue with the UIRoom state. + +Back To Top +Back To "The Demo Menu" + + +OnJoinRoomFailed() +Two errors are very common to handle here: + +ErrorCode.JoinFailedFoundActiveJoiner: Tried to join but the client is still marked active in the room (the server does not know that it disconnected). Mitigation: retry until 10 seconds are over. +ErrorCode.JoinFailedWithRejoinerNotFound: Tried to rejoin but there is not client marked active in the room. Mitigation: join normally. +Back To Top +Back To "The Demo Menu" + + +UIRoom +In addition to matchmaking and connection callbacks UIRoom listens to IInRoomCallbacks and IOnEventCallback. + +This is the state before the Quantum simulation where clients are already in the Photon Room. The sample uses room properties to communicate some game configuration like the map selection. Only the master client can change the settings. + +To start a Quantum session usually a RuntimeConfig is required which includes custom settings for each game. By default it can be changed inside the Menu.scene (Menu > RuntimeConfig). + +Back To Top +Back To "The Demo Menu" + + +Start The Game +The sample uses the Photon communication tool OpRaiseEvent(UIMain.PhotonEventCode.StartGame) to communicate the starting of the game which is then dispatched by all client in OnEvent(). + +public void OnEvent(EventData photonEvent) { + switch (photonEvent.Code) { + case (byte)UIMain.PhotonEventCode.StartGame: +For client coming after the start or are rejoining the information that the game has started is saved in the room properties: + +var ht = new ExitGames.Client.Photon.Hashtable {{"STARTED", true}}; +UIMain.Client.CurrentRoom.SetCustomProperties(ht); +All clients returning to the room initially check if the game has already started. + +UIMain.Client.CurrentRoom.CustomProperties.TryGetValue("MAP-GUID", out mapGuidValue) +UIMain.Client.CurrentRoom.CustomProperties.TryGetValue("STARTED", out var started)) +Starting or restarting the Quantum session is done inside StartQuantumGame(). + +Initially the RuntimeConfig is copied using FromByteArray(ToByteArray()) to not accidentally write on the source and a map guid is set. + +var config = RuntimeConfigContainer != null ? RuntimeConfig.FromByteArray(RuntimeConfig.ToByteArray(RuntimeConfigContainer.Config)) : new RuntimeConfig(); +config.Map.Id = mapGuid; +The StartParameters are configured. There are different settings when joining as a spectator and for rejoiners there is an optional local snapshot that can be send (the recoding happens in UIGame). + +var param = new QuantumRunner.StartParameters { +RuntimeConfig = config, +DeterministicConfig = DeterministicSessionConfigAsset.Instance.Config, +ReplayProvider = null, +GameMode = Spectate ? Photon.Deterministic.DeterministicGameMode.Spectating : Photon.Deterministic.DeterministicGameMode.Multiplayer, +FrameData = IsRejoining ? UIGame.Instance?.FrameSnapshot : null, +InitialFrame = IsRejoining ? (UIGame.Instance?.FrameSnapshotNumber).Value : 0, +PlayerCount = UIMain.Client.CurrentRoom.MaxPlayers, +LocalPlayerCount = Spectate ? 0 : 1, +RecordingFlags = RecordingFlags.None, +NetworkClient = UIMain.Client, +StartGameTimeoutInSeconds = 10.0f +}; +The clientId used to start the Quantum game is usually the Photon UserId. It is important to be the same for reconnecting players in order to be assigned to the same Quantum player slot. For testing proposes the ClientIdProvider script located in the menu scene (Menu > UICanvas > Menu > IdProvider) can be configured to use different sources. + +var clientId = ClientIdProvider.CreateClientId(IdProvider, UIMain.Client); +QuantumRunner.StartGame(clientId, param); +After calling QuantumRunner.StartGame() the Quantum starting sequence is executed automatically. It will also trigger the map scene loading configured in the SimulationConfig. The demo menu state transitions to UIGame. + +Back To Top +Back To "The Demo Menu" + + +UIGame +The game UI state handles: + +Displaying and listening to a disconnect button +Recording snapshot on the OnDisconnected() callback +Stop the quantum simulation when disconnecting by calling QuantumRunner.ShutdownAll(true) +Back To Top +Back To "The Demo Menu" + + +Testing Disconnects +Inside OnLeaveClicked() change UIMain.Client.Disconnect() to something else to simulate slightly more natural disconnects or exceptions in the networking thread. + +public void OnLeaveClicked() { + UIMain.Client.Disconnect(); + // Debugging: use these instead of UIMain.Client.Disconnect() + //UIMain.Client.SimulateConnectionLoss(true); + //UIMain.Client.LoadBalancingPeer.StopThread(); +} +Back To Top + + +Quantum Start Sequences +Starting A Quantum Online Session +Reconnecting Into A Quantum Online Session +Further Readings +What happens after QuantumRunner.StartGame() is called? The following diagram visualizes the protocol and order of callbacks. + +Back To Top + + +Starting A Quantum Online Session +The client joins the server session and is selected to upload the DeterministicConfig and RuntimeConfig (can be overwritten on the server authoritatively by running an enterprise Quantum server). The server uses the first configs it received and progresses to the simulation start while down-streaming the accepted configs to all clients. + +On the client the GameStarted callback is invoked followed by OnInit() on all systems. + +Clients optionally run SetPlayerData() to upload their RuntimePlayer data, which in turn is returned by the server resulting in OnPlayerDataSet signal for everyone. + +Online Session: Start Sequence +Online Session: Start Sequence +Back To Top +Back To "Quantum Start Sequences" + + +Reconnecting Into A Quantum Online Session +During the reconnection or late-joining sequence the protocol changes if a snapshot has to be send to the client. Read the Reconnection Manual for more details about snapshots timings. + +For buddy-snapshots another client is tasked with uploading a recent snapshot. In the meantime the reconnecting client is signaled a successful start and OnInit() runs for the systems. Once the snapshot has been received the GameResynced callback is executed and input is coming in to catch up the last "second". + +If desired SetPlayerData() can be invoked again. + +Online Session: Restart Sequence +Online Session: Restart Sequence +Back To Top +Back To "Quantum Start Sequences" + + +Further Readings +quantum cheat protection manual +quantum reconnection manual +quantum config files +Back To Top + + +Stopping The Session And Disconnecting +To stop the Quantum simulation locally run QuantumRunner.ShutdownAll(bool immediate). Only set immediate:true when it's not called from within a Quantum callback. When set to false the shutdown is postponed until the next Unity update. + +ShutdownAll will destroy the QuantumRunner object which triggers the local Quantum simulation to be stopped. It will also result in either a connection Disconnect() or LeaveRoom() depending what is set as StartParameters.QuitBehaviour. + +If the client should exit the game gracefully, for example to clean up the player avatar for remote clients, extra logic has to be implemented into the simulation. Either a client issued command or monitoring the player connected state (see PlayerConnectedSystem). + +Considering that players also close their app or Alt+F4 their games there might not always be an opportunity to send a graceful disconnect. \ No newline at end of file diff --git a/data/overview.txt b/data/overview.txt new file mode 100644 index 0000000000000000000000000000000000000000..f2c910af9824443ffcc06cfd71fcfc245ab4e446 --- /dev/null +++ b/data/overview.txt @@ -0,0 +1,6 @@ +Introduction +The Quantum physics engine is cross-platform, deterministic and fully supports Quantum's predict rollback model. + +This page covers both 2D, 3D and 2.5D Physics documentation. + +Important: the 2D and 3D APIs are very similar. This will become apparent in the examples we will cover; each example will be presenting the 2D code followed by its 3D equivalent. \ No newline at end of file diff --git a/data/player- input connection flags.txt b/data/player- input connection flags.txt new file mode 100644 index 0000000000000000000000000000000000000000..b09343c0c952123fb74d18678ffc55ca8a57a4fe --- /dev/null +++ b/data/player- input connection flags.txt @@ -0,0 +1,56 @@ +Introduction +The DeterministicInputFlags are used by Quantum to: + +detect whether a player is present , i.e. connected, to the simulation; +decide how to predict the next tick's input for a given player; and, +know whether the input on a verified frame was provided by a client or was replaced by the server. +It is possible to automate the checks by implementing PlayerConnectedSystem, for more information please refer to its entry on the player page. + +Back To Top + + +Types +public enum DeterministicInputFlags : byte { + Repeatable = 1 << 0, + PlayerNotPresent = 1 << 1, + ReplacedByServer = 1 << 2 +} +PlayerNotPresent = means there is no client connected for this player index. +ReplacedByServer = means the player index is controlled by a client, but the client did not send the input in time which resulted in the server repeating or replacing/zeroing out the input. +Repeatable = tells both the server and other clients to copy this input data into the next tick (on server when replacing input due to timeout, and on other clients for the local prediction algorithm). This can be set by the developer from Unity when injecting player input and should be used on direct-control-like input such as movement; it is not meant for command-like input (e.g. buy item). +Back To Top + + +Implementation Example +IMPORTANT: DeterministicInputFlags can only be trusted on verified frames. + +The code snippet below is an extra from the LittleGuys sample found on the BotSDK page. + +private void UpdateIsBot(Frame f, EntityRef littleGuyEntity) +{ + // Return if players shouldn't be replaced by bots + if (!f.RuntimeConfig.ReplaceOnDisconnect) + return; + + // Only update this information if this frame is Verified. + if (!f.IsVerified) return; + + var littleGuyComponent = f.Unsafe.GetPointer(littleGuyEntity); + + // Get the input flags for that player + var inputFlags = f.GetPlayerInputFlags(littleGuyComponent->PlayerRef); + + // Bitwise operations to see if the PlayerNotPresent flag is activated + var playerDisconnected = (inputFlags & DeterministicInputFlags.PlayerNotPresent) == DeterministicInputFlags.PlayerNotPresent; + + // Store it in the IsBot field so this can be evaluated in other parts of code + littleGuyComponent->IsBot = playerDisconnected; + + // Only initialize the entity as a bot if it doesn't have the HFSM Agent component yet + if (playerDisconnected && f.TryGet(littleGuyEntity, out var hfsmAgent) == false) + { + // We're replacing players only by the HFSM, but this could easily be changed to be GOAP instead + + HFSMHelper.SetupHFSM(f, littleGuyEntity, f.RuntimeConfig.ReplacementHFSM); + } +} \ No newline at end of file diff --git a/data/player- replacement bot.txt b/data/player- replacement bot.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b3986ff15e8fc6628f9369a31228c8258d7eee3 --- /dev/null +++ b/data/player- replacement bot.txt @@ -0,0 +1,75 @@ +Introduction +It is often useful to let AI get the control over players' characters in one of two situations: + +To replace players who got disconnected from the game during an ongoing match. This helps creating fairer matches as Bots can help while the player tries to reconnect to the game, or even to compensate for a player who did a rage quit. +To fill a room with fake players when the minimum amount of necessary players to start a game session has not been reached. This is particularly important during the early stages of a game's release cycle when the playerbase is still small. +Back To Top + + +The Setup +In Quantum, the AI logic for such a feature is executed locally by every client's machine; meaning there is no concept of a "master client who simulates Bots input". + +Although it is usually simple to run an AI to control some game entities, doing so is game specific. Of course, the complexity of the AI implementation itself can range from very simple to very complex. + +The easiest way to start doing this is to signalize whether an entity is, at any point in time, controlled by AI. This can be achieved in different ways: + +Adding a "flag component", like a component AI {} which is added / removed from entities when needed. A system can then iterate over every entity which has an AI component to perform the controlling logic; +Using a Boolean in a component to turn on / off the AI controls, like component MyCharacter { bool ControlledByAI; }; +Adding more AI-specific components with lots of extra data, such as the Bot SDK's agent components (HFSM, BT, etc), or a custom one; +Now, when should it be done? It depends on the chosen use cases mentioned before; these will be explored throughout the next sections. + +Back To Top + + +Replacing A Real Player During A Game Match +This can be achieved by activating the PlayerConnectedSystem and then reacting to the ISignalOnPlayerConnected and ISignalOnPlayerDisconnected signals. The system and its accompanying signals are explained in the player documentation. +Once the player disconnects: find the entity or entities controlled by that player, and setup the AI for them as explained above. +When the player connects again, check if there are entities which were controlled by that player and remove the AI setup so the player takes back the control from the AI. + +It is worth mentioning the system above uses the PlayerInputFlags in order to work, which can also be used independently of the PlayerConnectedSystem, if desired. Find more information about player input flags here. + +Back To Top + + +Filling A Room With Bots +In this case, there are no actual players involved. In other words, entities are created which are never meant to be controlled by an actual person. + +Since no players are involved, no connectivity logic is needed either. It is possible for the custom game logic to fill the room with entities, like in this sample algorithm / snippet: + +In a Quantum system, wait an interval of time after the game started so Players have time to connect and send their player data; +When Players arrive, using the OnPlayerDataSet callback, save in the game state (e.g. in a variable in frame.Global) the amount of players who have successfully connected and joined to the game; +After the interval, subtract this amount from the expected player count from the frame API, like so: +int fillAmount = frame.PlayerCount - frame.Global->ConnectedPlayersCount; +Use the result to perform a for loop where the bot entities will be created: +for(int i = 0; i < fillAmount; i++) +{ + // Create a new Entity here + // Setup it as a Bot as explained earlier on this document +} +The snippet above is very simple and should be adjusted to the game's and game design's requirements; for instance, it may be useful to assign special information to the bot entities such as faked player information, team data, etc. + +Back To Top + + +Selecting A Bot To Create +Depending on the game type, it can be useful to create a new Bot based on some already known data. For example pick a Bot for a character which was not yet chosen or with varying levels of difficulty. + +TIP: The RuntimeConfig asset can hold some references to Entity Prototypes (i.e AssetRefEntityPrototype) so you can reference a variety of characters to pick from. Alternatively, there could be a single type of character with a reference to different AI assets to control it (e.g. different State Machines based on the difficulty level). + +Back To Top + + +Players And Bots Architecture +Characters are controlled by Quantum Systems. These systems usually know how to read player inputs to change their character's game state, such as moving them, rotating them and triggering attacks. + +Now, controlling these same characters with AI logic can be done in multiple ways. Here is an example code architecture which usually works well: + +Players naturally have an Input which can be polled with frame.GetPlayerInput(playerIndex), which returns you a pointer to a struct of type Input; +Bots can also have the same struct in a custom component - component Bot { Input Input } - , and the AI logic itself might be used just to fill the data inside of it; +Fill the input data before any character system runs. This way, if systems know how to get the input regardless of who is filling it, then no additional special checks in the systems are needed to know if that entity is a player or a Bot; +This means that the AI system might (almost) never directly influence the entity state, but rather it generates fake inputs based on its decision making logic. +The advantage of using such architecture is a clear separation of: Inputs | Players and Bots | Characters, by providing decoupled systems. + +Remember: this is just a suggestion. This architecture is not at all mandatory and the same result can be achieve in many other ways. + +Here is a visualisation of this strategy used in the twin stick shooter sample: \ No newline at end of file diff --git a/data/player-overview.txt b/data/player-overview.txt new file mode 100644 index 0000000000000000000000000000000000000000..4798cd78be2f7346a71660dc581c36f5358158f5 --- /dev/null +++ b/data/player-overview.txt @@ -0,0 +1,194 @@ +Introduction +Quantum is agnostic to the concept of player entities. All entities are the same in the eyes of the simulation. Therefore, when we refer to "the player" in this document, we mean the player controlled entity. + +Back To Top + + +Player Identification +A player can be identified in two ways: + +their player index; and, +their PlayerRef. +Back To Top + + +Player Index Assignment +The Quantum player index is assigned by the server based on the order in which the Session.Join() messages arrive. This is not to be confused with the Photon Id which is based on order in players joined the Photon room. It is not possible to set the "Desired Quantum Id" on a Photon Player. + +N.B.: In the event of a disconnect, we guarantee the client gets the same player index IF it reconnects with the same ClientId; regardless of their Photon Id - public static QuantumRunner StartGame(String clientId, Int32 playerCount, StartParameters param). + +Back To Top + + +Player Index Vs PlayerRef +The PlayerRef is a wrapper for the player index in the Quantum ECS. The PlayerRef is 1-based, while player index starts at 0. The reason is that default(PlayerRef) will return a "null/invalid" player ref struct for convenience. + +There are automatic cast operators that can cast an Integer to a PlayerRef and vice-versa. + +default(PlayerRef), internally a 0, means NOBODY +PlayerRef, internally 1, is the same as player index 0 +PlayerRef, internally 2, is the same as player index 1 +Back To Top + + +Photon Id +You can identify a player's corresponding Photon Id via the Frame API: + +Frame.PlayerToActorId(PlayerRef player) converts a Quantum PlayerRef to an ActorId (Photon client id); or, +Frame.ActorIdToAllPlayers(Int32 actorId) the reverse process of the previous method. +Use this if you plan on showing player names via PhotonPlayer.Nickname for example. + +IMPORTANT: The Photon Id is irrelevant to the Quantum simulation. + +Back To Top + + +Join The Game +When a game starts, the followings things happen in sequence: + +QuantumRunner.Session.Join() sends join request to server with desired player count. +The request is received by the server where it is validated and a confirmation is sent back to the user. If the information attached to the request is not valid, the request will be refused. +The Start game message is received by the client. +The player can now send and receive inputs. +(OPTIONAL) - In case of a late join, the client may receive a snapshot-resync; in this case step 4 would not be sending inputs while waiting for the snapshot. +(OPTIONAL) - SendPlayerData can now be used. It may be used as many times as needed during the game session (at game start and/or during the session itself). Every time SendPlayerData is called, it sends a serialized version of RuntimePlayer to the server, which then attaches to a tick input set confirmation and thus deterministically triggers the signal. +Config Sequence Diagram +Sequence Diagram +For more information on the configuration files involved, please refer to the Configuration Files document of the manual. + +Back To Top + + +SendPlayerData +QuantumGame.SendPlayerData(RuntimePlayer features) is a data path to deterministically inject a special kind of data (serialized RuntimePlayer) into the input stream. Although SendPlayerData is commonly called at game start to set up all the player; it is also possible to call it during the game session if the data needs to be updated. + +After starting, joining the Quantum Game the CallbackGameStarted callback fires. It is at this moment that each player may call the SendPlayerData method to be added as a player in everyone else's simulation. Calling this explicitly greatly simplifies the process for late-joining players. + +public class MyCallbacks : MonoBehaviour { + private void OnEnable() { + QuantumCallback.Subscribe(this, OnGameStart); + } + + private void OnGameStart(CallbackGameStarted callback) { + // paused on Start means waiting for Snapshot + if (callback.Game.Session.IsPaused) return; + + // It needs to be sent for each local player. + foreach (var lp in callback.Game.GetLocalPlayers()) { + Debug.Log("CustomCallbacks - sending player: " + lp); + callback.Game.SendPlayerData(lp, new Quantum.RuntimePlayer { }); + } + } +} +Back To Top + + +Player Entities Are Instantiated In Local Mode But Not In Multiplayer Mode +Most likely QuantumGame.SendPlayerData() was not executed for each player. If you are using the demo menus to start the game add the script CustomCallbacks.cs anywhere to the menu scene. + +Back To Top + + +PlayerConnectedSystem +To keep track of players' connection to a quantum session Input & Connection Flags are used. The PlayerConnectedSystem automates the procedure and notifies the simulation if a player has connected to the session or disconnected from it. To make use of the system, it has to be added to the SystemSetup. + +public static class SystemSetup { + public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig) { + return new SystemBase[] { + // pre-defined core systems + ... + + new PlayerConnectedSystem(), + + // custom systems + ... + } + } +} +In order to received the connection and disconnection callbacks the ISignalOnPlayerConnected and ISignalOnPlayerDisconnected have to be implemented in a system. + +Back To Top + + +ISignalOnPlayerDataSet +Implementing ISignalOnPlayerDataSet in a system give you access to public void OnPlayerDataSet(Frame f, PlayerRef playerRef). OnPlayerDataSet is called every time a serialized RuntimePlayer is part of a specific tick input. + +Back To Top + + +RuntimePlayer +The class RuntimePlayer is meant to hold player specific information such as for instance the character selected. For RuntimePlayer to work with your custom needs, you have to implement the serialization method - in the case of asset links, the GUID needs to be serialized. + +RuntimePlayer is a partial class in the quantum.code project. To facilitate upgrading SDKs and future proofing, your custom implementations are to be done in RuntimePlayer.User.cs. In here you can add the parameters you would like to specify for each player and their serialization can be implemented in the SerializeUserData method. The result will resemble this: + +namespace Quantum { + partial class RuntimePlayer { + public AssetRefCharacterSpec CharacterSpec; + + partial void SerializeUserData(BitStream stream) + { + stream.Serialize(ref CharacterSpec.Id.Value); + } + } +} +Back To Top + + +Accessing At Runtime +The RuntimePlayer asset associated with a player can be retrieved by querying Frame.GetPlayerData() with their PlayerRef. + +public void OnPlayerDataSet(Frame f, PlayerRef player){ + var data = f.GetPlayerData(player); +} +Back To Top + + +Initializing A Player Entity +The entity controlled by a player can be initialized at any point during the simulation. A common approach is to initialize it when the player connects(ISignalOnPlayerConnected) and / or the player data is received (ISignalOnPlayerDataSet). + +ISignalOnPlayerConnected: The player entity can be initialized with whatever information is already available in the simulation or the asset database. +ISignalOnPlayerDataSet: The player entity can be initialized with the information associated with the player's RuntimePlayer specific information. This is convenient for things such as selected character model / skin or inventory loadout. +Back To Top + + +Simulation Vs View +First, a few clarifications: + +From the simulation's perspective (Quantum), player controlled entity are entities with player input. It does not know of local or remote players. +From the view's perspective (Unity), we poll input from the players on the local client. +To recapt, in the simulation there is no such thing as "local" or "remote" players; however, in the view a player is either "local" or it is not. + +Photon.Deterministic.DeterministicGameMode.Local +Photon.Deterministic.DeterministicGameMode.Multiplayer +Photon.Deterministic.DeterministicGameMode.Replay +Back To Top + + +Max Amount Of Players +The max player count is essential to know in advance for it defines how much space needs to be allocated inside the memory chunk for each frame. By default the maximum amount of players is 6. To change it add the following lines to any of your qtn-files: + +#define PLAYER_COUNT 8 +#pragma max_players PLAYER_COUNT +The define acts like a define and can be used inside the DSL (e.g. for allocating arrays with the player count). +The pragma actually defines how many player the simulation can handle. +Back To Top + + +Local Player +Quantum offers to APIs in the View to check if a player is local: + +QuantumRunner.Default.Game.Session.IsLocalPlayer(int player); and, +QuantumRunner.Default.Game.PlayerIsLocal(PlayerRef playerRef). +Back To Top + + +Multiple Local Players +QuantumRunner.Default.Game.GetLocalPlayers() returns an array that is unique for every client and represents the indexes for players that your local machine controls in the Quantum simulation. + +It returns one index if there is only one local player. Should several players be on the same local machine controls, then the array will have the length of the local player count. +These are exactly the same indexes that are passed into QuantumInput.Instance.PollInput(int player). +The indexes are defined by the server (unless it is a local game). +The indexes are always within [0, PlayerCount-1]. PlayerCount represents the total player count in the match. It is passed into QuantumRunner.StartGame. +The index values are arbitrary (within the range of 0 to max players for this session) and depend on the order of multiple players connecting and disconnecting and when their messages reach the server. +If a local machine has more than one player, the values are not necessarily consecutive. +When rejoining the game you can be assigned the same player index as long as you call Session.Join() with the same GUID and the room has not been filled with new players since you disconnected. \ No newline at end of file diff --git a/data/prediction culling.txt b/data/prediction culling.txt new file mode 100644 index 0000000000000000000000000000000000000000..7bc56128d6c9ac2bc830a1f213387d2676a49297 --- /dev/null +++ b/data/prediction culling.txt @@ -0,0 +1,107 @@ +Introduction +Prediction Culling is used in games where players only have a partial view of the game world at any given time. It is safe to use and simple to activate. + +Prediction Culling allows to save CPU time during the Quantum prediction and rollback phases. Having it enabled will allow the predictor to run exclusively for important and visible entities to the local player(s), while leaving anything outside the view to be simulated only once per tick once the inputs are confirmed by the server; thus avoiding rollbacks wherever possible. + +Although the performance benefits vary from game to game, they can be quite large; this is particularly important the more players you have, as the predictor will eventually miss at least for one of them. Take for instance a game running at a 30Hz simulate rate. If the game requires an average of a ten tick rollback per confirmed input, this means the game simulation will have to lightweight enough to run close to 300Hz (including rollbacks). Using Prediction Culling, full frames will be simulated at the expected 30/60Hz at all times, and the culling will be applied to the prediction area is running within the prediction buffer. + +Back To Top + + +Setting Up Prediction Culling +As a consequence of Prediction Culling means the predicted simulation can never be accepted as the final result of a frame since part of it was culled, thus it never advanced the simulation of the whole game state. + +To set up prediction culling, there are two steps; one in Quantum and one in Unity. + +Back To Top + + +In Quantum +Enabling prediction culling in Quantum is a simple as adding the culling systems to SystemSetup.cs before any of the other systems. + +namespace Quantum { + public static class SystemSetup { + public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig) { + return new SystemBase[] { + // pre-defined core systems + new Core.CullingSystem2D(), + new Core.CullingSystem3D(), + + new Core.PhysicsSystem2D(), + new Core.PhysicsSystem3D(), + + new Core.NavigationSystem(), + new Core.EntityPrototypeSystem(), + + // user systems go here + }; + } + } +} +By default both Core.CullingSystem2D() and Core.CullingSystem3D() are included in SystemSetup. + +Back To Top + + +In Unity +In Unity, you need to set the prediction area. This will be used to decide which entities to cull from prediction. You can update the prediction area by calling SetPredictionArea() on every Unity update: + +// center is either FPVector2 or FPVector3 +// radius is an FP +QuantumRunner.Default.Game.SetPredictionArea(center, radius); +Back To Top + + +What To Expect + +Physics And Navmesh Agents +The physics engines and the NavMesh related systems are affected by Prediction Culling. + +When Prediction Culling is enabled, they will only consider and update entities within the visible area on non-verified, i.e. predicted, frames. + +CPU cycles are saved on account of physics and navmesh related agents skipping updates for any entity with the relevant component (PhysicsCollider, PhysicsBody, NavMeshPathFinder, NavMeshSteeringAgent, NavMeshAvoidanceAgent) and outside the area of interest as defined by the Prediction Area center point and radius on the local machine. + +Back To Top + + +Iterators +Your own code will also benefit from Prediction Culling. Any filter that includes a Transform2D or Transform3D will be subject to culling based on their positions. + +Essentially whenever a prediction frame is running, calling any of the methods below will only return entities within the limits o the prediction radius, while the same call will return all active instances when simulating the verified frames after input confirmations arrive). + +f.Filter() +f.Unsafe.FilterStruct() +N.B.: While filters benefit from Prediction Culling, component iterators do NOT. + +f.GetComponentIterator() +f.Unsafe.GetComponentBlockIterator() +Back To Top + + +Manual Culling Control Flags +It is also possible to manually flag entities for culling on predicted frames via the API provided via the Frame. + +Method Description +SetCullable(EntityRef entityRef, bool cullable) Sets if an entity can be culled or not. Does nothing if the entity does not exist (including invalid entity refs). +IsCulled(EntityRef entityRef) If an entity is currently culled from the simulation, regardless of the frame state (Predicted or Verified). +True if the entity is culled (for instance, not inside the prediction area) or does not exist. +False otherwise (if the entity exists and is not being culled). +Culled(EntiyRef entityRef) If an entity is prediction-culled. +True if the frame is Predicted AND the entity IsCulled. +False otherwise (if the frame is Verified or the entity is not culled). +Cull(EntiyRef entityRef) Manually marks a cullable and existing entity as culled for this tick. Does nothing if the entity does not exist or is not cullable. +ClearCulledState() Resets the culling state of all entities on that frame. Called automatically at the beginning of every frame simulation. +To keep a consistent state and avoid desync, de-flag the culled entities on verified frames in the same systems you originally flag them. from the same system, so you keep a consistent state and do not desync. + +Back To Top + + +Avoiding RNG Issues +Using RNGSession instances with Prediction Culling is perfectly safe and determinism is guaranteed. However, their combined use can result in some visual jitter when two entities share a RNGSession, such as the default one stored in Quantum's _globals_. This is due to new a RNG value being generated for a predicted entity after a verifited frame was simulated, thus changing/modifying the entity's final position. + +The solution is to store an isolated RNGSession struct in each entity subject to culling. The isolation guarantees culling will not affect the final positions of predicted entities unless the rollback actually required it. + +struct SomeStruct { + RNGSession MyRNG; +} +You can inject each RNGSession with their seeds in any way you desire. \ No newline at end of file diff --git a/data/profiling.txt b/data/profiling.txt new file mode 100644 index 0000000000000000000000000000000000000000..9ac69a4ed219af600e3a7a1ac78944c2eeabf027 --- /dev/null +++ b/data/profiling.txt @@ -0,0 +1,99 @@ +Introduction +Profiling in general is a good tool to find the relative performance between parts of the code and enable developers to drill down on hotspots. But it's not useful to find absolute performance measurements because profiling tools, especially Unity's, impact the performance with their overhead. + +The recommended performance analysis path is: + +Measure simulation times, rendering times, etc using a Quantum Release build (quantum solution) and an IL2CPP Unity build only the Quantum Graph Profiler attached to give overall numbers over time. +Then, with the rough idea where to look (is it the simulation, is it rendering, etc), follow up with a profiling session using the Unity Profiler or Quantum Task Profiler. +Keep in mind that a Quantum debug build can be 5x slower than a release build. As well as that a debug+mono build can be 10x slower that a release+il2cpp build. + +Back To Top + + +Unity Profiler +Quantum performance stats are integrated into the Unity Profiler and are started by default inside the QuantumRunner script. + +Quantum.Profiling.HostProfiler.Init(..) +You can add custom sections in your Quantum simulation code by this know Unity Profiler pattern: + +HostProfiler.Start("Foo"); +{ + HostProfiler.Start("Bar1"); + // do work + HostProfiler.End(); + + HostProfiler.Start("Bar2"); + // do work + HostProfiler.End(); +} +HostProfiler.End(); +With the most current Quantum SDK versions (2.1) Quantum also supplies data for the Timeline profiler in Unity. Quantum only provides profiling data in Debug configuration. + +Back To Top + + +Quantum Task Profiler +The Quantum Task Profiler is a custom and stand-alone graphical performance profiler for Unity similar to the Unity Timeline profiler. It only provides data when the quantum solution is compiled in Debug or in ReleaseProfiler configuration (the latter being added in Quantum 2.1). Similar to the Unity Profiler an app running on a remote device connects to the Unity Editor via UDP inside the same local network. + +Quantum Task Profiler +Quantum Task Profiler +Back To Top + + +Remote Profiling +It is possible to remotely hook into the Quantum Task Profiler to build running on the same network as the editor (UDP Port 30000). To enable the feature, simply toggle the Remote Profiler checkbox at the bottom of the QuantumEditorSettings asset. Close and reopen the Task Profiler View afterwards. + +Profiling Graphs +Toggle for the Remote Profiler in the QuantumEditorSettings +Back To Top + + +Quantum Graph Profiler +The Quantum Graph provider is an extra tool that can be integrate into an app to visually analyze performance and network statistics. + +Please download the version for your Unity Editor: + +Unity Version Release Date Download +Unity 2018.4 Jan 30, 2020 Profilers_Q2_20201030_Unity2018 +Unity 2019.4+ Jan 21, 2022 QuantumProfilers_20220121 +Back To Top + + +Real-Time Profiling +These runtime graphs help tracking the overall performance of the game and the Quantum simulation under various network conditions. The graphs and their values are based on the Unity update rate where each value equals the accumulated time/count/etc... in a single Unity frame. + +The profiler offers graphs for: + +Engine Delta Time: equals Time.unscaledDeltaTime between Unity frames. Sometimes Engine Delta Time may not reflect the target FPS, to fix this set QualitySettings.vSyncCount = 0; +Frame Time: all scripts logic including Unity internal and rendering, but excluding the wait for end of frame; +User Scripts Time: the time to run FixedUpdate() + Update() + LateUpdate(); +Render Time: equals time from last LateUpdate() until the end of render; +Simulation Time: equals QuantumRunner.Default.Game.Session.Stats.UpdateTime; +Predicted Frames: the amount of predicted Quantum frames simulated in a Unity frame equals QuantumRunner.Default.Game.Session.PredictedFrames; +Verified Frames: the amount of verified Quantum frames simulated in a Unity frame; +Network Activity: the time since the last data transmission from the server; +Ping: network peer round trip time (RTT); +Markers: up to 8 custom boolean can track values using markers. Each marker is represented by unique color; by default Red = input replaced by server and Orange = checksum calculated. +Profiling Graphs +Real-Time Profiling Graphs +Back To Top + + +A Note On Markers +For better legibility, the markers graph is running 2x faster than the others. This can be adjusted via the Samples property on the Profilers prefab. + +Multiple instances of MarkersProfiler are supported: + +Get an instance by name MarkersProfiler profiler = MarkersProfiler.Get(GAMEOBJECT_NAME); +Call profiler.SetMarker(INDEX); +Back To Top + + +Other Tools +The real-time profiling tool also contains other (more basic) tools for: + +changing target FPS (Application.targetFrameRate); and, +to simulate network conditions (lag, jitter, loss). +These are useful to quickly simulate different rendering speeds and bad networks. The effects can be seen immediately in graphs (predicted frames, simulation time, ...). + +N.B.: When simulating network loss, set values carefully. Use 1-3% to simulate loss on network and higher values to simulate local loss (e.g. bad connection to router behind 3 walls). \ No newline at end of file diff --git a/data/quantum 100.txt b/data/quantum 100.txt new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/data/quantum overview.txt b/data/quantum overview.txt new file mode 100644 index 0000000000000000000000000000000000000000..85c2b0ef55f36e3c3ca96043eebf23adc3f669ee --- /dev/null +++ b/data/quantum overview.txt @@ -0,0 +1,162 @@ +Overview +Photon Quantum is a high-performance deterministic ECS (Entity Component System) framework for online multiplayer games made with Unity. + +It is based on the predict/rollback approach which is ideal for latency-sensitive online games such as action RPGs, sports games, fighting games, FPS and more. + +Quantum also helps the developer to write clean code, fully decoupling simulation logic (Quantum ECS) from view/presentation (Unity), while also taking care of the network implementations specifics (internal predict/rollback + transport layer + game agnostic server logic): + +Quantum implements a state-of-the-art tech stack composed of the following pieces: + +Server-managed predict/rollback simulation core. +Sparse-set ECS memory model and API. +Complete set of stateless deterministic libraries (math, 2D and 3D physics, navigation, etc.). +Rich Unity editor integration and tooling. +All built on top of mature and industry-proven existing Photon products and infrastructure (photon realtime transport layer, photon server plugin to host server logic, etc.); + +Determinism Without Lockstep +In deterministic systems, game clients only exchange player input with the simulation running locally on all clients. In the past, this has used a lockstep approach, in which game clients would wait for the input from all other players before updating each tick/frame of the simulation. + +In Quantum, however, game clients are free to advance the simulation locally using input prediction, and an advanced rollback system takes care of restoring game state and re-simulating any mispredictions. + +Because Quantum also relies on a game-agnostic authoritative server component (photon server plugin) to manage input latency and clock synchronization, clients never need to wait for the slowest one to rollback/confirm the simulation forward: + +Quantum Server-Managed predict/Rollback +In Quantum, deterministic input exchange is managed via game-agnostic server logic. This prevents game clients with bad network from interfering with the experience of players on good networks. +These are the basic building blocks of a Quantum game: +Quantum Server Plugin: manages input timing and delivery between game clients, acts as clock synchronization source. Can be extended to integrate with customer-hosted back-end systems (matchmaking, player services, etc.). +Game Client Simulator: communicates with Quantum server, runs local simulation, performing all input prediction and rollbacks. +Custom Gameplay Code: developed by the customer as an isolated pure C# simulation (decoupled from Unity) using the Quantum ECS. Besides providing a framework for how to organize high-performance code, Quantum's API offers a great range of pre-built components (data) and systems (logic) that can be reused in any game such as deterministic 3D vector math, 2D and 3D physics engines, navmesh pathfinder, etc. + +Old School Coding +Starting from the assumption that all simulation code must be high-performance out of the box, Quantum internal systems are all designed with that in mind from the ground up. + +The key to Quantum's high performance is the use of pointer-based C# code combined with its sparse-set ECS memory model (all based memory aligned data-structs and a custom heap allocator - no garbage collection at runtime from simulation code). + +The goal is to leave most of the CPU budget for view/rendering code (Unity), including here the re-simulations induced by input mispredictions, inherent to the predict/rollback approach: + +Quantum hyper-fast predict/rollback +Quantum is designed to make your simulation code run as fast as possible, leaving most of the CPU budget for rendering updates. +Although the use of pointer-based C# is exposed (for performance), most of the complexity is hidden away from the developer by the clever use of a custom DSL and automatic code generation. + +Back To Top + + +Code Generation +In Quantum, all gameplay data (game state) is kept either in the sparse-set ECS data structures (entities and components) or in our custom heap-allocator (dynamic collections and custom data), always as blittable memory-aligned C# structs. + +To define all data structures that go into that, the developer uses a custom DSL (domain specific language) that lets him concentrate on the game concepts instead of performance-oriented restrictions: + +// components define reusable game state data groups + +component Resources +{ + Int32 Mana; + FP Health; +} + +// structs, c-style unions, enums, flags, etc, can be defined directly from the DSL as well +struct CustomData +{ + FP Resources; + Boolean Active; +} +The code-snippet above would generate the corresponding types (with explicit memory alignment), serialization code, and a all boiler plate control logic for special types (like components). + +The auto-generated API lets you both query and modify the game state with comprehensive functions to iterate, modify, create or destroy entities (based on composition): + +var es = frame.Filter(); +// Next fills in copies of each of the components + the EntityRef +while (es.NextUnsafe(out var entity, out var transform, out var resources)) { + transform->Position += FPVector3.Forward * frame.DeltaTime; +} +Back To Top + + +Stateless Systems +While Quantum's DSL covers game state data definition with concepts such as entities, components and auxiliary structures (structs, enums, unions, bitsets, collections, etc.), there needs to be a way to organize the custom game logic that will update this game state. + +You write custom logic by implementing Systems, which are stateless pieces of logic that will be executed every tick update by Quantum's client simulation loop: + +public unsafe class LogicSystem : SystemMainThread +{ + public override void Update(Frame f) + { + // your game logic here (f is a reference for the generated game state container). + } +} +The Systems API game loop call order, signals for system intercommunication (both custom and pre-built, such as the physics engine collision callbacks), events and several other extension hooks. + +Back To Top + + +Events +While the simulation is implemented in pure C#, without referencing Unity's API directly, there are two important features to let gameplay code communicate with the rendering engine: events and the asset linking system. + +Events are a way for the game code to inform the rendering engine that important things happened during the simulation. One good example is when something results in damage to a character. + +Using the state from the previous section as a basis, imagine that damage reduces the health value from the resources component of a character entity. From the Unity rendering scripts, the only noticeable data will be the new health value itself, without any way to know what caused the damage, and also what was the previous health value, etc. + +Event definition in a file DSL: + +event Damage +{ + entity_ref Character; + FP Amount; +} +Gameplay code raises events as a simple API call (generated): + +public void ApplyDamage(Frame f, EntityRef c, FP amount) +{ + // missing here, the logic to apply damage to the character itself + + // this sends an event to the "view" (Unity) + f.Events.Damage(amount, c); +} +Quantum's event processor will handle all generated events after the tick update is done, taking care of events that require server-confirmed input, event repetitions, and also cancelation/confirmation when simulation rollbacks do occur. + +Events raised from the simulation code can then be consumed in runtime from callbacks created in Unity scripts: + +public void OnDamage(DamageEvent dmg) +{ + // instantiate and show floating damage number above the target character, etc +} +Back To Top + + +Asset Linking +Unity is known for its flexible editor and smooth asset pipeline. The Asset Linking system allows game and level designers to create and edit data-driven simulation objects from the Unity Editor, which are then fed into the simulation. This is essential to prototype and to add final balancing touches to the gameplay. + +From the C# simulation project, the developer creates a data-driven class exposing the desired attributes: + +public partial class CharacterClass +{ + public Int32 MaxMana; + public FP MaxHealth; +} +Then from Unity, level designers can create as many instances of this asset as needed, each one being automatically assigned with a Unique GUID: + +Character Classes - Asset Linking +Example of the Asset Linking system: data-driven character-class asset containers being created/modified directly from the Unity Editor. +Then, programmers can use data from these assets directly from inside the simulation: + +var data = frame.FindAsset("character_class_id"); +var mana = data.MaxMana; +It's also possible to reference these assets directly in components from the state definition DSL: + +component CharacterAbilities +{ + asset_ref CharacterData; +} +Back To Top + + +Deterministic Library +In Quantum, the simulation needs to compute the same results on all clients, given they use the same input values. This means it has to be deterministic, which implies neither using any float or doubles variables, nor anything from the Unity API such as their vectors, physics engine, etc. + +To help game developers to implement this style of gameplay, Quantum comes bundled with a flexible and extensible deterministic library, both with a set of classes/struct/functions and also some components that can be used directly when defining entities with the DSL. + +The following modules are available: + +Deterministic math library: FP (Fixed Point) type (Q48.16) to replace floats/doubles, FPVector2, FPVector3, FPMatrix, FPQuaternion, RNGSession, FPBounds2, and all extra math utils including safe casts, and parsers from native types. The math library is implemented with performance as a primary goal, so we make intense use of inlining, lookup tables and fast operators whenever possible. +2D and 3D Physics Engines: high performance stateless 2D/3D physics engines with support for static and dynamic objects, callbacks, joints, etc. +NavMesh/PathFinder/Agents: includes both an exporter from an existing Unity navmesh or an editor to manipulate the mesh directly. Also includes industry standard HRVO collision avoidance, funneled paths, and many more features. \ No newline at end of file diff --git a/data/quantum project.txt b/data/quantum project.txt new file mode 100644 index 0000000000000000000000000000000000000000..237294fbe77dca82cf03eee8633593f3c90738be --- /dev/null +++ b/data/quantum project.txt @@ -0,0 +1,300 @@ +Introduction +A collection of background information, tutorials and best-practices to customize the integration of the Quantum SDK into specialized workflows. + +Back To Top + + +Release And Debug Builds +The Quantum project outputs its dll and references directly into the Unity project (by default QuantumSDK\quantum_unity\Assets\Photon\Quantum\Assemblies\). Depending on the build configuration selected in Visual Studio (or Rider) the dlls from QuantumSDK\assemblies\debug or \release are referenced and copied. + +To switch from a debug to a release build, rebuild the quantum solution with the desired configuration. + +Build Configuration +Toggle Build Configuration In Visual Studio +Rebuild +Rebuild The Quantum Solution In Visual Studio +The debug build will make it possible to debug the quantum.code.csproj and place breakpoints. After rebuilding the solution, attach Visual Studio (or Rider) to the running Unity Editor. + +The debug build has significant performance penalties compared to a release build. For performance tests always use a Quantum release build (and Unity IL2CPP). Read more about this in the profiling section. + +The debug build contains assertions, exceptions, checks and debug outputs that help during development and which are disabled in release configuration. For example: + +Log.Debug() and Log.Trace(), for example called from the quantum code project, will not be outputting log anymore. +As well as all Draw.Circle() methods. +NavMeshAgentConfig.ShowDebugAvoidance and ShowDebugSteering will not draw gizmos anymore. +Assertions and exceptions inside low level systems like physics are disabled. +Back To Top + + +Quantum-Unity Code Integration +A guide to demonstrate how to import and keep the Quantum simulation code in Unity. + +Note: The procedure requires Unity 2019.4 and up. + +The default way of working with Quantum is to have the simulation code (quantum_code) completely separate from Unity (quantum_unity). The double solution approach is not to everyone's liking, so with Quantum v2 we introduced an option to include quantum_code projects in the solution Unity generates with QuantumEditorSettings.MergeWithVisualStudioSolution setting. However, there are still use cases where having simulation code inside of Unity may be desirable. For instance, it lets users modify and rebuild simulation code without a license for Visual Studio or Rider. + +You can convert your project to use this approach. + +IMPORTANT: This is a one-way conversion. + +Any files that you add/remove in Unity will not be added to/removed from quantum_code/quantum.code/quantum.code.csproj. This is not a problem if do not intend to use the project; if you plan on using the console runners and/or server plug-ins, you will have to update the project manually yourself. + +Back To Top + + +Integration Steps +Delete quantum_unity/Assets/Photon/Quantum/Assemblies/quantum.code.dll +Copy tools/codeintegration_unity/QuantumCodeIntegration and tools/codeintegration_unity/QuantumCode to quantum_unity/Assets/Photon +Copy everything (except for bin, obj and Properties directories) from quantum_code/quantum.code to quantum_unity/Assets/Photon/QuantumCode +If you get compile errors due to generated code being missing after opening the Unity project, run the codegen via the Quantum/Code Integration/Run All CodeGen menu. + +Back To Top + + +Gotchas +PhotonQuantumCode.asmdef explicitly removes Unity assemblies references. This is to ensure the nondeterministic Unity code is not mixed with the simulation code; this ensures there's always a way back to quantum_code as a standalone project. +N.B.: Any issues arising from including Unity assemblies will not receive any support. + +If for whatever the reason you happen to run into a "chicken and egg" problem (cannot compile because codegen is out of date, cannot run codegen because there are compile errors) and there is no Quantum/Code Integration menu, you can always run the codegen manually via the console (on non-Windows platforms prefix these with mono): +tools/codegen/quantum.codegen.host.exe quantum_unity/Assets/Photon/QuantumCode + +tools/codegen_unity/quantum.codegen.unity.host.exe quantum_unity/Library/ScriptAssemblies/PhotonQuantumCode.dll quantum_unity/Assets + +Back To Top + + +Quantum DSL Integration +This section is about how to integrate the Quantum DSL files and their compilation into a workflow. + + +Qtn File Discovery +The qtn-files will be compiled as a pre build step of the quantum_code.csproj by calling tools\codegen\quantum.codegen.host.exe as a pre build step with either a cs-proj file or a folder as paramter. There are two ways we recommend to set up the codegen: + +1. (DEFAULT) Add the qtn-files explicitly to quantum_code.csproj: + + + + + +The PreBuildEvent looks like this: + +# win +"$(ProjectDir)..\..\tools\codegen\quantum.codegen.host.exe" "$(ProjectPath)" +# mac +mono "$(ProjectDir)..\..\tools\codegen\quantum.codegen.host.exe" "$(ProjectPath)" +2. If tools\codegen\quantum.codegen.host.exe is called with a folder instead of a file it will search for every qtn-file inside the given folder. + +Change the PreBuildEvent of quantum_code.csproj to this: + +# win +"$(ProjectDir)..\..\tools\codegen\quantum.codegen.host.exe" "$(ProjectDir)" +# mac +mono "$(ProjectDir)..\..\tools\codegen\quantum.codegen.host.exe" "$(ProjectDir)" +Back To Top + + +Qtn File Syntax Highlighting +To enable syntax highlighting in QTN files, follow the guide for your respective IDE. + + +Visual Studio +In Visual Studio, you can add syntax highlighting for QTN files by associating it with another type (e.g. C# or Microsoft Visual C++). To do this go to Tools -> Options -> Text Editor -> File Extension. + +Back To Top + + +JetBrains Rider +In JetBrains Rider, you can add syntax highlighting to QTN file by defining a new file type. + +Step 1: Navigate to File->Settings->Editor->File Types. +File Types +The `File Types` settings in JetBrains Rider. +Step 2: In the Recognized File Types category, press the + sign at the right of the to add a new file type. +New File Type +The `New File Type` window in JetBrains Rider. +Step 3: Check the settings for line comments, block comments, etc... +Step 4: Paste the list into the keywords level 1 (see below). +#define +#pragma +abstract +any +array +asset +asset_ref +bitset +button +byte +collision +component +dictionary +entity +entity_ref +enum +event +fields +filter +flags +global +has +import +input +int +list +local +long +not +player_ref +remote +sbyte +set +short +signal +struct +synced +uint +ulong +union +use +ushort +using +Step 5: Paste the list into the keywords level 2 (see below). +( +) +* +: +; +< += +> +? +[ +] +{ +} +Step 6: In the File Name Patterns category, press the + sign at the right. +Step 7: Enter *.qtn as the wildcard for the type. +DSL Syntax Highlighting +DSL Syntax Highlighting in .QTN files (JetBrains Rider). +Back To Top + + +Quantum Code Generation Tools +Quantum uses two code generation tools that are required to run before and after the quantum.code.dll compilation. + +Before compilation: Quantum Codegen (CodeGen.cs) + +After compilation: Quantum Unity Codegen (Unity scripts) + +Back To Top + + +Quantum Codegen +Executes the Quantum DSL code generation by converting found qtn files to C# code (Core/CodeGen.cs). Has two modes, one selects all qtn files recursively while the other only checks explicitly names qtn files in the csproj file. + +Location tools\codegen\quantum.codegen.host.exe +Platform Windows, Mono +Usage quantum.codegen.host.exe project-folder|project-file +project-folder The path to the folder that the quantum.code.csproj is located. This mode will select all qtn files found recursively in the provided folder. +project-file The path of the quantum.code.csproj file. This mode will select all qtn files that are explicitly listed as items: + + + +Back To Top + + +Quantum Unity Codegen +Runs the Quantum codegen part that generates the Unity asset scripts (AssetBase), editors, prototype classes and the AOT file, which includes necessary class and generic declarations for AOT compilers. + +Location tools\codegen_unity\quantum.codegen.unity.host.exe +Platform Windows, Mono +Usage quantum.codegen.unity.host.exe AssemblyPath OutputDir +AssemblyPath Path to quantum.code.dll file. +OutputDir Output folder for Unity scripts. Usually quantum_unity\Assets. See default paths and how to customize them below. +Back To Top + + +Overwrite Asset Script Location And Disable AOT File Generation +Create the file tools\codegen_unity\quantum.codegen.unity.dll.config. + +Caveat: The file will be overwritten during the default upgrade procedure. + + + + + + + + + + +Back To Top + + +Quantum Unity Codegen (Netcore) +This version of the Quantum codegen Unity tool will be able to load a quantum.code.dll compiled with netstandard2.0. + +Location tools\codegen_unity\netcoreapp3.1\quantum.codegen.unity.host.exe +Platform Windows, Linux +Usage quantum.codegen.unity.host.exe --additional-deps AdditionalDepsDir +AssemblyPath Path to quantum.code.dll file. +OutputDir Output folder for Unity scripts. Usually quantum_unity\Assets. See default paths and how to customize them below. +AdditionalDepsDir Additional dependencies are required to load the quantum dlls which are usually located in assemblies\release. +Set up as PostBuildEvent from Visual Studio: + +"$(ProjectDir)..\..\tools\codegen_unity\netcoreapp3.1\quantum.codegen.unity.host.exe" "$(TargetDir)\quantum.code.dll" "$(ProjectDir)..\..\quantum_unity\Assets" --additional-deps "$(ProjectDir)..\..\assemblies\$(Configuration)" + + To Document Top + +Hide Sidebar +Search … +PRODUCTS +QUANTUM | v2v1 +API Reference +Getting Started + +Quantum 100 + +Game Samples + +Technical Samples + +AddOns + +Manual + +Quantum Ecs + +Animation +Assets + +Cheat Protection +Commands +Configuration Files +Custom Server Plugin + +Entity Prototypes +Frames +Game Session + +Git Ignore Files +Input +Materialization +Multi-Client Runner +Navigation + +Physics + +Player + +Prediction Culling +Profiling +Quantum Project +WebGL +Concepts And Patterns + +Consoles + +Reference + +Languages English , 日本語 , 한국어 , 繁体中文 diff --git a/data/quantum.code.csproj b/data/quantum.code.csproj new file mode 100644 index 0000000000000000000000000000000000000000..7eec4ac7ad681f24975ba6864c19637d01bfe59d --- /dev/null +++ b/data/quantum.code.csproj @@ -0,0 +1,98 @@ + + + + + Debug + AnyCPU + {FBF32099-B197-4AB9-8E5A-B44D9D3750BD} + Library + Properties + Quantum + quantum.code + v4.6.2 + 512 + + + + true + portable + false + ..\..\Quantum 8Ballpool\Assets\Photon\Quantum\Assemblies\ + TRACE;DEBUG;PROFILER_REPORT + prompt + 4 + true + false + latest + true + + + pdbonly + true + ..\..\Quantum 8Ballpool\Assets\Photon\Quantum\Assemblies\ + PROFILER_REPORT + prompt + 4 + true + false + latest + true + + + + + + False + ..\..\assemblies\release\PhotonDeterministic.dll + ..\..\assemblies\debug\PhotonDeterministic.dll + + + False + ..\..\assemblies\release\quantum.core.dll + ..\..\assemblies\debug\quantum.core.dll + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "$(ProjectDir)..\..\tools\codegen_unity\quantum.codegen.unity.host.exe" "$(TargetDir)\quantum.code.dll" "$(SolutionDir)..\Quantum 8Ballpool\Assets" + "$(ProjectDir)..\..\tools\codegen\quantum.codegen.host.exe" "$(ProjectPath)" + mono "$(ProjectDir)..\..\tools\codegen_unity\quantum.codegen.unity.host.exe" "$(TargetDir)\quantum.code.dll" "$(SolutionDir)..\Quantum 8Ballpool\Assets" + mono "$(ProjectDir)..\..\tools\codegen\quantum.codegen.host.exe" "$(ProjectPath)" + + \ No newline at end of file diff --git a/data/queries.txt b/data/queries.txt new file mode 100644 index 0000000000000000000000000000000000000000..23c003c2c7ab6f54a3c22a4fd10c1386bb6b81a0 --- /dev/null +++ b/data/queries.txt @@ -0,0 +1,264 @@ +Introduction +Queries may take into account dynamic entities and static colliders. The API for rays, lines and shape overlaps is very similar in that is always results a collection of hits (with the same kind of data in their fields). + +Back To Top + + +Queries + +Linecast And Raycast +// For 2D +var hits = f.Physics2D.LinecastAll(FPVector2.Zero, FPVector2.One); +for (int i = 0; i < hits.Count; i++) { + var hit = hits[i]; +} + +// For 3D +var hits = f.Physics3D.LinecastAll(FPVector3.Zero, FPVector3.One); +for (int i = 0; i < hits.Count; i++){ + var hit = hits[i]; +} +The resulting HitCollection object, contains the following properties: + +Each item in the HitCollection holds an EntityRef or Static collider info. They mutually exclusive - one will be valid, and the other null; +Count should always be used to iterate over the HitCollection; and, +Hits are not sorted. You can sort them by calling Sort() and passing in a FPVector2, this will result in the hits being sorted according to their distance to the reference point provided to the function. +Raycasts are syntax-sugar for Linecasts. They work the same and simply require a start, direction and max-distance instead of start and end. Additionally, you may pass these optional parameters to the linecast and raycast: + +LayerMask, to specify which physics layers to perform the cast against; and, +QueryOptions, to specify the type of collider to consider in the cast. +Back To Top + + +Shape Queries +Quantum supports two different types of shape queries: + +ShapeOverlap; and, +ShapeCasts (since v2.1). +These can be used with all dynamic shapes supported in Quantum. + +Note: CompoundShapes can be used for performing shape queries. For more information, please read the Shape Config page. + +Back To Top + + +ShapeOverlaps +OverlapShape() returns a HitCollection. The required parameters are: + +a center position (FPVector2 or FPVector3); +a rotation (FP or FPQuaternion for the 3D equivalent); and, +a shape (Shape2D or Shape3D - either from a PhysicsCollider, or created at the time of calling). +// For 2D +var hits = f.Physics2D.OverlapShape(FPVector2.Zero, FP._0, Shape2D.CreateCircle(FP._1)) +for (int i = 0; i < hits.Count; i++){ + var hit = hits[i]; +} + +// For 3D +var hits = f.Physics3D.OverlapShape(FPVector3.Zero, FPQuaternion.Identity, Shape3D.CreateSphere(1)); +for (int i = 0; i < hits.Count; i++){ + var hit = hits[i]; +} +Back To Top + + +ShapeCasts +ShapeCasts (2D & 3D) are available since Quantum 2.1 . + +ShapeCastAll() returns a HitCollection. The required parameters are: + +the center position ( FPVector2 or FPVector3); +the rotation of the shape ( FP or FPQuaternion for the 3D equivalent); +the shape pointer ( _Shape2D* _ or Shape3D* - either from a PhysicsCollider, or created at the time of calling); and, +the distance and direction expressed as a vector ( FPVector2 or FPVector3 ). +// For 2D +var shape = Shape2D.CreateCircle(FP._1); +var hits = f.Physics2D.ShapeCastAll(FPVector2.Zero, FP._0, &shape, FPVector2.One); +for (int i = 0; i < hits.Count; i++){ + var hit = hits[i]; +} + +// For 3D +var shape = Shape3D.CreateSphere(1); +var hits = f.Physics3D.ShapeCastAll(FPVector3.Zero, FPQuaternion.Identity, &shape, FPVector3.One); +for (int i = 0; i < hits.Count; i++){ + var hit = hits[i]; +} +It uses a custom GJK-based algorithm. The GJKConfig settings are available in the SimulationConfig asset's Physics > GJKConfig section. The settings allow to balance accuracy and performance as both come with their trade-offs. The default values are balanced to compromise for regular sized shapes. + +Simplex Min/Max Bit Shift: Allows better precision for points in the Voronoy Simplex by progressively shifting their raw values, avoiding degenerate cases without compromising the valid range of positions in the Physics space. Consider increasing the values if the scale of the shapes involved and/or the distance between them is very small. +Shape Cast Max Iterations: The max number of iterations performed by the algorithm while searching for a solution below the hard tolerance. Increasing it might result in more accurate results, at the cost of performance in worst-case scenarios, and vice-versa. +Shape Cast Hard Tolerance: An iteration result (closest distance between the shapes) below this threshold is acceptable as a finishing condition. Decreasing it might result in more accurate results, at the cost of more iterations, and vice-versa. +Shape Cast Soft Tolerance: A shape cast resolution that fails to find an acceptable result below the defined Hard Tolerance within the Max Iterations allowed will still return positive if the best result found so far is below this soft threshold. In these cases, increasing this threshold enhances the probability of false-positives, while decreasing it enhances false-negatives. +Back To Top + + +Sorting Hits +All queries returning a HitCollection can be sorted. + +Sort(): takes a FPVector2 in 2D and a FPVector3 in 3D and sorts the collection according to the hits' respective distance to the point provided. +SortCastDistance(): used for sorting the results of ShapeCast query. It takes no arguments and orders the hits based on the cast distance. +Back To Top + + +Options +All queries, including their broadphase version, can use QueryOptions to customize the operation and its results. QueryOptions create a mask that filters which types of objects are taken into account and what information will be computed. You can combined these by using the binary | operator. + +Back To Top + + +Hit Normals +To offer the most performant query, all default queries only check whether the two shapes are overlapping. + +In order to receive additional information, more computation will be needed which in turn creates additional overhead; it is therefore necessary you explicitly specify it by passing ComputeDetailedInfo as the QueryOptions parameter. This will enable the computation of the hit's: + +point +normal +penetration +For ray-triangle checks the normal is always the triangle's normal. Since this is cached in the triangle data, there is no additional computation in this case. + +Back To Top + + +Filtering Hits +The following QueryOptions allow you to define the mask used by the query. If an object does not match the QueryOptions specified as the parameter, it will be skipped; only objects matching the QueryOptions will be evaluated and returned in the result. + +HitStatics : will only hit static colliders +HitKinematics : will hit entities who meet any of the following conditions: +entities with a PhysicsCollider and no PhysicsBody +entities with a PhysicsCollider and a disabled PhysicsBody +entities with a PhysicsCollider and a kinematic PhysicsBody +HitDynamics : will only hit entities with an enabled and non-kinematic PhysicsBody +HitTriggers : has to be used in combination with other flags to hit trigger colliders. +HitAll : will hit all entities that have a PhysicsCollider +By default, a query will use the HitAll option. Choosing any other option will save computation. + +Back To Top + + +Broadphase Queries +Quantum comes with an option for injecting physics queries (ray-casts and overlaps) to be resolved during the physics systems. For this you need to: + +Create a system. +Insert it before Core.PhysicsSystem when adding it to SystemSetup.cs. +Retrieve the information in any system running after Core.PhysicsSystem. +This setup benefits from the parallel resolution on physics steps which makes it significantly faster than normal querying after physics. + +public static class SystemSetup { + public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig) { + return new SystemBase[] { + + // pre-defined core systems + new Core.CullingSystem2D(), + new Core.CullingSystem3D(), + + // Placing systems scheduling Broadphase queries here + // allows them to benefit from the CullingSystems on predicted frames. + new ProjectileHitQueryInjectionSystem(), + + new Core.PhysicsSystem2D(), + new Core.PhysicsSystem3D(), + + new Core.NavigationSystem(), + new Core.EntityPrototypeSystem(), + + // user systems go here + // This is also where systems retrieving the results of broadphase queries go + new ProjectileHitRetrievalSystem(), + }; + } +} +Note: Sometimes broadphase queries are also referred to as injected queries or scheduled queries, because they are scheduled/injected into the physics engine before the solver runs. + +Back To Top + + +Injecting Queries +You can inject a query from any main thread system running before physics. The injected query will return a 0-based index, using this same index you will be able to retrieve the results after the physics system ran. The query's index is meant to be generated and consumed within the frame, therefore it can be stored anywhere - including outside the rollback-able frame data. + +namespace Quantum +{ + public unsafe struct ProjectileFilter + { + public EntityRef EntityRef; + public Transform3D* Transform; + public Projectile* Component; + } + + public unsafe class ProjectileHitQueryInjectionSystem : SystemMainThread + { + public override void Update(Frame f) + { + var projectileFilter = f.Unsafe.FilterStruct(); + var projectile = default(ProjectileFilter); + + while (projectileFilter.Next(&projectile)) + { + projectile.Component->PathQueryIndex = f.Physics3D.AddRaycastQuery( + projectile.Transform->Position, + projectile.Transform->Forward, + projectile.Component->Speed * f.DeltaTime); + + var spec = f.FindAsset(projectile.Component->WeaponSpec.Id); + + projectile.Component->DamageZoneQueryIndex = f.Physics3D.AddOverlapShapeQuery( + projectile.Transform->Position, + projectile.Transform->Rotation, + spec.AttackShape.CreateShape(f), + spec.AttackLayers); + } + } + } +} +IMPORTANT: The query indices returned by AddXXXQuery are absolutely necessary to retrieve the results of the queries later on. It is thus advisable to save in a component attached to the entity who will need to process the hits down the line. + +Back To Top + + +Retrieving Query Results +The query results can be retrieved from any system that runs after the physics. To retrieve the results (HitCollection*) you need the pass the index previous saved into Frame.Physics.GetQueryHits(). + +using Photon.Deterministic; + +namespace Quantum +{ + public unsafe class ProjectileHitRetrievalSystem : SystemMainThread + { + public override void Update(Frame f) + { + var projectileFilter = f.Unsafe.FilterStruct(); + var projectile = default(ProjectileFilter); + + while (projectileFilter.Next(&projectile)) + { + var hitsOnTrajectory = f.Physics3D.GetQueryHits(projectile.Component->PathQueryIndex); + if (hitsOnTrajectory.Count <= FP._0) + { + projectile.Transform->Position = + projectile.Transform->Rotation * + projectile.Transform->Forward * + projectile.Component->Speed * f.DeltaTime; + continue; + } + + var damageZoneHits = f.Physics3D.GetQueryHits(projectile.Component->DamageZoneQueryIndex); + + for (int i = 0; i < damageZoneHits.Count; i++) + { + // Apply damage logic + } + } + } + } +} +In addition to that, you can grab all broadphase results via the public bool GetAllQueriesHits(out HitCollection* queriesHits, out int queriesCount) call which is also available via Frame.Physics. + +Back To Top + + +Note +A few important points to keep in mind when using broadphase queries: + +The performance is around 20x better for large numbers (e.g. projectiles). +They are based on the frame state before the Physics system kicks in. +Broadphase queries do not carry over between frames; i.e. they need to be injected at the start of a frame before the Physics. A broadphase query injected after the Physics has run will never return a result. This is because Quantum's Physics are stateless. \ No newline at end of file diff --git a/data/reconnecting.txt b/data/reconnecting.txt new file mode 100644 index 0000000000000000000000000000000000000000..840013a1406bee4f776fb35a63fed14e88697bb0 --- /dev/null +++ b/data/reconnecting.txt @@ -0,0 +1,353 @@ +The following documentation will shed light into all aspects of reconnecting into a running Quantum session. The basic flow is implemented in the demo menu sample that is shipped with the Quantum SDK. + +The reconnection process consists of two main parts: How to get back into the Photon Realtime room and what to do the Quantum simulation? + +Detecting Disconnects +Photon Realtime Fast Reconnect +Requirements: PlayerTTL +Requirements: RoomTTL (Waiting For Snapshots) +Requirements: Photon UserId +Possible Error: ReconnectAndRejoin Returns False +Possible Error: PlayerTTL Ran Out +Possible Error: Authentication Token Timeout +Possible Error: Connection Still Unavailable +Reconnecting After App Restart +Different Master Server +Other Photon Realtime Topics +Best Region Summary +AppVersion +Further Readings +Reconnecting Into A Running Quantum Game +Quantum ClientId +Further Readings +Restarting The Quantum Session +EntityViews And UI +Events +SetPlayerData +StartParameters.QuitBehaviour +Late-Joining And Buddy-Snapshots +Local Snapshots + +Detecting Disconnects +The most common cases of disrupted connections: + +IConnectionCallbacks.OnDisconnected(DisconnectCause cause) is called with something other than the DisconnectCause.DisconnectByClientLogic reason. +Mobile app loses focus +App restarts +A custom way to detect signal loss prematurely +How To Disconnects For Debugging + +In the Unity Editor just hit Play to stop and start the application +LoadBalancingClient.SimulateConnectionLoss(true) will stop sending and receiving and will result in a DisconnectCause.ClientTimeout disconnection after 10 seconds. +LoadBalancingClient.LoadBalancingPeer.StopThread() immediately causes a disconnect DisconnectCause.None. +Use an external network tool (for example clumsy) to block the game server ports +Clumsy: +Filter (udp.DstPort == 5056 or udp.SrcPort == 5056) or (tcp.DstPort == 4531 or tcp.SrcPort == 4531) +Drop 100% +Back To Top + + +Photon Realtime Fast Reconnect +To return to the room there are two ways: + +Connecting through the name server (Photon Cloud) and, when arriving at the master server, joining the room as new Photon Actor (this is referred to as the default way) +Or reconnecting and rejoining +LoadBalancingClient.ReconnectAndRejoin() +The method will try to directly connect to the game server and rejoin the room using cached authentication data, server address, room name, and so on. This information is cached on the LoadBalancingClient object. + +Rejoining a room will assign the same Photon Actor id to the client. + +Rejoining a room can also be performed after reconnecting or connecting to the master server: + +LoadBalancingClient.ReconnectToMaster() +// .. +public void IConnectionCallbacks.OnConnectedToMaster() { + _client.OpReJoinRoom(roomName); +} +The rejoin operation only works if the client has not left the room, yet. (see next section PlayerTTL) + +Caveat: Fast reconnect in Quantum version 2.0.x will only work when providing a local snapshot (StartParameters.InitialFrame and StartParameters.FrameData). + +var frameData = QuantumRunner.Default.Game.Frames.Verified.Serialize(DeterministicFrameSerializeMode.Blit); +var initialFrame = QuantumRunner.Default.Game.Frames.Verified.Number; +Back To Top + + +Requirements: PlayerTTL +Clients inside a room are generally active. They become inactive.. + +after a timeout of 10 seconds (by default) without answering the server +after calling LoadBalancingClient.Disonnect() +after calling LoadBalancingClient.OpLeaveRoom(true) +Now, there are two options: + +A) the room runs the player-left logic (PlayerTTL is 0, default) + +B) the player is marked inactive and kept in that state for PlayerTTL milliseconds before running the leave-room routine. The PlayerTTL value needs to be explicitly set in the RoomOptions when creating the room. Usually 20 seconds (20000 ms) is a good value to start with. + +Fast Reconnect will allow clients back into their room when they are still active (before the 10 second timeout which OpJoinRoom() does not allow) and inactive (during the PlayerTTL) timeout. + +When the client rejoined successfully IMatchmakingCallbacks.OnJoinedRoom() is called. + +The demo menu sample implements two options to check out (UIConnect.OnReconnectClicked()). + +When PlayerTTL > 0 do ReconnectAndRejoin() +When PlayerTTL == 0 do ReconnectToMaster() followed by OpJoinRom() +Back To Top + + +Requirements: RoomTTL (Waiting For Snapshots) +When the room detects that all clients are inactive it will close itself right away. To prevent that set RoomOptions.EmptyRoomTTL. This may be important when your room only has a small number of players and the probability that all of them have connection problems at the same time is given. Because there needs to be someone present to send a snapshot, this will only work reliably with custom server plugin and server-side snapshots. + +Consider this case: In a two player online game one player is reconnecting or late-joining and waiting for a snapshot while the other player starts to have connections problems. The snapshot is never send and the player is stuck waiting. + +The simple solution is to detect the game start timeout and disconnect the waiting player. This can be done by passing QuantumRunner.StartParameters.StartGameTimeoutInSeconds and checking QuantumRunner.HasGameStartTimedOut and finally implementing custom error handling (like giving player feedback and returning back to the lobby UI). As shown in UIGame.Update(). + +Back To Top + + +Requirements: Photon UserId +photon realtime: lobby and matchmaking | userids and friends + +In Photon, a player is identified using a unique UserID. To return to the room using rejoin the UserId has to be the same. It does not matter if the UserId originally has been set explicitly or by Photon. + +Once in the room, the UserId does not matter for Quantum as it uses a different id to identify players (see section Quantum CliendId). + +The Photon UserId can be.. + +set by the client when connecting (AuthenticationValues.UserId) +if left empty it is set by Photon +or set by an external authentication service +To complete the background info about Photon ids: + +Photon Actor Number (also referred to as actor id) identifies the player in his current room and is assigned per room and only valid in that context. Clients leaving and joining back a room will get a new actor id. A successful OpRejoinRoom() or ReconnectAndRejoin() will retain the actor id. Quantum provides a way to backtrace the actor ids and match them to a player Frame.PlayerToActorId(PlayerRef). But keep in mind, that they can change for player leaving and joining back (not rejoining). +Photon Nickname is a Photon client property that gets propagated in the rooms to know a bit more about the other clients. Has nothing to do with Quantum. +Back To Top + + +Possible Error: ReconnectAndRejoin Returns False +The current connection handler LoadBalancingClient is missing relevant data to perform a reconnect. Run your default connection sequence and try to join or rejoin the room in a regular way. + +Also see section Reconnecting After App Restart. + +Back To Top + + +Possible Error: PlayerTTL Ran Out +When rejoining past the PlayerTTL timeout ErrorCode.JoinFailedWithRejoinerNotFound is thrown. + +This also means that we are connected to the MasterServer and can join the room with OpJoinRoom(). + +public void (IMatchmakingCallbacks.)OnJoinRoomFailed(short returnCode, string message) { + switch (returnCode) { + case ErrorCode.JoinFailedWithRejoinerNotFound: + // Try to join the room without rejoining + _client.OpJoinRoom(new EnterRoomParams { RoomName = roomName }); + break; +} +Back To Top + + +Possible Error: Authentication Token Timeout +The authentication ticket expires after 1 hour. It will be refreshed automatically before running out in the course of the Quantum game session (photon realtime: encryption | token refresh). If your general game sessions are long and you want to support reconnecting players after around 20 minutes you need to handle this error. Resolution is to restart the default connection routine and try to join back into the room. + +public void OnDisconnected(DisconnectCause cause) { + switch (cause) { + case DisconnectCause.AuthenticationTicketExpired: + case DisconnectCause.InvalidAuthentication: + // Restart with your default connection sequence + break; +Back To Top + + +Possible Error: Connection Still Unavailable +Of course the connection can still be obstructed or other errors can occur. In each case a IConnectionCallbacks.OnDisconnected(DisconnectCause cause) is called. + +Back To Top + + +Reconnecting After App Restart +The LoadBalancingClient object caches data (token) relevant to the rejoining operation and that information can get lost when restarting the application. + +In that case the connection has to be restarted from scratch while reusing the same UserId, FixedRegion and AppVersion. When arriving at the master server either Rejoin() or Join() back into the room. + +Due to the lost connection cache, rejoining may fail with ErrorCode.JoinFailedFoundActiveJoiner because the server did not register the disconnect, yet (10 sec timeout). In this case retry until rejoining worked or another error occurs. + +The demo menu sample class ReconnectInformation demonstrates how relevant minimum viable reconnection data is saved and restored using Unity PlayerPrefs. + +The reconnection process is applied inside UIConnect.OnReconnectClicked() and UIReconnecting.OnConnectedToMaster(). + +Saving the Photon UserId to PlayerPrefs can of course be replaced by custom authentication. + +It is also possible to store and load a snapshot inside PlayerPrefs, which may be interesting for games with a very low player count. To store binary data in PlayerPrefs as a string use base64 en- and decoding. + +Back To Top + + +Different Master Server +ReconnectAndRejoin() and ReconnectToMaster() both prevent a fringe case that would let the client end up on a different master server than before when connecting back via the cloud. Reasons are: + +There are multiple cluster for one app +Master server has been replaced (rotated out) +Best region ping has a new result +Back To Top + + +Other Photon Realtime Topics +These features are not important for reconnection but are part of the demo menu sample so we might as well cover them here. + + +Best Region Summary +The class QuantumLoadBalancingClient wraps around Photon Realtime LoadBalancingClient. This is just for conveniently caching the best region ping results. After successfully connecting to the master server we store them into Unity PlayerPrefs and provide them for the next connection attempt via the AppSettings object to speed up connecting to the best region. + +The region ping is forced from time to time but to be certain players are not stuck with a bad ping result it could be smart to implement invalidation so players are not stuck with a bad or wrong result forever (e.g. if ping is above threshold clear BestRegionSummary every other day). Also it could happen that players travel to other parts of the world where a new ping would be required to find the closest region. + +Back To Top + + +AppVersion +The demo menu sample uses random matchmaking to match players. The AppVersion we supply with the AppSettings will group the player bases for the same AppId into separate groups. Players connecting to the same AppId and different AppVersions will not find each other at all. + +This is useful when running multiple game versions live as well as during development to prevent other clients (that have a different code base and would instantly desync the game) to join a game running by a developer. + +UIConnect.cs covers adding generic AppVersions and having a private key for uninterrupted testing. The private AppVersion string is generated for every workspace once (see PhotonPrivateAppVersion.cs). Every build that has been created from that workspace is able to match players with each other. + +Back To Top + + +Further Readings +photon realtime: analysing disconnects | quick rejoin (reconnectandrejoin) +photon realtime: known issues | mobile background apps +Photon Realtime: .NET Client API | LoadBalancingClient +Back To Top + + +Reconnecting Into A Running Quantum Game + +Quantum ClientId +The ClientId is a secret between the client and the server. Other clients never know it. It is passed when starting the QuantumRunner. + +public static QuantumRunner StartGame(String clientId, StartParameters param) +Independently of having joined as a new Photon room actor or having rejoined, reconnecting clients are identified by their ClientId and will be assigned to the same player index they previously had if the slot was not filled by another players in the meantime. In short: player must use the same ClientId when reconnecting. + +Quantum will not let a client start the session while another active player with the same ClientId is inside the room and waits for the disconnect timeout (10 seconds): + +DISCONNECTED: Error #5: Duplicate client id +This is why ReconnectAndRejoin() is required to recover from short term connection losses. + +Back To Top + + +Further Readings +quantum: player manual +Back To Top + + +Restarting The Quantum Session +After a disconnect the QuantumRunner and QuantumSession are not usable any more and must be destroyed and recreated. + +When the client either joined or re-joined back into the room that runs the Quantum game the QuantumRunner needs to be restarted. The simulation will be paused until the snapshot arrives from another client. Then will catch-up and sync to the most recent game time. + +Rough outline: + +detect disconnect, destroy QuantumRunner +reconnect and rejoin the room +re-start Quantum by calling QuantumRunner.Start() +To stop and destroy the QuantumSession call: + +QuantumRunner.ShutdownAll(true); +Only call this method with immediate:true when you are on the Unity main thread and never from inside a Quantum callback. Call with immediate:false or delay the call manually until it gets picked up from a Unity update call. + +The demo menu sample demonstrates how starting a new game, late-joining or re-joining a running game require very similar procedures. In UIRoom.OnShowScreen() we detect that that game has already been started by evaluating the room properties and then immediately start the game. + +Back To Top + + +EntityViews And UI +Late-joins and reconnection players put high demands on how flexible your game is constructed. It needs to support starting the game from any point in time and possibly reusing instantiated prefabs and UI as well as stopping and cleaning up the game at any possible moment. Side effects are high loading times, having unwanted VFX and animations in the new scene, being stuck in UI transitions, etc. + +If you want to keep the EntityViewUpdater and the EntityViews alive to reuse them, they need to manually be stopped from being updated, re-match them with the new QuantumGame instance, subscribe to the new callbacks, etc. + +On the other side the handling of Quantum is extremely simple: shutdown runner, start runner. + +Back To Top + + +Events +The client will not receive previous events that were raised before the player joined or rejoined. The game view should be able to fully initialize/reset itself by polling the current state of the simulation and use future events/polling to keep itself updated. + +Back To Top + + +SetPlayerData +Calling SetPlayerData() for reconnecting players is optional. It depends if your avatar setup logic in the simulation requires this. + +Back To Top + + +StartParameters.QuitBehaviour +When the Quantum shutdown sequence is being executed (QuantumRunner.ShutdownAll) the QuantumNetworkCommunicator class will optionally perform room leave operations or disconnect the LoadBalancing client. Set to QuitBehaviour.None on the QuantumRunner.StartParameters to handle it yourself. + +Back To Top + + +Late-Joining And Buddy-Snapshots +A Quantum game snapshot is a platform independent blob of data that contains the complete state of the game after a verified (all input has been received) tick. The Quantum simulation can be started from a snapshot and seamlessly continue from that state on. + +A client can create its own snapshot when the simulation is still running (local snapshot), the snapshot can be requested from other clients (buddy snapshot) or it can be send down from a custom server plugin that runs the simulation. + +Starting or restarting from snapshots is very handy and is provided turn-key by Quantum. Otherwise late-joining or reconnecting clients would have to start the game session from the very beginning and fast-forward through the input history send by the server which can render the client app useless until it caught up and also input history stored on the server is limited to ~10 minutes. + +The buddy snapshot process is started automatically when any client is starting its QuantumRunner (no matter if the client is starting the session for the first time, late-joining or reconnecting). The session will be put into paused mode DeterministicSession.IsPaused and a snapshot will be requested. Successful late joins will log the following messages: + +Waiting for snapshot. Clock paused. +Detected Resync. Verified tick: 6541 +Buddy snapshots are requested for clients connecting 5 seconds after the initial start. + +The server uses a load balancing mechanism to decide which client it will ask for a buddy snapshot to not overburden individual clients. + +Errors during the snapshot process will be forwarded to the client using the Disconnect message (e.g. the snapshot waiting state will time out after 15 seconds): + +Error #13: Snapshot request failed +Error #14: Snapshot request timed out +There are a few differences when starting from a snapshot during the game starting routines: + +Instead of CallbackGameStarted the callback CallbackGameResynced is executed. +System.OnInit() is called before the snapshot is received. +Back To Top + + +Local Snapshots +As an optional reconnection strategy a local snapshot of the last verified tick can be saved and used when starting the new QuantumRunner. This works best when the anticipated time offline is small. Local snapshots are generally more bandwidth friendly and faster. + +Guidelines + +Quantum enforces tight limitations around the local snapshot acceptance timing, because starting from a snapshot that is too old can degrade the user experience. + +By default local snapshots that are older than 10 seconds are not accepted by the server and instead a buddy-snapshot is requested. The process works transparently and from the clients perspective the only difference is the received snapshot age. + +For games that have a low user count (e.g. 1 vs 1) the chance that there is no other client online that can provide a buddy snapshot is high. These types of games usually require to work with the EmptyRoomTTL value and Quantum prolongs the local snapshot acceptance time to EmptyRoomTTL but to a maximum of two minutes. + +Workflow + +Detect disconnect +Take snapshot +Shutdown QuantumRunner +Fast Photon Reconnect +restart Quantum with snapshot +Read through reconnecting sequence in the demo menu. UIGame.OnDisconnect creates a snapshot when the disconnect reason is other than the client disconnected itself. It uses a timeout of 10 seconds after which the snapshot is not used and a new one is requested from another client/server because the catching up time would become too long. + +_frameSnapshot = QuantumRunner.Default.Game.Frames.Verified.Serialize(DeterministicFrameSerializeMode.Blit); +_frameSnapshotNumber = QuantumRunner.Default.Game.Frames.Verified.Number; +_frameSnapshotTimeout = Time.time + 10.0f; +During the reconnecting in UIRoom.StartQuantumGame() the snapshot info is set as StartParameter. + +var param = new QuantumRunner.StartParameters { + FrameData = IsRejoining ? UIGame.Instance?.FrameSnapshot : null, + InitialFrame = IsRejoining ? (UIGame.Instance?.FrameSnapshotNumber).Value : 0, + // ... +} +When successful Quantum will log this with the requested tick number: + +Resetting to tick 4316 +Detected Resync. Verified tick: 4316 \ No newline at end of file diff --git a/data/runtime.txt b/data/runtime.txt new file mode 100644 index 0000000000000000000000000000000000000000..359a440ccdd78407a8f28c4739db7b3d9e3aac5d --- /dev/null +++ b/data/runtime.txt @@ -0,0 +1,81 @@ +Quantum.RuntimeConfig Class Reference +In contrast to the SimulationConfig, which has only static configuration data, the RuntimeConfig holds information that can be different from game to game. More... + +Public Member Functions +String Dump () + Dump the content into a human readable form. More... + +void Serialize (BitStream stream) + Serializing the members to be send to the server plugin and other players. More... + +Static Public Member Functions +static RuntimeConfig FromByteArray (Byte[] data) + Deserialize the class from a byte array. More... + +static Byte[] ToByteArray (RuntimeConfig config) + Serialize the class into a byte array. More... + +Public Attributes +AssetRefMap Map + Asset reference of the Quantum map used with the upcoming game session. More... + +Int32 Seed + Seed to initialize the randomization session under Frame.RNG. More... + +AssetRefSimulationConfig SimulationConfig + Asset reference to the SimulationConfig used with the upcoming game session. More... + +Detailed Description +In contrast to the SimulationConfig, which has only static configuration data, the RuntimeConfig holds information that can be different from game to game. + +By default is defines for example what map to load and the random start seed. It is assembled from scratch each time starting a game. + +Developers can add custom data to quantum_code/quantum.state/RuntimeConfig.User.cs (don't forget to fill out the serialization methods). + +Like the DeterministicSessionConfig this config is distributed to every other client after the first player connected and joined the Quantum plugin. + +Member Function Documentation +◆ Serialize() +void Quantum.RuntimeConfig.Serialize ( BitStream stream ) +inline +Serializing the members to be send to the server plugin and other players. + +Parameters +stream Input output stream +◆ Dump() +String Quantum.RuntimeConfig.Dump ( ) +inline +Dump the content into a human readable form. + +Returns +String representation +◆ ToByteArray() +static Byte [] Quantum.RuntimeConfig.ToByteArray ( RuntimeConfig config ) +inlinestatic +Serialize the class into a byte array. + +Parameters +config Config to serialized +Returns +Byte array +◆ FromByteArray() +static RuntimeConfig Quantum.RuntimeConfig.FromByteArray ( Byte[] data ) +inlinestatic +Deserialize the class from a byte array. + +Parameters +data Config class in byte array form +Returns +New instance of the deserialized class +Member Data Documentation +◆ Seed +Int32 Quantum.RuntimeConfig.Seed +Seed to initialize the randomization session under Frame.RNG. + +◆ Map +AssetRefMap Quantum.RuntimeConfig.Map +Asset reference of the Quantum map used with the upcoming game session. + +◆ SimulationConfig +AssetRefSimulationConfig Quantum.RuntimeConfig.SimulationConfig +Asset reference to the SimulationConfig used with the upcoming game session. \ No newline at end of file diff --git a/data/settings.txt b/data/settings.txt new file mode 100644 index 0000000000000000000000000000000000000000..571a7778abfe1c905297dac59fa39053d7181b11 --- /dev/null +++ b/data/settings.txt @@ -0,0 +1,51 @@ +Overview +The Physics settings can be edited in the Map Asset associated with a Scene in its Map Data script, and in the Simulation Config Asset linked in the Quantum Runner script. The settings in the Map are specific to a given scene, while the Simulation Config can be shared among multiple scenes. + +Back To Top + + +Map Data +The scene's playable area related settings can be found in the scene's MapData script or the Map Asset slotted in the Asset field. + +Adjust the World Size for your Playable Area +The Physics settings as seen in the MapData script in the Scene. +Aside from World Size, tweaking these settings should only be a concern if the physics simulation is a bottleneck in your game. + +Setting Description +World Size The physics scene size in the bucketing axis. The broad phase is clamped by a bounding box of all physics entries between -WorldSize/2 to WorldSize/2. It is therefore crucial to ensure the world is big enough to encompass all entities. If an entity is outside the world, it will cost you performance as it is added to either the first or last bucket. Everything outside the bounding box is considered to be at the world's edge, from the physics engine perspective, which will result in false collision candidates. +In the non bucketing axis, the physics world is only limited by the value range of FP.UsableMin to FP.UsableMax. +Buckets Count The amount of buckets used in the broad phase, which are resolved in parallel. Use a reasonable amount according to how many physics entries (colliders) you have. Too many buckets and the handling overhead increases without any performance gain because there are only few entries in each one; too few buckets and there will be an excessive amount of entries in each, slowing down the broad phase performance. +Buckets Subdivisions Regular queries (overlaps and raycasts) use a stabbing approach for checking as few entries as possible in the buckets subdivisions. Tweak the number in accordance with the expected amount of entries and regular queries you perform. Too many subdivisions will add overhead without performance, while too few will result in queries taking longer to resolve, because they will have to check too many entries. +NOTE on Buckets Count & Buckets Subdivisions +The default buckets count and bucket subdivisions values (16 buckets with 8 subdivisions) are usually good for up to 1~2K entries. You should thus not have to worry about tweaking them unless the physics are the bottleneck of your game. In that case, use the Task Profiler for evaluating the performance and tweak the values based on the findings (broad phase and regular queries resolution respectively). +Bucketing Axis Physics entries are put into buckets according to their position in the bucketing axis. +Sorting Axis The queries in a bucket are sorted according to their position in the sorting axis. +NOTE on Bucketing Axis & Sorting Axis +The Y-Axis represents the vertical axis of the physics simulation. In 2D this is equal to the Y-Axis, whereas in 3D the Y-Axis is mapped to the Z-Axis as the 3D space partitioning is performed on the XZ-Plane. +Choose these a bucketing and sorting axis based on how the entries are spread out in the world. Selecting a different axis for bucketing and sorting (e.g. X-Y or Y-X) is good for uniformly spread entries across that plane. If entries are concentrated in one axis, consider using the same axis for both bucketing and sorting. +Triangle Mesh Cell Size Defines the size of the cells into which the 3D triangle soup is divided. This number should be adapted based on how dense the meshes' triangles density to get a reasonable amount of triangles per cell. +For better visualization enable related fields in the QuantumEditorSettings asset's Collider gizmos section. +This will affect the performance of both the broad phase and regular queries. Use the Task Profiler to analyse the performance and find the most suitable number for your game. +Back To Top + + +Simulation Config +The SimulationConfig data asset contains an extensive set of settings for the physics engines: + +Physics Settings on SimulationConfig +SimulationConfig Asset. +Layers and the corresponding Layer Collision Matrix can be imported from Unity ones. Once imported, the Collision Matrix can be edited directly in the settings. + +Back To Top + + +Optimization Tips +In this section we are covering some general considerations to take when optimizing your physics settings for enhanced performance: + +Collision Matrix, make sure to only enable collisions between layers that actually require to be checked against each other; +Angular Velocity (physics controlled rotation), disabling this option leads to faster and more stable physics simulation; +Kinematic Entities, use kinematic entities rather than dynamic entities whenever possible. Kinematics do not check for collisions against each other, unless one of them is a trigger kinematic. +Raycasts, use reasonable distances for rays to prevent them from becoming a bottleneck. +PhysicsBody, enabling resting bodies in the settings allows resting entities to be excluded from collisions checks and reduce the load on the collision detection system. Resting bodies can be awaken again by either another moving body, or by code. +Thread Count, tweaking this options allows to raise the amount of threads available to the Quantum Simulation at runtime. +Profiler, run it on your code systems before and during performance tweaks. Bottlenecks are often tied to custom code rather than the physics engine. Furthermore, the profiler helps to identify which settings work best under the game specific load. diff --git a/data/shape config.txt b/data/shape config.txt new file mode 100644 index 0000000000000000000000000000000000000000..3219e8e3b223272eca54cc5a4db67faf1fb69e2e --- /dev/null +++ b/data/shape config.txt @@ -0,0 +1,208 @@ +Introduction +The shape config holds information on a shape. It can be used to easily expose configuration options in the editor and streamline shape initialization in code. ShapeConfigs exist for both 2D and 3D - Shape2DConfig and Shape3DConfig respectively. + +Currently, ShapeConfigs targeted for use with dynamic entities; as such, the shape types supported by it are: + +// in 2D + +Circle +Box +Polygon +Edge +Compound (a mixture of all these) +// in 3D + +Sphere +Box +Compound (i.e. a mixture of Spheres, Boxes, and/or Compound) +All of the aforementioned shapes are compatible with ShapeOverlaps and PhysicsColliders. + +Back To Top + + +Exposing ShapeConfig To The Editor +Including either a Shape2DConfig or Shape3DConfig in your custom asset will automatically expose it in the Editor. + +namespace Quantum +{ + public unsafe partial class WeaponSpec + { + public Shape3DConfig AttackShape; + public LayerMask AttackLayers; + public FP Damage; + public FP KnockbackForce; + } +} +The asset resulting from the snippet above will offer the following exposing all ShapeConfigs options in the inspector: + +ShapeConfig Setting of the example asset as shown in the Unity Editor +ShapeConfig Setting of the WeaponSpec example asset as shown in the Unity Editor. +You may have already come that in the PhysicsCollider section called Shape 2D/3D of Entity Prototype script. + +Back To Top + + +Creating/Using A Shape From ShapeConfig +When using a ShapeConfig, you can simply call its CreateShape method. This will automatically process the information held in the ShapeConfig asset, create the appropriate shape and its parameter with the data found in the config. + +private static void Attack(in Frame f, in EntityRef entity) +{ + // A melee attack performed by using an OverlapShape on the attack area. + + var transform = f.Unsafe.GetPointer(entity); + var weapon = f.Unsafe.GetPointer(entity); + var weaponSpec = f.FindAsset(weapon->WeaponSpec.Id); + + var hits = f.Physics3D.OverlapShape( + transform->Position, + transform->Rotation, + weaponSpec.AttackShape.CreateShape(f), + weaponSpec.AttackLayers); + + // Game logic iterating over the hits. +} +The same can be done when initializing the Shape of a PhysicsCollider. + +Back To Top + + +Compound Shape +A Compound Shape is a shape made of several other shapes. The shapes that can be used to create a compound shape are the ones listed in the introduction section. + +PhysicsColliders and ShapeOverlaps are fully compatible with compound shapes. + +As of now, Quantum offers persistent compound shapes, i.e. a shape pointing to a buffer of other shapes in the heap. This buffer will persist between frames until it is manually disposed. + +Back To Top + + +Create New Compound Shape +A compound shape can be create from a ShapeConfig by simply calling the CreateShape method, or manually via the Shape.CreatePersistentCompound, and later dispose of it calling FreePersistent on. Here is an example of how the lifetime can be managed, the same applies to 3D Shapes: + +// creating a persistent compound. This does not allocate memory until you actually add shapes +var compoundShape = Shape2D.CreatePersistentCompound(); + +// adding shapes to a compound (shape1 and 2 can be of any type) +compoundShape.Compound.AddShape(f, shape1); +compoundShape.Compound.AddShape(f, shape2); + +(...) // Game logic + +// this compound persists until you manually dispose it +compoundShape.Compound.FreePersistent(f); +The API also offers methods such as RemoveShapes, GetShapes, and FreePersistent; check the API documentation in the SDK's docs folder for more information on those and other methods. + +Back To Top + + +CopyFrom An Existing Compound Shape +You can also create a new compound shape by copying an existing one. + +// Using the exising compoundShape from the example above. + +var newCompoundShape = Shape2D.CreatePersistentCompound(); +compoundShape.Compound.CopyFrom(f, ref newCompoundShape); +Creating a new compound shape also means a new buffer. This will need to be freed by the developer manually like any other persistent compound shape. + +Back To Top + + +Accesing Individual Shapes +An example of how to iterate through the Shapes is to use the GetShapes method to get the pointers buffer, and use a simple for loop, where the integer index can be used to access all Shape* contained in the compound. +Do not surprass the count returned by the method as it is the boundary where the shape pointers are contained in memory. + +if (shape->Compound.GetShapes(frame, out Shape3D* shapesBuffer, out int count)) +{ + for (var i = 0; i < count; i++) + { + Shape3D* currentShape = shapes + i; + // do something with the shape + } +} +Back To Top + + +Compound Collider +A compound collider is a regular collider with a shape of type compound. + + +Create In Editor +In the editor you can find the options to create a compound collider in the Entity Component Physics Collider 2D/3D and the Entity Prototype script's respective section. + +ShapeConfig for a Compound Collider in the Entity Prototype script as shown in the Unity Editor +Creating a Compound Collider (Sphere + Box) via the Entity Prototype script in the Unity Editor. +When you create a collider prototype with a compound shape memory management is handled for you, i.e. the collider will have and manage its compound shape and you won't have to manually dispose anything. + +Back To Top + + +Create In Code +When creating a collider in code, simply pass a compound shape into its Create() factory method. Once the compound shape has been created as shown in the code snippet from the previous section, you can create a collider by replacing (...) with this: + +var collider = PhysicsCollider2D.Create(f, compoundShape); +f.Set(entity, collider); +In the code snippet provided above collider.Shape and compoundShape point to different buffers in the heap. If you are done using the compound shape - i.e. it was only needed for creating the collider - you can dispose it right after that. The collider will dispose its own copy in memory when destroyed/removed. + +Back To Top + + +Important Note About Memory +A collider only creates a copy of the compound shape buffer if it is used as part of its factory method Create(). + +var compoundShape = Shape2D.CreatePersistentCompound(); +compoundShape.Compound.AddShape(f, shape1); +compoundShape.Compound.AddShape(f, shape2); + +// collider1 and collider2 each create a copy of the compoundShape buffer. +// collider1 and collider2 will each dispose of their copy on destroy/remove. +var collider1 = PhysicsCollider2D.Create(f, compoundShape); +f.Set(entity1, collider1); + +var collider2 = PhysicsCollider2D.Create(f, compoundShape); +f.Set(entity2, collider2); + +// Here we dispose of the compoundShape's buffer as it is no longer needed +compoundShape.Compound.FreePersistent(f); +In contrast, should you create a collider with a regular shape, and later set its collider.Shape = someCompound it will not create a copy of the buffer, i.e. collider.Shape and someCompound will point to the same buffer. Doing this can be dangerous if you happened to have multiple compound colliders and/or compound shapes pointing towards the same buffer. If one disposes of it, it will effectively break the others' reference to the buffer. + +var compoundShape = Shape2D.CreatePersistentCompound(); +compoundShape.Compound.AddShape(f, shape1); +compoundShape.Compound.AddShape(f, shape2); + +var collider1 = PhysicsCollider2D.Create(f, default(Shape2D)); +collider1.Shape = compoundShape; +f.Set(entity1, collider1); + +var collider2 = PhysicsCollider2D.Create(f, Shape2D.CreateCircle(1)); +collider2.Shape = compoundShape; +f.Set(entity2, collider2); + +// collider1.Shape, collider2.Shape and compoundShape all point to the same buffer +// dispose of compoundShape here will break collider1 and collider2 +compoundShape.Compound.FreePersistent(f); +However, if you only do it conscientiously this will assign the shape and its memory management to the collider in question thus simplifying memory management by relinquishing the responsibilty to the collider which will dispose of it when destroyed/removed. + +var compoundShape = Shape2D.CreatePersistentCompound(); +compoundShape.Compound.AddShape(f, shape1); +compoundShape.Compound.AddShape(f, shape2); + +var collider1 = PhysicsCollider2D.Create(f, Shape2D.CreateCircle(1)); +collider1.Shape = compoundShape; +f.Set(entity1, collider1); + +// In this instance we do not need to dispose of the compoundShape buffer because +// collider1 already points to it and will take care of it on destroy/remove. +Back To Top + + +Compound Shape Query +Compound shape queries are fully supported for both broadphase and regular queries. The behaviour and performance impact is the same as performing multiple queries, except the results will be returned in the same HitCollection; + +Back To Top + + +Nested Compound Shape +Nested compound shapes are supported by the physics engines though with two limitations: + +a shape can only hold one reference to the same buffer in the heap in its hierarchy. Add an already referenced buffer will throw an error in debug mode. This is to avoid issues with cyclic references and invalid pointers on disposal. +nested compound shapes are not supported in the editor (a warning message will be shown). This is due to the Unity serializer limitations which requires a more intricate structure and drawer for the shape config. \ No newline at end of file diff --git a/data/snip.txt b/data/snip.txt new file mode 100644 index 0000000000000000000000000000000000000000..5c21ed025bb069673891611379d483325655b591 --- /dev/null +++ b/data/snip.txt @@ -0,0 +1,518 @@ +HTTP Requests + +CustomQuantumPlugin +Defer plugin callbacks like OnCreateGame() to retrieve any specific room configurations from a trusted backend and block the room creation momentarily. The same is possible for call room callbacks with an info object (OnJoin(),..). + +public override void OnCreateGame(ICreateGameCallInfo info) { + var request = new HttpRequest() { Url = "http://microsoft.com", Async = false, Callback = OnCreateGameContinue }; + PluginHost.HttpRequest(request, info); + + // Do not call base.OnCreateGame() to prevent the continuation +} + +private void OnCreateGameContinue(IHttpResponse response, object userState) { + // Complete the OnCreateGame() call + base.OnCreateGame((ICreateGameCallInfo)response.CallInfo); +} +Back To Top + + +CustomQuantumServer +Defer Quantum server callbacks to overwrite RuntimePlayer with data fetched from a trusted backend. + +IPluginHost.HttpRequest(request, info) can be used with info = null. + +Request has to be Async = true. + +public override bool OnDeterministicPlayerDataSet(DeterministicPluginClient client, SetPlayerData playerData) { + // Use client.ClientId as unique client id (UserId) + var request = new HttpRequest() { Url = "http://microsoft.com", Async = true, Callback = OnDeterministicPlayerDataSetContinue, UserState = playerData.Index }; + ((DeterministicPlugin)PluginHost).PluginHost.HttpRequest(request, null); + + // Return false to not conntinue with SetPlayerData request + return false; +} + +private void OnDeterministicPlayerDataSetContinue(IHttpResponse response, object userState) { + private void OnDeterministicPlayerDataSetContinue(IHttpResponse response, object userState) { + // Reponse sends player data in json for example: deserialize json into RuntimePlayer + var runtimePlayer = new RuntimePlayer(); + + SetPlayerData data = new SetPlayerData { + Data = RuntimePlayer.ToByteArray(runtimePlayer), + Index = (int)userState + }; + + // continue the interupted flow of Photon.Deterministic.Server.DeterministicServer.OnDeterministicPlayerDataSet + SetDeterministicPlayerData(data); + } +} +Back To Top + + +Save Replay / Input History Snippet +Input history is an array (number of ticks) of DeterministicTickInputSet objects which in turn store the input for each player: + +public struct DeterministicTickInputSet { + public int Tick; + public DeterministicTickInput[] Inputs; +} +Save the input inside the InputProvider class during the OnDeterministicInputConfirmed() callback. This is when the input for a player has been confirmed. Or create a custom similar data structure. + +public override void OnDeterministicSessionConfig(DeterministicPluginClient client, SessionConfig configData) +{ + _config = configData.Config; +} +public override void OnDeterministicStartSession() { + _inputProvider = new InputProvider(_config); +} +public override void OnDeterministicInputConfirmed(DeterministicPluginClient client, int tick, int playerIndex, DeterministicTickInput input) { + _inputProvider.InjectInput(input, true); +} +Use the ReplayFile data structure to create a complete replay with required config files. The serializer is a QuantumJsonSerializer that outputs JSON. + +private void SaveReplayToFile(int verifiedFrame) { + var replayFile = new ReplayFile { + DeterministicConfig = container.DeterministicConfig, + RuntimeConfig = container.RuntimeConfig, + InputHistory = _inputProvider.ExportToList(verifiedFrame), + Length = verifiedFrame + }; + + + var filepath = Path.Combine(PluginLocation, "replay.json"); + File.WriteAllBytes(filepath, _serializer.SerializeReplay(replayFile)); +} +Save the ReplayFile without knowing the highest verified tick. + +private void SaveReplayToFile() { + // This will not cut out incomplete input in the end, but we should be able to live with it + var inputSets = _inputProvider.ExportToList(int.MaxValue); + + // Find out what the highest verified tick that has a complete input set (for all players) + int maxVerifiedTick = 0; + for (int i = inputSets.Length - 1; i >= 0; i--) { + if (inputSets[i].IsComplete()) { + maxVerifiedTick = inputSets[i].Tick; + break; + } + } + + var replayFile = new ReplayFile { + DeterministicConfig = container.DeterministicConfig, + RuntimeConfig = container.RuntimeConfig, + InputHistory = inputSets, + Length = maxVerifiedTick + }; + + + var filepath = Path.Combine(PluginLocation, "replay.json"); + File.WriteAllBytes(filepath, _serializer.SerializeReplay(replayFile)); +} +Back To Top + + +Server Command Snippet +A snippet showing how to intercept commands sent by clients, possibly reject them and send commands from the server itself. + +private DeterministicCommandSerializer _cmdSerializer; + +public override bool OnDeterministicCommand(DeterministicPluginClient client, Command cmd) { + if (_cmdSerializer == null) { + _cmdSerializer = new DeterministicCommandSerializer(); + _cmdSerializer.RegisterFactories(DeterministicCommandSetup.GetCommandFactories(runtimeConfig, null)); + + _cmdSerializer.CommandSerializerStreamRead.Reading = true; + _cmdSerializer.CommandSerializerStreamWrite.Writing = true; + } + + var stream = _cmdSerializer.CommandSerializerStreamRead; + + stream.SetBuffer(cmd.Data); + + if (_cmdSerializer.ReadNext(stream, out var command)) { + // handle DeterministicCommand + + // return false if a command should be rejected from the (or any) client + if (command is TestCommand testCmd) { + return false; + } + } + + return true; +} + +public void SendDeterministicCommand(DeterministicCommand cmd) { + if (_cmdSerializer == null) { + _cmdSerializer = new DeterministicCommandSerializer(); + _cmdSerializer.RegisterFactories(DeterministicCommandSetup.GetCommandFactories(runtimeConfig, null)); + + _cmdSerializer.CommandSerializerStreamRead.Reading = true; + _cmdSerializer.CommandSerializerStreamWrite.Writing = true; + } + + var stream = _cmdSerializer.CommandSerializerStreamWrite; + + stream.Reset(stream.Capacity); + + if (_cmdSerializer.PackNext(stream, cmd)) { + + SendDeterministicCommand(new Command { + Index = 0, + Data = stream.ToArray(), + }); + + // optional: pool byte arrays and use them instead of allocating with ToArray() + // Buffer.BlockCopy(stream.Data, stream.Offset, pooledByteArray, 0, stream.BytesRequired); + } +} +Back To Top + + +Subscribing To Quantum Events And Callbacks +It is possible to, from the custom plugin, react to Quantum Events (defined and triggered in the simulation code) and Quantum Callbacks (defined and triggered from Unity). + +That can achieved that by adding events and callbacks dispatchers to the start parameters in the OnDeterministicStartSession() callback on the custom plugin: + +// given a Quantum Event named "Foo", defined in the Quantum simulation code + +public override void OnDeterministicStartSession() +{ + // Create an instance of an EventDispacther + var events = new EventDispatcher(); + // Subscribe to the Quantum event + events.Subscribe(this, EventReaction); + // Insert it in the params + startParams.EventDispatcher = events; + + // Same goes for the CallbackDispatcher + var callbacks = new CallbackDispatcher(); + callbacks.Subscribe(this, c => Log.Warn(c.Game.Session.FrameVerified.Number)); + startParams.CallbackDispatcher = callbacks; + + // the dispatchers are then injected within the paramrs object + container.StartReplay(startParams, inputProvider, "server", false, taskRunner: taskRunner); +} + +// This is just a demonstration of the event callback reaction +// It works exactly the same as reacting to events from Unity code +private void EventReaction(EventFoo e) { } +Back To Top + + +Majority Vote +Clients upload their game result to the server, the server waits until a majority vote can be issued, computes one result to publish. +A custom c# class is used to represent the result. In this example the server plugin only operates on the binary data, which is enough to check for equal results. But when the information is forwarded to a custom backend (not implemented) if the backend expects other data format then the sever plugin requires the deserialization code before sending it (reference to the game.dll for example). +Back To Top + + +Changes To Custom Quantum Plugin Class +Forces an evaluation right before the room is closed +Overrides OnRaiseEvent() to filter out the custom message, always cancels the message so it's not forwarded to other clients. +using Photon.Deterministic; +using Photon.Deterministic.Server.Interface; +using Photon.Hive.Plugin; + +namespace Quantum { + public class CustomQuantumPlugin : DeterministicPlugin { + protected CustomQuantumServer _server; + + public CustomQuantumPlugin(IServer server) : base(server) { + Assert.Check(server is CustomQuantumServer); + _server = (CustomQuantumServer)server; + } + + public override void OnCloseGame(ICloseGameCallInfo info) { + EvaluateMajorityVote(true); + _majorityVote?.Dispose(); + _majorityVote = null; + _server.Dispose(); + base.OnCloseGame(info); + } + + private MajorityVote _majorityVote = new MajorityVote(2); + + private void EvaluateMajorityVote(bool force) { + if (_majorityVote != null) { + if (force || _majorityVote.IsReady || _majorityVote.IsWaitingTimeOver) { + if (_majorityVote.Evaluate(out var results)) { + // Send data somewhere + //results[0].Data + Log.Warn($"Game result accepted with {results[0].Count}"); + _majorityVote.Dispose(); + _majorityVote = null; + } + } + } + } + + public override void OnRaiseEvent(IRaiseEventCallInfo info) { + if (info.Request.EvCode == 41) { + // Cancel the message right away, it should not be send to anyone else + info.Cancel(); + + var client = _server.GetClientForActor(info.ActorNr); + if (client == null) { + // Dismiss the message when the client has already left + return; + } + + if (info.Request.Data == null) { + // Client send no data, disconnect + _server.DisconnectClient(client, "Operation Failed"); + return; + } + + if (_majorityVote == null) { + // Vote is over + return; + } + + _majorityVote.AddVote(client.ClientId, (byte[])info.Request.Data); + EvaluateMajorityVote(false); + + // Don't process message any further + return; + } + + base.OnRaiseEvent(info); + } + } +} +Back To Top + + +Majority Vote Class +The timings around min required votes and wait time settings are depending on the actual game: How many players? Does the game have teams? Etc. + +There probably is something faster than StructuralComparisons.StructuralEqualityComparer. Although Linq offers nice collection tools it not be used on server code. + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Security.Cryptography; + +namespace Quantum { + /// + /// Trying to not use Linq for performance. + /// Only compares binary data, serialzed by the client. + /// The final result should be send to backend to deserialize or deserialize on custom plugin with correct references. + /// + public class MajorityVote : IDisposable { + private bool _startWaitTimeSet; + private DateTime _startWaitTime; + private double _minWaitTimeSec; + private double _maxWaitTime; + private int _minVotesRequired; + private List _clientResults; + private HashSet _clientResultsHashset; + private MD5CryptoServiceProvider _hashProvider; + + /// + /// There is at least one result and at least MinVotesRequired. + /// The MinWaitingTime has passed. + /// + public bool IsReady { + get { + return + _clientResults.Count >= _minVotesRequired && + (DateTime.Now - _startWaitTime).TotalSeconds >= _minWaitTimeSec; + } + } + + /// + /// The MaxWaitingTime has passed a result be tried to evaluate now. + /// + public bool IsWaitingTimeOver { + get { + return + _maxWaitTime > 0 && + (DateTime.Now - _startWaitTime).TotalSeconds >= _maxWaitTime; + } + } + + /// + /// Number of client votes, indentified by their ClientId. + /// + public int Count => _clientResults.Count; + + /// + /// Create a voting machine. + /// + /// The minimal votes required to make IsReady return true + /// The min wait tim in seconds to make IsReady return true + /// The max wait time in seconds to make IsWaitingTimeOver return true, 0 = undefinately + public MajorityVote(int minVotesRequired, double minWaitTimeSec = 0, double maxWaitTime = 0) { + _minVotesRequired = Math.Max(minVotesRequired, 1); + _minWaitTimeSec = minWaitTimeSec; + _maxWaitTime = maxWaitTime; + _clientResults = new List(); + _clientResultsHashset = new HashSet(); + _hashProvider = new MD5CryptoServiceProvider(); + } + + /// + /// Disposes the hash provider object and clears internal lists. + /// + public void Dispose() { + _clientResults?.Clear(); + _clientResults = null; + + _clientResultsHashset?.Clear(); + _clientResultsHashset = null; + + _hashProvider?.Dispose(); + _hashProvider = null; + } + + /// + /// Add a vote for a ClientId. If a client already passed the vote subsequent times are ignored. + /// + /// The clients id + /// The result as byte[] array + public void AddVote(string clientId, byte[] data) { + if (_clientResultsHashset.Contains(clientId) == false) { + var hash = _hashProvider.ComputeHash(data); + var result = new ClientResult { ClientId = clientId, Result = data, Hash = hash }; + _clientResults.Add(result); + _clientResultsHashset.Add(clientId); + } + + if (_startWaitTimeSet == false && Count >= 2) { + // Only start the waitime when two votes have been cast. Two votes because one would be too easy to missuse. + _startWaitTime = DateTime.Now; + _startWaitTimeSet = true; + } + } + + /// + /// Run majority vote for the votes that have been cast. + /// + /// Summary of the results, sorted by count + /// True if there has been consensus. + public bool Evaluate(out List finalResults) { + var map = new Dictionary(ByteArrayComparer.Default); + + var majority = 0; + if (_clientResults.Count % 2 == 0) { + majority = _clientResults.Count / 2 + 1; + } + else { + majority = (int)Math.Ceiling(_clientResults.Count / (double)2); + } + + // Compare each result hash with eath other + for (int i = 0; i < _clientResults.Count; i++) { + var vote = default(FinalResult); + if (map.TryGetValue(_clientResults[i].Hash, out vote) == false) { + vote = new FinalResult { ClientIds = new List(), Result = _clientResults[i].Result }; + map.Add(_clientResults[i].Hash, vote); + } + + vote.ClientIds.Add(_clientResults[i].ClientId); + vote.Count++; + } + + // Sort + finalResults = new List(); + foreach (var v in map) { + finalResults.Add(v.Value); + } + finalResults.Sort(FinalResult.CompareByCount); + + if (finalResults.Count > 0 && finalResults[0].Count >= majority) { + return true; + } + + return false; + } + + public class FinalResult { + public byte[] Result; + public int Count; + public List ClientIds; + + public static int CompareByCount(FinalResult a, FinalResult b) { + return a.Count.CompareTo(b.Count); + } + } + + private class ClientResult { + public string ClientId; + public byte[] Result; + public byte[] Hash; + } + + private class ByteArrayComparer : IEqualityComparer { + private static ByteArrayComparer _default; + + public static ByteArrayComparer Default { + get { + if (_default == null) { + _default = new ByteArrayComparer(); + } + + return _default; + } + } + + public bool Equals(byte[] a, byte[] b) { + return StructuralComparisons.StructuralEqualityComparer.Equals(a, b); + } + + public int GetHashCode(byte[] obj) { + return StructuralComparisons.StructuralEqualityComparer.GetHashCode(obj); + } + } + } +} +Back To Top + + +Unity Test Code +Sending to the plugin using OpRaiseEvent() +Uses ByteArraySlice to send binary data to reduce further allocations +The result should be taken from a verified frame and it should be the same on each (untampered) client +using ExitGames.Client.Photon; +using Photon.Realtime; +using Quantum.Demo; +using UnityEngine; + +public class SendGameResult : MonoBehaviour { + private readonly ByteArraySlice _sendSlice = new ByteArraySlice(); + + private class GameResult { + public struct Place { + public string PlayerId; + public int Points; + } + public Place[] Ranking; + + public void Serialize(Photon.Deterministic.BitStream stream) { + foreach (var r in Ranking) { + stream.WriteString(r.PlayerId); + stream.WriteInt(r.Points); + } + } + } + + private void Update() { + // Use ByteSliceArray for optimization (non-alloc) + // Gather results from a verified frame only (otherwise prediciton can differ) + if (Input.GetKeyDown(KeyCode.Space)) { + var gameResult = new GameResult { Ranking = new GameResult.Place[] { + new GameResult.Place { PlayerId = "a", Points = 1 }, + new GameResult.Place { PlayerId = "b", Points = 2 } } + }; + + var stream = new Photon.Deterministic.BitStream(new byte[100 * 1024]); + gameResult.Serialize(stream); + + _sendSlice.Buffer = stream.Data; + _sendSlice.Count = stream.BytesRequired; + _sendSlice.Offset = 0; + + UIMain.Client.OpRaiseEvent(41, _sendSlice, RaiseEventOptions.Default, SendOptions.SendReliable); + } + } +} \ No newline at end of file diff --git a/data/static colliders.txt b/data/static colliders.txt new file mode 100644 index 0000000000000000000000000000000000000000..75564d0033d4f0a209a48adcb273c11c9fb55446 --- /dev/null +++ b/data/static colliders.txt @@ -0,0 +1,172 @@ +Introduction +Adding static colliders to a Scene takes three simple steps: + +Attach a Quantum Static Collider Script to a Unity GameObject; +Edit the properties to resemble the geometry you want for the static obstacle in the scene; and, +Bake the Scene via the MapData Script. +Step 1 & 2 - Add Static Colliders to GameObject in Unity Scene and adjust Settings +Step 1 & 2 - Add Static Colliders to GameObject in Unity Scene and adjust Settings. +Step 3 - Baking the Map Saves the Scene Colliders as a Quantum Asset (Map) +Step 3 - Baking the Map Saves the Scene Colliders as a Quantum Asset (Map). +Back To Top + + +Unity Collider As A Source +The Quantum static collider can also mirror the properties from a Unity collider. To do that, simply drag and drop the desired collider into the Source Collider field on the Quantum Static Collider component: + +unity-collider-source +Back To Top + + +Shapes +The 2D Physics shapes are: + +Circle +Box +Polygon +N.B.: All of them have a Height field, which allows the creation of 2.5D shapes. +The 3D Physics shapes are: + +Sphere +Box +Mesh +Back To Top + + +Configuration +Static colliders can be fitted with a PhysicsMaterial and a User Asset. The latter is available in the simulation via the collision callbacks. + + +Smooth Sphere Mesh Collider +A Static Mesh Collider 3D has an option called Smooth Sphere Mesh Collision. When toggling this option on, the physics solver will resolve sphere-mesh collisions as if the mesh was a regular flat and smooth plane. This prevents adding spin to a sphere colliding with triangle edges. + +Static Smooth Mesh Collider +Static Mesh Collider. +If the `Static Mesh Collider 3D` is marked with the `Smooth Sphere Mesh Collision` option but the mesh is not completely flat, it might result in undesirable collision responses. +Back To Top + + +Enable / Disable At Runtime +This section will present several approaches to enable and disable static colliders at runtime in simulation. + + +Physics Engine +It is possible to toggle static colliders on and off at runtime directly in the Physics Engine. + +For a static collider to be toggle-able, its Mode needs to be set at edit-time (Unity) and baked into the Map asset. The mode can be set to: + +Immutable (default): the collider cannot be enabled or disabled at runtime. +Toggleable Start On: the collider can be toggled at runtime and starts enabled. +Toggleable Start Off: the collider can be toggled at runtime and starts disabled. +Enable toggle on 3D Static Mesh Colliders +Enable toggle on a 3D Static Mesh Collider component. +Once a static collider has been marked as toggle-able and baked, it becomes possible to enable and disable the collider at runtime from the simulation (Quantum) using SetStaticColliderEnabled() in Frame.Physics3D and Frame.Physics2D for 3D and 2D static colliders respectively. + +The index to be passed as a parameter is the collider's index in the frame.Map.StaticColliders array. Collision callbacks return the index (ColliderIndex) of a static collider as part of the StaticData in their TriggerInfo or CollisionInfo. + +IMPORTANT: A disabled static mesh collider is ignored by physics queries and will not trigger collision signals. + +Back To Top + + +Manual Tracking +Although static colliders can be enabled / disabled at the physics engine level, there are various approaches to do the same manually. + + +Keep A Global Bitset For The State +If the only purpose is to keep track of which static colliders to ignore or take into account in a collision callback, the most convenient approach is to define a global BitSet which is of the same length or bigger than the frame.Map.StaticColliders array. This can be done as part of the Frame object or as a singleton component. + +singleton component StaticColliderState { + bitset[256] colliders; +} +This allows to use the bitset instance with the collider indices to set its bits. + +// loops through the bitset to initialize all bits as "On" to mark all colliders as active +public override void OnInit(Frame f) +{ + var collidersState = f.Unsafe.GetPointerSingleton(); + for (int i = 0; i < collidersState->colliders.Length; i++) { + collidersState->colliders.Set(i); + } +} + +public void OnTrigger3D(Frame frame, TriggerInfo3D info) +{ + if (info.IsStatic == false) return; + + // Use a custom asset slotted in the UserAsset field to identify toggleable colliders + var colliderAsset = frame.FindAsset(info.StaticData.Asset); + if (colliderAsset == null) return; + + var collidersState = frame.Unsafe.GetPointerSingleton(); + collidersState->colliders.Clear(info.StaticData.ColliderIndex); +} +The values can then be read using IsSet() and used to check whether a collision signal should be handled or ignored. This is particularly useful when dealing with static interactable objects, environmental barriers or implementing IKCCCallbacks3D for movement. + +Back To Top + + +Toggle With Behaviour +Static colliders are assets, i.e. they are stateless and immutable at runtime. However, there are instance where static objects should be enabled / disabled based on dynamic conditions. + +For example, pick-ups can usually be represented with a static position and a trigger collider; turning those into static colliders will avoid the over associated with dynamic entities. Unfortunately, the timer commonly to re-spawn a power-up after its pick-up cooldown requires a state. It is possible to solve this conundrum by extending the concept presented in the previous section. + +First, the state of the static colliders representing power-ups needs to be held somewhere. + +singleton component PowerUps { + [ExcludeFromPrototype] bitset[256] IsPowerUp; + [ExcludeFromPrototype] bitset[256] State; + [ExcludeFromPrototype] array[256] Timers; + FP SpawnCooldown; +} +Then a system can be created to handled the enabling and disabling of the power-ups. + +public unsafe class MyPowerUpSystem : SystemMainThread { +public override void OnInit(Frame f) +{ + var powerUps = f.Unsafe.GetPointerSingleton(); + for (int i = 0; i < powerUps->IsPowerUp.Length; i++) + { + var powerUp = f.FindAsset(f.Map.StaticColliders3D[i].StaticData.Asset); + if (powerUp == null) { + powerUps->IsPowerUp.Clear(i); + continue; + } + + powerUps->IsPowerUp.Set(i); + powerUps->State.Set(i); + powerUps->Timers[i] = FP._0; + } +} + +public override void Update(Frame f){ + var powerUps = f.Unsafe.GetPointerSingleton(); + for (int i = 0; i < powerUps->IsPowerUp.Length; i++) + { + if (powerUps->IsPowerUp.IsSet(i) == false) continue; + if (powerUps->State.IsSet(i)) continue; + + powerUps->Timers[i] -= f.DeltaTime; + if(powerUps->Timers[i] > 0) continue; + + powerUps->State.Set(i); + // Other code visualizing the spawned / re-enabled power-up + // can use frame event to trigger VFX, SFX, re-enable visual / GameObject + } + +} + +public void OnTrigger3D(Frame frame, TriggerInfo3D info) +{ + if(info.IsStatic == false) return; + + var powerUps = f.Unsafe.GetPointerSingleton(); + + if(powerUps->IsPowerUp.IsSet(info.StaticData.ColliderIndex) == false) return; + if(powerUps->State.IsSet(info.StaticData.ColliderIndex) == false) return; + + powerUps->State.Clear(info.StaticData.ColliderIndex); + powerUps->Timers[info.StaticData.ColliderIndex] = powerUps->SpawnCooldown; + + // Remember to communicate the disabled state visually, e.g. trigger a frame event to disable the GameObject in Unity +} \ No newline at end of file diff --git a/data/systems game logic.txt b/data/systems game logic.txt new file mode 100644 index 0000000000000000000000000000000000000000..8d0c5a9932075f90e4ed7819581f5d1818637cb5 --- /dev/null +++ b/data/systems game logic.txt @@ -0,0 +1,373 @@ +Introduction +Systems are the entry points for all gameplay logic in Quantum. + +They are implemented as normal C# classes, although a few restrictions apply for a System to be compliant with the predict/rollback model: + +Have to be stateless (gameplay data - an instance of the Frame class - will be passed as a parameter by Quantum's simulator to every system Update); +Implement and/or use only deterministic libraries and algorithms (we provide libraries for fixed point math, vector math, physics, random number generation, path finding, etc); +Reside in the Quantum namespace; +There are three base system classes one can inherit from: + +SystemMainThread: for simple gameplay implementation (init and update callbacks + signals). +SystemSignalsOnly: update-less system just to implement signals (reduces overhead by not scheduling a task for it). +SystemBase: advanced uses only, for scheduling parallel jobs into the task graph (not covered in this basic manual). +Back To Top + + +Core Systems +By default the Quantum SDK includes all Core systems in the SystemSetup. + +Core.CullingSystem2D(): Culls entities with a Transform2D component in predicted frames. +Core.CullingSystem3D(): Culls entities with a Transform3D component in predicted frames. +Core.PhysicsSystem2D(): Runs physics on all entities with a Transform2D AND a PhysicsCollider2D component. +Core.PhysicsSystem3D(): Runs physics on all entities with a Transform3D AND a PhysicsCollider3D component. +Core.NavigationSystem(): Used for all NavMesh related components. +Core.EntityPrototypeSystem(): Creates, Materializes and Initializes EntityPrototypes. +Core.PlayerConnectedSystem(): Used to trigger the ISignalOnPlayerConnected and ISignalOnPlayerDisconnected signals. +Core.DebugCommand.CreateSystem(): Used by the state inspector to send data to instantiate / remove / modify entities on the fly (Only available in the Editor!). +All systems are included by default for the user's convenience. Core systems can be selectively added / removed based on the game's required functionalities; e.g. only keep the PhysicsSystem2D or PhysicsSystem3D based on whether the game is 2D or 3D. + +Back To Top + + +Basic System +A most basic System in Quantum is a C# class that inherits from SystemMainThread. The skeleton implementation requires at least the Update callback to be defined: + +namespace Quantum +{ + public unsafe class MySystem : SystemMainThread + { + public override void Update(Frame f) + { + } + } +} +These are the callbacks that can be overridden in a System class: + +OnInit(Frame f): called only once, when the gameplay is being initialized (good place to set up game control data, etc); +Update(Frame f): used to advance the game state (game loop entry point); +OnDisabled(Frame f) and OnEnabled(Frame f): called when a system is disabled/enabled by another system; +Notice that all available callbacks include the same parameter (an instance of Frame). The Frame class is the container for all the transient and static game state data, including entities, physics, navigation and others like immutable asset objects (which will be covered in a separate chapter). + +The reason for this is that Systems must be stateless to comply with Quantum's predict/rollback model. Quantum only guarantees determinism if all (mutable) game state data is fully contained in the Frame instance. + +It is valid to create read-only constants or private methods (that should receive all need data as parameters). + +The following code snippet shows some basic examples of valid and not valid (violating the stateless requirement) in a System: + +namespace Quantum +{ + + public unsafe class MySystem : SystemMainThread + { + // this is ok + private const int _readOnlyData = 10; + // this is NOT ok (this data will not be rolled back, so it would lead to instant drifts between game clients during rollbacks) + private int _transientData = 10; + + public override void Update(Frame f) + { + // ok to use a constant to compute something here + var temporaryData = _readOnlyData + 5; + + // NOT ok to modify transient data that lives outside of the Frame object: + _transientData = 5; + } + } + +} +Back To Top + + +System Setup +Concrete System classes must be injected into Quantum's simulator during gameplay initialization. This is done in the SystemSetup.cs file : + +namespace Quantum +{ + public static class SystemSetup + { + public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig) + { + return new SystemBase[] + { + // pre-defined core systems + new Core.PhysicsSystem(), + new Core.NavMeshAgentSystem(), + new Core.EntityPrototypeSystem(), + + // user systems go here + new MySystem(), + }; + } + } +} +Notice that Quantum includes a few pre-built Systems (entry point for the physics engine updates, navmesh and entity prototype instantiations). + +To guarantee determinism, the order in which Systems are inserted will be the order in which all callbacks will be executed by the simulator on all clients. So, to control the sequence in which your updates occur, just insert your custom systems in the desired order. + +Back To Top + + +Activating And Deactivating Systems +All injected systems are active by default, but it is possible to control their status in runtime by calling these generic functions from any place in the simulation (they are available in the Frame object): + +public override void OnInit(Frame f) +{ + // deactivates MySystem, so no updates (or signals) are called in it + f.SystemDisable(); + // (re)activates MySystem + f.SystemEnable(); + // possible to query if a System is currently enabled + var enabled = f.SystemIsEnabled(); +} +Any System can deactivate (and re-activate) another System, so one common pattern is to have a main controller system that manages the active/inactive lifecycle of more specialized Systems using a simple state machine (one example is to have an in-game lobby first, with a countdown to gameplay, then normal gameplay, and finally a score state). + +To make a system start disabled by default override this property: + +public override bool StartEnabled => false; +Back To Top + + +Special System Types +Although you are likely to use the default SystemMainThread type for most of your systems, Quantum offers several alternative options for specialized systems. + +System Description +SystemMainThread Most common system type. Implements a regular Update() with all the usual features. +SystemSignalsOnly Does not have an Update() function. It is meant for systems that focus solely on implementing and receiving signals from other systems. By avoiding the Update loop, it helps you save some overhead +SystemMainThreadFilter This type of system uses a FilterStruct of type T to filter a set of entities based on it, loop through them and calls a method. N.B.: It does not support the any and without parameters, If you need a more complex option, we advise you to inherit from SystemMainThread and iterate through the filter yourself (See the Components page for more information on FilterStructs and Filters). +Back To Top + + +System Groups +Systems can be setup and processed as a group. + +The first step involves creating a class inheriting from SystemMainThreadGroup. + +namespace Quantum +{ + public class MySystemGroup : SystemMainThreadGroup + { + public MySystemGroup(string update, params SystemMainThread[] children) : base(update, children) + { + } + } +} +The MySystemGroup system can now be used in SystemSetup.cs to group systems together. System groups can be used in mixed and matched with regular systems. + +namespace Quantum { + public static class SystemSetup { + public static SystemBase[] CreateSystems(RuntimeConfig gameConfig, SimulationConfig simulationConfig) { + return new SystemBase[] { + + new MyRegularSystem(), + + new MySystemGroup("Gameplay Systems", new MyMovementSystem(), new MyOrbitScanSystem()), + }; + } + } +} +This allows to enable / disable a set of systems with a single line of code. Enabling / disabling a system group will enable / disable all systems which are part of it. N.B.: The Frame.SystemEnable() and Frame.SystemDisable() methods identify systems by type; thus if there are to be several system groups, they each need their own implementation to allow enabling / disabling multiple system groups independently. + +Back To Top + + +Entity Lifecycle API +This section uses the direct API methods for entity creation and composition. Please refer to the chapter on entity prototypes for the the data-driven approach. + +To create a new entity instance, just use this (method returns an EntityRef): + +var e = frame.Create(); +Entities do not have pre-defined components any more, to add a Transform3D and a PhysicsCollider3D to this entity, just type: + +var t = Transform3D.Create(); +frame.Set(e, t); + +var c = PhysicsCollider3D.Create(f, Shape3D.CreateSphere(1)); +frame.Set(e, c); +These two methods are also useful: + +// destroys the entity, including any component that was added to it. +frame.Destroy(e); + +// checks if an EntityRef is still valid (good for when you store it as a reference inside other components): +if (frame.Exists(e)) { + // safe to do stuff, Get/Set components, etc +} +Also possible to check dynamically if an entity contains a certain component type, and get a pointer to the component data directly from frame: + +if (frame.Has(e)) { + var t = frame.Unsafe.GetPointer(e); +} +With ComponentSet, you can do a single check if an entity has multiple components: + +var components = ComponentSet.Create(); +if (frame.Has(e, components)) { + // do something +} +Removing components dynamically is as easy as: + +frame.Remove(e); +Back To Top + + +The EntityRef Type +Quantum's rollback model maintains a variable sized frame buffer; in other words several copies of the game state data (defined from the DSL) are kept in memory blocks at separate locations. This means any pointer to either an entity, component or struct is only valid within a single Frame object (updates, etc). + +Entity refs are safe-to-keep references to entities (temporarily replacing pointers) which work across frames, as long as the entity in question still exists. Entity refs contain the following data internally: + +Entity index: entity slot, from the DSL-defined maximum number for the specific type; +Entity version number: used to render old entity refs obsolete when an entity instance is destroyed and the slot can be reused for a new one. +Back To Top + + +Filters +Quantum v2 does not have entity types. In the sparse-set ECS memory model, entities are indexes to a collection of components; the EntityRef type holds some additional information such as versioning. These collections are kept in dynamically allocated sparse sets. Therefore, instead of iterating over a collection of entities, filters are used to create a set of components the system will work on. + +public unsafe class MySystem : SystemMainThread +{ + public override void Update(Frame f) + { + var filtered = frame.Filter(); + + while (filtered.Next(out var e, out var t, out var b)) { + t.Position += FPVector3.Forward * frame.DeltaTime; + frame.Set(e, t); + } + } +} +For a comprehensive view on how filters are used, please refer to the Components page. + +Back To Top + + +Pre-Built Assets And Config Classes +Quantum contains a few pre-built data assets that are always passed into Systems through the Frame object. + +These are the most important pre-built asset objects (from Quantum's Asset DB): + +Map and NavMesh: data about the playable area, static physics colliders, navigation meshes, etc... . Custom player data can be added from a data asset slot (will be covered in the data assets chapter); +SimulationConfig: general configuration data for physics engine, navmesh system, etc. +default PhysicsMaterial and agent configs (KCC, navmesh, etc): +The following snippets show how to access current Map and NavMesh instances from the Frame object: + +// Map is the container for several static data, such as navmeshes, etc +Map map = f.Map; +var navmesh = map.NavMeshes["MyNavmesh"]; +Back To Top + + +Assets Database +All Quantum data assets are available inside Systems through the dynamic asset DataBase API. The following snippets (DSL then C# code from a System) shows how to acquire a data asset from the database and assign it to an asset_ref slot into a Character. First you declare the asset in a qtn file, and create a component that can hold it: + +asset CharacterSpec; + +component CharacterData +{ + asset_ref Spec; + // other data +} +Once the asset and component holding a reference to it are declared, you can set the reference in a system like so: + +// C# code from inside a System + +// grabing the data asset from the database, using the unique GUID (long) or path (string) +var spec = frame.FindAsset("path-to-spec"); +// assigning the asset reference assuming you have a pointer to CharacterData component +data->Spec = spec; +Data assets are explained in more detail in their own chapter (including options on how to populate it either through Unity scriptable objects - default; custom serializers or procedurally generated content). + +Back To Top + + +Signals +As explained in the previous chapter, signals are function signatures used to generate a publisher/subscriber API for inter-systems communication. + +The following example in a DSL file (from the previous chapter): + +signal OnDamage(FP damage, entity_ref entity); +Would lead to this trigger signal being generated on the Frame class (f variable), which can be called from "publisher" Systems: + +// any System can trigger the generated signal, not leading to coupling with a specific implementation +f.Signals.OnDamage(10, entity) +A "subscriber" System would implement the generated "ISignalOnDamage" interface, which would look like this: + +namespace Quantum +{ + class CallbacksSystem : SystemSignalsOnly, ISignalOnDamage + { + public void OnDamage(Frame f, FP damage, EntityRef entity) + { + // this will be called everytime any other system calls the OnDamage signal + } + + } +} +Notice signals always include the Frame object as the first parameter, as this is normally needed to do anything useful to the game state. + +Back To Top + + +Generated And Pre-Built Signals +Besides explicit signals defined directly in the DSL, Quantum also includes some pre-built ("raw" physics collision callbacks, for example) and generated ones based on the entity definitions (entity-type-specific create/destroy callbacks). + +The collision callback signals will be covered in the specific chapter about the physics engine, so here's a brief description of other pre-built signals: + +ISignalOnPlayerDataSet: called when a game client sends an instance of RuntimePlayer to server (and the data is confirmed/attached to one tick). +ISignalOnAdd, ISignalOnRemove: called when a component type T is added/removed to/from an entity. +Back To Top + + +Triggering Events +Similar to what happens to signals, the entry point for triggering events is the Frame object, and each (concrete) event will result in a specific generated function (with the event data as the parameters). + +// taking this DSL event definition as a basis +event TriggerSound +{ + FPVector2 Position; + FP Volume; +} +This can be called from a System to trigger an instance of this event (processing it from Unity will be covered on the chapter about the bootstrap project): + +// any System can trigger the generated events (FP._0_5 means fixed point value for 0.5) +f.Events.TriggerSound(FPVector2.Zero, FP._0_5); +Important to reinforce that events MUST NOT be used to implement gameplay itself (as the callbacks on the Unity side are not deterministic). Events are just a one-way fine-grained API to communicate the rendering engine of detailed game state updates, so the visuals, sound and any UI-related object can be updated on Unity. + +Back To Top + + +Extra Frame API Items +The Frame class also contains entry points for several other deterministic parts of the API that need to be treated as transient data (so rolled back when needed). The following snippet shows the most important ones: + +// RNG is a pointer. +// Next gives a random FP between 0 and 1. +// There are also bound options for both FP and int +f.RNG->Next(); + +// any property defined in the global {} scope in the DSL files is accessed through the Global pointer +var d = f.Global->DeltaTime; + +// input from a player is referenced by its index (i is a pointer to the DSL defined Input struct) +var i = f.GetPlayerInput(0); +Back To Top + + +Optimization By Scheduling +To optimize systems identified as performance hotspots a simple modulo-based entity scheduling can help. Using this only a subset of entities are updated while iterating through them each tick. + +public override void Update(Frame frame) { + foreach (var (entity, c) in f.GetComponentIterator()) { + const int schedulePeriod = 5; + if (frame.Number % entity.Index == frame.Number % schedulePeriod) { + // it is time to update this entity + } +} +Choosing a schedulePeriod of 5 will make the entity only be updated every 5th tick. Choosing 2 would mean every other tick. + +This way the total number of updates is significantly reduced. To avoid updating all entities in one tick adding entity.Index will make the load be spread over multiple frames. + +Deferring the entity update like this has requirements on the user code: + +The deferred update code has to be able to handle different delta times. +The entity lazy "responsiveness" may be visually noticeable. +Using entity.Index may add to the laziness because new information is processed sooner or later for different entities. +The quantum navigation system has this feature build-in. \ No newline at end of file diff --git a/data/unity.txt b/data/unity.txt new file mode 100644 index 0000000000000000000000000000000000000000..2b206611021a0a277a954fd58abd407203447f29 --- /dev/null +++ b/data/unity.txt @@ -0,0 +1,56 @@ +Extending Assets for Unity +Overview +Example +Access At Runtime +Access At Edit-time + +Overview +Quantum assets can be extended with Unity-specific data not relevant for the simulation like data for the UI (colors, texts, icons...). This is done with the use of partial classes. + +Back To Top + + +Example +Let's take the CharacterSpec asset as an example. Its ScriptableObject-based wrapper in Unity is called CharacterSpecAsset and is the type which needs to extended. + +public partial class CharacterSpecAsset { + [Header("Unity")] + public Sprtie Icon; + public Color Color; + public string DisplayName; +} +These fields can only be accessed in the View (Unity) and cannot be accessed or used in the simulation (Quantum). +The newly created partial class needs to be added to the same assembly as the original definition of CharacterSpecAsset. By default, all Unity-side Quantum code belongs to the PhotonQuantum assembly. + +To ensure the partial class belongs to the correct assembly use one of the following approaches: + +Save the class in Assets/Photon/Quantum/User directory. +Save the class in any directory that has an AssemblyDefinitionReference asset pointing to the PhotonQuantum assembly. +Delete Assets/Photon/Quantum/PhotonQuantum.asmdef. This will make Quantum a part of the main assembly. Note that this step needs to be repeated after each Quantum SDK update. +Back To Top + + +Access At Runtime +To access the extra fields at runtime, use the UnityDB.FindAsset() method. + +CharacterSpecAsset characterSpecAsset = UnityDB.FindAsset(guid); +Debug.Log(characterSpecAsset.DisplayName); +Alternatively, the code-generated GetUnityAsset() extension methods can be used: + +CharacterSpec characterSpec = frame.FindAsset(guid); +CharacterSpecAsset characterSpecAsset = characterSpec.GetUnityAsset(); +Debug.Log(characterSpecAsset.DisplayName); +Both of the approaches will result in the asset being loaded into Quantum's AssetDB using the appropriate method, as discussed here: resources, addressables and asset bundles. + +Back To Top + + +Access At Edit-time +To load an asset using its path while in the Unity Editor, the UnityEditor.AssetDataBase.LoadAssetAtPath() method can be used. + +CharacterSpecAsset characterSpecAsset = UnityEditor.AssetDatabase.LoadAssetAtPath(path); +Debug.Log(characterSpecAsset.DisplayName); +Alternatively, the asset can be loaded using its AssetGuid via the UnityDB.FindAssetForInspector() method and casting the result to the correct type. + +CharacterSpecAsset characterSpecAsset = (CharacterSpecAsset)UnityDB.FindAssetForInspector(guid); +Debug.Log(characterSpecAsset.DisplayName); diff --git a/data/web Gl.txt b/data/web Gl.txt new file mode 100644 index 0000000000000000000000000000000000000000..c76891d9723de3e671dd08e419c9d4f5100339b6 --- /dev/null +++ b/data/web Gl.txt @@ -0,0 +1,27 @@ +WebGL +Quantum supports multiple platforms, including WebGL, which comes with its unique challenges that developers must be aware of when working with it. This page provides a comprehensive list of these considerations. + + +Unity Versions +For optimal performance with WebGL, it is recommended to use Unity versions 2021.2.8f1 or later. Older versions of Unity may result in decreased performance when running in WebGL. The minimum acceptable version for building with WebGL is 2018.4.30f1. + +Quantum WebGL is supported since Quantum version 2.1 Build 967. + +Back To Top + + +WebGL Performance +WebGL is a unique environment that presents certain limitations. In general, performance is expected to be lower compared to other platforms. Hence, it is crucial to test the performance of your application in WebGL builds and not just within the editor to ensure optimal performance. + +When the runInBackground option is disabled in the Player Settings, the application will stop running when the player switches to another tab. If the tab remains inactive for an extended period, the client will disconnect and will require reestablishing the connection once the tab is brought back into focus. + +Given the low performance of WebGL, it is recommended to build both the Quantum code project in Release mode and set Unity to IL2CPP. Debug builds of the quantum code project can be extremely slow on WebGL. + +Unity WebGL builds do not support multithreading. The simulation is automatically confined to the main thread in WebGL and the ThreadCount setting in the SimulationConfig is disregarded. +WebSockets +Browsers cannot establish direct UDP connections, so WebSockets over TCP are utilized instead. However, TCP's reliable and sequenced transfer protocol can negatively impact gameplay for players with poor network connections. To provide the best player experience, it is recommended to also offer the game as a download. + +A warning that the application is switching to WebSockets may appear in the browser, but this can be safely ignored. + +Stack Traces +To enhance WebGL performance in release builds you can turn off the stack trace of logs in Unity. Go to edit > project settings > Player > Other Settings and scroll all the way down to Stack Trace* Set the stack trace of Warning and Log to None \ No newline at end of file