Adds HugBot (#37557)

* - hugbot
  - bdy with two arms because it needs two arms to hug
  - is constructable from:
    - box of hugs
    - proximity sensor
    - two borg arms
  - lots of voice lines
  - kinda like a medibot, it chases you down and then hugs you
    - except if it's emagged, then it punches you :)
    - it has a 2m cooldown per person by default

- MeleeAttackOperator
  - Read the doc, but it's an operator which makes the NPC hit a target exactly once assuming it's in range.
  - Used to make the hugbot attack
- RaiseEventForOwnerOperator
  - Read the doc, but it's an operator which raises an event on the owning NPC.
  - Used to make the hugbot hug extra code, specifically for the cooldown

- Changes to existing code:
  - `ComponentFilter : UtilityQueryFilter` gets `RetainWithComp` added which, as the name implies, retains entities with the specified comps rather than removing them. Basically, it lets you negate the filter.
  - `SpeakOperator : HTNOperator`'s `speech` field can use a `LocalizedDataSet` instead of just a locstring now
    - (I updated all of the existing usages for this)
  -

* two arms

* wait what if we just used mimebot arms so it doesn't look awful

* smort
This commit is contained in:
Centronias
2025-10-10 17:51:12 -07:00
committed by GitHub
parent ad708eec3b
commit d3f85701f7
24 changed files with 639 additions and 17 deletions

View File

@@ -0,0 +1,5 @@
using Content.Shared.Silicons.Bots;
namespace Content.Client.Silicons.Bots;
public sealed partial class HugBotSystem : SharedHugBotSystem;

View File

@@ -0,0 +1,31 @@
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);
}
}

View File

@@ -0,0 +1,70 @@
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);
}
}

View File

@@ -1,13 +1,21 @@
using Content.Server.Chat.Systems; 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; namespace Content.Server.NPC.HTN.PrimitiveTasks.Operators;
public sealed partial class SpeakOperator : HTNOperator public sealed partial class SpeakOperator : HTNOperator
{ {
private ChatSystem _chat = default!; private ChatSystem _chat = default!;
[Dependency] private readonly IPrototypeManager _proto = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[DataField(required: true)] [DataField(required: true)]
public string Speech = string.Empty; public SpeakOperatorSpeech Speech;
/// <summary> /// <summary>
/// Whether to hide message from chat window and logs. /// Whether to hide message from chat window and logs.
@@ -18,15 +26,51 @@ public sealed partial class SpeakOperator : HTNOperator
public override void Initialize(IEntitySystemManager sysManager) public override void Initialize(IEntitySystemManager sysManager)
{ {
base.Initialize(sysManager); base.Initialize(sysManager);
_chat = sysManager.GetEntitySystem<ChatSystem>(); _chat = sysManager.GetEntitySystem<ChatSystem>();
} }
public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime) 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); 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); 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;
}
}
} }

View File

@@ -0,0 +1,47 @@
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;
}

View File

@@ -9,4 +9,11 @@ public sealed partial class ComponentFilter : UtilityQueryFilter
/// </summary> /// </summary>
[DataField("components", required: true)] [DataField("components", required: true)]
public ComponentRegistry Components = new(); 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;
} }

View File

@@ -512,13 +512,14 @@ public sealed class NPCUtilitySystem : EntitySystem
{ {
foreach (var comp in compFilter.Components) foreach (var comp in compFilter.Components)
{ {
if (HasComp(ent, comp.Value.Component.GetType())) var hasComp = HasComp(ent, comp.Value.Component.GetType());
continue; if (!compFilter.RetainWithComp == hasComp)
{
_entityList.Add(ent); _entityList.Add(ent);
break; break;
} }
} }
}
foreach (var ent in _entityList) foreach (var ent in _entityList)
{ {

View File

@@ -0,0 +1,65 @@
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;

View File

@@ -0,0 +1,15 @@
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;
}

View File

@@ -0,0 +1,12 @@
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);
}

View File

@@ -0,0 +1,38 @@
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;
}

View File

@@ -0,0 +1 @@
construction-graph-tag-boxhug = a box of hugs

View File

@@ -0,0 +1,26 @@
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.

View File

@@ -5,3 +5,19 @@
slots: slots:
hand 1: hand 1:
part: LeftArmBorg 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

View File

@@ -0,0 +1,17 @@
- 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

View File

@@ -460,3 +460,40 @@
- Supply - Supply
- type: Puller - type: Puller
needsHands: false 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

View File

@@ -85,6 +85,5 @@
- tasks: - tasks:
- !type:HTNPrimitiveTask - !type:HTNPrimitiveTask
operator: !type:SpeakOperator operator: !type:SpeakOperator
speech: "fuck!" speech: !type:SingleSpeakOperatorSpeech
line: "fuck!"

View File

@@ -18,7 +18,8 @@
- !type:HTNPrimitiveTask - !type:HTNPrimitiveTask
operator: !type:SpeakOperator operator: !type:SpeakOperator
speech: firebot-fire-detected speech: !type:SingleSpeakOperatorSpeech
line: firebot-fire-detected
hidden: true hidden: true
- !type:HTNPrimitiveTask - !type:HTNPrimitiveTask

View File

@@ -0,0 +1,134 @@
- 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

View File

@@ -20,7 +20,8 @@
- !type:HTNPrimitiveTask - !type:HTNPrimitiveTask
operator: !type:SpeakOperator operator: !type:SpeakOperator
speech: medibot-start-inject speech: !type:SingleSpeakOperatorSpeech
line: medibot-start-inject
hidden: true hidden: true
- !type:HTNPrimitiveTask - !type:HTNPrimitiveTask

View File

@@ -0,0 +1,33 @@
- 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

View File

@@ -53,3 +53,11 @@
targetNode: bot targetNode: bot
category: construction-category-utilities category: construction-category-utilities
objectType: Item objectType: Item
- type: construction
id: hugbot
graph: HugBot
startNode: start
targetNode: bot
category: construction-category-utilities
objectType: Item

Binary file not shown.

After

Width:  |  Height:  |  Size: 342 B

View File

@@ -0,0 +1,14 @@
{
"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"
}
]
}