Snapping Cursor
Cursor that snaps to the grid
[2024-11-06]
[gamedev]
Snapping Cursor animation

Idea

I knew I wanted satisfying snapping cursor for my grid-based game. But what makes a cursor really satisfying? I could, of course, try to do some research in that direction, but that is much less fun than tinkering with code.

Plan

What do I want my cursor component to do?

  • Follow mouse movements - it is a cursor, after all.
  • Snap to grid cell when the mouse eneters one.
  • Stay put when the mouse moves inside the cell.
  • Animate movement - some snapping action.
  • Allow fine-tuning of movement animation aspects.
  • Enable easy changes to the cursor texture.

Godot scene

To create the component, I chose Sprite2D as the base node. This allows the cursor component to have its texture easily available in the inspector. It also requires a reference to the main grid. I used export variable to loosely couple the components.

I separated the snapping animation into two phases:

  • Overshoot phase - The cursor moves slightly beyond the target position.
  • Correction phase - The cursor adjusts back to the target position.

The cursor snaps between two cells to demonstrate the animation phases The cursor snaps between two cells to demonstrate the animation phases

To achieve control over these, I created three parameters:

  • overshoot_pixels - An integer that stores number of pixels by which to overshoot the target cell.
  • time_to_overshoot - A float value that specifies the time (in seconds) to reach the overshoot position.
  • time_to_position - A float value that specifies the time (in seconds) to reach the target position.

However, I didn’t really like the interface of having two separate times that needed adjusting. To simplify this, I created two additional variables:

  • moving_time - A float representing the total movement time in seconds.
  • overshoot_ratio - A float that specifies what proportion of the total movement time should be allocated to the overshoot phase.

These two variables are exported for the programmer to interact with. When either of them is changed, setters update the values of the underlying time_to_overshoot and time_to_position variables, which are then used by the component.

extends Sprite2D

class_name Cursor

@export var main_grid: MainGrid

@export_range(0.01, 3, 0.01, "or_greater", "suffix:s") var moving_time = 0.1:
	set(value):
		set_move_and_overshoot_times(value, overshoot_ratio)
		moving_time = value
@export_range(0.1, 0.9, 0.01) var overshoot_ratio = 0.5:
	set(value):
		set_move_and_overshoot_times(moving_time, value)
		overshoot_ratio = value
@export_range(1, 16, 1, "suffix:px") var overshoot_pixels = 4

func set_move_and_overshoot_times(new_moving_time, new_overshoot_ratio):
	time_to_overshoot = new_moving_time * new_overshoot_ratio
	time_to_position = new_moving_time - time_to_overshoot

var time_to_overshoot = 0.05
var time_to_position = 0.05

The component also keeps track of the last snapped position and the current cell in terms of the main grid coordinates.

Within the _ready function, the script checks if I remembered to connect it to the main grid and alerts me if I didn’t.

var last_snapped_position: Vector2 = Vector2(16,16)
var current_cell: Vector2i = Vector2i(0,0)

func _ready() -> void:
	assert(main_grid != null, "set main_grid")

To make the component respond to mouse movements, I added the update_position function. It retrieves the mouse position from the input event, then uses the main grid to get the current cell and snapped position. If the new position is different from the last one, it calls the move_cursor function and updates the last_snapped_position variable.

func _input(event):
	if event is InputEventMouseMotion:
		update_position(event.position)

func update_position(event_position):
	current_cell = main_grid.position_to_grid(event_position)
	var new_snapped_position = main_grid.grid_to_position(current_cell)
	if last_snapped_position != new_snapped_position:
		move_cursor(new_snapped_position)
		last_snapped_position = new_snapped_position

The animation logic is contained within the move_cursor function.

Using some vector math, it figures out the direction to the overshoot position, then scales it based on the value of the overshoot_pixels variable.

Next, it creates a Tween that will move the cursor to the overshoot position over time.

Finally, it creates another Tween to correct the overshoot.

func move_cursor(new_position):
	var overshoot = position - new_position
	overshoot = overshoot.normalized()
	overshoot = overshoot * overshoot_pixels

	var tween = get_tree().create_tween()
	tween.tween_property(
		$".",
		"position",
		new_position - overshoot,
		time_to_overshoot
	)
	tween.tween_property(
		$".",
		"position",
		new_position,
		time_to_position
	)

Effect

Properties of the Cursor component viewed from the inspector Properties of the Cursor component viewed from the inspector

I created a simple scene to showcase the three adjustable parameters. For the base values, I used the following defaults:

  • overshoot_pixels = 4
  • moving_time - specified per demo
  • overshoot_ratio = 0.5

For each scene, one of the parameters is changed in some way. The first scene demonstrates how changing the value of the moving_time variable affects the resulting motion. The values start at 1.0 for Cursor1 and decrease by 0.2 for each following cursor, ending with a value of 0.2 for Cursor5.

Cursors with different speeds Cursors with different speeds

The following GIF shows how the overshoot_ratio affects the cursors. The values start at 0.9 for Cursor1 and decrese by 0.2 for each following cursor, ending with value of 0.1 for Cursor5. The value of the moving_time variable is set to 1.0 to make the changes more visible.

Cursors with different overshoot ratios Cursors with different overshoot ratios

The final GIF demonstrates how the value of overshoot_pixels affects the cursors. The values start at 4 for Cursor1 and increase by 2 for each following cursor, ending with the value of 12 for Cursor5. The value of the moving_time variable is set to 0.5 to make changes more visible.

Cursors with different values of the overshoot_pixels variable Cursors with different values of the overshoot_pixels variable

The last one (for real this time), shows just some zooming around.

Cursor just zooming around Cursor just zooming around

Summary

I’m really happy with how this component turned out. Adjusting its settings and changing the texture is easy. With these parameters, I can create many different types of cursors with distinct feels for various applications. The cursor’s ability to keep track of the current cell might come in handy one day.

References

Bonus GIF 👀 Bonus GIF 👀

😳
Well, that's a little embarrasing
Please check out my website on a bigger screen