diff --git a/Content.Client/Doors/AirlockVisualizer.cs b/Content.Client/Doors/AirlockVisualizer.cs
index af40479a6c..60cb788e4a 100644
--- a/Content.Client/Doors/AirlockVisualizer.cs
+++ b/Content.Client/Doors/AirlockVisualizer.cs
@@ -30,6 +30,9 @@ namespace Content.Client.Doors
[DataField("animatedPanel")]
private bool _animatedPanel = true;
+ ///
+ /// Means the door is simply open / closed / opening / closing. No wires or access.
+ ///
[DataField("simpleVisuals")]
private bool _simpleVisuals = false;
diff --git a/Content.Client/Entry/IgnoredComponents.cs b/Content.Client/Entry/IgnoredComponents.cs
index 3413c60249..8b938dbe07 100644
--- a/Content.Client/Entry/IgnoredComponents.cs
+++ b/Content.Client/Entry/IgnoredComponents.cs
@@ -120,6 +120,7 @@ namespace Content.Client.Entry
"SurgeryTool",
"EmitSoundOnThrow",
"Flash",
+ "Docking",
"Telecrystal",
"TrashSpawner",
"RCD",
diff --git a/Content.Server/Doors/Components/ServerDoorComponent.cs b/Content.Server/Doors/Components/ServerDoorComponent.cs
index ee9bdccf6f..a35b185885 100644
--- a/Content.Server/Doors/Components/ServerDoorComponent.cs
+++ b/Content.Server/Doors/Components/ServerDoorComponent.cs
@@ -11,22 +11,18 @@ using Content.Server.Construction;
using Content.Server.Construction.Components;
using Content.Server.Hands.Components;
using Content.Server.Stunnable;
-using Content.Server.Stunnable.Components;
using Content.Server.Tools;
using Content.Server.Tools.Components;
using Content.Shared.Damage;
using Content.Shared.Doors;
using Content.Shared.Interaction;
using Content.Shared.Sound;
-using Content.Shared.Stunnable;
using Content.Shared.Tools;
-using Content.Shared.Tools.Components;
using Robust.Shared.Audio;
using Robust.Shared.Containers;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
using Robust.Shared.Maths;
-using Robust.Shared.Physics;
using Robust.Shared.Player;
using Robust.Shared.Players;
using Robust.Shared.Prototypes;
@@ -276,6 +272,11 @@ namespace Content.Server.Doors.Components
public void TryOpen(IEntity? user=null)
{
+ var msg = new DoorOpenAttemptEvent();
+ Owner.EntityManager.EventBus.RaiseLocalEvent(Owner.Uid, msg);
+
+ if (msg.Cancelled) return;
+
if (user == null)
{
// a machine opened it or something, idk
@@ -412,6 +413,11 @@ namespace Content.Server.Doors.Components
public void TryClose(IEntity? user=null)
{
+ var msg = new DoorCloseAttemptEvent();
+ Owner.EntityManager.EventBus.RaiseLocalEvent(Owner.Uid, msg);
+
+ if (msg.Cancelled) return;
+
if (user != null && !CanCloseByEntity(user))
{
Deny();
@@ -495,7 +501,7 @@ namespace Content.Server.Doors.Components
if (CloseSound != null)
{
SoundSystem.Play(Filter.Pvs(Owner), CloseSound.GetSound(), Owner,
- AudioParams.Default.WithVolume(-10));
+ AudioParams.Default.WithVolume(-5));
}
Owner.SpawnTimer(CloseTimeOne, async () =>
@@ -673,8 +679,10 @@ namespace Content.Server.Doors.Components
var canEv = new BeforeDoorPryEvent(eventArgs);
Owner.EntityManager.EventBus.RaiseLocalEvent(Owner.Uid, canEv, false);
+ if (canEv.Cancelled) return false;
+
var successfulPry = await toolSystem.UseTool(eventArgs.Using.Uid, eventArgs.User.Uid, Owner.Uid,
- 0f, ev.PryTimeModifier * PryTime, _pryingQuality, () => !canEv.Cancelled);
+ 0f, ev.PryTimeModifier * PryTime, _pryingQuality);
if (successfulPry && !IsWeldedShut)
{
diff --git a/Content.Server/Physics/Controllers/MoverController.cs b/Content.Server/Physics/Controllers/MoverController.cs
index 5c06695e26..0734b30371 100644
--- a/Content.Server/Physics/Controllers/MoverController.cs
+++ b/Content.Server/Physics/Controllers/MoverController.cs
@@ -99,7 +99,7 @@ namespace Content.Server.Physics.Controllers
// inputs will do different things.
// TODO: Do that
float speedCap;
- var angularSpeed = 0.75f;
+ var angularSpeed = 0.075f;
// ShuttleSystem has already worked out the ratio so we'll just multiply it back by the mass.
var movement = (mover.VelocityDir.walking + mover.VelocityDir.sprinting);
diff --git a/Content.Server/Shuttles/DockingSystem.cs b/Content.Server/Shuttles/DockingSystem.cs
new file mode 100644
index 0000000000..952f7a7a7e
--- /dev/null
+++ b/Content.Server/Shuttles/DockingSystem.cs
@@ -0,0 +1,494 @@
+using System;
+using Content.Server.Doors.Components;
+using Content.Server.Power.Components;
+using Content.Shared.Doors;
+using Content.Shared.Shuttles;
+using Content.Shared.Verbs;
+using Robust.Shared.GameObjects;
+using Robust.Shared.IoC;
+using Robust.Shared.Localization;
+using Robust.Shared.Log;
+using Robust.Shared.Map;
+using Robust.Shared.Maths;
+using Robust.Shared.Physics;
+using Robust.Shared.Physics.Collision.Shapes;
+using Robust.Shared.Physics.Dynamics;
+using Robust.Shared.Physics.Dynamics.Joints;
+using Robust.Shared.Utility;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Server.Shuttles
+{
+ [RegisterComponent]
+ public sealed class DockingComponent : SharedDockingComponent
+ {
+ [ViewVariables]
+ public DockingComponent? DockedWith;
+
+ [ViewVariables]
+ public Joint? DockJoint;
+
+ [ViewVariables]
+ public override bool Docked => DockedWith != null;
+ }
+
+ public sealed class DockingSystem : EntitySystem
+ {
+ [Dependency] private readonly IMapManager _mapManager = default!;
+ [Dependency] private readonly SharedBroadphaseSystem _broadphaseSystem = default!;
+ [Dependency] private readonly SharedJointSystem _jointSystem = default!;
+
+ private const string DockingFixture = "docking";
+ private const string DockingJoint = "docking";
+ private const float DockingRadius = 0.20f;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnStartup);
+ SubscribeLocalEvent(OnShutdown);
+ SubscribeLocalEvent(OnPowerChange);
+ SubscribeLocalEvent(OnAnchorChange);
+
+ SubscribeLocalEvent(OnVerb);
+ SubscribeLocalEvent(OnAutoClose);
+ SubscribeLocalEvent(OnDoorOpenAttempt);
+ SubscribeLocalEvent(OnDoorCloseAttempt);
+ }
+
+ // Won't allow users to override door controls
+ private void OnDoorOpenAttempt(EntityUid uid, DockingComponent component, DoorOpenAttemptEvent args)
+ {
+ args.Cancel();
+ }
+
+ private void OnDoorCloseAttempt(EntityUid uid, DockingComponent component, DoorCloseAttemptEvent args)
+ {
+ args.Cancel();
+ }
+
+ private void OnAutoClose(EntityUid uid, DockingComponent component, BeforeDoorAutoCloseEvent args)
+ {
+ // We'll just pin the door open when docked.
+ if (component.Docked)
+ args.Cancel();
+ }
+
+ private void OnVerb(EntityUid uid, DockingComponent component, GetInteractionVerbsEvent args)
+ {
+ if (!args.CanInteract ||
+ !args.CanAccess) return;
+
+ Verb? verb;
+
+ // TODO: Have it open the UI and have the UI do this.
+ if (!component.Docked &&
+ EntityManager.TryGetComponent(uid, out PhysicsComponent? body) &&
+ EntityManager.TryGetComponent(uid, out TransformComponent? xform))
+ {
+ DockingComponent? otherDock = null;
+
+ if (component.Enabled)
+ otherDock = GetDockable(body, xform);
+
+ verb = new Verb
+ {
+ Disabled = otherDock == null,
+ Text = Loc.GetString("docking-component-dock"),
+ Act = () =>
+ {
+ if (otherDock == null) return;
+ TryDock(component, otherDock);
+ }
+ };
+ }
+ else if (component.Docked)
+ {
+ verb = new Verb
+ {
+ Disabled = !component.Docked,
+ Text = Loc.GetString("docking-component-undock"),
+ Act = () =>
+ {
+ if (component.DockedWith == null || !component.Enabled) return;
+
+ Undock(component);
+ }
+ };
+ }
+ else
+ {
+ return;
+ }
+
+ args.Verbs.Add(verb);
+ }
+
+ private DockingComponent? GetDockable(PhysicsComponent body, TransformComponent dockingXform)
+ {
+ // Did you know Saltern is the most dockable station?
+
+ // Assume the docking port itself (and its body) is valid
+
+ if (!_mapManager.TryGetGrid(dockingXform.GridID, out var grid) ||
+ !EntityManager.HasComponent(grid.GridEntityId)) return null;
+
+ var transform = body.GetTransform();
+ var dockingFixture = body.GetFixture(DockingFixture);
+
+ if (dockingFixture == null)
+ {
+ DebugTools.Assert(false);
+ Logger.ErrorS("docking", $"Found null fixture on {EntityManager.GetEntity(body.OwnerUid)}");
+ return null;
+ }
+
+ Box2? aabb = null;
+
+ for (var i = 0; i < dockingFixture.Shape.ChildCount; i++)
+ {
+ aabb = aabb?.Union(dockingFixture.Shape.ComputeAABB(transform, i)) ?? dockingFixture.Shape.ComputeAABB(transform, i);
+ }
+
+ if (aabb == null) return null;
+
+ var enlargedAABB = aabb.Value.Enlarged(DockingRadius * 1.5f);
+
+ // Get any docking ports in range on other grids.
+ _mapManager.FindGridsIntersectingEnumerator(dockingXform.MapID, enlargedAABB, out var enumerator);
+
+ while (enumerator.MoveNext(out var otherGrid))
+ {
+ if (otherGrid.Index == dockingXform.GridID) continue;
+
+ foreach (var ent in otherGrid.GetAnchoredEntities(enlargedAABB))
+ {
+ if (!EntityManager.TryGetComponent(ent, out DockingComponent? otherDocking) ||
+ !otherDocking.Enabled ||
+ !EntityManager.TryGetComponent(ent, out PhysicsComponent? otherBody)) continue;
+
+ var otherTransform = otherBody.GetTransform();
+ var otherDockingFixture = otherBody.GetFixture(DockingFixture);
+
+ if (otherDockingFixture == null)
+ {
+ DebugTools.Assert(false);
+ Logger.ErrorS("docking", $"Found null docking fixture on {EntityManager.GetEntity(ent)}");
+ continue;
+ }
+
+ for (var i = 0; i < otherDockingFixture.Shape.ChildCount; i++)
+ {
+ var otherAABB = otherDockingFixture.Shape.ComputeAABB(otherTransform, i);
+
+ if (!aabb.Value.Intersects(otherAABB)) continue;
+
+ // TODO: Need CollisionManager's GJK for accurate bounds
+ // Realistically I want 2 fixtures anyway but I'll deal with that later.
+ return otherDocking;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ private void OnShutdown(EntityUid uid, DockingComponent component, ComponentShutdown args)
+ {
+ if (component.DockedWith == null ||
+ EntityManager.GetComponent(uid).EntityLifeStage > EntityLifeStage.MapInitialized) return;
+
+ Cleanup(component);
+ }
+
+ private void Cleanup(DockingComponent dockA)
+ {
+ _jointSystem.RemoveJoint(dockA.DockJoint!);
+
+ var dockB = dockA.DockedWith;
+
+ if (dockB == null || dockA.DockJoint == null)
+ {
+ DebugTools.Assert(false);
+ Logger.Error("docking", $"Tried to cleanup {dockA.OwnerUid} but not docked?");
+
+ dockA.DockedWith = null;
+ if (dockA.DockJoint != null)
+ {
+ // We'll still cleanup the dock joint on release at least
+ _jointSystem.RemoveJoint(dockA.DockJoint);
+ }
+
+ return;
+ }
+
+ dockB.DockedWith = null;
+ dockB.DockJoint = null;
+
+ dockA.DockJoint = null;
+ dockA.DockedWith = null;
+
+ // If these grids are ever invalid then need to look at fixing ordering for unanchored events elsewhere.
+ var gridAUid = _mapManager.GetGrid(EntityManager.GetComponent(dockA.OwnerUid).GridID).GridEntityId;
+ var gridBUid = _mapManager.GetGrid(EntityManager.GetComponent(dockB.OwnerUid).GridID).GridEntityId;
+
+ var msg = new UndockEvent
+ {
+ DockA = dockA,
+ DockB = dockB,
+ GridAUid = gridAUid,
+ GridBUid = gridBUid,
+ };
+
+ EntityManager.EventBus.RaiseLocalEvent(dockA.OwnerUid, msg, false);
+ EntityManager.EventBus.RaiseLocalEvent(dockB.OwnerUid, msg, false);
+ EntityManager.EventBus.RaiseEvent(EventSource.Local, msg);
+ }
+
+ private void OnStartup(EntityUid uid, DockingComponent component, ComponentStartup args)
+ {
+ // Use startup so transform already initialized
+ if (!EntityManager.GetComponent(uid).Anchored) return;
+
+ EnableDocking(uid, component);
+ }
+
+ private void OnAnchorChange(EntityUid uid, DockingComponent component, ref AnchorStateChangedEvent args)
+ {
+ if (args.Anchored)
+ {
+ EnableDocking(uid, component);
+ }
+ else
+ {
+ DisableDocking(uid, component);
+ }
+ }
+
+ private void OnPowerChange(EntityUid uid, DockingComponent component, PowerChangedEvent args)
+ {
+ if (args.Powered)
+ {
+ EnableDocking(uid, component);
+ }
+ else
+ {
+ DisableDocking(uid, component);
+ }
+ }
+
+ private void DisableDocking(EntityUid uid, DockingComponent component)
+ {
+ if (!component.Enabled) return;
+
+ component.Enabled = false;
+
+ if (component.DockedWith != null)
+ {
+ Undock(component);
+ }
+
+ if (!EntityManager.TryGetComponent(uid, out PhysicsComponent? physicsComponent))
+ {
+ return;
+ }
+
+ _broadphaseSystem.DestroyFixture(physicsComponent, DockingFixture);
+ }
+
+ private void EnableDocking(EntityUid uid, DockingComponent component)
+ {
+ if (component.Enabled)
+ return;
+
+ if (!EntityManager.TryGetComponent(uid, out PhysicsComponent? physicsComponent))
+ return;
+
+ component.Enabled = true;
+
+ // TODO: WTF IS THIS GARBAGE
+ var shape = new PhysShapeCircle
+ {
+ // Want half of the unit vector
+ Position = new Vector2(0f, -0.5f),
+ Radius = DockingRadius
+ };
+
+ // Listen it makes intersection tests easier; you can probably dump this but it requires a bunch more boilerplate
+ var fixture = new Fixture(physicsComponent, shape)
+ {
+ ID = DockingFixture,
+ Hard = false,
+ };
+
+ // TODO: I want this to ideally be 2 fixtures to force them to have some level of alignment buuuttt
+ // I also need collisionmanager for that yet again so they get dis.
+ _broadphaseSystem.CreateFixture(physicsComponent, fixture);
+ }
+
+ ///
+ /// Docks 2 ports together and assumes it is valid.
+ ///
+ private void Dock(DockingComponent dockA, DockingComponent dockB)
+ {
+ Logger.DebugS("docking", $"Docking between {dockA.Owner} and {dockB.Owner}");
+
+ // https://gamedev.stackexchange.com/questions/98772/b2distancejoint-with-frequency-equal-to-0-vs-b2weldjoint
+
+ // We could also potentially use a prismatic joint? Depending if we want clamps that can extend or whatever
+
+ var dockAXform = EntityManager.GetComponent(dockA.OwnerUid);
+ var dockBXform = EntityManager.GetComponent(dockB.OwnerUid);
+
+ var gridA = _mapManager.GetGrid(dockAXform.GridID).GridEntityId;
+ var gridB = _mapManager.GetGrid(dockBXform.GridID).GridEntityId;
+
+ SharedJointSystem.LinearStiffness(
+ 2f,
+ 0.7f,
+ EntityManager.GetComponent(gridA).Mass,
+ EntityManager.GetComponent(gridB).Mass,
+ out var stiffness,
+ out var damping);
+
+ // These need playing around with
+ // Could also potentially have collideconnected false and stiffness 0 but it was a bit more suss???
+ var joint = _jointSystem.CreateWeldJoint(gridA, gridB, DockingJoint + dockA.OwnerUid);
+
+ var gridAXform = EntityManager.GetComponent(gridA);
+ var gridBXform = EntityManager.GetComponent(gridB);
+
+ var anchorA = dockAXform.LocalPosition + dockAXform.LocalRotation.ToWorldVec() / 2f;
+ var anchorB = dockBXform.LocalPosition + dockBXform.LocalRotation.ToWorldVec() / 2f;
+
+ joint.LocalAnchorA = anchorA;
+ joint.LocalAnchorB = anchorB;
+ joint.ReferenceAngle = (float) (gridBXform.WorldRotation - gridAXform.WorldRotation);
+ joint.CollideConnected = true;
+ joint.Stiffness = stiffness;
+ joint.Damping = damping;
+
+ dockA.DockedWith = dockB;
+ dockB.DockedWith = dockA;
+ dockA.DockJoint = joint;
+ dockB.DockJoint = joint;
+
+ if (EntityManager.TryGetComponent(dockA.OwnerUid, out ServerDoorComponent? doorA))
+ {
+ doorA.Open();
+ }
+
+ if (EntityManager.TryGetComponent(dockB.OwnerUid, out ServerDoorComponent? doorB))
+ {
+ doorB.Open();
+ }
+
+ var msg = new DockEvent
+ {
+ DockA = dockA,
+ DockB = dockB,
+ GridAUid = gridA,
+ GridBUid = gridB,
+ };
+
+ EntityManager.EventBus.RaiseLocalEvent(dockA.OwnerUid, msg, false);
+ EntityManager.EventBus.RaiseLocalEvent(dockB.OwnerUid, msg, false);
+ EntityManager.EventBus.RaiseEvent(EventSource.Local, msg);
+ }
+
+ ///
+ /// Attempts to dock 2 ports together and will return early if it's not possible.
+ ///
+ private void TryDock(DockingComponent dockA, DockingComponent dockB)
+ {
+ if (!EntityManager.TryGetComponent(dockA.OwnerUid, out PhysicsComponent? bodyA) ||
+ !EntityManager.TryGetComponent(dockB.OwnerUid, out PhysicsComponent? bodyB) ||
+ !dockA.Enabled ||
+ !dockB.Enabled)
+ {
+ return;
+ }
+
+ var fixtureA = bodyA.GetFixture(DockingFixture);
+ var fixtureB = bodyB.GetFixture(DockingFixture);
+
+ if (fixtureA == null || fixtureB == null)
+ {
+ return;
+ }
+
+ var transformA = bodyA.GetTransform();
+ var transformB = bodyB.GetTransform();
+ var intersect = false;
+
+ for (var i = 0; i < fixtureA.Shape.ChildCount; i++)
+ {
+ var aabb = fixtureA.Shape.ComputeAABB(transformA, i);
+
+ for (var j = 0; j < fixtureB.Shape.ChildCount; j++)
+ {
+ var otherAABB = fixtureB.Shape.ComputeAABB(transformB, j);
+ if (!aabb.Intersects(otherAABB)) continue;
+
+ // TODO: Need collisionmanager's GJK for accurate checks don't @ me son
+ intersect = true;
+ break;
+ }
+
+ if (intersect) break;
+ }
+
+ if (!intersect) return;
+
+ Dock(dockA, dockB);
+ }
+
+ private void Undock(DockingComponent dock)
+ {
+ if (dock.DockedWith == null)
+ {
+ DebugTools.Assert(false);
+ Logger.ErrorS("docking", $"Tried to undock {dock.OwnerUid} but not docked with anything?");
+ return;
+ }
+
+ if (EntityManager.TryGetComponent(dock.OwnerUid, out ServerDoorComponent? doorA))
+ {
+ doorA.Close();
+ }
+
+ if (EntityManager.TryGetComponent(dock.DockedWith.OwnerUid, out ServerDoorComponent? doorB))
+ {
+ doorB.Close();
+ }
+
+ // Could maybe give the shuttle a light push away, or at least if there's no other docks left?
+
+ Cleanup(dock);
+ }
+
+ ///
+ /// Raised whenever 2 airlocks dock.
+ ///
+ public sealed class DockEvent : EntityEventArgs
+ {
+ public DockingComponent DockA = default!;
+ public DockingComponent DockB = default!;
+
+ public EntityUid GridAUid = default!;
+ public EntityUid GridBUid = default!;
+ }
+
+ ///
+ /// Raised whenever 2 grids undock.
+ ///
+ public sealed class UndockEvent : EntityEventArgs
+ {
+ public DockingComponent DockA = default!;
+ public DockingComponent DockB = default!;
+
+ public EntityUid GridAUid = default!;
+ public EntityUid GridBUid = default!;
+ }
+ }
+}
diff --git a/Content.Server/Shuttles/ShuttleComponent.cs b/Content.Server/Shuttles/ShuttleComponent.cs
index 5cbf09536d..952b3bda19 100644
--- a/Content.Server/Shuttles/ShuttleComponent.cs
+++ b/Content.Server/Shuttles/ShuttleComponent.cs
@@ -4,8 +4,5 @@ using Robust.Shared.GameObjects;
namespace Content.Server.Shuttles
{
[RegisterComponent]
- public class ShuttleComponent : SharedShuttleComponent
- {
-
- }
+ public sealed class ShuttleComponent : SharedShuttleComponent {}
}
diff --git a/Content.Server/Shuttles/ShuttleSystem.cs b/Content.Server/Shuttles/ShuttleSystem.cs
index e8cb2977f6..45d3e1c4c9 100644
--- a/Content.Server/Shuttles/ShuttleSystem.cs
+++ b/Content.Server/Shuttles/ShuttleSystem.cs
@@ -126,7 +126,7 @@ namespace Content.Server.Shuttles
//component.FixedRotation = false; TODO WHEN ROTATING SHUTTLES FIXED.
component.FixedRotation = false;
component.LinearDamping = 0.2f;
- component.AngularDamping = 0.1f;
+ component.AngularDamping = 0.3f;
}
private void Disable(PhysicsComponent component)
diff --git a/Content.Shared/Doors/SharedDoorComponent.cs b/Content.Shared/Doors/SharedDoorComponent.cs
index e85da53ff9..812043d6ea 100644
--- a/Content.Shared/Doors/SharedDoorComponent.cs
+++ b/Content.Shared/Doors/SharedDoorComponent.cs
@@ -11,7 +11,7 @@ using Robust.Shared.ViewVariables;
namespace Content.Shared.Doors
{
- [NetworkedComponent()]
+ [NetworkedComponent]
public abstract class SharedDoorComponent : Component
{
public override string Name => "Door";
@@ -177,4 +177,14 @@ namespace Content.Shared.Doors
CurTime = curTime;
}
}
+
+ public sealed class DoorOpenAttemptEvent : CancellableEntityEventArgs
+ {
+
+ }
+
+ public sealed class DoorCloseAttemptEvent : CancellableEntityEventArgs
+ {
+
+ }
}
diff --git a/Content.Shared/Shuttles/SharedDockingComponent.cs b/Content.Shared/Shuttles/SharedDockingComponent.cs
new file mode 100644
index 0000000000..30baf5be43
--- /dev/null
+++ b/Content.Shared/Shuttles/SharedDockingComponent.cs
@@ -0,0 +1,18 @@
+using Robust.Shared.GameObjects;
+using Robust.Shared.GameStates;
+using Robust.Shared.ViewVariables;
+
+namespace Content.Shared.Shuttles
+{
+ public abstract class SharedDockingComponent : Component
+ {
+ // Yes I left this in for now because there's no overhead and we'll need a client one later anyway
+ // and I was too lazy to delete it.
+ public override string Name => "Docking";
+
+ [ViewVariables]
+ public bool Enabled = false;
+
+ public abstract bool Docked { get; }
+ }
+}
diff --git a/Resources/Audio/Effects/docking.ogg b/Resources/Audio/Effects/docking.ogg
new file mode 100644
index 0000000000..5f94a19b88
Binary files /dev/null and b/Resources/Audio/Effects/docking.ogg differ
diff --git a/Resources/Locale/en-US/shuttles/docking.ftl b/Resources/Locale/en-US/shuttles/docking.ftl
new file mode 100644
index 0000000000..cda14bbeac
--- /dev/null
+++ b/Resources/Locale/en-US/shuttles/docking.ftl
@@ -0,0 +1,2 @@
+docking-component-dock = dock
+docking-component-undock = undock
diff --git a/Resources/Prototypes/Entities/Structures/Doors/Airlocks/shuttle.yml b/Resources/Prototypes/Entities/Structures/Doors/Airlocks/shuttle.yml
new file mode 100644
index 0000000000..0c959111aa
--- /dev/null
+++ b/Resources/Prototypes/Entities/Structures/Doors/Airlocks/shuttle.yml
@@ -0,0 +1,88 @@
+- type: entity
+ id: AirlockShuttle
+ parent: BaseStructure
+ name: airlock
+ description: Necessary for connecting two space craft together.
+ components:
+ - type: Docking
+ - type: InteractionOutline
+ - type: Sprite
+ netsync: false
+ sprite: Structures/Doors/Airlocks/Standard/shuttle.rsi
+ layers:
+ - state: closed
+ map: ["enum.DoorVisualLayers.Base"]
+ #- state: closed_unlit
+ # shader: unshaded
+ # map: ["enum.DoorVisualLayers.BaseUnlit"]
+ #- state: welded
+ # map: ["enum.DoorVisualLayers.BaseWelded"]
+ #- state: bolted_unlit
+ # shader: unshaded
+ # map: ["enum.DoorVisualLayers.BaseBolted"]
+ #- state: panel_open
+ # map: ["enum.WiresVisualLayers.MaintenancePanel"]
+ - type: Physics
+ fixtures:
+ - shape:
+ !type:PhysShapeAabb
+ bounds: "-0.49,-0.49,0.49,0.49" # don't want this colliding with walls or they won't close
+ mass: 100
+ mask:
+ - MobImpassable
+ layer:
+ - Opaque
+ - Impassable
+ - MobImpassable
+ - VaultImpassable
+ - SmallImpassable
+ - type: Door
+ closeTimeTwo: 0.4
+ openTimeTwo: 0.4
+ board: DoorElectronics
+ crushDamage:
+ types:
+ Blunt: 15
+ openSound:
+ path: /Audio/Effects/docking.ogg
+ closeSound:
+ path: /Audio/Effects/docking.ogg
+ # denySound:
+ # path: /Audio/Machines/airlock_deny.ogg
+ - type: Airlock
+ - type: Appearance
+ visuals:
+ - type: AirlockVisualizer
+ simpleVisuals: true
+ # - type: WiresVisualizer
+ - type: ApcPowerReceiver
+ - type: ExtensionCableReceiver
+ #- type: Wires
+ # BoardName: "Airlock Control"
+ # LayoutId: Airlock
+ #- type: UserInterface
+ # interfaces:
+ # - key: enum.WiresUiKey.Key
+ # type: WiresBoundUserInterface
+ - type: Airtight
+ fixVacuum: true
+ - type: Occluder
+ - type: Damageable
+ damageContainer: Inorganic
+ damageModifierSet: Metallic
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 500
+ behaviors:
+ - !type:DoActsBehavior
+ acts: ["Destruction"]
+ #- type: Construction
+ # graph: airlock
+ # node: airlock
+ - type: IconSmooth
+ key: walls
+ mode: NoSprite
+ placement:
+ mode: SnapgridCenter
diff --git a/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/closed.png b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/closed.png
new file mode 100644
index 0000000000..979c79540e
Binary files /dev/null and b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/closed.png differ
diff --git a/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/closing.png b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/closing.png
new file mode 100644
index 0000000000..905ba30ebc
Binary files /dev/null and b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/closing.png differ
diff --git a/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/meta.json b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/meta.json
new file mode 100644
index 0000000000..fb51d45117
--- /dev/null
+++ b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/meta.json
@@ -0,0 +1,47 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from CEV-Eris at commit https://github.com/discordia-space/CEV-Eris/commit/14517938186858388656a6aee14bf47af9e9649f",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "closed"
+ },
+ {
+ "name": "open"
+ },
+ {
+ "name": "closing",
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ },
+ {
+ "name": "opening",
+ "delays": [
+ [
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2,
+ 0.2
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/open.png b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/open.png
new file mode 100644
index 0000000000..4a4e534504
Binary files /dev/null and b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/open.png differ
diff --git a/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/opening.png b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/opening.png
new file mode 100644
index 0000000000..2e19084260
Binary files /dev/null and b/Resources/Textures/Structures/Doors/Airlocks/Standard/shuttle.rsi/opening.png differ