--- /dev/null
+using Content.Shared.Silicons.Bots;
+
+namespace Content.Client.Silicons.Bots;
+
+public sealed partial class HugBotSystem : SharedHugBotSystem;
--- /dev/null
+using Content.Shared.Emag.Systems;
+
+namespace Content.Server.NPC.HTN.Preconditions;
+
+/// <summary>
+/// A precondition which is met if the NPC is emagged with <see cref="EmagType"/>, as computed by
+/// <see cref="EmagSystem.CheckFlag"/>. This is useful for changing NPC behavior in the case that the NPC is emagged,
+/// eg. like a helper NPC bot turning evil.
+/// </summary>
+public sealed partial class IsEmaggedPrecondition : HTNPrecondition
+{
+ private EmagSystem _emag;
+
+ /// <summary>
+ /// The type of emagging to check for.
+ /// </summary>
+ [DataField]
+ public EmagType EmagType = EmagType.Interaction;
+
+ public override void Initialize(IEntitySystemManager sysManager)
+ {
+ base.Initialize(sysManager);
+ _emag = sysManager.GetEntitySystem<EmagSystem>();
+ }
+
+ public override bool IsMet(NPCBlackboard blackboard)
+ {
+ var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+ return _emag.CheckFlag(owner, EmagType);
+ }
+}
--- /dev/null
+using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Interactions;
+using Content.Shared.CombatMode;
+using Content.Shared.Weapons.Melee;
+
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Combat.Melee;
+
+/// <summary>
+/// Something between <see cref="MeleeOperator"/> and <see cref="InteractWithOperator"/>, this operator causes the NPC
+/// to attempt a SINGLE <see cref="SharedMeleeWeaponSystem.AttemptLightAttack">melee attack</see> on the specified
+/// <see cref="TargetKey">target</see>.
+/// </summary>
+public sealed partial class MeleeAttackOperator : HTNOperator
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ private SharedMeleeWeaponSystem _melee;
+
+ /// <summary>
+ /// Key that contains the target entity.
+ /// </summary>
+ [DataField(required: true)]
+ public string TargetKey = default!;
+
+ public override void Initialize(IEntitySystemManager sysManager)
+ {
+ base.Initialize(sysManager);
+ _melee = sysManager.GetEntitySystem<SharedMeleeWeaponSystem>();
+ }
+
+ public override void TaskShutdown(NPCBlackboard blackboard, HTNOperatorStatus status)
+ {
+ base.TaskShutdown(blackboard, status);
+
+ ExitCombatMode(blackboard);
+ }
+
+ public override void PlanShutdown(NPCBlackboard blackboard)
+ {
+ base.PlanShutdown(blackboard);
+
+ ExitCombatMode(blackboard);
+ }
+
+ public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
+ {
+ var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+
+ if (!_entManager.TryGetComponent<CombatModeComponent>(owner, out var combatMode) ||
+ !_melee.TryGetWeapon(owner, out var weaponUid, out var weapon))
+ {
+ return HTNOperatorStatus.Failed;
+ }
+
+ _entManager.System<SharedCombatModeSystem>().SetInCombatMode(owner, true, combatMode);
+
+
+ if (!blackboard.TryGetValue<EntityUid>(TargetKey, out var target, _entManager) ||
+ !_melee.AttemptLightAttack(owner, weaponUid, weapon, target))
+ {
+ return HTNOperatorStatus.Continuing;
+ }
+
+ return HTNOperatorStatus.Finished;
+ }
+
+ private void ExitCombatMode(NPCBlackboard blackboard)
+ {
+ var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
+ _entManager.System<SharedCombatModeSystem>().SetInCombatMode(owner, false);
+ }
+}
using Content.Server.Chat.Systems;
+using Content.Shared.Dataset;
+using Content.Shared.Random.Helpers;
+using JetBrains.Annotations;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using static Content.Server.NPC.HTN.PrimitiveTasks.Operators.SpeakOperator.SpeakOperatorSpeech;
namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators;
public sealed partial class SpeakOperator : HTNOperator
{
private ChatSystem _chat = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
[DataField(required: true)]
- public string Speech = string.Empty;
+ public SpeakOperatorSpeech Speech;
/// <summary>
/// Whether to hide message from chat window and logs.
public override void Initialize(IEntitySystemManager sysManager)
{
base.Initialize(sysManager);
-
_chat = sysManager.GetEntitySystem<ChatSystem>();
}
public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
{
+ LocId speechLocId;
+ switch (Speech)
+ {
+ case LocalizedSetSpeakOperatorSpeech localizedDataSet:
+ if (!_proto.TryIndex(localizedDataSet.LineSet, out var speechSet))
+ return HTNOperatorStatus.Failed;
+ speechLocId = _random.Pick(speechSet);
+ break;
+ case SingleSpeakOperatorSpeech single:
+ speechLocId = single.Line;
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(Speech));
+ }
+
var speaker = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
- _chat.TrySendInGameICMessage(speaker, Loc.GetString(Speech), InGameICChatType.Speak, hideChat: Hidden, hideLog: Hidden);
+ _chat.TrySendInGameICMessage(
+ speaker,
+ Loc.GetString(speechLocId),
+ InGameICChatType.Speak,
+ hideChat: Hidden,
+ hideLog: Hidden
+ );
return base.Update(blackboard, frameTime);
}
+
+ [ImplicitDataDefinitionForInheritors, MeansImplicitUse]
+ public abstract partial class SpeakOperatorSpeech
+ {
+ public sealed partial class SingleSpeakOperatorSpeech : SpeakOperatorSpeech
+ {
+ [DataField(required: true)]
+ public string Line;
+ }
+
+ public sealed partial class LocalizedSetSpeakOperatorSpeech : SpeakOperatorSpeech
+ {
+ [DataField(required: true)]
+ public ProtoId<LocalizedDatasetPrototype> LineSet;
+ }
+ }
}
--- /dev/null
+namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators.Specific;
+
+/// <summary>
+/// Raises an <see cref="HTNRaisedEvent"/> on the <see cref="NPCBlackboard.Owner">owner</see>. The event will contain
+/// the specified <see cref="Args"/>, and if not null, the value of <see cref="TargetKey"/>.
+/// </summary>
+public sealed partial class RaiseEventForOwnerOperator : HTNOperator
+{
+ [Dependency] private readonly IEntityManager _entMan = default!;
+
+ /// <summary>
+ /// The conceptual "target" of this event. Note that this is NOT the entity for which the event is raised. If null,
+ /// <see cref="HTNRaisedEvent.Target"/> will be null.
+ /// </summary>
+ [DataField]
+ public string? TargetKey;
+
+ /// <summary>
+ /// The data contained in the raised event. Since <see cref="HTNRaisedEvent"/> is itself pretty meaningless, this is
+ /// included to give some context of what the event is actually supposed to mean.
+ /// </summary>
+ [DataField(required: true)]
+ public EntityEventArgs Args;
+
+ public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
+ {
+ _entMan.EventBus.RaiseLocalEvent(
+ blackboard.GetValue<EntityUid>(NPCBlackboard.Owner),
+ new HTNRaisedEvent(
+ blackboard.GetValue<EntityUid>(NPCBlackboard.Owner),
+ TargetKey is { } targetKey ? blackboard.GetValue<EntityUid>(targetKey) : null,
+ Args
+ )
+ );
+
+ return HTNOperatorStatus.Finished;
+ }
+}
+
+public sealed partial class HTNRaisedEvent(EntityUid owner, EntityUid? target, EntityEventArgs args) : EntityEventArgs
+{
+ // Owner and target are both included here in case we want to add a "RaiseEventForTargetOperator" in the future
+ // while reusing this event.
+ public EntityUid Owner = owner;
+ public EntityUid? Target = target;
+ public EntityEventArgs Args = args;
+}
/// </summary>
[DataField("components", required: true)]
public ComponentRegistry Components = new();
+
+ /// <summary>
+ /// If true, this filter retains entities with ALL of the specified components. If false, this filter removes
+ /// entities with ANY of the specified components.
+ /// </summary>
+ [DataField]
+ public bool RetainWithComp = true;
}
{
foreach (var comp in compFilter.Components)
{
- if (HasComp(ent, comp.Value.Component.GetType()))
- continue;
-
- _entityList.Add(ent);
- break;
+ var hasComp = HasComp(ent, comp.Value.Component.GetType());
+ if (!compFilter.RetainWithComp == hasComp)
+ {
+ _entityList.Add(ent);
+ break;
+ }
}
}
--- /dev/null
+using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Specific;
+using Content.Shared.Silicons.Bots;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Silicons.Bots;
+
+/// <summary>
+/// Beyond what <see cref="SharedHugBotSystem"/> does, this system manages the "lifecycle" of
+/// <see cref="RecentlyHuggedByHugBotComponent"/>.
+/// </summary>
+public sealed class HugBotSystem : SharedHugBotSystem
+{
+ [Dependency] private readonly IGameTiming _gameTiming = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<HugBotComponent, HTNRaisedEvent>(OnHtnRaisedEvent);
+ }
+
+ private void OnHtnRaisedEvent(Entity<HugBotComponent> entity, ref HTNRaisedEvent args)
+ {
+ if (args.Args is not HugBotDidHugEvent ||
+ args.Target is not {} target)
+ return;
+
+ var ev = new HugBotHugEvent(GetNetEntity(entity));
+ RaiseLocalEvent(target, ev);
+
+ ApplyHugBotCooldown(entity, target);
+ }
+
+ /// <summary>
+ /// Applies <see cref="RecentlyHuggedByHugBotComponent"/> to <paramref name="target"/> based on the configuration of
+ /// <paramref name="hugBot"/>.
+ /// </summary>
+ public void ApplyHugBotCooldown(Entity<HugBotComponent> hugBot, EntityUid target)
+ {
+ var hugged = EnsureComp<RecentlyHuggedByHugBotComponent>(target);
+ hugged.CooldownCompleteAfter = _gameTiming.CurTime + hugBot.Comp.HugCooldown;
+ }
+
+ public override void Update(float frameTime)
+ {
+ // Iterate through all RecentlyHuggedByHugBot entities...
+ var huggedEntities = AllEntityQuery<RecentlyHuggedByHugBotComponent>();
+ while (huggedEntities.MoveNext(out var huggedEnt, out var huggedComp))
+ {
+ // ... and if their cooldown is complete...
+ if (huggedComp.CooldownCompleteAfter <= _gameTiming.CurTime)
+ {
+ // ... remove it, allowing them to receive the blessing of hugs once more.
+ RemCompDeferred<RecentlyHuggedByHugBotComponent>(huggedEnt);
+ }
+ }
+ }
+}
+
+/// <summary>
+/// This event is indirectly raised (by being <see cref="HTNRaisedEvent.Args"/>) on a HugBot when it hugs (or emaggedly
+/// punches) an entity.
+/// </summary>
+[Serializable, DataDefinition]
+public sealed partial class HugBotDidHugEvent : EntityEventArgs;
--- /dev/null
+using Content.Shared.Silicons.Bots;
+
+namespace Content.Server.Silicons.Bots;
+
+/// <summary>
+/// This marker component indicates that its entity has been recently hugged by a HugBot and should not be hugged again
+/// before <see cref="CooldownCompleteAfter">a cooldown period</see> in order to prevent hug spam.
+/// </summary>
+/// <see cref="SharedHugBotSystem"/>
+[RegisterComponent, AutoGenerateComponentPause]
+public sealed partial class RecentlyHuggedByHugBotComponent : Component
+{
+ [DataField, AutoPausedField]
+ public TimeSpan CooldownCompleteAfter = TimeSpan.MinValue;
+}
--- /dev/null
+namespace Content.Shared.Silicons.Bots;
+
+/// <summary>
+/// This component describes how a HugBot hugs.
+/// </summary>
+/// <see cref="SharedHugBotSystem"/>
+[RegisterComponent, AutoGenerateComponentState]
+public sealed partial class HugBotComponent : Component
+{
+ [DataField, AutoNetworkedField]
+ public TimeSpan HugCooldown = TimeSpan.FromMinutes(2);
+}
--- /dev/null
+using Content.Shared.Emag.Systems;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Silicons.Bots;
+
+/// <summary>
+/// This system handles HugBots.
+/// </summary>
+public abstract class SharedHugBotSystem : EntitySystem
+{
+ [Dependency] private readonly EmagSystem _emag = default!;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent<HugBotComponent, GotEmaggedEvent>(OnEmagged);
+ }
+
+ private void OnEmagged(Entity<HugBotComponent> entity, ref GotEmaggedEvent args)
+ {
+ if (!_emag.CompareFlag(args.Type, EmagType.Interaction) ||
+ _emag.CheckFlag(entity, EmagType.Interaction) ||
+ !TryComp<HugBotComponent>(entity, out var hugBot))
+ return;
+
+ // HugBot HTN checks for emag state within its own logic, so we don't need to change anything here.
+
+ args.Handled = true;
+ }
+}
+
+/// <summary>
+/// This event is raised on an entity when it is hugged by a HugBot.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed partial class HugBotHugEvent(NetEntity hugBot) : EntityEventArgs
+{
+ public readonly NetEntity HugBot = hugBot;
+}
--- /dev/null
+construction-graph-tag-boxhug = a box of hugs
--- /dev/null
+hugbot-start-hug-1 = LEVEL 5 HUG DEFICIENCY DETECTED!
+hugbot-start-hug-2 = You look like you need a hug!
+hugbot-start-hug-3 = Aww, somebody needs a hug!
+hugbot-start-hug-4 = Target acquired; Initiating hug routine.
+hugbot-start-hug-5 = Hold still, please.
+hugbot-start-hug-6 = Hugs!
+hugbot-start-hug-7 = Deploying HUG.
+hugbot-start-hug-8 = I am designed to hug, and you WILL be hugged.
+
+hugbot-finish-hug-1 = All done.
+hugbot-finish-hug-2 = Hug routine terminated.
+hugbot-finish-hug-3 = Feel better?
+hugbot-finish-hug-4 = Feel better soon!
+hugbot-finish-hug-5 = You are loved.
+hugbot-finish-hug-6 = You matter.
+hugbot-finish-hug-7 = It always gets better!
+hugbot-finish-hug-8 = Hug: COMPLETE.
+
+hugbot-emagged-finish-hug-1 = Actually, fuck you.
+hugbot-emagged-finish-hug-2 = Nobody loves you.
+hugbot-emagged-finish-hug-3 = Ewww... no.
+hugbot-emagged-finish-hug-4 = It can only get worse from here!
+hugbot-emagged-finish-hug-5 = Fucking crybaby.
+hugbot-emagged-finish-hug-6 = Go die.
+hugbot-emagged-finish-hug-7 = Drop dead.
+hugbot-emagged-finish-hug-8 = You are alone in this universe.
slots:
hand 1:
part: LeftArmBorg
+
+# It's like a medibot or a cleanbot except it has two arms to hug :)
+- type: body
+ id: HugBot
+ name: "hugBot"
+ root: box
+ slots:
+ box:
+ part: TorsoBorg
+ connections:
+ - right_arm
+ - left_arm
+ right_arm:
+ part: RightArmBorg
+ left_arm:
+ part: LeftArmBorg
--- /dev/null
+- type: localizedDataset
+ id: HugBotStarts
+ values:
+ prefix: hugbot-start-hug-
+ count: 8
+
+- type: localizedDataset
+ id: HugBotFinishes
+ values:
+ prefix: hugbot-finish-hug-
+ count: 8
+
+- type: localizedDataset
+ id: EmaggedHugBotFinishes
+ values:
+ prefix: hugbot-emagged-finish-hug-
+ count: 8
- Supply
- type: Puller
needsHands: false
+
+- type: entity
+ parent: [ MobSiliconBase, MobCombat ]
+ id: MobHugBot
+ name: hugbot
+ description: Awww, who needs a hug?
+ components:
+ - type: Sprite
+ sprite: Mobs/Silicon/Bots/hugbot.rsi
+ state: hugbot
+ - type: Construction
+ graph: HugBot
+ node: bot
+ - type: MovementSpeedModifier
+ baseWalkSpeed: 2
+ baseSprintSpeed: 3
+ - type: MeleeWeapon
+ soundHit:
+ path: /Audio/Weapons/boxingpunch1.ogg
+ angle: 30
+ animation: WeaponArcPunch
+ damage:
+ types:
+ Blunt: 2
+ - type: Anchorable
+ - type: Hands # This probably REALLY needs hand whitelisting, but we NEED hands for hugs, so...
+ - type: ComplexInteraction # Hugging is a complex interaction, apparently.
+ - type: HugBot
+ - type: Body
+ prototype: HugBot
+ - type: HTN
+ rootTask:
+ task: HugBotCompound
+ - type: InteractionPopup
+ interactSuccessString: hugging-success-generic
+ interactSuccessSound: /Audio/Effects/thudswoosh.ogg
+ messagePerceivedByOthers: hugging-success-generic-others
- !type:KeyFloatLessPrecondition
key: Count
value: 50
-
+
- !type:HTNPrimitiveTask
operator: !type:RandomOperator
targetKey: IdleTime
- tasks:
- !type:HTNPrimitiveTask
operator: !type:SpeakOperator
- speech: "fuck!"
-
-
\ No newline at end of file
+ speech: !type:SingleSpeakOperatorSpeech
+ line: "fuck!"
task: DouseFireTargetCompound
- tasks:
- !type:HTNCompoundTask
- task: IdleCompound
-
+ task: IdleCompound
+
- type: htnCompound
id: DouseFireTargetCompound
branches:
- !type:HTNPrimitiveTask
operator: !type:SpeakOperator
- speech: firebot-fire-detected
- hidden: true
+ speech: !type:SingleSpeakOperatorSpeech
+ line: firebot-fire-detected
+ hidden: true
- !type:HTNPrimitiveTask
operator: !type:MoveToOperator
--- /dev/null
+- type: htnCompound
+ id: HugBotCompound
+ branches:
+ - tasks:
+ - !type:HTNCompoundTask
+ task: HugNearbyCompound
+ - tasks:
+ - !type:HTNCompoundTask
+ task: IdleCompound
+
+- type: htnCompound
+ id: HugNearbyCompound
+ branches:
+ - tasks:
+ # Locate hug recipient.
+ - !type:HTNPrimitiveTask
+ operator: !type:UtilityOperator
+ proto: NearbyNeedingHug
+
+ # Announce intent to hug
+ - !type:HTNPrimitiveTask
+ operator: !type:SpeakOperator
+ speech: !type:LocalizedSetSpeakOperatorSpeech
+ lineSet: HugBotStarts
+ hidden: true
+
+ # Approach hug recipient
+ - !type:HTNPrimitiveTask
+ operator: !type:MoveToOperator
+ pathfindInPlanning: true
+ removeKeyOnFinish: false
+ targetKey: TargetCoordinates
+ pathfindKey: TargetPathfind
+ rangeKey: MeleeRange
+
+ # HUG!!
+ - !type:HTNCompoundTask
+ task: HugBotHugCompound
+
+ # Stick around to enjoy the hug instead of just running up, squeezing and leaving.
+ - !type:HTNPrimitiveTask
+ operator: !type:SetFloatOperator
+ targetKey: IdleTime
+ amount: 1
+ - !type:HTNPrimitiveTask
+ operator: !type:WaitOperator
+ key: IdleTime
+ preconditions:
+ - !type:KeyExistsPrecondition
+ key: IdleTime
+
+ # Special case operator which applies the hugbot cooldown.
+ - !type:HTNPrimitiveTask
+ operator: !type:RaiseEventForOwnerOperator
+ args: !type:HugBotDidHugEvent
+ targetKey: Target
+
+ # Announce that the hug is completed.
+ - !type:HTNCompoundTask
+ task: HugBotFinishSpeakCompound
+
+- type: htnCompound
+ id: HugBotHugCompound
+ branches:
+ # Hit if emagged
+ - preconditions:
+ - !type:IsEmaggedPrecondition
+ tasks:
+ - !type:HTNPrimitiveTask
+ preconditions:
+ - !type:TargetInRangePrecondition
+ targetKey: Target
+ rangeKey: InteractRange
+ operator: !type:MeleeAttackOperator
+ targetKey: Target
+ services:
+ - !type:UtilityService
+ id: HugService
+ proto: NearbyNeedingHug
+ key: Target
+ # Hug otherwise
+ - tasks:
+ - !type:HTNPrimitiveTask
+ preconditions:
+ - !type:TargetInRangePrecondition
+ targetKey: Target
+ rangeKey: InteractRange
+ operator: !type:InteractWithOperator
+ targetKey: Target
+ services:
+ - !type:UtilityService
+ id: HugService
+ proto: NearbyNeedingHug
+ key: Target
+
+- type: htnCompound
+ id: HugBotFinishSpeakCompound
+ branches:
+ # Say mean things if emagged
+ - preconditions:
+ - !type:IsEmaggedPrecondition
+ tasks:
+ - !type:HTNPrimitiveTask
+ operator: !type:SpeakOperator
+ speech: !type:LocalizedSetSpeakOperatorSpeech
+ lineSet: EmaggedHugBotFinishes
+ hidden: true
+ # Say nice thing otherwise
+ - tasks:
+ - !type:HTNPrimitiveTask
+ operator: !type:SpeakOperator
+ speech: !type:LocalizedSetSpeakOperatorSpeech
+ lineSet: HugBotFinishes
+ hidden: true
+
+- type: utilityQuery
+ id: NearbyNeedingHug
+ query:
+ - !type:ComponentQuery
+ components:
+ - type: HumanoidAppearance
+ species: Human # This specific value isn't actually used, so don't worry about it being just `Human`.
+ - !type:ComponentFilter
+ retainWithComp: false
+ components:
+ - type: RecentlyHuggedByHugBot
+ considerations:
+ - !type:TargetDistanceCon
+ curve: !type:PresetCurve
+ preset: TargetDistance
+ - !type:TargetAccessibleCon
+ curve: !type:BoolCurve
+ - !type:TargetInLOSOrCurrentCon
+ curve: !type:BoolCurve
- !type:HTNPrimitiveTask
operator: !type:SpeakOperator
- speech: medibot-start-inject
+ speech: !type:SingleSpeakOperatorSpeech
+ line: medibot-start-inject
hidden: true
- !type:HTNPrimitiveTask
--- /dev/null
+- type: constructionGraph
+ id: HugBot
+ start: start
+ graph:
+ - node: start
+ edges:
+ - to: bot
+ steps:
+ - tag: BoxHug
+ icon:
+ sprite: Objects/Storage/boxes.rsi
+ state: box_hug
+ name: construction-graph-tag-boxhug
+ - tag: ProximitySensor
+ icon:
+ sprite: Objects/Misc/proximity_sensor.rsi
+ state: icon
+ name: construction-graph-tag-proximity-sensor
+ doAfter: 2
+ - tag: BorgArm
+ icon:
+ sprite: Mobs/Silicon/drone.rsi
+ state: l_hand
+ name: construction-graph-tag-borg-arm
+ doAfter: 2
+ - tag: BorgArm
+ icon:
+ sprite: Mobs/Silicon/drone.rsi
+ state: l_hand
+ name: construction-graph-tag-borg-arm
+ doAfter: 2
+ - node: bot
+ entity: MobHugBot
targetNode: bot
category: construction-category-utilities
objectType: Item
+
+- type: construction
+ id: hugbot
+ graph: HugBot
+ startNode: start
+ targetNode: bot
+ category: construction-category-utilities
+ objectType: Item
--- /dev/null
+{
+ "version": 1,
+ "license": "CC-BY-SA-4.0",
+ "copyright": "Original sprite made by compilatron (Discord) for SS13, relicensed for SS14/Moffstation; modified by Centronias (GitHub) to have two arms",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "hugbot"
+ }
+ ]
+}