Deep Dive into AGMaker: Plugin Release - Rotate Specific Node2D to Face Movement Direction

[Deep Dive into AGMaker] Plugin Action - Rotate a Specific Node2D to Face the Direction of Movement

License Agreement

You are free to use or modify this plugin for any purpose within Action Game Maker, for creating either free or commercial projects.
However, please do not redistribute it. Attribution is not required but is appreciated.

Plugin Effect

The name of this plugin action is JatkRotateToMovementDemo.
Its function is to serve as a custom action in the VS editor, causing a specified Node2D to rotate every frame so that it points in the direction of the object’s actual displacement.

Unlike previous versions that only worked with GameObject and relied on velocity, this version calculates direction based on the position difference between consecutive frames. Therefore, both GameObject and Area2DGameObject can use the same logic.

Key Points

  • Simply place JatkRotateToMovementDemo.gd anywhere in your project.
  • In any VS editor, try adding an “Execute Action”; you should see this new action under the new JusAgmToolkit tab.
  • This action can rotate the owner itself or a specific Node2D under the owner.
  • If rotate_target is left blank, it defaults to rotating the owner itself.
  • Two rotation modes are available: Instant and Smooth.
  • rotation_speed only takes effect in Smooth mode.
  • min_displacement_squared can be used to filter out very small jitter movements.
  • This action is suitable for use in states where the owner is a GameObject or Area2DGameObject.

Core

Steps

  1. Copy the entire JatkRotateToMovementDemo.gd script below and place it anywhere in your project, then save.
  2. Wait for AGMaker/Godot to refresh the script classes.
  3. Open any VS editor.
  4. Try adding an “Execute Action”.
  5. Under the newly appeared JusAgmToolkit tab, find the action named JatkRotateToMovementDemo.
  6. Add it to the appropriate state, then configure rotate_target, rotation_mode, rotation_speed, and min_displacement_squared as needed.
  7. If you want to rotate the owner itself, leave rotate_target blank. If you want to rotate a child node, set rotate_target to point to that Node2D.

Parameter Description

  • rotate_target
    The path to the Node2D that needs to be rotated. If left blank, the owner itself is rotated by default.
  • rotation_mode
    Instant immediately aligns to the current displacement direction. Smooth rotates smoothly towards it.
  • rotation_speed
    Used only in Smooth mode. The higher the value, the faster the rotation follows the displacement direction.
  • min_displacement_squared
    Minimum displacement squared threshold. Rotation only occurs if the squared displacement between two frames exceeds this value. Using the squared value also avoids unnecessary square root calculations.

Script

@tool
extends AGMPluginAction
class_name JatkRotateToMovementDemo

enum RotationMode {
	INSTANT,
	SMOOTH,
}

@export_group("Rotation Target")
@export_node_path("Node2D") var rotate_target: NodePath

@export_group("Rotation Settings")
@export_enum("Instant", "Smooth") var rotation_mode: int = RotationMode.SMOOTH:
	set(value):
		rotation_mode = value
		notify_property_list_changed()
@export_range(0.0, 100000.0, 0.1, "or_greater") var rotation_speed: float = 5.0
@export_range(0.0, 100000.0, 0.0001, "or_greater") var min_displacement_squared: float = 0.0001

var _owner_node_2d: Node2D
var _target_node_2d: Node2D
var _previous_global_position: Vector2 = Vector2.ZERO
var _is_active: bool = false


func get_plugin_tab_name() -> String:
	return "JusAgmToolkit"


func get_group_name() -> String:
	return "Demo"


func get_description() -> String:
	var locale: String = _get_editor_locale()
	if locale.begins_with("en"):
		return "Teaching demo: rotates a node from the displacement direction of a GameObject or Area2DGameObject without relying on velocity."
	if locale.begins_with("zh"):
		return "教学示例:根据 GameObject 或 Area2DGameObject 的位移方向旋转节点,不依赖 velocity。"
	if locale.begins_with("ja"):
		return "教材用サンプル: GameObject または Area2DGameObject の移動量の向きからノードを回転し、velocity には依存しません。"
	return "Teaching demo: rotates a node from the displacement direction of a GameObject or Area2DGameObject without relying on velocity."


func on_state_enter(p_owner: Object) -> void:
	if Engine.is_editor_hint():
		return

	_reset_runtime_state()
	_owner_node_2d = p_owner as Node2D
	if _owner_node_2d == null:
		push_error("[JatkRotateToMovementDemo] Owner must be a Node2D, GameObject, or Area2DGameObject.")
		return

	_target_node_2d = _resolve_rotate_target(_owner_node_2d)
	if _target_node_2d == null:
		push_error("[JatkRotateToMovementDemo] No valid Node2D rotate target was found. owner=%s configured_path=%s" % [
			_owner_node_2d.name,
			str(rotate_target),
		])
		_reset_runtime_state()
		return

	# English: Snapshot the starting position so later updates can derive facing from displacement instead of velocity.
	_previous_global_position = _owner_node_2d.global_position
	_is_active = true


func on_state_update(_p_owner: Object, p_delta: float) -> void:
	if Engine.is_editor_hint() or not _is_active:
		return

	if _owner_node_2d == null or not is_instance_valid(_owner_node_2d):
		_reset_runtime_state()
		return

	if _target_node_2d == null or not is_instance_valid(_target_node_2d):
		_reset_runtime_state()
		return

	var current_global_position: Vector2 = _owner_node_2d.global_position
	var displacement: Vector2 = current_global_position - _previous_global_position
	_previous_global_position = current_global_position

	# English: We only care about the actual frame-to-frame displacement, so GameObject and Area2DGameObject share the same logic.
	if displacement.length_squared() < min_displacement_squared:
		return

	_apply_rotation_from_displacement(displacement, p_delta)


func on_state_exit(_p_owner: Object) -> void:
	if Engine.is_editor_hint():
		return

	_reset_runtime_state()


func _validate_property(property: Dictionary) -> void:
	if property.name == "rotation_speed" and rotation_mode != RotationMode.SMOOTH:
		property.usage = PROPERTY_USAGE_NO_EDITOR


func _apply_rotation_from_displacement(displacement: Vector2, p_delta: float) -> void:
	var target_angle: float = displacement.angle()

	match rotation_mode:
		RotationMode.INSTANT:
			_target_node_2d.global_rotation = target_angle
		RotationMode.SMOOTH:
			# English: Smooth mode keeps the old RotateToVelocity angular interpolation, but now feeds it with displacement direction.
			var current_angle: float = _target_node_2d.global_rotation
			var angle_diff: float = wrapf(target_angle - current_angle, -PI, PI)
			_target_node_2d.global_rotation = current_angle + angle_diff * minf(1.0, maxf(rotation_speed, 0.0) * maxf(p_delta, 0.0))


func _resolve_rotate_target(owner_node_2d: Node2D) -> Node2D:
	if owner_node_2d == null:
		return null

	if rotate_target == NodePath():
		return owner_node_2d

	var target_node: Node = owner_node_2d.get_node_or_null(rotate_target)
	if target_node == null and owner_node_2d.owner != null:
		target_node = owner_node_2d.owner.get_node_or_null(rotate_target)

	return target_node as Node2D


func _reset_runtime_state() -> void:
	_owner_node_2d = null
	_target_node_2d = null
	_previous_global_position = Vector2.ZERO
	_is_active = false


func _get_editor_locale() -> String:
	if Engine.is_editor_hint() and ClassDB.class_exists("EditorInterface"):
		var editor_settings: EditorSettings = EditorInterface.get_editor_settings()
		if editor_settings:
			var editor_language: Variant = editor_settings.get_setting("interface/editor/editor_language")
			if editor_language is String and not editor_language.is_empty():
				return editor_language

	return TranslationServer.get_locale()

JatkRotateToMovementDemo.gd (5.5 KB)

2 Likes