diff --git a/Content.Client/Clothing/ClientClothingSystem.cs b/Content.Client/Clothing/ClientClothingSystem.cs
index bd1b4c40a4f..713596e5059 100644
--- a/Content.Client/Clothing/ClientClothingSystem.cs
+++ b/Content.Client/Clothing/ClientClothingSystem.cs
@@ -337,6 +337,7 @@ private void RenderEquipment(EntityUid equipee, EntityUid equipment, string slot
displacementData = inventory.MaleDisplacements.GetValueOrDefault(slot);
break;
case Sex.Female:
+ case Sex.Futanari: // Arcane-edit
if (inventory.FemaleDisplacements.Count > 0)
displacementData = inventory.FemaleDisplacements.GetValueOrDefault(slot);
break;
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
index 6498adb1472..913a0597cd2 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml
@@ -119,6 +119,13 @@ SPDX-License-Identifier: AGPL-3.0-or-later
+
+
+
+
+
+
+
diff --git a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
index 6a87eefb488..aa20b30e5f3 100644
--- a/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
+++ b/Content.Client/Lobby/UI/HumanoidProfileEditor.xaml.cs
@@ -195,6 +195,7 @@
using Direction = Robust.Shared.Maths.Direction;
using Content.Goobstation.Common.CCVar; // Goob Station - Barks
using Content.Goobstation.Common.Barks; // Goob Station - Barks
+using Content.Shared._Arcane.ERP; // Arcane-edit
namespace Content.Client.Lobby.UI
{
[GenerateTypedNameReferences]
@@ -627,6 +628,23 @@ public HumanoidProfileEditor(
#endregion SpawnPriority
+ // Arcane-Start
+ #region ErpPreference
+
+ foreach (var value in Enum.GetValues())
+ {
+ ErpPreferenceButton.AddItem(Loc.GetString($"humanoid-profile-editor-erp-preference-{value.ToString().ToLower()}"), (int) value);
+ }
+
+ ErpPreferenceButton.OnItemSelected += args =>
+ {
+ ErpPreferenceButton.SelectId(args.Id);
+ SetErpPreference((ErpPreference) args.Id);
+ };
+
+ #endregion ErpPreference
+ // Arcane-End
+
#region Eyes
EyeColorPicker.OnEyeColorPicked += newColor =>
@@ -1349,6 +1367,7 @@ public void SetProfile(HumanoidCharacterProfile? profile, int? slot)
UpdateGenderControls();
UpdateSkinColor();
UpdateSpawnPriorityControls();
+ UpdateErpPreferenceControls(); // Arcane-edit
UpdateAgeEdit();
UpdateEyePickers();
UpdateSaveButton();
@@ -1958,6 +1977,14 @@ private void SetSpawnPriority(SpawnPriorityPreference newSpawnPriority)
SetDirty();
}
+ // Arcane-Start
+ private void SetErpPreference(ErpPreference preference)
+ {
+ Profile = Profile?.WithErpPreference(preference);
+ SetDirty();
+ }
+ // Arcane-End
+
// Goob Station - Start
private void SetProfileHeight(float height)
{
@@ -2226,6 +2253,16 @@ private void UpdateSpawnPriorityControls()
SpawnPriorityButton.SelectId((int) Profile.SpawnPriority);
}
+ // Arcane-Start
+ private void UpdateErpPreferenceControls()
+ {
+ if (Profile == null)
+ return;
+
+ ErpPreferenceButton.SelectId((int) Profile.ErpPreference);
+ }
+ // Arcane-End
+
// begin Goobstation: port EE height/width sliders
private void UpdateHeightWidthSliders()
{
diff --git a/Content.Goobstation.Shared/Humanoid/SharedGoobHumanoidAppearanceSystem.cs b/Content.Goobstation.Shared/Humanoid/SharedGoobHumanoidAppearanceSystem.cs
index a648dfc1b5f..ba5ecf1269e 100644
--- a/Content.Goobstation.Shared/Humanoid/SharedGoobHumanoidAppearanceSystem.cs
+++ b/Content.Goobstation.Shared/Humanoid/SharedGoobHumanoidAppearanceSystem.cs
@@ -22,6 +22,7 @@ public void SwapSex(EntityUid uid, HumanoidAppearanceComponent? humanoid = null)
switch (humanoid.Sex)
{
case Sex.Unsexed:
+ case Sex.Futanari: // Arcane-edit: no meaningful swap
default: break;
case Sex.Male: newGender = Gender.Female; newSex = Sex.Female; break;
case Sex.Female: newGender = Gender.Male; newSex = Sex.Male; break;
diff --git a/Content.Server/_Arcane/ERP/ArousalSystem.cs b/Content.Server/_Arcane/ERP/ArousalSystem.cs
new file mode 100644
index 00000000000..5a2318f2fb0
--- /dev/null
+++ b/Content.Server/_Arcane/ERP/ArousalSystem.cs
@@ -0,0 +1,194 @@
+using Content.Shared._Arcane.ERP;
+using Content.Shared.Alert;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Server._Arcane.ERP;
+
+public sealed class ArousalSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly AlertsSystem _alerts = default!;
+
+ private static readonly TimeSpan PhaseCheckRate = TimeSpan.FromSeconds(5);
+
+ private static readonly ProtoId AlertCategory = "Arousal";
+ private static readonly ProtoId AlertAroused = "ArousalAroused";
+ private static readonly ProtoId AlertHeated = "ArousalHeated";
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnInit);
+ SubscribeLocalEvent(OnErpPreferenceChanged);
+ }
+
+ private void OnInit(Entity ent, ref ComponentInit args)
+ {
+ SetArousal(ent, 0f);
+ }
+
+ private void OnErpPreferenceChanged(Entity ent, ref ErpPreferenceChangedEvent args)
+ {
+ if (args.NewPreference != ErpPreference.No)
+ return;
+
+ ent.Comp.PassiveSources.Clear();
+ SetArousal(ent, 0f);
+ }
+
+ ///
+ /// Returns current arousal accounting for passive gain/decay since last authoritative set.
+ /// Passive gain is suppressed during the refractory period.
+ ///
+ public float GetArousal(ArousalComponent comp)
+ {
+ var sinceChange = _timing.CurTime - comp.LastChangeTime;
+ var elapsed = (float)sinceChange.TotalSeconds;
+ var passiveRate = IsRefractory(comp) ? 0f : comp.PassiveGainRate;
+ var netRate = passiveRate - comp.DecayRate;
+ return Math.Clamp(comp.LastValue + netRate * elapsed, 0f, comp.MaxArousal);
+ }
+
+ public bool IsRefractory(ArousalComponent comp)
+ {
+ return _timing.CurTime < comp.RefractoryUntil;
+ }
+
+ public void SetPassiveSource(EntityUid uid, string sourceId, float rate, ArousalComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ if (IsErpDisabled(uid))
+ return;
+
+ // Snapshot current value before changing rate to prevent retroactive application.
+ comp.LastValue = GetArousal(comp);
+ comp.LastChangeTime = _timing.CurTime;
+ comp.PassiveSources[sourceId] = rate;
+ Dirty(uid, comp);
+ }
+
+ public void RemovePassiveSource(EntityUid uid, string sourceId, ArousalComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ // Snapshot current value before removing source to prevent retroactive application.
+ comp.LastValue = GetArousal(comp);
+ comp.LastChangeTime = _timing.CurTime;
+ comp.PassiveSources.Remove(sourceId);
+ Dirty(uid, comp);
+ }
+
+ public void AddArousal(EntityUid uid, float amount, ArousalComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ if (IsErpDisabled(uid))
+ return;
+
+ if (IsRefractory(comp))
+ return;
+
+ var before = GetArousal(comp);
+ var target = Math.Clamp(before + amount, 0f, comp.MaxArousal);
+ SetArousal((uid, comp), target);
+ if (target > before)
+ RaiseLocalEvent(uid, new ArousedEvent(before, target));
+ }
+
+ public void ReduceArousal(EntityUid uid, float amount, ArousalComponent? comp = null)
+ {
+ if (!Resolve(uid, ref comp))
+ return;
+
+ SetArousal((uid, comp), GetArousal(comp) - amount);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var now = _timing.CurTime;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (now < comp.NextPhaseCheckAt)
+ continue;
+
+ comp.NextPhaseCheckAt = now + PhaseCheckRate;
+ UpdatePhase((uid, comp));
+ }
+ }
+
+ private void SetArousal(Entity entity, float value)
+ {
+ var comp = entity.Comp;
+ value = Math.Clamp(value, 0f, comp.MaxArousal);
+
+ comp.LastValue = value;
+ comp.LastChangeTime = _timing.CurTime;
+ Dirty(entity.Owner, comp);
+
+ UpdatePhase(entity);
+ }
+
+ private void UpdatePhase(Entity entity)
+ {
+ var comp = entity.Comp;
+ var newPhase = comp.ComputePhase(GetArousal(comp));
+ if (newPhase == comp.CurrentPhase)
+ return;
+
+ var previous = comp.CurrentPhase;
+ comp.CurrentPhase = newPhase;
+
+ if (newPhase == ArousalPhase.Peak)
+ {
+ // Reset arousal inline to avoid re-entering UpdatePhase via SetArousal.
+ // Alerts go straight to Calm — no single-tick Peak flash.
+ comp.LastOrgasmAt = _timing.CurTime;
+ comp.RefractoryUntil = _timing.CurTime + comp.RefractoryDuration;
+ comp.LastValue = 0f;
+ comp.LastChangeTime = _timing.CurTime;
+ comp.CurrentPhase = ArousalPhase.Calm;
+ Dirty(entity.Owner, comp);
+
+ UpdateAlerts(entity.Owner, ArousalPhase.Calm);
+ var orgasmEv = new ArousalOrgasmEvent();
+ RaiseLocalEvent(entity.Owner, ref orgasmEv);
+ RaiseLocalEvent(entity.Owner, new ArousalPhaseChangedEvent(previous, ArousalPhase.Calm));
+ }
+ else
+ {
+ Dirty(entity.Owner, comp);
+ UpdateAlerts(entity.Owner, newPhase);
+ RaiseLocalEvent(entity.Owner, new ArousalPhaseChangedEvent(previous, newPhase));
+ }
+ }
+
+ private void UpdateAlerts(EntityUid uid, ArousalPhase phase)
+ {
+ switch (phase)
+ {
+ case ArousalPhase.Aroused:
+ _alerts.ShowAlert(uid, AlertAroused);
+ break;
+ case ArousalPhase.Heated:
+ _alerts.ShowAlert(uid, AlertHeated);
+ break;
+ default:
+ _alerts.ClearAlertCategory(uid, AlertCategory);
+ break;
+ }
+ }
+
+ private bool IsErpDisabled(EntityUid uid)
+ {
+ return TryComp(uid, out var status)
+ && status.Preference == ErpPreference.No;
+ }
+}
diff --git a/Content.Server/_Arcane/ERP/EroticCoverageSystem.cs b/Content.Server/_Arcane/ERP/EroticCoverageSystem.cs
new file mode 100644
index 00000000000..28d1edeb726
--- /dev/null
+++ b/Content.Server/_Arcane/ERP/EroticCoverageSystem.cs
@@ -0,0 +1,80 @@
+using Content.Shared._Arcane.ERP;
+using Content.Shared._Arcane.ERP.Organs;
+using Content.Shared.Body.Part;
+using Content.Shared.Body.Systems;
+using Content.Shared.Clothing;
+using Content.Shared.Clothing.Components;
+using Content.Shared.Humanoid;
+using Content.Shared.Inventory;
+using Robust.Shared.Containers;
+
+namespace Content.Server._Arcane.ERP;
+
+public sealed class EroticCoverageSystem : EntitySystem
+{
+ [Dependency] private readonly SharedBodySystem _body = default!;
+ [Dependency] private readonly InventorySystem _inventory = default!;
+ [Dependency] private readonly SharedContainerSystem _containers = default!;
+
+ private const SlotFlags GroinCovering = SlotFlags.INNERCLOTHING | SlotFlags.OUTERCLOTHING | SlotFlags.LEGS | SlotFlags.UNDERWEAR;
+ private const SlotFlags ChestCovering = SlotFlags.INNERCLOTHING | SlotFlags.OUTERCLOTHING | SlotFlags.UNDERSHIRT;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnOrgansSpawned);
+ SubscribeLocalEvent(OnEquipped);
+ SubscribeLocalEvent(OnUnequipped);
+ }
+
+ private void OnOrgansSpawned(Entity ent, ref EroticOrgansSpawnedEvent args)
+ {
+ RefreshOrganVisibility(ent);
+ }
+
+ private void OnEquipped(Entity ent, ref ClothingDidEquippedEvent args)
+ {
+ RefreshOrganVisibility(ent);
+ }
+
+ private void OnUnequipped(Entity ent, ref ClothingDidUnequippedEvent args)
+ {
+ RefreshOrganVisibility(ent);
+ }
+
+ private void RefreshOrganVisibility(EntityUid uid)
+ {
+ var coverage = SlotFlags.NONE;
+ var enumerator = _inventory.GetSlotEnumerator(uid, GroinCovering | ChestCovering);
+ while (enumerator.NextItem(out var item))
+ {
+ if (TryComp(item, out var clothing))
+ coverage |= clothing.Slots;
+ }
+
+ var groinCovered = (coverage & GroinCovering) != SlotFlags.NONE;
+ var chestCovered = (coverage & ChestCovering) != SlotFlags.NONE;
+
+ foreach (var organ in _body.GetBodyOrganEntityComps((uid, null)))
+ {
+ if (!_containers.TryGetContainingContainer(organ.Owner, out var container))
+ continue;
+
+ if (!TryComp(container.Owner, out var part))
+ continue;
+
+ var visible = part.PartType switch
+ {
+ BodyPartType.Groin => !groinCovered,
+ BodyPartType.Chest => !chestCovered,
+ _ => true,
+ };
+
+ if (organ.Comp1.Visible == visible)
+ continue;
+
+ organ.Comp1.Visible = visible;
+ Dirty(organ.Owner, organ.Comp1);
+ }
+ }
+}
diff --git a/Content.Server/_Arcane/ERP/ErpExamineSystem.cs b/Content.Server/_Arcane/ERP/ErpExamineSystem.cs
new file mode 100644
index 00000000000..25700c2428c
--- /dev/null
+++ b/Content.Server/_Arcane/ERP/ErpExamineSystem.cs
@@ -0,0 +1,50 @@
+using Content.Shared._Arcane.ERP;
+using Content.Shared.Examine;
+using Content.Shared.Verbs;
+using Robust.Shared.Utility;
+
+namespace Content.Server._Arcane.ERP;
+
+public sealed class ErpExamineSystem : EntitySystem
+{
+ [Dependency] private readonly ExamineSystemShared _examine = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent>(OnGetExamineVerbs);
+ }
+
+ private void OnGetExamineVerbs(Entity ent, ref GetVerbsEvent args)
+ {
+ var user = args.User;
+ var inRange = _examine.IsInDetailsRange(user, ent);
+
+ var verb = new ExamineVerb
+ {
+ Act = () =>
+ {
+ var statusKey = ent.Comp.Preference switch
+ {
+ ErpPreference.Yes => "erp-examine-status-yes",
+ ErpPreference.Ask => "erp-examine-status-ask",
+ _ => "erp-examine-status-no",
+ };
+
+ var msg = new FormattedMessage();
+ msg.AddMarkupOrThrow(Loc.GetString("erp-examine-status-header"));
+ msg.PushNewline();
+ msg.AddMarkupOrThrow(Loc.GetString(statusKey));
+
+ _examine.SendExamineTooltip(user, ent, msg, false, false);
+ },
+ Text = Loc.GetString("erp-examine-verb-text"),
+ Category = VerbCategory.Examine,
+ Disabled = !inRange,
+ Message = inRange ? null : Loc.GetString("erp-examine-verb-disabled"),
+ Icon = new SpriteSpecifier.Texture(new("/Textures/_Arcane/Interface/heartIcon.png")),
+ };
+
+ args.Verbs.Add(verb);
+ }
+}
diff --git a/Content.Server/_Arcane/ERP/ErpStatusSystem.cs b/Content.Server/_Arcane/ERP/ErpStatusSystem.cs
new file mode 100644
index 00000000000..39ff0f543cb
--- /dev/null
+++ b/Content.Server/_Arcane/ERP/ErpStatusSystem.cs
@@ -0,0 +1,34 @@
+using Content.Server.Preferences.Managers;
+using Content.Shared._Arcane.ERP;
+using Content.Shared.Humanoid;
+using Content.Shared.Preferences;
+using Robust.Shared.Player;
+
+namespace Content.Server._Arcane.ERP;
+
+public sealed class ErpStatusSystem : EntitySystem
+{
+ [Dependency] private readonly IServerPreferencesManager _prefs = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnPlayerAttached);
+ }
+
+ private void OnPlayerAttached(Entity ent, ref PlayerAttachedEvent args)
+ {
+ var profile = _prefs.GetPreferences(args.Player.UserId).SelectedCharacter as HumanoidCharacterProfile;
+ var preference = profile?.ErpPreference ?? ErpPreference.Ask;
+
+ EnsureComp(ent);
+
+ var comp = EnsureComp(ent);
+ var oldPreference = comp.Preference;
+ comp.Preference = preference;
+ Dirty(ent, comp);
+
+ if (oldPreference != preference)
+ RaiseLocalEvent(ent, new ErpPreferenceChangedEvent(oldPreference, preference));
+ }
+}
diff --git a/Content.Shared/Humanoid/HumanoidVisualLayersExtension.cs b/Content.Shared/Humanoid/HumanoidVisualLayersExtension.cs
index c4d514a6e4b..8a048089a32 100644
--- a/Content.Shared/Humanoid/HumanoidVisualLayersExtension.cs
+++ b/Content.Shared/Humanoid/HumanoidVisualLayersExtension.cs
@@ -38,7 +38,10 @@ public static string GetSexMorph(HumanoidVisualLayers layer, Sex sex, string id)
if (!HasSexMorph(layer) || sex == Sex.Unsexed)
return id;
- return $"{id}{sex}";
+ // Arcane-start: Futanari uses female sprite layers — no separate Futanari layer prototypes exist.
+ var visualSex = sex == Sex.Futanari ? Sex.Female : sex;
+ return $"{id}{visualSex}";
+ // Arcane-end
}
///
diff --git a/Content.Shared/Humanoid/Prototypes/SpeciesPrototype.cs b/Content.Shared/Humanoid/Prototypes/SpeciesPrototype.cs
index f6079166147..e71d7c928a6 100644
--- a/Content.Shared/Humanoid/Prototypes/SpeciesPrototype.cs
+++ b/Content.Shared/Humanoid/Prototypes/SpeciesPrototype.cs
@@ -130,7 +130,7 @@ public sealed partial class SpeciesPrototype : IPrototype
public SpeciesNaming Naming { get; private set; } = SpeciesNaming.FirstLast;
[DataField]
- public List Sexes { get; private set; } = new() { Sex.Male, Sex.Female };
+ public List Sexes { get; private set; } = new() { Sex.Male, Sex.Female, Sex.Futanari }; // Arcane-edit
///
/// Characters younger than this are too young to be hired by Nanotrasen.
diff --git a/Content.Shared/Humanoid/Sex.cs b/Content.Shared/Humanoid/Sex.cs
index 8c40c6fa191..d0aa1e63790 100644
--- a/Content.Shared/Humanoid/Sex.cs
+++ b/Content.Shared/Humanoid/Sex.cs
@@ -24,6 +24,7 @@ public enum Sex : byte
Male,
Female,
Unsexed,
+ Futanari, // Arcane-edit
}
///
diff --git a/Content.Shared/Preferences/HumanoidCharacterProfile.cs b/Content.Shared/Preferences/HumanoidCharacterProfile.cs
index 5bb464e9ed0..ebee454b8ef 100644
--- a/Content.Shared/Preferences/HumanoidCharacterProfile.cs
+++ b/Content.Shared/Preferences/HumanoidCharacterProfile.cs
@@ -58,6 +58,7 @@
using Content.Shared.Preferences.Loadouts;
using Content.Shared.Roles;
using Content.Goobstation.Common.Barks; // Goob Station - Barks
+using Content.Shared._Arcane.ERP; // Arcane-edit
using Content.Shared.Traits;
using Robust.Shared.Collections;
using Robust.Shared.Configuration;
@@ -155,6 +156,11 @@ public sealed partial class HumanoidCharacterProfile : ICharacterProfile
public string NsfwTagsFlavorText { get; set; } = string.Empty;
// Orion-End
+ // Arcane-Start
+ [DataField]
+ public ErpPreference ErpPreference { get; set; } = ErpPreference.Ask;
+ // Arcane-End
+
///
/// Associated for this profile.
///
@@ -327,6 +333,7 @@ public HumanoidCharacterProfile(HumanoidCharacterProfile other)
new Dictionary(other.Loadouts),
other.BarkVoice) // Goob Station - Barks
{
+ ErpPreference = other.ErpPreference; // Arcane-edit
}
///
@@ -539,6 +546,13 @@ public HumanoidCharacterProfile WithBarkVoice(BarkPrototype barkVoice)
}
// Goob Station - Barks End
+ // Arcane-Start
+ public HumanoidCharacterProfile WithErpPreference(ErpPreference preference)
+ {
+ return new(this) { ErpPreference = preference };
+ }
+ // Arcane-End
+
public HumanoidCharacterProfile WithJobPriorities(IEnumerable, JobPriority>> jobPriorities)
{
var dictionary = new Dictionary, JobPriority>(jobPriorities);
@@ -721,6 +735,7 @@ public bool MemberwiseEquals(ICharacterProfile maybeOther)
if (NsfwLinksFlavorText != other.NsfwLinksFlavorText) return false;
if (NsfwTagsFlavorText != other.NsfwTagsFlavorText) return false;
// Orion-End
+ if (ErpPreference != other.ErpPreference) return false; // Arcane-edit
return Appearance.MemberwiseEquals(other.Appearance);
}
@@ -740,6 +755,7 @@ public void EnsureValid(ICommonSession session, IDependencyCollection collection
Sex.Male => Sex.Male,
Sex.Female => Sex.Female,
Sex.Unsexed => Sex.Unsexed,
+ Sex.Futanari => Sex.Futanari, // Arcane-edit
_ => Sex.Male // Invalid enum values.
};
@@ -947,6 +963,14 @@ public void EnsureValid(ICommonSession session, IDependencyCollection collection
_ => PreferenceUnavailableMode.StayInLobby // Invalid enum values.
};
+ var erpPreference = ErpPreference switch
+ {
+ ErpPreference.Yes => ErpPreference.Yes,
+ ErpPreference.Ask => ErpPreference.Ask,
+ ErpPreference.No => ErpPreference.No,
+ _ => ErpPreference.Ask // Invalid enum values.
+ };
+
var spawnPriority = SpawnPriority switch
{
SpawnPriorityPreference.None => SpawnPriorityPreference.None,
@@ -1006,6 +1030,7 @@ public void EnsureValid(ICommonSession session, IDependencyCollection collection
Gender = gender;
Appearance = appearance;
SpawnPriority = spawnPriority;
+ ErpPreference = erpPreference; // Arcane-edit
_jobPriorities.Clear();
@@ -1141,6 +1166,7 @@ public override int GetHashCode()
hashCode.Add(BarkVoice); // Goob Station - Barks
hashCode.Add((int) SpawnPriority);
hashCode.Add((int) PreferenceUnavailable);
+ hashCode.Add((int) ErpPreference); // Arcane-edit
return hashCode.ToHashCode();
}
diff --git a/Content.Shared/_Arcane/ERP/ArousalComponent.cs b/Content.Shared/_Arcane/ERP/ArousalComponent.cs
new file mode 100644
index 00000000000..638cf006c46
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/ArousalComponent.cs
@@ -0,0 +1,98 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared._Arcane.ERP;
+
+public enum ArousalPhase : byte
+{
+ Calm,
+ Interested,
+ Aroused,
+ Heated,
+ Peak,
+}
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
+public sealed partial class ArousalComponent : Component
+{
+ ///
+ /// Authoritative arousal value as of LastChangeTime.
+ /// Use ArousalSystem.GetArousal to get the current value accounting for decay.
+ ///
+ [AutoNetworkedField]
+ public float LastValue;
+
+ ///
+ /// Time at which LastValue was last set.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField]
+ public TimeSpan LastChangeTime;
+
+ ///
+ /// Passive decay per second.
+ ///
+ [DataField, AutoNetworkedField]
+ public float DecayRate = 0.3f;
+
+ [DataField]
+ public float MaxArousal = 100f;
+
+ ///
+ /// Registered passive arousal sources. Key is source ID, value is gain per second.
+ /// Use ArousalSystem.SetPassiveSource and ArousalSystem.RemovePassiveSource.
+ ///
+ public Dictionary PassiveSources = [];
+
+ ///
+ /// Total passive gain per second — sum of all .
+ ///
+ public float PassiveGainRate
+ {
+ get
+ {
+ var total = 0f;
+ foreach (var rate in PassiveSources.Values)
+ total += rate;
+ return total;
+ }
+ }
+
+ ///
+ /// When to next check for phase transitions from passive decay.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoNetworkedField, AutoPausedField]
+ public TimeSpan NextPhaseCheckAt;
+
+ [AutoNetworkedField]
+ public ArousalPhase CurrentPhase;
+
+ ///
+ /// When the last orgasm occurred.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoPausedField]
+ public TimeSpan LastOrgasmAt;
+
+ ///
+ /// Arousal gain is blocked until this time (refractory period after orgasm).
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoNetworkedField, AutoPausedField]
+ public TimeSpan RefractoryUntil;
+
+ ///
+ /// How long the refractory period lasts after orgasm.
+ ///
+ [DataField]
+ public TimeSpan RefractoryDuration = TimeSpan.FromSeconds(90);
+
+ public ArousalPhase ComputePhase(float arousal) => arousal switch
+ {
+ < 20f => ArousalPhase.Calm,
+ < 40f => ArousalPhase.Interested,
+ < 70f => ArousalPhase.Aroused,
+ < 95f => ArousalPhase.Heated,
+ _ => ArousalPhase.Peak,
+ };
+}
diff --git a/Content.Shared/_Arcane/ERP/ArousalEvents.cs b/Content.Shared/_Arcane/ERP/ArousalEvents.cs
new file mode 100644
index 00000000000..4c16a063991
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/ArousalEvents.cs
@@ -0,0 +1,20 @@
+namespace Content.Shared._Arcane.ERP;
+
+public sealed class ArousedEvent(float before, float after) : EntityEventArgs
+{
+ public readonly float Before = before;
+ public readonly float After = after;
+ public float GetDelta()
+ {
+ return After - Before;
+ }
+}
+
+public sealed class ArousalPhaseChangedEvent(ArousalPhase previous, ArousalPhase current) : EntityEventArgs
+{
+ public ArousalPhase Previous = previous;
+ public ArousalPhase Current = current;
+}
+
+[ByRefEvent]
+public record struct ArousalOrgasmEvent;
diff --git a/Content.Shared/_Arcane/ERP/EroticOrganSpawnSystem.cs b/Content.Shared/_Arcane/ERP/EroticOrganSpawnSystem.cs
new file mode 100644
index 00000000000..744468764af
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/EroticOrganSpawnSystem.cs
@@ -0,0 +1,127 @@
+using Content.Shared._Arcane.ERP.Organs;
+using Content.Shared._Shitmed.Humanoid.Events;
+using Content.Shared.Body.Organ;
+using Content.Shared.Body.Part;
+using Content.Shared.Body.Systems;
+using Content.Shared.Humanoid;
+using Robust.Shared.Containers;
+using Robust.Shared.Network;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Arcane.ERP;
+
+public sealed class EroticOrganSpawnSystem : EntitySystem
+{
+ [Dependency] private readonly SharedBodySystem _body = default!;
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly SharedContainerSystem _containers = default!;
+ [Dependency] private readonly INetManager _net = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ // Run after SharedBodySystem so body parts are already spawned when we look for them.
+ SubscribeLocalEvent(OnMapInit, after: [typeof(SharedBodySystem)]);
+ SubscribeLocalEvent(OnProfileLoaded);
+ SubscribeLocalEvent(OnSexChanged);
+ }
+
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ if (!_net.IsServer)
+ return;
+
+ if (!TryComp(ent, out var humanoid))
+ return;
+
+ SpawnEroticOrgans(ent, ent.Comp, humanoid.Sex);
+ }
+
+ private void OnProfileLoaded(Entity ent, ref ProfileLoadFinishedEvent args)
+ {
+ if (!_net.IsServer)
+ return;
+
+ if (!TryComp(ent, out var humanoid))
+ return;
+
+ RemoveEroticOrgans(ent);
+ SpawnEroticOrgans(ent, ent.Comp, humanoid.Sex);
+ }
+
+ private void OnSexChanged(Entity ent, ref SexChangedEvent args)
+ {
+ if (!_net.IsServer)
+ return;
+
+ RemoveEroticOrgans(ent);
+ SpawnEroticOrgans(ent, ent.Comp, args.NewSex);
+ }
+
+ private void SpawnEroticOrgans(EntityUid uid, EroticOrgansComponent def, Sex sex)
+ {
+ if (sex == Sex.Unsexed)
+ return;
+
+ var groin = GetBodyPartOfType(uid, BodyPartType.Groin);
+ var chest = GetBodyPartOfType(uid, BodyPartType.Chest);
+
+ if (groin.HasValue)
+ {
+ TrySpawnOrgans(uid, groin.Value, def.GroinCommon);
+
+ if (sex is Sex.Male or Sex.Futanari)
+ TrySpawnOrgans(uid, groin.Value, def.GroinMale);
+
+ if (sex is Sex.Female or Sex.Futanari)
+ TrySpawnOrgans(uid, groin.Value, def.GroinFemale);
+ }
+
+ if (chest.HasValue && sex is Sex.Female or Sex.Futanari)
+ TrySpawnOrgans(uid, chest.Value, def.ChestFemale);
+
+ var ev = new EroticOrgansSpawnedEvent();
+ RaiseLocalEvent(uid, ref ev);
+ }
+
+ private void RemoveEroticOrgans(EntityUid bodyUid)
+ {
+ var organs = _body.GetBodyOrganEntityComps((bodyUid, null));
+ foreach (var organ in organs)
+ {
+ _body.RemoveOrgan(organ.Owner, organ.Comp2);
+ QueueDel(organ.Owner);
+ }
+ }
+
+ private void TrySpawnOrgans(EntityUid bodyUid, EntityUid partUid, List organs)
+ {
+ foreach (var entry in organs)
+ TrySpawnOrgan(bodyUid, partUid, entry.Proto, entry.Slot);
+ }
+
+ private void TrySpawnOrgan(EntityUid bodyUid, EntityUid partUid, EntProtoId protoId, string slotId)
+ {
+ if (!_proto.HasIndex(protoId))
+ return;
+
+ _body.TryCreateOrganSlot(partUid, slotId, out _);
+
+ var containerId = SharedBodySystem.GetOrganContainerId(slotId);
+ if (_containers.TryGetContainer(partUid, containerId, out var container)
+ && container.ContainedEntities.Count > 0)
+ return;
+
+ var organEnt = Spawn(protoId, Transform(partUid).Coordinates);
+ if (!_body.InsertOrgan(partUid, organEnt, slotId))
+ QueueDel(organEnt);
+ }
+
+ private EntityUid? GetBodyPartOfType(EntityUid bodyUid, BodyPartType partType)
+ {
+ foreach (var (partUid, _) in _body.GetBodyChildrenOfType(bodyUid, partType))
+ return partUid;
+
+ return null;
+ }
+}
diff --git a/Content.Shared/_Arcane/ERP/EroticOrgansSpawnedEvent.cs b/Content.Shared/_Arcane/ERP/EroticOrgansSpawnedEvent.cs
new file mode 100644
index 00000000000..3f13079c57c
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/EroticOrgansSpawnedEvent.cs
@@ -0,0 +1,4 @@
+namespace Content.Shared._Arcane.ERP;
+
+[ByRefEvent]
+public record struct EroticOrgansSpawnedEvent;
diff --git a/Content.Shared/_Arcane/ERP/Erp.cs b/Content.Shared/_Arcane/ERP/Erp.cs
new file mode 100644
index 00000000000..c95e0c41794
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/Erp.cs
@@ -0,0 +1,14 @@
+namespace Content.Shared._Arcane.ERP;
+
+public enum ErpPreference : byte
+{
+ Yes,
+ Ask,
+ No,
+}
+
+public sealed class ErpPreferenceChangedEvent(ErpPreference oldPreference, ErpPreference newPreference) : EntityEventArgs
+{
+ public ErpPreference OldPreference = oldPreference;
+ public ErpPreference NewPreference = newPreference;
+}
diff --git a/Content.Shared/_Arcane/ERP/ErpStatusComponent.cs b/Content.Shared/_Arcane/ERP/ErpStatusComponent.cs
new file mode 100644
index 00000000000..d535309dd4e
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/ErpStatusComponent.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Arcane.ERP;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class ErpStatusComponent : Component
+{
+ [DataField, AutoNetworkedField]
+ public ErpPreference Preference = ErpPreference.Ask;
+}
diff --git a/Content.Shared/_Arcane/ERP/Organs/EroticOrganComponent.cs b/Content.Shared/_Arcane/ERP/Organs/EroticOrganComponent.cs
new file mode 100644
index 00000000000..d324414c042
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/Organs/EroticOrganComponent.cs
@@ -0,0 +1,26 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Arcane.ERP.Organs;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class EroticOrganComponent : Component
+{
+ ///
+ /// Base sensitivity multiplier for arousal calculations. 1.0 = normal.
+ ///
+ [DataField, AutoNetworkedField]
+ public float Sensitivity = 1.0f;
+
+ ///
+ /// Size modifier. 1.0 = average. Used in interaction condition checks and descriptions.
+ ///
+ [DataField, AutoNetworkedField]
+ public float Size = 1.0f;
+
+ ///
+ /// Whether this organ is currently exposed (not covered by clothing).
+ /// Managed by the clothing coverage system.
+ ///
+ [AutoNetworkedField]
+ public bool Visible = true;
+}
diff --git a/Content.Shared/_Arcane/ERP/Organs/EroticOrganTypes.cs b/Content.Shared/_Arcane/ERP/Organs/EroticOrganTypes.cs
new file mode 100644
index 00000000000..eccf90e7309
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/Organs/EroticOrganTypes.cs
@@ -0,0 +1,24 @@
+namespace Content.Shared._Arcane.ERP.Organs;
+
+// Marker components — each is a tag for interaction condition checks.
+// All sexes
+[RegisterComponent]
+public sealed partial class AnusOrganComponent : Component;
+
+// Male + Futanari
+[RegisterComponent]
+public sealed partial class PenisOrganComponent : Component;
+
+[RegisterComponent]
+public sealed partial class TesticlesOrganComponent : Component;
+
+// Female + Futanari
+[RegisterComponent]
+public sealed partial class VaginaOrganComponent : Component;
+
+[RegisterComponent]
+public sealed partial class UterusOrganComponent : Component;
+
+// Female + Futanari
+[RegisterComponent]
+public sealed partial class BreastsOrganComponent : Component;
diff --git a/Content.Shared/_Arcane/ERP/Organs/EroticOrgansComponent.cs b/Content.Shared/_Arcane/ERP/Organs/EroticOrgansComponent.cs
new file mode 100644
index 00000000000..9d3beb25b54
--- /dev/null
+++ b/Content.Shared/_Arcane/ERP/Organs/EroticOrgansComponent.cs
@@ -0,0 +1,41 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Arcane.ERP.Organs;
+
+[RegisterComponent]
+public sealed partial class EroticOrgansComponent : Component
+{
+ ///
+ /// Organs spawned for all sexes in the groin slot.
+ ///
+ [DataField]
+ public List GroinCommon = [];
+
+ ///
+ /// Organs spawned for Male and Futanari in the groin slot.
+ ///
+ [DataField]
+ public List GroinMale = [];
+
+ ///
+ /// Organs spawned for Female and Futanari in the groin slot.
+ ///
+ [DataField]
+ public List GroinFemale = [];
+
+ ///
+ /// Organs spawned for Female and Futanari in the chest slot.
+ ///
+ [DataField]
+ public List ChestFemale = [];
+}
+
+[DataDefinition]
+public sealed partial class EroticOrganEntry
+{
+ [DataField(required: true)]
+ public EntProtoId Proto = default!;
+
+ [DataField(required: true)]
+ public string Slot = default!;
+}
diff --git a/Content.Shared/_Shitmed/Surgery/Pain/Components/NerveSystemComponent.cs b/Content.Shared/_Shitmed/Surgery/Pain/Components/NerveSystemComponent.cs
index 3b98c2a2549..d9eb9bf5dd4 100644
--- a/Content.Shared/_Shitmed/Surgery/Pain/Components/NerveSystemComponent.cs
+++ b/Content.Shared/_Shitmed/Surgery/Pain/Components/NerveSystemComponent.cs
@@ -102,6 +102,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default.WithVariation(0.2f),
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("PainScreamsShortFemale")
+ {
+ Params = AudioParams.Default.WithVariation(0.04f),
+ }
+ },
+ // Arcane-end
};
[DataField]
@@ -125,6 +133,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default.WithVariation(0.2f),
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("AgonyScreamsFemale")
+ {
+ Params = AudioParams.Default.WithVariation(0.04f),
+ }
+ },
+ // Arcane-end
};
[DataField]
@@ -148,6 +164,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default.WithVariation(0.2f),
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("PainShockScreamsFemale")
+ {
+ Params = AudioParams.Default.WithVariation(0.05f),
+ }
+ },
+ // Arcane-end
};
[DataField]
@@ -171,6 +195,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default,
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("CritWhimpersFemale")
+ {
+ Params = AudioParams.Default,
+ }
+ },
+ // Arcane-end
};
[DataField]
@@ -194,6 +226,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default,
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("PainShockWhimpersFemale")
+ {
+ Params = AudioParams.Default,
+ }
+ },
+ // Arcane-end
};
[DataField]
@@ -217,6 +257,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default,
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("OrganDamagePainedFemale")
+ {
+ Params = AudioParams.Default,
+ }
+ },
+ // Arcane-end
};
[DataField]
@@ -240,6 +288,14 @@ public sealed partial class NerveSystemComponent : Component
Params = AudioParams.Default,
}
},
+ // Arcane-start
+ {
+ Sex.Futanari, new SoundCollectionSpecifier("OrganDamageWhimpersFemale")
+ {
+ Params = AudioParams.Default,
+ }
+ },
+ // Arcane-end
};
[DataField("reflexThresholds"), ViewVariables(VVAccess.ReadOnly)]
diff --git a/Resources/Locale/en-US/_Arcane/ERP/erp.ftl b/Resources/Locale/en-US/_Arcane/ERP/erp.ftl
new file mode 100644
index 00000000000..6db28216ca6
--- /dev/null
+++ b/Resources/Locale/en-US/_Arcane/ERP/erp.ftl
@@ -0,0 +1,16 @@
+humanoid-profile-editor-erp-preference-label = ERP
+humanoid-profile-editor-erp-preference-yes = Yes
+humanoid-profile-editor-erp-preference-ask = Ask
+humanoid-profile-editor-erp-preference-no = Disabled
+
+erp-examine-verb-text = ERP Status
+erp-examine-verb-disabled = Too far away
+erp-examine-status-header = ERP status:
+erp-examine-status-yes = [color=#44ff44]Yes[/color]
+erp-examine-status-ask = [color=#ffcc00]Ask first[/color]
+erp-examine-status-no = [color=#ff4444]Disabled[/color]
+
+alerts-arousal-aroused-name = Aroused
+alerts-arousal-aroused-desc = You feel aroused.
+alerts-arousal-heated-name = Heated
+alerts-arousal-heated-desc = You feel very aroused.
diff --git a/Resources/Locale/en-US/_Arcane/preferences/humanoid-profile-editor.ftl b/Resources/Locale/en-US/_Arcane/preferences/humanoid-profile-editor.ftl
new file mode 100644
index 00000000000..f1dc78f1553
--- /dev/null
+++ b/Resources/Locale/en-US/_Arcane/preferences/humanoid-profile-editor.ftl
@@ -0,0 +1 @@
+humanoid-profile-editor-sex-futanari-text = Futanari
diff --git a/Resources/Locale/ru-RU/_Arcane/ERP/erp.ftl b/Resources/Locale/ru-RU/_Arcane/ERP/erp.ftl
new file mode 100644
index 00000000000..c1f8030f6e9
--- /dev/null
+++ b/Resources/Locale/ru-RU/_Arcane/ERP/erp.ftl
@@ -0,0 +1,16 @@
+humanoid-profile-editor-erp-preference-label = ERP
+humanoid-profile-editor-erp-preference-yes = Да
+humanoid-profile-editor-erp-preference-ask = Спрашивать
+humanoid-profile-editor-erp-preference-no = Отключено
+
+erp-examine-verb-text = ERP-статус
+erp-examine-verb-disabled = Слишком далеко
+erp-examine-status-header = ERP-статус:
+erp-examine-status-yes = [color=#44ff44]Да[/color]
+erp-examine-status-ask = [color=#ffcc00]Спрашивать[/color]
+erp-examine-status-no = [color=#ff4444]Отключено[/color]
+
+alerts-arousal-aroused-name = Возбуждён
+alerts-arousal-aroused-desc = Вы чувствуете возбуждение.
+alerts-arousal-heated-name = Сильное возбуждение
+alerts-arousal-heated-desc = Вы сильно возбуждены.
diff --git a/Resources/Locale/ru-RU/_Arcane/body/Organs/erotic.ftl b/Resources/Locale/ru-RU/_Arcane/body/Organs/erotic.ftl
new file mode 100644
index 00000000000..97ec4727dbb
--- /dev/null
+++ b/Resources/Locale/ru-RU/_Arcane/body/Organs/erotic.ftl
@@ -0,0 +1,17 @@
+ent-OrganAnus = анус
+ .desc = Внутренняя мышца-сфинктер.
+
+ent-OrganPenis = пенис
+ .desc = Мужской половой орган.
+
+ent-OrganTesticles = яички
+ .desc = Мужские половые железы.
+
+ent-OrganVagina = вагина
+ .desc = Женский половой орган.
+
+ent-OrganUterus = матка
+ .desc = Женский репродуктивный орган.
+
+ent-OrganBreasts = грудь
+ .desc = Молочные железы.
diff --git a/Resources/Locale/ru-RU/_Arcane/preferences/humanoid-profile-editor.ftl b/Resources/Locale/ru-RU/_Arcane/preferences/humanoid-profile-editor.ftl
new file mode 100644
index 00000000000..70a014ac235
--- /dev/null
+++ b/Resources/Locale/ru-RU/_Arcane/preferences/humanoid-profile-editor.ftl
@@ -0,0 +1,4 @@
+humanoid-profile-editor-sex-male-text = Мужской
+humanoid-profile-editor-sex-female-text = Женский
+humanoid-profile-editor-sex-unsexed-text = Отсутствует
+humanoid-profile-editor-sex-futanari-text = Футанари
diff --git a/Resources/Locale/ru-RU/preferences/ui/humanoid-profile-editor.ftl b/Resources/Locale/ru-RU/preferences/ui/humanoid-profile-editor.ftl
index 77f2f665a1e..8902ce72285 100644
--- a/Resources/Locale/ru-RU/preferences/ui/humanoid-profile-editor.ftl
+++ b/Resources/Locale/ru-RU/preferences/ui/humanoid-profile-editor.ftl
@@ -5,9 +5,6 @@ humanoid-profile-editor-appearance-tab = Внешность
humanoid-profile-editor-clothing = Отображать одежду
humanoid-profile-editor-clothing-show = Переключить
humanoid-profile-editor-sex-label = Пол:
-humanoid-profile-editor-sex-male-text = Мужской
-humanoid-profile-editor-sex-female-text = Женский
-humanoid-profile-editor-sex-unsexed-text = Отсутствует
humanoid-profile-editor-age-label = Возраст:
humanoid-profile-editor-skin-color-label = Цвет кожи:
humanoid-profile-editor-species-label = Раса:
diff --git a/Resources/Prototypes/Entities/Mobs/Species/human.yml b/Resources/Prototypes/Entities/Mobs/Species/human.yml
index 044dbbb1cf3..38634787f01 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/human.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml
@@ -152,6 +152,25 @@
state: jumpsuit-female
- type: Sprinter # Goob Change: human major
staminaDrainRate: 8
+ # Arcane-start
+ - type: EroticOrgans
+ groinCommon:
+ - proto: OrganAnus
+ slot: anus
+ groinMale:
+ - proto: OrganPenis
+ slot: penis
+ - proto: OrganTesticles
+ slot: testicles
+ groinFemale:
+ - proto: OrganVagina
+ slot: vagina
+ - proto: OrganUterus
+ slot: uterus
+ chestFemale:
+ - proto: OrganBreasts
+ slot: breasts
+ # Arcane-end
- type: entity
diff --git a/Resources/Prototypes/_Arcane/Alerts/arousal.yml b/Resources/Prototypes/_Arcane/Alerts/arousal.yml
new file mode 100644
index 00000000000..12ec2034dbd
--- /dev/null
+++ b/Resources/Prototypes/_Arcane/Alerts/arousal.yml
@@ -0,0 +1,20 @@
+- type: alertCategory
+ id: Arousal
+
+- type: alert
+ id: ArousalAroused
+ category: Arousal
+ icons:
+ - sprite: /Textures/Interface/Alerts/hunger.rsi
+ state: peckish
+ name: alerts-arousal-aroused-name
+ description: alerts-arousal-aroused-desc
+
+- type: alert
+ id: ArousalHeated
+ category: Arousal
+ icons:
+ - sprite: /Textures/Interface/Alerts/hunger.rsi
+ state: starving
+ name: alerts-arousal-heated-name
+ description: alerts-arousal-heated-desc
diff --git a/Resources/Prototypes/_Arcane/Body/Organs/erotic.yml b/Resources/Prototypes/_Arcane/Body/Organs/erotic.yml
new file mode 100644
index 00000000000..0031d56dcf1
--- /dev/null
+++ b/Resources/Prototypes/_Arcane/Body/Organs/erotic.yml
@@ -0,0 +1,125 @@
+- type: entity
+ id: BaseEroticOrgan
+ parent: BaseHumanOrgan
+ abstract: true
+ components:
+ - type: Organ
+ canEnable: true
+ removable: false
+
+# Universal (all sexes)
+# Internal muscle — no integrity display in health scanner.
+- type: entity
+ id: OrganAnus
+ parent: BaseEroticOrgan
+ name: anus
+ description: An internal sphincter muscle.
+ components:
+ - type: Sprite
+ sprite: _Arcane/Mobs/organs/anus.rsi
+ state: default
+ - type: Organ
+ slotId: anus
+ removable: false
+ - type: EroticOrgan
+ sensitivity: 1.2
+ - type: AnusOrgan
+
+# Male / Futanari
+# External organ — shows in health scanner.
+- type: entity
+ id: OrganPenis
+ parent: BaseEroticOrgan
+ name: penis
+ description: A male genital organ.
+ components:
+ - type: Sprite
+ sprite: _Arcane/Mobs/organs/penis.rsi
+ state: default
+ - type: Organ
+ intCap: 15
+ integrity: 15
+ integrityThresholds:
+ Normal: 15
+ Damaged: 6
+ Destroyed: 0
+ slotId: penis
+ removable: false
+ - type: EroticOrgan
+ sensitivity: 1.5
+ - type: PenisOrgan
+
+# Internal gonads — no integrity display in health scanner.
+- type: entity
+ id: OrganTesticles
+ parent: BaseEroticOrgan
+ name: testicles
+ description: Male gonads.
+ components:
+ - type: Sprite
+ sprite: _Arcane/Mobs/organs/testicles.rsi
+ state: default
+ - type: Organ
+ slotId: testicles
+ removable: false
+ - type: EroticOrgan
+ sensitivity: 1.0
+ - type: TesticlesOrgan
+
+# Female / Futanari
+# Mostly internal — no integrity display in health scanner.
+- type: entity
+ id: OrganVagina
+ parent: BaseEroticOrgan
+ name: vagina
+ description: A female genital organ.
+ components:
+ - type: Sprite
+ sprite: _Arcane/Mobs/organs/vagina.rsi
+ state: default
+ - type: Organ
+ slotId: vagina
+ removable: false
+ - type: EroticOrgan
+ sensitivity: 1.5
+ - type: VaginaOrgan
+
+# Deeply internal — no integrity display in health scanner.
+- type: entity
+ id: OrganUterus
+ parent: BaseEroticOrgan
+ name: uterus
+ description: A female reproductive organ.
+ components:
+ - type: Sprite
+ sprite: _Arcane/Mobs/organs/uterus.rsi
+ state: default
+ - type: Organ
+ slotId: uterus
+ removable: false
+ - type: EroticOrgan
+ sensitivity: 0.8
+ - type: UterusOrgan
+
+# External tissue — shows in health scanner.
+- type: entity
+ id: OrganBreasts
+ parent: BaseEroticOrgan
+ name: breasts
+ description: Mammary glands.
+ components:
+ - type: Sprite
+ sprite: _Arcane/Mobs/organs/breasts.rsi
+ state: default
+ - type: Organ
+ intCap: 15
+ integrity: 15
+ integrityThresholds:
+ Normal: 15
+ Damaged: 6
+ Destroyed: 0
+ slotId: breasts
+ removable: false
+ - type: EroticOrgan
+ sensitivity: 1.1
+ - type: BreastsOrgan
diff --git a/Resources/Prototypes/_Arcane/Species/demon.yml b/Resources/Prototypes/_Arcane/Species/demon.yml
index a9f5bd34ec6..2ca0e9ff34d 100644
--- a/Resources/Prototypes/_Arcane/Species/demon.yml
+++ b/Resources/Prototypes/_Arcane/Species/demon.yml
@@ -9,7 +9,7 @@
dollPrototype: MobDemonDummy
skinColoration: Hues
naming: firstlast
- sexes: [Male, Female]
+ sexes: [Male, Female, Futanari]
category: Unusual
description: "/ServerInfo/_Arcane/SpeciesDescriptions/Demon.xml"
pros:
diff --git a/Resources/Prototypes/_EinsteinEngines/Species/shadowkin.yml b/Resources/Prototypes/_EinsteinEngines/Species/shadowkin.yml
index 95b26d124f3..580d0cc0b85 100644
--- a/Resources/Prototypes/_EinsteinEngines/Species/shadowkin.yml
+++ b/Resources/Prototypes/_EinsteinEngines/Species/shadowkin.yml
@@ -24,6 +24,7 @@
sexes:
- Male
- Female
+ - Futanari # Arcane-edit
# Orion-Start
category: Unusual
description: "/ServerInfo/_Orion/SpeciesDescriptions/Shadekin.xml"
diff --git a/Resources/Prototypes/_Floofstation/Species/resomi.yml b/Resources/Prototypes/_Floofstation/Species/resomi.yml
index b08a93e547c..f4048a12cae 100644
--- a/Resources/Prototypes/_Floofstation/Species/resomi.yml
+++ b/Resources/Prototypes/_Floofstation/Species/resomi.yml
@@ -24,6 +24,7 @@
- Unsexed
- Male
- Female
+ - Futanari # Arcane-edit
# Orion-Start
category: Classic
description: "/ServerInfo/_Orion/SpeciesDescriptions/Resomi.xml"
diff --git a/Resources/Textures/_Arcane/Interface/heartIcon.png b/Resources/Textures/_Arcane/Interface/heartIcon.png
new file mode 100644
index 00000000000..2b84bd40f36
Binary files /dev/null and b/Resources/Textures/_Arcane/Interface/heartIcon.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/anus.rsi/default.png b/Resources/Textures/_Arcane/Mobs/organs/anus.rsi/default.png
new file mode 100644
index 00000000000..880fb824f30
Binary files /dev/null and b/Resources/Textures/_Arcane/Mobs/organs/anus.rsi/default.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/anus.rsi/meta.json b/Resources/Textures/_Arcane/Mobs/organs/anus.rsi/meta.json
new file mode 100644
index 00000000000..76917f4cfbb
--- /dev/null
+++ b/Resources/Textures/_Arcane/Mobs/organs/anus.rsi/meta.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from SPLURT-Station-13",
+ "states": [
+ {
+ "name": "default",
+ "directions": 1
+ }
+ ]
+}
diff --git a/Resources/Textures/_Arcane/Mobs/organs/breasts.rsi/default.png b/Resources/Textures/_Arcane/Mobs/organs/breasts.rsi/default.png
new file mode 100644
index 00000000000..c821cb310e6
Binary files /dev/null and b/Resources/Textures/_Arcane/Mobs/organs/breasts.rsi/default.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/breasts.rsi/meta.json b/Resources/Textures/_Arcane/Mobs/organs/breasts.rsi/meta.json
new file mode 100644
index 00000000000..76917f4cfbb
--- /dev/null
+++ b/Resources/Textures/_Arcane/Mobs/organs/breasts.rsi/meta.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from SPLURT-Station-13",
+ "states": [
+ {
+ "name": "default",
+ "directions": 1
+ }
+ ]
+}
diff --git a/Resources/Textures/_Arcane/Mobs/organs/penis.rsi/default.png b/Resources/Textures/_Arcane/Mobs/organs/penis.rsi/default.png
new file mode 100644
index 00000000000..97f2eeafbd1
Binary files /dev/null and b/Resources/Textures/_Arcane/Mobs/organs/penis.rsi/default.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/penis.rsi/meta.json b/Resources/Textures/_Arcane/Mobs/organs/penis.rsi/meta.json
new file mode 100644
index 00000000000..76917f4cfbb
--- /dev/null
+++ b/Resources/Textures/_Arcane/Mobs/organs/penis.rsi/meta.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from SPLURT-Station-13",
+ "states": [
+ {
+ "name": "default",
+ "directions": 1
+ }
+ ]
+}
diff --git a/Resources/Textures/_Arcane/Mobs/organs/testicles.rsi/default.png b/Resources/Textures/_Arcane/Mobs/organs/testicles.rsi/default.png
new file mode 100644
index 00000000000..89818253124
Binary files /dev/null and b/Resources/Textures/_Arcane/Mobs/organs/testicles.rsi/default.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/testicles.rsi/meta.json b/Resources/Textures/_Arcane/Mobs/organs/testicles.rsi/meta.json
new file mode 100644
index 00000000000..76917f4cfbb
--- /dev/null
+++ b/Resources/Textures/_Arcane/Mobs/organs/testicles.rsi/meta.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from SPLURT-Station-13",
+ "states": [
+ {
+ "name": "default",
+ "directions": 1
+ }
+ ]
+}
diff --git a/Resources/Textures/_Arcane/Mobs/organs/uterus.rsi/default.png b/Resources/Textures/_Arcane/Mobs/organs/uterus.rsi/default.png
new file mode 100644
index 00000000000..fa89796875c
Binary files /dev/null and b/Resources/Textures/_Arcane/Mobs/organs/uterus.rsi/default.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/uterus.rsi/meta.json b/Resources/Textures/_Arcane/Mobs/organs/uterus.rsi/meta.json
new file mode 100644
index 00000000000..76917f4cfbb
--- /dev/null
+++ b/Resources/Textures/_Arcane/Mobs/organs/uterus.rsi/meta.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from SPLURT-Station-13",
+ "states": [
+ {
+ "name": "default",
+ "directions": 1
+ }
+ ]
+}
diff --git a/Resources/Textures/_Arcane/Mobs/organs/vagina.rsi/default.png b/Resources/Textures/_Arcane/Mobs/organs/vagina.rsi/default.png
new file mode 100644
index 00000000000..3886ba8a031
Binary files /dev/null and b/Resources/Textures/_Arcane/Mobs/organs/vagina.rsi/default.png differ
diff --git a/Resources/Textures/_Arcane/Mobs/organs/vagina.rsi/meta.json b/Resources/Textures/_Arcane/Mobs/organs/vagina.rsi/meta.json
new file mode 100644
index 00000000000..76917f4cfbb
--- /dev/null
+++ b/Resources/Textures/_Arcane/Mobs/organs/vagina.rsi/meta.json
@@ -0,0 +1,15 @@
+{
+ "version": 1,
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from SPLURT-Station-13",
+ "states": [
+ {
+ "name": "default",
+ "directions": 1
+ }
+ ]
+}