diff --git a/Content.Server/Teleportation/HandTeleporterSystem.cs b/Content.Server/Teleportation/HandTeleporterSystem.cs
new file mode 100644
index 0000000000..8efb99e07b
--- /dev/null
+++ b/Content.Server/Teleportation/HandTeleporterSystem.cs
@@ -0,0 +1,57 @@
+using Content.Shared.Interaction.Events;
+using Content.Shared.Teleportation.Components;
+using Content.Shared.Teleportation.Systems;
+using Robust.Server.GameObjects;
+
+namespace Content.Server.Teleportation;
+
+///
+/// This handles creating portals from a hand teleporter.
+///
+public sealed class HandTeleporterSystem : EntitySystem
+{
+ [Dependency] private readonly LinkedEntitySystem _link = default!;
+ [Dependency] private readonly AudioSystem _audio = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnUseInHand);
+ }
+
+ private void OnUseInHand(EntityUid uid, HandTeleporterComponent component, UseInHandEvent args)
+ {
+ if (Deleted(component.FirstPortal))
+ component.FirstPortal = null;
+
+ if (Deleted(component.SecondPortal))
+ component.SecondPortal = null;
+
+ // Create the first portal.
+ if (component.FirstPortal == null && component.SecondPortal == null)
+ {
+ var timeout = EnsureComp(args.User);
+ timeout.EnteredPortal = null;
+ component.FirstPortal = Spawn(component.FirstPortalPrototype, Transform(args.User).Coordinates);
+ _audio.PlayPvs(component.NewPortalSound, uid);
+ }
+ else if (component.SecondPortal == null)
+ {
+ var timeout = EnsureComp(args.User);
+ timeout.EnteredPortal = null;
+ component.SecondPortal = Spawn(component.SecondPortalPrototype, Transform(args.User).Coordinates);
+ _link.TryLink(component.FirstPortal!.Value, component.SecondPortal.Value, true);
+ _audio.PlayPvs(component.NewPortalSound, uid);
+ }
+ else
+ {
+ // Clear both portals
+ QueueDel(component.FirstPortal!.Value);
+ QueueDel(component.SecondPortal!.Value);
+
+ component.FirstPortal = null;
+ component.SecondPortal = null;
+ _audio.PlayPvs(component.ClearPortalsSound, uid);
+ }
+ }
+}
diff --git a/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs b/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs
new file mode 100644
index 0000000000..24ca40d6bb
--- /dev/null
+++ b/Content.Shared/Teleportation/Components/HandTeleporterComponent.cs
@@ -0,0 +1,36 @@
+using Content.Shared.Audio;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Shared.Teleportation.Components;
+
+///
+/// Creates portals. If two are created, both are linked together--otherwise the first teleports randomly.
+/// Using it with both portals active deactivates both.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed class HandTeleporterComponent : Component
+{
+ [ViewVariables, DataField("firstPortal")]
+ public EntityUid? FirstPortal = null;
+
+ [ViewVariables, DataField("secondPortal")]
+ public EntityUid? SecondPortal = null;
+
+ [DataField("firstPortalPrototype", customTypeSerializer:typeof(PrototypeIdSerializer))]
+ public string FirstPortalPrototype = "PortalRed";
+
+ [DataField("secondPortalPrototype", customTypeSerializer:typeof(PrototypeIdSerializer))]
+ public string SecondPortalPrototype = "PortalBlue";
+
+ [DataField("newPortalSound")]
+ public SoundSpecifier NewPortalSound = new SoundPathSpecifier("/Audio/Machines/high_tech_confirm.ogg")
+ {
+ Params = AudioParams.Default.WithVolume(-2f)
+ };
+
+ [DataField("clearPortalsSound")]
+ public SoundSpecifier ClearPortalsSound = new SoundPathSpecifier("/Audio/Machines/button.ogg");
+}
diff --git a/Content.Shared/Teleportation/Components/LinkedEntityComponent.cs b/Content.Shared/Teleportation/Components/LinkedEntityComponent.cs
new file mode 100644
index 0000000000..40850ae6df
--- /dev/null
+++ b/Content.Shared/Teleportation/Components/LinkedEntityComponent.cs
@@ -0,0 +1,42 @@
+using Content.Shared.Teleportation.Systems;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Teleportation.Components;
+
+///
+/// Represents an entity which is linked to other entities (perhaps portals), and which can be walked through/
+/// thrown into to teleport an entity.
+///
+[RegisterComponent, Access(typeof(LinkedEntitySystem)), NetworkedComponent]
+public sealed class LinkedEntityComponent : Component
+{
+ ///
+ /// The entities that this entity is linked to.
+ ///
+ [DataField("linkedEntities")]
+ public HashSet LinkedEntities = new();
+
+ ///
+ /// Should this entity be deleted if all of its links are removed?
+ ///
+ [DataField("deleteOnEmptyLinks")]
+ public bool DeleteOnEmptyLinks = false;
+}
+
+[Serializable, NetSerializable]
+public sealed class LinkedEntityComponentState : ComponentState
+{
+ public HashSet LinkedEntities;
+
+ public LinkedEntityComponentState(HashSet linkedEntities)
+ {
+ LinkedEntities = linkedEntities;
+ }
+}
+
+[Serializable, NetSerializable]
+public enum LinkedEntityVisuals : byte
+{
+ HasAnyLinks
+}
diff --git a/Content.Shared/Teleportation/Components/PortalComponent.cs b/Content.Shared/Teleportation/Components/PortalComponent.cs
new file mode 100644
index 0000000000..4fbeb1b6d8
--- /dev/null
+++ b/Content.Shared/Teleportation/Components/PortalComponent.cs
@@ -0,0 +1,31 @@
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Teleportation.Components;
+
+///
+/// Marks an entity as being a 'portal' which teleports entities sent through it to linked entities.
+/// Relies on being set up.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed class PortalComponent : Component
+{
+ ///
+ /// Sound played on arriving to this portal, centered on the destination.
+ /// The arrival sound of the entered portal will play if the destination is not a portal.
+ ///
+ [DataField("arrivalSound")]
+ public SoundSpecifier ArrivalSound = new SoundPathSpecifier("/Audio/Effects/teleport_arrival.ogg");
+
+ ///
+ /// Sound played on departing from this portal, centered on the original portal.
+ ///
+ [DataField("departureSound")]
+ public SoundSpecifier DepartureSound = new SoundPathSpecifier("/Audio/Effects/teleport_departure.ogg");
+
+ ///
+ /// If no portals are linked, the subject will be teleported a random distance at maximum this far away.
+ ///
+ [DataField("maxRandomRadius")]
+ public float MaxRandomRadius = 10.0f;
+}
diff --git a/Content.Shared/Teleportation/Components/PortalTimeoutComponent.cs b/Content.Shared/Teleportation/Components/PortalTimeoutComponent.cs
new file mode 100644
index 0000000000..96306198a6
--- /dev/null
+++ b/Content.Shared/Teleportation/Components/PortalTimeoutComponent.cs
@@ -0,0 +1,29 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Teleportation.Components;
+
+///
+/// Attached to an entity after portal transit to mark that they should not immediately be portaled back
+/// at the end destination.
+///
+[RegisterComponent, NetworkedComponent]
+public sealed class PortalTimeoutComponent : Component
+{
+ ///
+ /// The portal that was entered. Null if coming from a hand teleporter, etc.
+ ///
+ [ViewVariables, DataField("enteredPortal")]
+ public EntityUid? EnteredPortal = null;
+}
+
+[Serializable, NetSerializable]
+public sealed class PortalTimeoutComponentState : ComponentState
+{
+ public EntityUid? EnteredPortal;
+
+ public PortalTimeoutComponentState(EntityUid? enteredPortal)
+ {
+ EnteredPortal = enteredPortal;
+ }
+}
diff --git a/Content.Shared/Teleportation/Systems/LinkedEntitySystem.cs b/Content.Shared/Teleportation/Systems/LinkedEntitySystem.cs
new file mode 100644
index 0000000000..3c8e4a7413
--- /dev/null
+++ b/Content.Shared/Teleportation/Systems/LinkedEntitySystem.cs
@@ -0,0 +1,115 @@
+using System.Linq;
+using Content.Shared.Teleportation.Components;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Teleportation.Systems;
+
+///
+/// Handles symmetrically linking two entities together, and removing links properly.
+/// This does not do anything on its own (outside of deleting entities that have 0 links, if that option is true)
+/// Systems can do whatever they please with the linked entities, such as .
+///
+public sealed class LinkedEntitySystem : EntitySystem
+{
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnLinkShutdown);
+
+ SubscribeLocalEvent(OnGetState);
+ SubscribeLocalEvent(OnHandleState);
+ }
+
+ private void OnGetState(EntityUid uid, LinkedEntityComponent component, ref ComponentGetState args)
+ {
+ args.State = new LinkedEntityComponentState(component.LinkedEntities);
+ }
+
+ private void OnHandleState(EntityUid uid, LinkedEntityComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is LinkedEntityComponentState state)
+ component.LinkedEntities = state.LinkedEntities;
+ }
+
+ private void OnLinkShutdown(EntityUid uid, LinkedEntityComponent component, ComponentShutdown args)
+ {
+ // Remove any links to this entity when deleted.
+ foreach (var ent in component.LinkedEntities.ToArray())
+ {
+ if (LifeStage(ent) < EntityLifeStage.Terminating && TryComp(ent, out var link))
+ {
+ TryUnlink(uid, ent, component, link);
+ }
+ }
+ }
+
+ #region Public API
+
+ ///
+ /// Links two entities together. Does not require the existence of on either
+ /// already. Linking is symmetrical, so order doesn't matter.
+ ///
+ /// The first entity to link
+ /// The second entity to link
+ /// Whether both entities should now delete once their links are removed
+ /// Whether linking was successful (e.g. they weren't already linked)
+ public bool TryLink(EntityUid first, EntityUid second, bool deleteOnEmptyLinks=false)
+ {
+ var firstLink = EnsureComp(first);
+ var secondLink = EnsureComp(second);
+
+ firstLink.DeleteOnEmptyLinks = deleteOnEmptyLinks;
+ secondLink.DeleteOnEmptyLinks = deleteOnEmptyLinks;
+
+ _appearance.SetData(first, LinkedEntityVisuals.HasAnyLinks, true);
+ _appearance.SetData(second, LinkedEntityVisuals.HasAnyLinks, true);
+
+ Dirty(firstLink);
+ Dirty(secondLink);
+
+ return firstLink.LinkedEntities.Add(second)
+ && secondLink.LinkedEntities.Add(first);
+ }
+
+ ///
+ /// Unlinks two entities. Deletes either entity if
+ /// was true and its links are now empty. Symmetrical, so order doesn't matter.
+ ///
+ /// The first entity to unlink
+ /// The second entity to unlink
+ /// Resolve comp
+ /// Resolve comp
+ /// Whether unlinking was successful (e.g. they both were actually linked to one another)
+ public bool TryUnlink(EntityUid first, EntityUid second,
+ LinkedEntityComponent? firstLink=null, LinkedEntityComponent? secondLink=null)
+ {
+ if (!Resolve(first, ref firstLink))
+ return false;
+
+ if (!Resolve(second, ref secondLink))
+ return false;
+
+ var success = firstLink.LinkedEntities.Remove(second)
+ && secondLink.LinkedEntities.Remove(first);
+
+ _appearance.SetData(first, LinkedEntityVisuals.HasAnyLinks, firstLink.LinkedEntities.Any());
+ _appearance.SetData(second, LinkedEntityVisuals.HasAnyLinks, secondLink.LinkedEntities.Any());
+
+ Dirty(firstLink);
+ Dirty(secondLink);
+
+ if (firstLink.LinkedEntities.Count == 0 && firstLink.DeleteOnEmptyLinks)
+ QueueDel(first);
+
+ if (secondLink.LinkedEntities.Count == 0 && secondLink.DeleteOnEmptyLinks)
+ QueueDel(second);
+
+ return success;
+ }
+
+ #endregion
+}
diff --git a/Content.Shared/Teleportation/Systems/PortalSystem.cs b/Content.Shared/Teleportation/Systems/PortalSystem.cs
new file mode 100644
index 0000000000..841c3411a5
--- /dev/null
+++ b/Content.Shared/Teleportation/Systems/PortalSystem.cs
@@ -0,0 +1,146 @@
+using System.Linq;
+using Content.Shared.Projectiles;
+using Content.Shared.Teleportation.Components;
+using Robust.Shared.GameStates;
+using Robust.Shared.Map;
+using Robust.Shared.Network;
+using Robust.Shared.Physics.Dynamics;
+using Robust.Shared.Physics.Events;
+using Robust.Shared.Player;
+using Robust.Shared.Random;
+
+namespace Content.Shared.Teleportation.Systems;
+
+///
+/// This handles teleporting entities through portals, and creating new linked portals.
+///
+public sealed class PortalSystem : EntitySystem
+{
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly INetManager _netMan = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+
+ private const string PortalFixture = "portalFixture";
+ private const string ProjectileFixture = "projectile";
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnCollide);
+ SubscribeLocalEvent(OnEndCollide);
+
+ SubscribeLocalEvent(OnGetState);
+ SubscribeLocalEvent(OnHandleState);
+ }
+
+ private void OnGetState(EntityUid uid, PortalTimeoutComponent component, ref ComponentGetState args)
+ {
+ args.State = new PortalTimeoutComponentState(component.EnteredPortal);
+ }
+
+ private void OnHandleState(EntityUid uid, PortalTimeoutComponent component, ref ComponentHandleState args)
+ {
+ if (args.Current is PortalTimeoutComponentState state)
+ component.EnteredPortal = state.EnteredPortal;
+ }
+
+ private bool ShouldCollide(Fixture our, Fixture other)
+ {
+ // most non-hard fixtures shouldn't pass through portals, but projectiles are non-hard as well
+ // and they should still pass through
+ return our.ID == PortalFixture && (other.Hard || other.ID == ProjectileFixture);
+ }
+
+ private void OnCollide(EntityUid uid, PortalComponent component, ref StartCollideEvent args)
+ {
+ if (!ShouldCollide(args.OurFixture, args.OtherFixture))
+ return;
+
+ var subject = args.OtherFixture.Body.Owner;
+
+ // best not.
+ if (Transform(subject).Anchored)
+ return;
+
+ // if they came from another portal, just return and wait for them to exit the portal
+ if (HasComp(subject))
+ {
+ return;
+ }
+
+ if (TryComp(uid, out var link))
+ {
+ if (!link.LinkedEntities.Any())
+ return;
+
+ // client can't predict outside of simple portal-to-portal interactions due to randomness involved
+ // --also can't predict if the target doesn't exist on the client / is outside of PVS
+ if (_netMan.IsClient)
+ {
+ var first = link.LinkedEntities.First();
+ var exists = Exists(first);
+ if (link.LinkedEntities.Count != 1 || !exists || (exists && Transform(first).MapID == MapId.Nullspace))
+ return;
+ }
+
+ // pick a target and teleport there
+ var target = _random.Pick(link.LinkedEntities);
+
+ if (HasComp(target))
+ {
+ // if target is a portal, signal that they shouldn't be immediately portaled back
+ var timeout = EnsureComp(subject);
+ timeout.EnteredPortal = uid;
+ Dirty(timeout);
+ }
+
+ TeleportEntity(uid, subject, Transform(target).Coordinates, target);
+ return;
+ }
+
+ if (_netMan.IsClient)
+ return;
+
+ // no linked entity--teleport randomly
+ var randVector = _random.NextVector2(component.MaxRandomRadius);
+ var newCoords = Transform(uid).Coordinates.Offset(randVector);
+ TeleportEntity(uid, subject, newCoords);
+ }
+
+ private void OnEndCollide(EntityUid uid, PortalComponent component, ref EndCollideEvent args)
+ {
+ if (!ShouldCollide(args.OurFixture, args.OtherFixture))
+ return;
+
+ var subject = args.OtherFixture.Body.Owner;
+
+ // if they came from (not us), remove the timeout
+ if (TryComp(subject, out var timeout) && timeout.EnteredPortal != uid)
+ {
+ RemComp(subject);
+ }
+ }
+
+ private void TeleportEntity(EntityUid portal, EntityUid subject, EntityCoordinates target, EntityUid? targetEntity=null,
+ PortalComponent? portalComponent = null)
+ {
+ if (!Resolve(portal, ref portalComponent))
+ return;
+
+ var arrivalSound = CompOrNull(targetEntity)?.ArrivalSound ?? portalComponent.ArrivalSound;
+ var departureSound = portalComponent.DepartureSound;
+
+ // Some special cased stuff: projectiles should stop ignoring shooter when they enter a portal, to avoid
+ // stacking 500 bullets in between 2 portals and instakilling people--you'll just hit yourself instead
+ // (as expected)
+ if (TryComp(subject, out var projectile))
+ {
+ projectile.IgnoreShooter = false;
+ }
+
+ Transform(subject).Coordinates = target;
+
+ _audio.PlayPredicted(departureSound, portal, subject);
+ _audio.PlayPredicted(arrivalSound, subject, subject);
+ }
+}
diff --git a/Resources/Audio/Effects/attributions.yml b/Resources/Audio/Effects/attributions.yml
index efa74489b3..66b0c39ac9 100644
--- a/Resources/Audio/Effects/attributions.yml
+++ b/Resources/Audio/Effects/attributions.yml
@@ -2,3 +2,7 @@
license: "CC-BY-NC-SA-3.0"
copyright: "Amateur foley and audio editing by Bright0."
source: "https://github.com/Bright0"
+- files: ["teleport_arrival.ogg", "teleport_departure.ogg"]
+ license: "CC-BY-SA-3.0"
+ copyright: "tgstation"
+ source: "https://github.com/tgstation/tgstation/commit/906fb0682bab6a0975b45036001c54f021f58ae7"
diff --git a/Resources/Audio/Effects/teleport_arrival.ogg b/Resources/Audio/Effects/teleport_arrival.ogg
index 650867782b..9dc97c802b 100644
Binary files a/Resources/Audio/Effects/teleport_arrival.ogg and b/Resources/Audio/Effects/teleport_arrival.ogg differ
diff --git a/Resources/Audio/Effects/teleport_departure.ogg b/Resources/Audio/Effects/teleport_departure.ogg
index 650867782b..163ca45f39 100644
Binary files a/Resources/Audio/Effects/teleport_departure.ogg and b/Resources/Audio/Effects/teleport_departure.ogg differ
diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml b/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml
index 6d3fd7468b..dadcd41da2 100644
--- a/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml
+++ b/Resources/Prototypes/Catalog/Fills/Lockers/heads.yml
@@ -152,6 +152,7 @@
- id: ClothingNeckCloakRd
- id: ClothingHeadsetMedicalScience
- id: ClothingOuterHardsuitRd
+ - id: HandTeleporter
- id: PlushieSlime
prob: 0.1
- id: DoorRemoteResearch
diff --git a/Resources/Prototypes/Entities/Effects/portal.yml b/Resources/Prototypes/Entities/Effects/portal.yml
new file mode 100644
index 0000000000..af21e6bd96
--- /dev/null
+++ b/Resources/Prototypes/Entities/Effects/portal.yml
@@ -0,0 +1,51 @@
+- type: entity
+ id: BasePortal
+ abstract: true
+ name: bluespace portal
+ description: Transports you to a linked destination!
+ components:
+ - type: InteractionOutline
+ - type: Clickable
+ - type: Physics
+ bodyType: Static
+ - type: Sprite
+ sprite: /Textures/Effects/portal.rsi
+ - type: Fixtures
+ fixtures:
+ - id: portalFixture
+ shape:
+ !type:PhysShapeAabb
+ bounds: "-0.25,-0.48,0.25,0.48"
+ mask:
+ - FullTileMask
+ layer:
+ - WallLayer
+ hard: false
+ - type: Portal
+
+- type: entity
+ id: PortalRed
+ parent: BasePortal
+ description: This one looks more like a redspace portal.
+ components:
+ - type: Sprite
+ layers:
+ - state: portal-red
+ - type: PointLight
+ netsync: false
+ color: OrangeRed
+ radius: 3
+ energy: 3
+
+- type: entity
+ id: PortalBlue
+ parent: BasePortal
+ components:
+ - type: Sprite
+ layers:
+ - state: portal-blue
+ - type: PointLight
+ netsync: false
+ color: SkyBlue
+ radius: 3
+ energy: 3
diff --git a/Resources/Prototypes/Entities/Objects/Devices/hand_teleporter.yml b/Resources/Prototypes/Entities/Objects/Devices/hand_teleporter.yml
new file mode 100644
index 0000000000..55839ada54
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/hand_teleporter.yml
@@ -0,0 +1,11 @@
+- type: entity
+ id: HandTeleporter
+ parent: BaseItem
+ name: hand teleporter
+ description: "A Nanotrasen signature item--only the finest bluespace tech. Instructions: Use once to create a portal which teleports at random. Use again to link it to a portal at your current location. Use again to clear all portals."
+ components:
+ - type: Sprite
+ sprite: /Textures/Objects/Devices/hand_teleporter.rsi
+ layers:
+ - state: icon
+ - type: HandTeleporter
diff --git a/Resources/Prototypes/Objectives/objectiveGroups.yml b/Resources/Prototypes/Objectives/objectiveGroups.yml
index e4a6b0907d..5130a51c90 100644
--- a/Resources/Prototypes/Objectives/objectiveGroups.yml
+++ b/Resources/Prototypes/Objectives/objectiveGroups.yml
@@ -20,6 +20,7 @@
CorgiMeatStealObjective: 1
CaptainGunStealObjective: 0.5
CaptainJetpackStealObjective: 0.5
+ HandTeleporterStealObjective: 0.5
- type: weightedRandom
id: TraitorObjectiveGroupKill
diff --git a/Resources/Prototypes/Objectives/traitorObjectives.yml b/Resources/Prototypes/Objectives/traitorObjectives.yml
index 9946a2e2ac..c2bcb936e6 100644
--- a/Resources/Prototypes/Objectives/traitorObjectives.yml
+++ b/Resources/Prototypes/Objectives/traitorObjectives.yml
@@ -82,6 +82,22 @@
prototype: ClothingOuterHardsuitRd
owner: job-name-rd
+- type: objective
+ id: HandTeleporterStealObjective
+ issuer: syndicate
+ difficultyOverride: 2.75
+ requirements:
+ - !type:TraitorRequirement {}
+ - !type:IncompatibleConditionsRequirement
+ conditions:
+ - DieCondition
+ - !type:NotRoleRequirement
+ roleId: ResearchDirector
+ conditions:
+ - !type:StealCondition
+ prototype: HandTeleporter
+ owner: job-name-rd
+
- type: objective
id: NukeDiskStealObjective
issuer: syndicate
diff --git a/Resources/Textures/Effects/portal.rsi/meta.json b/Resources/Textures/Effects/portal.rsi/meta.json
index c3d19f1ce6..74d208234b 100644
--- a/Resources/Textures/Effects/portal.rsi/meta.json
+++ b/Resources/Textures/Effects/portal.rsi/meta.json
@@ -8,7 +8,7 @@
"copyright": "https://github.com/discordia-space/CEV-Eris/raw/237d8f7894617007d75c71d5d9feb4354c78debd/icons/obj/stationobjs.dmi",
"states": [
{
- "name": "portal-pending",
+ "name": "portal-blue",
"delays": [[
0.1,
0.1,
@@ -27,7 +27,26 @@
]]
},
{
- "name": "portal-unconnected",
+ "name": "portal-red",
+ "delays": [[
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]]
+ },
+ {
+ "name": "portal",
"delays": [[
0.1,
0.1,
@@ -46,4 +65,4 @@
]]
}
]
-}
\ No newline at end of file
+}
diff --git a/Resources/Textures/Effects/portal.rsi/portal-pending.png b/Resources/Textures/Effects/portal.rsi/portal-blue.png
similarity index 100%
rename from Resources/Textures/Effects/portal.rsi/portal-pending.png
rename to Resources/Textures/Effects/portal.rsi/portal-blue.png
diff --git a/Resources/Textures/Effects/portal.rsi/portal-unconnected.png b/Resources/Textures/Effects/portal.rsi/portal-red.png
similarity index 100%
rename from Resources/Textures/Effects/portal.rsi/portal-unconnected.png
rename to Resources/Textures/Effects/portal.rsi/portal-red.png
diff --git a/Resources/Textures/Effects/portal.rsi/portal.png b/Resources/Textures/Effects/portal.rsi/portal.png
new file mode 100644
index 0000000000..e6d6fc96f5
Binary files /dev/null and b/Resources/Textures/Effects/portal.rsi/portal.png differ
diff --git a/Resources/Textures/Objects/Devices/hand_teleporter.rsi/icon.png b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/icon.png
new file mode 100644
index 0000000000..c5cb0de69f
Binary files /dev/null and b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Devices/hand_teleporter.rsi/inhand-left.png b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/inhand-left.png
new file mode 100644
index 0000000000..2d3863145b
Binary files /dev/null and b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/inhand-left.png differ
diff --git a/Resources/Textures/Objects/Devices/hand_teleporter.rsi/inhand-right.png b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/inhand-right.png
new file mode 100644
index 0000000000..1704b9c3c1
Binary files /dev/null and b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/inhand-right.png differ
diff --git a/Resources/Textures/Objects/Devices/hand_teleporter.rsi/meta.json b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/meta.json
new file mode 100644
index 0000000000..6243286e77
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/hand_teleporter.rsi/meta.json
@@ -0,0 +1,30 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "tgstation at 43d3fb991641d0dc73b6ce81cdf4f3f09d6b70a0",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "inhand-left",
+ "directions": 4
+ },
+ {
+ "name": "inhand-right",
+ "directions": 4
+ }
+ ]
+}