diff --git a/Maple2.File.Ingest/Mapper/SkillMapper.cs b/Maple2.File.Ingest/Mapper/SkillMapper.cs index b57ad891c..9461f8be5 100644 --- a/Maple2.File.Ingest/Mapper/SkillMapper.cs +++ b/Maple2.File.Ingest/Mapper/SkillMapper.cs @@ -158,7 +158,7 @@ private static SkillMetadataRange Convert(RegionSkill region) { }, Distance: region.distance, Height: region.height, - Width: region.height, + Width: region.width, EndWidth: region.endWidth, RotateZDegree: region.rangeZRotateDegree, RangeAdd: region.rangeAdd, diff --git a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs index 83264743e..8c56be911 100644 --- a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs +++ b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs @@ -3,10 +3,15 @@ using Maple2.Server.Game.Manager.Field; using ImGuiNET; using Maple2.Model.Enum; +using Maple2.Model.Metadata; using Maple2.Model.Metadata.FieldEntity; using Maple2.Server.DebugGame.Graphics.Data; using Maple2.Server.Game.Model; +using Maple2.Server.Game.Model.ActorStateComponent; +using Maple2.Server.Game.Model.Enum; +using Maple2.Server.Game.Model.Skill; using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Util; using Maple2.Tools.VectorMath; using Maple2.Tools.Collision; using Silk.NET.Maths; @@ -54,6 +59,7 @@ public bool IsActive { public bool ShowPlotLabels = true; // toggle for showing floating plot labels (status/owner/etc.) public bool ShowTriggers = true; public bool ShowTriggerInformation = true; + public bool ShowSkillHitboxes = false; // toggle for rendering active skill hitboxes public bool PlayerMoveMode; public bool ForceMove; @@ -284,6 +290,11 @@ private void RenderFieldEntities3D(DebugFieldWindow window) { // Render text labels above actors using ImGui RenderActorTextLabels(); } + + // Render skill hitboxes + if (ShowSkillHitboxes) { + RenderSkillHitboxes(window); + } } private void RenderBoxColliders(DebugFieldWindow window) { @@ -1212,4 +1223,121 @@ private void RenderTriggerTextLabels() { drawList.AddText(topLeft, ImGui.ColorConvertFloat4ToU32(textColor), idText); } } + + private void RenderSkillHitboxes(DebugFieldWindow window) { + // Set orange color for skill hitboxes + instanceBuffer.Color = new Vector4(1.0f, 0.5f, 0.0f, 0.7f); + + // Render hitboxes for all actors with active skills + foreach ((int _, FieldPlayer player) in Field.Players) { + RenderActorSkillHitbox(window, player); + } + foreach ((int _, FieldNpc npc) in Field.Npcs) { + RenderActorSkillHitbox(window, npc); + } + foreach ((int _, FieldNpc mob) in Field.Mobs) { + RenderActorSkillHitbox(window, mob); + } + } + + private void RenderActorSkillHitbox(DebugFieldWindow window, IActor actor) { + // Get the active SkillRecord from the appropriate source + SkillRecord? skillRecord = null; + + if (actor is FieldNpc npc) { + // For NPCs/mobs: get from MovementState's active cast task + if (npc.MovementState.CastTask is MovementState.NpcSkillCastTask skillTask) { + skillRecord = skillTask.Cast; + } + } else { + // For players: use ActiveSkills but only if currently in a skill animation + if (actor.Animation.Current is { Type: AnimationType.Skill }) { + skillRecord = actor.ActiveSkills.GetMostRecent(); + } + } + + if (skillRecord == null) { + return; + } + + // Only render the CURRENT attack point (not all attacks in the motion) + // Bounds check to avoid race condition with game thread updating MotionPoint/AttackPoint + if (skillRecord.MotionPoint >= skillRecord.Metadata.Data.Motions.Length || + skillRecord.AttackPoint >= skillRecord.Motion.Attacks.Length) { + return; + } + SkillMetadataAttack attack = skillRecord.Attack; + Prism prism = SkillUtils.GetPrism(attack.Range, actor.Position, actor.Rotation.Z); + + RenderPrism(window, prism, attack.Range); + } + + private void RenderPrism(DebugFieldWindow window, Prism prism, SkillMetadataRange range) { + IPolygon polygon = prism.Polygon; + + if (polygon is Circle circle) { + // Render cylinder for circular hitbox + Vector3 center = new(circle.Origin.X, circle.Origin.Y, prism.Height.Min + (prism.Height.Max - prism.Height.Min) * 0.5f); + float radius = circle.Radius; + float height = prism.Height.Max - prism.Height.Min; + + var rotation = Matrix4x4.CreateRotationX((float) (Math.PI / 2)); + instanceBuffer.Transformation = Matrix4x4.Transpose( + Matrix4x4.CreateScale(new Vector3(radius, height * 0.5f, radius)) * + rotation * + Matrix4x4.CreateTranslation(center)); + + UpdateWireframeInstance(window); + Context.CoreModels!.Cylinder.Draw(); + } else if (polygon is Trapezoid trapezoid) { + // For trapezoid, calculate center and size from the points array + Vector2 p0 = trapezoid.Points[0]; + Vector2 p1 = trapezoid.Points[1]; + Vector2 p2 = trapezoid.Points[2]; + Vector2 p3 = trapezoid.Points[3]; + + // Calculate center of the trapezoid + Vector2 center2D = (p0 + p1 + p2 + p3) * 0.25f; + Vector3 center = new(center2D.X, center2D.Y, prism.Height.Min + (prism.Height.Max - prism.Height.Min) * 0.5f); + + // Use range metadata for dimensions + float avgWidth = (range.Width + range.EndWidth) * 0.5f; + float distance = range.Distance; + float height = prism.Height.Max - prism.Height.Min; + + // Calculate rotation from trapezoid points + Vector2 forward2D = Vector2.Normalize(p2 - p1); + float angleRad = MathF.Atan2(forward2D.Y, forward2D.X) - MathF.PI / 2; // Adjust for coordinate system + + var rotation = Matrix4x4.CreateRotationZ(angleRad); + // Intentional visual approximation: uniform-width box using avgWidth instead of true start/end widths. + // This is a debug aid and does not reflect the actual trapezoidal collision shape. + instanceBuffer.Transformation = Matrix4x4.Transpose( + Matrix4x4.CreateScale(new Vector3(avgWidth, distance, height)) * + rotation * + Matrix4x4.CreateTranslation(center)); + + UpdateWireframeInstance(window); + Context.CoreModels!.WireCube.Draw(); + } else { + // Fallback for other polygon types (e.g. Rectangle, HoleCircle). + // Derive the 2D center from the vertex average when the polygon is a Polygon subclass. + Vector2 center2D = Vector2.Zero; + if (polygon is Polygon genericPolygon && genericPolygon.Points.Length > 0) { + foreach (Vector2 p in genericPolygon.Points) { + center2D += p; + } + center2D /= genericPolygon.Points.Length; + } + float height = prism.Height.Max - prism.Height.Min; + Vector3 center = new(center2D.X, center2D.Y, prism.Height.Min + (prism.Height.Max - prism.Height.Min) * 0.5f); + + instanceBuffer.Transformation = Matrix4x4.Transpose( + Matrix4x4.CreateScale(new Vector3(100, 100, height)) * + Matrix4x4.CreateTranslation(center)); + + UpdateWireframeInstance(window); + Context.CoreModels!.WireCube.Draw(); + } + } } diff --git a/Maple2.Server.DebugGame/Graphics/Ui/Windows/VisualizationControlsWindow.cs b/Maple2.Server.DebugGame/Graphics/Ui/Windows/VisualizationControlsWindow.cs index da9efa6cb..8997a871d 100644 --- a/Maple2.Server.DebugGame/Graphics/Ui/Windows/VisualizationControlsWindow.cs +++ b/Maple2.Server.DebugGame/Graphics/Ui/Windows/VisualizationControlsWindow.cs @@ -55,6 +55,7 @@ public void Render() { ImGui.Checkbox("Show Mobs", ref renderer.ShowMobs); ImGui.Unindent(); } + ImGui.Checkbox("Show Skill Hitboxes", ref renderer.ShowSkillHitboxes); ImGui.Separator(); ImGui.Text("Camera Controls:"); if (ImGui.Button("Reset Camera")) { diff --git a/Maple2.Server.Game/Commands/MobAttackCommand.cs b/Maple2.Server.Game/Commands/MobAttackCommand.cs new file mode 100644 index 000000000..5dd356a32 --- /dev/null +++ b/Maple2.Server.Game/Commands/MobAttackCommand.cs @@ -0,0 +1,72 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using Maple2.Model.Enum; +using Maple2.Model.Metadata; +using Maple2.Server.Game.Model; +using Maple2.Server.Game.Session; + +namespace Maple2.Server.Game.Commands; + +public class MobAttackCommand : GameCommand { + private readonly GameSession session; + + public MobAttackCommand(GameSession session) : base(AdminPermissions.Debug, "mob-attack", "Make first mob on map use a skill.") { + this.session = session; + + var skillId = new Argument("skillId", () => null, "Skill id for the mob to use."); + var skillLevel = new Option(["--level", "-l"], () => 1, "Skill level."); + + AddArgument(skillId); + AddOption(skillLevel); + this.SetHandler(Handle, skillId, skillLevel); + } + + private void Handle(InvocationContext ctx, int? skillId, short skillLevel) { + if (session.Field is null) { + ctx.Console.Error.WriteLine("No field loaded."); + return; + } + + // Find first mob on the map + FieldNpc? mob = session.Field.Mobs.Values.FirstOrDefault(); + if (mob is null) { + ctx.Console.Error.WriteLine("No mobs found on the current map."); + return; + } + + ctx.Console.Out.WriteLine($"Using mob: {mob.Value.Metadata.Name} (Id: {mob.Value.Metadata.Id}, ObjectId: {mob.ObjectId})"); + + // Check if mob has skills + NpcMetadataSkill.Entry[] entries = mob.Value.Metadata.Skill.Entries; + if (entries.Length == 0) { + ctx.Console.Error.WriteLine("This mob has no skills."); + return; + } + + // If no skill id passed, list available skills grouped by id + if (skillId is null) { + ctx.Console.Out.WriteLine("Available skills:"); + foreach (var group in entries.GroupBy(e => e.Id)) { + string levels = string.Join(", ", group.Select(e => e.Level)); + ctx.Console.Out.WriteLine($" SkillId: {group.Key} (Levels: {levels})"); + } + return; + } + + // Validate skill exists in metadata + if (!session.Field.SkillMetadata.TryGet(skillId.Value, skillLevel, out SkillMetadata? _)) { + ctx.Console.Error.WriteLine($"Skill {skillId.Value} level {skillLevel} not found in metadata."); + return; + } + + // Warn if the mob does not own the skill in its own skill list + if (!entries.Any(e => e.Id == skillId.Value && e.Level == skillLevel)) { + ctx.Console.Out.WriteLine($"Warning: {mob.Value.Metadata.Name} does not have skill {skillId.Value} (level {skillLevel}) in its skill list. Casting anyway..."); + } + + // Cast the skill facing the player + mob.CastAiSkill(skillId.Value, skillLevel, faceTarget: 1, facePos: session.Player.Position); + ctx.Console.Out.WriteLine($"Mob {mob.Value.Metadata.Name} casting skill {skillId.Value} (level {skillLevel})."); + } +} diff --git a/Maple2.Server.Game/Commands/NpcCommand.cs b/Maple2.Server.Game/Commands/NpcCommand.cs index 5320845ad..473f4125e 100644 --- a/Maple2.Server.Game/Commands/NpcCommand.cs +++ b/Maple2.Server.Game/Commands/NpcCommand.cs @@ -21,13 +21,15 @@ public NpcCommand(GameSession session, NpcMetadataStorage npcStorage) : base(Adm var id = new Argument("id", "Id of npc to spawn."); var amount = new Option(["--amount", "-a"], () => 1, "Amount of the npc."); + var noAi = new Option(["--no-ai"], () => false, "Spawn NPC without AI."); AddArgument(id); AddOption(amount); - this.SetHandler(Handle, id, amount); + AddOption(noAi); + this.SetHandler(Handle, id, amount, noAi); } - private void Handle(InvocationContext ctx, int npcId, int amount) { + private void Handle(InvocationContext ctx, int npcId, int amount, bool noAi) { try { if (session.Field == null || !npcStorage.TryGet(npcId, out NpcMetadata? metadata)) { ctx.Console.Error.WriteLine($"Invalid Npc: {npcId}"); @@ -46,7 +48,7 @@ private void Handle(InvocationContext ctx, int npcId, int amount) { 0 // Keep Z position the same ); - FieldNpc? fieldNpc = session.Field.SpawnNpc(metadata, spawnPosition, rotation); + FieldNpc? fieldNpc = session.Field.SpawnNpc(metadata, spawnPosition, rotation, disableAi: noAi); if (fieldNpc == null) { ctx.Console.Error.WriteLine($"Failed to spawn npc {i + 1}/{amount}: {npcId}"); continue; diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs index 89b3d89fe..3da48aa88 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs @@ -104,7 +104,7 @@ public FieldPlayer SpawnPlayer(GameSession session, Player player, int portalId return fieldPlayer; } - public FieldNpc? SpawnNpc(NpcMetadata npc, Vector3 position, Vector3 rotation, FieldMobSpawn? owner = null, SpawnPointNPC? spawnPointNpc = null, string spawnAnimation = "") { + public FieldNpc? SpawnNpc(NpcMetadata npc, Vector3 position, Vector3 rotation, bool disableAi = false, FieldMobSpawn? owner = null, SpawnPointNPC? spawnPointNpc = null, string spawnAnimation = "") { // Apply random offset if SpawnRadius is set Vector3 spawnPosition = position; if (spawnPointNpc?.SpawnRadius > 0) { @@ -132,7 +132,8 @@ public FieldPlayer SpawnPlayer(GameSession session, Player player, int portalId DtCrowdAgent agent = Navigation.AddAgent(npc, spawnPosition); AnimationMetadata? animation = NpcMetadata.GetAnimation(npc.Model.Name); - var fieldNpc = new FieldNpc(this, NextLocalId(), agent, new Npc(npc, animation), npc.AiPath, patrolDataUUID: spawnPointNpc?.PatrolData, spawnAnimation: spawnAnimation) { + string aiPath = disableAi ? string.Empty : npc.AiPath; + var fieldNpc = new FieldNpc(this, NextLocalId(), agent, new Npc(npc, animation), aiPath, patrolDataUUID: spawnPointNpc?.PatrolData, spawnAnimation: spawnAnimation) { Owner = owner, Position = spawnPosition, Rotation = rotation, @@ -150,7 +151,7 @@ public FieldPlayer SpawnPlayer(GameSession session, Player player, int portalId } public FieldNpc? SpawnNpc(NpcMetadata npc, SpawnPointNPC spawnPointNpc) { - return SpawnNpc(npc, spawnPointNpc.Position, spawnPointNpc.Rotation, null, spawnPointNpc); + return SpawnNpc(npc, spawnPointNpc.Position, spawnPointNpc.Rotation, disableAi: false, owner: null, spawnPointNpc: spawnPointNpc); } public FieldPet? SpawnPet(Item pet, Vector3 position, Vector3 rotation, FieldMobSpawn? owner = null, FieldPlayer? player = null) { diff --git a/Maple2.Server.Game/Model/Field/Actor/Actor.cs b/Maple2.Server.Game/Model/Field/Actor/Actor.cs index e799df0b5..427af9f97 100644 --- a/Maple2.Server.Game/Model/Field/Actor/Actor.cs +++ b/Maple2.Server.Game/Model/Field/Actor/Actor.cs @@ -198,6 +198,8 @@ public virtual void TargetAttack(SkillRecord record) { return; } + // For NPC casts, ImpactPosition is never set (only comes from client packets), so fall back to caster position + Vector3 impactPosition = record.ImpactPosition.LengthSquared() > 0.001f ? record.ImpactPosition : Position; var damage = new DamageRecord(record.Metadata, record.Attack) { CasterId = record.Caster.ObjectId, TargetUid = record.TargetUid, @@ -206,7 +208,7 @@ public virtual void TargetAttack(SkillRecord record) { Level = record.Level, AttackPoint = record.AttackPoint, MotionPoint = record.MotionPoint, - Position = record.ImpactPosition, + Position = impactPosition, Direction = record.Direction, }; diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs index 0ddd234bf..2eb377053 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs @@ -97,7 +97,7 @@ private void SkillCast(NpcSkillCastTask task, int id, short level, long uid, byt return; } - if (!actor.Animation.TryPlaySequence(cast.Motion.MotionProperty.SequenceName, cast.Motion.MotionProperty.SequenceSpeed, AnimationType.Skill, out AnimationSequenceMetadata? sequence)) { + if (!actor.Animation.TryPlaySequence(cast.Motion.MotionProperty.SequenceName, cast.Motion.MotionProperty.SequenceSpeed, AnimationType.Skill, out AnimationSequenceMetadata? sequence, skill: cast.Metadata)) { task.Cancel(); return; diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index 1d231b8d5..8eb4038a0 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -67,13 +67,12 @@ public void SkillCastAttack(SkillRecord cast, byte attackPoint, List att } // Apply damage to targets server-side for NPC attacks - var resolvedTargets = new List(attackTargets); - if (resolvedTargets.Count == 0) { - // Fallback: query targets from attack range - Maple2.Tools.Collision.Prism prism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); - foreach (IActor target in actor.Field.GetTargets(actor, new[] { prism }, attack.Range.ApplyTarget, attack.TargetCount)) { - resolvedTargets.Add(target); - } + // Always use the attack range prism to resolve targets so spatial checks are respected + Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); + var resolvedTargets = new List(); + int queryLimit = attack.TargetCount > 0 ? attack.TargetCount : 1; + foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range.ApplyTarget, queryLimit)) { + resolvedTargets.Add(target); } if (resolvedTargets.Count > 0) { diff --git a/Maple2.Server.Game/Model/Skill/SkillQueue.cs b/Maple2.Server.Game/Model/Skill/SkillQueue.cs index 1f4182977..ca60b0506 100644 --- a/Maple2.Server.Game/Model/Skill/SkillQueue.cs +++ b/Maple2.Server.Game/Model/Skill/SkillQueue.cs @@ -52,4 +52,18 @@ public void Clear() { casts[i] = null; } } + + /// + /// Returns the most recently added non-null SkillRecord, or null if none exist. + /// + public SkillRecord? GetMostRecent() { + // Walk backwards from the last written index + for (int i = 0; i < MAX_PENDING; i++) { + int idx = (index - 1 - i + MAX_PENDING) % MAX_PENDING; + if (casts[idx] != null) { + return casts[idx]; + } + } + return null; + } } diff --git a/Maple2.Server.Game/PacketHandlers/RevivalHandler.cs b/Maple2.Server.Game/PacketHandlers/RevivalHandler.cs index 59ca3f0ac..2953bed72 100644 --- a/Maple2.Server.Game/PacketHandlers/RevivalHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/RevivalHandler.cs @@ -34,7 +34,7 @@ public override void Handle(GameSession session, IByteReader packet) { /// private void HandleSafeRevive(GameSession session) { if (session.Field is null) return; - if (session.Field.Metadata.Property.NoRevivalHere || session.Field.Metadata.Property.RevivalReturnId == 0) { + if (session.Field.Metadata.Property.NoRevivalHere) { return; } // Revive player - this will handle moving to spawn point diff --git a/Maple2.Server.Game/Util/SkillUtils.cs b/Maple2.Server.Game/Util/SkillUtils.cs index 7ebde4a34..d0147dff0 100644 --- a/Maple2.Server.Game/Util/SkillUtils.cs +++ b/Maple2.Server.Game/Util/SkillUtils.cs @@ -15,16 +15,19 @@ public static Prism GetPrism(this SkillMetadataRange range, in Vector3 position, return new Prism(IPolygon.Null, 0, 0); } - var origin = new Vector2(position.X, position.Y); + float adjustedAngle = angle + range.RotateZDegree + 180; + var origin = new Vector2(position.X + range.RangeOffset.X, position.Y + range.RangeOffset.Y); + float boxWidth = range.Width + range.RangeAdd.X; IPolygon polygon = range.Type switch { - SkillRegion.Box => new Rectangle(origin, range.Width + range.RangeAdd.X, range.Distance + range.RangeAdd.Y, angle), + // Box projects forward from caster (0 to distance), same as Frustum but with equal widths. + SkillRegion.Box => new Trapezoid(origin, boxWidth, boxWidth, range.Distance + range.RangeAdd.Y, adjustedAngle), SkillRegion.Cylinder => new Circle(origin, range.Distance), - SkillRegion.Frustum => new Trapezoid(origin, range.Width, range.EndWidth, range.Distance, angle), + SkillRegion.Frustum => new Trapezoid(origin, range.Width, range.EndWidth, range.Distance, adjustedAngle), SkillRegion.HoleCylinder => new HoleCircle(origin, range.Width, range.EndWidth), _ => throw new ArgumentOutOfRangeException($"Invalid range type: {range.Type}"), }; - return new Prism(polygon, position.Z, range.Height + range.RangeAdd.Z); + return new Prism(polygon, position.Z + range.RangeOffset.Z, range.Height + range.RangeAdd.Z); } public static IEnumerable Filter(this Prism prism, IEnumerable entities, int limit = 10) where T : IActor { diff --git a/Maple2.Server.Tests/Tools/Collision/CircleTests.cs b/Maple2.Server.Tests/Tools/Collision/CircleTests.cs index 900b0390e..71ffe0ccf 100644 --- a/Maple2.Server.Tests/Tools/Collision/CircleTests.cs +++ b/Maple2.Server.Tests/Tools/Collision/CircleTests.cs @@ -27,8 +27,7 @@ public void RectangleIntersectsTest() { Assert.That(circle.Intersects(rectangle), Is.True); } { - var rectangle = new Rectangle(new Vector2(4, 4), 2, 2, 0); - Console.WriteLine(string.Join(",", rectangle.Points)); + var rectangle = new Rectangle(new Vector2(5, 5), 2, 2, 0); Assert.That(circle.Intersects(rectangle), Is.False); } } diff --git a/Maple2.Server.Tests/Tools/Collision/HoleCircleTest.cs b/Maple2.Server.Tests/Tools/Collision/HoleCircleTest.cs index 9f2b4b752..b886f9834 100644 --- a/Maple2.Server.Tests/Tools/Collision/HoleCircleTest.cs +++ b/Maple2.Server.Tests/Tools/Collision/HoleCircleTest.cs @@ -32,7 +32,7 @@ public void RectangleIntersectsTest() { Assert.That(circle.Intersects(rectangle), Is.True); } { - var rectangle = new Rectangle(new Vector2(4, 4), 2, 2, 0); + var rectangle = new Rectangle(new Vector2(7, 7), 2, 2, 0); Assert.That(circle.Intersects(rectangle), Is.False); } { @@ -50,7 +50,7 @@ public void TrapezoidIntersectsTest() { Assert.That(circle.Intersects(trapezoid), Is.True); } { - var trapezoid = new Trapezoid(new Vector2(4, 4), 4, 6, 3, 0); + var trapezoid = new Trapezoid(new Vector2(6, 6), 4, 6, 3, 0); Assert.That(circle.Intersects(trapezoid), Is.False); } { diff --git a/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs new file mode 100644 index 000000000..c8cc31715 --- /dev/null +++ b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs @@ -0,0 +1,103 @@ +using System.Numerics; +using Maple2.Tools.Collision; + +namespace Maple2.Server.Tests.Tools.Collision; + +/// +/// Tests for Polygon.Intersects() covering the SAT (Separating Axis Theorem) +/// for both polygon-polygon and polygon-circle cases. +/// +/// Trapezoid at angle=0 extends from origin along +Y: +/// P0=(-halfW, 0), P1=(halfW, 0), P2=(halfEndW, dist), P3=(-halfEndW, dist) +/// +/// Note: Circle.AxisProjection scales Radius by axis.Length(), so circle projections +/// are comparable to polygon projections even on non-normalized axes. +/// +public class PolygonIntersectTests { + // Box: origin at (0,0), 100 wide, 500 deep, pointing +Y (angle=0°). + // Points: (-50,0), (50,0), (50,500), (-50,500). + + [Test] + public void Trapezoid_CircleInFront_Intersects() { + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 0); + var circle = new Circle(new Vector2(0, 250), 10); // centre of box + Assert.That(box.Intersects(circle), Is.True); + } + + [Test] + public void Trapezoid_CircleAtTip_Intersects() { + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 0); + var circle = new Circle(new Vector2(0, 480), 10); // near far edge, clearly inside + Assert.That(box.Intersects(circle), Is.True); + } + + [Test] + public void Trapezoid_CircleBehind_NoIntersect() { + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 0); + var circle = new Circle(new Vector2(0, -200), 10); // clearly behind the near edge + Assert.That(box.Intersects(circle), Is.False); + } + + [Test] + public void Trapezoid_CircleBeyondTip_NoIntersect() { + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 0); + var circle = new Circle(new Vector2(0, 700), 10); // well past far edge + Assert.That(box.Intersects(circle), Is.False); + } + + [Test] + public void Trapezoid_CircleToSide_NoIntersect() { + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 0); + var circle = new Circle(new Vector2(300, 250), 10); // far to the right + Assert.That(box.Intersects(circle), Is.False); + } + + // At angle=90°, Normal(x,y)=(y,-x) means the box extends in -X direction. + // Points at 90°: (0,-50), (0,50), (-500,50), (-500,-50). + + [Test] + public void Trapezoid_CircleInFrontRotated90_Intersects() { + // Box at 90° extends in -X: from x=0 to x=-500, y=-50 to y=50. + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 90); + var circle = new Circle(new Vector2(-250, 0), 10); // centre of the rotated box + Assert.That(box.Intersects(circle), Is.True); + } + + [Test] + public void Trapezoid_CircleBehindRotated90_NoIntersect() { + // Box at 90° extends in -X. Circle at +X is clearly behind the near edge. + var box = new Trapezoid(Vector2.Zero, 100, 100, 500, 90); + var circle = new Circle(new Vector2(300, 0), 10); // opposite side + Assert.That(box.Intersects(circle), Is.False); + } + + [Test] + public void Trapezoid_FrustumCircleInsideNarrowEnd_Intersects() { + // Frustum: 200 wide at origin, 50 wide at distance 300. + var frustum = new Trapezoid(Vector2.Zero, 200, 50, 300, 0); + var circle = new Circle(new Vector2(15, 280), 10); // inside narrow section + Assert.That(frustum.Intersects(circle), Is.True); + } + + [Test] + public void Trapezoid_FrustumCircleOutsideNarrowEnd_NoIntersect() { + // Circle is well outside the narrow far end of the frustum. + var frustum = new Trapezoid(Vector2.Zero, 200, 50, 300, 0); + var circle = new Circle(new Vector2(150, 280), 10); // far outside narrow edge + Assert.That(frustum.Intersects(circle), Is.False); + } + + [Test] + public void TrapezoidIntersectsTrapezoid_Overlapping() { + var a = new Trapezoid(Vector2.Zero, 100, 100, 200, 0); + var b = new Trapezoid(new Vector2(0, 100), 100, 100, 200, 0); // overlaps a + Assert.That(a.Intersects(b), Is.True); + } + + [Test] + public void TrapezoidIntersectsTrapezoid_Separated() { + var a = new Trapezoid(Vector2.Zero, 100, 100, 200, 0); + var b = new Trapezoid(new Vector2(500, 0), 100, 100, 200, 0); // far to the right + Assert.That(a.Intersects(b), Is.False); + } +} diff --git a/Maple2.Server.Tests/Tools/Collision/PrismTests.cs b/Maple2.Server.Tests/Tools/Collision/PrismTests.cs new file mode 100644 index 000000000..45da4d77b --- /dev/null +++ b/Maple2.Server.Tests/Tools/Collision/PrismTests.cs @@ -0,0 +1,86 @@ +using System.Numerics; +using Maple2.Tools.Collision; + +namespace Maple2.Server.Tests.Tools.Collision; + +/// +/// Tests for Prism.Intersects() — 3D collision combining 2D polygon intersection +/// with a height range overlap check. +/// +public class PrismTests { + // Player-sized prism: circle of radius 10, height from Z=0 to Z=100. + private static Prism PlayerAt(float x, float y, float z) => + new(new Circle(new Vector2(x, y), 10), z, 100); + + // Skill box prism: 100 wide, 500 deep, pointing +Y from origin, height 200-500. + private static Prism SkillBox(float baseZ = 200) => + new(new Trapezoid(Vector2.Zero, 100, 100, 500, 0), baseZ, 300); + + [Test] + public void Prism_PlayerInBox_Intersects() { + Prism skill = SkillBox(); + Prism player = PlayerAt(0, 250, 250); // centre of skill box, matching height + Assert.That(skill.Intersects(player), Is.True); + } + + [Test] + public void Prism_PlayerBehindBox_NoIntersect() { + Prism skill = SkillBox(); + Prism player = PlayerAt(0, -200, 250); // behind the box + Assert.That(skill.Intersects(player), Is.False); + } + + [Test] + public void Prism_PlayerAboveBox_NoIntersect() { + // Skill box spans Z 200–500. Player is at Z 600–700. + Prism skill = SkillBox(); + Prism player = PlayerAt(0, 250, 600); + Assert.That(skill.Intersects(player), Is.False); + } + + [Test] + public void Prism_PlayerBelowBox_NoIntersect() { + // Skill box spans Z 200–500. Player is at Z 0–100. + Prism skill = SkillBox(); + Prism player = PlayerAt(0, 250, 0); + Assert.That(skill.Intersects(player), Is.False); + } + + [Test] + public void Prism_PlayerAtHeightBoundary_Intersects() { + // Player bottom (Z=190) to top (Z=290) overlaps box bottom (Z=200). + Prism skill = SkillBox(baseZ: 200); + Prism player = PlayerAt(0, 250, 190); + Assert.That(skill.Intersects(player), Is.True); + } + + [Test] + public void Prism_PlayerTopTouchesBoxBottom_Intersects() { + // Player top (Z=100+100=200) exactly touches box bottom (Z=200). + // Range.Overlaps is inclusive so boundary contact counts as intersection. + Prism skill = SkillBox(baseZ: 200); + Prism player = PlayerAt(0, 250, 100); // top = 200, box starts at 200 + Assert.That(skill.Intersects(player), Is.True); + } + + [Test] + public void Prism_PlayerClearlyBelowHeightBoundary_NoIntersect() { + Prism skill = SkillBox(baseZ: 300); + Prism player = PlayerAt(0, 250, 0); // top = 100, box starts at 300 + Assert.That(skill.Intersects(player), Is.False); + } + + [Test] + public void Prism_CylinderSkill_PlayerInside_Intersects() { + var skill = new Prism(new Circle(Vector2.Zero, 300), 0, 300); + Prism player = PlayerAt(200, 200, 100); + Assert.That(skill.Intersects(player), Is.True); + } + + [Test] + public void Prism_CylinderSkill_PlayerOutside_NoIntersect() { + var skill = new Prism(new Circle(Vector2.Zero, 100), 0, 300); + Prism player = PlayerAt(500, 0, 100); + Assert.That(skill.Intersects(player), Is.False); + } +} diff --git a/Maple2.Tools/Collision/Circle.cs b/Maple2.Tools/Collision/Circle.cs index 1912d9b01..2b6dcc49b 100644 --- a/Maple2.Tools/Collision/Circle.cs +++ b/Maple2.Tools/Collision/Circle.cs @@ -37,7 +37,8 @@ public Vector2[] GetAxes(Polygon? other) { public Range AxisProjection(Vector2 axis) { float centerProjection = Vector2.Dot(axis, Origin); - return new Range(centerProjection - Radius, centerProjection + Radius); + float scaledRadius = Radius * axis.Length(); + return new Range(centerProjection - scaledRadius, centerProjection + scaledRadius); } public virtual bool Intersects(IPolygon other) { diff --git a/Maple2.Tools/Collision/Polygon.cs b/Maple2.Tools/Collision/Polygon.cs index 88f207527..2fb7fb700 100644 --- a/Maple2.Tools/Collision/Polygon.cs +++ b/Maple2.Tools/Collision/Polygon.cs @@ -88,13 +88,13 @@ public bool Intersects(IPolygon other) { return false; } } - if (other is Polygon polygon) { - foreach (Vector2 axis in GetAxes(polygon)) { - Range range = AxisProjection(axis); - Range otherRange = other.AxisProjection(axis); - if (!range.Overlaps(otherRange)) { - return false; - } + // Always test this polygon's edge normals (required for correct SAT) + // Previously only tested when other was also a Polygon, which broke Circle vs Polygon checks + foreach (Vector2 axis in GetAxes(other as Polygon)) { + Range range = AxisProjection(axis); + Range otherRange = other.AxisProjection(axis); + if (!range.Overlaps(otherRange)) { + return false; } }