diff --git a/zzio/InventoryCard.cs b/zzio/InventoryCard.cs index 3a937ea3..c97faf47 100644 --- a/zzio/InventoryCard.cs +++ b/zzio/InventoryCard.cs @@ -90,8 +90,8 @@ public class InventoryFairy : InventoryCard public uint maxMHP; public float moveSpeed; public float jumpPower; - public float jumpMana = 10000f; - public float maxJumpMana = 10000f; + public int jumpMana = 10000; + public int maxJumpMana = 10000; public float criticalHit; protected override void ReadSub(BinaryReader r) diff --git a/zzre.core.tests/math/TestMath.cs b/zzre.core.tests/math/TestMath.cs index 86093274..e697197a 100644 --- a/zzre.core.tests/math/TestMath.cs +++ b/zzre.core.tests/math/TestMath.cs @@ -4,7 +4,7 @@ using System.Numerics; using NUnit.Framework; -namespace zzre.core.tests.math; +namespace zzre.tests; [TestFixture] public class TestMath @@ -58,4 +58,66 @@ public static IEnumerable GenerateUniformPoints() yield return vec; } } + + public readonly record struct AlmostANumber(int value) + : IComparable, IComparisonOperators + { + public int CompareTo(AlmostANumber other) => value - other.value; + public static bool operator <(AlmostANumber left, AlmostANumber right) => left.CompareTo(right) < 0; + public static bool operator >(AlmostANumber left, AlmostANumber right) => left.CompareTo(right) > 0; + public static bool operator <=(AlmostANumber left, AlmostANumber right) => left.CompareTo(right) <= 0; + public static bool operator >=(AlmostANumber left, AlmostANumber right) => left.CompareTo(right) >= 0; + } + public static readonly AlmostANumber A = new(1), B = new(2), C = new(3), D = new(4), E = new(5); + + [Test, Repeat(1000)] + public void TestNextOf() + { + var value = Random.Shared.NextOf(new[] { A, B, C, D, E }); + Assert.That(value, Is.AnyOf(A, B, C, D, E)); + } + + [Test] + public void TestNextOfEmpty() + { + Assert.That(() => + { + Random.Shared.NextOf(new AlmostANumber[] { }); + }, Throws.ArgumentException); + } + + [Test, Repeat(1000)] + public void TestNextOfExceptEmpty() + { + var value = Random.Shared.NextOf([A, B, C, D, E], []); + Assert.That(value, Is.AnyOf(A, B, C, D, E)); + } + + [Test, Repeat(1000)] + public void TestNextOfDisjunct() + { + var value = Random.Shared.NextOf([A, B, C], [D, E]); + Assert.That(value, Is.AnyOf(A, B, C)); + } + + [Test, Repeat(1000)] + public void TestNextOfEmptyInput() + { + var value = Random.Shared.NextOf([], [A, B, C, D, E]); + Assert.That(value, Is.Null); + } + + [Test, Repeat(1000)] + public void TestNextOfSuper() + { + var value = Random.Shared.NextOf([B, C, D], [A, B, C, D, E]); + Assert.That(value, Is.Null); + } + + [Test, Repeat(1000)] + public void TestNextOfExcept() + { + var value = Random.Shared.NextOf([A, B, C, D, E], [B, C, D]); + Assert.That(value, Is.AnyOf(A, E)); + } } diff --git a/zzre.core/PooledList.cs b/zzre.core/PooledList.cs index a52acb0c..1da53c4c 100644 --- a/zzre.core/PooledList.cs +++ b/zzre.core/PooledList.cs @@ -14,7 +14,8 @@ public struct PooledList : IDisposable, IEnumerable where T : struct private int count; public readonly int Capacity => array.Length; - public readonly bool IsFull => count >= array.Length; + public readonly bool IsFull => count >= array.Length; + public readonly bool IsEmpty => count == 0; public int Count { @@ -79,5 +80,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/MathEx.cs b/zzre.core/math/MathEx.cs index 66c0bbf6..e80fe04b 100644 --- a/zzre.core/math/MathEx.cs +++ b/zzre.core/math/MathEx.cs @@ -14,6 +14,7 @@ public static class MathEx public const float DegToRad = MathF.PI / 180f; public const float RadToDeg = 180f / MathF.PI; public const float ZeroEpsilon = 0.1E-10f; + public const float TwoPI = MathF.PI * 2f; public static readonly Vector2 Vector2NaN = Vector2.One * float.NaN; public static readonly Vector3 Vector3NaN = Vector3.One * float.NaN; public static readonly Vector4 Vector4NaN = Vector4.One * float.NaN; @@ -43,6 +44,18 @@ public static bool Cmp(float a, float b) => [MethodImpl(MIOptions)] public static bool CmpZero(float a) => Math.Abs(a) < ZeroEpsilon; + [MethodImpl(MIOptions)] + public static float NormalizeAngle(float angle) + { + while (angle < -MathF.PI) angle += TwoPI; + while (angle > +MathF.PI) angle -= TwoPI; + return angle; + } + + [MethodImpl(MIOptions)] + public static Vector3 HorizontalDirection(float angle) => + new(MathF.Sin(angle), 0f, MathF.Cos(angle)); + [MethodImpl(MIOptions)] public static Vector2 Floor(Vector2 v) => new(MathF.Floor(v.X), MathF.Floor(v.Y)); diff --git a/zzre.core/math/NumericsExtensions.cs b/zzre.core/math/NumericsExtensions.cs index bffb61a1..1eee742e 100644 --- a/zzre.core/math/NumericsExtensions.cs +++ b/zzre.core/math/NumericsExtensions.cs @@ -1,4 +1,5 @@ using System; +using System.Buffers; using System.Collections.Generic; using System.Linq; using System.Numerics; @@ -129,6 +130,89 @@ 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; + } + + // the original code would just give up after 20 tries, I will at least try a bit more + private const int NextOfRandomCount = 20; + + public static T? NextOf(this Random random, ReadOnlySpan from, ReadOnlySpan except) + where T : struct, IComparable, IEquatable, IComparisonOperators + { + if (from.IsEmpty) + return null; + if (except.IsEmpty) + return from[random.Next(from.Length)]; + + if (except.Length > 8 && except.IsSorted()) + { + for (int i = 0; i < NextOfRandomCount; i++) + { + int index = random.Next(from.Length); + if (except.BinarySearch(from[index]) < 0) + return from[index]; + } + } + else + { + for (int i = 0; i < NextOfRandomCount; i++) + { + int index = random.Next(from.Length); + if (!except.Contains(from[index])) + return from[index]; + } + } + + return NextOfPrefilter(random, from, except); + } + + public static T? NextOfPrefilter(this Random random, ReadOnlySpan fromOriginal, ReadOnlySpan exceptOriginal) + where T : struct, IComparable, IEquatable, IComparisonOperators + { + if (fromOriginal.IsEmpty) + return null; + if (exceptOriginal.IsEmpty) + return fromOriginal[random.Next(fromOriginal.Length)]; + + var buffer = ArrayPool.Shared.Rent(fromOriginal.Length * 2 + exceptOriginal.Length); + var from = buffer.AsSpan(0, fromOriginal.Length); + var except = buffer.AsSpan(from.Length, exceptOriginal.Length); + + var destination = buffer.AsSpan(from.Length + except.Length); + fromOriginal.CopyTo(buffer); + exceptOriginal.CopyTo(buffer.AsSpan(from.Length)); + Array.Sort(buffer, 0, from.Length); + Array.Sort(buffer, from.Length, except.Length); + + int j = 0; + int k = 0; + for (int i = 0; i < from.Length; i++) + { + while (j < except.Length && except[j] < from[i]) + j++; + if (j >= except.Length) + { + from[i..].CopyTo(destination[k..]); + k += from.Length - i; + break; + } + if (except[j] > from[i]) + destination[k++] = from[i]; + } + + T? result = k > 0 ? destination[random.Next(k)] : null; + ArrayPool.Shared.Return(buffer); + return result; + } + 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 d1bff4e8..582cae05 100644 --- a/zzre/game/DuelGame.cs +++ b/zzre/game/DuelGame.cs @@ -56,6 +56,8 @@ public DuelGame(ITagContainer diContainer, messages.StartDuel message) : base(di new systems.effect.Sound(this), // Fairies + new systems.AIPath(this), + new systems.AIMovement(this), new systems.FairyPhysics(this), new systems.FairyAnimation(this), new systems.FairyActivation(this), @@ -90,20 +92,25 @@ 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; var playerEntity = CreateParticipant(message.OverworldPlayer, isPlayer: true); - foreach (var enemy in message.OverworldEnemies) - CreateParticipant(enemy, isPlayer: false); + var enemyEntities = new DefaultEcs.Entity[message.OverworldEnemies.Length]; + for (int i = 0; i < enemyEntities.Length; i++) + enemyEntities[i] = CreateParticipant(message.OverworldEnemies[i], isPlayer: false); playerEntity.Set(); playerEntity.Set(components.DuelCameraMode.ZoomIn); ecsWorld.Set(new components.PlayerEntity(playerEntity)); ecsWorld.Publish(new messages.SwitchFairy(playerEntity)); + foreach (var enemy in enemyEntities) + ecsWorld.Publish(new messages.SwitchFairy(enemy)); } private DefaultEcs.Entity CreateParticipant(DefaultEcs.Entity overworldEntity, bool isPlayer) @@ -122,7 +129,10 @@ private DefaultEcs.Entity CreateParticipant(DefaultEcs.Entity overworldEntity, b continue; var fairy = CreateFairyFor(duelEntity, invFairy); if (!isPlayer) + { fairy.Set(); + fairy.Set(); + } participant.Fairies[i] = fairy; } diff --git a/zzre/game/Inventory.GameLogic.cs b/zzre/game/Inventory.GameLogic.cs index b66ea590..3b01978a 100644 --- a/zzre/game/Inventory.GameLogic.cs +++ b/zzre/game/Inventory.GameLogic.cs @@ -103,6 +103,11 @@ public void AddMana(InventorySpell spell, int delta) spell.mana = (uint)Math.Clamp((int)spell.mana + delta, 0, dbRow.MaxMana); } + public static void AddJumpMana(InventoryFairy fairy, int delta) + { + fairy.jumpMana = Math.Clamp(fairy.jumpMana + delta, 0, fairy.maxJumpMana); + } + public void AddXP(InventoryFairy fairy, uint moreXP) { fairy.xpChangeCount += moreXP; diff --git a/zzre/game/PathFinder.cs b/zzre/game/PathFinder.cs new file mode 100644 index 00000000..c6720111 --- /dev/null +++ b/zzre/game/PathFinder.cs @@ -0,0 +1,232 @@ +using System; +using System.Buffers; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Numerics; +using zzio; +using zzio.scn; + +namespace zzre.game; + +public enum WaypointEdgeKind +{ + None, + Walkable, + Jumpable +} + +public enum FindPathResult +{ + NotThereYet, + Success, + NotFound, + Timeout +} + +public class PathFinder : IDisposable +{ + public const uint InvalidId = uint.MaxValue; + + private readonly Random random = Random.Shared; + private readonly WorldCollider collider; + private readonly WaypointSystem wpSystem; + private readonly FrozenDictionary idToIndex; + private readonly bool[] isVisible; + private bool disposedValue; + + public int WaypointCount => wpSystem.Waypoints.Length; + + public PathFinder(WaypointSystem wpSystem, WorldCollider collider) + { + this.collider = collider; + this.wpSystem = wpSystem; + idToIndex = wpSystem.Waypoints + .Indexed() + .ToFrozenDictionary(t => t.Value.Id, t => t.Index); + + isVisible = ArrayPool.Shared.Rent(WaypointCount * WaypointCount); + if (WaypointCount > 0) + { + if (wpSystem.Waypoints[0].VisibleIds is null) + RaycastVisibility(); + else + SetVisibilityFromData(); + } + } + + protected virtual void Dispose(bool disposing) + { + if (!disposedValue) + { + if (disposing) + { + ArrayPool.Shared.Return(isVisible); + } + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + private void RaycastVisibility() + { + Array.Fill(isVisible, false); + for (int i = 0; i < WaypointCount; i++) + { + isVisible[i * WaypointCount + i] = true; + for (int j = i + 1; j < WaypointCount; j++) + { + if (collider.Intersects(new Line(wpSystem.Waypoints[i].Position, wpSystem.Waypoints[j].Position))) + { + isVisible[i * WaypointCount + j] = true; + isVisible[j * WaypointCount + i] = true; + } + } + } + } + + private void SetVisibilityFromData() + { + Array.Fill(isVisible, false); + for (int i = 0; i < WaypointCount; i++) + { + var visibleIds = wpSystem.Waypoints[i].VisibleIds; + foreach (var id in visibleIds ?? []) + { + var j = idToIndex[id]; + isVisible[i * WaypointCount + j] = true; + } + } + } + + public bool IsVisible(uint fromId, uint toId) => isVisible[fromId * WaypointCount + toId]; + + public Vector3 this[uint id] => wpSystem.Waypoints[idToIndex[id]].Position; + + public interface IWaypointFilter + { + bool IsValid(in Waypoint waypoint) => true; + bool IsValid(in Waypoint waypoint, int index) => IsValid(waypoint); + } + + public uint NearestId(Vector3 position, TFilter filter) + where TFilter : struct, IWaypointFilter + { + // TODO: Investigate acceleration structure for searching nearest waypoint + float bestDistanceSqr = float.PositiveInfinity; + uint bestId = InvalidId; + for (int i = 0; i < WaypointCount; i++) + { + ref readonly var waypoint = ref wpSystem.Waypoints[i]; + if (!filter.IsValid(waypoint, i)) + continue; + + var curDistanceSqr = Vector3.DistanceSquared(waypoint.Position, position); + if (curDistanceSqr < bestDistanceSqr) + { + bestDistanceSqr = curDistanceSqr; + bestId = waypoint.Id; + } + } + return bestId; + } + + public uint FurthestId(Vector3 position, TFilter filter) + where TFilter : IWaypointFilter + { + float bestDistanceSqr = float.NegativeInfinity; + uint bestId = InvalidId; + for (int i = 0; i < WaypointCount; i++) + { + ref readonly var waypoint = ref wpSystem.Waypoints[i]; + if (!filter.IsValid(waypoint, i)) + continue; + + var curDistanceSqr = Vector3.DistanceSquared(waypoint.Position, position); + if (curDistanceSqr > bestDistanceSqr) + { + bestDistanceSqr = curDistanceSqr; + bestId = waypoint.Id; + } + } + 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; + + public uint NearestTraversableId(Vector3 position) => NearestId(position, new TraversableFilter()); + private readonly struct TraversableFilter : IWaypointFilter + { + public bool IsValid(in Waypoint waypoint) => IsTraversable(waypoint); + } + + public uint NearestJumpableId(Vector3 position) => NearestId(position, new JumpableFilter()); + private readonly struct JumpableFilter : IWaypointFilter + { + public bool IsValid(in Waypoint waypoint) => waypoint.JumpableIds.Length > 0; + } + + public uint NearestIdOfGroup(Vector3 position, uint group) => NearestId(position, new GroupFilter(group)); + public uint FurthestIdOfGroup(Vector3 position, uint group) => FurthestId(position, new GroupFilter(group)); + private readonly struct GroupFilter(uint group) : IWaypointFilter + { + public bool IsValid(in Waypoint waypoint) => waypoint.Group == group; + } + + public uint FurthestIdOfCompatible(Vector3 position, uint group) => FurthestId(position, new CompatibleGroupFilter( + wpSystem.CompatibleGroups.GetValueOrDefault(group, []), group)); + private readonly struct CompatibleGroupFilter(uint[] compatibleGroups, uint group) : IWaypointFilter + { + public bool IsValid(in Waypoint waypoint) => + (waypoint.WalkableIds.Length > 0 || waypoint.JumpableIds.Length > 0) && + (waypoint.Group == group || Array.IndexOf(compatibleGroups, waypoint.Group) >= 0); + } + + public uint NearestInvisibleTo(Vector3 position, uint otherId) => NearestId(position, new NearestInvisibleFilter( + new ArraySegment(isVisible, idToIndex[otherId] * WaypointCount, WaypointCount))); + private readonly struct NearestInvisibleFilter(ArraySegment isVisible) : IWaypointFilter + { + 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]]; + + if (random.NextOf(from.WalkableIds, except) is uint toId && IsTraversable(toId)) + { + edgeKind = WaypointEdgeKind.Walkable; + return toId; + } + + if (random.NextOf(from.JumpableIds, except) is uint toId2 && IsTraversable(toId2)) + { + edgeKind = WaypointEdgeKind.Jumpable; + return toId2; + } + + edgeKind = WaypointEdgeKind.None; + return InvalidId; + } +} diff --git a/zzre/game/components/duel/AIMovement.cs b/zzre/game/components/duel/AIMovement.cs new file mode 100644 index 00000000..8c8c71f3 --- /dev/null +++ b/zzre/game/components/duel/AIMovement.cs @@ -0,0 +1,19 @@ +using System.Numerics; + +namespace zzre.game.components; + +public struct AIMovement +{ + public Vector3 CurrentPos; + public Vector3 TargetTargetDir; + public Vector3 DirToCurrentWp; + public WaypointEdgeKind CurrentEdgeKind; + public float YVelocity; + public float DistToCurWp; + public float DistMovedToCurWp; + public bool ShouldJump; + public bool DidMove; + public bool TryBailout; + public bool ShouldAdvanceNode; + public bool DidTimeoutFindingPath; +} diff --git a/zzre/game/components/duel/AIPath.cs b/zzre/game/components/duel/AIPath.cs new file mode 100644 index 00000000..e0438a0a --- /dev/null +++ b/zzre/game/components/duel/AIPath.cs @@ -0,0 +1,77 @@ +using System.Numerics; + +namespace zzre.game.components; + +public struct AIPath +{ + public PooledList WaypointIds; + public PooledList Waypoints; + public PooledList EdgeKinds; + public int TargetIndex; + public FindPathResult LastResult; + + public readonly bool HasNextWaypoint => TargetIndex + 1 < WaypointIds.Count; +} + +/* + +(STATE) + +AI PUPPET UPDATE +============================================== +calc speed + +isPlayerNear + ? updateReverse until path no found (reset isPlayerNear if NoFound) + : updateNormal +earlyExit if not success/NotThereYet + +augment targetPos for upward movement +modify jumpPower, sounds, velocities for jumping + +update targetDir based on state and spinning +update actor rotation + +MOVEMENT NORMAL +============================================== +reset if didReversePath +move with moveDistLeft +result + move again + targetPos shift. // main due to NotThereYet + +MOVEMENT LOOP + early-exit if has path but does not reach next waypoint (NotThereYet) + advance nextNode if necessary + needsNewPath if reached end of path or nodeCount < 4? + bailout behavior (METHOD) affecting needsNewPath + find new path if needsNewPath with early exit for timeout/NoFound + setup next wp + +SETUP NEXT WP + calc distance to cur wp + set position to cur wp + reduce moveDistLeft by dist to cur wp + switch waypoint + update curEdgeType + update distToCurWp + IF any distance to nextWp + update dirToCurrentWp + ELSE + reset next and prev to path.start.next + update prevRealWp + earlyExit if moveDistLeft == 0 + +MOVEMENT REVERSE +============================================== +if not didReversePath + invert dirToCurrentWp + distMovedToCurWp is distance to next wp + shift nextNode one back +move with moveDistLeft +shouldAdvanceNode if Success/Timeout/NotThereYet (so not NoFound/Invalid) +if Success/NotThereYet/Invalid + +... not necessary for first implementations now is it? + +*/ diff --git a/zzre/game/messages/duel/GenerateAIPath.cs b/zzre/game/messages/duel/GenerateAIPath.cs new file mode 100644 index 00000000..07878034 --- /dev/null +++ b/zzre/game/messages/duel/GenerateAIPath.cs @@ -0,0 +1,10 @@ +using System.Numerics; + +namespace zzre.game.messages; + +public readonly record struct GenerateAIPath( + DefaultEcs.Entity ForEntity, + uint CurrentWaypointId = uint.MaxValue, + Vector3? CurrentPosition = null) +{ +} diff --git a/zzre/game/messages/duel/ResetAIMovement.cs b/zzre/game/messages/duel/ResetAIMovement.cs new file mode 100644 index 00000000..6c460750 --- /dev/null +++ b/zzre/game/messages/duel/ResetAIMovement.cs @@ -0,0 +1,3 @@ +namespace zzre.game.messages; + +public readonly record struct ResetAIMovement(DefaultEcs.Entity ForEntity); diff --git a/zzre/game/systems/duel/AIMovement.cs b/zzre/game/systems/duel/AIMovement.cs new file mode 100644 index 00000000..0809fcfa --- /dev/null +++ b/zzre/game/systems/duel/AIMovement.cs @@ -0,0 +1,243 @@ +using System; +using System.Diagnostics; +using System.Numerics; +using DefaultEcs.System; +using Serilog; +using zzio; +using zzre.game.components.behaviour; + +namespace zzre.game.systems; + +public sealed partial class AIMovement : AEntitySetSystem +{ + private const int MinPathLength = 4; // TODO: Is this necessary or configurable? + + [Configuration(Key = "/zanzarah.ai.AI_WIZ_FORM_SPEED")] + private float WizFormSpeed = 2.0f; + [Configuration(Key = "/zanzarah.ai.AI_GRAVITY")] + private float Gravity = -4.0f; + [Configuration(Key = "/zanzarah.ai.AI_JUMP_POWER")] + private float JumpPower = 3.0f; + [Configuration] + private int ManaPerJump = -500; + [Configuration] + private float FloorOffset = -0.2f; + + private readonly ILogger logger; + private readonly IDisposable configDisposable; + private readonly IDisposable resetMessageDisposable; + + public AIMovement(ITagContainer diContainer) : base(diContainer.GetTag(), CreateEntityContainer, useBuffer: false) + { + logger = diContainer.GetLoggerFor(); + configDisposable = diContainer.GetConfigFor(this); + resetMessageDisposable = World.Subscribe(HandleResetMovement); + } + + public override void Dispose() + { + base.Dispose(); + configDisposable.Dispose(); + resetMessageDisposable.Dispose(); + } + + private void HandleResetMovement(in messages.ResetAIMovement msg) + { + ref var movement = ref msg.ForEntity.Get(); + movement.DistMovedToCurWp = movement.DistToCurWp = -1f; + movement.ShouldAdvanceNode = true; + } + + [Update] + private void Update( + float elapsedTime, + in DefaultEcs.Entity entity, + Location location, + ref components.AIPath path, + ref components.AIMovement movement, + ref components.PuppetActorMovement targetDir, + ref components.Velocity velocity, + InventoryFairy invFairy, + in Sphere colliderSphere) + { + if (!FindPlayerFairy(out var playerPos)) + return; + + movement.DidMove = false; + var speed = WizFormSpeed * invFairy.moveSpeed; + // TODO: AI Movement: Disable movement in some condition + // TODO: AI Movement: Slow down sharp turns + // TODO: AI Movement: Reverse AI movement if player is near + float moveDistLeft = speed * elapsedTime; + var result = UpdateForward(moveDistLeft, entity, location, ref path, ref movement); + if (result is not (FindPathResult.Success or FindPathResult.NotThereYet)) + return; + + var isHinderedByGravity = Move(elapsedTime, speed, location, ref movement, ref velocity, invFairy, in colliderSphere); + UpdateTargetTargetDir(ref movement, in velocity, isHinderedByGravity, playerPos); + Rotate(elapsedTime, speed, location, ref movement, ref targetDir); + } + + private bool Move( + float elapsedTime, + float speed, + Location location, + ref components.AIMovement movement, + ref components.Velocity velocity, + InventoryFairy invFairy, + in Sphere colliderSphere) + { + movement.DidMove = true; + velocity.Value = speed * movement.DirToCurrentWp; + + if (movement.ShouldJump) + { + movement.ShouldJump = false; + movement.YVelocity = JumpPower; + Inventory.AddJumpMana(invFairy, ManaPerJump); + } + else + movement.YVelocity += Gravity * elapsedTime; + + var nextPosition = movement.CurrentPos; + var nextYByGravity = location.LocalPosition.Y + elapsedTime * movement.YVelocity; + var nextYByMovement = movement.CurrentPos.Y + colliderSphere.Radius + FloorOffset; + var isHinderedByGravity = nextYByGravity >= nextYByMovement; + if (isHinderedByGravity) + { + nextPosition.Y = nextYByGravity; + velocity.Value = velocity.Value with { Y = elapsedTime * movement.YVelocity }; + } + else + { + nextPosition.Y = nextYByMovement; + velocity.Value = velocity.Value with { Y = 0f }; + movement.YVelocity = 0f; + movement.ShouldJump |= movement.CurrentEdgeKind == WaypointEdgeKind.Jumpable; + Inventory.AddJumpMana(invFairy, (int)(elapsedTime * 1000f * invFairy.jumpPower)); + } + location.LocalPosition = nextPosition; + return isHinderedByGravity; + } + + private static void UpdateTargetTargetDir( + ref components.AIMovement movement, + in components.Velocity velocity, + bool isHinderedByGravity, + Vector3 playerPos) + { + movement.TargetTargetDir = 0 switch + { + // _ when isSpinning => Vector3.Zero, + _ when isHinderedByGravity => Vector3.Normalize(playerPos - velocity.Value), + _ => movement.DirToCurrentWp + }; + } + + private static void Rotate( + float elapsedTime, + float speed, + Location location, + ref components.AIMovement movement, + ref components.PuppetActorMovement targetDir) + { + var targetDirAngle = MathF.Atan2(targetDir.TargetDirection.X, targetDir.TargetDirection.Z); + var targetTargetDirAngle = MathF.Atan2(movement.TargetTargetDir.X, movement.TargetTargetDir.Z); + var dirAngleDelta = MathEx.NormalizeAngle(targetTargetDirAngle - targetDirAngle); + var newAngle = targetDirAngle + MathF.CopySign(elapsedTime * speed, dirAngleDelta); + var newAngleDelta = MathEx.NormalizeAngle(targetTargetDirAngle - newAngle); + if ((newAngleDelta < 0) != (dirAngleDelta < 0)) + newAngle = targetTargetDirAngle; + + targetDir.TargetDirection = MathEx.HorizontalDirection(newAngle); + location.LookIn(targetDir.TargetDirection); + } + + private FindPathResult UpdateForward( + float moveDistLeft, + in DefaultEcs.Entity entity, + Location location, + ref components.AIPath path, + ref components.AIMovement movement) + { + // TODO: Check path reversal + var result = AdvancePath(ref moveDistLeft, entity, location, ref path, ref movement); + movement.ShouldAdvanceNode = result is not FindPathResult.NotFound; + + if (result is FindPathResult.NotFound) + { + // set scatter6 state + movement.TryBailout = true; + result = AdvancePath(ref moveDistLeft, entity, location, ref path, ref movement); + movement.ShouldAdvanceNode = result is FindPathResult.Success; + } + else if (result is FindPathResult.Success or FindPathResult.NotThereYet) + { + movement.CurrentPos += moveDistLeft * movement.DirToCurrentWp; + movement.DistMovedToCurWp += moveDistLeft; + } + return result; + } + + private FindPathResult AdvancePath( + ref float moveDistLeft, + in DefaultEcs.Entity entity, + Location location, + ref components.AIPath path, + ref components.AIMovement movement) + { + while (moveDistLeft > 0f) + { + if (!path.Waypoints.IsEmpty && movement.DistToCurWp - (movement.DistMovedToCurWp + moveDistLeft) > 0f) + break; + //if (movement.ShouldAdvanceNode && path.WaypointIds.Count > 2) // TODO: Why 2? + // path.CurrentIndex++; + + var needsNewPath = /*path.WaypointIds.Count < MinPathLength || */ !path.HasNextWaypoint; + // TODO: Add bailout behavior + + if (needsNewPath) + { + movement.DidTimeoutFindingPath = false; + var lastWaypointId = path.WaypointIds.Count > 0 ? path.WaypointIds[^1] : PathFinder.InvalidId; + World.Publish(new messages.GenerateAIPath(entity, lastWaypointId)); + + if (path.LastResult is FindPathResult.Timeout) + { + movement.DidTimeoutFindingPath = true; + logger.Warning("Path find timeout"); + return FindPathResult.Timeout; + } + + if (path.LastResult is not FindPathResult.Success) + { + path.WaypointIds.Clear(); + return path.LastResult; + } + + // I skipped a lot of weird original cached/non-cached/smoothing waypoint handling here + } + + Debug.Assert(path.WaypointIds.Count > 0 && path.HasNextWaypoint); + moveDistLeft -= Vector3.Distance(path.Waypoints[path.TargetIndex], movement.CurrentPos); // rest distance + movement.CurrentPos = path.Waypoints[path.TargetIndex]; + path.TargetIndex++; + movement.CurrentEdgeKind = path.EdgeKinds[path.TargetIndex]; + movement.DirToCurrentWp = MathEx.SafeNormalize(path.Waypoints[path.TargetIndex] - movement.CurrentPos); + movement.DistToCurWp = Vector3.Distance(path.Waypoints[path.TargetIndex], movement.CurrentPos); + movement.DistMovedToCurWp = 0f; + + // TODO: Investigate wheter DistToCurWp can be zero upon switch + } + return FindPathResult.NotThereYet; + } + + + private bool FindPlayerFairy(out Vector3 playerPosition) + { + var playerEntity = World.Get().Entity; + var playerFairy = playerEntity.Get().ActiveFairy; + playerPosition = playerFairy.IsAlive ? playerFairy.Get().GlobalPosition : default; + return playerFairy.IsAlive; + } +} diff --git a/zzre/game/systems/duel/AIPath.cs b/zzre/game/systems/duel/AIPath.cs new file mode 100644 index 00000000..0540de5e --- /dev/null +++ b/zzre/game/systems/duel/AIPath.cs @@ -0,0 +1,112 @@ +using System; +using DefaultEcs.System; + +namespace zzre.game.systems; + +public sealed partial class AIPath : ISystem +{ + private const int DefaultPathLength = 64; + + private readonly DefaultEcs.World ecsWorld; + private readonly IDisposable componentRemovedSubscription; + private readonly IDisposable sceneLoadedSubscription; + private readonly IDisposable generateMessageSubscription; + private readonly IDisposable resetMessageSubscription; + 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); + resetMessageSubscription = ecsWorld.Subscribe(HandleResetMovement); + } + + public void Dispose() + { + componentRemovedSubscription.Dispose(); + sceneLoadedSubscription.Dispose(); + generateMessageSubscription.Dispose(); + resetMessageSubscription.Dispose(); + } + + private void HandleComponentRemoved(in DefaultEcs.Entity entity, in components.AIPath value) + { + value.WaypointIds.Dispose(); + value.Waypoints.Dispose(); + value.EdgeKinds.Dispose(); + } + + private void HandleSceneLoaded(in messages.SceneLoaded msg) + { + pathFinder = new PathFinder(msg.Scene.waypointSystem, ecsWorld.Get()); + ecsWorld.Set(pathFinder); + } + + private static ref components.AIPath ResetPath(DefaultEcs.Entity entity) + { + var optPath = entity.TryGet(); + if (!optPath.HasValue) + { + entity.Set(new() + { + WaypointIds = new(DefaultPathLength), + Waypoints = new(DefaultPathLength), + EdgeKinds = new(DefaultPathLength), + TargetIndex = -1 + }); + optPath = new(ref entity.Get()); + } + ref var path = ref optPath.Value; + path.Waypoints.Clear(); + path.WaypointIds.Clear(); + path.EdgeKinds.Clear(); + path.TargetIndex = 0; + path.LastResult = FindPathResult.NotFound; + return ref path; + } + + private void HandleResetMovement(in messages.ResetAIMovement msg) + { + ResetPath(msg.ForEntity); + var nearestId = pathFinder.NearestTraversableId(msg.ForEntity.Get().GlobalPosition); + msg.ForEntity.Get().CurrentPos = pathFinder[nearestId]; + } + + private void HandleGenerateAIPath(in messages.GenerateAIPath message) + { + ref var path = ref ResetPath(message.ForEntity); + + 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.WaypointIds.Add(nearestId); + path.Waypoints.Add(pathFinder[nearestId]); + path.EdgeKinds.Add(WaypointEdgeKind.None); + var currentId = nearestId; + while (path.WaypointIds.Count < 4) + { + currentId = pathFinder.TryRandomNextTraversable(currentId, path.WaypointIds, out var edgeKind); + if (currentId == PathFinder.InvalidId) + return; + path.WaypointIds.Add(currentId); + path.Waypoints.Add(pathFinder[currentId]); + path.EdgeKinds.Add(edgeKind); + } + if (path.WaypointIds.Count > 0) + path.LastResult = FindPathResult.Success; + } + + 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..d5ed7df3 --- /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.WaypointIds) + { + lineRenderer.AddDiamondSphere(new(pathFinder[index], 0.1f), IColor.White); + } + + for (int i = 1; i < aiPath.WaypointIds.Count; i++) + { + lineRenderer.Add(IColor.White, + pathFinder[aiPath.WaypointIds[i - 1]], + pathFinder[aiPath.WaypointIds[i]]); + } + } + + protected override void PostUpdate(CommandList cl) + { + base.PostUpdate(cl); + lineRenderer.Render(cl); + } +} diff --git a/zzre/game/systems/effect/LensFlare.cs b/zzre/game/systems/effect/LensFlare.cs index 5bcc20f3..4b0767a7 100644 --- a/zzre/game/systems/effect/LensFlare.cs +++ b/zzre/game/systems/effect/LensFlare.cs @@ -87,7 +87,7 @@ public partial class LensFlare : AEntitySetSystem public LensFlare(ITagContainer diContainer) : base(diContainer.GetTag(), CreateEntityContainer, useBuffer: false) { - logger = diContainer.GetTag(); + logger = diContainer.GetLoggerFor(); camera = diContainer.GetTag(); zzContainer = diContainer.GetTag(); assetRegistry = diContainer.GetTag(); diff --git a/zzre/game/systems/fairy/FairyActivation.cs b/zzre/game/systems/fairy/FairyActivation.cs index 05d91569..2876ba44 100644 --- a/zzre/game/systems/fairy/FairyActivation.cs +++ b/zzre/game/systems/fairy/FairyActivation.cs @@ -113,6 +113,11 @@ 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.ResetAIMovement(nextFairy)); + ecsWorld.Publish(new messages.GenerateAIPath(nextFairy)); + } } private (Vector3 pos, Vector3 dir) FindFarthestStartPoint() @@ -121,11 +126,11 @@ private void HandleSwitchFairy(in messages.SwitchFairy fairy) throw new InvalidOperationException("No start points were found in scene"); int bestTriggerI = -1; - float bestDistanceSqr = float.PositiveInfinity; + float bestDistanceSqr = float.NegativeInfinity; for (int i = 0; i < startPoints.Count; i++) { var curDistanceSqr = GetMinimalDistanceToFairies(startPoints[i].pos); - if (curDistanceSqr < bestDistanceSqr) + if (curDistanceSqr > bestDistanceSqr) { bestDistanceSqr = curDistanceSqr; bestTriggerI = i;