diff --git a/Content.Server/_Fish/Leash/Components/LeashComponent.cs b/Content.Server/_Fish/Leash/Components/LeashComponent.cs new file mode 100644 index 00000000000..1101d099f46 --- /dev/null +++ b/Content.Server/_Fish/Leash/Components/LeashComponent.cs @@ -0,0 +1,44 @@ +using Content.Shared.Damage; + +namespace Content.Server._Fish.Leash.Components; + +[RegisterComponent, Access(typeof(Systems.LeashSystem))] +public sealed partial class LeashComponent : Component +{ + [DataField] + public DamageSpecifier ChokeDamage = new() + { + DamageDict = + { + ["Asphyxiation"] = 0.75, + }, + }; + + [DataField] + public TimeSpan ChokeCooldown = TimeSpan.FromSeconds(0.5); + + [DataField] + public float ChokeDistance = 3.25f; + + [DataField] + public float ResistanceThreshold = 0.05f; + + [DataField] + public float MaxTensionDistance = 1.35f; + + [DataField] + public float MaximumDistance = 4f; + + [DataField] + public float PullForce = 32f; + + [DataField("modes")] + public List Modes = new() { 1f, 2.5f, 4f, 5.5f, 7f }; + + [DataField("currentModeIndex")] + public int CurrentModeIndex = 2; + + public EntityUid? AttachedCollar; + public EntityUid? Holder; + public TimeSpan NextChokeTime; +} diff --git a/Content.Server/_Fish/Leash/Systems/LeashSystem.cs b/Content.Server/_Fish/Leash/Systems/LeashSystem.cs new file mode 100644 index 00000000000..f3ff605fe10 --- /dev/null +++ b/Content.Server/_Fish/Leash/Systems/LeashSystem.cs @@ -0,0 +1,841 @@ +using System.Numerics; +using Content.Server._Fish.Leash.Components; +using Content.Shared.Alert; +using Content.Shared._Sunrise.Movement.Carrying; +using Content.Shared._Sunrise.Movement.Carrying.Slowdown; +using Content.Shared._Sunrise.Movement.Pulling; +using Content.Shared._Fish.Leash; +using Content.Shared._Fish.Leash.Components; +using Content.Shared.Damage.Systems; +using Content.Shared.Disposal.Components; +using Content.Shared.Disposal.Unit; +using Content.Shared.Disposal.Unit.Events; +using Content.Shared.DoAfter; +using Content.Shared.Examine; +using Content.Shared.Hands; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Interaction; +using Content.Shared.Interaction.Events; +using Content.Shared.Inventory; +using Content.Shared.Inventory.Events; +using Content.Shared.Item; +using Content.Shared.Movement.Components; +using Content.Shared.Movement.Events; +using Content.Shared.Movement.Systems; +using Content.Shared.Physics; +using Content.Shared.Popups; +using Content.Shared.Verbs; +using Robust.Shared.Containers; +using Robust.Shared.Physics.Components; +using Robust.Shared.Physics.Systems; +using Robust.Shared.Map; +using Robust.Shared.Physics; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using Robust.Shared.Random; + +namespace Content.Server._Fish.Leash.Systems; + +public sealed partial class LeashSystem : EntitySystem +{ + private static readonly SpriteSpecifier.Rsi LeashVisualSprite = + new(new ResPath("/Textures/_Fish/Objects/Fun/leash_line.rsi"), "line"); + private static readonly Vector2 LeashHolderOffset = Vector2.Zero; + private static readonly Vector2 LeashWearerOffset = new(0f, 0.08f); + + [Dependency] private AlertsSystem _alerts = default!; + [Dependency] private DamageableSystem _damageable = default!; + [Dependency] private SharedDoAfterSystem _doAfter = default!; + [Dependency] private SharedHandsSystem _hands = default!; + [Dependency] private InventorySystem _inventory = default!; + [Dependency] private SharedPhysicsSystem _physics = default!; + [Dependency] private SharedPopupSystem _popup = default!; + [Dependency] private SharedTransformSystem _transform = default!; + [Dependency] private IGameTiming _timing = default!; + [Dependency] private IRobustRandom _random = default!; + + private readonly HashSet _allowedCollarUnequips = new(); + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnCollarEquipped); + SubscribeLocalEvent(OnCollarUnequipAttempt); + SubscribeLocalEvent(OnCollarUnequipped); + SubscribeLocalEvent(OnCollarTerminating); + SubscribeLocalEvent>(OnCollarGetVerbs); + + SubscribeLocalEvent(OnLeashAfterInteract); + SubscribeLocalEvent(OnLeashEquippedHand); + SubscribeLocalEvent(OnLeashUnequippedHand); + SubscribeLocalEvent(OnLeashDropped); + SubscribeLocalEvent(OnLeashInsertedIntoContainer); + SubscribeLocalEvent(OnLeashTerminating); + SubscribeLocalEvent(OnLeashUseInHand); + SubscribeLocalEvent(OnLeashExamined); + SubscribeLocalEvent>(OnLeashGetVerbs); + + SubscribeLocalEvent(OnWearerMoveInput); + SubscribeLocalEvent(OnCollarWearerPickupAttempt); + SubscribeLocalEvent(OnRemoveCollarAlert); + SubscribeLocalEvent(OnRemoveCollarDoAfter); + SubscribeLocalEvent(OnHolderMoveInput); + SubscribeLocalEvent>(OnCarryDoAfterAttempt); + SubscribeLocalEvent>(OnCarryDoAfterAttempt); + SubscribeLocalEvent(OnBeforeDisposalFlush); + + SubscribeLocalEvent(OnRemoveCollarDoAfterVerb); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var leashUid, out var leash)) + { + UpdateLeashConstraint(leashUid, leash, frameTime); + } + } + + private void OnLeashUseInHand(EntityUid uid, LeashComponent component, UseInHandEvent args) + { + if (args.Handled) + return; + + if (component.Modes.Count == 0) + return; + + args.Handled = true; + + component.CurrentModeIndex = (component.CurrentModeIndex + 1) % component.Modes.Count; + var newDistance = component.Modes[component.CurrentModeIndex]; + component.MaximumDistance = newDistance; + + _popup.PopupEntity(Loc.GetString("leash-mode-changed", ("length", newDistance)), args.User, args.User); + } + + private void OnLeashGetVerbs(EntityUid uid, LeashComponent component, GetVerbsEvent args) + { + if (!args.CanInteract || !args.CanAccess) + return; + + if (component.Modes.Count < 2) + return; + + var verb = new AlternativeVerb + { + Act = () => + { + component.CurrentModeIndex = (component.CurrentModeIndex + 1) % component.Modes.Count; + var newDistance = component.Modes[component.CurrentModeIndex]; + component.MaximumDistance = newDistance; + _popup.PopupEntity(Loc.GetString("leash-mode-changed", ("length", newDistance)), args.User, args.User); + }, + Text = Loc.GetString("leash-switch-mode-verb"), + Priority = 1, + }; + args.Verbs.Add(verb); + } + + private void OnLeashExamined(EntityUid uid, LeashComponent component, ExaminedEvent args) + { + if (!args.IsInDetailsRange) + return; + + var currentLength = component.MaximumDistance; + args.PushMarkup(Loc.GetString("leash-examine-length", ("length", currentLength))); + } + + private void OnCollarEquipped(EntityUid uid, CollarComponent component, GotEquippedEvent args) + { + if (args.Slot != "neck") + return; + + component.Wearer = args.Equipee; + var wearer = EnsureComp(args.Equipee); + wearer.Collar = uid; + _alerts.ShowAlert(args.Equipee, component.Alert); + } + + private void OnCollarUnequipAttempt(EntityUid uid, CollarComponent component, BeingUnequippedAttemptEvent args) + { + if (args.Slot != "neck") + return; + + if (_allowedCollarUnequips.Contains(uid)) + return; + } + + private void OnCollarUnequipped(EntityUid uid, CollarComponent component, GotUnequippedEvent args) + { + var owner = args.Equipee; + + DetachLeashFromCollar(uid, component); + + if (TryComp(owner, out var wearer) && wearer.Collar == uid) + { + RemCompDeferred(owner); + } + + component.Wearer = null; + _alerts.ClearAlert(owner, component.Alert); + } + + private void OnCollarTerminating(EntityUid uid, CollarComponent component, ref EntityTerminatingEvent args) + { + DetachLeashFromCollar(uid, component); + + if (component.Wearer is { Valid: true } wearer) + _alerts.ClearAlert(wearer, component.Alert); + } + + private void OnCollarGetVerbs(EntityUid uid, CollarComponent component, GetVerbsEvent args) + { + if (!args.CanInteract || !args.CanAccess) + return; + + if (component.Wearer == null || component.Wearer == args.User) + return; + + var verb = new AlternativeVerb + { + Act = () => TryRemoveCollarWithDoAfter(args.User, uid, component), + Text = Loc.GetString("leash-remove-collar-verb"), + Priority = 2, + }; + args.Verbs.Add(verb); + } + + private void TryRemoveCollarWithDoAfter(EntityUid user, EntityUid collarUid, CollarComponent collar) + { + if (collar.Wearer == null) + return; + + var doAfter = new DoAfterArgs(EntityManager, user, collar.BreakoutTime, new RemoveCollarDoAfterEvent(), collar.Wearer.Value, target: collar.Wearer, used: collarUid) + { + BreakOnMove = true, + BreakOnDamage = true, + NeedHand = true, + BreakOnDropItem = false, + }; + + if (!_doAfter.TryStartDoAfter(doAfter)) + { + _popup.PopupEntity(Loc.GetString("leash-remove-collar-fail"), user, user, PopupType.SmallCaution); + return; + } + + _popup.PopupEntity(Loc.GetString("leash-remove-collar-start"), user, user); + } + + private void OnRemoveCollarDoAfterVerb(RemoveCollarDoAfterEvent args) + { + if (args.Handled) + return; + + args.Handled = true; + + var (user, target, used) = (args.Args.User, args.Args.Target, args.Args.Used); + if (target == null || used == null) + return; + + var collarUid = used.Value; + if (!TryComp(collarUid, out var collar) || collar.Wearer != target) + return; + + if (args.Cancelled) + { + _popup.PopupEntity(Loc.GetString("leash-remove-collar-fail"), target.Value, target.Value, PopupType.SmallCaution); + return; + } + + EntityUid? removedItem; + _allowedCollarUnequips.Add(collarUid); + try + { + if (!_inventory.TryUnequip(target.Value, user, "neck", out removedItem, checkDoafter: false)) + { + _popup.PopupEntity(Loc.GetString("leash-remove-collar-fail"), target.Value, target.Value, PopupType.SmallCaution); + return; + } + } + finally + { + _allowedCollarUnequips.Remove(collarUid); + } + + if (removedItem != null) + { + if (!_hands.TryPickupAnyHand(user, removedItem.Value)) + { + Transform(removedItem.Value).AttachToGridOrMap(); + } + _popup.PopupEntity(Loc.GetString("leash-remove-collar-success"), user, user); + } + } + + private void OnLeashAfterInteract(EntityUid uid, LeashComponent component, AfterInteractEvent args) + { + if (args.Handled || !args.CanReach || args.Target == null) + return; + + if (!TryResolveCollarTarget(args.Target.Value, out var collarUid, out var collar)) + { + _popup.PopupEntity(Loc.GetString("leash-no-collar"), args.User, args.User, PopupType.SmallCaution); + return; + } + + if (collarUid == component.AttachedCollar) + { + DetachLeash(uid, component, popupUser: args.User); + args.Handled = true; + return; + } + + if (collar.Wearer == null) + { + _popup.PopupEntity(Loc.GetString("leash-collar-must-be-worn"), args.User, args.User, PopupType.SmallCaution); + return; + } + + if (collar.Wearer == args.User) + { + _popup.PopupEntity(Loc.GetString("leash-cannot-attach-to-self"), args.User, args.User, PopupType.SmallCaution); + args.Handled = true; + return; + } + + AttachLeash(uid, component, collarUid, collar, args.User); + args.Handled = true; + } + + private void OnLeashEquippedHand(EntityUid uid, LeashComponent component, GotEquippedHandEvent args) + { + component.Holder = args.User; + var holder = EnsureComp(args.User); + holder.Leashes.Add(uid); + + UpdateLeashVisuals(uid, component); + TryStartLeashPull(uid, component); + } + + private void OnLeashUnequippedHand(EntityUid uid, LeashComponent component, GotUnequippedHandEvent args) + { + if (component.Holder != args.User) + return; + + RemoveHolderLeash(uid, args.User); + component.Holder = null; + UpdateLeashVisuals(uid, component); + } + + private void OnLeashDropped(EntityUid uid, LeashComponent component, DroppedEvent args) + { + HandleLeashReleased(uid, component, args.User); + } + + private void OnLeashInsertedIntoContainer(EntityUid uid, LeashComponent component, ref EntInsertedIntoContainerMessage args) + { + if (component.AttachedCollar != null) + DetachLeash(uid, component); + + if (component.Holder != null) + { + StopLeashPull(uid, component); + RemoveHolderLeash(uid, component.Holder); + component.Holder = null; + } + + UpdateLeashVisuals(uid, component); + } + + private void OnLeashTerminating(EntityUid uid, LeashComponent component, ref EntityTerminatingEvent args) + { + DetachLeash(uid, component); + RemoveHolderLeash(uid, component.Holder); + } + + private void OnWearerMoveInput(EntityUid uid, CollarWearerComponent component, ref MoveInputEvent args) + { + if (!args.HasDirectionalMovement || + !TryGetLeash(uid, component, out _, out var leash) || + leash.Holder is not { Valid: true } holder) + { + return; + } + + if (TryStopStretchingMovement(uid, holder, leash, args.Entity.Comp)) + return; + + if (_timing.CurTime < leash.NextChokeTime) + return; + + var wearerCoords = _transform.GetMapCoordinates(uid); + var holderCoords = _transform.GetMapCoordinates(holder); + + if (wearerCoords.MapId != holderCoords.MapId) + return; + + var toHolder = holderCoords.Position - wearerCoords.Position; + if (toHolder.LengthSquared() < leash.ChokeDistance * leash.ChokeDistance) + return; + + var moveVector = GetMoveVector(args.Entity.Comp.HeldMoveButtons); + if (moveVector.LengthSquared() <= 0f) + return; + + var awayDot = Vector2.Dot(Vector2.Normalize(moveVector), Vector2.Normalize(toHolder)); + if (awayDot > -0.2f) + return; + } + + private void OnHolderMoveInput(EntityUid uid, LeashHolderComponent component, ref MoveInputEvent args) + { + foreach (var leashUid in component.Leashes) + { + if (!TryComp(leashUid, out var leash) || + !TryGetLeashWearer(leash, out var wearer) || + !TryStopStretchingMovement(uid, wearer, leash, args.Entity.Comp)) + { + continue; + } + + return; + } + } + + private void OnCollarWearerPickupAttempt(EntityUid uid, CollarWearerComponent component, GettingPickedUpAttemptEvent args) + { + if (!HasActiveLeash(uid)) + return; + + args.Cancel(); + if (args.ShowPopup) + _popup.PopupEntity(Loc.GetString("leash-cannot-pick-up"), uid, args.User, PopupType.SmallCaution); + } + + private void OnCarryDoAfterAttempt(EntityUid uid, TComponent component, DoAfterAttemptEvent args) + where TComponent : Component + { + if (!HasActiveLeash(uid) && !HasActiveLeash(args.DoAfter.Args.User)) + return; + + args.Cancel(); + _popup.PopupEntity(Loc.GetString("leash-cannot-carry"), uid, args.DoAfter.Args.User, PopupType.SmallCaution); + } + + private void OnBeforeDisposalFlush(EntityUid uid, DisposalUnitComponent component, BeforeDisposalFlushEvent args) + { + foreach (var contained in component.Container.ContainedEntities) + { + if (!HasActiveLeash(contained)) + continue; + + args.Cancel(); + return; + } + } + + private void OnRemoveCollarAlert(EntityUid uid, CollarWearerComponent component, ref RemoveCollarAlertEvent args) + { + if (args.Handled || + component.Collar is not { Valid: true } collarUid || + !TryComp(collarUid, out var collar) || + collar.Wearer != uid) + { + return; + } + + TryStartRemoveCollarDoAfter(uid, collarUid, collar); + args.Handled = true; + } + + private void OnRemoveCollarDoAfter(EntityUid uid, CollarWearerComponent component, RemoveCollarDoAfterEvent args) + { + if (args.Handled) + return; + + args.Handled = true; + + if (component.Collar is not { Valid: true } collarUid || + !TryComp(collarUid, out var collar) || + collar.Wearer != uid) + { + return; + } + + if (args.Cancelled) + { + _popup.PopupEntity(Loc.GetString("leash-remove-collar-fail"), uid, uid, PopupType.SmallCaution); + return; + } + + if (collar.RemoveSuccessChance < 1f) + { + if (_random.NextFloat() > collar.RemoveSuccessChance) + { + _popup.PopupEntity(Loc.GetString("leash-remove-collar-fail-chance"), uid, uid, PopupType.SmallCaution); + return; + } + } + + EntityUid? removedItem; + _allowedCollarUnequips.Add(collarUid); + try + { + if (!_inventory.TryUnequip(uid, uid, "neck", out removedItem, checkDoafter: false)) + { + _popup.PopupEntity(Loc.GetString("leash-remove-collar-fail"), uid, uid, PopupType.SmallCaution); + return; + } + } + finally + { + _allowedCollarUnequips.Remove(collarUid); + } + + if (removedItem != null) + _hands.PickupOrDrop(uid, removedItem.Value); + + _popup.PopupEntity(Loc.GetString("leash-remove-collar-success"), uid, uid); + } + + private void HandleLeashReleased(EntityUid uid, LeashComponent component, EntityUid user) + { + if (component.Holder != user) + return; + + if (component.AttachedCollar != null) + DetachLeash(uid, component); + + StopLeashPull(uid, component); + RemoveHolderLeash(uid, component.Holder); + component.Holder = null; + UpdateLeashVisuals(uid, component); + } + + private void AttachLeash(EntityUid leashUid, LeashComponent leash, EntityUid collarUid, CollarComponent collar, EntityUid user) + { + DetachLeash(leashUid, leash); + + if (collar.AttachedLeash is { Valid: true } otherLeashUid && + otherLeashUid != leashUid && + TryComp(otherLeashUid, out var otherLeash)) + { + DetachLeash(otherLeashUid, otherLeash); + } + + leash.AttachedCollar = collarUid; + collar.AttachedLeash = leashUid; + leash.NextChokeTime = _timing.CurTime; + + if (collar.Wearer is { Valid: true } wearer) + { + _popup.PopupEntity(Loc.GetString("leash-attached-user"), user, user); + _popup.PopupEntity(Loc.GetString("leash-attached-target"), wearer, wearer); + } + + UpdateLeashVisuals(leashUid, leash); + TryStartLeashPull(leashUid, leash); + } + + private void DetachLeash(EntityUid leashUid, LeashComponent leash, EntityUid? popupUser = null) + { + var oldCollarUid = leash.AttachedCollar; + if (oldCollarUid == null) + return; + + StopLeashPull(leashUid, leash); + + if (TryComp(oldCollarUid, out var oldCollar) && + oldCollar.AttachedLeash == leashUid) + { + oldCollar.AttachedLeash = null; + + if (popupUser != null && + oldCollar.Wearer is { Valid: true } wearer) + { + _popup.PopupEntity(Loc.GetString("leash-detached-user"), popupUser.Value, popupUser.Value); + _popup.PopupEntity(Loc.GetString("leash-detached-target"), wearer, wearer); + } + } + + leash.AttachedCollar = null; + UpdateLeashVisuals(leashUid, leash); + } + + private void DetachLeashFromCollar(EntityUid collarUid, CollarComponent collar) + { + if (collar.AttachedLeash is not { Valid: true } leashUid || + !TryComp(leashUid, out var leash)) + { + collar.AttachedLeash = null; + return; + } + + if (leash.AttachedCollar == collarUid) + DetachLeash(leashUid, leash); + + collar.AttachedLeash = null; + } + + private void TryStartLeashPull(EntityUid leashUid, LeashComponent leash) + { + if (leash.Holder is not { Valid: true } holder || + leash.AttachedCollar is not { Valid: true } collarUid || + !TryComp(collarUid, out var collar) || + collar.Wearer is not { Valid: true } wearer || + holder == wearer) + { + return; + } + } + + private void StopLeashPull(EntityUid leashUid, LeashComponent leash) + { + if (leash.Holder is { Valid: true } holder) + _physics.WakeBody(holder); + + if (leash.AttachedCollar is { Valid: true } collarUid && + TryComp(collarUid, out var collar) && + collar.Wearer is { Valid: true } wearer) + { + _physics.WakeBody(wearer); + } + } + + private void RemoveHolderLeash(EntityUid leashUid, EntityUid? holderUid) + { + if (holderUid is not { Valid: true } holder || + !TryComp(holder, out var holderComp)) + { + return; + } + + holderComp.Leashes.Remove(leashUid); + if (holderComp.Leashes.Count == 0) + RemCompDeferred(holder); + } + + private bool TryResolveCollarTarget(EntityUid target, out EntityUid collarUid, out CollarComponent collar) + { + collarUid = default; + collar = default!; + + if (TryComp(target, out CollarComponent? directCollar) && + directCollar.Wearer != null) + { + collar = directCollar; + collarUid = target; + return true; + } + + if (!_inventory.TryGetSlotEntity(target, "neck", out EntityUid? neckItem) || + neckItem == null || + !TryComp(neckItem.Value, out CollarComponent? slotCollar)) + { + return false; + } + + collar = slotCollar; + collarUid = neckItem.Value; + return true; + } + + private bool TryGetLeash(EntityUid wearer, CollarWearerComponent wearerComp, out EntityUid leashUid, out LeashComponent leash) + { + leashUid = default; + leash = default!; + + if (wearerComp.Collar is not { Valid: true } collarUid || + !TryComp(collarUid, out var collar) || + collar.Wearer != wearer || + collar.AttachedLeash is not { Valid: true } attachedLeash || + !TryComp(attachedLeash, out LeashComponent? leashComp)) + { + return false; + } + + leash = leashComp; + leashUid = attachedLeash; + return true; + } + + private bool TryGetLeashWearer(LeashComponent leash, out EntityUid wearer) + { + wearer = default; + + if (leash.AttachedCollar is not { Valid: true } collarUid || + !TryComp(collarUid, out var collar) || + collar.Wearer is not { Valid: true } collarWearer) + { + return false; + } + + wearer = collarWearer; + return true; + } + + private bool HasActiveLeash(EntityUid uid) + { + if (TryComp(uid, out var wearer) && + wearer.Collar is { Valid: true } collarUid && + TryComp(collarUid, out var collar) && + collar.AttachedLeash is { Valid: true }) + { + return true; + } + + if (!TryComp(uid, out var holder)) + return false; + + foreach (var leashUid in holder.Leashes) + { + if (TryComp(leashUid, out var leash) && + leash.Holder == uid && + leash.AttachedCollar is { Valid: true }) + { + return true; + } + } + + return false; + } + + private void UpdateLeashVisuals(EntityUid leashUid, LeashComponent leash) + { + if (leash.Holder is { Valid: true } holder && + leash.AttachedCollar is { Valid: true } collarUid && + TryComp(collarUid, out var collar) && + collar.Wearer is { Valid: true } wearer && + holder != wearer) + { + var visuals = EnsureComp(leashUid); + visuals.Sprite = LeashVisualSprite; + visuals.Target = wearer; + visuals.OffsetA = LeashHolderOffset; + visuals.OffsetB = LeashWearerOffset; + Dirty(leashUid, visuals); + return; + } + + RemCompDeferred(leashUid); + } + + private static Vector2 GetMoveVector(MoveButtons buttons) + { + var x = 0; + x -= (buttons & MoveButtons.Left) != 0 ? 1 : 0; + x += (buttons & MoveButtons.Right) != 0 ? 1 : 0; + + var y = 0; + y -= (buttons & MoveButtons.Down) != 0 ? 1 : 0; + y += (buttons & MoveButtons.Up) != 0 ? 1 : 0; + + var vector = new Vector2(x, y); + return vector.LengthSquared() > 0f ? Vector2.Normalize(vector) : Vector2.Zero; + } + + private bool TryStartRemoveCollarDoAfter(EntityUid user, EntityUid collarUid, CollarComponent collar) + { + var doAfter = new DoAfterArgs(EntityManager, user, collar.BreakoutTime, new RemoveCollarDoAfterEvent(), user, target: user, used: collarUid) + { + BreakOnMove = true, + BreakOnDamage = true, + NeedHand = true, + BreakOnDropItem = false, + }; + + if (!_doAfter.TryStartDoAfter(doAfter)) + return false; + + _popup.PopupEntity(Loc.GetString("leash-remove-collar-start"), user, user); + return true; + } + + private bool TryStopStretchingMovement(EntityUid mover, EntityUid anchor, LeashComponent leash, InputMoverComponent moverComp) + { + if (!IsMovingAwayPastLimit(mover, anchor, moverComp.HeldMoveButtons, leash.MaximumDistance)) + return false; + + moverComp.CurTickWalkMovement = Vector2.Zero; + moverComp.CurTickSprintMovement = Vector2.Zero; + Dirty(mover, moverComp); + _physics.WakeBody(mover); + return true; + } + + private bool IsMovingAwayPastLimit(EntityUid mover, EntityUid anchor, MoveButtons buttons, float maxDistance) + { + var moverCoords = _transform.GetMapCoordinates(mover); + var anchorCoords = _transform.GetMapCoordinates(anchor); + + if (moverCoords.MapId != anchorCoords.MapId) + return false; + + var away = moverCoords.Position - anchorCoords.Position; + if (away.LengthSquared() < maxDistance * maxDistance) + return false; + + var moveVector = GetMoveVector(buttons); + if (moveVector.LengthSquared() <= 0f || away.LengthSquared() <= 0.001f) + return false; + + return Vector2.Dot(Vector2.Normalize(moveVector), Vector2.Normalize(away)) > 0.2f; + } + + private void UpdateLeashConstraint(EntityUid leashUid, LeashComponent leash, float frameTime) + { + if (leash.Holder is not { Valid: true } holder || + leash.AttachedCollar is not { Valid: true } collarUid || + !TryComp(collarUid, out var collar) || + collar.Wearer is not { Valid: true } wearer || + holder == wearer) + { + return; + } + + var wearerCoords = _transform.GetMapCoordinates(wearer); + var holderCoords = _transform.GetMapCoordinates(holder); + if (wearerCoords.MapId != holderCoords.MapId) + { + DetachLeash(leashUid, leash); + return; + } + + var delta = holderCoords.Position - wearerCoords.Position; + var distance = delta.Length(); + if (distance <= leash.MaxTensionDistance || distance <= 0.001f) + return; + + if (!TryComp(wearer, out var wearerBody)) + return; + + var direction = delta / distance; + var excess = distance - leash.MaxTensionDistance; + var impulse = direction * (leash.PullForce * MathF.Max(1f, excess * 3f) * wearerBody.Mass * frameTime); + + _physics.WakeBody(wearer); + _physics.ApplyLinearImpulse(wearer, impulse, body: wearerBody); + + if (distance >= leash.MaximumDistance && + TryComp(holder, out var holderBody)) + { + var holderImpulse = -direction * (leash.PullForce * MathF.Max(1f, (distance - leash.MaximumDistance) * 3f) * holderBody.Mass * frameTime); + _physics.WakeBody(holder); + _physics.ApplyLinearImpulse(holder, holderImpulse, body: holderBody); + + if (TryComp(holder, out var holderMover)) + TryStopStretchingMovement(holder, wearer, leash, holderMover); + } + + if (distance < leash.ChokeDistance || _timing.CurTime < leash.NextChokeTime) + return; + + leash.NextChokeTime = _timing.CurTime + leash.ChokeCooldown; + _damageable.TryChangeDamage(wearer, leash.ChokeDamage, ignoreResistances: true, interruptsDoAfters: false, origin: holder); + _popup.PopupEntity(Loc.GetString("leash-choking"), wearer, wearer, PopupType.SmallCaution); + } +} \ No newline at end of file diff --git a/Content.Shared/_Fish/Leash/CollarEvents.cs b/Content.Shared/_Fish/Leash/CollarEvents.cs new file mode 100644 index 00000000000..14120cc8c65 --- /dev/null +++ b/Content.Shared/_Fish/Leash/CollarEvents.cs @@ -0,0 +1,12 @@ +using Content.Shared.Alert; +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Shared._Fish.Leash; + +public sealed partial class RemoveCollarAlertEvent : BaseAlertEvent; + +[Serializable, NetSerializable] +public sealed partial class RemoveCollarDoAfterEvent : SimpleDoAfterEvent +{ +} diff --git a/Content.Shared/_Fish/Leash/Components/CollarComponent.cs b/Content.Shared/_Fish/Leash/Components/CollarComponent.cs new file mode 100644 index 00000000000..1b9e1320cee --- /dev/null +++ b/Content.Shared/_Fish/Leash/Components/CollarComponent.cs @@ -0,0 +1,20 @@ +using Content.Shared.Alert; +using Robust.Shared.Prototypes; + +namespace Content.Shared._Fish.Leash.Components; + +[RegisterComponent] +public sealed partial class CollarComponent : Component +{ + [DataField] + public TimeSpan BreakoutTime = TimeSpan.FromSeconds(4); + + [DataField] + public ProtoId Alert = "Collared"; + + [DataField] + public float RemoveSuccessChance = 1f; + + public EntityUid? Wearer; + public EntityUid? AttachedLeash; +} diff --git a/Content.Shared/_Fish/Leash/Components/CollarWearerComponent.cs b/Content.Shared/_Fish/Leash/Components/CollarWearerComponent.cs new file mode 100644 index 00000000000..74ea5470c6c --- /dev/null +++ b/Content.Shared/_Fish/Leash/Components/CollarWearerComponent.cs @@ -0,0 +1,7 @@ +namespace Content.Shared._Fish.Leash.Components; + +[RegisterComponent] +public sealed partial class CollarWearerComponent : Component +{ + public EntityUid? Collar; +} diff --git a/Content.Shared/_Fish/Leash/Components/LeashHolderComponent.cs b/Content.Shared/_Fish/Leash/Components/LeashHolderComponent.cs new file mode 100644 index 00000000000..4718279bb2a --- /dev/null +++ b/Content.Shared/_Fish/Leash/Components/LeashHolderComponent.cs @@ -0,0 +1,7 @@ +namespace Content.Shared._Fish.Leash.Components; + +[RegisterComponent] +public sealed partial class LeashHolderComponent : Component +{ + public readonly HashSet Leashes = new(); +} diff --git a/Content.Shared/_Fish/Leash/Systems/SharedCollarSystem.cs b/Content.Shared/_Fish/Leash/Systems/SharedCollarSystem.cs new file mode 100644 index 00000000000..7475b7412dc --- /dev/null +++ b/Content.Shared/_Fish/Leash/Systems/SharedCollarSystem.cs @@ -0,0 +1,41 @@ +using Content.Shared._Fish.Leash.Components; +using Content.Shared.Inventory; +using Content.Shared.Interaction; +using Content.Shared.Nutrition.EntitySystems; + +namespace Content.Shared._Fish.Leash.Systems; + +public sealed partial class SharedCollarSystem : EntitySystem +{ + [Dependency] private InventorySystem _inventory = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnCollarAfterInteract, before: [typeof(IngestionSystem)]); + } + + private void OnCollarAfterInteract(EntityUid uid, CollarComponent component, ref AfterInteractEvent args) + { + if (args.Handled || + !args.CanReach || + args.Target is not { Valid: true } target || + !TryComp(target, out var inventory)) + { + return; + } + + _inventory.TryEquip( + args.User, + target, + uid, + "neck", + predicted: true, + inventory: inventory, + checkDoafter: true, + triggerHandContact: false); + + args.Handled = true; + } +} diff --git a/MSBuild/Content.props b/MSBuild/Content.props index de44d3ccc65..9d9d4bb715b 100644 --- a/MSBuild/Content.props +++ b/MSBuild/Content.props @@ -12,6 +12,6 @@ true - CS0618,CS0672,CS0612,CS1062,CS1064,NU1903 + CS0618,CS0672,CS0612,CS1062,CS1064,NU1903,NU1900 diff --git a/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/alerts/leash.ftl b/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/alerts/leash.ftl new file mode 100644 index 00000000000..28ecb00619e --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/alerts/leash.ftl @@ -0,0 +1,16 @@ +leash-no-collar = На цели нет ошейника. +leash-collar-must-be-worn = Поводок можно пристегнуть только к надетому ошейнику. +leash-attached-user = Вы пристёгиваете поводок к ошейнику. +leash-attached-target = К вашему ошейнику пристегнули поводок. +leash-detached-user = Вы отстёгиваете поводок. +leash-detached-target = Поводок отстегнули от вашего ошейника. +leash-choking = Ошейник впивается в шею и душит вас! +leash-remove-collar-start = Вы начинаете возиться с застёжкой ошейника. +leash-remove-collar-fail = Вам не удаётся снять ошейник. +leash-remove-collar-fail-chance = Попытка снять ошейник не увенчалась успехом. +leash-remove-collar-success = Вам удаётся снять ошейник. +leash-remove-collar-blocked = Сначала нужно расстегнуть ошейник. +leash-cannot-pick-up = Поводок натянулся. +leash-cannot-carry = Поводок натянулся. +leash-mode-changed = Длина поводка установлена на { $length } м. +leash-cannot-attach-to-self = Вы не можете пристегнуть поводок к собственному ошейнику. diff --git a/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/collars.ftl b/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/collars.ftl new file mode 100644 index 00000000000..982db1fccb9 --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/collars.ftl @@ -0,0 +1,2 @@ +ent-LeashBase = серый поводок + .desc = Поводок-рулетка, который можно прицепить к ошейнику. \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/leash.ftl b/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/leash.ftl new file mode 100644 index 00000000000..e7a27baeee3 --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_fish/entities/fun/leash.ftl @@ -0,0 +1,2 @@ +ent-ClothingNeckCollarBase = серый ошейник + .desc = Ошейник, с кольцом для поводка. Зачем он вам? \ No newline at end of file diff --git a/Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/machines/vending_machines.ftl b/Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/machines/vending_machines.ftl new file mode 100644 index 00000000000..27193e07aff --- /dev/null +++ b/Resources/Locale/ru-RU/_prototypes/_fish/entities/structures/machines/vending_machines.ftl @@ -0,0 +1,2 @@ +ent-VendingMachinePetMate = питомце-мат + .desc = Приведи питомца на работу! \ No newline at end of file diff --git a/Resources/Prototypes/_Fish/Alerts/alerts.yml b/Resources/Prototypes/_Fish/Alerts/alerts.yml new file mode 100644 index 00000000000..6c7b79e3cf0 --- /dev/null +++ b/Resources/Prototypes/_Fish/Alerts/alerts.yml @@ -0,0 +1,8 @@ +- type: alert + id: Collared + clickEvent: !type:RemoveCollarAlertEvent + icons: + - sprite: /Textures/_Fish/Clothing/Neck/Misc/collar.rsi + state: icon + name: alerts-collared-name + description: alerts-collared-desc \ No newline at end of file diff --git a/Resources/Prototypes/_Fish/Catalog/VendingMachines/petomat_catalog.yml b/Resources/Prototypes/_Fish/Catalog/VendingMachines/petomat_catalog.yml new file mode 100644 index 00000000000..3c1d67d3bcb --- /dev/null +++ b/Resources/Prototypes/_Fish/Catalog/VendingMachines/petomat_catalog.yml @@ -0,0 +1,7 @@ +- type: vendingMachineInventory + id: PetMateVendInventory + startingInventory: + # Collars + ClothingNeckCollarBase: 3 + # Leashs + LeashBase: 3 \ No newline at end of file diff --git a/Resources/Prototypes/_Fish/Entities/Clothing/Neck/collars.yml b/Resources/Prototypes/_Fish/Entities/Clothing/Neck/collars.yml new file mode 100644 index 00000000000..ef1684f9529 --- /dev/null +++ b/Resources/Prototypes/_Fish/Entities/Clothing/Neck/collars.yml @@ -0,0 +1,39 @@ +# Base +- type: entity + parent: ClothingNeckBase + id: ClothingNeckCollarBase + name: grey collar + description: A collar with a leash ring. Why do you need it? + categories: [ DoNotMap ] + components: + - type: Item + size: Small + sprite: _Fish/Clothing/Neck/Misc/collar.rsi + # color: "#DADADA" + - type: Butcherable + butcheringType: Knife + spawned: + - id: MaterialCloth1 + amount: 1 + - type: Clothing + quickEquip: true + equipSound: /Audio/Items/belt_equip.ogg + unequipSound: /Audio/Items/belt_equip.ogg + equipDelay: 2 + unequipDelay: 1 + sprite: _Fish/Clothing/Neck/Misc/collar.rsi + clothingVisuals: + collar: + - state: equipped-NECK + # color: "#DADADA" + - type: Sprite + sprite: _Fish/Clothing/Neck/Misc/collar.rsi + layers: + - state: icon + # color: "#DADADA" + - type: Collar + breakoutTime: 4 + removeSuccessChance: 0.7 + - type: Tag + tags: + - Recyclable \ No newline at end of file diff --git a/Resources/Prototypes/_Fish/Entities/Objects/Fun/leash.yml b/Resources/Prototypes/_Fish/Entities/Objects/Fun/leash.yml new file mode 100644 index 00000000000..ece00668717 --- /dev/null +++ b/Resources/Prototypes/_Fish/Entities/Objects/Fun/leash.yml @@ -0,0 +1,28 @@ +# Base +- type: entity + parent: BaseItem + id: LeashBase + name: grey leash + description: A leash roulette that attaches to the collar. + categories: [ DoNotMap ] + components: + - type: Sprite + sprite: _Fish/Objects/Fun/leash.rsi + # scale: 0.75, 0.75 + layers: + - state: icon + # color: "#DCDCDC" + - state: leash-icon + - type: Item + sprite: _Fish/Objects/Fun/leash.rsi + size: Normal + inhandVisuals: + left: + - state: inhand-left + # color: "#DCDCDC" + right: + - state: inhand-right + # color: "#DCDCDC" + - type: Leash + modes: [1, 2.5, 4, 5.5, 7] + currentModeIndex: 2 \ No newline at end of file diff --git a/Resources/Prototypes/_Fish/Entities/Structures/Machines/vending_machines.yml b/Resources/Prototypes/_Fish/Entities/Structures/Machines/vending_machines.yml new file mode 100644 index 00000000000..2b928d1d6c7 --- /dev/null +++ b/Resources/Prototypes/_Fish/Entities/Structures/Machines/vending_machines.yml @@ -0,0 +1,28 @@ +- type: entity + parent: VendingMachine + id: VendingMachinePetMate + suffix: DO NOT MAP + name: Pet-O-Mat + description: Bring your pet to work! + components: + - type: VendingMachine + pack: PetMateVendInventory + offState: off + brokenState: broken + normalState: normal-unshaded + denyState: deny-unshaded + ejectDelay: 2 + - type: Sprite + sprite: _Fish/Structures/Machines/petmate.rsi + layers: + - state: "off" + map: ["enum.VendingMachineVisualLayers.Base"] + - state: "off" + map: ["enum.VendingMachineVisualLayers.BaseUnshaded"] + shader: unshaded + - state: panel + map: ["enum.WiresVisualLayers.MaintenancePanel"] + - type: PointLight + radius: 1.5 + energy: 1.8 + color: "#ffddaa" \ No newline at end of file diff --git a/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/equipped-NECK.png b/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/equipped-NECK.png new file mode 100644 index 00000000000..6d2031f6324 Binary files /dev/null and b/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/equipped-NECK.png differ diff --git a/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/icon.png b/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/icon.png new file mode 100644 index 00000000000..40726e46086 Binary files /dev/null and b/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/icon.png differ diff --git a/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/meta.json b/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/meta.json new file mode 100644 index 00000000000..5249b9ee865 --- /dev/null +++ b/Resources/Textures/_Fish/Clothing/Neck/Misc/collar.rsi/meta.json @@ -0,0 +1,18 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by Foxanto (Discord/Telegram) and upnostnote (Discord) for Space Station 14 server Fish-Station", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "equipped-NECK", + "directions": 4 + } + ] +} diff --git a/Resources/Textures/_Fish/Objects/Fun/leash.rsi/icon.png b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/icon.png new file mode 100644 index 00000000000..29e5d9f7ec4 Binary files /dev/null and b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/icon.png differ diff --git a/Resources/Textures/_Fish/Objects/Fun/leash.rsi/inhand-left.png b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/inhand-left.png new file mode 100644 index 00000000000..6b1425dc5b5 Binary files /dev/null and b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/inhand-left.png differ diff --git a/Resources/Textures/_Fish/Objects/Fun/leash.rsi/inhand-right.png b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/inhand-right.png new file mode 100644 index 00000000000..509d16e2998 Binary files /dev/null and b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/inhand-right.png differ diff --git a/Resources/Textures/_Fish/Objects/Fun/leash.rsi/leash-icon.png b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/leash-icon.png new file mode 100644 index 00000000000..38c1a5a48d1 Binary files /dev/null and b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/leash-icon.png differ diff --git a/Resources/Textures/_Fish/Objects/Fun/leash.rsi/leash.png b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/leash.png new file mode 100644 index 00000000000..59746ae7e59 Binary files /dev/null and b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/leash.png differ diff --git a/Resources/Textures/_Fish/Objects/Fun/leash.rsi/meta.json b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/meta.json new file mode 100644 index 00000000000..714f8f68302 --- /dev/null +++ b/Resources/Textures/_Fish/Objects/Fun/leash.rsi/meta.json @@ -0,0 +1,28 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by Foxanto (Discord/Telegram) and upnostnote (Discord) for Space Station 14 server Fish-Station", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "leash-icon" + }, + { + "name": "inhand-left", + "directions": 4 + }, + { + "name": "inhand-right", + "directions": 4 + }, + { + "name": "leash" + } + ] +} diff --git a/Resources/Textures/_Fish/Objects/Fun/leash_line.rsi/line.png b/Resources/Textures/_Fish/Objects/Fun/leash_line.rsi/line.png new file mode 100644 index 00000000000..3ecf9aa1915 Binary files /dev/null and b/Resources/Textures/_Fish/Objects/Fun/leash_line.rsi/line.png differ diff --git a/Resources/Textures/_Fish/Objects/Fun/leash_line.rsi/meta.json b/Resources/Textures/_Fish/Objects/Fun/leash_line.rsi/meta.json new file mode 100644 index 00000000000..e16537c357f --- /dev/null +++ b/Resources/Textures/_Fish/Objects/Fun/leash_line.rsi/meta.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by Foxanto (Discord/Telegram) and upnostnote (Discord) for Space Station 14 server Fish-Station", + "size": { + "x": 2, + "y": 32 + }, + "states": [ + { + "name": "line" + } + ] +} diff --git a/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/broken.png b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/broken.png new file mode 100644 index 00000000000..05f556b6368 Binary files /dev/null and b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/broken.png differ diff --git a/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/deny-unshaded.png b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/deny-unshaded.png new file mode 100644 index 00000000000..b53226b4708 Binary files /dev/null and b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/deny-unshaded.png differ diff --git a/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/meta.json b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/meta.json new file mode 100644 index 00000000000..8130a68fbf4 --- /dev/null +++ b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/meta.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Created by Foxanto (Discord/Telegram) and upnostnote (Discord) for Space Station 14 server Fish-Station", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "broken" + }, + { + "name": "off" + }, + { + "name": "panel" + }, + { + "name": "normal-unshaded", + "delays": [ + [ + 0.7, + 0.5, + 1.2, + 0.5, + 1.2, + 0.5, + 1.2, + 0.5 + ] + ] + }, + { + "name": "deny-unshaded", + "delays": [ + [ + 0.8, + 0.8 + ] + ] + } + ] +} \ No newline at end of file diff --git a/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/normal-unshaded.png b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/normal-unshaded.png new file mode 100644 index 00000000000..908ea64f3c1 Binary files /dev/null and b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/normal-unshaded.png differ diff --git a/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/off.png b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/off.png new file mode 100644 index 00000000000..87ce0fe3586 Binary files /dev/null and b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/off.png differ diff --git a/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/panel.png b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/panel.png new file mode 100644 index 00000000000..a38f39a6832 Binary files /dev/null and b/Resources/Textures/_Fish/Structures/Machines/petmate.rsi/panel.png differ