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 + } + ] +}