Sentry turrets - Part 3: Turret AI (#35058)

* Initial commit

* Updated Access/command.yml

* Fix for Access/AccessLevelPrototype.cs

* Added silicon access levels to admin items

* Included self-recharging battery changes

* Revert "Included self-recharging battery changes"

* Addressed reviewers comments

* Additional reviewer comments
This commit is contained in:
chromiumboy
2025-03-01 11:42:33 -06:00
committed by GitHub
parent e8c812f90f
commit 10c868011e
23 changed files with 332 additions and 10 deletions

View File

@@ -31,7 +31,7 @@ public sealed class IdCardSystem : SharedIdCardSystem
private void OnMicrowaved(EntityUid uid, IdCardComponent component, BeingMicrowavedEvent args) private void OnMicrowaved(EntityUid uid, IdCardComponent component, BeingMicrowavedEvent args)
{ {
if (!component.CanMicrowave || !TryComp<MicrowaveComponent>(args.Microwave, out var micro) || micro.Broken) if (!component.CanMicrowave || !TryComp<MicrowaveComponent>(args.Microwave, out var micro) || micro.Broken)
return; return;
if (TryComp<AccessComponent>(uid, out var access)) if (TryComp<AccessComponent>(uid, out var access))
{ {
@@ -78,7 +78,12 @@ public sealed class IdCardSystem : SharedIdCardSystem
} }
// Give them a wonderful new access to compensate for everything // Give them a wonderful new access to compensate for everything
var random = _random.Pick(_prototypeManager.EnumeratePrototypes<AccessLevelPrototype>().ToArray()); var ids = _prototypeManager.EnumeratePrototypes<AccessLevelPrototype>().Where(x => x.CanAddToIdCard).ToArray();
if (ids.Length == 0)
return;
var random = _random.Pick(ids);
access.Tags.Add(random.ID); access.Tags.Add(random.ID);
Dirty(uid, access); Dirty(uid, access);

View File

@@ -40,6 +40,13 @@ public sealed partial class NPCRangedCombatComponent : Component
[ViewVariables(VVAccess.ReadWrite)] [ViewVariables(VVAccess.ReadWrite)]
public bool TargetInLOS = false; public bool TargetInLOS = false;
/// <summary>
/// If true, only opaque objects will block line of sight.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
// ReSharper disable once InconsistentNaming
public bool UseOpaqueForLOSChecks = false;
/// <summary> /// <summary>
/// Delay after target is in LOS before we start shooting. /// Delay after target is in LOS before we start shooting.
/// </summary> /// </summary>

View File

@@ -10,7 +10,7 @@ public sealed partial class HTNComponent : NPCComponent
/// The base task to use for planning /// The base task to use for planning
/// </summary> /// </summary>
[ViewVariables(VVAccess.ReadWrite), [ViewVariables(VVAccess.ReadWrite),
DataField("rootTask", required: true)] DataField("rootTask", required: true)]
public HTNCompoundTask RootTask = default!; public HTNCompoundTask RootTask = default!;
/// <summary> /// <summary>
@@ -47,4 +47,10 @@ public sealed partial class HTNComponent : NPCComponent
/// Is this NPC currently planning? /// Is this NPC currently planning?
/// </summary> /// </summary>
[ViewVariables] public bool Planning => PlanningJob != null; [ViewVariables] public bool Planning => PlanningJob != null;
/// <summary>
/// Determines whether plans should be made / updated for this entity
/// </summary>
[DataField]
public bool Enabled = true;
} }

View File

@@ -133,6 +133,39 @@ public sealed class HTNSystem : EntitySystem
component.PlanningJob = null; component.PlanningJob = null;
} }
/// <summary>
/// Enable / disable the hierarchical task network of an entity
/// </summary>
/// <param name="ent">The entity and its <see cref="HTNComponent"/></param>
/// <param name="state">Set 'true' to enable, or 'false' to disable, the HTN</param>
/// <param name="planCooldown">Specifies a time in seconds before the entity can start planning a new action (only takes effect when the HTN is enabled)</param>
// ReSharper disable once InconsistentNaming
[PublicAPI]
public void SetHTNEnabled(Entity<HTNComponent> ent, bool state, float planCooldown = 0f)
{
if (ent.Comp.Enabled == state)
return;
ent.Comp.Enabled = state;
ent.Comp.PlanAccumulator = planCooldown;
ent.Comp.PlanningToken?.Cancel();
ent.Comp.PlanningToken = null;
if (ent.Comp.Plan != null)
{
var currentOperator = ent.Comp.Plan.CurrentOperator;
ShutdownTask(currentOperator, ent.Comp.Blackboard, HTNOperatorStatus.Failed);
ShutdownPlan(ent.Comp);
ent.Comp.Plan = null;
}
if (ent.Comp.Enabled && ent.Comp.PlanAccumulator <= 0)
RequestPlan(ent.Comp);
}
/// <summary> /// <summary>
/// Forces the NPC to replan. /// Forces the NPC to replan.
/// </summary> /// </summary>
@@ -147,12 +180,15 @@ public sealed class HTNSystem : EntitySystem
_planQueue.Process(); _planQueue.Process();
var query = EntityQueryEnumerator<ActiveNPCComponent, HTNComponent>(); var query = EntityQueryEnumerator<ActiveNPCComponent, HTNComponent>();
while(query.MoveNext(out var uid, out _, out var comp)) while (query.MoveNext(out var uid, out _, out var comp))
{ {
// If we're over our max count or it's not MapInit then ignore the NPC. // If we're over our max count or it's not MapInit then ignore the NPC.
if (count >= maxUpdates) if (count >= maxUpdates)
break; break;
if (!comp.Enabled)
continue;
if (comp.PlanningJob != null) if (comp.PlanningJob != null)
{ {
if (comp.PlanningJob.Exception != null) if (comp.PlanningJob.Exception != null)

View File

@@ -1,4 +1,5 @@
using Content.Server.Interaction; using Content.Server.Interaction;
using Content.Shared.Physics;
namespace Content.Server.NPC.HTN.Preconditions; namespace Content.Server.NPC.HTN.Preconditions;
@@ -13,6 +14,9 @@ public sealed partial class TargetInLOSPrecondition : HTNPrecondition
[DataField("rangeKey")] [DataField("rangeKey")]
public string RangeKey = "RangeKey"; public string RangeKey = "RangeKey";
[DataField("opaqueKey")]
public bool UseOpaqueForLOSChecksKey = true;
public override void Initialize(IEntitySystemManager sysManager) public override void Initialize(IEntitySystemManager sysManager)
{ {
base.Initialize(sysManager); base.Initialize(sysManager);
@@ -27,7 +31,8 @@ public sealed partial class TargetInLOSPrecondition : HTNPrecondition
return false; return false;
var range = blackboard.GetValueOrDefault<float>(RangeKey, _entManager); var range = blackboard.GetValueOrDefault<float>(RangeKey, _entManager);
var collisionGroup = UseOpaqueForLOSChecksKey ? CollisionGroup.Opaque : (CollisionGroup.Impassable | CollisionGroup.InteractImpassable);
return _interaction.InRangeUnobstructed(owner, target, range); return _interaction.InRangeUnobstructed(owner, target, range, collisionGroup);
} }
} }

View File

@@ -33,6 +33,12 @@ public sealed partial class GunOperator : HTNOperator, IHtnConditionalShutdown
[DataField("requireLOS")] [DataField("requireLOS")]
public bool RequireLOS = false; public bool RequireLOS = false;
/// <summary>
/// If true, only opaque objects will block line of sight.
/// </summary>
[DataField("opaqueKey")]
public bool UseOpaqueForLOSChecks = false;
// Like movement we add a component and pass it off to the dedicated system. // Like movement we add a component and pass it off to the dedicated system.
public override async Task<(bool Valid, Dictionary<string, object>? Effects)> Plan(NPCBlackboard blackboard, public override async Task<(bool Valid, Dictionary<string, object>? Effects)> Plan(NPCBlackboard blackboard,
@@ -56,8 +62,10 @@ public sealed partial class GunOperator : HTNOperator, IHtnConditionalShutdown
public override void Startup(NPCBlackboard blackboard) public override void Startup(NPCBlackboard blackboard)
{ {
base.Startup(blackboard); base.Startup(blackboard);
var ranged = _entManager.EnsureComponent<NPCRangedCombatComponent>(blackboard.GetValue<EntityUid>(NPCBlackboard.Owner)); var ranged = _entManager.EnsureComponent<NPCRangedCombatComponent>(blackboard.GetValue<EntityUid>(NPCBlackboard.Owner));
ranged.Target = blackboard.GetValue<EntityUid>(TargetKey); ranged.Target = blackboard.GetValue<EntityUid>(TargetKey);
ranged.UseOpaqueForLOSChecks = UseOpaqueForLOSChecks;
if (blackboard.TryGetValue<float>(NPCBlackboard.RotateSpeed, out var rotSpeed, _entManager)) if (blackboard.TryGetValue<float>(NPCBlackboard.RotateSpeed, out var rotSpeed, _entManager))
{ {

View File

@@ -0,0 +1,12 @@
namespace Content.Server.NPC.Queries.Considerations;
/// <summary>
/// Returns 0f if the NPC has a <see cref="TurretTargetSettingsComponent"/> and the
/// target entity is exempt from being targeted, otherwise it returns 1f.
/// See <see cref="TurretTargetSettingsSystem.EntityIsTargetForTurret"/>
/// for further details on turret target validation.
/// </summary>
public sealed partial class TurretTargetingCon : UtilityConsideration
{
}

View File

@@ -1,6 +1,7 @@
using Content.Server.NPC.Components; using Content.Server.NPC.Components;
using Content.Shared.CombatMode; using Content.Shared.CombatMode;
using Content.Shared.Interaction; using Content.Shared.Interaction;
using Content.Shared.Physics;
using Content.Shared.Weapons.Ranged.Components; using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events; using Content.Shared.Weapons.Ranged.Events;
using Robust.Shared.Map; using Robust.Shared.Map;
@@ -132,8 +133,10 @@ public sealed partial class NPCCombatSystem
if (comp.LOSAccumulator < 0f) if (comp.LOSAccumulator < 0f)
{ {
comp.LOSAccumulator += UnoccludedCooldown; comp.LOSAccumulator += UnoccludedCooldown;
// For consistency with NPC steering. // For consistency with NPC steering.
comp.TargetInLOS = _interaction.InRangeUnobstructed(uid, comp.Target, distance + 0.1f); var collisionGroup = comp.UseOpaqueForLOSChecks ? CollisionGroup.Opaque : (CollisionGroup.Impassable | CollisionGroup.InteractImpassable);
comp.TargetInLOS = _interaction.InRangeUnobstructed(uid, comp.Target, distance + 0.1f, collisionGroup);
} }
if (!comp.TargetInLOS) if (!comp.TargetInLOS)

View File

@@ -20,6 +20,7 @@ using Content.Shared.NPC.Systems;
using Content.Shared.Nutrition.Components; using Content.Shared.Nutrition.Components;
using Content.Shared.Nutrition.EntitySystems; using Content.Shared.Nutrition.EntitySystems;
using Content.Shared.Tools.Systems; using Content.Shared.Tools.Systems;
using Content.Shared.Turrets;
using Content.Shared.Weapons.Melee; using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Ranged.Components; using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events; using Content.Shared.Weapons.Ranged.Events;
@@ -53,6 +54,7 @@ public sealed class NPCUtilitySystem : EntitySystem
[Dependency] private readonly ExamineSystemShared _examine = default!; [Dependency] private readonly ExamineSystemShared _examine = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly MobThresholdSystem _thresholdSystem = default!; [Dependency] private readonly MobThresholdSystem _thresholdSystem = default!;
[Dependency] private readonly TurretTargetSettingsSystem _turretTargetSettings = default!;
private EntityQuery<PuddleComponent> _puddleQuery; private EntityQuery<PuddleComponent> _puddleQuery;
private EntityQuery<TransformComponent> _xformQuery; private EntityQuery<TransformComponent> _xformQuery;
@@ -358,6 +360,14 @@ public sealed class NPCUtilitySystem : EntitySystem
return 1f; return 1f;
return 0f; return 0f;
} }
case TurretTargetingCon:
{
if (!TryComp<TurretTargetSettingsComponent>(owner, out var turretTargetSettings) ||
_turretTargetSettings.EntityIsTargetForTurret((owner, turretTargetSettings), targetUid))
return 1f;
return 0f;
}
default: default:
throw new NotImplementedException(); throw new NotImplementedException();
} }

View File

@@ -15,9 +15,15 @@ namespace Content.Shared.Access
/// <summary> /// <summary>
/// The player-visible name of the access level, in the ID card console and such. /// The player-visible name of the access level, in the ID card console and such.
/// </summary> /// </summary>
[DataField("name")] [DataField]
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Denotes whether this access level is intended to be assignable to a crew ID card.
/// </summary>
[DataField]
public bool CanAddToIdCard = true;
public string GetAccessLevelName() public string GetAccessLevelName()
{ {
if (Name is { } name) if (Name is { } name)

View File

@@ -0,0 +1,19 @@
using Content.Shared.Access;
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared.Turrets;
/// <summary>
/// Attached to entities to provide them with turret target selection data.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(TurretTargetSettingsSystem))]
public sealed partial class TurretTargetSettingsComponent : Component
{
/// <summary>
/// Crew with one or more access levels from this list are exempt from being targeted by turrets.
/// </summary>
[DataField, AutoNetworkedField]
public HashSet<ProtoId<AccessLevelPrototype>> ExemptAccessLevels = new();
}

View File

@@ -0,0 +1,126 @@
using Content.Shared.Access;
using Content.Shared.Access.Systems;
using JetBrains.Annotations;
using Robust.Shared.Prototypes;
namespace Content.Shared.Turrets;
/// <summary>
/// This system is used for validating potential targets for NPCs with a <see cref="TurretTargetSettingsComponent"/> (i.e., turrets).
/// A turret will consider an entity a valid target if the entity does not possess any access tags which appear on the
/// turret's <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list.
/// </summary>
public sealed partial class TurretTargetSettingsSystem : EntitySystem
{
[Dependency] private readonly AccessReaderSystem _accessReader = default!;
private ProtoId<AccessLevelPrototype> _accessLevelBorg = "Borg";
private ProtoId<AccessLevelPrototype> _accessLevelBasicSilicon = "BasicSilicon";
/// <summary>
/// Adds or removes access levels from a <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list.
/// </summary>
/// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="exemption">The proto ID for the access level</param>
/// <param name="enabled">Set 'true' to add the exemption, or 'false' to remove it</param>
[PublicAPI]
public void SetAccessLevelExemption(Entity<TurretTargetSettingsComponent> ent, ProtoId<AccessLevelPrototype> exemption, bool enabled)
{
if (enabled)
ent.Comp.ExemptAccessLevels.Add(exemption);
else
ent.Comp.ExemptAccessLevels.Remove(exemption);
}
/// <summary>
/// Adds or removes a collection of access levels from a <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list.
/// </summary>
/// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="exemption">The collection of access level proto IDs to add or remove</param>
/// <param name="enabled">Set 'true' to add the collection as exemptions, or 'false' to remove them</param>
[PublicAPI]
public void SetAccessLevelExemptions(Entity<TurretTargetSettingsComponent> ent, ICollection<ProtoId<AccessLevelPrototype>> exemptions, bool enabled)
{
foreach (var exemption in exemptions)
SetAccessLevelExemption(ent, exemption, enabled);
}
/// <summary>
/// Sets a <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list to contain only a supplied collection of access levels.
/// </summary>
/// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="exemptions">The supplied collection of access level proto IDs</param>
[PublicAPI]
public void SyncAccessLevelExemptions(Entity<TurretTargetSettingsComponent> ent, ICollection<ProtoId<AccessLevelPrototype>> exemptions)
{
ent.Comp.ExemptAccessLevels.Clear();
SetAccessLevelExemptions(ent, exemptions, true);
}
/// <summary>
/// Sets a <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list to match that of another.
/// </summary>
/// <param name="target">The entity this is having its exemption list updated <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="source">The entity that is being used as a template for the target</param>
[PublicAPI]
public void SyncAccessLevelExemptions(Entity<TurretTargetSettingsComponent> target, Entity<TurretTargetSettingsComponent> source)
{
SyncAccessLevelExemptions(target, source.Comp.ExemptAccessLevels);
}
/// <summary>
/// Returns whether a <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list contains a specific access level.
/// </summary>
/// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="exemption">The access level proto ID being checked</param>
[PublicAPI]
public bool HasAccessLevelExemption(Entity<TurretTargetSettingsComponent> ent, ProtoId<AccessLevelPrototype> exemption)
{
if (ent.Comp.ExemptAccessLevels.Count == 0)
return false;
return ent.Comp.ExemptAccessLevels.Contains(exemption);
}
/// <summary>
/// Returns whether a <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list contains one or more access levels from another collection.
/// </summary>
/// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="exemptions"></param>
[PublicAPI]
public bool HasAnyAccessLevelExemption(Entity<TurretTargetSettingsComponent> ent, ICollection<ProtoId<AccessLevelPrototype>> exemptions)
{
if (ent.Comp.ExemptAccessLevels.Count == 0)
return false;
foreach (var exemption in exemptions)
{
if (HasAccessLevelExemption(ent, exemption))
return true;
}
return false;
}
/// <summary>
/// Returns whether an entity is a valid target for a turret.
/// </summary>
/// <remarks>
/// Returns false if the target possesses one or more access tags that are present on the entity's <see cref="TurretTargetSettingsComponent.ExemptAccessLevels"/> list.
/// </remarks>
/// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
/// <param name="target">The target entity</param>
[PublicAPI]
public bool EntityIsTargetForTurret(Entity<TurretTargetSettingsComponent> ent, EntityUid target)
{
var accessLevels = _accessReader.FindAccessTags(target);
if (accessLevels.Contains(_accessLevelBorg))
return !HasAccessLevelExemption(ent, _accessLevelBorg);
if (accessLevels.Contains(_accessLevelBasicSilicon))
return !HasAccessLevelExemption(ent, _accessLevelBasicSilicon);
return !HasAnyAccessLevelExemption(ent, accessLevels);
}
}

View File

@@ -45,3 +45,7 @@ id-card-access-level-syndicate-agent = Syndicate Agent
id-card-access-level-central-command = Central Command id-card-access-level-central-command = Central Command
id-card-access-level-wizard = Wizard id-card-access-level-wizard = Wizard
id-card-access-level-station-ai = Artifical Intelligence
id-card-access-level-borg = Cyborg
id-card-access-level-basic-silicon = Robot

View File

@@ -13,14 +13,20 @@
- type: accessGroup - type: accessGroup
id: Command id: Command
tags: tags:
- Command
- Captain - Captain
- HeadOfPersonnel - Command
- ChiefEngineer
- ChiefMedicalOfficer
- Cryogenics - Cryogenics
- HeadOfPersonnel
- HeadOfSecurity
- Quartermaster
- ResearchDirector
- type: accessLevel - type: accessLevel
id: EmergencyShuttleRepealAll id: EmergencyShuttleRepealAll
name: id-card-access-level-emergency-shuttle-repeal name: id-card-access-level-emergency-shuttle-repeal
canAddToIdCard: false
- type: accessLevel - type: accessLevel
id: Cryogenics id: Cryogenics

View File

@@ -0,0 +1,21 @@
- type: accessLevel
id: Borg
name: id-card-access-level-borg
canAddToIdCard: false
- type: accessLevel
id: BasicSilicon
name: id-card-access-level-basic-silicon
canAddToIdCard: false
- type: accessLevel
id: StationAi
name: id-card-access-level-station-ai
canAddToIdCard: false
- type: accessGroup
id: Silicon
tags:
- StationAi
- Borg
- BasicSilicon

View File

@@ -290,6 +290,8 @@
enabled: false enabled: false
groups: groups:
- AllAccess - AllAccess
tags:
- Borg
- type: AccessReader - type: AccessReader
access: [["Command"], ["Research"]] access: [["Command"], ["Research"]]
- type: ShowJobIcons - type: ShowJobIcons

View File

@@ -31,6 +31,9 @@
- type: NpcFactionMember - type: NpcFactionMember
factions: factions:
- SimpleNeutral - SimpleNeutral
- type: Access
tags:
- BasicSilicon
- type: IntrinsicRadioReceiver - type: IntrinsicRadioReceiver
- type: ActiveRadio - type: ActiveRadio
channels: channels:

View File

@@ -30,6 +30,7 @@
- type: Access - type: Access
groups: groups:
- AllAccess - AllAccess
- Silicon
tags: tags:
- NuclearOperative - NuclearOperative
- SyndicateAgent - SyndicateAgent

View File

@@ -430,6 +430,7 @@
- type: Access - type: Access
groups: groups:
- AllAccess - AllAccess
- Silicon
- type: Eye - type: Eye
drawFov: false drawFov: false
- type: Examiner - type: Examiner

View File

@@ -827,6 +827,7 @@
- type: Access - type: Access
groups: groups:
- AllAccess - AllAccess
- Silicon
tags: tags:
- CentralCommand - CentralCommand
- NuclearOperative - NuclearOperative

View File

@@ -90,6 +90,8 @@
- Armory - Armory
- Atmospherics - Atmospherics
- Bar - Bar
- BasicSilicon
- Borg
- Brig - Brig
- Detective - Detective
- Captain - Captain
@@ -117,6 +119,7 @@
- Salvage - Salvage
- Security - Security
- Service - Service
- StationAi
- Theatre - Theatre
- CentralCommand - CentralCommand
- NuclearOperative - NuclearOperative

View File

@@ -32,6 +32,39 @@
- tasks: - tasks:
- !type:HTNCompoundTask - !type:HTNCompoundTask
task: IdleSpinCompound task: IdleSpinCompound
- type: htnCompound
id: EnergyTurretCompound
branches:
- tasks:
- !type:HTNPrimitiveTask
operator: !type:UtilityOperator
proto: NearbyGunTargets
- !type:HTNPrimitiveTask
preconditions:
- !type:KeyExistsPrecondition
key: Target
- !type:TargetInRangePrecondition
targetKey: Target
# TODO: Non-scuffed
rangeKey: RangedRange
- !type:TargetInLOSPrecondition
targetKey: Target
rangeKey: RangedRange
opaqueKey: true
operator: !type:GunOperator
targetKey: Target
opaqueKey: true
services:
- !type:UtilityService
id: RangedService
proto: NearbyGunTargets
key: Target
- tasks:
- !type:HTNCompoundTask
task: IdleSpinCompound
- type: htnCompound - type: htnCompound
id: SimpleRangedHostileCompound id: SimpleRangedHostileCompound

View File

@@ -188,6 +188,10 @@
considerations: considerations:
- !type:TargetIsAliveCon - !type:TargetIsAliveCon
curve: !type:BoolCurve curve: !type:BoolCurve
- !type:TargetIsCritCon
curve: !type:InverseBoolCurve
- !type:TurretTargetingCon
curve: !type:BoolCurve
- !type:TargetDistanceCon - !type:TargetDistanceCon
curve: !type:PresetCurve curve: !type:PresetCurve
preset: TargetDistance preset: TargetDistance