Climbing system (#1750)

* Initial commit

* Climbing uses its own controller now

* Missed a check

* Get rid of hands check

* Cleanup

* Get rid of speciescomponent stuff

* Remove unneeded check, add separate case for moving other players.

* Add DoAfter

* IClientDraggable added to ClimbingComponent

* Added some basic integration tests. Renamed ClimbMode to Climbing.

* oops

* Minor fixes

* ffff

* Table fix

* Revamped system so its more predicted, uses proper  logic for de-climbing. Get hype!!!

* Flag check fix

* Distance check and reset numticksblocked

* get rid
This commit is contained in:
nuke
2020-08-19 18:13:22 -04:00
committed by GitHub
parent b7e5aafdbc
commit f4909cdb98
13 changed files with 608 additions and 9 deletions

View File

@@ -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
{
}
}

View File

@@ -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<IClimbable>();
}
bool IClientDraggable.ClientCanDrag(CanDragEventArgs eventArgs)
{
return true;
}
}
}

View File

@@ -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<IMapManager>();
mapManager.CreateNewMapEntity(MapId.Nullspace);
var entityManager = IoCManager.Resolve<IEntityManager>();
// 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<ClimbController>(), "Player has no ClimbController");
// Force the player out of climb state. It should immediately remove the ClimbController.
climbing.IsClimbing = false;
Assert.True(!body.HasController<ClimbController>(), "Player wrongly has a ClimbController");
});
await server.WaitIdleAsync();
}
}
}

View File

@@ -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
/// <summary>
/// The range from which this entity can be climbed.
/// </summary>
[ViewVariables]
private float _range;
/// <summary>
/// The time it takes to climb onto the entity.
/// </summary>
[ViewVariables]
private float _climbDelay;
private ICollidableComponent _collidableComponent;
private DoAfterSystem _doAfterSystem;
public override void Initialize()
{
base.Initialize();
_collidableComponent = Owner.GetComponent<ICollidableComponent>();
_doAfterSystem = EntitySystem.Get<DoAfterSystem>();
}
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<ClimbingComponent>())
{
_notifyManager.PopupMessage(eventArgs.User, eventArgs.User, Loc.GetString("You are incapable of climbing!"));
return false;
}
var bodyManager = eventArgs.User.GetComponent<BodyManagerComponent>();
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<SharedInteractionSystem>();
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<ClimbingComponent>())
{
_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<SharedInteractionSystem>();
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<ClimbingComponent>();
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<ClimbingComponent>();
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);
}
}
}
}

View File

@@ -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<ClimbController>();
}
_isClimbing = value;
Dirty();
}
}
/// <summary>
/// Make the owner climb from one point to another
/// </summary>
public void TryMoveTo(Vector2 from, Vector2 to)
{
if (Body != null)
{
_climbController = Body.EnsureController<ClimbController>();
_climbController.TryMoveTo(from, to);
}
}
public void Update(float frameTime)
{
if (Body != null && IsClimbing)
{
if (_climbController != null && (_climbController.IsBlocked || !_climbController.IsActive))
{
if (Body.TryRemoveController<ClimbController>())
{
_climbController = null;
}
}
if (!IsOnClimbableThisFrame && IsClimbing && _climbController == null)
{
IsClimbing = false;
}
IsOnClimbableThisFrame = false;
}
}
public override ComponentState GetComponentState()
{
return new ClimbModeComponentState(_isClimbing);
}
}
}

View File

@@ -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<ClimbingComponent>())
{
comp.Update(frameTime);
}
}
}
}

View File

@@ -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";
}
}

View File

@@ -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<ClimbController>(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<IClimbable>())
{
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; }
}
}
}

View File

@@ -54,7 +54,6 @@
public const uint STUNNABLE = 1048; public const uint STUNNABLE = 1048;
public const uint HUNGER = 1049; public const uint HUNGER = 1049;
public const uint THIRST = 1050; public const uint THIRST = 1050;
public const uint FLASHABLE = 1051; public const uint FLASHABLE = 1051;
public const uint BUCKLE = 1052; public const uint BUCKLE = 1052;
public const uint PROJECTILE = 1053; public const uint PROJECTILE = 1053;
@@ -65,6 +64,7 @@
public const uint DO_AFTER = 1058; public const uint DO_AFTER = 1058;
public const uint RADIATION_PULSE = 1059; public const uint RADIATION_PULSE = 1059;
public const uint BODY_MANAGER = 1060; public const uint BODY_MANAGER = 1060;
public const uint CLIMBING = 1061;
// Net IDs for integration tests. // Net IDs for integration tests.
public const uint PREDICTION_TEST = 10001; public const uint PREDICTION_TEST = 10001;

View File

@@ -10,21 +10,13 @@ namespace Content.Shared.GameObjects.EntitySystems
public interface IActionBlocker public interface IActionBlocker
{ {
bool CanMove() => true; bool CanMove() => true;
bool CanInteract() => true; bool CanInteract() => true;
bool CanUse() => true; bool CanUse() => true;
bool CanThrow() => true; bool CanThrow() => true;
bool CanSpeak() => true; bool CanSpeak() => true;
bool CanDrop() => true; bool CanDrop() => true;
bool CanPickup() => true; bool CanPickup() => true;
bool CanEmote() => true; bool CanEmote() => true;
bool CanAttack() => true; bool CanAttack() => true;
bool CanEquip() => true; bool CanEquip() => true;

View File

@@ -0,0 +1,87 @@
#nullable enable
using Robust.Shared.Maths;
using Robust.Shared.Physics;
namespace Content.Shared.Physics
{
/// <summary>
/// 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.
/// </summary>
public class ClimbController : VirtualController
{
private Vector2? _movingTo = null;
private Vector2 _lastKnownPosition = default;
private int _numTicksBlocked = 0;
/// <summary>
/// If 5 ticks have passed and our position has not changed then something is blocking us.
/// </summary>
public bool IsBlocked => _numTicksBlocked > 5 || _isMovingWrongDirection;
/// <summary>
/// If the controller is currently moving the player somewhere, it is considered active.
/// </summary>
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;
}
}
}
}

View File

@@ -22,6 +22,7 @@
- type: IconSmooth - type: IconSmooth
key: generic key: generic
base: solid_ base: solid_
- type: Climbable
- type: Destructible - type: Destructible
maxHP: 50 maxHP: 50
spawnOnDestroy: SteelSheet1 spawnOnDestroy: SteelSheet1

View File

@@ -141,6 +141,7 @@
- type: RotationVisualizer - type: RotationVisualizer
- type: BuckleVisualizer - type: BuckleVisualizer
- type: CombatMode - type: CombatMode
- type: Climbing
- type: Teleportable - type: Teleportable
- type: CharacterInfo - type: CharacterInfo
- type: FootstepSound - type: FootstepSound