Fix 2.5D editor viewport and gizmo for Godot 4.x

This commit is contained in:
Aaron Franke
2024-04-11 19:07:56 -07:00
parent 57c1cb9ffa
commit 3788da41cd
6 changed files with 120 additions and 87 deletions

View File

@@ -1,104 +1,127 @@
@tool
extends Node2D
# If the mouse is farther than this many pixels, it won't grab anything.
const DEADZONE_RADIUS: float = 20
const DEADZONE_RADIUS_SQ: float = DEADZONE_RADIUS * DEADZONE_RADIUS
# Not pixel perfect for all axes in all modes, but works well enough.
# Rounding is not done until after the movement is finished.
const ROUGHLY_ROUND_TO_PIXELS = true
# Set when the node is created.
var node_25d: Node25D
var spatial_node
var _spatial_node
# Input from Viewport25D, represents if the mouse is clicked.
var wants_to_move = false
# Used to control the state of movement.
var _moving = false
var _start_position = Vector2()
var _start_mouse_position := Vector2.ZERO
# Stores state of closest or currently used axis.
var dominant_axis
var _dominant_axis
@onready var lines_root = $Lines
@onready var lines = [$Lines/X, $Lines/Y, $Lines/Z]
@onready var _lines = [$X, $Y, $Z]
@onready var _viewport_overlay: SubViewport = get_parent()
@onready var _viewport_25d_bg: ColorRect = _viewport_overlay.get_parent()
func _process(_delta):
if not lines:
if not _lines:
return # Somehow this node hasn't been set up yet.
if not node_25d:
if not node_25d or not _viewport_25d_bg:
return # We're most likely viewing the Gizmo25D scene.
global_position = node_25d.global_position
# While getting the mouse position works in any viewport, it doesn't do
# anything significant unless the mouse is in the 2.5D viewport.
var mouse_position = get_local_mouse_position()
var mouse_position: Vector2 = _viewport_25d_bg.get_local_mouse_position()
var full_transform: Transform2D = _viewport_overlay.canvas_transform * global_transform
mouse_position = full_transform.affine_inverse() * mouse_position
if not _moving:
# If the mouse is farther than this many pixels, it won't grab anything.
var closest_distance = 20.0
dominant_axis = -1
for i in range(3):
lines[i].modulate.a = 0.8 # Unrelated, but needs a loop too.
var distance = _distance_to_segment_at_index(i, mouse_position)
if distance < closest_distance:
closest_distance = distance
dominant_axis = i
if dominant_axis == -1:
# If we're not hovering over a line, ensure they are placed correctly.
lines_root.global_position = node_25d.global_position
determine_dominant_axis(mouse_position)
if _dominant_axis == -1:
# If we're not hovering over a line, nothing to do.
return
lines[dominant_axis].modulate.a = 1
_lines[_dominant_axis].modulate.a = 1
if not wants_to_move:
_moving = false
elif wants_to_move and not _moving:
if _moving:
# When we're done moving, ensure the inspector is updated.
node_25d.notify_property_list_changed()
_moving = false
return
# By this point, we want to move.
if not _moving:
_moving = true
_start_position = mouse_position
if _moving:
# Change modulate of unselected axes.
lines[(dominant_axis + 1) % 3].modulate.a = 0.5
lines[(dominant_axis + 2) % 3].modulate.a = 0.5
# Calculate mouse movement and reset for next frame.
var mouse_diff = mouse_position - _start_position
_start_position = mouse_position
# Calculate movement.
var projected_diff = mouse_diff.project(lines[dominant_axis].points[1])
var movement = projected_diff.length() / Node25D.SCALE
if is_equal_approx(PI, projected_diff.angle_to(lines[dominant_axis].points[1])):
movement *= -1
# Apply movement.
spatial_node.transform.origin += spatial_node.transform.basis[dominant_axis] * movement
else:
# Make sure the gizmo is located at the object.
global_position = node_25d.global_position
if ROUGHLY_ROUND_TO_PIXELS:
spatial_node.transform.origin = (spatial_node.transform.origin * Node25D.SCALE).round() / Node25D.SCALE
# Move the gizmo lines appropriately.
lines_root.global_position = node_25d.global_position
node_25d.notify_property_list_changed()
_start_mouse_position = mouse_position
# By this point, we are moving.
move_using_mouse(mouse_position)
# Initializes after _ready due to the onready vars, called manually in Viewport25D.gd.
func determine_dominant_axis(mouse_position: Vector2) -> void:
var closest_distance = DEADZONE_RADIUS
_dominant_axis = -1
for i in range(3):
_lines[i].modulate.a = 0.8 # Unrelated, but needs a loop too.
var distance = _distance_to_segment_at_index(i, mouse_position)
if distance < closest_distance:
closest_distance = distance
_dominant_axis = i
func move_using_mouse(mouse_position: Vector2) -> void:
# Change modulate of unselected axes.
_lines[(_dominant_axis + 1) % 3].modulate.a = 0.5
_lines[(_dominant_axis + 2) % 3].modulate.a = 0.5
# Calculate movement.
var mouse_diff: Vector2 = mouse_position - _start_mouse_position
var line_end_point: Vector2 = _lines[_dominant_axis].points[1]
var projected_diff: Vector2 = mouse_diff.project(line_end_point)
var movement: float = projected_diff.length() * global_scale.x / Node25D.SCALE
if is_equal_approx(PI, projected_diff.angle_to(line_end_point)):
movement *= -1
# Apply movement.
var move_dir_3d: Vector3 = _spatial_node.transform.basis[_dominant_axis]
_spatial_node.transform.origin += move_dir_3d * movement
_snap_spatial_position()
# Move the gizmo appropriately.
global_position = node_25d.global_position
# Setup after _ready due to the onready vars, called manually in Viewport25D.gd.
# Sets up the points based on the basis values of the Node25D.
func initialize():
func setup(in_node_25d: Node25D):
node_25d = in_node_25d
var basis = node_25d.get_basis()
for i in range(3):
lines[i].points[1] = basis[i] * 3
_lines[i].points[1] = basis[i] * 3
global_position = node_25d.global_position
spatial_node = node_25d.get_child(0)
_spatial_node = node_25d.get_child(0)
func set_zoom(zoom: float) -> void:
var new_scale: float = EditorInterface.get_editor_scale() / zoom
global_scale = Vector2(new_scale, new_scale)
func _snap_spatial_position(step_meters: float = 1.0 / Node25D.SCALE) -> void:
var scaled_px: Vector3 = _spatial_node.transform.origin / step_meters
_spatial_node.transform.origin = scaled_px.round() * step_meters
# Figures out if the mouse is very close to a segment. This method is
# specialized for this script, it assumes that each segment starts at
# (0, 0) and it provides a deadzone around the origin.
func _distance_to_segment_at_index(index, point):
if not lines:
if not _lines:
return INF
if point.length_squared() < 400:
if point.length_squared() < DEADZONE_RADIUS_SQ:
return INF
var segment_end = lines[index].points[1]
var segment_end: Vector2 = _lines[index].points[1]
var length_squared = segment_end.length_squared()
if length_squared < 400:
if length_squared < DEADZONE_RADIUS_SQ:
return INF
var t = clamp(point.dot(segment_end) / length_squared, 0, 1)

View File

@@ -5,19 +5,17 @@
[node name="Gizmo25D" type="Node2D"]
script = ExtResource("1")
[node name="Lines" type="Node2D" parent="."]
[node name="X" type="Line2D" parent="Lines"]
[node name="X" type="Line2D" parent="."]
modulate = Color(1, 1, 1, 0.8)
points = PackedVector2Array(0, 0, 100, 0)
default_color = Color(0.91, 0.273, 0, 1)
[node name="Y" type="Line2D" parent="Lines"]
[node name="Y" type="Line2D" parent="."]
modulate = Color(1, 1, 1, 0.8)
points = PackedVector2Array(0, 0, 0, -100)
default_color = Color(0, 0.91, 0.273, 1)
[node name="Z" type="Line2D" parent="Lines"]
[node name="Z" type="Line2D" parent="."]
modulate = Color(1, 1, 1, 0.8)
points = PackedVector2Array(0, 0, 0, 100)
default_color = Color(0.3, 0, 1, 1)

View File

@@ -61,16 +61,15 @@ size_flags_horizontal = 3
alignment = 2
[node name="ZoomOut" type="Button" parent="TopBar/Zoom"]
custom_minimum_size = Vector2(28, 2.08165e-12)
custom_minimum_size = Vector2(32, 2.08165e-12)
layout_mode = 2
text = "-"
[node name="ZoomPercent" type="Label" parent="TopBar/Zoom"]
custom_minimum_size = Vector2(80, 2.08165e-12)
custom_minimum_size = Vector2(100, 2.08165e-12)
layout_mode = 2
text = "100%"
horizontal_alignment = 1
clip_text = true
[node name="ZoomReset" type="Button" parent="TopBar/Zoom/ZoomPercent"]
modulate = Color(1, 1, 1, 0)
@@ -79,10 +78,13 @@ anchor_right = 1.0
anchor_bottom = 1.0
[node name="ZoomIn" type="Button" parent="TopBar/Zoom"]
custom_minimum_size = Vector2(28, 2.08165e-12)
custom_minimum_size = Vector2(32, 2.08165e-12)
layout_mode = 2
text = "+"
[node name="Spacer" type="Control" parent="TopBar/Zoom"]
layout_mode = 2
[node name="Viewport25D" type="ColorRect" parent="."]
layout_mode = 2
size_flags_horizontal = 3

View File

@@ -56,8 +56,9 @@ func _process(_delta):
var zoom = _get_zoom_amount()
# SubViewport size.
var size = get_global_rect().size
viewport_2d.size = size
var vp_size = get_global_rect().size
viewport_2d.size = vp_size
viewport_overlay.size = vp_size
# SubViewport transform.
var viewport_trans = Transform2D.IDENTITY
@@ -69,27 +70,31 @@ func _process(_delta):
# Delete unused gizmos.
var selection = editor_interface.get_selection().get_selected_nodes()
var overlay_children = viewport_overlay.get_children()
for overlay_child in overlay_children:
var gizmos = viewport_overlay.get_children()
for gizmo in gizmos:
var contains = false
for selected in selection:
if selected == overlay_child.node_25d and not view_mode_changed_this_frame:
if selected == gizmo.node_25d and not view_mode_changed_this_frame:
contains = true
if not contains:
overlay_child.queue_free()
gizmo.queue_free()
# Add new gizmos.
for selected in selection:
if selected is Node25D:
var new = true
for overlay_child in overlay_children:
if selected == overlay_child.node_25d:
new = false
if new:
var gizmo = gizmo_25d_scene.instantiate()
viewport_overlay.add_child(gizmo)
gizmo.node_25d = selected
gizmo.initialize()
_ensure_node25d_has_gizmo(selected, gizmos)
# Update gizmo zoom.
for gizmo in gizmos:
gizmo.set_zoom(zoom)
func _ensure_node25d_has_gizmo(node: Node25D, gizmos: Array[Node]) -> void:
var new = true
for gizmo in gizmos:
if node == gizmo.node_25d:
return
var gizmo = gizmo_25d_scene.instantiate()
viewport_overlay.add_child(gizmo)
gizmo.setup(node)
# This only accepts input when the mouse is inside of the 2.5D viewport.
@@ -104,7 +109,7 @@ func _gui_input(event):
accept_event()
elif event.button_index == MOUSE_BUTTON_MIDDLE:
is_panning = true
pan_center = viewport_center - event.position
pan_center = viewport_center - event.position / _get_zoom_amount()
accept_event()
elif event.button_index == MOUSE_BUTTON_LEFT:
var overlay_children = viewport_overlay.get_children()
@@ -121,7 +126,7 @@ func _gui_input(event):
accept_event()
elif event is InputEventMouseMotion:
if is_panning:
viewport_center = pan_center + event.position
viewport_center = pan_center + event.position / _get_zoom_amount()
accept_event()

View File

@@ -48,3 +48,7 @@ func _get_plugin_name():
func _get_plugin_icon():
return preload("res://addons/node25d/icons/viewport_25d.svg")
func _handles(obj: Object) -> bool:
return obj is Node25D

View File

@@ -24,21 +24,22 @@ size = Vector3(10, 1, 10)
[node name="Player25D" parent="." instance=ExtResource("2")]
z_index = -3956
position = Vector2(0, -11.3137)
[node name="Shadow25D" parent="." instance=ExtResource("3")]
visible = true
z_index = -3958
position = Vector2(0, 10.7834)
spatial_position = Vector3(0, -0.476562, 0)
position = Vector2(3.5845e-13, 11.3137)
spatial_position = Vector3(1.12016e-14, -0.5, 1.12016e-14)
[node name="Platform0" type="Node2D" parent="."]
z_index = -3952
position = Vector2(-256, -113.137)
script = ExtResource("4")
spatial_position = Vector3(-8, 5, 0)
spatial_position = Vector3(-8, 5, 2.08165e-12)
[node name="PlatformMath" type="StaticBody3D" parent="Platform0"]
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 5, 0)
transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, -8, 5, 2.08165e-12)
collision_layer = 1048575
collision_mask = 1048575