Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public sealed partial class CanPerformComboComponent : Component
public EntityUid? CurrentTarget;

[DataField, AutoNetworkedField]
public ProtoId<ComboPrototype> BeingPerformed;
public ProtoId<ComboPrototype>? BeingPerformed;// Omu, needs to be nullable, to pass serialization round-trip.

[DataField]
public int LastAttacksLimit = 4;
Expand Down
105 changes: 105 additions & 0 deletions Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using System.Threading;
using System.Threading.Tasks;
using Content.Server.NPC;
using Content.Server.NPC.Components;
using Content.Server.NPC.HTN;
using Content.Server.NPC.HTN.PrimitiveTasks;
using Content.Shared.CombatMode;
using Content.Shared.Mobs;
using Content.Shared.Mobs.Components;

namespace Content.Omu.Server.MartialArts;

/// <summary>
/// HTN operator for NPC martial arts combo combat.
/// Adds NpcMartialArtsCombatComponent on startup so NpcMartialArtsCombatSystem can drive attacks.
/// </summary>
public sealed partial class MartialArtsCombatOperator : HTNOperator, IHtnConditionalShutdown
{
[Dependency] private readonly IEntityManager _entManager = default!;

[DataField("shutdownState")]
public HTNPlanState ShutdownState { get; private set; } = HTNPlanState.TaskFinished;

[DataField("targetKey", required: true)]
public string TargetKey = default!;

[DataField("targetState")]
public MobState TargetState = MobState.Alive;

public override void Startup(NPCBlackboard blackboard)
{
base.Startup(blackboard);
var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
var comp = _entManager.EnsureComponent<NpcMartialArtsCombatComponent>(owner);
comp.Target = blackboard.GetValue<EntityUid>(TargetKey);
_entManager.System<SharedCombatModeSystem>().SetInCombatMode(owner, true);
}

public void ConditionalShutdown(NPCBlackboard blackboard)
{
var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);
_entManager.System<SharedCombatModeSystem>().SetInCombatMode(owner, false);
_entManager.RemoveComponent<NpcMartialArtsCombatComponent>(owner);
blackboard.Remove<EntityUid>(TargetKey);
}

public override async Task<(bool Valid, Dictionary<string, object>? Effects)> Plan(
NPCBlackboard blackboard,
CancellationToken cancelToken)
{
if (!blackboard.TryGetValue<EntityUid>(TargetKey, out var target, _entManager))
return (false, null);

if (_entManager.TryGetComponent<MobStateComponent>(target, out var mob) &&
mob.CurrentState > TargetState)
return (false, null);

return (true, null);
}

public override void TaskShutdown(NPCBlackboard blackboard, HTNOperatorStatus status)
{
base.TaskShutdown(blackboard, status);
ConditionalShutdown(blackboard);
}

public override void PlanShutdown(NPCBlackboard blackboard)
{
base.PlanShutdown(blackboard);
ConditionalShutdown(blackboard);
}

public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTime)
{
base.Update(blackboard, frameTime);
var owner = blackboard.GetValue<EntityUid>(NPCBlackboard.Owner);

if (!_entManager.TryGetComponent<NpcMartialArtsCombatComponent>(owner, out var combat) ||
!blackboard.TryGetValue<EntityUid>(TargetKey, out var target, _entManager) ||
target == EntityUid.Invalid)
{
return HTNOperatorStatus.Failed;
}

if (combat.ActiveCombo == null)
combat.Target = target;

if (_entManager.TryGetComponent<MobStateComponent>(combat.Target, out var mob) &&
mob.CurrentState > TargetState)
{
return HTNOperatorStatus.Finished;
}

if (combat.Status is not (CombatStatus.Normal or CombatStatus.TargetOutOfRange))
return HTNOperatorStatus.Failed;

return ShutdownState == HTNPlanState.PlanFinished
? HTNOperatorStatus.Finished
: HTNOperatorStatus.Continuing;
}
}
49 changes: 49 additions & 0 deletions Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Goobstation.Common.MartialArts;
using Content.Server.NPC.Components;

namespace Content.Omu.Server.MartialArts;

/// <summary>
/// Transient component for NPC martial arts combat.
/// Holds target, combo progress, and status while an HTN plan is active.
/// </summary>
[RegisterComponent]
public sealed partial class NpcMartialArtsCombatComponent : Component
{
[ViewVariables(VVAccess.ReadWrite)]
public EntityUid Target;

[ViewVariables]
public CombatStatus Status = CombatStatus.Normal;

[ViewVariables]
public ComboPrototype? ActiveCombo;

[ViewVariables]
public int StepIndex;

[ViewVariables]
public float StepCooldown;

[DataField]
public float TimeBetweenSteps = 0.45f;

[ViewVariables]
public TimeSpan LastStepTime;

[DataField]
public TimeSpan StepTimeout = TimeSpan.FromSeconds(2);

[ViewVariables]
public int FillerAttacksRemaining;

[DataField]
public int FillerAttacksMin = 1;

[DataField]
public int FillerAttacksMax = 3;
}
151 changes: 151 additions & 0 deletions Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.Combo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// SPDX-FileCopyrightText: 2026 Eponymic-sys
//
// SPDX-License-Identifier: AGPL-3.0-or-later

using Content.Goobstation.Common.MartialArts;
using Content.Goobstation.Shared.MartialArts.Components;
using Content.Shared.Movement.Pulling.Components;
using Content.Shared.Standing;
using Content.Shared.Weapons.Melee;
using Robust.Shared.Random;

namespace Content.Omu.Server.MartialArts;

/// <summary>
/// Combo selection, step execution, and attack dispatch for NPC martial art.
/// </summary>
public sealed partial class NpcMartialArtsCombatSystem
{
// Combo selection

/// <summary>
/// Randomly selects a combo appropriate for the NPC's current posture.
/// Prone NPCs only consider self-targeting prone-capable combos (KickUp).
/// </summary>
private bool TryPickCombo(EntityUid uid, NpcMartialArtsCombatComponent martial, CanPerformComboComponent combo)
{
if (combo.AllowedCombos.Count == 0)
return false;

var isProne = TryComp<StandingStateComponent>(uid, out var standing) && !standing.Standing;
var candidates = FilterCombos(combo.AllowedCombos, isProne);

if (candidates.Count == 0)
return false;

martial.ActiveCombo = _random.Pick(candidates);
martial.StepIndex = 0;
martial.StepCooldown = 0f;
martial.LastStepTime = _timing.CurTime;
combo.LastAttacks.Clear();
return true;
}

private static List<ComboPrototype> FilterCombos(IReadOnlyList<ComboPrototype> combos, bool isProne)
{
var result = new List<ComboPrototype>();

foreach (var c in combos)
{
var eligible = isProne
? c.PerformOnSelf && c.CanDoWhileProne
: !c.PerformOnSelf;

if (eligible)
result.Add(c);
}

return result;
}

// Step execution

/// <summary>
/// Advances to the next attack in the active combo sequence.
/// Resets the combo on failure or after the final step completes.
/// </summary>
private void ExecuteNextStep(
EntityUid uid,
MeleeWeaponComponent fists,
NpcMartialArtsCombatComponent martial,
EntityUid attackTarget)
{
var attackType = martial.ActiveCombo!.AttackTypes[martial.StepIndex];

if (!DispatchAttack(uid, fists, attackTarget, attackType))
{
FinishCombo(uid, martial);
return;
}

martial.LastStepTime = _timing.CurTime;
martial.StepIndex++;
martial.StepCooldown = martial.TimeBetweenSteps;

if (martial.StepIndex >= martial.ActiveCombo.AttackTypes.Count)
FinishCombo(uid, martial);
}

private void FinishCombo(EntityUid uid, NpcMartialArtsCombatComponent martial)
{
ReleasePull(uid, martial.Target);
martial.ActiveCombo = null;
martial.StepIndex = 0;
martial.StepCooldown = 0f;
martial.FillerAttacksRemaining = _random.Next(martial.FillerAttacksMin, martial.FillerAttacksMax + 1);
}

// Attack dispatch

private bool DispatchAttack(EntityUid uid, MeleeWeaponComponent fists, EntityUid target, ComboAttackType type)
{
return type switch
{
ComboAttackType.Harm or ComboAttackType.HarmLight
=> _melee.AttemptLightAttack(uid, uid, fists, target),
ComboAttackType.Disarm
=> _melee.AttemptDisarmAttack(uid, uid, fists, target),
ComboAttackType.Grab
=> TryGrab(uid, target),
_ => false,
};
}

private bool TryGrab(EntityUid uid, EntityUid target)
{
if (!TryComp<PullerComponent>(uid, out var puller))
return _pulling.TryStartPull(uid, target);

if (puller.Pulling == target)
return _pulling.TryGrab(target, uid, ignoreCombatMode: true);

if (puller.Pulling is { } other && TryComp<PullableComponent>(other, out var pullable))
_pulling.TryStopPull(other, pullable, uid, true);

return _pulling.TryStartPull(uid, target);
}

// Pull helpers

private void ReleasePull(EntityUid uid, EntityUid target)
{
if (TryComp<PullerComponent>(uid, out var puller)
&& puller.Pulling == target
&& TryComp<PullableComponent>(target, out var pullable))
{
_pulling.TryStopPull(target, pullable, uid, true);
}
}

private void ReleaseNonTarget(EntityUid uid, EntityUid target)
{
if (!TryComp<PullerComponent>(uid, out var puller) || puller.Pulling is not { } pulled)
return;

if (pulled == target)
return;

if (TryComp<PullableComponent>(pulled, out var pullable))
_pulling.TryStopPull(pulled, pullable, uid, true);
}
}
Loading
Loading