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; diff --git a/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs b/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs new file mode 100644 index 00000000000..78d5d77e2fe --- /dev/null +++ b/Content.Omu.Server/MartialArts/MartialArtsCombatOperator.cs @@ -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; + +/// +/// 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; + } + + if (combat.ActiveCombo == null) + combat.Target = target; + + if (_entManager.TryGetComponent(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; + } +} diff --git a/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs new file mode 100644 index 00000000000..db6851d3ea0 --- /dev/null +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatComponent.cs @@ -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; + +/// +/// 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; + + [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 new file mode 100644 index 00000000000..a85b04cef49 --- /dev/null +++ b/Content.Omu.Server/MartialArts/NpcMartialArtsCombatSystem.cs @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: 2026 Eponymic-sys +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +using Content.Goobstation.Shared.MartialArts.Components; +using Content.Server.NPC.Components; +using Content.Shared.CombatMode; +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.. +/// +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); + + 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 (martial.ActiveCombo != null && curTime - martial.LastStepTime > martial.StepTimeout) + { + FinishCombo(uid, martial); + return; + } + + if (!TryComp(uid, out var fists)) + { + martial.Status = CombatStatus.NoWeapon; + return; + } + + if (fists.NextAttack > curTime) + return; + + if (!TryComp(uid, out var combo)) + return; + + if (martial.ActiveCombo == null && martial.FillerAttacksRemaining > 0) + { + if (!ValidateTarget(uid, martial, out var fillerDist) || fillerDist > fists.Range) + return; + + _melee.AttemptLightAttack(uid, uid, fists, martial.Target); + martial.FillerAttacksRemaining--; + return; + } + + if (martial.ActiveCombo == null && !TryPickCombo(uid, martial, combo)) + return; + + if (martial.StepCooldown > 0f) + { + martial.StepCooldown -= frameTime; + return; + } + + if (martial.ActiveCombo!.PerformOnSelf) + { + ExecuteNextStep(uid, fists, martial, uid); + return; + } + + if (!ValidateTarget(uid, martial, out var distance)) + return; + + if (distance > fists.Range) + { + martial.Status = CombatStatus.TargetOutOfRange; + return; + } + + martial.Status = CombatStatus.Normal; + ExecuteNextStep(uid, fists, martial, martial.Target); + } + + 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; + } +} 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..58404864450 --- /dev/null +++ b/Resources/Prototypes/_Omu/Entities/Mobs/NPCs/MartialArts/martial_arts_npc.yml @@ -0,0 +1,158 @@ +# 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 + - KickUp + +- 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