Turnstiles (#36313)

* construction rotation fix

* Turnstiles

* renaming

* review-slarticodefast-1

* mild attempts to fix (sorry sloth)

* move some more shit

* Remove engine dependency

* grid agnostic

* remove debug string

* fix json

* Update Content.Shared/Movement/Pulling/Systems/PullingSystem.cs

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>

* Update Content.Shared/Movement/Pulling/Systems/PullingSystem.cs

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>

* remove pass delay for mispredict reasons.

* most minor of changes

* Give directional indicator on examine

---------

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>
This commit is contained in:
Nemanja
2025-04-24 07:39:40 -04:00
committed by GitHub
parent 0deb35000f
commit 712954f1c4
15 changed files with 486 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
using Content.Shared.Doors.Components;
using Content.Shared.Doors.Systems;
using Content.Shared.Examine;
using Robust.Client.Animations;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Shared.Map;
using Robust.Shared.Prototypes;
namespace Content.Client.Doors;
/// <inheritdoc/>
public sealed class TurnstileSystem : SharedTurnstileSystem
{
[Dependency] private readonly AnimationPlayerSystem _animationPlayer = default!;
private static EntProtoId _examineArrow = "TurnstileArrow";
private const string AnimationKey = "Turnstile";
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<TurnstileComponent, AnimationCompletedEvent>(OnAnimationCompleted);
SubscribeLocalEvent<TurnstileComponent, ExaminedEvent>(OnExamined);
}
private void OnAnimationCompleted(Entity<TurnstileComponent> ent, ref AnimationCompletedEvent args)
{
if (args.Key != AnimationKey)
return;
if (!TryComp<SpriteComponent>(ent, out var sprite))
return;
sprite.LayerSetState(TurnstileVisualLayers.Base, new RSI.StateId(ent.Comp.DefaultState));
}
private void OnExamined(Entity<TurnstileComponent> ent, ref ExaminedEvent args)
{
Spawn(_examineArrow, new EntityCoordinates(ent, 0, 0));
}
protected override void PlayAnimation(EntityUid uid, string stateId)
{
if (!TryComp<AnimationPlayerComponent>(uid, out var animation) || !TryComp<SpriteComponent>(uid, out var sprite))
return;
var ent = (uid, animation);
if (_animationPlayer.HasRunningAnimation(animation, AnimationKey))
_animationPlayer.Stop(ent, AnimationKey);
if (sprite.BaseRSI == null || !sprite.BaseRSI.TryGetState(stateId, out var state))
return;
var animLength = state.AnimationLength;
var anim = new Animation
{
AnimationTracks =
{
new AnimationTrackSpriteFlick
{
LayerKey = TurnstileVisualLayers.Base,
KeyFrames =
{
new AnimationTrackSpriteFlick.KeyFrame(state.StateId, 0f),
},
},
},
Length = TimeSpan.FromSeconds(animLength),
};
_animationPlayer.Play(ent, anim, AnimationKey);
}
}

View File

@@ -0,0 +1,6 @@
using Content.Shared.Doors.Systems;
namespace Content.Server.Doors.Systems;
/// <inheritdoc/>
public sealed class TurnstileSystem : SharedTurnstileSystem;

View File

@@ -0,0 +1,71 @@
using Content.Shared.Doors.Systems;
using Content.Shared.Whitelist;
using Robust.Shared.Audio;
using Robust.Shared.GameStates;
using Robust.Shared.Serialization;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared.Doors.Components;
/// <summary>
/// This is used for a condition door that allows entry only through a single side.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
[Access(typeof(SharedTurnstileSystem))]
public sealed partial class TurnstileComponent : Component
{
/// <summary>
/// A whitelist of the things this turnstile can choose to block or let through.
/// Things not in this whitelist will be ignored by default.
/// </summary>
[DataField]
public EntityWhitelist? ProcessWhitelist;
/// <summary>
/// The next time at which the resist message can show.
/// </summary>
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField]
public TimeSpan NextResistTime;
/// <summary>
/// Maintained hashset of entities currently passing through the turnstile.
/// </summary>
[DataField, AutoNetworkedField]
public HashSet<EntityUid> CollideExceptions = new();
/// <summary>
/// default state of the turnstile sprite.
/// </summary>
[DataField]
public string DefaultState = "turnstile";
/// <summary>
/// animation state of the turnstile spinning.
/// </summary>
[DataField]
public string SpinState = "operate";
/// <summary>
/// animation state of the turnstile denying entry.
/// </summary>
[DataField]
public string DenyState = "deny";
/// <summary>
/// Sound to play when the turnstile admits a mob through.
/// </summary>
[DataField]
public SoundSpecifier? TurnSound = new SoundPathSpecifier("/Audio/Items/ratchet.ogg");
/// <summary>
/// Sound to play when the turnstile denies entry
/// </summary>
[DataField]
public SoundSpecifier? DenySound = new SoundPathSpecifier("/Audio/Machines/airlock_deny.ogg");
}
[Serializable, NetSerializable]
public enum TurnstileVisualLayers : byte
{
Base
}

View File

@@ -0,0 +1,131 @@
using Content.Shared.Access.Systems;
using Content.Shared.Doors.Components;
using Content.Shared.Movement.Pulling.Systems;
using Content.Shared.Popups;
using Content.Shared.Whitelist;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Physics.Events;
using Robust.Shared.Timing;
namespace Content.Shared.Doors.Systems;
/// <summary>
/// This handles logic and interactions related to <see cref="TurnstileComponent"/>
/// </summary>
public abstract partial class SharedTurnstileSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly AccessReaderSystem _accessReader = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly EntityWhitelistSystem _entityWhitelist = default!;
[Dependency] private readonly PullingSystem _pulling = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
/// <inheritdoc/>
public override void Initialize()
{
SubscribeLocalEvent<TurnstileComponent, PreventCollideEvent>(OnPreventCollide);
SubscribeLocalEvent<TurnstileComponent, StartCollideEvent>(OnStartCollide);
SubscribeLocalEvent<TurnstileComponent, EndCollideEvent>(OnEndCollide);
}
private void OnPreventCollide(Entity<TurnstileComponent> ent, ref PreventCollideEvent args)
{
if (args.Cancelled || !args.OurFixture.Hard || !args.OtherFixture.Hard)
return;
if (ent.Comp.CollideExceptions.Contains(args.OtherEntity))
{
args.Cancelled = true;
return;
}
// We need to add this in here too for chain pulls
if (_pulling.GetPuller(args.OtherEntity) is { } puller && ent.Comp.CollideExceptions.Contains(puller))
{
ent.Comp.CollideExceptions.Add(args.OtherEntity);
Dirty(ent);
args.Cancelled = true;
return;
}
// unblockables go through for free.
if (_entityWhitelist.IsWhitelistFail(ent.Comp.ProcessWhitelist, args.OtherEntity))
{
args.Cancelled = true;
return;
}
if (CanPassDirection(ent, args.OtherEntity))
{
if (!_accessReader.IsAllowed(args.OtherEntity, ent))
return;
ent.Comp.CollideExceptions.Add(args.OtherEntity);
if (_pulling.GetPulling(args.OtherEntity) is { } uid)
ent.Comp.CollideExceptions.Add(uid);
args.Cancelled = true;
Dirty(ent);
}
else
{
if (_timing.CurTime >= ent.Comp.NextResistTime)
{
_popup.PopupClient(Loc.GetString("turnstile-component-popup-resist", ("turnstile", ent.Owner)), ent, args.OtherEntity);
ent.Comp.NextResistTime = _timing.CurTime + TimeSpan.FromSeconds(0.1);
Dirty(ent);
}
}
}
private void OnStartCollide(Entity<TurnstileComponent> ent, ref StartCollideEvent args)
{
if (!ent.Comp.CollideExceptions.Contains(args.OtherEntity))
{
if (CanPassDirection(ent, args.OtherEntity))
{
if (!_accessReader.IsAllowed(args.OtherEntity, ent))
{
_audio.PlayPredicted(ent.Comp.DenySound, ent, args.OtherEntity);
PlayAnimation(ent, ent.Comp.DenyState);
}
}
return;
}
// if they passed through:
PlayAnimation(ent, ent.Comp.SpinState);
_audio.PlayPredicted(ent.Comp.TurnSound, ent, args.OtherEntity);
}
private void OnEndCollide(Entity<TurnstileComponent> ent, ref EndCollideEvent args)
{
if (!args.OurFixture.Hard)
{
ent.Comp.CollideExceptions.Remove(args.OtherEntity);
Dirty(ent);
}
}
protected bool CanPassDirection(Entity<TurnstileComponent> ent, EntityUid other)
{
var xform = Transform(ent);
var otherXform = Transform(other);
var (pos, rot) = _transform.GetWorldPositionRotation(xform);
var otherPos = _transform.GetWorldPosition(otherXform);
var approachAngle = (pos - otherPos).ToAngle();
var rotateAngle = rot.ToWorldVec().ToAngle();
var dif = Math.Min(Math.Abs(approachAngle.Theta - rotateAngle.Theta), Math.Abs(rotateAngle.Theta - approachAngle.Theta));
return dif < Math.PI / 4;
}
protected virtual void PlayAnimation(EntityUid uid, string stateId)
{
}
}

View File

@@ -341,6 +341,16 @@ public sealed class PullingSystem : EntitySystem
return Resolve(puller, ref component, false) && component.Pulling != null;
}
public EntityUid? GetPuller(EntityUid puller, PullableComponent? component = null)
{
return !Resolve(puller, ref component, false) ? null : component.Puller;
}
public EntityUid? GetPulling(EntityUid puller, PullerComponent? component = null)
{
return !Resolve(puller, ref component, false) ? null : component.Pulling;
}
private void OnReleasePulledObject(ICommonSession? session)
{
if (session?.AttachedEntity is not { Valid: true } player)

View File

@@ -0,0 +1 @@
turnstile-component-popup-resist = {CAPITALIZE(THE($turnstile))} resists your efforts!

View File

@@ -0,0 +1,77 @@
- type: entity
id: Turnstile
parent: BaseStructure
name: turnstile
description: A mechanical door that permits one-way access and prevents tailgating.
components:
- type: Sprite
sprite: Structures/Doors/turnstile.rsi
snapCardinals: true
drawdepth: Doors
layers:
- state: turnstile
map: [ "enum.TurnstileVisualLayers.Base" ]
- type: AnimationPlayer
- type: Fixtures
fixtures:
fix1:
shape:
!type:PhysShapeAabb
bounds: "-0.49,-0.49,0.49,0.49" # same dimensions as a door for tall turnstile, prevents objects being thrown through
density: 100
mask:
- FullTileMask
layer:
- AirlockLayer
fix2:
shape:
!type:PhysShapeAabb
bounds: "-0.50,-0.50,0.50,0.50" # same dimensions as a door for tall turnstile, prevents objects being thrown through
hard: false
mask:
- FullTileMask
layer:
- AirlockLayer
- type: MeleeSound
soundGroups:
Brute:
path:
"/Audio/Weapons/smash.ogg"
- type: InteractionOutline
- type: Turnstile
processWhitelist:
components:
- MobState # no mobs
- Pullable # no dragging things in
- type: Appearance
- type: Damageable
damageContainer: Inorganic
damageModifierSet: StrongMetallic
- type: Destructible
thresholds:
- trigger:
!type:DamageTrigger
damage: 500
behaviors:
- !type:DoActsBehavior
acts: ["Destruction"]
- type: Construction
graph: Turnstile
node: turnstile
- # Spawned by the client-side turnstile examine code to indicate the direction to pass through.
type: entity
id: TurnstileArrow
categories: [ HideSpawnMenu ]
components:
- type: Sprite
sprite: Structures/Doors/turnstile.rsi
color: "#FFFFFFBB"
layers:
- state: arrow
offset: 0, 0.78125
- type: TimedDespawn
lifetime: 2
- type: Tag
tags:
- HideContextMenu

View File

@@ -0,0 +1,33 @@
- type: constructionGraph
id: Turnstile
start: start
graph:
- node: start
actions:
- !type:DeleteEntity { }
edges:
- to: turnstile
completed:
- !type:SnapToGrid
steps:
- material: MetalRod
amount: 4
doAfter: 6
- material: Steel
amount: 1
doAfter: 2
- node: turnstile
entity: Turnstile
edges:
- to: start
completed:
- !type:SpawnPrototype
prototype: PartRodMetal1
amount: 4
- !type:DeleteEntity
steps:
- tool: Welding
doAfter: 4.0
- tool: Cutting
doAfter: 2.0

View File

@@ -796,6 +796,23 @@
conditions:
- !type:TileNotBlocked
- type: construction
name: turnstile
id: Turnstile
graph: Turnstile
startNode: start
targetNode: turnstile
category: construction-category-structures
description: A mechanical door that permits one-way access and prevents tailgating.
icon:
sprite: Structures/Doors/turnstile.rsi
state: turnstile
objectType: Structure
placementMode: SnapgridCenter
canBuildInImpassable: false
conditions:
- !type:TileNotBlocked
- type: construction
name: shutter
id: Shutters

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,65 @@
{
"version": 1,
"license": "CC-BY-SA-3.0",
"copyright": "Taken from OracleStation at https://github.com/OracleStation/OracleStation/blob/c1046bdd14674dc5a8631074aaa6650770b2937e/icons/obj/turnstile.dmi. Arrow by EmoGarbage404 (github).",
"size": {
"x": 32,
"y": 32
},
"states": [
{
"name": "arrow",
"delays": [
[
0.5,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
]
]
},
{
"name": "turnstile"
},
{
"name": "turnstile_map",
"directions": 4
},
{
"name": "operate",
"delays": [
[
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
]
]
},
{
"name": "deny",
"delays": [
[
0.1,
0.1,
0.1,
0.1,
0.1,
0.1
]
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB