diff --git a/Content.Pirate.Client/_JustDecor/Weapons/SmartRevolver/SmartRevolverOverlay.cs b/Content.Pirate.Client/_JustDecor/Weapons/SmartRevolver/SmartRevolverOverlay.cs new file mode 100644 index 000000000000..040a00829491 --- /dev/null +++ b/Content.Pirate.Client/_JustDecor/Weapons/SmartRevolver/SmartRevolverOverlay.cs @@ -0,0 +1,97 @@ +using System.Numerics; +using Content.Pirate.Shared._JustDecor.Weapons.SmartRevolver; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.Player; +using Robust.Client.UserInterface; +using Robust.Shared.Enums; +using Robust.Shared.Maths; + +namespace Content.Pirate.Client._JustDecor.Weapons.SmartRevolver; + +/// +/// Overlay that shows a target indicator around the selected entity for the smart revolver. +/// +public sealed class SmartRevolverOverlay : Overlay +{ + private readonly IEntityManager _entityManager; + private readonly IPlayerManager _playerManager; + private readonly IEyeManager _eyeManager; + private readonly SharedHandsSystem _hands; + + public override OverlaySpace Space => OverlaySpace.ScreenSpace; + + public SmartRevolverOverlay(IEntityManager entityManager, IPlayerManager playerManager, IEyeManager eyeManager) + { + _entityManager = entityManager; + _playerManager = playerManager; + _eyeManager = eyeManager; + _hands = _entityManager.System(); + } + + protected override void Draw(in OverlayDrawArgs args) + { + var player = _playerManager.LocalSession?.AttachedEntity; + if (player == null) + return; + + if (!_hands.TryGetActiveItem(player.Value, out var activeHandEntity)) + return; + + if (!_entityManager.TryGetComponent(activeHandEntity, out var comp)) + return; + + if (comp.SelectedTarget == null || !_entityManager.EntityExists(comp.SelectedTarget.Value)) + return; + + if (!_entityManager.TryGetComponent(comp.SelectedTarget.Value, out var targetXform)) + return; + + if (targetXform.MapID != args.MapId) + return; + + var screenPos = _eyeManager.CoordinatesToScreen(targetXform.Coordinates); + var handle = args.ScreenHandle; + var uiScale = (args.ViewportControl as Control)?.UIScale ?? 1f; + + // Прицільний індикатор + var color = Color.Gold; + var boxSize = new Vector2(60f, 60f) * uiScale; + var halfSize = boxSize / 2; + var topLeft = screenPos.Position - halfSize; + var borderThickness = 2.5f * uiScale; + + // Зовнішня рамка + handle.DrawRect(UIBox2.FromDimensions(topLeft, new Vector2(boxSize.X, borderThickness)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(0, boxSize.Y - borderThickness), new Vector2(boxSize.X, borderThickness)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft, new Vector2(borderThickness, boxSize.Y)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(boxSize.X - borderThickness, 0), new Vector2(borderThickness, boxSize.Y)), color); + + // Кути для "lock-on" ефекту + var cornerLength = 12f * uiScale; + var cornerThickness = 3f * uiScale; + var cornerOffset = 5f * uiScale; + + // Верхній лівий кут + handle.DrawRect(UIBox2.FromDimensions(topLeft - new Vector2(cornerOffset, cornerOffset), new Vector2(cornerLength, cornerThickness)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft - new Vector2(cornerOffset, cornerOffset), new Vector2(cornerThickness, cornerLength)), color); + + // Верхній правий кут + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(boxSize.X - cornerLength + cornerOffset, -cornerOffset), new Vector2(cornerLength, cornerThickness)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(boxSize.X - cornerThickness + cornerOffset, -cornerOffset), new Vector2(cornerThickness, cornerLength)), color); + + // Нижній лівий кут + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(-cornerOffset, boxSize.Y - cornerThickness + cornerOffset), new Vector2(cornerLength, cornerThickness)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(-cornerOffset, boxSize.Y - cornerLength + cornerOffset), new Vector2(cornerThickness, cornerLength)), color); + + // Нижній правий кут + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(boxSize.X - cornerLength + cornerOffset, boxSize.Y - cornerThickness + cornerOffset), new Vector2(cornerLength, cornerThickness)), color); + handle.DrawRect(UIBox2.FromDimensions(topLeft + new Vector2(boxSize.X - cornerThickness + cornerOffset, boxSize.Y - cornerLength + cornerOffset), new Vector2(cornerThickness, cornerLength)), color); + + // Центральна частинка + var diamondSize = 5f * uiScale; + handle.DrawRect(UIBox2.FromDimensions(screenPos.Position - new Vector2(diamondSize / 2, diamondSize / 2), new Vector2(diamondSize, diamondSize)), color); + } +} diff --git a/Content.Pirate.Client/_JustDecor/Weapons/SmartRevolver/SmartRevolverSystem.cs b/Content.Pirate.Client/_JustDecor/Weapons/SmartRevolver/SmartRevolverSystem.cs new file mode 100644 index 000000000000..4858af70d8b9 --- /dev/null +++ b/Content.Pirate.Client/_JustDecor/Weapons/SmartRevolver/SmartRevolverSystem.cs @@ -0,0 +1,113 @@ +using Robust.Client.Graphics; +using Robust.Client.Player; +using Robust.Client.Input; +using Robust.Shared.Input; +using Robust.Shared.Input.Binding; +using Content.Shared.CombatMode; +using Robust.Shared.Network; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.IoC; +using Content.Pirate.Shared._JustDecor.Weapons.SmartRevolver; +using Content.Shared.Mobs.Components; +using Content.Shared.Damage; +using Robust.Shared.Player; +using Content.Shared.Hands.EntitySystems; +using Content.Client.ContextMenu.UI; +using Content.Client.UserInterface.Systems.Actions; +using Content.Client.UserInterface.Systems.Hands; +using Content.Client.UserInterface.Systems.Inventory; +using Content.Client.UserInterface.Systems.Storage; + +namespace Content.Pirate.Client._JustDecor.Weapons.SmartRevolver; + +public sealed class SmartRevolverSystem : EntitySystem +{ + [Dependency] private readonly SharedCombatModeSystem _combat = default!; + [Dependency] private readonly IOverlayManager _overlay = default!; + [Dependency] private readonly IPlayerManager _player = default!; + [Dependency] private readonly IEyeManager _eye = default!; + [Dependency] private readonly IEntityManager _entity = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + + private SmartRevolverOverlay _overlayInst = default!; + + public override void Initialize() + { + base.Initialize(); + + _overlayInst = new SmartRevolverOverlay(_entity, _player, _eye); + _overlay.AddOverlay(_overlayInst); + CommandBinds.Builder + .BindAfter(EngineKeyFunctions.UseSecondary, new PointerInputCmdHandler(OnRightClick), + typeof(EntityMenuUIController), + typeof(HandsUIController), + typeof(InventoryUIController), + typeof(StorageUIController), + typeof(ActionUIController)) + .Register(); + } + + private bool OnRightClick(ICommonSession? session, EntityCoordinates coords, EntityUid uid) + { + return TryHandleInstantTargeting(session, coords, uid); + } + + private bool TryHandleInstantTargeting(ICommonSession? session, EntityCoordinates coords, EntityUid uid) + { + if (session == null) + return false; + + var player = session?.AttachedEntity; + if (player == null) + return false; + + // Instant targeting only in combat mode + if (!_combat.IsInCombatMode(player.Value)) + return false; + + // Must be holding Smart Revolver + if (!_hands.TryGetActiveItem(player.Value, out var activeItem)) + return false; + + if (!TryComp(activeItem.Value, out SmartRevolverComponent? revolver)) + return false; + + EntityUid target = uid; + + if (!target.IsValid() || target == player || target == activeItem.Value) + { + RaiseNetworkEvent(new SmartRevolverSetTargetMessage(NetEntity.Invalid)); + return true; + } + + var playerPos = _transform.GetMapCoordinates(player.Value).Position; + var targetPos = target.IsValid() + ? _transform.GetMapCoordinates(target).Position + : _transform.ToMapCoordinates(coords).Position; + + if ((targetPos - playerPos).Length() > revolver.MaxTargetDistance) + { + RaiseNetworkEvent(new SmartRevolverSetTargetMessage(NetEntity.Invalid)); + return true; + } + + if (HasComp(target) || HasComp(target)) + { + RaiseNetworkEvent(new SmartRevolverSetTargetMessage(GetNetEntity(target))); + return true; + } + + // Клік в пусте місце -> очищення цілі + RaiseNetworkEvent(new SmartRevolverSetTargetMessage(NetEntity.Invalid)); + return true; + } + + public override void Shutdown() + { + base.Shutdown(); + _overlay.RemoveOverlay(_overlayInst); + CommandBinds.Unregister(); + } +} diff --git a/Content.Pirate.Shared/_JustDecor/Weapons/Ranged/RicochetProjectileComponent.cs b/Content.Pirate.Shared/_JustDecor/Weapons/Ranged/RicochetProjectileComponent.cs new file mode 100644 index 000000000000..ac461e929457 --- /dev/null +++ b/Content.Pirate.Shared/_JustDecor/Weapons/Ranged/RicochetProjectileComponent.cs @@ -0,0 +1,91 @@ +using System.Numerics; +using Robust.Shared.GameStates; + +namespace Content.Pirate.Shared._JustDecor.Weapons.Ranged; + +/// +/// Component for projectiles that can ricochet off walls to hit a target. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class RicochetProjectileComponent : Component +{ + /// + /// The target entity that the projectile should try to hit via ricochets. + /// + [DataField, AutoNetworkedField] + public EntityUid? Target; + + /// + /// Maximum number of bounces allowed before the projectile stops ricocheting. + /// + [DataField, AutoNetworkedField] + public int MaxBounces = 4; + + /// + /// Current number of bounces that have occurred. + /// + [DataField, AutoNetworkedField] + public int CurrentBounces = 0; + + /// + /// Planned waypoints for the ricochet path (wall hit positions). + /// + [DataField] + public List PlannedPath = new(); + + /// + /// Whether the projectile should follow the planned path. + /// If false, the projectile will calculate ricochets dynamically on each bounce. + /// + [DataField, AutoNetworkedField] + public bool FollowPlannedPath = true; + + /// + /// Minimum speed required for ricochet to occur. Below this speed, projectile will stop. + /// + [DataField] + public float MinimumRicochetSpeed = 5f; + + /// + /// Speed multiplier applied after each bounce (energy loss). + /// + [DataField] + public float SpeedRetentionOnBounce = 0.95f; + + /// + /// How strongly the projectile steers towards its target every frame. + /// 0.0 means no steering, 1.0 means instant snap. + /// + [DataField] + public float SteeringStrength = 1.0f; // High default for 100% hits + + /// + /// Delay before homing starts (seconds). + /// + [DataField] + public float HomingDelay = 0.2f; + + /// + /// Current accumulator for homing delay. + /// + [ViewVariables] + public float HomingAccumulator = 0f; + + /// + /// How much speed to add per bounce. + /// + [DataField] + public float SpeedBonusPerBounce = 5f; + + /// + /// Lifetime bonus applied after each bounce. + /// + [DataField] + public float BounceLifetimeBonus = 3f; + + /// + /// Target number of bounces before hitting the final target. + /// + [DataField] + public int TargetBounces = 0; +} diff --git a/Content.Pirate.Shared/_JustDecor/Weapons/Ranged/RicochetProjectileSystem.cs b/Content.Pirate.Shared/_JustDecor/Weapons/Ranged/RicochetProjectileSystem.cs new file mode 100644 index 000000000000..cb406e03b6a0 --- /dev/null +++ b/Content.Pirate.Shared/_JustDecor/Weapons/Ranged/RicochetProjectileSystem.cs @@ -0,0 +1,437 @@ +using System.Numerics; +using Content.Shared.Physics; +using Content.Shared.Projectiles; +using Content.Shared.Damage; +using Robust.Shared.Map; +using Robust.Shared.Physics; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Events; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Spawners; + +namespace Content.Pirate.Shared._JustDecor.Weapons.Ranged; + +/// +/// System that handles ricochet projectiles with improved tracking and collision handling. +/// +public sealed class RicochetProjectileSystem : EntitySystem +{ + [Dependency] private readonly SharedTransformSystem _transform = default!; + [Dependency] private readonly SharedPhysicsSystem _physics = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; + + private const float MaxSearchRadius = 50f; + private const float MinWallDistance = 0.45f; + private const float AngleSearchStep = 10f; + private const int MaxRaycasts = 256; + private const float SteeringResponsiveness = 50f; + private const float MinHomingDelay = 0.05f; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnStartup); + SubscribeLocalEvent(OnProjectileHit); + SubscribeLocalEvent(OnPreventCollide); + } + + private void OnStartup(EntityUid uid, RicochetProjectileComponent component, ComponentStartup args) + { + if (component.Target == null || !component.Target.Value.IsValid() || !TryComp(component.Target.Value, out TransformComponent? _)) + { + component.FollowPlannedPath = false; + return; + } + + component.HomingAccumulator = component.HomingDelay; + CalculateRicochetPath(uid, component); + } + + private void OnPreventCollide(EntityUid uid, RicochetProjectileComponent component, ref PreventCollideEvent args) + { + if (component.Target == null || !component.Target.Value.IsValid()) + return; + + var maxBounces = component.MaxBounces <= 0 ? component.TargetBounces : Math.Min(component.TargetBounces, component.MaxBounces); + + // Don't collide with the target UNTIL we have finished bounces or have LoS shortcut + if (args.OtherEntity == component.Target) + { + // If we have LoS and at least one bounce, we SHOULD collide + if (component.CurrentBounces >= 1) + { + if (!component.FollowPlannedPath || component.CurrentBounces >= maxBounces) + { + if (!TryComp(component.Target.Value, out TransformComponent? targetXform)) + return; + + var currentPos = _transform.GetWorldPosition(uid); + var targetPos = _transform.GetWorldPosition(targetXform); + if (HasDirectLineOfSight(currentPos, targetPos, Transform(uid).MapID, component.Target.Value)) + { + return; // Allow collision + } + } + } + + if (component.CurrentBounces < maxBounces && component.FollowPlannedPath) + { + args.Cancelled = true; + } + } + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var component, out var physics, out var xform)) + { + // Reduce homing delay + if (component.HomingAccumulator > 0) + component.HomingAccumulator -= frameTime; + + // Sync rotation + var currentVelocity = physics.LinearVelocity; + var currentSpeed = currentVelocity.Length(); + + if (currentSpeed > 0.1f) + { + _transform.SetWorldRotation(uid, currentVelocity.ToWorldAngle()); + } + + if (component.Target == null || !component.Target.Value.IsValid() || Deleted(component.Target.Value)) + continue; + + if (!TryComp(component.Target.Value, out TransformComponent? targetXform)) + continue; + + var currentPos = _transform.GetWorldPosition(xform); + var targetPos = _transform.GetWorldPosition(targetXform); + var dist = (targetPos - currentPos).Length(); + + // Manual hit fallback to prevent orbiting/flying through hitboxes + if (dist < 0.6f) + { + HandleTargetHit(uid, component); + continue; + } + + // Homing logic + var hasLoS = component.CurrentBounces >= 1 && HasDirectLineOfSight(currentPos, targetPos, xform.MapID, component.Target.Value); + + if (component.HomingAccumulator <= 0 && (component.CurrentBounces >= component.TargetBounces || hasLoS)) + { + var towardsTarget = (targetPos - currentPos).Normalized(); + var currentDir = currentVelocity.Normalized(); + + if (currentSpeed < 1f) continue; + + // Very aggressive steering + var responsiveness = component.SteeringStrength * SteeringResponsiveness; + var alpha = 1f - MathF.Exp(-responsiveness * frameTime); + var newDir = Vector2.Normalize(Vector2.Lerp(currentDir, towardsTarget, MathF.Min(alpha, 1.0f))); + _physics.SetLinearVelocity(uid, newDir * currentSpeed, body: physics); + + // Keep projectile alive for ricochet handling. + } + } + } + + private void HandleTargetHit(EntityUid uid, RicochetProjectileComponent component) + { + if (Deleted(uid) || component.Target == null) + return; + + if (Deleted(component.Target.Value)) + return; + + if (TryComp(uid, out var proj)) + { + var hitEvent = new ProjectileHitEvent(proj.Damage, component.Target.Value, proj.Shooter); + RaiseLocalEvent(uid, ref hitEvent); + _damageable.TryChangeDamage(component.Target.Value, hitEvent.Damage, proj.IgnoreResistances, origin: proj.Shooter); + + proj.DeleteOnCollide = true; + proj.ProjectileSpent = true; + _physics.SetLinearVelocity(uid, Vector2.Zero); + PredictedQueueDel(uid); + } + } + + private void OnProjectileHit(EntityUid uid, RicochetProjectileComponent component, ref ProjectileHitEvent args) + { + if (args.Target == component.Target) + { + PredictedQueueDel(uid); + return; + } + + var maxBounces = component.MaxBounces <= 0 ? component.TargetBounces : Math.Min(component.TargetBounces, component.MaxBounces); + if (component.CurrentBounces >= maxBounces) + { + if (TryComp(uid, out var proj)) + { + proj.DeleteOnCollide = true; + Dirty(uid, proj); + } + return; + } + + if (HasComp(args.Target)) + return; + + if (!TryComp(uid, out var physics)) + return; + + // Apply speed and lifetime bonus + var speed = physics.LinearVelocity.Length(); + speed *= component.SpeedRetentionOnBounce; + speed += component.SpeedBonusPerBounce; + if (speed < component.MinimumRicochetSpeed) + { + PredictedQueueDel(uid); + return; + } + + if (TryComp(uid, out var timed)) + timed.Lifetime += component.BounceLifetimeBonus; + + component.CurrentBounces++; + component.HomingAccumulator = MathF.Max(component.HomingDelay, MinHomingDelay); + + var currentPos = _transform.GetWorldPosition(uid); + var mapId = Transform(uid).MapID; + + // Find normal to push out of wall + var normal = TryGetContactNormal(uid, args.Target, out var contactNormal) + ? contactNormal + : (currentPos - _transform.GetWorldPosition(args.Target)).Normalized(); + _transform.SetWorldPosition(uid, currentPos + normal * 0.15f); + + // Check for LoS shortcut + if (component.Target != null && TryComp(component.Target.Value, out TransformComponent? tXform)) + { + var targetPos = _transform.GetWorldPosition(tXform); + if (HasDirectLineOfSight(currentPos, targetPos, mapId, component.Target.Value)) + { + var direction = (targetPos - currentPos).Normalized(); + _physics.SetLinearVelocity(uid, direction * speed, body: physics); + component.FollowPlannedPath = false; + ResetProjectileState(uid); + return; + } + } + + // Follow path or dynamic bounce + if (component.FollowPlannedPath && component.PlannedPath.Count > component.CurrentBounces) + { + var nextWaypoint = component.PlannedPath[component.CurrentBounces]; + var direction = (nextWaypoint - currentPos).Normalized(); + _physics.SetLinearVelocity(uid, direction * speed, body: physics); + ResetProjectileState(uid); + } + else + { + var velocity = CalculateDynamicBounce(uid, physics, component.Target ?? EntityUid.Invalid, speed, normal); + _physics.SetLinearVelocity(uid, velocity, body: physics); + ResetProjectileState(uid); + } + } + + private void ResetProjectileState(EntityUid uid) + { + if (TryComp(uid, out var proj)) + { + proj.DeleteOnCollide = false; + proj.ProjectileSpent = false; + Dirty(uid, proj); + } + } + + private void CalculateRicochetPath(EntityUid projectile, RicochetProjectileComponent component) + { + if (component.Target == null || !component.Target.Value.IsValid()) + { + component.FollowPlannedPath = false; + return; + } + + if (!TryComp(projectile, out TransformComponent? xform) || !TryComp(component.Target.Value, out TransformComponent? targetXform)) + { + component.FollowPlannedPath = false; + return; + } + + var startPos = _transform.GetWorldPosition(xform); + var targetPos = _transform.GetWorldPosition(targetXform); + var mapId = xform.MapID; + + component.PlannedPath.Clear(); + + var visited = new HashSet(); + var depth = component.MaxBounces <= 0 ? component.TargetBounces : Math.Min(component.TargetBounces, component.MaxBounces); + var raycastBudget = MaxRaycasts; + var path = TryRecursiveSolve(startPos, targetPos, mapId, depth, component.Target.Value, visited, 0, ref raycastBudget); + + if (path != null) + { + component.PlannedPath.AddRange(path); + component.FollowPlannedPath = true; + if (path.Count > 0 && TryComp(projectile, out var physics)) + { + var dir = (path[0] - startPos).Normalized(); + _physics.SetLinearVelocity(projectile, dir * physics.LinearVelocity.Length(), body: physics); + } + } + else + { + component.FollowPlannedPath = false; + } + } + + private List? TryRecursiveSolve(Vector2 current, Vector2 target, MapId mapId, int depth, EntityUid targetEnt, HashSet visited, int currentDepth, ref int raycastBudget) + { + if (depth == 0) + return HasDirectLineOfSight(current, target, mapId, targetEnt, ref raycastBudget) ? new List() : null; + + if (currentDepth > 8 || raycastBudget <= 0) return null; + + for (float angle = 0; angle < 360; angle += AngleSearchStep) + { + var dir = Angle.FromDegrees(angle).ToWorldVec(); + var ray = new CollisionRay(current, dir, (int) (CollisionGroup.Impassable | CollisionGroup.MidImpassable | CollisionGroup.BulletImpassable)); + if (!TryGetFirstRayHit(mapId, ray, MaxSearchRadius, out var hit, ref raycastBudget)) + continue; + + if (visited.Contains(hit.HitEntity)) continue; + + var normal = (current - hit.HitPos).Normalized(); + var nextPos = hit.HitPos + normal * MinWallDistance; + + visited.Add(hit.HitEntity); + var subPath = TryRecursiveSolve(nextPos, target, mapId, depth - 1, targetEnt, visited, currentDepth + 1, ref raycastBudget); + visited.Remove(hit.HitEntity); + + if (subPath != null) + { + subPath.Insert(0, hit.HitPos); + return subPath; + } + } + return null; + } + + private bool HasDirectLineOfSight(Vector2 from, Vector2 to, MapId mapId, EntityUid targetEntity) + { + var direction = (to - from).Normalized(); + var distance = (to - from).Length(); + if (distance < 0.2f) return true; + + var ray = new CollisionRay(from, direction, (int) (CollisionGroup.Impassable | CollisionGroup.MidImpassable | CollisionGroup.BulletImpassable)); + if (!TryGetFirstRayHit(mapId, ray, distance, out var hit)) + return true; + + return hit.HitEntity == targetEntity; + } + + private bool HasDirectLineOfSight(Vector2 from, Vector2 to, MapId mapId, EntityUid targetEntity, ref int raycastBudget) + { + var direction = (to - from).Normalized(); + var distance = (to - from).Length(); + if (distance < 0.2f) return true; + + if (raycastBudget <= 0) + return false; + + var ray = new CollisionRay(from, direction, (int) (CollisionGroup.Impassable | CollisionGroup.MidImpassable | CollisionGroup.BulletImpassable)); + if (!TryGetFirstRayHit(mapId, ray, distance, out var hit, ref raycastBudget)) + return true; + + return hit.HitEntity == targetEntity; + } + + private bool TryGetFirstRayHit(MapId mapId, CollisionRay ray, float distance, out RayCastResults hit) + { + foreach (var result in _physics.IntersectRay(mapId, ray, distance, returnOnFirstHit: true)) + { + hit = result; + return true; + } + + hit = default; + return false; + } + + private bool TryGetFirstRayHit(MapId mapId, CollisionRay ray, float distance, out RayCastResults hit, ref int raycastBudget) + { + if (raycastBudget <= 0) + { + hit = default; + return false; + } + + raycastBudget--; + + foreach (var result in _physics.IntersectRay(mapId, ray, distance, returnOnFirstHit: true)) + { + hit = result; + return true; + } + + hit = default; + return false; + } + + private bool TryGetContactNormal(EntityUid uid, EntityUid target, out Vector2 normal) + { + normal = Vector2.Zero; + + if (!TryComp(uid, out FixturesComponent? fixtures)) + return false; + + var contacts = _physics.GetContacts((uid, fixtures)); + while (contacts.MoveNext(out var contact)) + { + if (contact == null) + continue; + + var bodyA = contact.BodyA; + var bodyB = contact.BodyB; + if (bodyA == null || bodyB == null) + continue; + + if (bodyA.Owner != uid && bodyB.Owner != uid) + continue; + + var other = bodyA.Owner == uid ? bodyB.Owner : bodyA.Owner; + if (other != target) + continue; + + var (posA, rotA) = _transform.GetWorldPositionRotation(bodyA.Owner); + var (posB, rotB) = _transform.GetWorldPositionRotation(bodyB.Owner); + var transformA = new Robust.Shared.Physics.Transform(posA, rotA); + var transformB = new Robust.Shared.Physics.Transform(posB, rotB); + contact.GetWorldManifold(transformA, transformB, out var contactNormal); + + if (bodyA.Owner == uid) + contactNormal = -contactNormal; + + normal = contactNormal.Normalized(); + return true; + } + + return false; + } + + private Vector2 CalculateDynamicBounce(EntityUid projectile, PhysicsComponent physics, EntityUid target, float speed, Vector2 normal) + { + var reflect = Vector2.Reflect(physics.LinearVelocity.Normalized(), normal); + if (target == EntityUid.Invalid || !Exists(target)) return reflect * speed; + + var toTarget = (_transform.GetWorldPosition(target) - _transform.GetWorldPosition(projectile)).Normalized(); + return (reflect * 0.3f + toTarget * 0.7f).Normalized() * speed; + } +} diff --git a/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverComponent.cs b/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverComponent.cs new file mode 100644 index 000000000000..61e8c0b0aefd --- /dev/null +++ b/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverComponent.cs @@ -0,0 +1,68 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; +using Content.Shared.Actions; + +namespace Content.Pirate.Shared._JustDecor.Weapons.SmartRevolver; + +/// +/// Компонент для розумного револьвера, що може вибирати цілі і стріляти рекошетними патронами. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class SmartRevolverComponent : Component +{ + /// + /// Вибрана ціль. + /// + [DataField, AutoNetworkedField] + public EntityUid? SelectedTarget; + + /// + /// Максимальна дистанція для вибору цілі. + /// + [DataField] + public float MaxTargetDistance = 50f; + + /// + /// Чи показувати візуальний превью траєкторії (опціональна функція). + /// + [DataField] + public bool ShowTrajectory = false; + + /// + /// Мінімальна кількість відскоків для куль, випущених з цієї зброї. + /// + [DataField] + public int MinRicochets = 1; + + /// + /// Максимальна кількість відскоків для куль, випущених з цієї зброї. + /// + [DataField] + public int MaxRicochets = 4; + + /// + /// Список всіх доступних цілей для циклу. + /// + [ViewVariables] + public List AvailableTargets = new(); + + /// + /// Поточний індекс у списку доступних цілей. + /// + [ViewVariables] + public int CurrentTargetIndex = 0; + + /// + /// Дія для циклу по цілях. + /// + [DataField] + public EntityUid? CycleTargetAction; +} + +/// +/// Івент для циклу по цілях. +/// +[DataDefinition] +public sealed partial class CycleSmartRevolverTargetEvent : InstantActionEvent +{ +} diff --git a/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverMessages.cs b/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverMessages.cs new file mode 100644 index 000000000000..90dd8ed948c6 --- /dev/null +++ b/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverMessages.cs @@ -0,0 +1,18 @@ +using Robust.Shared.Serialization; +using Robust.Shared.GameObjects; + +namespace Content.Pirate.Shared._JustDecor.Weapons.SmartRevolver; + +/// +/// Client sends this to server to request setting a target. +/// +[Serializable, NetSerializable] +public sealed class SmartRevolverSetTargetMessage : EntityEventArgs +{ + public NetEntity Target; + + public SmartRevolverSetTargetMessage(NetEntity target) + { + Target = target; + } +} diff --git a/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverSystem.cs b/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverSystem.cs new file mode 100644 index 000000000000..253576dfc10e --- /dev/null +++ b/Content.Pirate.Shared/_JustDecor/Weapons/SmartRevolver/SmartRevolverSystem.cs @@ -0,0 +1,286 @@ +using Content.Pirate.Shared._JustDecor.Weapons.Ranged; +using Content.Shared.Actions; +using Content.Shared.CombatMode; +using Content.Shared.Damage; +using Content.Shared.Damage.Components; +using Content.Shared.Mobs.Components; +using Content.Shared.Popups; +using Content.Shared.Weapons.Ranged.Components; +using Content.Shared.Weapons.Ranged.Events; +using Content.Shared.Weapons.Ranged.Systems; +using Content.Shared.Projectiles; +using Content.Shared.Verbs; +using Content.Shared.Examine; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Network; +using Robust.Shared.Player; +using Robust.Shared.Utility; + +namespace Content.Pirate.Shared._JustDecor.Weapons.SmartRevolver; + +/// +/// System that handles smart revolver target selection, cycling, and ricochet bullet creation. +/// +public sealed class SmartRevolverSystem : EntitySystem +{ + [Dependency] private readonly SharedTransformSystem _transform = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + [Dependency] private readonly SharedGunSystem _gun = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly ActionContainerSystem _actions = default!; + [Dependency] private readonly SharedCombatModeSystem _combatMode = default!; + [Dependency] private readonly Content.Shared.Hands.EntitySystems.SharedHandsSystem _hands = default!; + [Dependency] private readonly Robust.Shared.Random.IRobustRandom _random = default!; + [Dependency] private readonly ExamineSystemShared _examine = default!; + [Dependency] private readonly EntityLookupSystem _lookup = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnMapInit); + SubscribeLocalEvent(OnAmmoShot); + SubscribeLocalEvent>(OnAlternativeVerb); + SubscribeLocalEvent(OnCycleTarget); + SubscribeLocalEvent(OnGetItemActions); + + SubscribeNetworkEvent(OnSetTargetMessage); + } + + private void OnMapInit(EntityUid uid, SmartRevolverComponent component, MapInitEvent args) + { + _actions.EnsureAction(uid, ref component.CycleTargetAction, "ActionCycleSmartRevolverTarget"); + } + + private void OnGetItemActions(EntityUid uid, SmartRevolverComponent component, GetItemActionsEvent args) + { + args.AddAction(ref component.CycleTargetAction, "ActionCycleSmartRevolverTarget"); + } + + private void OnAlternativeVerb(EntityUid uid, SmartRevolverComponent component, GetVerbsEvent args) + { + if (!args.CanInteract || !IsValidTarget(args.Target)) + return; + + args.Verbs.Add(new AlternativeVerb + { + Act = () => SetTarget(uid, component, args.Target, args.User), + Text = Loc.GetString("smart-revolver-target-selection"), + Icon = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/VerbIcons/scope.svg.192dpi.png")), + Priority = 100 + }); + } + + private void OnSetTargetMessage(SmartRevolverSetTargetMessage msg, EntitySessionEventArgs args) + { + var user = args.SenderSession.AttachedEntity; + if (user == null || !_combatMode.IsInCombatMode(user.Value)) + return; + + if (!_hands.TryGetActiveItem(user.Value, out var heldEntity)) + return; + + if (!TryComp(heldEntity, out var component)) + return; + + var target = GetEntity(msg.Target); + + // Логіка: якщо клікнути на ту саму ціль, очистити + if (component.SelectedTarget == target) + { + ClearTarget(heldEntity.Value, component, user.Value); + return; + } + if (msg.Target == NetEntity.Invalid || !IsValidTarget(target)) + { + ClearTarget(heldEntity.Value, component, user.Value); + return; + } + + var userPos = _transform.GetMapCoordinates(user.Value); + var targetPos = _transform.GetMapCoordinates(target); + + if (targetPos.MapId != userPos.MapId) + { + ClearTarget(heldEntity.Value, component, user.Value); + return; + } + + var distance = (targetPos.Position - userPos.Position).Length(); + if (distance > component.MaxTargetDistance || !_examine.InRangeUnOccluded(user.Value, target, component.MaxTargetDistance)) + { + ClearTarget(heldEntity.Value, component, user.Value); + return; + } + + SetTarget(heldEntity.Value, component, target, user.Value); + } + + private void OnAmmoShot(EntityUid uid, SmartRevolverComponent component, ref AmmoShotEvent args) + { + if (!TryComp(uid, out GunComponent? gun)) + return; + + var target = component.SelectedTarget ?? gun.Target; + + if (target == null) + return; + + if (!IsValidTarget(target.Value)) + { + ClearTarget(uid, component, null); + return; + } + + var revolverPos = _transform.GetMapCoordinates(uid); + var targetPos = _transform.GetMapCoordinates(target.Value); + if (targetPos.MapId != revolverPos.MapId || + (targetPos.Position - revolverPos.Position).Length() > component.MaxTargetDistance) + { + ClearTarget(uid, component, null); + return; + } + + foreach (var projectile in args.FiredProjectiles) + { + if (TryComp(projectile, out var proj)) + { + proj.DeleteOnCollide = false; + Dirty(projectile, proj); + } + + var ricochet = EnsureComp(projectile); + ricochet.Target = target; + + ricochet.TargetBounces = _random.Next(component.MinRicochets, component.MaxRicochets + 1); + ricochet.MaxBounces = ricochet.TargetBounces; + + ricochet.FollowPlannedPath = true; + Dirty(projectile, ricochet); + } + } + + private void OnCycleTarget(EntityUid uid, SmartRevolverComponent component, CycleSmartRevolverTargetEvent args) + { + if (args.Handled) + return; + + if (!_net.IsServer) + return; + + if (!args.Performer.IsValid()) + return; + + UpdateAvailableTargets(uid, component, args.Performer); + + if (component.AvailableTargets.Count == 0) + { + _popup.PopupEntity(Loc.GetString("smart-revolver-no-valid-targets"), uid, PopupType.Medium); + args.Handled = true; + return; + } + + component.CurrentTargetIndex = (component.CurrentTargetIndex + 1) % component.AvailableTargets.Count; + var newTarget = component.AvailableTargets[component.CurrentTargetIndex]; + + SetTarget(uid, component, newTarget, null); + args.Handled = true; + } + + public void SetTarget(EntityUid revolverUid, SmartRevolverComponent component, EntityUid target, EntityUid? user) + { + component.SelectedTarget = target; + Dirty(revolverUid, component); + + if (_net.IsServer) + { + var targetName = MetaData(target).EntityName; + var message = Loc.GetString("smart-revolver-target-set", ("target", targetName)); + + if (user != null && Exists(user.Value)) + { + _popup.PopupEntity(message, revolverUid, user.Value, PopupType.Medium); + } + else + { + _popup.PopupEntity(message, revolverUid, PopupType.Medium); + } + } + } + + public void ClearTarget(EntityUid revolverUid, SmartRevolverComponent component, EntityUid? user) + { + if (component.SelectedTarget == null) + return; + + component.SelectedTarget = null; + component.AvailableTargets.Clear(); + component.CurrentTargetIndex = 0; + Dirty(revolverUid, component); + + if (_net.IsServer) + { + var message = Loc.GetString("smart-revolver-target-cleared"); + + if (user != null && Exists(user.Value)) + { + _popup.PopupEntity(message, revolverUid, user.Value, PopupType.Small); + } + else + { + _popup.PopupEntity(message, revolverUid, PopupType.Small); + } + } + } + + private void UpdateAvailableTargets(EntityUid revolverUid, SmartRevolverComponent component, EntityUid user) + { + component.AvailableTargets.Clear(); + + var revolverPos = _transform.GetMapCoordinates(revolverUid); + foreach (var uid in _lookup.GetEntitiesInRange(revolverPos, component.MaxTargetDistance)) + { + if (uid == revolverUid || uid == user) + continue; + + if (!IsValidCycleTarget(uid)) + continue; + + if (!_examine.InRangeUnOccluded(user, uid, component.MaxTargetDistance)) + continue; + + if (!TryComp(uid, out TransformComponent? xform)) + continue; + + var targetPos = _transform.GetMapCoordinates(uid, xform); + if (targetPos.MapId != revolverPos.MapId) + continue; + + var distance = (targetPos.Position - revolverPos.Position).Length(); + if (distance > component.MaxTargetDistance) + continue; + + component.AvailableTargets.Add(uid); + } + } + + private bool IsValidTarget(EntityUid target) + { + // Перевіряємо чи ціль існує + if (!Exists(target) || Deleted(target)) + return false; + + // Перевіряємо наявність MobStateComponent або DamageableComponent + return HasComp(target) || + HasComp(target); + } + + private bool IsValidCycleTarget(EntityUid target) + { + if (!Exists(target) || Deleted(target)) + return false; + + return HasComp(target) || HasComp(target); + } +} diff --git a/Resources/Locale/uk-UA/_Pirate/ui/smart-revolver.ftl b/Resources/Locale/uk-UA/_Pirate/ui/smart-revolver.ftl new file mode 100644 index 000000000000..e0721db15583 --- /dev/null +++ b/Resources/Locale/uk-UA/_Pirate/ui/smart-revolver.ftl @@ -0,0 +1,4 @@ +smart-revolver-target-selection = Вибрати ціль розумного револьвера +smart-revolver-no-valid-targets = Немає дійсних цілей у полі зору! +smart-revolver-target-set = Ціль встановлена: { $target } +smart-revolver-target-cleared = Ціль очищена diff --git a/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Actions/weapon_actions.yml b/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Actions/weapon_actions.yml new file mode 100644 index 000000000000..0c49c793aa98 --- /dev/null +++ b/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Actions/weapon_actions.yml @@ -0,0 +1,12 @@ +- type: entity + parent: BaseAction + id: ActionCycleSmartRevolverTarget + name: Прокрутка цілей + description: Нехай доля обирає наступну жертву. + components: + - type: Action + icon: Interface/VerbIcons/refresh.svg.192dpi.png + useDelay: 0.5 + itemIconStyle: BigItem + - type: InstantAction + event: !type:CycleSmartRevolverTargetEvent diff --git a/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Entities/ocelot_ammo.yml b/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Entities/ocelot_ammo.yml new file mode 100644 index 000000000000..feee041a6fa9 --- /dev/null +++ b/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Entities/ocelot_ammo.yml @@ -0,0 +1,43 @@ +- type: entity + id: CartridgeSmartMagnum + name: ".45 магнум розумний картридж" + parent: BaseCartridgeMagnum + suffix: JustDecor + components: + - type: CartridgeAmmo + proto: BulletSmartMagnum + - type: Sprite + layers: + - state: base + map: [ "enum.AmmoVisualLayers.Base" ] + - state: tip + map: [ "enum.AmmoVisualLayers.Tip" ] + color: "#FFD700" + +- type: entity + id: BulletSmartMagnum + name: ".45 розумний магнум патрон" + suffix: JustDecor + parent: BulletMagnum + components: + - type: RicochetProjectile + steeringStrength: 3.0 + speedBonusPerBounce: 8.0 + - type: TimedDespawn + lifetime: 10.0 + - type: Projectile + damage: + types: + Piercing: 35 + Structural: 15 + deleteOnCollide: false # We handle it in the system + +- type: entity + id: SpeedLoaderMagnumSmart + name: "speed loader (.45 магнум розумний)" + parent: SpeedLoaderMagnum + suffix: JustDecor + components: + - type: BallisticAmmoProvider + proto: CartridgeSmartMagnum + capacity: 8 diff --git a/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Entities/smart_revolver.yml b/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Entities/smart_revolver.yml new file mode 100644 index 000000000000..3be68e102141 --- /dev/null +++ b/Resources/Prototypes/_Pirate/_JustDecor/_BigBos/Entities/smart_revolver.yml @@ -0,0 +1,42 @@ +- type: entity + name: "Single Action Army" + parent: [WeaponRevolverMateba, BaseCentcommContraband] + id: WeaponRevolverSingleActionArmy + suffix: JustDecor + description: "Найкращий пістолет, який коли-небудь виготовляли. Single Action Army. Вісім куль... Більш ніж достатньо, щоб вбити все, що рухається. Цей легендарний револьвер оснащений вдосконаленою системою наведення, яка дозволяє кулям відскакувати від стін і влучати у вибрані цілі. На стволі надпис \"Ocelot\"." + components: + - type: Sprite + sprite: Objects/Weapons/Guns/Revolvers/mateba.rsi + state: icon + color: "#FFD700" # Gold color for legendary status + - type: Clothing + sprite: Objects/Weapons/Guns/Revolvers/mateba.rsi + - type: Gun + fireRate: 2 + soundGunshot: + path: /Audio/Weapons/Guns/Gunshots/revolver.ogg + params: + volume: 3 + - type: RevolverAmmoProvider + whitelist: + tags: + - CartridgeMagnum + - SpeedLoaderMagnum + proto: CartridgeSmartMagnum + capacity: 8 + chambers: [ True, True, True, True, True, True, True, True ] + ammoSlots: [ null, null, null, null, null, null, null, null ] + soundEject: + path: /Audio/Weapons/Guns/MagOut/revolver_magout.ogg + soundInsert: + path: /Audio/Weapons/Guns/MagIn/revolver_magin.ogg + - type: SmartRevolver + maxTargetDistance: 50 + showTrajectory: false + minRicochets: 1 + maxRicochets: 4 + - type: StaticPrice + price: 10000 + - type: Tag + tags: + - Sidearm