diff --git a/trinity/CMakeLists.txt b/trinity/CMakeLists.txt index cbcebdd1b..5fd21e5d5 100644 --- a/trinity/CMakeLists.txt +++ b/trinity/CMakeLists.txt @@ -770,6 +770,9 @@ set(_SOURCES Eve/AudioGameObject.cpp Eve/AudioGameObject.h Eve/AudioGameObject_Blue.cpp + Eve/EveTriggerVolume.cpp + Eve/EveTriggerVolume.h + Eve/EveTriggerVolume_Blue.cpp Eve/EveEffectRoot2.cpp Eve/EveEffectRoot2.h Eve/EveEffectRoot2_Blue.cpp diff --git a/trinity/Controllers/Actions/Tr2ActionBindRTPC.cpp b/trinity/Controllers/Actions/Tr2ActionBindRTPC.cpp index acbe8e8dc..050b18579 100644 --- a/trinity/Controllers/Actions/Tr2ActionBindRTPC.cpp +++ b/trinity/Controllers/Actions/Tr2ActionBindRTPC.cpp @@ -85,6 +85,7 @@ void Tr2ActionBindRTPC::StartWithController( ITr2ActionController* controller ) void Tr2ActionBindRTPC::Stop( ITr2ActionController& controller ) { controller.UnRegisterUpdateable( *this ); + m_emitter = nullptr; } void Tr2ActionBindRTPC::StopWithController( ITr2ActionController* controller ) diff --git a/trinity/Eve/EveTriggerVolume.cpp b/trinity/Eve/EveTriggerVolume.cpp new file mode 100644 index 000000000..f14ac8405 --- /dev/null +++ b/trinity/Eve/EveTriggerVolume.cpp @@ -0,0 +1,292 @@ +// Copyright © 2026 CCP ehf. + +#include "StdAfx.h" +#include "EveTriggerVolume.h" +#include "TriDevice.h" + +namespace +{ +void InvokeTriggerCallback( void* context, bool entered ) +{ + EveTriggerVolume* triggerVolume = reinterpret_cast( context ); + + triggerVolume->InvokeCallback( entered ); + triggerVolume->GetRawRoot()->Unlock(); +} + +void TriggerEnterCallback( void* context ) +{ + InvokeTriggerCallback( context, true ); +} + +void TriggerExitCallback( void* context ) +{ + InvokeTriggerCallback( context, false ); +} +} + +EveTriggerVolume::EveTriggerVolume( IRoot* lockobj ) : + PARENTLOCK( m_volumes ), + PARENTLOCK( m_exclusionVolumes ), + PARENTLOCK( m_externalParameters ), + m_rotation( 0.0f, 0.0f, 0.0f, 1.0f ), + m_translation( 0.0f, 0.0f, 0.0f ), + m_worldTransform( IdentityMatrix() ), + m_enterThreshold( 0.5f ), + m_isInside( false ), + m_currentIntensity( 0.0f ) +{ +} + +EveTriggerVolume::~EveTriggerVolume() +{ +} + +void EveTriggerVolume::RebuildBoundingSphere() +{ + CCP_STATS_ZONE( __FUNCTION__ ); + + m_boundingSphere = CcpMath::Sphere(); + + for( const auto& volume : m_volumes ) + { + auto volumeSphere = volume->GetBoundingSphere(); + + if( !volumeSphere.IsInitialized() ) + { + continue; + } + + if( !m_boundingSphere.IsInitialized() || volumeSphere.IsSphereInside( m_boundingSphere ) ) + { + m_boundingSphere = volumeSphere; + continue; + } + + if( m_boundingSphere.IsSphereInside( volumeSphere ) ) + { + continue; + } + + Vector3 delta = volumeSphere.center - m_boundingSphere.center; + float deltaLen = Length( delta ); + + m_boundingSphere.center += 0.5f * ( 1.f + ( volumeSphere.radius - m_boundingSphere.radius ) / deltaLen ) * delta; + m_boundingSphere.radius = 0.5f * ( m_boundingSphere.radius + volumeSphere.radius + deltaLen ); + } +} + +void EveTriggerVolume::SetCallback( const BlueScriptCallback& callback ) +{ + m_callback = callback; +} + +void EveTriggerVolume::InvokeCallback( bool entered ) +{ + BlueScriptCallback callback = m_callback; + if( !callback ) + { + return; + } + + callback.CallVoid( m_name.c_str(), entered ).ReportException(); +} + +void EveTriggerVolume::QueueCallback( bool entered ) +{ + if( !m_callback ) + { + return; + } + // Keeps the object alive until the queued callback runs. + GetRawRoot()->Lock(); + + if( entered ) + { + gTriDev->AddPostUpdateCallback( TriggerEnterCallback, reinterpret_cast( this ) ); + } + else + { + gTriDev->AddPostUpdateCallback( TriggerExitCallback, reinterpret_cast( this ) ); + } +} + +void EveTriggerVolume::UpdateWorldTransform( Be::Time time ) +{ + Vector3 translation; + + if( m_ballPosition ) + { + m_ballPosition->Update( &translation, time ); + } + else + { + translation = m_translation; + } + + m_worldTransform = RotationMatrix( m_rotation ) * TranslationMatrix( translation ); +} + +// IEveSpaceObject2 +void EveTriggerVolume::UpdateSyncronous( const EveUpdateContext& updateContext ) +{ + CCP_STATS_ZONE( __FUNCTION__ ); + + UpdateWorldTransform( updateContext.GetTime() ); + + RebuildBoundingSphere(); + + UpdateTriggerState( updateContext ); +} + +float EveTriggerVolume::GetMaxIntensity( const PIEveVolumeVector& volumes, const Vector3& position ) +{ + float intensity = 0.0f; + for( const auto& volume : volumes ) + { + intensity = std::max( intensity, volume->GetIntensity( position ) ); + if( intensity == 1.0f ) + { + // early exit + break; + } + } + return intensity; +} + +void EveTriggerVolume::UpdateTriggerState( const EveUpdateContext& updateContext ) +{ + m_currentIntensity = 0.0f; + + bool inside = false; + if( m_trackedPosition && !m_volumes.empty() ) + { + Vector3 trackedPosition; + m_trackedPosition->Update( &trackedPosition, updateContext.GetTime() ); + + Matrix inverseWorldTransform = Inverse( m_worldTransform ); + Vector3 positionInObjectSpace = Transform( trackedPosition, inverseWorldTransform ).GetXYZ(); + + // check first if the tracked position is within the bounding sphere + if( m_boundingSphere.IsPointInside( positionInObjectSpace ) ) + { + m_currentIntensity = GetMaxIntensity( m_volumes, positionInObjectSpace ); + + if( m_currentIntensity != 0.0f ) + { + // check if the tracked position is within an exclusion volume + float negativeIntensity = GetMaxIntensity( m_exclusionVolumes, positionInObjectSpace ); + m_currentIntensity = std::max( 0.0f, m_currentIntensity - negativeIntensity ); + } + } + + inside = m_currentIntensity >= m_enterThreshold; + } + + if( inside != m_isInside ) + { + m_isInside = inside; + QueueCallback( inside ); + } +} + +void EveTriggerVolume::UpdateAsyncronous( const EveUpdateContext& updateContext ) +{ +} + +void EveTriggerVolume::UpdateVisibility( const EveUpdateContext& updateContext, const Matrix& parentTransform ) +{ +} + +void EveTriggerVolume::GetRenderables( std::vector& renderables, Tr2ImpostorManager* impostors ) +{ +} + +bool EveTriggerVolume::GetBoundingSphere( Vector4& sphere, BoundingSphereQuery query ) const +{ + Vector3 worldCenter = Transform( m_boundingSphere.center, m_worldTransform ).GetXYZ(); + sphere = Vector4( worldCenter.x, worldCenter.y, worldCenter.z, std::max( m_boundingSphere.radius, 1.0f ) ); + return true; +} + +void EveTriggerVolume::UpdateModelCenterWorldPosition( Vector3& position, Be::Time t ) +{ + UpdateWorldTransform( t ); + GetModelCenterWorldPosition( position ); +} + +void EveTriggerVolume::GetModelCenterWorldPosition( Vector3& position ) const +{ + position = Transform( m_boundingSphere.center, m_worldTransform ).GetXYZ(); +} + +bool EveTriggerVolume::GetLocalBoundingBox( Vector3& min, Vector3& max ) +{ + // Fall back to a unit box when no volumes are set up yet, so the object stays pickable in Graphite. + float radius = std::max( m_boundingSphere.radius, 1.0f ); + Vector3 extent( radius, radius, radius ); + + min = m_boundingSphere.center - extent; + max = m_boundingSphere.center + extent; + return true; +} + +void EveTriggerVolume::GetLocalToWorldTransform( Matrix& transform ) const +{ + transform = m_worldTransform; +} + +Vector3 EveTriggerVolume::GetWorldPosition() +{ + return m_worldTransform.GetTranslation(); +} + +Quaternion EveTriggerVolume::GetWorldRotation() +{ + return Normalize( RotationQuaternion( m_worldTransform ) ); +} + +bool EveTriggerVolume::Initialize() +{ + UpdateWorldTransform( Be::Time( 0.0 ) ); + RebuildBoundingSphere(); + return true; +} + +void EveTriggerVolume::GetDebugOptions( Tr2DebugRendererOptions& options ) +{ + options.insert( "Trigger Volumes" ); + options.insert( "Trigger Exclusion Volumes" ); + options.insert( "Trigger Bounding Sphere" ); +} + +void EveTriggerVolume::RenderDebugInfo( ITr2DebugRenderer2& renderer ) +{ + if( renderer.HasOption( GetRawRoot(), "Trigger Volumes" ) ) + { + // green when the tracked position is inside, white otherwise + Color color = 0xFFFFFFFF; + if( m_isInside ) + { + color = 0xFF33FF33; + } + + for( const auto& volume : m_volumes ) + { + volume->RenderDebugInfo( renderer, m_worldTransform, color ); + } + } + + if( renderer.HasOption( GetRawRoot(), "Trigger Exclusion Volumes" ) ) + { + for( const auto& volume : m_exclusionVolumes ) + { + volume->RenderDebugInfo( renderer, m_worldTransform, 0xFFFF3333 ); + } + } + + if( renderer.HasOption( GetRawRoot(), "Trigger Bounding Sphere" ) ) + { + renderer.DrawSphere( this, TranslationMatrix( m_boundingSphere.center ) * m_worldTransform, m_boundingSphere.radius, 10, Tr2DebugRenderer::Wireframe, 0xff333333 ); + } +} diff --git a/trinity/Eve/EveTriggerVolume.h b/trinity/Eve/EveTriggerVolume.h new file mode 100644 index 000000000..01f9a28c1 --- /dev/null +++ b/trinity/Eve/EveTriggerVolume.h @@ -0,0 +1,137 @@ +// Copyright © 2026 CCP ehf. + +#pragma once + +#ifndef EveTriggerVolume_h +#define EveTriggerVolume_h + +#include "IWorldPosition.h" +#include "IEveSpaceObject2.h" +#include "Tr2DebugRenderer.h" +#include "Eve/Volume/IEveVolume.h" + +#ifdef BLUE_USE_LOCAL_ITr2DebugRenderer2 +// This is only needed for py2 as the file now belongs in blue. +// Unfortunatly the blue py2 branch cannot be updated at present due to security vulnerability work. +// The file version in the older blue versions had diverged from this one is incompatible. +#include "Include/ITr2DebugRenderer2.h" +#else +#include +#endif + +#include + +BLUE_DECLARE_INTERFACE( IEveVolume ); +BLUE_DECLARE_IVECTOR( IEveVolume ); +BLUE_DECLARE( Tr2ExternalParameter ); +BLUE_DECLARE_VECTOR( Tr2ExternalParameter ); +BLUE_DECLARE( EveTriggerVolume ); + +/** + * @class EveTriggerVolume + * @brief A volume that triggers a Python callback when a tracked position enters or exits it. + * + */ +BLUE_CLASS( EveTriggerVolume ) : + public IWorldPosition, + public IEveSpaceObject2, + public IInitialize, + public ITr2DebugRenderable +{ +public: + EXPOSE_TO_BLUE(); + + EveTriggerVolume( IRoot* lockobj = NULL ); + ~EveTriggerVolume(); + + /** + * @brief Sets the callable invoked on enter/exit transitions. + * + * @param callback Callable or None. + */ + void SetCallback( const BlueScriptCallback& callback ); + + /** + * @brief Invokes the stored callback. Called from the post-update callback on the main thread. + * @param entered True if the tracked position entered the volume, false if it exited. + */ + void InvokeCallback( bool entered ); + + // IEveSpaceObject2 + void UpdateSyncronous( const EveUpdateContext& updateContext ) override; + void UpdateAsyncronous( const EveUpdateContext& updateContext ) override; + void UpdateVisibility( const EveUpdateContext& updateContext, const Matrix& parentTransform ) override; + void GetRenderables( std::vector & renderables, Tr2ImpostorManager * impostors ) override; + bool GetBoundingSphere( Vector4 & sphere, BoundingSphereQuery query = EVE_BOUNDS_NORMAL ) const override; + void UpdateModelCenterWorldPosition( Vector3 & position, Be::Time t ) override; + void GetModelCenterWorldPosition( Vector3 & position ) const override; + bool GetLocalBoundingBox( Vector3 & min, Vector3 & max ) override; + void GetLocalToWorldTransform( Matrix & transform ) const override; + + // IWorldPosition + Vector3 GetWorldPosition() override; + Quaternion GetWorldRotation() override; + + // IInitialize + bool Initialize() override; + + // ITr2DebugRenderable + void GetDebugOptions( Tr2DebugRendererOptions & options ) override; + void RenderDebugInfo( ITr2DebugRenderer2 & renderer ) override; + +private: + /** + * @brief Recomputes the broad-phase bounding sphere from the volume list. + */ + void RebuildBoundingSphere(); + + /** + * @brief Rebuilds the world transform from the position curve when attached, + * otherwise from the translation attribute. + */ + void UpdateWorldTransform( Be::Time time ); + + /** + * @brief Returns the highest intensity any enabled volume in the list gives the position. + * @param volumes The volumes to evaluate. + * @param position The position to evaluate, in object space. + */ + static float GetMaxIntensity( const PIEveVolumeVector& volumes, const Vector3& position ); + + /** + * @brief Evaluates whether the tracked position is inside the volumes and fires the callback on transitions. + */ + void UpdateTriggerState( const EveUpdateContext& updateContext ); + + /** + * @brief Queues the callback for invocation at the post-update point on the main thread. + * @param entered True if the tracked position entered the volume, false if it exited. + */ + void QueueCallback( bool entered ); + + Quaternion m_rotation; + Vector3 m_translation; + + std::string m_name; + PIEveVolumeVector m_volumes; + PIEveVolumeVector m_exclusionVolumes; + PTr2ExternalParameterVector m_externalParameters; + + CcpMath::Sphere m_boundingSphere; + + ITriVectorFunctionPtr m_trackedPosition; + + ITriVectorFunctionPtr m_ballPosition; + + Matrix m_worldTransform; + + float m_enterThreshold; + bool m_isInside; + float m_currentIntensity; + + BlueScriptCallback m_callback; +}; + +TYPEDEF_BLUECLASS( EveTriggerVolume ); + +#endif diff --git a/trinity/Eve/EveTriggerVolume_Blue.cpp b/trinity/Eve/EveTriggerVolume_Blue.cpp new file mode 100644 index 000000000..564eadd84 --- /dev/null +++ b/trinity/Eve/EveTriggerVolume_Blue.cpp @@ -0,0 +1,93 @@ +// Copyright © 2026 CCP ehf. + +#include "StdAfx.h" +#include "EveTriggerVolume.h" + +BLUE_DEFINE( EveTriggerVolume ); + +const Be::ClassInfo* EveTriggerVolume::ExposeToBlue() +{ + EXPOSURE_BEGIN( EveTriggerVolume, "A standalone spatial trigger that fires a Python callback when a tracked position enters or exits its volumes" ) + MAP_INTERFACE( IEveSpaceObject2 ) + MAP_INTERFACE( IInitialize ) + MAP_INTERFACE( IWorldPosition ) + MAP_INTERFACE( ITr2DebugRenderable ) + + MAP_ATTRIBUTE( + "name", + m_name, + "Name identifier, passed to the callback so one handler can serve many trigger volumes", + Be::READWRITE | Be::PERSIST ) + + MAP_ATTRIBUTE( + "translation", + m_translation, + "Local translation of the trigger volume", + Be::READWRITE | Be::PERSIST ) + + MAP_ATTRIBUTE( + "rotation", + m_rotation, + "Local rotation of the trigger volume", + Be::READWRITE | Be::PERSIST ) + + MAP_ATTRIBUTE( + "volumes", + m_volumes, + "The volumes defining the trigger region", + Be::READ | Be::PERSIST ) + + MAP_ATTRIBUTE( + "exclusionVolumes", + m_exclusionVolumes, + "Volumes subtracted from the trigger region", + Be::READ | Be::PERSIST ) + + MAP_ATTRIBUTE( + "enterThreshold", + m_enterThreshold, + "Volume intensity (0..1) at which the tracked position counts as inside", + Be::READWRITE | Be::PERSIST ) + + MAP_ATTRIBUTE( + "externalParameters", + m_externalParameters, + "List of external parameters exposing per-placement values, e.g. for dungeon asset manipulations", + Be::READ | Be::PERSIST ) + + MAP_ATTRIBUTE( + "translationCurve", + m_ballPosition, + "Function for animated position updates, e.g. the object's own destiny ball in the client", + Be::READWRITE | Be::PERSIST ) + + MAP_ATTRIBUTE( + "trackedPositionCurve", + m_trackedPosition, + "Vector function slot for attaching a destiny ball as the tracked position", + Be::READWRITE ) + + MAP_ATTRIBUTE( + "isInside", + m_isInside, + "Whether the tracked position is currently inside the trigger region", + Be::READ ) + + MAP_ATTRIBUTE( + "intensity", + m_currentIntensity, + "Most recent evaluated volume intensity of the tracked position", + Be::READ ) + +#if BLUE_WITH_PYTHON + MAP_METHOD_AND_WRAP( + "SetCallback", + SetCallback, + "Sets the callable invoked on enter/exit transitions.\n" + "The callable is invoked as callback( name, entered ) where entered is\n" + "True on entry and False on exit. Pass None to clear the callback.\n" + ":param callback: callable or None" ) +#endif + + EXPOSURE_END() +}