From 2656144746708e47eb0d6690265da9c697068edf Mon Sep 17 00:00:00 2001 From: Eponymic-sys Date: Sat, 28 Feb 2026 16:58:08 -0500 Subject: [PATCH 1/3] NPCs that can do Martial Arts --- .../MartialArts/MartialArtsCombatOperator.cs | 104 +++++++++ .../NpcMartialArtsCombatComponent.cs | 34 +++ .../MartialArts/NpcMartialArtsCombatSystem.cs | 215 ++++++++++++++++++ .../NPCs/MartialArts/htn_martial_arts.yml | 88 +++++++ .../NPCs/MartialArts/martial_arts_gear.yml | 61 +++++ .../NPCs/MartialArts/martial_arts_npc.yml | 157 +++++++++++++ 6 files changed, 659 insertions(+) create mode 100644 Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs create mode 100644 Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs create mode 100644 Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs create mode 100644 Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/htn_martial_arts.yml create mode 100644 Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_gear.yml create mode 100644 Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml diff --git a/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs b/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs new file mode 100644 index 00000000000..f2f5a94d888 --- /dev/null +++ b/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs @@ -0,0 +1,104 @@ +// 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; + +/// +/// HTN operator for NPC martial arts combo combat. +/// Adds NpcMartialArtsCombatComponent on startup so NpcMartialArtsCombatSystem can drive attacks. +/// +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(NPCBlackboard.Owner); + var comp = _entManager.EnsureComponent(owner); + comp.Target = blackboard.GetValue(TargetKey); + _entManager.System().SetInCombatMode(owner, true); + } + + public void ConditionalShutdown(NPCBlackboard blackboard) + { + var owner = blackboard.GetValue(NPCBlackboard.Owner); + _entManager.System().SetInCombatMode(owner, false); + _entManager.RemoveComponent(owner); + blackboard.Remove(TargetKey); + } + + public override async Task<(bool Valid, Dictionary? Effects)> Plan( + NPCBlackboard blackboard, + CancellationToken cancelToken) + { + if (!blackboard.TryGetValue(TargetKey, out var target, _entManager)) + return (false, null); + + if (_entManager.TryGetComponent(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(NPCBlackboard.Owner); + + if (!_entManager.TryGetComponent(owner, out var combat) || + !blackboard.TryGetValue(TargetKey, out var target, _entManager) || + target == EntityUid.Invalid) + { + return HTNOperatorStatus.Failed; + } + + combat.Target = target; + + if (_entManager.TryGetComponent(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; + } +} diff --git a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs new file mode 100644 index 00000000000..7b269059fee --- /dev/null +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs @@ -0,0 +1,34 @@ +// 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; + +/// +/// Transient component for NPC martial arts combat. +/// Holds target, combo progress, and status while an HTN plan is active. +/// +[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; +} diff --git a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs new file mode 100644 index 00000000000..3ae030084df --- /dev/null +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs @@ -0,0 +1,215 @@ +// 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.Server.NPC.Components; +using Content.Shared.CombatMode; +using Content.Shared.Movement.Pulling.Components; +using Content.Shared.Movement.Pulling.Systems; +using Content.Shared.NPC; +using Content.Shared.Weapons.Melee; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Omu.Server.MartialArts; + +/// +/// Drives NPC martial arts combat each tick. +/// Picks combos, waits on cooldowns, and fires melee/disarm/grab attacks. +/// +public sealed class NpcMartialArtsCombatSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly IRobustRandom _random = default!; + [Dependency] private readonly SharedMeleeWeaponSystem _melee = default!; + [Dependency] private readonly PullingSystem _pulling = default!; + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var curTime = _timing.CurTime; + var query = EntityQueryEnumerator(); + + while (query.MoveNext(out var uid, out var martial, out _)) + { + if (!TryComp(uid, out var combatMode) || !combatMode.IsInCombatMode) + { + RemCompDeferred(uid); + continue; + } + + ProcessCombat(uid, martial, curTime, frameTime); + } + } + + private void ProcessCombat( + EntityUid uid, + NpcMartialArtsCombatComponent martial, + TimeSpan curTime, + float frameTime) + { + ReleaseNonTarget(uid, martial.Target); + + if (!TryComp(uid, out var fists)) + { + martial.Status = CombatStatus.NoWeapon; + return; + } + + if (!ValidateTarget(uid, martial, out var distance)) + return; + + if (distance > fists.Range) + { + martial.Status = CombatStatus.TargetOutOfRange; + return; + } + + martial.Status = CombatStatus.Normal; + + if (fists.NextAttack > curTime) + return; + + if (!TryComp(uid, out var combo)) + return; + + if (martial.ActiveCombo == null && !TryPickCombo(martial, combo)) + return; + + if (martial.StepCooldown > 0f) + { + martial.StepCooldown -= frameTime; + return; + } + + ExecuteNextStep(uid, fists, martial); + } + + private bool ValidateTarget( + EntityUid uid, + NpcMartialArtsCombatComponent martial, + out float distance) + { + distance = 0f; + + if (!Exists(martial.Target) || Deleted(martial.Target)) + { + martial.Status = CombatStatus.TargetUnreachable; + return false; + } + + var xform = Transform(uid); + var targetXform = Transform(martial.Target); + + if (!xform.Coordinates.TryDistance(EntityManager, targetXform.Coordinates, out distance)) + { + martial.Status = CombatStatus.TargetUnreachable; + return false; + } + + return true; + } + + private bool TryPickCombo(NpcMartialArtsCombatComponent martial, CanPerformComboComponent combo) + { + if (combo.AllowedCombos.Count == 0) + return false; + + var candidates = new List(); + foreach (var c in combo.AllowedCombos) + { + if (!c.PerformOnSelf) + candidates.Add(c); + } + + if (candidates.Count == 0) + return false; + + martial.ActiveCombo = _random.Pick(candidates); + martial.StepIndex = 0; + martial.StepCooldown = 0f; + combo.LastAttacks.Clear(); + return true; + } + + private void ExecuteNextStep(EntityUid uid, MeleeWeaponComponent fists, NpcMartialArtsCombatComponent martial) + { + var attackType = martial.ActiveCombo!.AttackTypes[martial.StepIndex]; + + if (!ExecuteAttack(uid, fists, martial.Target, attackType)) + { + ReleasePull(uid, martial.Target); + ResetCombo(martial); + return; + } + + martial.StepIndex++; + martial.StepCooldown = martial.TimeBetweenSteps; + + if (martial.StepIndex >= martial.ActiveCombo.AttackTypes.Count) + { + ReleasePull(uid, martial.Target); + ResetCombo(martial); + } + } + + private bool ExecuteAttack(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(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(other, out var pullable)) + _pulling.TryStopPull(other, pullable, uid, true); + + return _pulling.TryStartPull(uid, target); + } + + private void ReleasePull(EntityUid uid, EntityUid target) + { + if (TryComp(uid, out var puller) && + puller.Pulling == target && + TryComp(target, out var pullable)) + { + _pulling.TryStopPull(target, pullable, uid, true); + } + } + + private void ReleaseNonTarget(EntityUid uid, EntityUid target) + { + if (!TryComp(uid, out var puller) || puller.Pulling is not { } pulled) + return; + + if (pulled == target) + return; + + if (TryComp(pulled, out var pullable)) + _pulling.TryStopPull(pulled, pullable, uid, true); + } + + private static void ResetCombo(NpcMartialArtsCombatComponent martial) + { + martial.ActiveCombo = null; + martial.StepIndex = 0; + martial.StepCooldown = 0f; + } +} diff --git a/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/htn_martial_arts.yml b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/htn_martial_arts.yml new file mode 100644 index 00000000000..87777ed1355 --- /dev/null +++ b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/htn_martial_arts.yml @@ -0,0 +1,88 @@ +# SPDX-FileCopyrightText: 2026 Eponymic-sys + +# SPDX-License-Identifier: AGPL-3.0-or-later + +- type: htnCompound + id: MartialArtsHostileCompound + branches: + - tasks: + - !type:HTNCompoundTask + task: MartialArtsCombatCompound + - tasks: + - !type:HTNCompoundTask + task: IdleCompound + +- type: htnCompound + id: MartialArtsCombatCompound + branches: + - tasks: + - !type:HTNPrimitiveTask + operator: !type:UtilityOperator + proto: NearbyMeleeTargets + key: Target + keyCoordinates: TargetCoordinates + - !type:HTNCompoundTask + task: BeforeMartialArtsAttackCompound + +- type: htnCompound + id: BeforeMartialArtsAttackCompound + branches: + - preconditions: + - !type:BuckledPrecondition + isBuckled: true + tasks: + - !type:HTNPrimitiveTask + operator: !type:UnbuckleOperator + shutdownState: TaskFinished + + - preconditions: + - !type:PulledPrecondition + isPulled: true + tasks: + - !type:HTNPrimitiveTask + operator: !type:UnPullOperator + shutdownState: TaskFinished + + - preconditions: + - !type:InContainerPrecondition + isInContainer: true + tasks: + - !type:HTNCompoundTask + task: EscapeCompound + + - tasks: + - !type:HTNCompoundTask + task: MartialArtsAttackTargetCompound + +- type: htnCompound + id: MartialArtsAttackTargetCompound + branches: + - preconditions: + - !type:KeyExistsPrecondition + key: Target + tasks: + - !type:HTNPrimitiveTask + operator: !type:MoveToOperator + shutdownState: PlanFinished + pathfindInPlanning: true + removeKeyOnFinish: false + targetKey: TargetCoordinates + pathfindKey: TargetPathfind + rangeKey: MeleeRange + - !type:HTNPrimitiveTask + operator: !type:JukeOperator + jukeType: AdjacentTile + - !type:HTNPrimitiveTask + operator: !type:MartialArtsCombatOperator + targetKey: Target + preconditions: + - !type:KeyExistsPrecondition + key: Target + - !type:TargetInRangePrecondition + targetKey: Target + rangeKey: MeleeRange + services: + - !type:UtilityService + id: MartialArtsService + proto: NearbyMeleeTargets + key: Target diff --git a/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_gear.yml b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_gear.yml new file mode 100644 index 00000000000..3fb2ab0142e --- /dev/null +++ b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_gear.yml @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 Eponymic-sys +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +- type: startingGear + id: MartialArtistGear + equipment: + jumpsuit: ClothingUniformJumpsuitColorGrey + shoes: ClothingShoesColorBlack + +- type: startingGear + id: MartialArtistGearCQC + equipment: + jumpsuit: ClothingUniformJumpsuitColorBlack + shoes: ClothingShoesBootsJack + gloves: ClothingHandsGlovesCombat + head: ClothingHeadBandBlack + +- type: startingGear + id: MartialArtistGearJudo + equipment: + jumpsuit: ClothingUniformJumpsuitLawyerBlack + shoes: ClothingShoesBootsLaceup + gloves: ClothingHandsGlovesCombat + +- type: startingGear + id: MartialArtistGearSleepingCarp + equipment: + jumpsuit: ClothingUniformJumpsuitMonasticRobeDark + shoes: ClothingShoesSandals + gloves: ClothingHandsGlovesFingerless + +- type: startingGear + id: MartialArtistGearCapoeira + equipment: + jumpsuit: ClothingUniformJumpsuitTrainer + shoes: ClothingShoesColorWhite + head: ClothingHeadBandGreen + +- type: startingGear + id: MartialArtistGearDragonKungFu + equipment: + jumpsuit: ClothingUniformJumpsuitKimono + shoes: ClothingShoesSandals + gloves: ClothingHandsGlovesFingerless + head: ClothingHeadBandGold + +- type: startingGear + id: MartialArtistGearNinjutsu + equipment: + jumpsuit: ClothingUniformJumpsuitColorBlack + shoes: ClothingShoesColorBlack + head: ClothingHeadBandBlack + +- type: startingGear + id: MartialArtistGearHellRip + equipment: + jumpsuit: ClothingUniformJumpsuitColorRed + shoes: ClothingShoesBootsCombat + head: ClothingHeadBandSkull + mask: ClothingMaskBandSkull diff --git a/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml new file mode 100644 index 00000000000..2dff748e57d --- /dev/null +++ b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml @@ -0,0 +1,157 @@ +# SPDX-FileCopyrightText: 2026 Eponymic-sys +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +- type: entity + id: BaseMobMartialArtist + abstract: true + parent: BaseMobHuman + components: + - type: RandomHumanoidAppearance + - type: HTN + rootTask: + task: MartialArtsHostileCompound + - type: NpcFactionMember + factions: + - SimpleHostile + - type: Loadout + prototypes: + - MartialArtistGear + +- type: entity + id: MobHostileCQC + name: CQC Expert + description: A hostile combatant trained in Close Quarters Combat. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearCQC + - type: MartialArtsKnowledge + martialArtsForm: CloseQuartersCombat + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - CQCKick + - CQCSlam + - CQCRestrain + - CQCPressure + - CQCConsecutive + +- type: entity + id: MobHostileJudo + name: Judo Master + description: A hostile combatant trained in Corporate Judo. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearJudo + - type: MartialArtsKnowledge + martialArtsForm: CorporateJudo + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - JudoDiscombobulate + - JudoEyePoke + - JudoThrow + - JudoArmbar + - JudoWheelThrow + +- type: entity + id: MobHostileSleepingCarp + name: Sleeping Carp Practitioner + description: A hostile combatant who follows the Way of the Sleeping Carp. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearSleepingCarp + - type: MartialArtsKnowledge + martialArtsForm: SleepingCarp + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - SleepingCarpGnashingTeeth + - SleepingCarpKneeHaul + - SleepingCarpCrashingWaves + +- type: entity + id: MobHostileCapoeira + name: Capoeirista + description: A hostile combatant trained in Capoeira. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearCapoeira + - type: MartialArtsKnowledge + martialArtsForm: Capoeira + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - PushKick + - SweepKick + - CircleKick + - SpinKick + +- type: entity + id: MobHostileDragonKungFu + name: Dragon Kung Fu Adept + description: A hostile combatant who channels the power of the dragon. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearDragonKungFu + - type: MartialArtsKnowledge + martialArtsForm: KungFuDragon + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - DragonClaw + - DragonTail + - DragonStrike + +- type: entity + id: MobHostileNinjutsu + name: Ninjutsu Assassin + description: A hostile combatant trained in the deadly art of Ninjutsu. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearNinjutsu + - type: MartialArtsKnowledge + martialArtsForm: Ninjutsu + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - BiteTheDust + - DirtyKill + +- type: entity + id: MobHostileHellRip + name: Hell Ripper + description: A terrifying combatant who channels infernal fury. + parent: BaseMobMartialArtist + components: + - type: Loadout + prototypes: + - MartialArtistGearHellRip + - type: MartialArtsKnowledge + martialArtsForm: HellRip + originalFistDamage: 5 + originalFistDamageType: Blunt + - type: CanPerformCombo + roundstartCombos: + - DropKick + - HeadRip + - TearDown + - Slam From fa499098353c5d09022bb1b1fbf33b93389fe5f7 Mon Sep 17 00:00:00 2001 From: Eponymic-sys Date: Sun, 1 Mar 2026 18:34:19 -0500 Subject: [PATCH 2/3] Moved combo selection to a partial, addded filler attacks and kickup to give Capoeira some love, added a timer to make them better with crowds. --- .../MartialArts/MartialArtsCombatOperator.cs | 5 +- .../NpcMartialArtsCombatComponent.cs | 15 ++ .../NpcMartialArtsCombatSystem.Combo.cs | 151 +++++++++++++++++ .../MartialArts/NpcMartialArtsCombatSystem.cs | 153 +++++------------- .../NPCs/MartialArts/martial_arts_npc.yml | 1 + 5 files changed, 208 insertions(+), 117 deletions(-) create mode 100644 Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.Combo.cs diff --git a/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs b/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs index f2f5a94d888..78d5d77e2fe 100644 --- a/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs +++ b/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs @@ -86,9 +86,10 @@ public override HTNOperatorStatus Update(NPCBlackboard blackboard, float frameTi return HTNOperatorStatus.Failed; } - combat.Target = target; + if (combat.ActiveCombo == null) + combat.Target = target; - if (_entManager.TryGetComponent(target, out var mob) && + if (_entManager.TryGetComponent(combat.Target, out var mob) && mob.CurrentState > TargetState) { return HTNOperatorStatus.Finished; diff --git a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs index 7b269059fee..db6851d3ea0 100644 --- a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs @@ -31,4 +31,19 @@ public sealed partial class NpcMartialArtsCombatComponent : Component [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; } diff --git a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.Combo.cs b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.Combo.cs new file mode 100644 index 00000000000..d5ef1b49b9f --- /dev/null +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.Combo.cs @@ -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; + +/// +/// Combo selection, step execution, and attack dispatch for NPC martial art. +/// +public sealed partial class NpcMartialArtsCombatSystem +{ + // Combo selection + + /// + /// Randomly selects a combo appropriate for the NPC's current posture. + /// Prone NPCs only consider self-targeting prone-capable combos (KickUp). + /// + private bool TryPickCombo(EntityUid uid, NpcMartialArtsCombatComponent martial, CanPerformComboComponent combo) + { + if (combo.AllowedCombos.Count == 0) + return false; + + var isProne = TryComp(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 FilterCombos(IReadOnlyList combos, bool isProne) + { + var result = new List(); + + foreach (var c in combos) + { + var eligible = isProne + ? c.PerformOnSelf && c.CanDoWhileProne + : !c.PerformOnSelf; + + if (eligible) + result.Add(c); + } + + return result; + } + + // Step execution + + /// + /// Advances to the next attack in the active combo sequence. + /// Resets the combo on failure or after the final step completes. + /// + 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(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(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(uid, out var puller) + && puller.Pulling == target + && TryComp(target, out var pullable)) + { + _pulling.TryStopPull(target, pullable, uid, true); + } + } + + private void ReleaseNonTarget(EntityUid uid, EntityUid target) + { + if (!TryComp(uid, out var puller) || puller.Pulling is not { } pulled) + return; + + if (pulled == target) + return; + + if (TryComp(pulled, out var pullable)) + _pulling.TryStopPull(pulled, pullable, uid, true); + } +} diff --git a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs index 3ae030084df..a85b04cef49 100644 --- a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs @@ -2,11 +2,9 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -using Content.Goobstation.Common.MartialArts; using Content.Goobstation.Shared.MartialArts.Components; using Content.Server.NPC.Components; using Content.Shared.CombatMode; -using Content.Shared.Movement.Pulling.Components; using Content.Shared.Movement.Pulling.Systems; using Content.Shared.NPC; using Content.Shared.Weapons.Melee; @@ -16,16 +14,20 @@ namespace Content.Omu.Server.MartialArts; /// -/// Drives NPC martial arts combat each tick. -/// Picks combos, waits on cooldowns, and fires melee/disarm/grab attacks. +/// Drives NPC martial arts combat each tick.. /// -public sealed class NpcMartialArtsCombatSystem : EntitySystem +public sealed partial class NpcMartialArtsCombatSystem : EntitySystem { [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly SharedMeleeWeaponSystem _melee = default!; [Dependency] private readonly PullingSystem _pulling = default!; + public override void Initialize() + { + base.Initialize(); + } + public override void Update(float frameTime) { base.Update(frameTime); @@ -53,39 +55,60 @@ private void ProcessCombat( { ReleaseNonTarget(uid, martial.Target); + if (martial.ActiveCombo != null && curTime - martial.LastStepTime > martial.StepTimeout) + { + FinishCombo(uid, martial); + return; + } + if (!TryComp(uid, out var fists)) { martial.Status = CombatStatus.NoWeapon; return; } - if (!ValidateTarget(uid, martial, out var distance)) + if (fists.NextAttack > curTime) return; - if (distance > fists.Range) + if (!TryComp(uid, out var combo)) + return; + + if (martial.ActiveCombo == null && martial.FillerAttacksRemaining > 0) { - martial.Status = CombatStatus.TargetOutOfRange; + if (!ValidateTarget(uid, martial, out var fillerDist) || fillerDist > fists.Range) + return; + + _melee.AttemptLightAttack(uid, uid, fists, martial.Target); + martial.FillerAttacksRemaining--; return; } - martial.Status = CombatStatus.Normal; + if (martial.ActiveCombo == null && !TryPickCombo(uid, martial, combo)) + return; - if (fists.NextAttack > curTime) + if (martial.StepCooldown > 0f) + { + martial.StepCooldown -= frameTime; return; + } - if (!TryComp(uid, out var combo)) + if (martial.ActiveCombo!.PerformOnSelf) + { + ExecuteNextStep(uid, fists, martial, uid); return; + } - if (martial.ActiveCombo == null && !TryPickCombo(martial, combo)) + if (!ValidateTarget(uid, martial, out var distance)) return; - if (martial.StepCooldown > 0f) + if (distance > fists.Range) { - martial.StepCooldown -= frameTime; + martial.Status = CombatStatus.TargetOutOfRange; return; } - ExecuteNextStep(uid, fists, martial); + martial.Status = CombatStatus.Normal; + ExecuteNextStep(uid, fists, martial, martial.Target); } private bool ValidateTarget( @@ -112,104 +135,4 @@ private bool ValidateTarget( return true; } - - private bool TryPickCombo(NpcMartialArtsCombatComponent martial, CanPerformComboComponent combo) - { - if (combo.AllowedCombos.Count == 0) - return false; - - var candidates = new List(); - foreach (var c in combo.AllowedCombos) - { - if (!c.PerformOnSelf) - candidates.Add(c); - } - - if (candidates.Count == 0) - return false; - - martial.ActiveCombo = _random.Pick(candidates); - martial.StepIndex = 0; - martial.StepCooldown = 0f; - combo.LastAttacks.Clear(); - return true; - } - - private void ExecuteNextStep(EntityUid uid, MeleeWeaponComponent fists, NpcMartialArtsCombatComponent martial) - { - var attackType = martial.ActiveCombo!.AttackTypes[martial.StepIndex]; - - if (!ExecuteAttack(uid, fists, martial.Target, attackType)) - { - ReleasePull(uid, martial.Target); - ResetCombo(martial); - return; - } - - martial.StepIndex++; - martial.StepCooldown = martial.TimeBetweenSteps; - - if (martial.StepIndex >= martial.ActiveCombo.AttackTypes.Count) - { - ReleasePull(uid, martial.Target); - ResetCombo(martial); - } - } - - private bool ExecuteAttack(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(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(other, out var pullable)) - _pulling.TryStopPull(other, pullable, uid, true); - - return _pulling.TryStartPull(uid, target); - } - - private void ReleasePull(EntityUid uid, EntityUid target) - { - if (TryComp(uid, out var puller) && - puller.Pulling == target && - TryComp(target, out var pullable)) - { - _pulling.TryStopPull(target, pullable, uid, true); - } - } - - private void ReleaseNonTarget(EntityUid uid, EntityUid target) - { - if (!TryComp(uid, out var puller) || puller.Pulling is not { } pulled) - return; - - if (pulled == target) - return; - - if (TryComp(pulled, out var pullable)) - _pulling.TryStopPull(pulled, pullable, uid, true); - } - - private static void ResetCombo(NpcMartialArtsCombatComponent martial) - { - martial.ActiveCombo = null; - martial.StepIndex = 0; - martial.StepCooldown = 0f; - } } diff --git a/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml index 2dff748e57d..58404864450 100644 --- a/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml +++ b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml @@ -98,6 +98,7 @@ - SweepKick - CircleKick - SpinKick + - KickUp - type: entity id: MobHostileDragonKungFu From daa1ba317de9dc6ba4e43dd658d4b58aad391afd Mon Sep 17 00:00:00 2001 From: Eponymic-sys Date: Thu, 19 Mar 2026 11:27:48 -0400 Subject: [PATCH 3/3] make beingperformed nullable --- .../MartialArts/Components/CanPerformComboComponent.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Content.Goobstation.Shared/MartialArts/Components/CanPerformComboComponent.cs b/Content.Goobstation.Shared/MartialArts/Components/CanPerformComboComponent.cs index 2d3516a34ae..e6c41d1e019 100644 --- a/Content.Goobstation.Shared/MartialArts/Components/CanPerformComboComponent.cs +++ b/Content.Goobstation.Shared/MartialArts/Components/CanPerformComboComponent.cs @@ -21,7 +21,7 @@ public sealed partial class CanPerformComboComponent : Component public EntityUid? CurrentTarget; [DataField, AutoNetworkedField] - public ProtoId BeingPerformed; + public ProtoId? BeingPerformed;// Omu, needs to be nullable, to pass serialization round-trip. [DataField] public int LastAttacksLimit = 4;