OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / stack_panel.gd

379 lines · gd ref:
extends VBoxContainer
## THE STACK PANEL (docs/cutting-and-operations.md §9b, docs/node-tree-and-stacks.md §3.4):
## the selected thing's stack, in the FIXED stage order (generator → copies → cuts → chamfer).
##
##   Generator: [Stairs ▾]                       row 0 (a plain group: [Make parametric])
##   [✓] ⇋ Mirror  [x ▾]  ✕                     copy modifiers, in stack order (#120 F2)
##   [✓] − Subtract ▾   ◇ cutter   ✕            one row per op, in stack order
##   [✓] ◢ Chamfer  [0.10]  ✕                   chamfer modifiers, after the cuts
##   [Add modifier ▾]                            mirror / array / radial / chamfer
##   cuts ▸ <target>                             when the selection IS a cutter
##
## Every control is one journal gesture through the map's API (set_op / remove_op), so
## one Ctrl+Z undoes it. No reorder: subtract / intersect / slice commute as sets (§9b).
## Make Real stays on the Shape panel — it bakes the whole stack.

const KINDS := ["subtract", "intersect", "slice", "inset", "outset"]
const KIND_GLYPH := {"subtract": "−", "intersect": "∩", "slice": "⫽", "inset": "⊔", "outset": "⊓"}
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
const MOD_GLYPH := {"mirror": "⇋", "array": "⋯", "grid": "▦", "radial": "✻", "curve": "∿", "chamfer": "◢", "dice": "⊞"}
const MOD_KINDS := ["mirror", "array", "grid", "radial", "curve", "chamfer", "dice"]
const AXES := ["x", "y", "z"]

var map: Node = null
var _subject := ""
var _ver := -1
var _title: Label = null
var _rows: VBoxContainer = null
var _hint: Label = null
var _ops: Array = []          # what the rows show: [{dot, kind, on, cutter}]
var _cuts: Array = []         # [{dot, kind, on, target}] when the subject is a cutter
var _mods: Array = []         # ModFold states of the subject's modifiers, in stack order
var _is_shape := false
var _gen: HBoxContainer = null
var _add: OptionButton = null


func setup(map_node: Node) -> void:
	map = map_node
	_title = Label.new()
	add_child(_title)
	_hint = Label.new()
	_hint.text = "No live cuts. Select a cutter shape, then the target, and Subtract."
	_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	_hint.custom_minimum_size = Vector2(220, 0)
	_hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7))
	add_child(_hint)
	_gen = HBoxContainer.new()
	add_child(_gen)
	_rows = VBoxContainer.new()
	add_child(_rows)
	_add = OptionButton.new()
	_add.focus_mode = Control.FOCUS_NONE
	_add.tooltip_text = "Add a modifier to this shape (one Ctrl+Z takes it back)"
	add_child(_add)
	_add.item_selected.connect(func(i: int) -> void:
		if i > 0:
			press("", "add_mod", MOD_KINDS[i - 1])
		_add.select(0))
	refresh()


func _process(_d: float) -> void:
	if map == null:
		return
	var s: String = map.stack_subject()
	var p: Node = map.get("_persist")
	var v: int = int(p.get("_ix_ver")) if p != null else -1
	if s != _subject or v != _ver:
		refresh()


# ---- the gate's API (and the panel's model) ----

func subject() -> String:
	return _subject


func op_rows() -> Array:
	return _ops


func cut_rows() -> Array:
	return _cuts


func mod_rows() -> Array:
	return _mods


## Act on one op's row: what = "eye" (toggle), "kind" (value = a KINDS name), "remove",
## "cutter" (select it).
func press(dot: String, what: String, value: Variant = null) -> void:
	# THE STACK'S OWN ROWS (#120 F2): generator and modifiers.
	match what:
		"generator":
			map.assign_generator(_subject, String(value))
			refresh()
			return
		"make_parametric":
			map.make_parametric()
			refresh()
			return
		"add_mod":
			map.add_modifier(String(value), _subject)
			refresh()
			return
		"mod_eye":
			for m: Dictionary in _mods:
				if String(m["dot"]) == dot:
					map.set_modifier(dot, {}, not bool(m["on"]))
			refresh()
			return
		"mod_param":
			map.set_modifier(dot, value as Dictionary)
			refresh()
			return
		"mod_remove":
			map.remove_modifier(dot)
			refresh()
			return
	var row: Dictionary = {}
	for r: Dictionary in _ops + _cuts:
		if String(r["dot"]) == dot:
			row = r
	if row.is_empty():
		return
	match what:
		"eye":
			map.set_op(dot, {"on": not bool(row["on"])})
		"kind":
			if KINDS.has(String(value)) and String(value) != String(row["kind"]):
				map.set_op(dot, {"op": String(value)})
		"depth":
			map.set_op(dot, {"depth": float(value)})
		"remove":
			map.remove_op(dot)
		"cutter":
			if row.has("cutter"):
				map.select_shape(String(row["cutter"]))
		"target":
			if row.has("target"):
				_select_thing(String(row["target"]))
	refresh()


# ---- drawing ----

func refresh() -> void:
	if map == null:
		return
	_subject = map.stack_subject()
	var p: Node = map.get("_persist")
	_ver = int(p.get("_ix_ver")) if p != null else -1
	_ops = map.op_stack(_subject) if _subject != "" else []
	_cuts = map.cuts_by(_subject) if _subject != "" else []
	_is_shape = _subject != "" and (map._persist.live_shapes() as Dictionary).has(_subject)
	_mods = map.modifier_stack(_subject) if _is_shape else []
	for c in _rows.get_children():
		_rows.remove_child(c)
		c.queue_free()
	_title.text = ("%s  %s" % [_generator_label(), _subject]) if _subject != "" else ""
	_hint.visible = _ops.is_empty() and _cuts.is_empty() and _mods.is_empty()
	_fill_generator()
	_fill_add()
	# THE FIXED STAGE ORDER: copies, cuts, chamfers.
	for m: Dictionary in _mods:
		if String(m["kind"]) != "chamfer":
			_rows.add_child(_mod_row(m))
	for r: Dictionary in _ops:
		_rows.add_child(_op_row(r))
	for m2: Dictionary in _mods:
		if String(m2["kind"]) == "chamfer":
			_rows.add_child(_mod_row(m2))
	for c2: Dictionary in _cuts:
		_rows.add_child(_cut_row(c2))


## Row 0: the generator. A shape: its type, switchable (map.assign_generator; the old one
## stays its passage), with "Remove generator" when it has an input. A plain group: the
## button that makes it a shape (map.make_parametric) — modifiers need one.
func _fill_generator() -> void:
	for c in _gen.get_children():
		_gen.remove_child(c)
		c.queue_free()
	# A LOOSE brush has no group, so no stack subject — but it can still be made a shape.
	var loose: bool = _subject == "" and not (map.get("_sel_brushes") as Array).is_empty()
	_gen.visible = _subject != "" or loose
	if not _gen.visible:
		return
	var l := Label.new()
	l.text = "Generator"
	_gen.add_child(l)
	if not _is_shape:
		var mp := Button.new()
		mp.focus_mode = Control.FOCUS_NONE
		mp.text = "Make parametric"
		mp.tooltip_text = "Turn the selected brush into a shape (box or hull) so it can take a generator and modifiers"
		mp.pressed.connect(func() -> void: press("", "make_parametric"))
		_gen.add_child(mp)
		return
	var st: Dictionary = map._persist.shape_state(_subject)
	var cur := String(st.get("type", ""))
	var inp := String((st.get("input", {}) as Dictionary).get("type", ""))
	var ob := OptionButton.new()
	ob.focus_mode = Control.FOCUS_NONE
	ob.tooltip_text = "Assign a generator: the current solid stays as its passage"
	var ids: Array = []
	var cur_t: Object = ParamShapes.get_type(cur)
	ob.add_item(cur_t.label() if cur_t != null else cur)
	ids.append(cur)
	if inp != "":
		var ti: Object = ParamShapes.get_type(inp)
		ob.add_item("Remove generator (back to %s)" % (ti.label() if ti != null else inp))
		ids.append(inp)
	for tid: String in ParamShapes.ids():
		if tid != cur and tid != inp:
			ob.add_item((ParamShapes.get_type(tid) as Object).label())
			ids.append(tid)
	ob.item_selected.connect(func(i: int) -> void:
		if i > 0:
			press("", "generator", ids[i]))
	_gen.add_child(ob)


func _fill_add() -> void:
	_add.clear()
	_add.add_item("Add modifier…")
	for k: String in MOD_KINDS:
		_add.add_item("%s %s" % [MOD_GLYPH[k], k.capitalize()])
	_add.select(0)
	_add.visible = _is_shape


## One modifier: eye, its name, its main knob, ✕.
func _mod_row(m: Dictionary) -> Control:
	var dot := String(m["dot"])
	var kind := String(m["kind"])
	var p: Dictionary = m["params"]
	var h := HBoxContainer.new()
	var eye := CheckBox.new()
	eye.button_pressed = bool(m["on"])
	eye.focus_mode = Control.FOCUS_NONE
	eye.tooltip_text = "On / off"
	eye.toggled.connect(func(_on: bool) -> void: press(dot, "mod_eye"))
	h.add_child(eye)
	var l := Label.new()
	l.text = "%s %s" % [MOD_GLYPH.get(kind, "?"), kind.capitalize()]
	h.add_child(l)
	match kind:
		"mirror":
			var ax := OptionButton.new()
			ax.focus_mode = Control.FOCUS_NONE
			for a: String in AXES:
				ax.add_item(a.to_upper())
			ax.select(maxi(0, AXES.find(String(p.get("axis", "x")))))
			ax.item_selected.connect(func(i: int) -> void: press(dot, "mod_param", {"axis": AXES[i]}))
			h.add_child(ax)
		"array", "radial":
			h.add_child(_spin(int(p.get("count", 2)), 2, 64, 1, func(v: float) -> void:
				press(dot, "mod_param", {"count": int(v)})))
		"grid", "dice":
			# One count per frame axis (X, Y, Z): the whole vector is written each time.
			var cn: Array = (p.get("count", [2, 1, 2]) as Array).duplicate()
			for ai in 3:
				var set_axis := func(v: float) -> void:
					cn[ai] = int(v)
					press(dot, "mod_param", {"count": cn.duplicate()})
				var sp := _spin(int(cn[ai]), 1, 32, 1, set_axis)
				sp.tooltip_text = "Copies along %s" % ["X", "Y", "Z"][ai]
				h.add_child(sp)
		"curve":
			# Which curve (by name), and how many along it (0 = fit by the shape's size).
			var cb := OptionButton.new()
			cb.focus_mode = Control.FOCUS_NONE
			cb.tooltip_text = "The curve it follows"
			var curves: Dictionary = map._persist.curve_states() if map.get("_persist") != null else {}
			var cdots: Array = curves.keys()
			cdots.sort()
			for cd: String in cdots:
				cb.add_item(String(curves[cd]["name"]))
			cb.select(cdots.find(String(p.get("curve", ""))))
			cb.item_selected.connect(func(i: int) -> void: press(dot, "mod_param", {"curve": cdots[i]}))
			h.add_child(cb)
			var sp := _spin(int(p.get("count", 0)), 0, 256, 1, func(v: float) -> void:
				press(dot, "mod_param", {"count": int(v)}))
			sp.tooltip_text = "How many along the curve (0 = fit)"
			h.add_child(sp)
		"chamfer":
			h.add_child(_spin(float(p.get("width", 0.1)), 0.01, 4.0, 0.01, func(v: float) -> void:
				press(dot, "mod_param", {"width": v})))
			h.add_child(_spin(int(p.get("segments", 1)), 1, 8, 1, func(v: float) -> void:
				press(dot, "mod_param", {"segments": int(v)})))
	var x := Button.new()
	x.focus_mode = Control.FOCUS_NONE
	x.text = "✕"
	x.tooltip_text = "Remove this modifier (Ctrl+Z undoes)"
	x.pressed.connect(func() -> void: press(dot, "mod_remove"))
	h.add_child(x)
	return h


func _spin(v: float, lo: float, hi: float, step: float, cb: Callable) -> SpinBox:
	var sb := SpinBox.new()
	sb.min_value = lo
	sb.max_value = hi
	sb.step = step
	sb.value = v
	sb.focus_mode = Control.FOCUS_NONE
	sb.get_line_edit().focus_mode = Control.FOCUS_CLICK
	sb.value_changed.connect(cb)
	return sb


func _generator_label() -> String:
	var st: Dictionary = map._persist.shape_state(_subject) if map.get("_persist") != null else {}
	if st.is_empty():
		return "▣ group"
	return "◇ %s" % String(st.get("type", "shape")).capitalize()


func _op_row(r: Dictionary) -> Control:
	var dot := String(r["dot"])
	var h := HBoxContainer.new()
	var eye := CheckBox.new()
	eye.button_pressed = bool(r["on"])
	eye.focus_mode = Control.FOCUS_NONE          # the dock rule: a focusable eats Tab
	eye.tooltip_text = "On / off (off keeps the cutter, cuts nothing)"
	eye.toggled.connect(func(_on: bool) -> void: press(dot, "eye"))
	h.add_child(eye)
	var kind := OptionButton.new()
	kind.focus_mode = Control.FOCUS_NONE
	for k: String in KINDS:
		kind.add_item("%s %s" % [KIND_GLYPH[k], k.capitalize()])
	kind.select(maxi(0, KINDS.find(String(r["kind"]))))
	kind.item_selected.connect(func(i: int) -> void: press(dot, "kind", KINDS[i]))
	h.add_child(kind)
	# Inset / outset: how deep the pocket, how proud the boss (#120 L1 §12.3).
	if String(r["kind"]) == "inset" or String(r["kind"]) == "outset":
		var dp := _spin(float(r.get("depth", 0.25)), 0.01, 8.0, 0.05, func(v: float) -> void: press(dot, "depth", v))
		dp.tooltip_text = "Depth (m)"
		h.add_child(dp)
	var cut := Button.new()
	cut.focus_mode = Control.FOCUS_NONE
	cut.text = "◇ cutter"
	cut.tooltip_text = "Select the cutter %s (move it to move the hole)" % String(r["cutter"])
	cut.pressed.connect(func() -> void: press(dot, "cutter"))
	h.add_child(cut)
	var x := Button.new()
	x.focus_mode = Control.FOCUS_NONE
	x.text = "✕"
	x.tooltip_text = "Remove this cut (Ctrl+Z undoes)"
	x.pressed.connect(func() -> void: press(dot, "remove"))
	h.add_child(x)
	return h


func _cut_row(r: Dictionary) -> Control:
	var dot := String(r["dot"])
	var h := HBoxContainer.new()
	var l := Label.new()
	l.text = "%s cuts ▸" % KIND_GLYPH.get(String(r["kind"]), "−")
	if not bool(r["on"]):
		l.text += " (off)"
	h.add_child(l)
	var t := Button.new()
	t.focus_mode = Control.FOCUS_NONE
	t.text = String(r["target"])
	t.tooltip_text = "Select what this cutter cuts"
	t.pressed.connect(func() -> void: press(dot, "target"))
	h.add_child(t)
	return h


func _select_thing(g: String) -> void:
	if (map._persist.live_shapes() as Dictionary).has(g):
		map.select_shape(g)
	else:
		map.select_brushes(map.level.get_group_brush_ids(g))