OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_editor_e3.gd

371 lines · gd ref:
extends SceneTree
## GATE — editor redesign E3 (docs/editor-redesign.md §5), on a real map_flat.
##   E3a  F3 COMMAND PALETTE: every action reachable in build mode, generated from the
##        keymap (label + live key + menu), fuzzy search, Enter runs it through
##        Keymap.run (the SAME path as the key), Escape closes without running,
##        recent actions remembered per player.
##   E3b  F1 KEY LIST: the contexts active NOW come first, and a search field filters it;
##        F1 / Escape still close it while the field has focus (always context).
##   E3c  SHIFT+SPACE TOOL POPUP: every toolbar tool with its key; pick by click or by the
##        tool's key; while it is open the `menu` context holds (no tools fire, no flying).
##   E3d  RMB TAP CONTEXT MENU: a quick right click (no drag) with no tool up opens a menu
##        built from the selection; a held / dragged RMB is still the look and opens nothing.
##   E3e  MODIFIER HINT: holding Ctrl / Shift / Alt ~300 ms lists in the status bar what it
##        means right now (fly vs tool) and every chord with it in the active contexts.
##   E3f  FIRST-RUN CARD: entering build mode once shows how in (F1, RMB, F3, Shift+Space),
##        dismissable, and never again for that player.
##   E3g  WHEEL = THE ACTIVE TOOL'S [ / ] (user decision 2026-10-02): with the box tool up,
##        wheel up / down grows / shrinks it; with no tool the wheel leaves the grid alone.
##
## Run: "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_editor_e3.gd

var ok := true
var checks := 0
var _frame := 0
var _map: Node
var _km: Node
var _dock: Node
var _pal: Node
var _tp: Node
var _cm: Node
var _hud: Node
var _t_mod := 0
var _saved_recent: Variant = []
var _saved_grid: Variant = 5
var _saved_seen: Variant = false
var _saved_snap: Variant = true
const TOOL_PREFS := ["tool.box.shape", "tool.box.size", "tool.box.height", "tool.box.p.stairs.steps",
	"tool.box.p.arch.segments"]
var _saved_tool: Dictionary = {}


func _p(cond: bool, what: String, note: String = "") -> void:
	checks += 1
	if not cond:
		ok = false
	print("  %-62s %s  %s" % [what, "OK" if cond else "**FAIL**", note])


func _press(code: Key, shift := false) -> void:
	for pressed: bool in [true, false]:
		var e := InputEventKey.new()
		e.keycode = code
		e.physical_keycode = code
		e.shift_pressed = shift
		e.pressed = pressed
		Input.parse_input_event(e)


func _key_state(code: Key, pressed: bool) -> void:
	var e := InputEventKey.new()
	e.keycode = code
	e.physical_keycode = code
	e.pressed = pressed
	Input.parse_input_event(e)


func _wheel(button: MouseButton) -> void:
	for pressed: bool in [true, false]:
		var e := InputEventMouseButton.new()
		e.button_index = button
		e.pressed = pressed
		e.position = Vector2(400, 300)
		Input.parse_input_event(e)


func _rmb(pressed: bool, at: Vector2) -> void:
	var e := InputEventMouseButton.new()
	e.button_index = MOUSE_BUTTON_RIGHT
	e.pressed = pressed
	e.position = at
	e.global_position = at
	Input.parse_input_event(e)


func _process(_d: float) -> bool:
	_frame += 1
	match _frame:
		1:
			# A REAL window: headless is 64x64, and the clicks below at (400, 300) must land
			# in the 3D view's free rectangle, not outside the window (#124 B1: everything
			# outside R is an area, so an off-window point counts as covered).
			root.size = Vector2i(1920, 1080)
			# Gates share the installed game's user:// — keep the player's recents.
			_dock = root.get_node("/root/BuildDock")
			_saved_recent = _dock.pref("palette_recent", [])
			_saved_grid = _dock.pref("grid", 5)
			_saved_seen = _dock.pref("first_run_seen", false)
			_saved_snap = _dock.pref("snap", true)
			_dock.set_pref("palette_recent", [])
			_dock.set_pref("grid", 5)
			# The box tool's sticky size/shape (E4) would start the wheel test somewhere
			# else on a machine that last used another size — start from the defaults.
			for k: String in TOOL_PREFS:
				_saved_tool[k] = _dock._prefs.get(k, null)
				_dock._prefs.erase(k)
			# Snap ON: with the player's snap off every box step is 1u, not 16u.
			_dock.set_pref("snap", true)
			_map = (load("res://scenes/maps/map_flat.tscn") as PackedScene).instantiate()
			root.add_child(_map)
		30:
			_km = root.get_node("/root/Keymap")
			root.get_node("/root/GameMode").set_mode("build")
			print("-- E3a: the command palette --")
			# Space since keymap step 3 (Blender_27x.py spacebar_action='SEARCH').
			_p(_km.describe("builder.palette") == "Space", "Space opens the command palette", _km.describe("builder.palette"))
			_pal = _map.get("_palette")
			_p(_pal != null, "the map owns a palette")
			if _pal == null:
				return _finish()
			_press(KEY_SPACE)
		33:
			_p(bool(_pal.is_open()), "Space in build mode opens it")
			var all: Array = _pal.shown_actions()
			_p(all.has("builder.create_box") and all.has("build_mode.csg_subtract") and all.has("global.help"),
				"empty search lists builder, build-mode AND global actions", "%d actions" % all.size())
			_p(not all.has("builder.palette"), "but not itself")
			_p(not all.has("builder.tool_more"), "nor mouse gestures (the wheel is not a command)")
			_p(String(_pal.row_text("builder.add_menu")).contains("Shift+A")
				and String(_pal.row_text("builder.add_menu")).contains("Add"),
				"a row shows the live key and its menu", String(_pal.row_text("builder.add_menu")))
			_pal.set_query("new shape")
			var hits: Array = _pal.shown_actions()
			_p(not hits.is_empty() and hits[0] == "builder.create_box", "\"new shape\" -> the create tool first", str(hits.slice(0, 3)))
			_pal.set_query("csgsub")
			hits = _pal.shown_actions()
			_p(not hits.is_empty() and hits[0] == "build_mode.csg_subtract", "fuzzy: \"csgsub\" -> CSG subtract", str(hits.slice(0, 3)))
			_pal.set_query("zzqqxx")
			_p(_pal.shown_actions().is_empty(), "no match -> empty list")
			# RUN something whose effect needs no aim (the box tool needs a surface under
			# the crosshair, which a headless gate does not have).
			_map.set_grid_index(5)
			_pal.set_query("coarser grid")
			_press(KEY_ENTER)
		38:
			_p(not bool(_pal.is_open()), "Enter closes it")
			_p(int(_map.get("_grid_idx")) == 6, "... and RAN it through the key path: grid 8u -> 16u",
				str(_map.get("_grid_idx")))
			_p((_dock.pref("palette_recent", []) as Array).size() > 0
				and (_dock.pref("palette_recent", []) as Array)[0] == "builder.grid_coarser", "remembered as recent")
			_press(KEY_SPACE)
		41:
			_p(bool(_pal.is_open()), "reopens")
			_p(_pal.shown_actions().size() > 0 and _pal.shown_actions()[0] == "builder.grid_coarser",
				"recent first on an empty search")
			_pal.set_query("delete")
			_press(KEY_ESCAPE)
		44:
			_p(not bool(_pal.is_open()), "Escape closes it")
			_p(_km.is_context_active("builder"), "and the builder keys work again (no text field left focused)")
			print("\n-- E3b: the F1 key list --")
			_press(KEY_F1)
		47:
			var help: Node = root.get_node("/root/HelpOverlay")
			_p(bool(help.visible), "F1 opens the key list")
			var t: String = help.text()
			# Keymap step 4 (§4a): F1 lists the contexts active NOW, under plain names — in
			# build mode the walking keys are not listed at all; "show every context" adds
			# them AFTER the active ones, marked not active.
			var i_build := t.find("BUILD MODE")
			_p(i_build >= 0 and t.find("PLAYING") < 0, "in build mode F1 lists the build keys, not the walking keys",
				"BUILD MODE@%d PLAYING@%d" % [i_build, t.find("PLAYING")])
			help.call("set_show_all", true)
			var ta: String = help.text()
			_p(ta.find("PLAYING") > ta.find("BUILD MODE") and ta.find("BUILD MODE") >= 0,
				"show every context: the walking keys come AFTER the build keys",
				"BUILD MODE@%d PLAYING@%d" % [ta.find("BUILD MODE"), ta.find("PLAYING")])
			help.call("set_show_all", false)
			help.set_query("grid")
			t = help.text()
			_p(t.contains("Coarser grid") and t.contains("Finer grid") and not t.contains("Jump"),
				"search \"grid\" keeps the grid keys, drops the rest")
			_p(_km.is_context_active("global"), "F1 / Esc stay live while the search has focus")
			_press(KEY_ESCAPE)
		50:
			var help2: Node = root.get_node("/root/HelpOverlay")
			_p(not bool(help2.visible), "Escape closes it")
			_p(_km.is_context_active("builder"), "builder keys live again after closing")
			print("\n-- E3c: the tool popup --")
			_p(_km.describe("builder.tool_popup") == "Shift+Space", "Shift+Space opens the tool popup",
				_km.describe("builder.tool_popup"))
			_tp = _map.get("_tool_popup")
			_p(_tp != null, "the map owns a tool popup")
			if _tp == null:
				return _finish()
			_press(KEY_SPACE, true)
		53:
			_p(bool(_tp.is_open()), "Shift+Space in build mode opens it")
			var tools: Array = load("res://scenes/game/editor_tool_bar.gd").TOOLS
			_p(_tp.item_count() == tools.size(), "one row per toolbar tool", "%d rows" % _tp.item_count())
			_p(String(_tp.item_text_of("builder.create_box")).contains("Box"), "rows are named like the toolbar",
				String(_tp.item_text_of("builder.create_box")))
			_p(_tp.accel_text_of("builder.move") == "G", "and show the live key", _tp.accel_text_of("builder.move"))
			_p(not _km.is_context_active("builder"), "while open, builder keys do NOT fire (menu context)")
			_p(not bool(_km.movement_allowed()), "and the camera does not fly")
			_tp.pick("builder.move")
		56:
			_p(not bool(_tp.is_open()), "a pick closes it")
			_p(String(_tp.last_run) == "builder.move", "and hands the tool to Keymap.run", String(_tp.last_run))
			_p(_km.is_context_active("builder") and bool(_km.movement_allowed()), "menu context gone: keys + flying back")
			var st: Node = _map.call("_ensure_ops")
			if st.get("active") != null:
				st.cancel()
			_tp.last_run = ""
			_press(KEY_SPACE, true)
		59:
			_p(bool(_tp.is_open()), "reopens")
			_press(KEY_K)                 # the knife's key since keymap step 3
		62:
			_p(not bool(_tp.is_open()) and String(_tp.last_run) == "builder.knife",
				"pressing a tool's key while it is open picks that tool", String(_tp.last_run))
			var st2: Node = _map.call("_ensure_ops")
			if st2.get("active") != null:
				st2.cancel()
			_tp.last_run = ""
			_press(KEY_SPACE, true)
		65:
			_press(KEY_ESCAPE)
		68:
			_p(not bool(_tp.is_open()) and String(_tp.last_run) == "", "Escape closes it and runs nothing")
			_p(_km.is_context_active("builder"), "builder keys live again")
			print("\n-- E3d: the RMB-tap context menu --")
			_cm = _map.get("_context_menu")
			_p(_cm != null, "the map owns a context menu")
			if _cm == null:
				return _finish()
			_rmb(true, Vector2(400, 300))
			_rmb(false, Vector2(400, 300))
		71:
			_p(bool(_cm.is_open()), "a quick RMB tap opens it")
			_p(_cm.has_action("builder.create_box") and _cm.has_action("builder.palette"),
				"nothing selected: Add & view (new box, palette ...)")
			_p(not _cm.has_action("build_mode.csg_subtract"), "no CSG without a selection")
			_p(not _km.is_context_active("builder") and not bool(_km.movement_allowed()), "menu context while open")
			_cm.pick("builder.grid_coarser")
			_map.set_grid_index(5)
		76:
			_p(not bool(_cm.is_open()) and int(_map.get("_grid_idx")) == 6, "a pick runs it (grid 8u -> 16u)",
				str(_map.get("_grid_idx")))
			# A DRAG is the look, not a tap.
			_rmb(true, Vector2(400, 300))
			var mm := InputEventMouseMotion.new()
			mm.relative = Vector2(40, 0)
			Input.parse_input_event(mm)
			_rmb(false, Vector2(400, 300))
		80:
			_p(not bool(_cm.is_open()), "an RMB drag (the look) opens nothing")
			# Two brushes selected -> the Brush section with CSG.
			_map.set("_sel_brushes", [1, 2] as Array[int])
			_rmb(true, Vector2(400, 300))
			_rmb(false, Vector2(400, 300))
		83:
			_p(bool(_cm.is_open()) and _cm.has_action("build_mode.csg_subtract") and _cm.has_action("builder.delete"),
				"two brushes selected: Brush section with CSG and delete")
			_press(KEY_ESCAPE)
		86:
			_p(not bool(_cm.is_open()), "Escape closes it")
			_map.set("_sel_brushes", [] as Array[int])
			print("\n-- E3e: modifier hints --")
			_hud = root.get_node("/root/BuildHud")
			var st: String = _hud.status_text()
			_p(st.contains("RMB") and st.contains("menu") and st.contains("Space"),
				"idle hints mention the RMB-tap menu and Space search")
			_key_state(KEY_SHIFT, true)
			_t_mod = Time.get_ticks_msec()
		87:
			if Time.get_ticks_msec() - _t_mod < 120:
				_frame -= 1                       # wait real time, not frames
				return false
			_p(not String(_hud.status_text()).begins_with("[b]Shift"), "a short Shift press shows nothing yet")
		88:
			if Time.get_ticks_msec() - _t_mod < 450:
				_frame -= 1
				return false
			var t: String = _hud.status_text()
			_p(t.begins_with("[b]Shift"), "held ~300 ms: the bar lists what Shift does", t.left(80))
			# Keymap step 1: Shift flies fast only WHILE flying; alone it means nothing here.
			_p(not t.contains("fly fast"), "no tool up, not flying: Shift alone means nothing")
			_p(t.contains("Duplicate") and t.contains("Snap to grid"), "and every Shift chord live now (Shift+D duplicate, Shift+Tab snap)")
			_p(not t.contains("Ctrl+"), "only Shift chords")
			_key_state(KEY_SHIFT, false)
		90:
			_p(not String(_hud.status_text()).begins_with("[b]Shift"), "released: the idle hints come back")
			_p(_hud.has_method("modifier_hint") and not String(_hud.modifier_hint("ctrl")).contains("fly down"),
				"Ctrl with no tool up is not 'fly down' any more (only while flying)")
			print("\n-- E3f: the first-run card --")
			var gm: Node = root.get_node("/root/GameMode")
			gm.set_mode("play")
			_dock.set_pref("first_run_seen", false)
			gm.set_mode("build")
		92:
			_p(_hud.has_method("first_run_visible") and bool(_hud.first_run_visible()),
				"first time in build mode: the card shows")
			if not _hud.has_method("first_run_text"):
				return _finish()
			var ct: String = _hud.first_run_text()
			_p(ct.contains("F1") and ct.contains("search every command") and ct.contains("Shift+Space")
				and ct.contains("RMB") and ct.contains("Shift+F"),
				"it names the ways in, with the LIVE keys (Space search, Shift+F fly)", ct.left(120))
			_hud.dismiss_first_run()
			_p(not bool(_hud.first_run_visible()) and bool(_dock.pref("first_run_seen", false)),
				"dismissed, and remembered")
			var gm2: Node = root.get_node("/root/GameMode")
			gm2.set_mode("play")
			gm2.set_mode("build")
		94:
			_p(not bool(_hud.first_run_visible()), "never again for this player")
			print("\n-- E3g: the wheel drives the active tool --")
			_p(_km.describe("builder.tool_more") == "Wheel down" and _km.describe("builder.tool_less") == "Wheel up",
				"wheel down = bigger, up = smaller (user: inverted)", "%s / %s" % [_km.describe("builder.tool_more"), _km.describe("builder.tool_less")])
			_map.set_grid_index(5)
			var cam := Camera3D.new()
			root.add_child(cam)
			cam.look_at_from_position(Vector3(0, 3, 2), Vector3(0, 0.5, 0))
			cam.make_current()
			_wheel(MOUSE_BUTTON_WHEEL_UP)
		96:
			_p(int(_map.get("_grid_idx")) == 5 and _map.call("_ensure_ops").get("active") == null,
				"no tool up: the wheel changes nothing in the level")
			_km.run("builder.create_box")    # menu-only since keymap step 3 (N = sidebar)
		99:
			var op: Variant = _map.call("_ensure_ops").get("active")
			# CONTAINS, not begins_with: the parametric box's status leads with its type
			# ("Box 16u x 16u x 16u — editable after placing", parametric-shapes-design §13).
			_p(op != null and String(op.status()).contains("16u x 16u x 16u"), "box tool up at 16u",
				String(op.status()) if op != null else "no tool")
			if op == null:
				return _finish()
			_wheel(MOUSE_BUTTON_WHEEL_DOWN)
		102:
			var op2: Variant = _map.call("_ensure_ops").get("active")
			_p(String(op2.status()).contains("24u x 24u x 24u"), "wheel DOWN grows the box one step", String(op2.status()))
			_wheel(MOUSE_BUTTON_WHEEL_UP)
			_wheel(MOUSE_BUTTON_WHEEL_UP)
		105:
			var op3: Variant = _map.call("_ensure_ops").get("active")
			_p(String(op3.status()).contains("8u x 8u x 8u") and not String(op3.status()).contains("18u"),
				"wheel UP shrinks it", String(op3.status()))
			_p(int(_map.get("_grid_idx")) == 5, "and the grid is untouched")
			_map.call("_ensure_ops").cancel()
			return _finish()
	return false


func _finish() -> bool:
	# The box tool remembers its size now (E4 sticky prefs); the wheel steps above changed
	# it, so put the player's own value back (absent stays absent).
	for k: String in _saved_tool:
		if _saved_tool[k] == null:
			_dock._prefs.erase(k)
		else:
			_dock._prefs[k] = _saved_tool[k]
	_dock.set_pref("palette_recent", _saved_recent)
	_dock.set_pref("grid", _saved_grid)
	_dock.set_pref("first_run_seen", _saved_seen)
	_dock.set_pref("snap", _saved_snap)
	if checks < 62:
		_p(false, "only %d checks ran" % checks)
	print("GATE editor_e3: %s (%d checks)" % ["PASS" if ok else "FAIL", checks])
	quit(0 if ok else 1)
	return true