OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / object_panel.gd

349 lines · gd ref:
extends VBoxContainer
## OBJECT panel — the selected scene object's properties (object.set_prop, #41 §7
## item 5), in the BuildDock's right column.
##
## GENERATED from PrefabCatalogue.PROPS, the same list the fold enforces, so the
## panel cannot offer a property the fold would refuse and a new prefab property
## needs no UI code. One widget per kind:
##   float  slider + value      colour  colour button
##   bool   check box           vec3    three number boxes + "Use my position"
##   string line edit
##
## ONE RECORD PER CHANGE, NOT PER FRAME. A slider writes when the drag ends and a
## colour when its picker closes; in between the node is previewed locally
## (SceneObjects.apply_prop), which the next fold overwrites with the journal's
## value. So History says "Set Brightness" once, and one Ctrl+Z takes it back.
##
## Objects are selected in the Outliner (map.select_object). Widgets built here
## after BuildDock.register are given the dock's focus rule by hand (buttons and
## sliders take no focus; text keeps click focus), because the dock applies it once.

var _map: Node = null
var _title: Label
var _box: VBoxContainer
var _dot := ""
var _type := ""
var _widgets := {}          # prop id -> {spec, ctl, ...}
var _dragging := ""         # prop id whose slider is mid-drag ("" = none)
var _tick := 0


func setup(map: Node) -> void:
	_map = map
	name = "ObjectPanel"
	add_theme_constant_override("separation", 4)
	_title = Label.new()
	_title.autowrap_mode = TextServer.AUTOWRAP_ARBITRARY
	add_child(_title)
	_box = VBoxContainer.new()
	_box.add_theme_constant_override("separation", 4)
	add_child(_box)
	# Build once now: refresh() rebuilds only on a CHANGE of selection, and "nothing
	# selected" at setup equals the initial state, so the empty-state title was never set.
	_rebuild()
	refresh()


func _process(_d: float) -> void:
	_tick += 1
	if _tick % 6 == 0:      # ~10 Hz, like the Face panel
		refresh()


## The selected object's dot, or "" when nothing (or something gone) is selected.
func selected() -> String:
	if _map == null:
		return ""
	var dot := String(_map.get("_sel_object"))
	if dot == "" or not _map.has_method("object_node") or _map.object_node(dot) == null:
		return ""
	return dot


func refresh() -> void:
	var dot := selected()
	var type_id := ""
	if dot != "":
		type_id = String(_map.object_node(dot).get_meta("object_type", ""))
	# The SPEC LIST can change without the selection changing: a bot's brain decides
	# which settings exist, so picking another brain must rebuild the rows.
	var sig := ""
	if dot != "":
		for s: Dictionary in PrefabCatalogue.props_of(type_id, _map.object_props(dot)):
			sig += String(s["id"]) + ","
	if dot != _dot or type_id != _type or sig != _spec_sig:
		_dot = dot
		_type = type_id
		_spec_sig = sig
		_rebuild()
	_sync_values()


var _spec_sig := ""


func _rebuild() -> void:
	for c in _box.get_children():
		_box.remove_child(c)
		c.queue_free()
	_widgets.clear()
	_dragging = ""
	if _dot == "":
		_title.text = "Select an object in the Outliner"
		return
	var specs: Array = PrefabCatalogue.props_of(_type, _map.object_props(_dot))
	_title.text = "%s  ·  %s" % [_type, _dot.get_slice(":", 1)]
	if specs.is_empty():
		var l := Label.new()
		l.text = "No settable properties"
		_box.add_child(l)
		return
	for s: Dictionary in specs:
		if bool(s.get("panel", true)):
			_add_row(s)


func _add_row(s: Dictionary) -> void:
	var id := String(s["id"])
	var row := HBoxContainer.new()
	var l := Label.new()
	l.text = String(s["label"])
	l.custom_minimum_size = Vector2(84, 0)
	row.add_child(l)
	var w := {"spec": s}
	match String(s["kind"]):
		"float", "int":
			var sl := HSlider.new()
			sl.min_value = float(s.get("min", 0.0))
			sl.max_value = float(s.get("max", 1.0))
			# open_max: the slider's range is a comfortable drag, not a limit — a larger
			# value (from the Scale tool) shows instead of clamping back (#109).
			sl.allow_greater = bool(s.get("open_max", false))
			sl.step = float(s.get("step", 0.01))
			# Exponential for ranges like 0.01–100, or the useful half sits in one pixel.
			sl.exp_edit = bool(s.get("exp", false))
			sl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			sl.custom_minimum_size = Vector2(110, 0)
			sl.focus_mode = Control.FOCUS_NONE
			sl.drag_started.connect(_on_drag_started.bind(id))
			sl.drag_ended.connect(_on_drag_ended.bind(id))
			sl.value_changed.connect(_on_slider.bind(id))
			row.add_child(sl)
			var v := Label.new()
			v.custom_minimum_size = Vector2(56, 0)
			row.add_child(v)
			w["ctl"] = sl
			w["readout"] = v
		"color":
			var cb := ColorPickerButton.new()
			cb.edit_alpha = false
			cb.custom_minimum_size = Vector2(64, 22)
			cb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			cb.focus_mode = Control.FOCUS_NONE
			cb.color_changed.connect(_on_color_preview.bind(id))
			cb.popup_closed.connect(_on_color_closed.bind(id))
			row.add_child(cb)
			w["ctl"] = cb
		"enum":
			var ob := OptionButton.new()
			var labels: Array = s.get("option_labels", s.get("options", []))
			for i in (s.get("options", []) as Array).size():
				ob.add_item(String(labels[i]), i)
			ob.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			ob.focus_mode = Control.FOCUS_NONE
			ob.item_selected.connect(_on_enum.bind(id))
			row.add_child(ob)
			w["ctl"] = ob
		"bool":
			var ck := CheckBox.new()
			ck.focus_mode = Control.FOCUS_NONE
			ck.toggled.connect(_on_toggled.bind(id))
			row.add_child(ck)
			w["ctl"] = ck
		"string":
			var le := LineEdit.new()
			le.max_length = int(s.get("max_len", 64))
			le.size_flags_horizontal = Control.SIZE_EXPAND_FILL
			le.placeholder_text = "(default)"
			le.focus_mode = Control.FOCUS_CLICK
			le.text_submitted.connect(_on_text_submitted.bind(id))
			le.focus_exited.connect(_on_text_left.bind(id))
			row.add_child(le)
			w["ctl"] = le
		"vec3":
			var boxes: Array = []
			for i in 3:
				var sb := SpinBox.new()
				sb.min_value = float(s.get("min", -100000.0))
				sb.max_value = float(s.get("max", 100000.0))
				sb.step = 0.1
				sb.custom_minimum_size = Vector2(70, 0)
				sb.focus_mode = Control.FOCUS_CLICK
				sb.get_line_edit().focus_mode = Control.FOCUS_CLICK
				sb.value_changed.connect(_on_vec_changed.bind(id))
				row.add_child(sb)
				boxes.append(sb)
			w["ctl"] = boxes
	_box.add_child(row)
	if String(s["kind"]) == "vec3":
		var here := Button.new()
		here.text = "Use my position"
		here.tooltip_text = "Set %s to where you are standing" % String(s["label"]).to_lower()
		here.focus_mode = Control.FOCUS_NONE
		here.pressed.connect(_on_use_position.bind(id))
		_box.add_child(here)
		w["here"] = here
	_widgets[id] = w


## Widgets <- the object's effective props, skipping whatever the player is touching.
## `_syncing` stops the value_changed this causes from being read as an edit.
var _syncing := false


func _sync_values() -> void:
	if _dot == "" or _widgets.is_empty():
		return
	var props: Dictionary = _map.object_props(_dot)
	_syncing = true
	for id: String in _widgets:
		if id == _dragging:
			continue
		var w: Dictionary = _widgets[id]
		var v: Variant = props.get(id, (w["spec"] as Dictionary)["default"])
		match String((w["spec"] as Dictionary)["kind"]):
			"float", "int":
				(w["ctl"] as HSlider).set_value_no_signal(float(v))
				(w["readout"] as Label).text = _fmt(float(v), w["spec"])
			"color":
				var cb := w["ctl"] as ColorPickerButton
				if not cb.get_popup().visible:
					cb.color = Color(float(v[0]), float(v[1]), float(v[2]))
			"bool":
				(w["ctl"] as CheckBox).set_pressed_no_signal(bool(v))
			"enum":
				var ob := w["ctl"] as OptionButton
				var at := ((w["spec"] as Dictionary).get("options", []) as Array).find(v)
				if ob.selected != at:
					ob.select(at)
			"string":
				var le := w["ctl"] as LineEdit
				if not le.has_focus() and le.text != String(v):
					le.text = String(v)
			"vec3":
				var boxes: Array = w["ctl"]
				for i in 3:
					var sb := boxes[i] as SpinBox
					if not sb.get_line_edit().has_focus():
						sb.set_value_no_signal(float(v[i]))
	_syncing = false


static func _fmt(x: float, spec: Dictionary) -> String:
	var unit := String(spec.get("unit", ""))
	var digits := 0 if float(spec.get("step", 0.01)) >= 1.0 else (1 if float(spec.get("step", 0.01)) >= 0.1 else 2)
	return String.num(x, digits) + ("" if unit == "" else (" " + unit if unit != "°" else unit))


# ---- writes ------------------------------------------------------------------

## One property, one record, one gesture labelled "Set <Label>". Public so gates
## drive the same path the widgets do.
func commit(id: String, value: Variant) -> bool:
	if _dot == "" or not _widgets.has(id):
		return false
	var spec: Dictionary = _widgets[id]["spec"]
	# Unchanged is not an edit: no record, no History row.
	if SceneObjects.same_value(value, _map.object_props(_dot).get(id)):
		return false
	return _map.set_object_props(_dot, {id: value}, "Set %s" % String(spec["label"]))


## Local, uncommitted look at a value while it is being dragged or picked.
func _preview(id: String, value: Variant) -> void:
	var n: Node = _map.object_node(_dot)
	if n != null and _widgets.has(id):
		SceneObjects.apply_prop(n, _widgets[id]["spec"], PrefabCatalogue.sanitize(_widgets[id]["spec"], value))


func _on_drag_started(id: String) -> void:
	_dragging = id


func _on_drag_ended(changed: bool, id: String) -> void:
	_dragging = ""
	if changed:
		commit(id, _num(id, (_widgets[id]["ctl"] as HSlider).value))


## A slider value in its spec's kind: an "int" setting commits a whole number.
func _num(id: String, v: float) -> Variant:
	return roundi(v) if String((_widgets[id]["spec"] as Dictionary).get("kind", "")) == "int" else v


## A click on the track or the wheel is a whole change; mid-drag it is a preview.
func _on_slider(v: float, id: String) -> void:
	if _syncing or not _widgets.has(id):
		return
	(_widgets[id]["readout"] as Label).text = _fmt(v, _widgets[id]["spec"])
	if _dragging == id:
		_preview(id, v)
	else:
		commit(id, _num(id, v))


func _on_color_preview(c: Color, id: String) -> void:
	_preview(id, [c.r, c.g, c.b])


func _on_color_closed(id: String) -> void:
	var c: Color = (_widgets[id]["ctl"] as ColorPickerButton).color
	if not commit(id, [c.r, c.g, c.b]):
		_sync_values()           # cancelled / unchanged: drop the preview's look
		var n: Node = _map.object_node(_dot)
		if n != null:
			SceneObjects.apply_prop(n, _widgets[id]["spec"], _map.object_props(_dot).get(id))


func _on_enum(index: int, id: String) -> void:
	if not _syncing and _widgets.has(id):
		commit(id, String(((_widgets[id]["spec"] as Dictionary)["options"] as Array)[index]))


func _on_toggled(on: bool, id: String) -> void:
	if not _syncing:
		commit(id, on)


func _on_text_submitted(t: String, id: String) -> void:
	commit(id, t)
	(_widgets[id]["ctl"] as LineEdit).release_focus()


func _on_text_left(id: String) -> void:
	if _widgets.has(id):
		commit(id, (_widgets[id]["ctl"] as LineEdit).text)


func _on_vec_changed(_v: float, id: String) -> void:
	if _syncing or not _widgets.has(id):
		return
	var boxes: Array = _widgets[id]["ctl"]
	commit(id, [float((boxes[0] as SpinBox).value), float((boxes[1] as SpinBox).value),
		float((boxes[2] as SpinBox).value)])


func _on_use_position(id: String) -> void:
	var pp: Node = get_node_or_null("/root/PlayerPosition")
	var p: Node3D = pp.get_local_player() if pp != null else null
	if p == null:
		print("[ObjectPanel] no local player to take a position from")
		return
	var g := p.global_position
	commit(id, [g.x, g.y, g.z])


## For gates: the widget(s) of a property.
func widget(id: String) -> Variant:
	return (_widgets.get(id, {}) as Dictionary).get("ctl")