From: Nemanja <98561806+EmoGarbage404@users.noreply.github.com>
Date: Thu, 24 Apr 2025 11:39:40 +0000 (-0400)
Subject: Turnstiles (#36313)
X-Git-Url: https://git.smokeofanarchy.ru/gitweb.cgi?a=commitdiff_plain;h=712954f1c4ab6e54d3d04b166546e1234dd48fd2;p=space-station-14.git
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>
---
diff --git a/Content.Client/Doors/TurnstileSystem.cs b/Content.Client/Doors/TurnstileSystem.cs
new file mode 100644
index 0000000000..6e76ce6aa0
--- /dev/null
+++ b/Content.Client/Doors/TurnstileSystem.cs
@@ -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;
+
+///
+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(OnAnimationCompleted);
+ SubscribeLocalEvent(OnExamined);
+ }
+
+ private void OnAnimationCompleted(Entity ent, ref AnimationCompletedEvent args)
+ {
+ if (args.Key != AnimationKey)
+ return;
+
+ if (!TryComp(ent, out var sprite))
+ return;
+ sprite.LayerSetState(TurnstileVisualLayers.Base, new RSI.StateId(ent.Comp.DefaultState));
+ }
+
+ private void OnExamined(Entity ent, ref ExaminedEvent args)
+ {
+ Spawn(_examineArrow, new EntityCoordinates(ent, 0, 0));
+ }
+
+ protected override void PlayAnimation(EntityUid uid, string stateId)
+ {
+ if (!TryComp(uid, out var animation) || !TryComp(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);
+ }
+}
diff --git a/Content.Server/Doors/Systems/TurnstileSystem.cs b/Content.Server/Doors/Systems/TurnstileSystem.cs
new file mode 100644
index 0000000000..687a97095f
--- /dev/null
+++ b/Content.Server/Doors/Systems/TurnstileSystem.cs
@@ -0,0 +1,6 @@
+using Content.Shared.Doors.Systems;
+
+namespace Content.Server.Doors.Systems;
+
+///
+public sealed class TurnstileSystem : SharedTurnstileSystem;
diff --git a/Content.Shared/Doors/Components/TurnstileComponent.cs b/Content.Shared/Doors/Components/TurnstileComponent.cs
new file mode 100644
index 0000000000..5f19dd3e0b
--- /dev/null
+++ b/Content.Shared/Doors/Components/TurnstileComponent.cs
@@ -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;
+
+///
+/// This is used for a condition door that allows entry only through a single side.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
+[Access(typeof(SharedTurnstileSystem))]
+public sealed partial class TurnstileComponent : Component
+{
+ ///
+ /// A whitelist of the things this turnstile can choose to block or let through.
+ /// Things not in this whitelist will be ignored by default.
+ ///
+ [DataField]
+ public EntityWhitelist? ProcessWhitelist;
+
+ ///
+ /// The next time at which the resist message can show.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField]
+ public TimeSpan NextResistTime;
+
+ ///
+ /// Maintained hashset of entities currently passing through the turnstile.
+ ///
+ [DataField, AutoNetworkedField]
+ public HashSet CollideExceptions = new();
+
+ ///
+ /// default state of the turnstile sprite.
+ ///
+ [DataField]
+ public string DefaultState = "turnstile";
+
+ ///
+ /// animation state of the turnstile spinning.
+ ///
+ [DataField]
+ public string SpinState = "operate";
+
+ ///
+ /// animation state of the turnstile denying entry.
+ ///
+ [DataField]
+ public string DenyState = "deny";
+
+ ///
+ /// Sound to play when the turnstile admits a mob through.
+ ///
+ [DataField]
+ public SoundSpecifier? TurnSound = new SoundPathSpecifier("/Audio/Items/ratchet.ogg");
+
+ ///
+ /// Sound to play when the turnstile denies entry
+ ///
+ [DataField]
+ public SoundSpecifier? DenySound = new SoundPathSpecifier("/Audio/Machines/airlock_deny.ogg");
+}
+
+[Serializable, NetSerializable]
+public enum TurnstileVisualLayers : byte
+{
+ Base
+}
diff --git a/Content.Shared/Doors/Systems/SharedTurnstileSystem.cs b/Content.Shared/Doors/Systems/SharedTurnstileSystem.cs
new file mode 100644
index 0000000000..0af6b91378
--- /dev/null
+++ b/Content.Shared/Doors/Systems/SharedTurnstileSystem.cs
@@ -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;
+
+///
+/// This handles logic and interactions related to
+///
+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!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnPreventCollide);
+ SubscribeLocalEvent(OnStartCollide);
+ SubscribeLocalEvent(OnEndCollide);
+ }
+
+ private void OnPreventCollide(Entity 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 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 ent, ref EndCollideEvent args)
+ {
+ if (!args.OurFixture.Hard)
+ {
+ ent.Comp.CollideExceptions.Remove(args.OtherEntity);
+ Dirty(ent);
+ }
+ }
+
+ protected bool CanPassDirection(Entity 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)
+ {
+
+ }
+}
diff --git a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs
index 6ac0460f8e..369225df2d 100644
--- a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs
+++ b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs
@@ -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)
diff --git a/Resources/Locale/en-US/doors/components/turnstile.ftl b/Resources/Locale/en-US/doors/components/turnstile.ftl
new file mode 100644
index 0000000000..16dca94244
--- /dev/null
+++ b/Resources/Locale/en-US/doors/components/turnstile.ftl
@@ -0,0 +1 @@
+turnstile-component-popup-resist = {CAPITALIZE(THE($turnstile))} resists your efforts!
diff --git a/Resources/Prototypes/Entities/Structures/Doors/turnstile.yml b/Resources/Prototypes/Entities/Structures/Doors/turnstile.yml
new file mode 100644
index 0000000000..6d675c5928
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Doors/turnstile.yml
@@ -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
diff --git a/Resources/Prototypes/Recipes/Construction/Graphs/structures/turnstile.yml b/Resources/Prototypes/Recipes/Construction/Graphs/structures/turnstile.yml
new file mode 100644
index 0000000000..cc1b8efb6a
--- /dev/null
+++ b/Resources/Prototypes/Recipes/Construction/Graphs/structures/turnstile.yml
@@ -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
diff --git a/Resources/Prototypes/Recipes/Construction/structures.yml b/Resources/Prototypes/Recipes/Construction/structures.yml
index 7602a6cb11..11579ac9ba 100644
--- a/Resources/Prototypes/Recipes/Construction/structures.yml
+++ b/Resources/Prototypes/Recipes/Construction/structures.yml
@@ -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
diff --git a/Resources/Textures/Structures/Doors/turnstile.rsi/arrow.png b/Resources/Textures/Structures/Doors/turnstile.rsi/arrow.png
new file mode 100644
index 0000000000..6332f6b326
Binary files /dev/null and b/Resources/Textures/Structures/Doors/turnstile.rsi/arrow.png differ
diff --git a/Resources/Textures/Structures/Doors/turnstile.rsi/deny.png b/Resources/Textures/Structures/Doors/turnstile.rsi/deny.png
new file mode 100644
index 0000000000..4afe444d7c
Binary files /dev/null and b/Resources/Textures/Structures/Doors/turnstile.rsi/deny.png differ
diff --git a/Resources/Textures/Structures/Doors/turnstile.rsi/meta.json b/Resources/Textures/Structures/Doors/turnstile.rsi/meta.json
new file mode 100644
index 0000000000..dea5fb8946
--- /dev/null
+++ b/Resources/Textures/Structures/Doors/turnstile.rsi/meta.json
@@ -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
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Doors/turnstile.rsi/operate.png b/Resources/Textures/Structures/Doors/turnstile.rsi/operate.png
new file mode 100644
index 0000000000..3178c298dd
Binary files /dev/null and b/Resources/Textures/Structures/Doors/turnstile.rsi/operate.png differ
diff --git a/Resources/Textures/Structures/Doors/turnstile.rsi/turnstile.png b/Resources/Textures/Structures/Doors/turnstile.rsi/turnstile.png
new file mode 100644
index 0000000000..48c85577e7
Binary files /dev/null and b/Resources/Textures/Structures/Doors/turnstile.rsi/turnstile.png differ
diff --git a/Resources/Textures/Structures/Doors/turnstile.rsi/turnstile_map.png b/Resources/Textures/Structures/Doors/turnstile.rsi/turnstile_map.png
new file mode 100644
index 0000000000..fb3fcc5c31
Binary files /dev/null and b/Resources/Textures/Structures/Doors/turnstile.rsi/turnstile_map.png differ