Files
wwdpublic/Content.Shared/Climbing/Systems/ClimbSystem.cs
SimpleStation14 9d059cf4b7 Mirror: Fix bypassing vaulting clumsy check with verb action. (#314)
## Mirror of PR #24977: [Fix bypassing vaulting clumsy check with verb
action.](https://github.com/space-wizards/space-station-14/pull/24977)
from <img src="https://avatars.githubusercontent.com/u/10567778?v=4"
alt="space-wizards" width="22"/>
[space-wizards](https://github.com/space-wizards)/[space-station-14](https://github.com/space-wizards/space-station-14)

###### `225bc3c5aeffbef6286b607b02cd24a8ad75a437`

PR opened by <img
src="https://avatars.githubusercontent.com/u/85356?v=4" width="16"/><a
href="https://github.com/Tayrtahn"> Tayrtahn</a> at 2024-02-05 21:32:50
UTC

---

PR changed 7 files with 101 additions and 39 deletions.

The PR had the following labels:
- Status: Needs Review


---

<details open="true"><summary><h1>Original Body</h1></summary>

> <!-- Please read these guidelines before opening your PR:
https://docs.spacestation14.io/en/getting-started/pr-guideline -->
> <!-- The text between the arrows are comments - they will not be
visible on your PR. -->
> 
> ## About the PR
> <!-- What did you change in this PR? -->
> Stops clumsy characters being able to climb/vault onto tables and such
by using alt-click or the verb menu instead of drag-dropping.
> 
> This also fixes a separate bug where, when a clumsy character managed
to pass the bonk check, the action would just cancel and the character
didn't actually climb the object. Passing the check will now let the
character climb.
> 
> Additionally, this fixes a oddity where the ability for mobs to climb
was determined by whether or not they had feet/legs, which was arbitrary
and problematic for creatures like slimes (which should be able to ooze
up onto a table). This check could also be bypassed by using the verb
instead of drag-dropping, so that was fixed too. The ability for mobs to
climb is now controlled in the prototypes.
> 
> The default chance of a clumsy character bonking on a climbable has
also been reduced from 75% to 50% to compensate somewhat.
> 
> ## Why / Balance
> <!-- Why was it changed? Link any discussions or issues here. Please
discuss how this would affect game balance. -->
> Fixes #24951
> Fixes #17423
> Fixes #25951
> 
> ## Technical details
> <!-- If this is a code change, summarize at high level how your new
code works. This makes it easier to review. -->
> ClimbSystem now raises AttemptClimbEvent as part of TryClimb, and
aborts climbing if it gets cancelled. This makes sure that all code
paths go through a clumsy check. BonkSystem now listens for
AttemptClimbEvents and responds to them instead of trying to intercept
DragDropEvents.
> CanVault now gets checked in TryClimb as well, to prevent bypassing
it.
> 
> The logic for climbing capabilities is now:
> - The presence of ClimbingComponent makes an entity able to be placed
on surfaces like tables.
> - ClimbingComponent.CanClimb controls whether it can climb onto
surfaces by drag-drop or verb.
> 
> The new field defaults to true to minimize changes to existing
behavior. Some mobs will have gained the ability to climb when
previously they couldn't, but that should be less impactful than the
opposite and can be resolved by YML changes in the future.
> 
> ## Media
> <!-- 
> PRs which make ingame changes (adding clothing, items, new features,
etc) are required to have media attached that showcase the changes.
> Small fixes/refactors are exempt.
> Any media may be used in SS14 progress reports, with clear credit
given.
> 
> If you're unsure whether your PR will require media, ask a maintainer.
> 
> Check the box below to confirm that you have in fact seen this (put an
X in the brackets, like [X]):
> -->
> 
> - [x] I have added screenshots/videos to this PR showcasing its
changes ingame, **or** this PR does not require an ingame showcase
> 
> ## Breaking changes
> <!--
> List any breaking changes, including namespace, public
class/method/field changes, prototype renames; and provide instructions
for fixing them. This will be pasted in #codebase-changes.
> -->
> 
> 
> **Changelog**
> <!--
> Make players aware of new features and changes that could affect how
they play the game by adding a Changelog entry. Please read the
Changelog guidelines located at:
https://docs.spacestation14.io/en/getting-started/pr-guideline#changelog
> -->
> 
> <!--
> Make sure to take this Changelog template out of the comment block in
order for it to show up.
> 🆑
> - add: Added fun!
> - remove: Removed fun!
> - tweak: Changed fun!
> - fix: Fixed fun!
> -->
> 🆑
> - fix: Clumsy characters can no longer avoid bonking their heads by
using the Climb verb.
> - tweak: Clumsy characters are less likely to bonk their heads when
trying to climb.


</details>

Co-authored-by: SimpleStation14 <Unknown>
2024-05-10 21:15:29 -04:00

500 lines
18 KiB
C#

using Content.Shared.ActionBlocker;
using Content.Shared.Body.Systems;
using Content.Shared.Buckle.Components;
using Content.Shared.Climbing.Components;
using Content.Shared.Climbing.Events;
using Content.Shared.Damage;
using Content.Shared.DoAfter;
using Content.Shared.DragDrop;
using Content.Shared.Hands.Components;
using Content.Shared.IdentityManagement;
using Content.Shared.Interaction;
using Content.Shared.Movement.Events;
using Content.Shared.Physics;
using Content.Shared.Popups;
using Content.Shared.Stunnable;
using Content.Shared.Verbs;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Physics;
using Robust.Shared.Physics.Collision.Shapes;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Controllers;
using Robust.Shared.Physics.Events;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.Climbing.Systems;
public sealed partial class ClimbSystem : VirtualController
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly DamageableSystem _damageableSystem = default!;
[Dependency] private readonly FixtureSystem _fixtureSystem = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedBodySystem _bodySystem = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!;
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
[Dependency] private readonly SharedPopupSystem _popupSystem = default!;
[Dependency] private readonly SharedPhysicsSystem _physics = default!;
[Dependency] private readonly SharedStunSystem _stunSystem = default!;
[Dependency] private readonly SharedTransformSystem _xformSystem = default!;
private const string ClimbingFixtureName = "climb";
private const int ClimbingCollisionGroup = (int) (CollisionGroup.TableLayer | CollisionGroup.LowImpassable);
private EntityQuery<FixturesComponent> _fixturesQuery;
private EntityQuery<TransformComponent> _xformQuery;
public override void Initialize()
{
base.Initialize();
_fixturesQuery = GetEntityQuery<FixturesComponent>();
_xformQuery = GetEntityQuery<TransformComponent>();
SubscribeLocalEvent<ClimbingComponent, UpdateCanMoveEvent>(OnMoveAttempt);
SubscribeLocalEvent<ClimbingComponent, EntParentChangedMessage>(OnParentChange);
SubscribeLocalEvent<ClimbingComponent, ClimbDoAfterEvent>(OnDoAfter);
SubscribeLocalEvent<ClimbingComponent, EndCollideEvent>(OnClimbEndCollide);
SubscribeLocalEvent<ClimbingComponent, BuckleChangeEvent>(OnBuckleChange);
SubscribeLocalEvent<ClimbableComponent, CanDropTargetEvent>(OnCanDragDropOn);
SubscribeLocalEvent<ClimbableComponent, GetVerbsEvent<AlternativeVerb>>(AddClimbableVerb);
SubscribeLocalEvent<ClimbableComponent, DragDropTargetEvent>(OnClimbableDragDrop);
SubscribeLocalEvent<GlassTableComponent, ClimbedOnEvent>(OnGlassClimbed);
}
public override void UpdateBeforeSolve(bool prediction, float frameTime)
{
base.UpdateBeforeSolve(prediction, frameTime);
var query = EntityQueryEnumerator<ClimbingComponent>();
var curTime = _timing.CurTime;
// Move anything still climb in the specified direction.
while (query.MoveNext(out var uid, out var comp))
{
if (comp.NextTransition == null)
continue;
if (comp.NextTransition < curTime)
{
FinishTransition(uid, comp);
continue;
}
var xform = _xformQuery.GetComponent(uid);
_xformSystem.SetLocalPosition(uid, xform.LocalPosition + comp.Direction * frameTime, xform);
}
}
private void FinishTransition(EntityUid uid, ClimbingComponent comp)
{
// TODO: Validate climb here
comp.NextTransition = null;
_actionBlockerSystem.UpdateCanMove(uid);
Dirty(uid, comp);
// Stop if necessary.
if (!_fixturesQuery.TryGetComponent(uid, out var fixtures) ||
!IsClimbing(uid, fixtures))
{
StopClimb(uid, comp);
return;
}
}
/// <summary>
/// Returns true if entity currently has a valid vault.
/// </summary>
private bool IsClimbing(EntityUid uid, FixturesComponent? fixturesComp = null)
{
if (!_fixturesQuery.Resolve(uid, ref fixturesComp) || !fixturesComp.Fixtures.TryGetValue(ClimbingFixtureName, out var climbFixture))
return false;
foreach (var contact in climbFixture.Contacts.Values)
{
var other = uid == contact.EntityA ? contact.EntityB : contact.EntityA;
if (HasComp<ClimbableComponent>(other))
{
return true;
}
}
return false;
}
private void OnMoveAttempt(EntityUid uid, ClimbingComponent component, UpdateCanMoveEvent args)
{
// Can't move when transition.
if (component.NextTransition != null)
args.Cancel();
}
private void OnParentChange(EntityUid uid, ClimbingComponent component, ref EntParentChangedMessage args)
{
if (component.NextTransition != null)
{
FinishTransition(uid, component);
}
}
private void OnCanDragDropOn(EntityUid uid, ClimbableComponent component, ref CanDropTargetEvent args)
{
if (args.Handled)
return;
var canVault = args.User == args.Dragged
? CanVault(component, args.User, uid, out _)
: CanVault(component, args.User, args.Dragged, uid, out _);
args.CanDrop = canVault;
if (!HasComp<HandsComponent>(args.User))
args.CanDrop = false;
args.Handled = true;
}
private void AddClimbableVerb(EntityUid uid, ClimbableComponent component, GetVerbsEvent<AlternativeVerb> args)
{
if (!args.CanAccess || !args.CanInteract || !_actionBlockerSystem.CanMove(args.User))
return;
if (!TryComp(args.User, out ClimbingComponent? climbingComponent) || climbingComponent.IsClimbing || !climbingComponent.CanClimb)
return;
// TODO VERBS ICON add a climbing icon?
args.Verbs.Add(new AlternativeVerb
{
Act = () => TryClimb(args.User, args.User, args.Target, out _, component),
Text = Loc.GetString("comp-climbable-verb-climb")
});
}
private void OnClimbableDragDrop(EntityUid uid, ClimbableComponent component, ref DragDropTargetEvent args)
{
if (args.Handled)
return;
TryClimb(args.User, args.Dragged, uid, out _, component);
}
public bool TryClimb(
EntityUid user,
EntityUid entityToMove,
EntityUid climbable,
out DoAfterId? id,
ClimbableComponent? comp = null,
ClimbingComponent? climbing = null)
{
id = null;
if (!Resolve(climbable, ref comp) || !Resolve(entityToMove, ref climbing, false))
return false;
var canVault = user == entityToMove
? CanVault(comp, user, climbable, out var reason)
: CanVault(comp, user, entityToMove, climbable, out reason);
if (!canVault)
{
_popupSystem.PopupClient(reason, user, user);
return false;
}
// Note, IsClimbing does not mean a DoAfter is active, it means the target has already finished a DoAfter and
// is currently on top of something..
if (climbing.IsClimbing)
return true;
var ev = new AttemptClimbEvent(user, entityToMove, climbable);
RaiseLocalEvent(climbable, ref ev);
if (ev.Cancelled)
return false;
var args = new DoAfterArgs(EntityManager, user, comp.ClimbDelay, new ClimbDoAfterEvent(),
entityToMove,
target: climbable,
used: entityToMove)
{
BreakOnTargetMove = true,
BreakOnUserMove = true,
BreakOnDamage = true
};
_audio.PlayPredicted(comp.StartClimbSound, climbable, user);
return _doAfterSystem.TryStartDoAfter(args, out id);
}
private void OnDoAfter(EntityUid uid, ClimbingComponent component, ClimbDoAfterEvent args)
{
if (args.Handled || args.Cancelled || args.Args.Target == null || args.Args.Used == null)
return;
Climb(uid, args.Args.User, args.Args.Target.Value, climbing: component);
args.Handled = true;
}
private void Climb(EntityUid uid, EntityUid user, EntityUid climbable, bool silent = false, ClimbingComponent? climbing = null,
PhysicsComponent? physics = null, FixturesComponent? fixtures = null, ClimbableComponent? comp = null)
{
if (!Resolve(uid, ref climbing, ref physics, ref fixtures, false))
return;
if (!Resolve(climbable, ref comp, false))
return;
if (!ReplaceFixtures(uid, climbing, fixtures))
return;
var xform = _xformQuery.GetComponent(uid);
var (worldPos, worldRot) = _xformSystem.GetWorldPositionRotation(xform);
var worldDirection = _xformSystem.GetWorldPosition(climbable) - worldPos;
var distance = worldDirection.Length();
var parentRot = worldRot - xform.LocalRotation;
// Need direction relative to climber's parent.
var localDirection = (-parentRot).RotateVec(worldDirection);
// On top of it already so just do it in place.
if (localDirection.LengthSquared() < 0.01f)
{
climbing.NextTransition = null;
}
// VirtualController over to the thing.
else
{
var climbDuration = TimeSpan.FromSeconds(distance / climbing.TransitionRate);
climbing.NextTransition = _timing.CurTime + climbDuration;
climbing.Direction = localDirection.Normalized() * climbing.TransitionRate;
_actionBlockerSystem.UpdateCanMove(uid);
}
climbing.IsClimbing = true;
Dirty(uid, climbing);
_audio.PlayPredicted(comp.FinishClimbSound, climbable, user);
var startEv = new StartClimbEvent(climbable);
var climbedEv = new ClimbedOnEvent(uid, user);
RaiseLocalEvent(uid, ref startEv);
RaiseLocalEvent(climbable, ref climbedEv);
if (silent)
return;
string selfMessage;
string othersMessage;
if (user == uid)
{
othersMessage = Loc.GetString("comp-climbable-user-climbs-other",
("user", Identity.Entity(uid, EntityManager)),
("climbable", climbable));
selfMessage = Loc.GetString("comp-climbable-user-climbs", ("climbable", climbable));
}
else
{
othersMessage = Loc.GetString("comp-climbable-user-climbs-force-other",
("user", Identity.Entity(user, EntityManager)),
("moved-user", Identity.Entity(uid, EntityManager)), ("climbable", climbable));
selfMessage = Loc.GetString("comp-climbable-user-climbs-force", ("moved-user", Identity.Entity(uid, EntityManager)),
("climbable", climbable));
}
_popupSystem.PopupEntity(othersMessage, uid, Filter.PvsExcept(user, entityManager: EntityManager), true);
_popupSystem.PopupClient(selfMessage, uid, user);
}
/// <summary>
/// Replaces the current fixtures with non-climbing collidable versions so that climb end can be detected
/// </summary>
/// <returns>Returns whether adding the new fixtures was successful</returns>
private bool ReplaceFixtures(EntityUid uid, ClimbingComponent climbingComp, FixturesComponent fixturesComp)
{
// Swap fixtures
foreach (var (name, fixture) in fixturesComp.Fixtures)
{
if (climbingComp.DisabledFixtureMasks.ContainsKey(name)
|| fixture.Hard == false
|| (fixture.CollisionMask & ClimbingCollisionGroup) == 0)
{
continue;
}
climbingComp.DisabledFixtureMasks.Add(name, fixture.CollisionMask & ClimbingCollisionGroup);
_physics.SetCollisionMask(uid, name, fixture, fixture.CollisionMask & ~ClimbingCollisionGroup, fixturesComp);
}
if (!_fixtureSystem.TryCreateFixture(
uid,
new PhysShapeCircle(0.35f),
ClimbingFixtureName,
collisionLayer: (int) CollisionGroup.None,
collisionMask: ClimbingCollisionGroup,
hard: false,
manager: fixturesComp))
{
return false;
}
return true;
}
private void OnClimbEndCollide(EntityUid uid, ClimbingComponent component, ref EndCollideEvent args)
{
if (args.OurFixtureId != ClimbingFixtureName
|| !component.IsClimbing
|| component.NextTransition != null
|| args.OurFixture.Contacts.Count > 1)
{
return;
}
foreach (var otherFixture in args.OurFixture.Contacts.Keys)
{
// If it's the other fixture then ignore em
if (otherFixture == args.OtherFixture)
continue;
// If still colliding with a climbable, do not stop climbing
if (HasComp<ClimbableComponent>(otherFixture.Owner))
return;
}
StopClimb(uid, component);
}
private void StopClimb(EntityUid uid, ClimbingComponent? climbing = null, FixturesComponent? fixtures = null)
{
if (!Resolve(uid, ref climbing, ref fixtures, false))
return;
foreach (var (name, fixtureMask) in climbing.DisabledFixtureMasks)
{
if (!fixtures.Fixtures.TryGetValue(name, out var fixture))
{
continue;
}
_physics.SetCollisionMask(uid, name, fixture, fixture.CollisionMask | fixtureMask, fixtures);
}
climbing.DisabledFixtureMasks.Clear();
_fixtureSystem.DestroyFixture(uid, ClimbingFixtureName, manager: fixtures);
climbing.IsClimbing = false;
climbing.NextTransition = null;
var ev = new EndClimbEvent();
RaiseLocalEvent(uid, ref ev);
Dirty(uid, climbing);
}
/// <summary>
/// Checks if the user can vault the target
/// </summary>
/// <param name="component">The component of the entity that is being vaulted</param>
/// <param name="user">The entity that wants to vault</param>
/// <param name="target">The object that is being vaulted</param>
/// <param name="reason">The reason why it cant be dropped</param>
public bool CanVault(ClimbableComponent component, EntityUid user, EntityUid target, out string reason)
{
if (!_actionBlockerSystem.CanInteract(user, target))
{
reason = Loc.GetString("comp-climbable-cant-interact");
return false;
}
if (!TryComp<ClimbingComponent>(user, out var climbingComp)
|| !climbingComp.CanClimb)
{
reason = Loc.GetString("comp-climbable-cant-climb");
return false;
}
if (!_interactionSystem.InRangeUnobstructed(user, target, component.Range))
{
reason = Loc.GetString("comp-climbable-cant-reach");
return false;
}
reason = string.Empty;
return true;
}
/// <summary>
/// Checks if the user can vault the dragged entity onto the the target
/// </summary>
/// <param name="component">The climbable component of the object being vaulted onto</param>
/// <param name="user">The user that wants to vault the entity</param>
/// <param name="dragged">The entity that is being vaulted</param>
/// <param name="target">The object that is being vaulted onto</param>
/// <param name="reason">The reason why it cant be dropped</param>
/// <returns></returns>
public bool CanVault(ClimbableComponent component, EntityUid user, EntityUid dragged, EntityUid target,
out string reason)
{
if (!_actionBlockerSystem.CanInteract(user, dragged) || !_actionBlockerSystem.CanInteract(user, target))
{
reason = Loc.GetString("comp-climbable-cant-interact");
return false;
}
if (!HasComp<ClimbingComponent>(dragged))
{
reason = Loc.GetString("comp-climbable-target-cant-climb", ("moved-user", Identity.Entity(dragged, EntityManager)));
return false;
}
bool Ignored(EntityUid entity) => entity == target || entity == user || entity == dragged;
if (!_interactionSystem.InRangeUnobstructed(user, target, component.Range, predicate: Ignored)
|| !_interactionSystem.InRangeUnobstructed(user, dragged, component.Range, predicate: Ignored))
{
reason = Loc.GetString("comp-climbable-cant-reach");
return false;
}
reason = string.Empty;
return true;
}
public void ForciblySetClimbing(EntityUid uid, EntityUid climbable, ClimbingComponent? component = null)
{
Climb(uid, uid, climbable, true, component);
}
private void OnBuckleChange(EntityUid uid, ClimbingComponent component, ref BuckleChangeEvent args)
{
if (!args.Buckling)
return;
StopClimb(uid, component);
}
private void OnGlassClimbed(EntityUid uid, GlassTableComponent component, ref ClimbedOnEvent args)
{
if (TryComp<PhysicsComponent>(args.Climber, out var physics) && physics.Mass <= component.MassLimit)
return;
_damageableSystem.TryChangeDamage(args.Climber, component.ClimberDamage, origin: args.Climber);
_damageableSystem.TryChangeDamage(uid, component.TableDamage, origin: args.Climber);
_stunSystem.TryParalyze(args.Climber, TimeSpan.FromSeconds(component.StunTime), true);
// Not shown to the user, since they already get a 'you climb on the glass table' popup
_popupSystem.PopupEntity(
Loc.GetString("glass-table-shattered-others", ("table", uid), ("climber", Identity.Entity(args.Climber, EntityManager))), args.Climber,
Filter.PvsExcept(args.Climber), true);
}
[Serializable, NetSerializable]
private sealed partial class ClimbDoAfterEvent : SimpleDoAfterEvent
{
}
}