Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions Content.Omu.Server/Enraged/EnragedBypassPrediction.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using System.Numerics;
using Content.Omu.Shared.Enraged;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Map;
using Content.Shared.Mind.Components;
using Content.Shared.StatusEffectNew;
using Content.Shared.StatusEffectNew.Components;
using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Melee.Components;
using Content.Shared.Weapons.Melee.Events;
using Robust.Shared.Player;

namespace Content.Omu.Server.Enraged;

/// <summary>
/// Handles melee attack sounds and lunge animation for enraged entities.
/// Resends animations to the attacking player to bypass client-side prediction.
/// Without this the client will not show the lunge animation as the server is making the attacks.
/// </summary>
public sealed class EnragedBypassPrediction : EntitySystem
{
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;

private const float LungeBuffer = 0.2f;
private const float MinLungeLength = 0.1f;
private const float DamagePitchVariation = 0.05f;

public void OnMeleeHit(Entity<MeleeWeaponComponent> weapon, ref MeleeHitEvent args)
{
if (!args.IsHit || !IsEnraged(args.User))
return;

if (!TryComp<ActorComponent>(args.User, out var actor) ||
!TryComp(args.User, out TransformComponent? userXform))
return;

var lungeOffset = ComputeLungeOffset(weapon.Comp, args.Coords, userXform);
var (animation, spriteRotation) = GetMeleeAnimation(weapon.Comp, args);

var filter = Filter.SinglePlayer(actor.PlayerSession);
RaiseNetworkEvent(
new MeleeLungeEvent(
GetNetEntity(args.User),
GetNetEntity(weapon.Owner),
weapon.Comp.Angle,
lungeOffset,
animation,
spriteRotation,
weapon.Comp.FlipAnimation),
filter);

SendWeaponAudio(filter, weapon, args);
}

private bool IsEnraged(EntityUid user)
{
if (!TryComp<StatusEffectContainerComponent>(user, out var statusEffects))
return false;

var effects = statusEffects.ActiveStatusEffects?.ContainedEntities;
if (effects == null)
return false;

foreach (var effect in effects)
{
if (HasComp<EnragedStatusEffectComponent>(effect))
return true;
}

return false;
}

private Vector2 ComputeLungeOffset(
MeleeWeaponComponent weapon,
EntityCoordinates hitCoords,
TransformComponent userXform)
{
var mapCoords = _transform.ToMapCoordinates(hitCoords);
var localPos = Vector2.Transform(mapCoords.Position, _transform.GetInvWorldMatrix(userXform));

var visualLength = MathF.Max(weapon.Range - LungeBuffer, weapon.Range);
if (localPos.LengthSquared() <= 0f)
localPos = new Vector2(MathF.Max(visualLength, MinLungeLength), 0f);

localPos = userXform.LocalRotation.RotateVec(localPos);
if (localPos.Length() > visualLength)
localPos = localPos.Normalized() * visualLength;

return localPos;
}

private static (string? Animation, Angle Rotation) GetMeleeAnimation(
MeleeWeaponComponent weapon,
MeleeHitEvent args)
{
if (args.Direction != null)
return (weapon.WideAnimation, weapon.WideAnimationRotation);

var animation = args.HitEntities.Count == 0
? weapon.MissAnimation
: weapon.Animation;

return (animation, weapon.AnimationRotation);
}

private void SendWeaponAudio(
Filter filter,
Entity<MeleeWeaponComponent> weapon,
MeleeHitEvent args)
{
_audio.PlayEntity(weapon.Comp.SwingSound, filter, weapon.Owner, false);

if (args.HitEntities.Count == 0)
return;

var hitSound = args.HitSoundOverride ?? weapon.Comp.HitSound ?? (SoundSpecifier)weapon.Comp.NoDamageSound;
_audio.PlayStatic(hitSound, filter, args.Coords, false,
AudioParams.Default.WithVariation(DamagePitchVariation));
}
}
173 changes: 173 additions & 0 deletions Content.Omu.Server/Enraged/EnragedStatusEffectSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Omu.Shared.Enraged;
using Content.Server.NPC;
using Content.Server.NPC.HTN;
using Content.Server.NPC.Systems;
using Content.Shared.Administration.Logs;
using Content.Shared.Database;
using Content.Shared.NPC.Components;
using Content.Shared.NPC.Systems;
using Content.Shared.SSDIndicator;
using Content.Shared.StatusEffectNew;
using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Melee.Components;
using Content.Shared.Weapons.Melee.Events;

namespace Content.Omu.Server.Enraged;

/// <summary>
/// Swaps NPC factions and HTN tasks, allows HTN to drive players and restores previous state on expiry.
/// </summary>
public sealed partial class EnragedStatusEffectSystem : EntitySystem
{
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly HTNSystem _htn = default!;
[Dependency] private readonly NPCSystem _npc = default!;
[Dependency] private readonly NpcFactionSystem _npcFaction = default!;
[Dependency] private readonly EnragedBypassPrediction _bypass = default!;

public override void Initialize()
{
base.Initialize();

SubscribeLocalEvent<EnragedStatusEffectComponent, StatusEffectAppliedEvent>(OnStatusApplied);
SubscribeLocalEvent<EnragedStatusEffectComponent, StatusEffectRemovedEvent>(OnStatusRemoved);
SubscribeLocalEvent<MeleeWeaponComponent, MeleeHitEvent>(OnMeleeHit);
}

private void OnMeleeHit(Entity<MeleeWeaponComponent> weapon, ref MeleeHitEvent args)
{
_bypass.OnMeleeHit(weapon, ref args);
}

// ──────────────────────────────────────────────
// Status-effect lifecycle
// ──────────────────────────────────────────────

private void OnStatusApplied(Entity<EnragedStatusEffectComponent> ent, ref StatusEffectAppliedEvent args)
{
var target = args.Target;

_adminLogger.Add(LogType.Action, LogImpact.High,
$"{ToPrettyString(target):target} became enraged.");

RemoveSsdIndicator(ent, target);
ApplyHostileFaction(ent, target);
ApplyHostileHtn(ent, target);
}

private void OnStatusRemoved(Entity<EnragedStatusEffectComponent> ent, ref StatusEffectRemovedEvent args)
{
var target = args.Target;

RestoreSsdIndicator(ent, target);
RestoreFaction(ent, target);
RestoreHtn(ent, target);
}

// ──────────────────────────────────────────────
// SSD indicator helpers
// ──────────────────────────────────────────────

private void RemoveSsdIndicator(Entity<EnragedStatusEffectComponent> ent, EntityUid target)
{
if (!TryComp<SSDIndicatorComponent>(target, out _))
return;

RemComp<SSDIndicatorComponent>(target);
ent.Comp.RemovedSsdIndicator = true;
}

private void RestoreSsdIndicator(Entity<EnragedStatusEffectComponent> ent, EntityUid target)
{
if (!ent.Comp.RemovedSsdIndicator || HasComp<SSDIndicatorComponent>(target))
return;

var ssd = EnsureComp<SSDIndicatorComponent>(target);
ssd.IsSSD = false;
Dirty(target, ssd);
}

// ──────────────────────────────────────────────
// NPC faction helpers
// ──────────────────────────────────────────────

private void ApplyHostileFaction(Entity<EnragedStatusEffectComponent> ent, EntityUid target)
{
if (!TryComp<NpcFactionMemberComponent>(target, out var npcFaction))
{
npcFaction = EnsureComp<NpcFactionMemberComponent>(target);
ent.Comp.AddedFactionComponent = true;
}

foreach (var f in npcFaction.Factions)
ent.Comp.OldFactions.Add(f.ToString());

_npcFaction.ClearFactions((target, npcFaction), false);
_npcFaction.AddFaction((target, npcFaction), ent.Comp.HostileFaction);
}

private void RestoreFaction(Entity<EnragedStatusEffectComponent> ent, EntityUid target)
{
if (!TryComp<NpcFactionMemberComponent>(target, out var npcFaction))
return;

_npcFaction.ClearFactions((target, npcFaction), false);

if (ent.Comp.AddedFactionComponent)
{
RemComp<NpcFactionMemberComponent>(target);
return;
}

foreach (var faction in ent.Comp.OldFactions)
{
_npcFaction.AddFaction((target, npcFaction), faction);
}
}

// ──────────────────────────────────────────────
// HTN AI helpers
// ──────────────────────────────────────────────

private void ApplyHostileHtn(Entity<EnragedStatusEffectComponent> ent, EntityUid target)
{
if (!TryComp<HTNComponent>(target, out var htn))
{
htn = EnsureComp<HTNComponent>(target);
ent.Comp.AddedHtnComponent = true;
}
else
{
ent.Comp.OldRootTask = htn.RootTask.Task;
}

htn.RootTask = new HTNCompoundTask { Task = ent.Comp.HostileRootTask };
htn.Blackboard.SetValue(NPCBlackboard.Owner, target);

_npc.WakeNPC(target, htn);
_htn.Replan(htn);
}

private void RestoreHtn(Entity<EnragedStatusEffectComponent> ent, EntityUid target)
{
if (!TryComp<HTNComponent>(target, out var htn))
return;

if (ent.Comp.AddedHtnComponent)
{
RemComp<HTNComponent>(target);
return;
}

if (!string.IsNullOrEmpty(ent.Comp.OldRootTask))
{
htn.RootTask = new HTNCompoundTask { Task = ent.Comp.OldRootTask };
_htn.Replan(htn);
}
}
}

37 changes: 37 additions & 0 deletions Content.Omu.Server/Organs/OrganReagentInjectorSystem.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Omu.Shared.Organs;
using Content.Shared.Body.Organ;
using Content.Shared.Chemistry.EntitySystems;
using Robust.Shared.Timing;

namespace Content.Omu.Server.Organs;

/// <summary>
/// Injects reagents from organs into their host body's solutions at regular intervals.
/// </summary>
public sealed class OrganReagentInjectorSystem : EntitySystem
{
[Dependency] private readonly SharedSolutionContainerSystem _solution = default!;
[Dependency] private readonly IGameTiming _timing = default!;

public override void Update(float frameTime)
{
var query = EntityQueryEnumerator<OrganReagentInjectorComponent, OrganComponent>();
while (query.MoveNext(out var uid, out var injector, out var organ))
{
if (_timing.CurTime < injector.NextInjectTime)
continue;

injector.NextInjectTime = _timing.CurTime + injector.Duration;

if (organ.Body is not { } body)
continue;

if (_solution.TryGetSolution(body, injector.TargetSolution, out var soln, out _))
_solution.TryAddSolution(soln.Value, injector.Reagents.Clone());
}
}
}
24 changes: 24 additions & 0 deletions Content.Omu.Shared/Enraged/EnragedStatusEffectComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

namespace Content.Omu.Shared.Enraged;

/// <summary>
/// Tracks the enraged status effect state and configuration.
/// Stores hostility settings and restoration data for when the effect expires.
/// </summary>
[RegisterComponent]
public sealed partial class EnragedStatusEffectComponent : Component
{
[DataField]
public string HostileFaction = "Hostile";

[DataField]
public string HostileRootTask = "SimpleHostileCompound";
public bool RemovedSsdIndicator;
public bool AddedFactionComponent;
public bool AddedHtnComponent;
public HashSet<string> OldFactions = new();
public string? OldRootTask;
}
26 changes: 26 additions & 0 deletions Content.Omu.Shared/Organs/OrganReagentInjectorComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Shared.Chemistry.Components;

namespace Content.Omu.Shared.Organs;

/// <summary>
/// Injects reagents into host body's solution at regular intervals.
/// </summary>
[RegisterComponent]
public sealed partial class OrganReagentInjectorComponent : Component
{
[DataField]
public string TargetSolution = "chemicals";

[DataField(required: true)]
public Solution Reagents = default!;

[DataField]
public TimeSpan Duration = TimeSpan.FromSeconds(1);

[ViewVariables]
public TimeSpan NextInjectTime;
}
Loading
Loading