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))