diff --git a/Content.Client/GameObjects/Components/Movement/ClimbableComponent.cs b/Content.Client/GameObjects/Components/Movement/ClimbableComponent.cs new file mode 100644 index 0000000000..d637853960 --- /dev/null +++ b/Content.Client/GameObjects/Components/Movement/ClimbableComponent.cs @@ -0,0 +1,12 @@ +using Robust.Shared.GameObjects; +using Content.Shared.GameObjects.Components.Movement; + +namespace Content.Client.GameObjects.Components.Movement +{ + [RegisterComponent] + [ComponentReference(typeof(IClimbable))] + public class ClimbableComponent : SharedClimbableComponent + { + + } +} diff --git a/Content.Client/GameObjects/Components/Movement/ClimbingComponent.cs b/Content.Client/GameObjects/Components/Movement/ClimbingComponent.cs new file mode 100644 index 0000000000..07f8c7c5b6 --- /dev/null +++ b/Content.Client/GameObjects/Components/Movement/ClimbingComponent.cs @@ -0,0 +1,34 @@ +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Components; +using Content.Shared.GameObjects.Components.Movement; +using Content.Client.Interfaces.GameObjects.Components.Interaction; +using Content.Shared.Physics; + +namespace Content.Client.GameObjects.Components.Movement +{ + [RegisterComponent] + public class ClimbingComponent : SharedClimbingComponent, IClientDraggable + { + public override void HandleComponentState(ComponentState curState, ComponentState nextState) + { + if (!(curState is ClimbModeComponentState climbModeState) || Body == null) + { + return; + } + + IsClimbing = climbModeState.Climbing; + } + + public override bool IsClimbing { get; set; } + + bool IClientDraggable.ClientCanDropOn(CanDropEventArgs eventArgs) + { + return eventArgs.Target.HasComponent(); + } + + bool IClientDraggable.ClientCanDrag(CanDragEventArgs eventArgs) + { + return true; + } + } +} diff --git a/Content.IntegrationTests/Tests/GameObjects/Components/Movement/ClimbUnitTest.cs b/Content.IntegrationTests/Tests/GameObjects/Components/Movement/ClimbUnitTest.cs new file mode 100644 index 0000000000..7ae95a83af --- /dev/null +++ b/Content.IntegrationTests/Tests/GameObjects/Components/Movement/ClimbUnitTest.cs @@ -0,0 +1,64 @@ +#nullable enable + +using System.Threading.Tasks; +using NUnit.Framework; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Content.Server.GameObjects.Components.Movement; +using Content.Shared.Physics; +using Robust.Shared.GameObjects.Components; + +namespace Content.IntegrationTests.Tests.GameObjects.Components.Movement +{ + [TestFixture] + [TestOf(typeof(ClimbableComponent))] + [TestOf(typeof(ClimbingComponent))] + public class ClimbUnitTest : ContentIntegrationTest + { + [Test] + public async Task Test() + { + var server = StartServerDummyTicker(); + + IEntity human; + IEntity table; + IEntity carpet; + ClimbableComponent climbable; + ClimbingComponent climbing; + + server.Assert(() => + { + var mapManager = IoCManager.Resolve(); + mapManager.CreateNewMapEntity(MapId.Nullspace); + + var entityManager = IoCManager.Resolve(); + + // Spawn the entities + human = entityManager.SpawnEntity("HumanMob_Content", MapCoordinates.Nullspace); + table = entityManager.SpawnEntity("Table", MapCoordinates.Nullspace); + + // Test for climb components existing + // Players and tables should have these in their prototypes. + Assert.True(human.TryGetComponent(out climbing), "Human has no climbing"); + Assert.True(table.TryGetComponent(out climbable), "Table has no climbable"); + + // Now let's make the player enter a climbing transitioning state. + climbing.IsClimbing = true; + climbing.TryMoveTo(human.Transform.WorldPosition, table.Transform.WorldPosition); + human.TryGetComponent(out ICollidableComponent body); + + Assert.True(body.HasController(), "Player has no ClimbController"); + + // Force the player out of climb state. It should immediately remove the ClimbController. + climbing.IsClimbing = false; + + Assert.True(!body.HasController(), "Player wrongly has a ClimbController"); + + }); + + await server.WaitIdleAsync(); + } + } +} diff --git a/Content.Server/GameObjects/Components/Movement/ClimbableComponent.cs b/Content.Server/GameObjects/Components/Movement/ClimbableComponent.cs new file mode 100644 index 0000000000..498f4afffb --- /dev/null +++ b/Content.Server/GameObjects/Components/Movement/ClimbableComponent.cs @@ -0,0 +1,235 @@ + +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Components; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Serialization; +using Robust.Shared.ViewVariables; +using Robust.Server.Interfaces.Player; +using Content.Server.Interfaces; +using Content.Shared.GameObjects.EntitySystems; +using Content.Shared.Interfaces.GameObjects.Components; +using Content.Shared.GameObjects.Components.Movement; +using Content.Shared.Interfaces; +using Content.Server.GameObjects.Components.Body; +using Content.Server.GameObjects.EntitySystems.DoAfter; +using Robust.Shared.Maths; +using System; + +namespace Content.Server.GameObjects.Components.Movement +{ + [RegisterComponent] + [ComponentReference(typeof(IClimbable))] + public class ClimbableComponent : SharedClimbableComponent, IDragDropOn + { +#pragma warning disable 649 + [Dependency] private readonly IServerNotifyManager _notifyManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; +#pragma warning restore 649 + + /// + /// The range from which this entity can be climbed. + /// + [ViewVariables] + private float _range; + + /// + /// The time it takes to climb onto the entity. + /// + [ViewVariables] + private float _climbDelay; + + private ICollidableComponent _collidableComponent; + private DoAfterSystem _doAfterSystem; + + public override void Initialize() + { + base.Initialize(); + + _collidableComponent = Owner.GetComponent(); + _doAfterSystem = EntitySystem.Get(); + } + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + + serializer.DataField(ref _range, "range", SharedInteractionSystem.InteractionRange / 1.4f); + serializer.DataField(ref _climbDelay, "delay", 0.8f); + } + + bool IDragDropOn.CanDragDropOn(DragDropEventArgs eventArgs) + { + if (!ActionBlockerSystem.CanInteract(eventArgs.User)) + { + _notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You can't do that!")); + + return false; + } + + if (eventArgs.User == eventArgs.Dropped) // user is dragging themselves onto a climbable + { + if (!eventArgs.User.HasComponent()) + { + _notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You are incapable of climbing!")); + + return false; + } + + var bodyManager = eventArgs.User.GetComponent(); + + if (bodyManager.GetBodyPartsOfType(Shared.GameObjects.Components.Body.BodyPartType.Leg).Count == 0 || + bodyManager.GetBodyPartsOfType(Shared.GameObjects.Components.Body.BodyPartType.Foot).Count == 0) + { + _notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You are unable to climb!")); + + return false; + } + + var userPosition = eventArgs.User.Transform.MapPosition; + var climbablePosition = eventArgs.Target.Transform.MapPosition; + var interaction = EntitySystem.Get(); + bool Ignored(IEntity entity) => (entity == eventArgs.Target || entity == eventArgs.User); + + if (!interaction.InRangeUnobstructed(userPosition, climbablePosition, _range, predicate: Ignored)) + { + _notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You can't reach there!")); + + return false; + } + } + else // user is dragging some other entity onto a climbable + { + if (eventArgs.Target == null || !eventArgs.Dropped.HasComponent()) + { + _notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You can't do that!")); + + return false; + } + + var userPosition = eventArgs.User.Transform.MapPosition; + var otherUserPosition = eventArgs.Dropped.Transform.MapPosition; + var climbablePosition = eventArgs.Target.Transform.MapPosition; + var interaction = EntitySystem.Get(); + bool Ignored(IEntity entity) => (entity == eventArgs.Target || entity == eventArgs.User || entity == eventArgs.Dropped); + + if (!interaction.InRangeUnobstructed(userPosition, climbablePosition, _range, predicate: Ignored) || + !interaction.InRangeUnobstructed(userPosition, otherUserPosition, _range, predicate: Ignored)) + { + _notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You can't reach there!")); + + return false; + } + } + + return true; + } + + bool IDragDropOn.DragDropOn(DragDropEventArgs eventArgs) + { + if (eventArgs.User == eventArgs.Dropped) + { + TryClimb(eventArgs.User); + } + else + { + TryMoveEntity(eventArgs.User, eventArgs.Dropped); + } + + return true; + } + + private async void TryMoveEntity(IEntity user, IEntity entityToMove) + { + var doAfterEventArgs = new DoAfterEventArgs(user, _climbDelay, default, entityToMove) + { + BreakOnTargetMove = true, + BreakOnUserMove = true, + BreakOnDamage = true, + BreakOnStun = true + }; + + var result = await _doAfterSystem.DoAfter(doAfterEventArgs); + + if (result != DoAfterStatus.Cancelled && entityToMove.TryGetComponent(out ICollidableComponent body) && body.PhysicsShapes.Count >= 1) + { + var direction = (Owner.Transform.WorldPosition - entityToMove.Transform.WorldPosition).Normalized; + var endPoint = Owner.Transform.WorldPosition; + + var climbMode = entityToMove.GetComponent(); + climbMode.IsClimbing = true; + + if (MathF.Abs(direction.X) < 0.6f) // user climbed mostly vertically so lets make it a clean straight line + { + endPoint = new Vector2(entityToMove.Transform.WorldPosition.X, endPoint.Y); + } + else if (MathF.Abs(direction.Y) < 0.6f) // user climbed mostly horizontally so lets make it a clean straight line + { + endPoint = new Vector2(endPoint.X, entityToMove.Transform.WorldPosition.Y); + } + + climbMode.TryMoveTo(entityToMove.Transform.WorldPosition, endPoint); + // we may potentially need additional logic since we're forcing a player onto a climbable + // there's also the cases where the user might collide with the person they are forcing onto the climbable that i haven't accounted for + + PopupMessageOtherClientsInRange(user, Loc.GetString("{0:them} forces {1:them} onto {2:theName}!", user, entityToMove, Owner), 15); + _notifyManager.PopupMessage(user, user, Loc.GetString("You force {0:them} onto {1:theName}!", entityToMove, Owner)); + } + } + + private async void TryClimb(IEntity user) + { + var doAfterEventArgs = new DoAfterEventArgs(user, _climbDelay, default, Owner) + { + BreakOnTargetMove = true, + BreakOnUserMove = true, + BreakOnDamage = true, + BreakOnStun = true + }; + + var result = await _doAfterSystem.DoAfter(doAfterEventArgs); + + if (result != DoAfterStatus.Cancelled && user.TryGetComponent(out ICollidableComponent body) && body.PhysicsShapes.Count >= 1) + { + var direction = (Owner.Transform.WorldPosition - user.Transform.WorldPosition).Normalized; + var endPoint = Owner.Transform.WorldPosition; + + var climbMode = user.GetComponent(); + climbMode.IsClimbing = true; + + if (MathF.Abs(direction.X) < 0.6f) // user climbed mostly vertically so lets make it a clean straight line + { + endPoint = new Vector2(user.Transform.WorldPosition.X, endPoint.Y); + } + else if (MathF.Abs(direction.Y) < 0.6f) // user climbed mostly horizontally so lets make it a clean straight line + { + endPoint = new Vector2(endPoint.X, user.Transform.WorldPosition.Y); + } + + climbMode.TryMoveTo(user.Transform.WorldPosition, endPoint); + + PopupMessageOtherClientsInRange(user, Loc.GetString("{0:them} jumps onto {1:theName}!", user, Owner), 15); + _notifyManager.PopupMessage(user, user, Loc.GetString("You jump onto {0:theName}!", Owner)); + } + } + + private void PopupMessageOtherClientsInRange(IEntity source, string message, int maxReceiveDistance) + { + var viewers = _playerManager.GetPlayersInRange(source.Transform.GridPosition, maxReceiveDistance); + + foreach (var viewer in viewers) + { + var viewerEntity = viewer.AttachedEntity; + + if (viewerEntity == null || source == viewerEntity) + { + continue; + } + + source.PopupMessage(viewer.AttachedEntity, message); + } + } + } +} diff --git a/Content.Server/GameObjects/Components/Movement/ClimbingComponent.cs b/Content.Server/GameObjects/Components/Movement/ClimbingComponent.cs new file mode 100644 index 0000000000..2ebbdb5397 --- /dev/null +++ b/Content.Server/GameObjects/Components/Movement/ClimbingComponent.cs @@ -0,0 +1,78 @@ +using Robust.Shared.GameObjects; +using Robust.Shared.Maths; +using Robust.Shared.GameObjects.Components; +using Content.Shared.Physics; +using Content.Shared.Maps; +using Robust.Shared.IoC; +using Robust.Shared.Interfaces.GameObjects; +using Content.Shared.GameObjects.Components.Movement; +using Content.Shared.GameObjects.EntitySystems; +using System.Collections.Generic; +using Robust.Shared.Physics; +using System.Diagnostics; + +namespace Content.Server.GameObjects.Components.Movement +{ + [RegisterComponent] + public class ClimbingComponent : SharedClimbingComponent, IActionBlocker + { + private bool _isClimbing = false; + private ClimbController _climbController = default; + + public override bool IsClimbing + { + get + { + return _isClimbing; + } + set + { + if (!value && Body != null) + { + Body.TryRemoveController(); + } + + _isClimbing = value; + Dirty(); + } + } + + /// + /// Make the owner climb from one point to another + /// + public void TryMoveTo(Vector2 from, Vector2 to) + { + if (Body != null) + { + _climbController = Body.EnsureController(); + _climbController.TryMoveTo(from, to); + } + } + + public void Update(float frameTime) + { + if (Body != null && IsClimbing) + { + if (_climbController != null && (_climbController.IsBlocked || !_climbController.IsActive)) + { + if (Body.TryRemoveController()) + { + _climbController = null; + } + } + + if (!IsOnClimbableThisFrame && IsClimbing && _climbController == null) + { + IsClimbing = false; + } + + IsOnClimbableThisFrame = false; + } + } + + public override ComponentState GetComponentState() + { + return new ClimbModeComponentState(_isClimbing); + } + } +} diff --git a/Content.Server/GameObjects/EntitySystems/ClimbSystem.cs b/Content.Server/GameObjects/EntitySystems/ClimbSystem.cs new file mode 100644 index 0000000000..f7d2e37372 --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/ClimbSystem.cs @@ -0,0 +1,18 @@ +using Content.Server.GameObjects.Components.Movement; +using JetBrains.Annotations; +using Robust.Shared.GameObjects.Systems; + +namespace Content.Server.GameObjects.EntitySystems +{ + [UsedImplicitly] + internal sealed class ClimbSystem : EntitySystem + { + public override void Update(float frameTime) + { + foreach (var comp in ComponentManager.EntityQuery()) + { + comp.Update(frameTime); + } + } + } +} diff --git a/Content.Shared/GameObjects/Components/Movement/SharedClimbableComponent.cs b/Content.Shared/GameObjects/Components/Movement/SharedClimbableComponent.cs new file mode 100644 index 0000000000..951d051ac1 --- /dev/null +++ b/Content.Shared/GameObjects/Components/Movement/SharedClimbableComponent.cs @@ -0,0 +1,11 @@ +using Robust.Shared.GameObjects; + +namespace Content.Shared.GameObjects.Components.Movement +{ + public interface IClimbable { }; + + public class SharedClimbableComponent : Component, IClimbable + { + public sealed override string Name => "Climbable"; + } +} diff --git a/Content.Shared/GameObjects/Components/Movement/SharedClimbingComponent.cs b/Content.Shared/GameObjects/Components/Movement/SharedClimbingComponent.cs new file mode 100644 index 0000000000..3fdb287576 --- /dev/null +++ b/Content.Shared/GameObjects/Components/Movement/SharedClimbingComponent.cs @@ -0,0 +1,66 @@ +using Content.Shared.GameObjects.EntitySystems; +using Content.Shared.Physics; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Components; +using Robust.Shared.Physics; +using Robust.Shared.Serialization; +using System; + +namespace Content.Shared.GameObjects.Components.Movement +{ + public abstract class SharedClimbingComponent : Component, IActionBlocker, ICollideSpecial + { + public sealed override string Name => "Climbing"; + public sealed override uint? NetID => ContentNetIDs.CLIMBING; + + protected ICollidableComponent Body; + protected bool IsOnClimbableThisFrame = false; + + protected bool OwnerIsTransitioning + { + get + { + if (Body.TryGetController(out var controller)) + { + return controller.IsActive; + } + + return false; + } + } + + public abstract bool IsClimbing { get; set; } + + bool IActionBlocker.CanMove() => !OwnerIsTransitioning; + bool IActionBlocker.CanChangeDirection() => !OwnerIsTransitioning; + + bool ICollideSpecial.PreventCollide(IPhysBody collided) + { + if (((CollisionGroup)collided.CollisionLayer).HasFlag(CollisionGroup.VaultImpassable) && collided.Entity.HasComponent()) + { + IsOnClimbableThisFrame = true; + return IsClimbing; + } + + return false; + } + + public override void Initialize() + { + base.Initialize(); + + Owner.TryGetComponent(out Body); + } + + [Serializable, NetSerializable] + protected sealed class ClimbModeComponentState : ComponentState + { + public ClimbModeComponentState(bool climbing) : base(ContentNetIDs.CLIMBING) + { + Climbing = climbing; + } + + public bool Climbing { get; } + } + } +} diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs index 337a543627..7d7bcfdc4f 100644 --- a/Content.Shared/GameObjects/ContentNetIDs.cs +++ b/Content.Shared/GameObjects/ContentNetIDs.cs @@ -54,7 +54,6 @@ public const uint STUNNABLE = 1048; public const uint HUNGER = 1049; public const uint THIRST = 1050; - public const uint FLASHABLE = 1051; public const uint BUCKLE = 1052; public const uint PROJECTILE = 1053; @@ -65,6 +64,7 @@ public const uint DO_AFTER = 1058; public const uint RADIATION_PULSE = 1059; public const uint BODY_MANAGER = 1060; + public const uint CLIMBING = 1061; // Net IDs for integration tests. public const uint PREDICTION_TEST = 10001; diff --git a/Content.Shared/GameObjects/EntitySystems/ActionBlockerSystem.cs b/Content.Shared/GameObjects/EntitySystems/ActionBlockerSystem.cs index c06963d53f..e7a32bf4fe 100644 --- a/Content.Shared/GameObjects/EntitySystems/ActionBlockerSystem.cs +++ b/Content.Shared/GameObjects/EntitySystems/ActionBlockerSystem.cs @@ -10,21 +10,13 @@ namespace Content.Shared.GameObjects.EntitySystems public interface IActionBlocker { bool CanMove() => true; - bool CanInteract() => true; - bool CanUse() => true; - bool CanThrow() => true; - bool CanSpeak() => true; - bool CanDrop() => true; - bool CanPickup() => true; - bool CanEmote() => true; - bool CanAttack() => true; bool CanEquip() => true; diff --git a/Content.Shared/Physics/ClimbController.cs b/Content.Shared/Physics/ClimbController.cs new file mode 100644 index 0000000000..8c779aa41d --- /dev/null +++ b/Content.Shared/Physics/ClimbController.cs @@ -0,0 +1,87 @@ +#nullable enable +using Robust.Shared.Maths; +using Robust.Shared.Physics; + +namespace Content.Shared.Physics +{ + /// + /// Movement controller used by the climb system. Lerps the player from A to B. + /// Also does checks to make sure the player isn't blocked. + /// + public class ClimbController : VirtualController + { + private Vector2? _movingTo = null; + private Vector2 _lastKnownPosition = default; + private int _numTicksBlocked = 0; + + /// + /// If 5 ticks have passed and our position has not changed then something is blocking us. + /// + public bool IsBlocked => _numTicksBlocked > 5 || _isMovingWrongDirection; + + /// + /// If the controller is currently moving the player somewhere, it is considered active. + /// + public bool IsActive => _movingTo.HasValue; + + private float _initialDist = default; + private bool _isMovingWrongDirection = false; + + public void TryMoveTo(Vector2 from, Vector2 to) + { + if (ControlledComponent == null) + { + return; + } + + _initialDist = (from - to).Length; + _numTicksBlocked = 0; + _lastKnownPosition = from; + _movingTo = to; + _isMovingWrongDirection = false; + } + + public override void UpdateAfterProcessing() + { + base.UpdateAfterProcessing(); + + if (ControlledComponent == null || _movingTo == null) + { + return; + } + + if ((ControlledComponent.Owner.Transform.WorldPosition - _lastKnownPosition).Length <= 0.05f) + { + _numTicksBlocked++; + } + else + { + _numTicksBlocked = 0; + } + + _lastKnownPosition = ControlledComponent.Owner.Transform.WorldPosition; + + if ((ControlledComponent.Owner.Transform.WorldPosition - _movingTo.Value).Length <= 0.05f) + { + _movingTo = null; + } + + if (_movingTo.HasValue) + { + var dist = (_lastKnownPosition - _movingTo.Value).Length; + + if (dist > _initialDist) + { + _isMovingWrongDirection = true; + } + + var diff = _movingTo.Value - ControlledComponent.Owner.Transform.WorldPosition; + LinearVelocity = diff.Normalized * 5; + } + else + { + LinearVelocity = Vector2.Zero; + } + } + } +} diff --git a/Resources/Prototypes/Entities/Constructible/Ground/table.yml b/Resources/Prototypes/Entities/Constructible/Ground/table.yml index 568a1f958d..1dd92f308e 100644 --- a/Resources/Prototypes/Entities/Constructible/Ground/table.yml +++ b/Resources/Prototypes/Entities/Constructible/Ground/table.yml @@ -22,6 +22,7 @@ - type: IconSmooth key: generic base: solid_ + - type: Climbable - type: Destructible maxHP: 50 spawnOnDestroy: SteelSheet1 diff --git a/Resources/Prototypes/Entities/Mobs/Species/human.yml b/Resources/Prototypes/Entities/Mobs/Species/human.yml index 637f722349..e104ad5ccb 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml @@ -141,6 +141,7 @@ - type: RotationVisualizer - type: BuckleVisualizer - type: CombatMode + - type: Climbing - type: Teleportable - type: CharacterInfo - type: FootstepSound