OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / face_panel.gd

218 lines · gd ref:
extends VBoxContainer
## FACE panel — UV controls for the selection (docs/uv-controls-design.md §4), in the
## BuildDock's right column on maps with texture packs.
##
## Read-outs (texture, mode, offset, scale, rotation — "multi" when the selection
## disagrees) and buttons; every button is map.uv_op, the same path as the keys, so the
## maths and the records are gated once (gate_uv_controls). Buttons that have a key show
## it in their tooltip, with shortcut processing OFF so a key press never fires twice
## (the map's _unhandled_key_input already owns it — editor_top_bar does the same).
## Typing exact values is a follow-up; this slice is read-outs + steps.

var _map: Node = null
var _title: Label
var _mode: OptionButton
var _vals := {}            # "off" / "scale" / "rot" -> Label
var _lock: CheckBox
var _buttons := {}         # name -> Button (gates press them by name)
var _sig := ""
var _tick := 0

const _MODES := [["Paraxial (world)", 1], ["Face-aligned", 3], ["Explicit (frozen)", 2], ["Hotspot (trim)", 4]]


func setup(map: Node) -> void:
	_map = map
	name = "FacePanel"
	add_theme_constant_override("separation", 4)
	_title = Label.new()
	_title.autowrap_mode = TextServer.AUTOWRAP_ARBITRARY
	add_child(_title)

	_mode = OptionButton.new()
	for m: Array in _MODES:
		_mode.add_item(String(m[0]), int(m[1]))
	_mode.tooltip_text = "Projection: world axes, the face's own axes, or frozen as is"
	_mode.focus_mode = Control.FOCUS_NONE
	_mode.item_selected.connect(func(i: int) -> void: _run("mode", {"mode": _mode.get_item_id(i)}))
	add_child(_mode)

	# Move and Justify are SCREEN-relative (TrenchBroom): the arrow says where the
	# texture goes as you see it, not which of u / v changes.
	_row("Move", "off", [["◀", "nudge_screen", {"dx": -1}, "builder.uv_left"],
		["▶", "nudge_screen", {"dx": 1}, "builder.uv_right"],
		["▲", "nudge_screen", {"dy": 1}, "builder.uv_up"],
		["▼", "nudge_screen", {"dy": -1}, "builder.uv_down"]])
	_row("Scale", "scale", [["÷2", "nudge", {"mul_u": 0.5, "mul_v": 0.5}, ""],
		["×2", "nudge", {"mul_u": 2.0, "mul_v": 2.0}, ""],
		["Flip H", "nudge", {"mul_u": -1.0}, ""], ["Flip V", "nudge", {"mul_v": -1.0}, ""]])
	_row("Rotation", "rot", [["−15°", "nudge", {"drot": -15.0}, "builder.uv_rot_cw"],
		["+15°", "nudge", {"drot": 15.0}, "builder.uv_rot_ccw"]])
	_row("Fit", "", [["Fit", "fit_both", {}, "builder.uv_fit"], ["Fit U", "fit", {"axis": 0}, ""],
		["Fit V", "fit", {"axis": 1}, ""], ["Trim U", "fit", {"axis": 0, "trim": true}, ""]])
	_row("Justify", "", [["←", "justify_screen", {"dx": -1}, ""],
		["→", "justify_screen", {"dx": 1}, ""],
		["↑", "justify_screen", {"dy": 1}, ""], ["↓", "justify_screen", {"dy": -1}, ""]])
	_row("", "", [["Align", "align", {}, "builder.uv_align"], ["Reset", "reset", {}, ""]])
	# Trim / hotspot sheets (#94 P2): fit to the best rect, try another, keep this one.
	_row("Hotspot", "", [["Fit rect", "hotspot", {}, "builder.uv_hotspot"],
		["Re-roll", "hotspot_reroll", {}, "builder.uv_reroll"], ["Pin", "hotspot_pin", {}, "builder.uv_pin"]])

	_lock = CheckBox.new()
	_lock.text = "Texture lock"
	_lock.tooltip_text = "Moving or scaling a brush carries its textures with it"
	_lock.focus_mode = Control.FOCUS_NONE
	_lock.toggled.connect(func(on: bool) -> void: _map.set("tex_lock", on))
	add_child(_lock)
	refresh()


## One labelled row: a value read-out (when `key` is set) and step buttons.
## Each spec: [text, op, args, keymap action or ""]. Offset args are in GRID STEPS
## and are scaled to metres when pressed.
func _row(label: String, key: String, specs: Array) -> void:
	var box := HBoxContainer.new()
	if label != "":
		var l := Label.new()
		l.text = label
		l.custom_minimum_size = Vector2(64, 0)
		box.add_child(l)
	if key != "":
		var v := Label.new()
		v.custom_minimum_size = Vector2(96, 0)
		box.add_child(v)
		_vals[key] = v
	for s: Array in specs:
		var b := Button.new()
		b.text = String(s[0])
		var tip := String(s[1]).capitalize()
		if String(s[3]) != "":
			# Through the main loop's root, NOT get_node_or_null("/root/Keymap"): setup()
			# runs before the panel is in the tree, where an absolute path finds nothing
			# (one engine error per button) and the tooltip lost its key label.
			var ml := Engine.get_main_loop() as SceneTree
			var km: Node = ml.root.get_node_or_null("Keymap") if ml != null else null
			if km != null:
				tip = "%s (%s)" % [String(km.binding_of(String(s[3])).get("label", tip)), km.describe(String(s[3]))]
		b.tooltip_text = tip
		b.set_process_shortcut_input(false)
		# NEVER take keyboard focus: a focused Button makes the GUI eat the arrows for
		# focus navigation before the map's _unhandled_key_input — so after one click
		# the arrow keys "worked a bit then stopped" (user report, gate_uv_controls).
		b.focus_mode = Control.FOCUS_NONE
		b.pressed.connect(_on_button.bind(String(s[1]), s[2] as Dictionary))
		box.add_child(b)
		_buttons["%s:%s" % [s[1], str(s[2])]] = b
		_buttons[String(s[0])] = b
	add_child(box)


func button(text: String) -> Button:
	return _buttons.get(text)


func _on_button(op: String, args: Dictionary) -> void:
	var a := args.duplicate()
	# Fit / Justify / Align: again = next, Shift = back (TrenchBroom's buttons).
	if op in ["fit", "fit_both", "justify", "justify_screen", "align"] and not a.has("policy"):
		a["policy"] = 2 if Input.is_key_pressed(KEY_SHIFT) else 1
	if op in ["nudge_screen", "justify_screen"]:
		a.merge(_map.camera_axes())
		a["step"] = _map._grid_step()
		a["dx"] = int(a.get("dx", 0))
		a["dy"] = int(a.get("dy", 0))
	if op == "nudge" and (a.has("du") or a.has("dv")):
		var step: float = _map._grid_step()
		a["du"] = float(a.get("du", 0.0)) * step
		a["dv"] = float(a.get("dv", 0.0)) * step
	if op == "fit_both":
		_run("fit", {"axis": 0, "policy": a["policy"]})
		_run("fit", {"axis": 1, "policy": a["policy"]})
		return
	_run(op, a)


func _run(op: String, args: Dictionary) -> void:
	if _map != null and _map.has_method("uv_op"):
		_map.uv_op(op, args)
	refresh()


func _process(_d: float) -> void:
	_tick += 1
	if _tick % 6 == 0:      # ~10 Hz is plenty for read-outs
		refresh()


## The selected faces' intents (textured faces only — the ops skip the rest).
func _faces() -> Array:
	var out: Array = []
	if _map == null or not _map.has_method("_texture_targets") or (_map.get("_sel_brushes") as Array).is_empty():
		return out
	var level: Node = _map.get("level")
	for t: Array in _map._texture_targets():
		var fts: Array = level.get_brush(int(t[0])).get("face_tex", [])
		for k: String in (t[1] as PackedStringArray):
			var f: int = level.face_by_key(int(t[0]), k)
			if f >= 0 and f < fts.size() and int((fts[f] as Dictionary).get("mode", 0)) != 0:
				out.append(fts[f])
	return out


## The selection's values, "multi" where they disagree. For gates and the read-outs.
func values() -> Dictionary:
	var fs := _faces()
	var out := {"count": fs.size()}
	if fs.is_empty():
		return out
	var pick := func(k: String) -> Variant:
		var v: Variant = (fs[0] as Dictionary).get(k)
		for f: Dictionary in fs:
			var w: Variant = f.get(k)
			if (v is float or w is float) and not is_equal_approx(float(v), float(w)):
				return "multi"
			if not (v is float or w is float) and v != w:
				return "multi"
		return v
	for k: String in ["tex", "mode", "off_u", "off_v", "rot", "scale_u", "scale_v"]:
		out[k] = pick.call(k)
	return out


func refresh() -> void:
	if _map == null:
		return
	var v := values()
	var sig := var_to_str(v) + str(_map.get("tex_lock"))
	if sig == _sig:
		return
	_sig = sig
	var n := int(v.get("count", 0))
	var on := n > 0
	for b: Button in _buttons.values():
		b.disabled = not on
	_mode.disabled = not on
	_lock.set_pressed_no_signal(bool(_map.get("tex_lock")))
	if not on:
		_title.text = "Select textured faces (B) or a brush"
		for l: Label in _vals.values():
			l.text = "—"
		return
	_title.text = "%s  ·  %d face%s" % [_fmt(v.get("tex")), n, "" if n == 1 else "s"]
	var mode: Variant = v.get("mode")
	if mode is int:
		for i in _mode.item_count:
			if _mode.get_item_id(i) == int(mode):
				_mode.select(i)
	else:
		_mode.select(-1)
	(_vals["off"] as Label).text = "%s, %s m" % [_fmt(v.get("off_u")), _fmt(v.get("off_v"))]
	(_vals["scale"] as Label).text = "%s × %s" % [_fmt(v.get("scale_u")), _fmt(v.get("scale_v"))]
	(_vals["rot"] as Label).text = "%s°" % _fmt(v.get("rot"))


static func _fmt(x: Variant) -> String:
	if x is float:
		return String.num(float(x), 2)
	return str(x)