extends Area # Forwards input events on a world space collision shape to a UI # # This is intended for use with world space 3D UI. The following node # setup is expected: # # o UI # o Viewport # o Controls... # o Area # o Quad (MeshInstance -> PlaneMesh) # o Box (CollisionShape -> BoxShape) # # This script gets attached to the area node and receives the input events # from the Box (CollisionShape). Mouse events are translated into viewport # coordinates. All input is forwarded into the Viewport node. # ------------------------------------------------------------------------------------------------- # Path to the node holding the Viewport containing the UI export var ViewportPath = "../Viewport" # Size of the collision shape that catches raycasts for the UI export var CollisionShapeSize = Vector2(3.0, 2.0) # Whether the camera or viewport UI plane can ever move relative to each other # @remarks # Event when the mouse sits still, if the camera or the plane onto which the UI is # being rendered moves around under the mouse cursor, the effective mouse position # within the plane's viewport would change. In this case, a synthetic mouse movement # needs to be sent to the UI. This option enables that (tiny performance cost) export var CameraOrPlaneCanMove = true # Whether the UI currently accepts input export var AcceptInput = true # ------------------------------------------------------------------------------------------------- # Maximum distance the UI can have from the camera to be clickable const MaximumDistanceCameraToUI = 25.0 # Smallest change in mouse position that gets reported to the viewport const SmallestViewportMouseMovement = 0.001 # Whether to do all input to viewport plane translation through Godot # @remarks # When the camera or viewport plane moves, a new synthetic mouse motion event is # generated. This can either be an event sent directly to the viewport # (more efficient, but currently has issues) or we can just inject the current # mouse position into Godot via Input.parse_input_event() (slightly higher # performance cost, but currently the only way that plays nice with the UI) const RelyOnGodotForInputTranslation = true # ------------------------------------------------------------------------------------------------- # Viewport node containing the UI, recieves forwarded events var viewportNode # Last known position of the mouse within the viewport # @remarks # Is set to null when the mouse goes outside the viewport. # Used to detect if no mouse movement within the viewport has occurred though movement of # the camera or viewport in world space. Allows us to reduce the number of mouse events. var lastViewportPosition # Mouse motion event that is used to report synthetic mouse movements # @remarks # Synthetic mouse movements means movements that are conjured out of thin air because # the position of the camera/viewport plane changed the effective location the mouse cursor # is hovering over in the viewport, even if the mouse itself hasn't changed position. var reusedMouseMotionEvent # ------------------------------------------------------------------------------------------------- # Initializes the script component when the node is added to the scene func _ready(): self.viewportNode = get_node(self.ViewportPath) assert(self.viewportNode != null) self.reusedMouseMotionEvent = InputEventMouseMotion.new() #set_process_input(false) self.viewportNode.set_process_input(false) self.viewportNode.gui_disable_input = true #if self.AcceptInput connect("input_event", self, "_area_input") # ------------------------------------------------------------------------------------------------- # Called to process user input # @param event Description of the input the user has performed func _input(var event): if !self.AcceptInput: return if event is InputEventMouseButton: self.reusedMouseMotionEvent.button_mask = event.button_mask return # Handled on the collision shape's input_event() directly if event is InputEventMouseMotion: return # Handled on the collision shape's input_event() directly if event is InputEventScreenDrag: return # Not currently supported by this script if event is InputEventScreenTouch: return # Not currently supported by this script # Forward all normal input (keyboard, etc.) to the viewport self.viewportNode.input(event) # ------------------------------------------------------------------------------------------------- # Called every frame to update the animation state of visuals # @param delta Time that has passed since the last frame in seconds func _process(var delta): if !self.CameraOrPlaneCanMove: return # No need to do the raycast at all if plane+camera are fixed if RelyOnGodotForInputTranslation: var mousePositionOnScreen = get_viewport().get_mouse_position() var event = self.reusedMouseMotionEvent event.global_position = mousePositionOnScreen event.position = mousePositionOnScreen event.relative = Vector2(0.0, 0.0) Input.parse_input_event(event) # If this is enabled, button clicking doesn't work ?! else: var mousePositionInWorld = raycastMouseInWorldSpace() var mousePositionInViewport = translateWorldPositionToViewport(mousePositionInWorld) if mousePositionInViewport == null: updateLastViewportPositionAndGetDelta(null) generateEventForMouseOutsideViewport() else: var mouseDelta = updateLastViewportPositionAndGetDelta(mousePositionInViewport) generateEventForMouseInsideViewport(mousePositionInViewport, mouseDelta) # ------------------------------------------------------------------------------------------------- # Called to process user input that occurs on the UI's area node # @param camera Camera through which the user was viewing the scene # @param event Description of the input event performed by the user # @param position World position at which the user performed the input # @param shapeIndex ? # @remarks # This event is generated when no other _input() function has processed # input normally. Godot casts a ray from the active camera and if it hits # the Area, it emits this signal. # # On the downside, the ray is not re-cast per frame, so if the Area is # moving relative to the camera, the position on the object becomes stale # until the user causes another input event by moving the mouse. func _area_input(var camera, var event, var position, var normal, var shapeIndex): if !self.AcceptInput: return if (event is InputEventMouseMotion) || (event is InputEventMouseButton): self.reusedMouseMotionEvent.button_mask = event.button_mask var mousePositionInViewport = translateWorldPositionToViewport(position) event.global_position = mousePositionInViewport event.position = mousePositionInViewport if event is InputEventMouseMotion: event.relative = updateLastViewportPositionAndGetDelta(mousePositionInViewport) # Throw away any mouse wheel input because Godot's UI just gets stuck # whenever this is processed. The mouse wheel is simulated as a button (?!?) # and even when we synthesize a 'button up' event, nothing works :-(( if event is InputEventMouseButton: if ( (event.button_index == BUTTON_WHEEL_UP) || (event.button_index == BUTTON_WHEEL_DOWN) || (event.button_index == BUTTON_WHEEL_LEFT) || (event.button_index == BUTTON_WHEEL_RIGHT) ): return self.viewportNode.input(event) #get_tree().set_input_as_handled() # We're blatantly ignoring InputEventScreenDrag here. # If you need drag'n drop, you need to implement it. # ------------------------------------------------------------------------------------------------- # Updates the last known mouse position in the viewport and returns the mouse movement delta # @param newViewportPosition New position of the mouse within the viewport # @returns The position change from the last position (mouse delta) # @remarks # Passing null is valid. This indicates that the mouse is outside the viewport. # When either the new or the previous mouse position are null, the delta will also # be null. This prevents sudden jumps in draggable controls when the mouse leaves # the viewport on one point and re-enters it on another. func updateLastViewportPositionAndGetDelta(var mousePositionInViewport): if self.lastViewportPosition == null: self.lastViewportPosition = mousePositionInViewport return Vector2(0.0, 0.0) elif mousePositionInViewport == null: self.lastViewportPosition = null return Vector2(0.0, 0.0) else: var delta = Vector2( mousePositionInViewport.x - self.lastViewportPosition.x, mousePositionInViewport.y - self.lastViewportPosition.y ) self.lastViewportPosition = mousePositionInViewport return delta # ------------------------------------------------------------------------------------------------- # Updates the reported mouse position inside the viewport by doing a raycast func raycastMouseInWorldSpace(): var viewport = get_viewport() var mousePositionOnScreen = viewport.get_mouse_position() var activeCamera = viewport.get_camera() var rayOrigin = activeCamera.project_ray_origin(mousePositionOnScreen) var rayDirection = activeCamera.project_ray_normal(mousePositionOnScreen) var spaceState = get_world().get_direct_space_state() var hitInfo = spaceState.intersect_ray( rayOrigin, rayOrigin + rayDirection * MaximumDistanceCameraToUI ) # Only return a position if the mouse hit the correct plane / collision shape if !hitInfo.empty(): var collider = hitInfo.collider if collider == self: return hitInfo.position else: collider = collider.get_node("..") # Collision shape may be child of area if collider == self: return hitInfo.position return null # ------------------------------------------------------------------------------------------------- # Translates a position in world space into viewport coordinates # @param worldPosition Position in world space that will get translated # @returns The viewport position that correlates to the specified world position func translateWorldPositionToViewport(var worldPosition): if worldPosition == null: return null else: var worldToAreaTransform = get_global_transform().affine_inverse() var areaPosition = worldToAreaTransform * worldPosition # Convert the area coordinates in world space to viewport coordinates var viewportPosition = Vector2(areaPosition.x, -areaPosition.y) viewportPosition.x += self.CollisionShapeSize.x / 2.0 viewportPosition.y += self.CollisionShapeSize.y / 2.0 viewportPosition.x /= self.CollisionShapeSize.x viewportPosition.y /= self.CollisionShapeSize.y viewportPosition.x *= self.viewportNode.size.x viewportPosition.y *= self.viewportNode.size.y return viewportPosition # ------------------------------------------------------------------------------------------------- # Generates an input event in the viewport when the mouse cursor is outside the viewport plane # @remarks # I couldn't find any documentation on how this should be communicated to the viewport. # Thus I simply change the position the viewport is told the mouse to be at, to a valid, # but likely empty, position :-/ func generateEventForMouseOutsideViewport(): var event = self.reusedMouseMotionEvent # I'd report 'outside' as {-1,-1} or use a special 'exit' event, but there doesn't seem # to be such a thing in Godot, so we just claim the mouse moved to {0,0} event.global_position = Vector2(0.0, 0.0) event.position = Vector2(0.0, 0.0) event.relative = Vector2(0.0, 0.0) self.viewportNode.input(event) # ------------------------------------------------------------------------------------------------- # Generates an input event in the viewport when the mouse cursor is inside the viewport plane # @param absolute New absolute mouse position in viewport coordinates # @param relative Position change relative to the previous position of the mouse func generateEventForMouseInsideViewport(var absolute, var relative): var event = self.reusedMouseMotionEvent event.global_position = absolute event.position = absolute event.relative = relative self.viewportNode.input(event)