From 3c7d21a8e1aea8a667a7a1990daf975612c36cc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:17 -0300 Subject: [PATCH 01/10] Fix SAT intersection bug for Circle vs Polygon Polygon.Intersects() was only testing the polygon's own edge normals when the other shape was also a Polygon. When intersecting with a Circle, those axes were never tested, causing all Circle vs Polygon checks to incorrectly return true regardless of position. --- Maple2.Tools/Collision/Polygon.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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; } } From 5f5b49b7dcd6c970947748700060bb89e5d99e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:26 -0300 Subject: [PATCH 02/10] Fix NPC skill hitbox direction and target resolution - GetPrism: use Trapezoid instead of Rectangle for Box type so the hitbox projects forward from the caster instead of being centered. Apply RotateZDegree, RangeOffset, and +180 to match the NPC's facing convention - SkillState: always resolve targets using the attack range prism rather than falling back to the client-provided target list - Actor.TargetAttack: fall back to caster position when ImpactPosition is unset (NPC casts never set it from a client packet) --- Maple2.Server.Game/Model/Field/Actor/Actor.cs | 4 +++- .../Field/Actor/ActorStateComponent/SkillState.cs | 14 ++++++-------- Maple2.Server.Game/Util/SkillUtils.cs | 12 ++++++++---- 3 files changed, 17 insertions(+), 13 deletions(-) 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/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index 1d231b8d5..07ca5b9a3 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Maple2.Model.Metadata; using Maple2.Server.Game.Model.Skill; using Maple2.Server.Game.Packets; @@ -67,13 +67,11 @@ 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 + Maple2.Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); + var resolvedTargets = new List(); + foreach (IActor target in actor.Field.GetTargets(actor, new[] { attackPrism }, attack.Range.ApplyTarget, attack.TargetCount)) { + resolvedTargets.Add(target); } if (resolvedTargets.Count > 0) { diff --git a/Maple2.Server.Game/Util/SkillUtils.cs b/Maple2.Server.Game/Util/SkillUtils.cs index 7ebde4a34..f3a560837 100644 --- a/Maple2.Server.Game/Util/SkillUtils.cs +++ b/Maple2.Server.Game/Util/SkillUtils.cs @@ -15,16 +15,20 @@ 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), + // Use Trapezoid with equal widths for Box - projects forward from caster (0 to distance) + // Rectangle is centered on origin which is wrong for skill hitboxes + 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 { From 726f47bd4acdd6e114605d4bcc96d13f0d0e1e28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:30 -0300 Subject: [PATCH 03/10] Add --no-ai flag to npc spawn command Allows spawning NPCs without AI so they stand still, useful for testing skill hitboxes without the NPC moving or attacking. --- Maple2.Server.Game/Commands/NpcCommand.cs | 8 +++++--- .../Manager/Field/FieldManager/FieldManager.State.cs | 7 ++++--- 2 files changed, 9 insertions(+), 6 deletions(-) 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) { From 9c62bf5669ea76651ec2d613e82746fc7fb627ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:34 -0300 Subject: [PATCH 04/10] Add mob-attack command for testing NPC skill hitboxes Allows forcing a spawned NPC to cast a specific skill at a chosen attack point, making it easier to test individual hitboxes in isolation. --- .../Commands/MobAttackCommand.cs | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 Maple2.Server.Game/Commands/MobAttackCommand.cs diff --git a/Maple2.Server.Game/Commands/MobAttackCommand.cs b/Maple2.Server.Game/Commands/MobAttackCommand.cs new file mode 100644 index 000000000..61439dd05 --- /dev/null +++ b/Maple2.Server.Game/Commands/MobAttackCommand.cs @@ -0,0 +1,67 @@ +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; + } + + // 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})."); + } +} From 55cbb4e0963ff8c3ea426ce48bf7a36917710147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:46 -0300 Subject: [PATCH 05/10] Add skill hitbox visualization to DebugGame Renders the current attack point's prism wireframe in orange for all actors with active skills. Toggleable via the Visualization Controls window. NPC skills are sourced from MovementState.CastTask; player skills from ActiveSkills when in a Skill animation. Supporting changes: - Pass skill metadata to TryPlaySequence so AnimationRecord tracks which skill is active for NPCs - Add SkillQueue.GetMostRecent() for player skill lookup --- .../Graphics/DebugFieldRenderer.cs | 121 ++++++++++++++++++ .../Ui/Windows/VisualizationControlsWindow.cs | 1 + .../MovementState.SkillCastTask.cs | 2 +- Maple2.Server.Game/Model/Skill/SkillQueue.cs | 14 ++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs index 83264743e..f1e7849a8 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 = true; // 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,114 @@ 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); + instanceBuffer.Transformation = Matrix4x4.Transpose( + Matrix4x4.CreateScale(new Vector3(avgWidth, distance, height)) * + rotation * + Matrix4x4.CreateTranslation(center)); + + UpdateWireframeInstance(window); + Context.CoreModels!.WireCube.Draw(); + } + else { + // For other polygon types, render a simple box at the prism position + // This is a fallback - extend as needed for other shapes + Vector3 center = new(0, 0, prism.Height.Min + (prism.Height.Max - prism.Height.Min) * 0.5f); + float height = prism.Height.Max - prism.Height.Min; + + 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/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/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; + } } From 4be8a0c456aad3b0b9f3b0208170b4d0709860c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:51 -0300 Subject: [PATCH 06/10] Fix safe revive blocked when RevivalReturnId is 0 The NoRevivalHere check was also gating on RevivalReturnId == 0, preventing revival in maps that have no return point configured but should still allow in-place revive. --- Maple2.Server.Game/PacketHandlers/RevivalHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f83399b5c73bfc596db0373e4f1d7d168bb2aca5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 14:45:59 -0300 Subject: [PATCH 07/10] Fix SkillMapper reading height instead of width for range Width SkillMetadataRange.Width was incorrectly set to region.height, causing every skill's hitbox width to use the height value instead. Requires re-ingesting skill data. --- Maple2.File.Ingest/Mapper/SkillMapper.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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, From 86862c40b63067775b0aec404af40525aa5c850e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 19:57:25 -0300 Subject: [PATCH 08/10] Fix circle projection; add collision tests Scale circle projection by axis length to fix SAT calculations: Circle.AxisProjection now multiplies Radius by axis.Length(). Add comprehensive collision unit tests: PolygonIntersectTests (polygon vs polygon/circle SAT cases) and PrismTests (3D prism height+polygon overlap). Adjust existing CircleTests and HoleCircleTest coordinates to match corrected geometry expectations. Minor code/cleanup: simplify Prism type reference in SkillState and update how the attack prism is passed to GetTargets; clarify Box behavior comment in SkillUtils. --- .../Actor/ActorStateComponent/SkillState.cs | 4 +- Maple2.Server.Game/Util/SkillUtils.cs | 3 +- .../Tools/Collision/CircleTests.cs | 3 +- .../Tools/Collision/HoleCircleTest.cs | 4 +- .../Tools/Collision/PolygonIntersectTests.cs | 104 ++++++++++++++++++ .../Tools/Collision/PrismTests.cs | 86 +++++++++++++++ Maple2.Tools/Collision/Circle.cs | 3 +- 7 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs create mode 100644 Maple2.Server.Tests/Tools/Collision/PrismTests.cs diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index 07ca5b9a3..cbd6a5beb 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -68,9 +68,9 @@ public void SkillCastAttack(SkillRecord cast, byte attackPoint, List att // Apply damage to targets server-side for NPC attacks // Always use the attack range prism to resolve targets so spatial checks are respected - Maple2.Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); + Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); var resolvedTargets = new List(); - foreach (IActor target in actor.Field.GetTargets(actor, new[] { attackPrism }, attack.Range.ApplyTarget, attack.TargetCount)) { + foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range.ApplyTarget, attack.TargetCount)) { resolvedTargets.Add(target); } diff --git a/Maple2.Server.Game/Util/SkillUtils.cs b/Maple2.Server.Game/Util/SkillUtils.cs index f3a560837..d0147dff0 100644 --- a/Maple2.Server.Game/Util/SkillUtils.cs +++ b/Maple2.Server.Game/Util/SkillUtils.cs @@ -19,8 +19,7 @@ public static Prism GetPrism(this SkillMetadataRange range, in Vector3 position, 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 { - // Use Trapezoid with equal widths for Box - projects forward from caster (0 to distance) - // Rectangle is centered on origin which is wrong for skill hitboxes + // 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, adjustedAngle), 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..ec6c4df9e --- /dev/null +++ b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs @@ -0,0 +1,104 @@ +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 uses Radius directly against non-normalized edge +/// normals, so barely-touching-edge cases may not be reliable. Tests use shapes +/// that are clearly inside or clearly outside. +/// +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..205192978 --- /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) { From 03b25b10de122591dd62d457185032f884512f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 21:13:54 -0300 Subject: [PATCH 09/10] format --- Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs | 6 ++---- Maple2.Server.Game/Commands/MobAttackCommand.cs | 2 +- .../Model/Field/Actor/ActorStateComponent/SkillState.cs | 2 +- .../Tools/Collision/PolygonIntersectTests.cs | 2 +- Maple2.Server.Tests/Tools/Collision/PrismTests.cs | 2 +- 5 files changed, 6 insertions(+), 8 deletions(-) diff --git a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs index f1e7849a8..442ef9de5 100644 --- a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs +++ b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs @@ -1289,8 +1289,7 @@ private void RenderPrism(DebugFieldWindow window, Prism prism, SkillMetadataRang UpdateWireframeInstance(window); Context.CoreModels!.Cylinder.Draw(); - } - else if (polygon is Trapezoid trapezoid) { + } 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]; @@ -1318,8 +1317,7 @@ private void RenderPrism(DebugFieldWindow window, Prism prism, SkillMetadataRang UpdateWireframeInstance(window); Context.CoreModels!.WireCube.Draw(); - } - else { + } else { // For other polygon types, render a simple box at the prism position // This is a fallback - extend as needed for other shapes Vector3 center = new(0, 0, prism.Height.Min + (prism.Height.Max - prism.Height.Min) * 0.5f); diff --git a/Maple2.Server.Game/Commands/MobAttackCommand.cs b/Maple2.Server.Game/Commands/MobAttackCommand.cs index 61439dd05..915a0507a 100644 --- a/Maple2.Server.Game/Commands/MobAttackCommand.cs +++ b/Maple2.Server.Game/Commands/MobAttackCommand.cs @@ -1,4 +1,4 @@ -using System.CommandLine; +using System.CommandLine; using System.CommandLine.Invocation; using System.CommandLine.IO; using Maple2.Model.Enum; diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index cbd6a5beb..09b43bb48 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Maple2.Model.Metadata; using Maple2.Server.Game.Model.Skill; using Maple2.Server.Game.Packets; diff --git a/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs index ec6c4df9e..151cfdc35 100644 --- a/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs +++ b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Maple2.Tools.Collision; namespace Maple2.Server.Tests.Tools.Collision; diff --git a/Maple2.Server.Tests/Tools/Collision/PrismTests.cs b/Maple2.Server.Tests/Tools/Collision/PrismTests.cs index 205192978..45da4d77b 100644 --- a/Maple2.Server.Tests/Tools/Collision/PrismTests.cs +++ b/Maple2.Server.Tests/Tools/Collision/PrismTests.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Maple2.Tools.Collision; namespace Maple2.Server.Tests.Tools.Collision; From b6b9c335fa655ce48d0c0fbe0329cf06140b7dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=82ngelo=20Tadeucci?= Date: Tue, 17 Feb 2026 21:31:15 -0300 Subject: [PATCH 10/10] Adjust debug renderer and fix target acquisition Change debug renderer defaults and visuals: disable skill hitbox rendering by default, add comments about visual approximation for trapezoids, and compute 2D center from polygon vertices for fallback prism rendering. Add a warning in the MobAttackCommand when a mob casts a skill not present in its skill list. Fix SkillState target resolution by ensuring the field query uses a minimum query limit (uses attack.TargetCount if >0, otherwise 1) when resolving targets. Also update a test comment to clarify how circle axis projections are scaled. --- .../Graphics/DebugFieldRenderer.cs | 17 +++++++++++++---- Maple2.Server.Game/Commands/MobAttackCommand.cs | 5 +++++ .../Actor/ActorStateComponent/SkillState.cs | 3 ++- .../Tools/Collision/PolygonIntersectTests.cs | 5 ++--- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs index 442ef9de5..8c56be911 100644 --- a/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs +++ b/Maple2.Server.DebugGame/Graphics/DebugFieldRenderer.cs @@ -59,7 +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 = true; // toggle for rendering active skill hitboxes + public bool ShowSkillHitboxes = false; // toggle for rendering active skill hitboxes public bool PlayerMoveMode; public bool ForceMove; @@ -1310,6 +1310,8 @@ private void RenderPrism(DebugFieldWindow window, Prism prism, SkillMetadataRang 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 * @@ -1318,10 +1320,17 @@ private void RenderPrism(DebugFieldWindow window, Prism prism, SkillMetadataRang UpdateWireframeInstance(window); Context.CoreModels!.WireCube.Draw(); } else { - // For other polygon types, render a simple box at the prism position - // This is a fallback - extend as needed for other shapes - Vector3 center = new(0, 0, prism.Height.Min + (prism.Height.Max - prism.Height.Min) * 0.5f); + // 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)) * diff --git a/Maple2.Server.Game/Commands/MobAttackCommand.cs b/Maple2.Server.Game/Commands/MobAttackCommand.cs index 915a0507a..5dd356a32 100644 --- a/Maple2.Server.Game/Commands/MobAttackCommand.cs +++ b/Maple2.Server.Game/Commands/MobAttackCommand.cs @@ -60,6 +60,11 @@ private void Handle(InvocationContext ctx, int? skillId, short skillLevel) { 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/Model/Field/Actor/ActorStateComponent/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index 09b43bb48..8eb4038a0 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -70,7 +70,8 @@ public void SkillCastAttack(SkillRecord cast, byte attackPoint, List att // 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(); - foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range.ApplyTarget, attack.TargetCount)) { + int queryLimit = attack.TargetCount > 0 ? attack.TargetCount : 1; + foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range.ApplyTarget, queryLimit)) { resolvedTargets.Add(target); } diff --git a/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs index 151cfdc35..c8cc31715 100644 --- a/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs +++ b/Maple2.Server.Tests/Tools/Collision/PolygonIntersectTests.cs @@ -10,9 +10,8 @@ namespace Maple2.Server.Tests.Tools.Collision; /// Trapezoid at angle=0 extends from origin along +Y: /// P0=(-halfW, 0), P1=(halfW, 0), P2=(halfEndW, dist), P3=(-halfEndW, dist) /// -/// Note: Circle.AxisProjection uses Radius directly against non-normalized edge -/// normals, so barely-touching-edge cases may not be reliable. Tests use shapes -/// that are clearly inside or clearly outside. +/// 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°).