diff --git a/zzre.core/PooledList.cs b/zzre.core/PooledList.cs index a52acb0c..63cfe81a 100644 --- a/zzre.core/PooledList.cs +++ b/zzre.core/PooledList.cs @@ -79,5 +79,7 @@ public ref T Add() public readonly ArraySegment.Enumerator GetEnumerator() => new ArraySegment(array, 0, count).GetEnumerator(); readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); - readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + readonly IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public static implicit operator ReadOnlySpan(in PooledList list) => list.Span; } diff --git a/zzre.core/math/NumericsExtensions.cs b/zzre.core/math/NumericsExtensions.cs index bffb61a1..1d1c4ce2 100644 --- a/zzre.core/math/NumericsExtensions.cs +++ b/zzre.core/math/NumericsExtensions.cs @@ -129,6 +129,39 @@ public static T NextOf(this Random random, IReadOnlyList list) => !list.An public static T NextOf(this Random random) where T : struct, Enum => random.NextOf(Enum.GetValues()); + public static bool IsSorted(this ReadOnlySpan list) + where T : struct, IComparisonOperators + { + for (int i = 1; i < list.Length; i++) + { + if (list[i - 1] > list[i]) + return false; + } + return true; + } + + public static T NextOf(this Random random, ReadOnlySpan from, ReadOnlySpan except) + where T : struct, IComparable, IEquatable, IComparisonOperators + { + // Used in the path finder the original code would only try 20 times and without sorting check + int index; + if (except.Length > 8 && except.IsSorted()) + { + do + { + index = random.Next(from.Length); + } while (except.BinarySearch(from[index]) >= 0); + } + else + { + do + { + index = random.Next(from.Length); + } while (except.Contains(from[index])); + } + return from[index]; + } + public static bool IsFinite(this Vector2 v) => float.IsFinite(v.X) && float.IsFinite(v.Y); diff --git a/zzre/game/DuelGame.cs b/zzre/game/DuelGame.cs index d014e465..7e81ebb4 100644 --- a/zzre/game/DuelGame.cs +++ b/zzre/game/DuelGame.cs @@ -56,6 +56,7 @@ public DuelGame(ITagContainer diContainer, messages.StartDuel message) : base(di new systems.effect.Sound(this), // Fairies + new systems.AIPath(this), new systems.FairyPhysics(this), new systems.FairyAnimation(this), new systems.FairyActivation(this), @@ -90,7 +91,9 @@ public DuelGame(ITagContainer diContainer, messages.StartDuel message) : base(di new systems.ModelRenderer(this, components.RenderOrder.LateSolid), new systems.ModelRenderer(this, components.RenderOrder.LateAdditive), new systems.effect.EffectRenderer(this, components.RenderOrder.LateEffect), - new systems.effect.EffectModelRenderer(this, components.RenderOrder.LateEffect)); + new systems.effect.EffectModelRenderer(this, components.RenderOrder.LateEffect), + + new systems.DebugAIPath(this)); LoadScene($"sd_{message.SceneId:D4}"); camera.Location.LocalPosition = -ecsWorld.Get().Origin; diff --git a/zzre/game/PathFinder.cs b/zzre/game/PathFinder.cs index 3343706d..1eb9a177 100644 --- a/zzre/game/PathFinder.cs +++ b/zzre/game/PathFinder.cs @@ -8,6 +8,13 @@ namespace zzre.game; +public enum WaypointEdgeKind +{ + None, + Walkable, + Jumpable +} + public class PathFinder : IDisposable { public const uint InvalidId = uint.MaxValue; @@ -142,6 +149,9 @@ public uint FurthestId(Vector3 position, TFilter filter) return bestId; } + public bool IsTraversable(uint waypointId) => + IsTraversable(wpSystem.Waypoints[idToIndex[waypointId]]); + public static bool IsTraversable(in Waypoint waypoint) => waypoint.Group == InvalidId || waypoint.WalkableIds.Length > 0 || waypoint.JumpableIds.Length > 0; @@ -180,4 +190,36 @@ private readonly struct NearestInvisibleFilter(ArraySegment isVisible) : I public bool IsValid(in Waypoint waypoint, int index) => !isVisible[index] && IsTraversable(waypoint); } + + public uint TryRandomNextTraversable(uint fromId, out WaypointEdgeKind edgeKind) => + TryRandomNextTraversable(fromId, ReadOnlySpan.Empty, out edgeKind); + + public uint TryRandomNextTraversable(uint fromId, ReadOnlySpan except, out WaypointEdgeKind edgeKind) + { + // TODO: Investigate weird waypoint modification from random next in original code + // The original engine would override the walkable list with the jumpable ones if it could not find + // any walkable next waypoint. This would not happen in the original code... right?! + + // Also with the validation command not done yet I just suspect that there are no directed edges so + // this should degrade cleanly to: any random walkable except - otherwise any random jumpable + // which oculd be implemented a bit more efficiently and cleaner. + + ref readonly var from = ref wpSystem.Waypoints[idToIndex[fromId]]; + var toId = random.NextOf(from.WalkableIds, except); + if (IsTraversable(toId)) + { + edgeKind = WaypointEdgeKind.Walkable; + return toId; + } + + toId = random.NextOf(from.JumpableIds, except); + if (IsTraversable(toId)) + { + edgeKind = WaypointEdgeKind.Jumpable; + return toId; + } + + edgeKind = WaypointEdgeKind.None; + return InvalidId; + } } diff --git a/zzre/game/components/ai/AIPath.cs b/zzre/game/components/ai/AIPath.cs index 941bc7d2..21933405 100644 --- a/zzre/game/components/ai/AIPath.cs +++ b/zzre/game/components/ai/AIPath.cs @@ -4,7 +4,7 @@ namespace zzre.game.components; public struct AIPath { - public PooledList<(uint Id, Vector3 Position)> WaypointIndices; + public PooledList WaypointIndices; } /* diff --git a/zzre/game/systems/duel/AIPath.cs b/zzre/game/systems/duel/AIPath.cs index 3cc22961..022fad3a 100644 --- a/zzre/game/systems/duel/AIPath.cs +++ b/zzre/game/systems/duel/AIPath.cs @@ -13,19 +13,24 @@ public sealed partial class AIPath : ISystem private readonly DefaultEcs.World ecsWorld; private readonly IDisposable componentRemovedSubscription; + private readonly IDisposable sceneLoadedSubscription; private readonly IDisposable generateMessageSubscription; + private PathFinder pathFinder = null!; public bool IsEnabled { get; set; } public AIPath(ITagContainer diContainer) { ecsWorld = diContainer.GetTag(); + ecsWorld.SetMaxCapacity(1); componentRemovedSubscription = ecsWorld.SubscribeEntityComponentRemoved(HandleComponentRemoved); + sceneLoadedSubscription = ecsWorld.Subscribe(HandleSceneLoaded); generateMessageSubscription = ecsWorld.Subscribe(HandleGenerateAIPath); } public void Dispose() { componentRemovedSubscription.Dispose(); + sceneLoadedSubscription.Dispose(); generateMessageSubscription.Dispose(); } @@ -34,6 +39,12 @@ private void HandleComponentRemoved(in DefaultEcs.Entity entity, in components.A value.WaypointIndices.Dispose(); } + private void HandleSceneLoaded(in messages.SceneLoaded msg) + { + pathFinder = new PathFinder(msg.Scene.waypointSystem, ecsWorld.Get()); + ecsWorld.Set(pathFinder); + } + private void HandleGenerateAIPath(in messages.GenerateAIPath message) { var optPath = message.ForEntity.TryGet(); @@ -48,7 +59,24 @@ private void HandleGenerateAIPath(in messages.GenerateAIPath message) ref var path = ref optPath.Value; path.WaypointIndices.Clear(); + uint nearestId = message.CurrentWaypointId; + if (message.CurrentWaypointId == PathFinder.InvalidId) + { + var currentPosition = message.CurrentPosition ?? message.ForEntity.Get().GlobalPosition; + nearestId = pathFinder.NearestTraversableId(currentPosition); + } + if (nearestId == PathFinder.InvalidId) + return; + path.WaypointIndices.Add(nearestId); + var currentId = nearestId; + while (path.WaypointIndices.Count < 4) + { + currentId = pathFinder.TryRandomNextTraversable(currentId, path.WaypointIndices, out _); + if (currentId == PathFinder.InvalidId) + return; + path.WaypointIndices.Add(currentId); + } } public void Update(float _) diff --git a/zzre/game/systems/duel/DebugAIPath.cs b/zzre/game/systems/duel/DebugAIPath.cs new file mode 100644 index 00000000..e3d7c8b7 --- /dev/null +++ b/zzre/game/systems/duel/DebugAIPath.cs @@ -0,0 +1,49 @@ +using System.Numerics; +using DefaultEcs.System; +using Veldrid; +using zzio; +using zzre.materials; +using zzre.rendering; + +namespace zzre.game.systems; + +public sealed partial class DebugAIPath : AEntitySetSystem +{ + private readonly DebugLineRenderer lineRenderer; + + public DebugAIPath(ITagContainer diContainer) : base(diContainer.GetTag(), CreateEntityContainer, useBuffer: false) + { + lineRenderer = new(diContainer); + lineRenderer.Material.LinkTransformsTo(diContainer.GetTag()); + lineRenderer.Material.World.Ref = Matrix4x4.Identity; + } + + protected override void PreUpdate(CommandList cl) + { + base.PreUpdate(cl); + lineRenderer.Clear(); + } + + [Update] + private void Update(in components.AIPath aiPath) + { + var pathFinder = World.Get(); + foreach (var index in aiPath.WaypointIndices) + { + lineRenderer.AddDiamondSphere(new(pathFinder[index], 0.1f), IColor.White); + } + + for (int i = 1; i < aiPath.WaypointIndices.Count; i++) + { + lineRenderer.Add(IColor.White, + pathFinder[aiPath.WaypointIndices[i - 1]], + pathFinder[aiPath.WaypointIndices[i]]); + } + } + + protected override void PostUpdate(CommandList cl) + { + base.PostUpdate(cl); + lineRenderer.Render(cl); + } +} diff --git a/zzre/game/systems/fairy/FairyActivation.cs b/zzre/game/systems/fairy/FairyActivation.cs index b11ac91e..59c3b4a1 100644 --- a/zzre/game/systems/fairy/FairyActivation.cs +++ b/zzre/game/systems/fairy/FairyActivation.cs @@ -113,6 +113,8 @@ private void HandleSwitchFairy(in messages.SwitchFairy fairy) participant.ActiveSlot = nextSlotI; var nextFairy = participant.ActiveFairy; nextFairy.Enable(); + if (fairy.Participant != ecsWorld.Get().Entity) + ecsWorld.Publish(new messages.GenerateAIPath(nextFairy)); } private (Vector3 pos, Vector3 dir) FindFarthestStartPoint()