OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_curve_tool.gd

233 lines · gd ref:
extends Node
## GATE — THE CURVE TOOL (#120 F5, CurveOperator on ModalControls).
##   T1  the Add menu lists Curve, and its menu action starts the tool
##   T2  P places a point where the crosshair hits, lifted 0.1 m along the surface normal,
##       and the point keeps that normal
##   T3  Backspace takes the last point back
##   T4  ] raises the lift (a bound value)
##   T5  C closes the curve
##   T6  Enter writes ONE curve.create: the curve is room state with those points, closed;
##       one gesture; the summary card says so
##   T7  Esc writes nothing
##   E1  EDIT: the menu action picks the curve point nearest the crosshair
##   E2  the point follows the aim; Enter writes ONE curve.set with it moved
##   E3  I inserts a point after it, X deletes one (the counts)
##   E4  Esc writes nothing
##   E5  nothing near the crosshair: refused
##   H5  V cycles a point's handle type (auto, aligned, free, vector)
##   H6  E grabs the out-handle: it follows the aim, the point stays; auto becomes aligned
##   H7  free: the other handle stays
##   H8  Enter: one curve.set with the type and handles; the overlay draws the handles
##   H9  starting aimed at a shown handle end grabs it
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_curve_tool.tscn

var fails := 0
var checks := 0
var map: Node3D
var cam: Camera3D
var top := Vector3.ZERO


func _ok(what: String, cond: bool, note: Variant = "") -> void:
	checks += 1
	if not cond:
		fails += 1
	print("  %-64s %s  %s" % [what, "OK" if cond else "**FAIL**", str(note)])


func _key(code: int, shift := false) -> InputEventKey:
	var e := InputEventKey.new()
	e.physical_keycode = code
	e.keycode = code
	e.shift_pressed = shift
	e.pressed = true
	return e


func _ready() -> void:
	var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
	gs.route("world:level:*", 1, "peer")
	var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
	if keet != null:
		keet.current_member_id = "gate-author"
	get_tree().root.get_node("/root/GameMode").set_mode("build")
	map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
	add_child(map)
	cam = Camera3D.new()
	add_child(cam)
	cam.current = true
	for i in 6:
		await get_tree().process_frame
	await _run()
	get_tree().root.get_node("/root/GameMode").set_mode("play")
	if checks < 28:
		print("  **FAIL** only %d checks ran -- setup died before the assertions" % checks)
		fails += 1
	print("\nGATE curve_tool: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
	get_tree().quit(0 if fails == 0 else 1)


## Aim straight down at (x, top, z), THROUGH THE SCREEN CENTRE. In CURSOR sub-mode the
## crosshair ray is the pointer + the map's wrap offset (map._crosshair_ray); a headless
## viewport keeps its pointer at (0, 0) whatever input is pushed, so the offset carries it
## to the centre — what a player's crosshair is.
func _aim(x: float, z: float) -> void:
	var at := Vector3(x, top.y, z)
	cam.global_position = at + Vector3(0.01, 4.0, 0.01)
	cam.look_at(at, Vector3.UP)
	# A frame first: the map resets the offset when the active tool changes (a tool just
	# ended), and that must not wipe the one set here.
	await get_tree().process_frame
	map.set("_wrap_offset", get_viewport().get_visible_rect().size * 0.5 - get_viewport().get_mouse_position())


func _run() -> void:
	var level: Node = map.get("level")
	var persist: Node = map.get("_persist")
	var hud: Node = get_tree().root.get_node("/root/BuildHud")
	# The big floor plate (100 x 0.2 x 100): aim at its top.
	var b: int = (level.get_live_brush_ids() as PackedInt32Array)[0]
	map.select_brushes(PackedInt32Array([b]))
	top = map.selection_centre() + Vector3(0, map.selection_size().y * 0.5, 0)
	map.clear_selection()

	# ---- T1 ----
	var rows: Array = []
	for sec: Array in map.add_sections():
		for r: Array in sec[1]:
			rows.append(String(r[0]))
	_ok("T1 the Add menu lists Curve", rows.has("builder.add_curve"), rows)
	var ev := InputEventAction.new()
	ev.action = "builder.add_curve"
	ev.pressed = true
	map._unhandled_input(ev)
	var ops: Node = map.call("_ensure_ops")
	var cu: Node = map.get("_curve_op")
	_ok("T1 its menu action starts the curve tool", ops.get("active") == cu)

	# ---- T2 ----
	for xz: Array in [[top.x - 3.0, top.z], [top.x, top.z + 2.0], [top.x + 3.0, top.z]]:
		await _aim(float(xz[0]), float(xz[1]))
		map._unhandled_key_input(_key(KEY_P))
	var pts: Array = cu.points()
	_ok("T2 three points placed", pts.size() == 3, pts.size())
	_ok("T2 ... where aimed, 0.1 m above the surface", pts.size() == 3
		and ((pts[1] as Dictionary)["p"] as Vector3).distance_to(Vector3(top.x, top.y + 0.1, top.z + 2.0)) < 1e-3,
		[(pts[1] as Dictionary)["p"] if pts.size() > 1 else null, Vector3(top.x, top.y + 0.1, top.z + 2.0)])
	_ok("T2 ... each keeping the surface normal", pts.all(func(q: Dictionary) -> bool: return (q["n"] as Vector3).is_equal_approx(Vector3.UP)))

	# ---- T3 ----
	map._unhandled_key_input(_key(KEY_BACKSPACE))
	_ok("T3 Backspace takes one back", (cu.points() as Array).size() == 2)
	await _aim(top.x + 3.0, top.z)

	# ---- T4 ----
	map._unhandled_key_input(_key(KEY_BRACKETRIGHT))
	_ok("T4 ] raises the lift to 0.15 m", String(cu.status()).contains("lift off the surface: 0.15 m"), cu.status())
	map._unhandled_key_input(_key(KEY_P))
	_ok("T4 ... the next point is placed at the new lift", absf(((cu.points()[2] as Dictionary)["p"] as Vector3).y - (top.y + 0.15)) < 1e-3)

	# ---- T5 ----
	map._unhandled_key_input(_key(KEY_C))
	_ok("T5 C closes it", String(cu.status()).contains("closed: yes"), cu.status())

	# ---- T6 ----
	var g0: int = (persist.gestures() as Array).size()
	ops.confirm()
	await get_tree().process_frame
	var st: Dictionary = map.curve_states()
	_ok("T6 Enter adds one curve", st.size() == 1, st.size())
	var c: Dictionary = st.values()[0] if st.size() == 1 else {}
	_ok("T6 ... with the three points, closed", (c.get("points", []) as Array).size() == 3 and bool(c.get("closed", false)))
	_ok("T6 ... in one gesture", (persist.gestures() as Array).size() == g0 + 1, (persist.gestures() as Array).size() - g0)
	_ok("T6 the summary card", String(hud.summary_text()).begins_with("Added a curve of 3 points (closed)"), hud.summary_text())

	# ---- T7 ----
	ops.activate(cu)
	await _aim(top.x, top.z)
	map._unhandled_key_input(_key(KEY_P))
	map._unhandled_key_input(_key(KEY_ESCAPE))
	_ok("T7 Esc writes nothing", map.curve_states().size() == 1 and ops.get("active") == null)

	# ---- E1-E5: edit ----
	var cdot := String(map.curve_states().keys()[0])
	var p1: Vector3 = (map.curve_states()[cdot]["points"][1] as Dictionary)["p"]
	await _aim(p1.x, p1.z)
	var ev2 := InputEventAction.new()
	ev2.action = "builder.edit_curve"
	ev2.pressed = true
	map._unhandled_input(ev2)
	var ce: Node = map.get("_curve_edit")
	_ok("E1 the edit tool picks the aimed point (2 of 3)", ops.get("active") == ce and String(ce.status()).contains("point: 2 of 3"),
		ce.status())
	await _aim(p1.x + 1.0, p1.z)
	ce.update_preview()
	var g1: int = (persist.gestures() as Array).size()
	ops.confirm()
	await get_tree().process_frame
	var moved: Vector3 = (map.curve_states()[cdot]["points"][1] as Dictionary)["p"]
	_ok("E2 Enter moved the point to the aim (+1 m x)", absf(moved.x - (p1.x + 1.0)) < 0.05, [p1, moved])
	_ok("E2 ... in one gesture", (persist.gestures() as Array).size() == g1 + 1)
	await _aim(moved.x, moved.z)
	map._unhandled_input(ev2)
	map._unhandled_key_input(_key(KEY_I))
	_ok("E3 I inserts a point (4)", (ce.points() as Array).size() == 4)
	map._unhandled_key_input(_key(KEY_X))
	map._unhandled_key_input(_key(KEY_X))
	_ok("E3 X deletes (2 left)", (ce.points() as Array).size() == 2)
	map._unhandled_key_input(_key(KEY_ESCAPE))
	_ok("E4 Esc writes nothing (3 points still)", (map.curve_states()[cdot]["points"] as Array).size() == 3)
	await _aim(top.x + 30.0, top.z + 30.0)
	map._unhandled_input(ev2)
	_ok("E5 nothing near the crosshair: refused", ops.get("active") == null)

	# ---- H5..H8: Bezier handles (docs/node-tree-and-stacks.md §6.1) ----
	var p2: Vector3 = (map.curve_states()[cdot]["points"][1] as Dictionary)["p"]
	await _aim(p2.x, p2.z)
	map._unhandled_input(ev2)
	_ok("H5 (setup) the edit tool is on point 2", ops.get("active") == ce and String(ce.status()).contains("point: 2 of 3"), ce.status())
	var types: Array = []
	for i in 4:
		map._unhandled_key_input(_key(KEY_V))
		types.append(CurveMath.handle_type((ce.points() as Array)[1]))
	_ok("H5 V cycles the type: aligned, free, vector, auto", types == ["aligned", "free", "vector", "auto"], types)
	var q0: Dictionary = ((ce.points() as Array)[1] as Dictionary).duplicate(true)
	map._unhandled_key_input(_key(KEY_E))
	_ok("H6 E: the out-handle follows the aim", String(ce.status()).contains("handle out"), ce.status())
	await _aim(p2.x + 1.0, p2.z)
	ce.update_preview()
	var q1: Dictionary = (ce.points() as Array)[1]
	_ok("H6 ... aim +1 m x: the out-handle +1 m x, the point stays",
		(q1["h_out"] as Vector3).distance_to((q0["h_out"] as Vector3) + Vector3(1, 0, 0)) < 0.05
		and (q1["p"] as Vector3).distance_to(q0["p"]) < 1.0e-4, [q0["h_out"], q1["h_out"]])
	_ok("H6 ... an auto point became aligned: the in-handle opposite, its length kept",
		CurveMath.handle_type(q1) == "aligned"
		and (q1["h_in"] as Vector3).normalized().distance_to(-(q1["h_out"] as Vector3).normalized()) < 1.0e-3
		and absf((q1["h_in"] as Vector3).length() - (q0["h_in"] as Vector3).length()) < 1.0e-3, [q1["h_in"], q0["h_in"]])
	map._unhandled_key_input(_key(KEY_V))            # aligned -> free
	var in_free: Vector3 = (ce.points() as Array)[1]["h_in"]
	await _aim(p2.x + 2.0, p2.z)
	ce.update_preview()
	var q2: Dictionary = (ce.points() as Array)[1]
	_ok("H7 free: the out-handle moves on, the in-handle stays", CurveMath.handle_type(q2) == "free"
		and (q2["h_out"] as Vector3).distance_to((q0["h_out"] as Vector3) + Vector3(2, 0, 0)) < 0.05
		and (q2["h_in"] as Vector3).is_equal_approx(in_free), [q2["h_out"], q2["h_in"], in_free])
	var g8: int = (persist.gestures() as Array).size()
	ops.confirm()
	await get_tree().process_frame
	var s8: Dictionary = (map.curve_states()[cdot]["points"] as Array)[1]
	_ok("H8 Enter: one curve.set, the type and handles stored", (persist.gestures() as Array).size() == g8 + 1
		and CurveMath.handle_type(s8) == "free" and (s8["h_out"] as Vector3).distance_to(q2["h_out"]) < 1.0e-3, s8)
	map._refresh_curves()
	_ok("H8 the Curves overlay draws the held handles (wire, points, handle lines, handle ends)",
		int(map.curve_view_surfaces()) == 4, map.curve_view_surfaces())

	# ---- H9 ----
	var hend: Vector3 = (s8["p"] as Vector3) + (s8["h_out"] as Vector3)
	await _aim(hend.x, hend.z)
	map._unhandled_input(ev2)
	_ok("H9 aimed at a shown handle end, the tool starts holding that handle",
		ops.get("active") == ce and String(ce.status()).contains("handle out"), ce.status())
	map._unhandled_key_input(_key(KEY_ESCAPE))