OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / editor_cursor.gd

238 lines · gd ref:
extends Node3D
## THE 3D CURSOR (hyper-games #109, 2026-10-12: "we need the concept of a 3d cursor that
## lives in the scene that we can place ... important as an option for many things
## including where to place objects, where to rotate around something"). Blender's,
## read from its source (docs/editor-3d-cursor.md):
##
##   PLACE   Shift+RMB (builder.cursor_place). view3d.cursor3d defaults: use_depth = true
##           (onto the surface under the pointer), orientation = VIEW (the cursor's
##           rotation becomes the view's) — view3d_edit.cc:1126-1135. Nothing under the
##           pointer: the cursor keeps its depth along the new ray.
##   DRAG    Shift+RMB drag slides it along the surfaces (Blender's cursor tweak).
##   SNAP    Shift+S menu (builder.cursor_menu) — Cursor to Selected (the MEDIAN of the
##           selection unless the pivot is bounding box, view3d_snap.cc:1003), to Active,
##           to Grid, to World Origin.
##
## VIEW-LOCAL: the user's tool, not the room's — saved per map in the dock prefs, never
## replicated (user rule: nothing new in StateBase for UI state).
## DRAWN THROUGH WALLS at a constant screen size: a red / white ring facing the camera,
## and the cursor's three axes (X red, Y green, Z blue).

const RING_SEGMENTS := 16
const SCREEN_SIZE := 0.035        # ring radius as a fraction of the distance (constant on screen)

var map: Node = null
var _mesh: MeshInstance3D = null
var _dragging := false


func setup(map_node: Node) -> void:
	map = map_node
	name = "EditorCursor"
	top_level = true
	_mesh = MeshInstance3D.new()
	_mesh.mesh = ImmediateMesh.new()
	var mat := StandardMaterial3D.new()
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.vertex_color_use_as_albedo = true
	mat.no_depth_test = true
	mat.render_priority = 10
	_mesh.material_override = mat
	_mesh.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	add_child(_mesh)
	_load()
	visible = GameMode.is_building()
	GameMode.mode_changed.connect(func(m: String) -> void: visible = m == GameMode.BUILD)
	if GameMode.has_signal("cursor_press"):
		GameMode.cursor_press.connect(_on_press)
		GameMode.cursor_drag.connect(_on_drag)
		GameMode.cursor_release.connect(_on_release)


# ---- placing ------------------------------------------------------------------

## Blender's cursor3d: onto the surface under `at` (a viewport position), oriented to the
## view. No surface: the same depth along the new ray.
func place_at(at: Vector2, save := true) -> void:
	var cam := get_viewport().get_camera_3d() if get_viewport() != null else null
	if cam == null or map == null:
		return
	var hit: Dictionary = map._crosshair_hit(at) if map.has_method("_crosshair_hit") else {}
	var p: Vector3
	if not hit.is_empty():
		p = hit["position"]
	else:
		var depth := maxf((global_position - cam.global_position).dot(-cam.global_transform.basis.z), 1.0)
		p = cam.project_position(at, depth)
	global_transform = Transform3D(cam.global_transform.basis.orthonormalized(), p)
	if save:
		_save()


func _on_press(at: Vector2) -> void:
	_dragging = true
	place_at(at, false)


## A drag writes no prefs per motion (that file is JSON on disk); the release saves once.
func _on_drag(at: Vector2) -> void:
	if _dragging:
		place_at(at, false)


func _on_release(_at: Vector2) -> void:
	if _dragging:
		_save()
	_dragging = false


# ---- snapping (the Shift+S menu) ----------------------------------------------

## The selection's MEDIAN — each selected brush's bounds centre and each selected object's
## origin, averaged (Blender's default pivot, view3d_snap.cc:1003-1009). False when nothing
## is selected (Blender's operator cancels).
func to_selected() -> bool:
	var pts := _selection_points()
	if pts.is_empty():
		return false
	var c := Vector3.ZERO
	for q: Vector3 in pts:
		c += q
	_set_position(c / float(pts.size()))
	return true


## The active element: the active brush's centre, else the active object's origin.
func to_active() -> bool:
	if map == null:
		return false
	var b: Variant = map.get("_sel_brush")
	if b is int and int(b) >= 0 and map.get("level") != null:
		var d: Dictionary = map.level.get_brush(int(b))
		if d.has("aabb_min"):
			_set_position(((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
			return true
	var o: Variant = map.get("_sel_object")
	if o is String and String(o) != "" and map.has_method("object_node"):
		var n := map.object_node(String(o)) as Node3D
		if n != null:
			_set_position(n.global_position)
			return true
	return false


## The nearest lattice point, through the map's own snap (its recentre offset included).
func to_grid() -> void:
	if map == null or not map.has_method("snap_to_map_grid"):
		return
	var p := global_position
	var units: float = map.grid_units() if map.has_method("grid_units") else 8.0
	for i in 3:
		p[i] = map.snap_to_map_grid(p[i], [Vector3.RIGHT, Vector3.UP, Vector3.BACK][i], units,
			map.get("_map_center"), map.get("_aligned_offset"))
	_set_position(p)


func to_origin() -> void:
	_set_position(Vector3.ZERO)
	global_transform.basis = Basis.IDENTITY
	_save()


func _set_position(p: Vector3) -> void:
	global_position = p
	_save()


func _selection_points() -> Array:
	var out: Array = []
	if map == null:
		return out
	var ids: Variant = map.get("_sel_brushes")
	if (ids is Array or ids is PackedInt32Array) and map.get("level") != null:
		for b: int in ids:
			var d: Dictionary = map.level.get_brush(b)
			if d.has("aabb_min"):
				out.append(((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
	if map.has_method("selected_objects") and map.has_method("object_node"):
		for dot: Variant in map.selected_objects():
			var n := map.object_node(String(dot)) as Node3D
			if n != null:
				out.append(n.global_position)
	return out


## Run a cursor.* action (the menus run them through Keymap like any key).
const ACTIONS := {"cursor.to_selected": "to_selected", "cursor.to_active": "to_active",
	"cursor.to_grid": "to_grid", "cursor.to_origin": "to_origin"}


func _unhandled_input(event: InputEvent) -> void:
	if not GameMode.is_building():
		return
	for a: String in ACTIONS:
		if Keymap.tapped(event, a):
			call(ACTIONS[a])
			get_viewport().set_input_as_handled()
			return


# ---- persistence (view-local, per map) ------------------------------------------

func _pref_key() -> String:
	var mid := String(GameState.current_map_id) if GameState != null else ""
	return "cursor3d:%s" % (mid if mid != "" else "default")


func _save() -> void:
	var dock := get_node_or_null(^"/root/BuildDock")
	if dock == null or not dock.has_method("set_pref"):
		return
	var t := global_transform
	dock.set_pref(_pref_key(), [t.origin.x, t.origin.y, t.origin.z,
		t.basis.x.x, t.basis.x.y, t.basis.x.z, t.basis.y.x, t.basis.y.y, t.basis.y.z,
		t.basis.z.x, t.basis.z.y, t.basis.z.z])


func _load() -> void:
	var dock := get_node_or_null(^"/root/BuildDock")
	var v: Variant = dock.pref(_pref_key(), null) if dock != null and dock.has_method("pref") else null
	if v is Array and (v as Array).size() == 12:
		var a: Array = v
		global_transform = Transform3D(Basis(Vector3(a[3], a[4], a[5]), Vector3(a[6], a[7], a[8]),
			Vector3(a[9], a[10], a[11])), Vector3(a[0], a[1], a[2]))


# ---- drawing ----------------------------------------------------------------------

func _process(_d: float) -> void:
	if not visible:
		return
	var cam := get_viewport().get_camera_3d() if get_viewport() != null else null
	if cam == null:
		return
	var dist := maxf((global_position - cam.global_position).length(), 0.1)
	var r := dist * SCREEN_SIZE
	if cam.projection == Camera3D.PROJECTION_ORTHOGONAL:
		r = cam.size * SCREEN_SIZE * 0.5
	var im := _mesh.mesh as ImmediateMesh
	im.clear_surfaces()
	im.surface_begin(Mesh.PRIMITIVE_LINES)
	# The ring faces the camera: drawn in the camera's plane, in this node's local space.
	var inv := global_transform.basis.inverse()
	var cx: Vector3 = inv * cam.global_transform.basis.x
	var cy: Vector3 = inv * cam.global_transform.basis.y
	for i in RING_SEGMENTS:
		var a0 := TAU * float(i) / RING_SEGMENTS
		var a1 := TAU * float(i + 1) / RING_SEGMENTS
		im.surface_set_color(Color(1, 0.2, 0.2) if i % 2 == 0 else Color(1, 1, 1))
		im.surface_add_vertex((cx * cos(a0) + cy * sin(a0)) * r)
		im.surface_set_color(Color(1, 0.2, 0.2) if i % 2 == 0 else Color(1, 1, 1))
		im.surface_add_vertex((cx * cos(a1) + cy * sin(a1)) * r)
	for ax: Array in [[Vector3.RIGHT, Color(1, 0.3, 0.3)], [Vector3.UP, Color(0.4, 1, 0.4)], [Vector3.BACK, Color(0.4, 0.6, 1)]]:
		im.surface_set_color(ax[1])
		im.surface_add_vertex(Vector3.ZERO)
		im.surface_set_color(ax[1])
		im.surface_add_vertex((ax[0] as Vector3) * r * 1.6)
	im.surface_end()