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