using Godot; using Rokojori.Extensions; namespace Rokojori { // Single owner of lock state. LockOnPointer only tracks candidates and the current selection; // ThirdPersonCamera (or anything else) only reads isLocked/lockTarget from here. [Tool] [GlobalClass] public partial class CameraLocker : Action { [Export] public LockOnPointer source; [Export] public Sensor engageSensor; [ExportGroup( "Release" )] [Export] public float releaseDistance = 0f; [Export] public Node3D releaseDistanceOrigin; [ExportGroup( "Cycling" )] [Export] public LockOnCyclingSetup cyclingSetup; [Export] public LockOnCyclingSetup lockedCyclingSetup; [ExportGroup( "Visualizer" )] [Export] public Node3D visualizerMarker; [ExportGroup( "Candidate Events" )] [Export] public Action onCandidateSpotted; [Export] public Action onCandidateUnlocked; [Export] public Action onCandidateVanished; [Export] public Action onCandidateChanged; [Export] public bool triggerCanidatesWhenLocked = false; [ExportGroup( "Lock Events" )] [Export] public Action onLock; [Export] public Action onReleasedLock; [Export] public Action onChangeLocked; [ExportGroup( "Read Only" )] [Export] public Pointable lockTarget; public bool isLocked => lockTarget != null; Pointable _previousCandidate; bool _wasEngaged = false; protected override void _OnTrigger() { if ( source == null ) { return; } source.Trigger(); UpdateCandidateEvents(); var previousTarget = lockTarget; var wasLocked = previousTarget != null; // Order matters: cycling may move an existing lock, engagement may start/end one, // auto-release may end one - each only touches lockTarget for its own reason, nothing // here resyncs it every frame just because source.pointable drifted on its own. UpdateCycling(); UpdateEngagement(); UpdateAutoRelease(); FireLockEvents( wasLocked, previousTarget ); UpdateVisualizer(); } public void Release() { if ( lockTarget == null ) { return; } var previousTarget = lockTarget; lockTarget = null; FireLockEvents( true, previousTarget ); } void UpdateCandidateEvents() { if ( lockTarget != null && ! triggerCanidatesWhenLocked ) { return; } var candidate = source.pointable; if ( candidate == _previousCandidate ) { return; } _previousCandidate = candidate; onCandidateChanged?.Trigger(); Action.TriggerSafe( candidate == null ? onCandidateVanished : onCandidateSpotted ); } void UpdateCycling() { var setup = isLocked ? ( lockedCyclingSetup ?? cyclingSetup ) : ( cyclingSetup ?? lockedCyclingSetup ); if ( setup == null ) { return; } var cycled = false; if ( setup.cycleMode == LockOnCycleMode.ClosestCycle ) { if ( setup.cycleSensor != null && setup.cycleSensor.isDown ) { source.CycleNext(); cycled = true; } } else if ( setup.cycleMode == LockOnCycleMode.Directional && setup.directionalCycleInputs != null ) { for ( int i = 0; i < setup.directionalCycleInputs.Length; i++ ) { var input = setup.directionalCycleInputs[ i ]; if ( input == null || input.sensor == null || ! input.sensor.isDown ) { continue; } source.CycleInScreenSpaceDirection( input.direction ); cycled = true; break; } } // Cycling is the only thing that is allowed to move an already-established lock onto a // different candidate - a deliberate, one-time assignment here, not a per-frame resync. if ( cycled && isLocked ) { lockTarget = source.pointable; } } void UpdateEngagement() { var engaged = engageSensor != null && engageSensor.isActive; var risingEdge = engaged && ! _wasEngaged; _wasEngaged = engaged; if ( ! engaged ) { lockTarget = null; return; } // Only a fresh press/toggle-on acquires a target. Once locked, the lock is held // regardless of what source.pointable does on its own (e.g. losing/regaining the raycast // hit) - it only changes via cycling, auto-release, or an explicit Release() call. if ( lockTarget == null && risingEdge ) { lockTarget = source.pointable; } } void UpdateAutoRelease() { if ( lockTarget == null ) { return; } if ( IsGone( lockTarget ) ) { lockTarget = null; return; } if ( IsTooFar( lockTarget ) ) { lockTarget = null; } } // Covers both an actually freed node and one that was removed from the tree without being // freed (e.g. pooled/recycled enemies) - either way it is no longer something the lock // should keep pointing at. static bool IsGone( Pointable target ) { return target == null || ! IsInstanceValid( target ) || ! target.IsInsideTree(); } bool IsTooFar( Pointable target ) { if ( releaseDistance <= 0f ) { return false; } var origin = releaseDistanceOrigin ?? source.caster; if ( origin == null ) { return false; } return origin.GlobalPosition.DistanceTo( target.GlobalPosition ) > releaseDistance; } void ReleaseEngageSensorIfToggled() { if ( engageSensor is ActivationModeSensor ams && ams.isActive ) { ams.Release(); } } void FireLockEvents( bool wasLocked, Pointable previousTarget ) { var isLockedNow = lockTarget != null; if ( isLockedNow && ! wasLocked ) { Action.TriggerSafe( onLock ); return; } if ( ! isLockedNow && wasLocked ) { ReleaseEngageSensorIfToggled(); Action.TriggerSafe( onReleasedLock ); if ( source.IsCandidate( previousTarget ) ) { Action.TriggerSafe( onCandidateUnlocked ); } return; } if ( isLockedNow && wasLocked && lockTarget != previousTarget ) { Action.TriggerSafe( onChangeLocked ); } } void UpdateVisualizer() { if ( visualizerMarker == null ) { return; } var current = lockTarget ?? source.pointable; if ( ! IsGone( current ) ) { visualizerMarker.CopyGlobalPoseFrom( current.pointerPosition ); } } } }