OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / terrain_panel.gd

473 lines · gd ref:
extends VBoxContainer
## TERRAIN NOISE CONTROLS — hyper-games #27, the world half of the editor.
##
## The editor could author the LEVEL (brushes, CSG, carves) and not the WORLD it
## sits in: the terrain's shape was compiled-in defaults that no player and no peer
## could change or keep.
##
## #27 opens by saying this needs a C++ binding pass first — "Bound to GDScript:
## two". That is STALE. DCTerrain already exports 63 properties, the whole
## DensityConfig generator among them (seed, the four noise layers, tunnels, the
## floor band, the octave caps). So this is a panel over an API that exists, not a
## binding job.
##
## TWO THINGS MAKE IT DIFFERENT FROM THE ATMOSPHERE PANEL, and both are why #27
## warned about it:
##
## 1. EVERY SETTER REBUILDS. They are all DC_SET_CONFIG, which calls rebuild() on
##    change — and rebuild() drops the octree and re-meshes the world. A slider
##    that applied per drag-frame would fire a full world rebuild per frame and be
##    unusable; worse, thirteen of them back-to-back is the documented way to stop
##    the mesher converging at all (map_dc_world.tscn's comment). So values apply
##    on RELEASE, never during the drag.
##
## 2. A NOISE CHANGE INVALIDATES THE WHOLE FIELD, not a region. Unlike an edit,
##    which is O(carve radius) and the property the whole architecture is built on,
##    this is O(world). Expect it to take a moment; that is the shape of the
##    operation, not a stall.
##
## PERSISTENCE IS FREE HERE, and #27's trap does not bite — but only by luck, so
## it is worth writing down. Its warning is that a key missing from GameState's
## route table falls through to _DEFAULT_ROUTE {REALTIME, "peer"} and is never
## written durably, which is how world:level:* silently lost every carve until
## 0.843.0. `world:terrain:*` is ALREADY routed DURABLE/"peer" (game.gd) and is in
## MAP_SCOPED_PREFIXES, so world:terrain:noise:* inherits both. And only
## `world:terrain:edit:` is author-scoped, so these keys have no author segment —
## can_write returns true early and they are shared last-write-wins config, which
## is exactly what #27 concludes noise params should be. Do not add an author.

const KEY_PREFIX := "world:terrain:noise:"
## Long enough that a drag reads as one change, short enough not to feel stuck.
const APPLY_DELAY := 0.35

## name -> {min, max, step}. ORDER IS THE PANEL ORDER, so it reads as the field is
## actually composed: base shape, then what deforms it, then the floor it sits on.
## Knobs for DCTerrainGeneratorProcNodes, used when the map's terrain runs that
## generator instead of the built-in DensityConfig. Ranges are the PROPERTY_HINT_RANGE
## values from dc_terrain_generator_procnodes.cpp, except mountain_octaves: the hint
## says 1..6 and map_dc_erosion ships 8, because the hint only bounds the inspector
## and the setter takes anything.
## Knobs for DCTerrainGeneratorProcNodes, used when the map's terrain runs that
## generator instead of the built-in DensityConfig.
const GEN_KNOBS: Array = [
	["terrain_height",    1000.0, 200000.0, 1000.0],
	["mountain_scale",       0.1,     10.0,    0.1],
	["mountain_height",      0.0,      2.0,   0.01],
	["mountain_octaves",     1.0,     12.0,    1.0],
	["mountain_gain",        0.2,      0.9,   0.01],
	["mountain_variance",    0.0,      1.0,   0.01],
	["relief_scale",         0.0,      8.0,   0.01],
	["relief_floor",         0.0,      1.0,   0.01],
	["detail_scale",         0.0,   2000.0,    1.0],
	["detail_height",        0.0,    500.0,    0.5],
	["detail_octaves",       1.0,     10.0,    1.0],
	["detail_gain",          0.2,      0.9,   0.01],
	["noise_lacunarity",     1.5,      3.0,   0.01],
	["erosion_scale",      0.002,      0.5,  0.001],
]

const KNOBS: Array = [
	["seed",               0.0,   999.0, 1.0],
	["primary_frequency",  0.001,   0.2, 0.001],
	["primary_strength",   0.0,     4.0, 0.01],
	["ridge_frequency",    0.001,   0.2, 0.001],
	["ridge_strength",     0.0,     4.0, 0.01],
	["warp_frequency",     0.001,   0.2, 0.001],
	["warp_strength",      0.0,     8.0, 0.01],
	["detail_frequency",   0.001,   0.5, 0.001],
	["detail_strength",    0.0,     2.0, 0.01],
	["tunnel_frequency",   0.001,   0.2, 0.001],
	["tunnel_strength",    0.0,     4.0, 0.01],
	["tunnel_radius",      0.0,     8.0, 0.05],
	["ground_bias",       -4.0,     4.0, 0.01],
	["flat_center_radius", 0.0,   200.0, 1.0],
	["terrain_size",      16.0,  2000.0, 1.0],
	["floor_strength",     0.0,     8.0, 0.01],
	["floor_height_min", -200.0,  200.0, 1.0],
	["floor_height_max", -200.0,  200.0, 1.0],
	["room_height",        0.0,   100.0, 0.5],
	["max_noise_octaves",  1.0,     8.0, 1.0],
	["max_ridge_octaves",  1.0,     8.0, 1.0],
]

var terrain: Node = null
## The object the knobs actually read and write. Either the DCTerrain node (the
## built-in DensityConfig) or its `generator` resource when that is a
## DCTerrainGeneratorProcNodes -- map_dc_erosion's parameters live on the
## generator, not on the terrain, and every one of its setters already calls
## _recompile_glsl() + emit_changed(), which is the same rebuild this panel is
## built around.
var _target: Object = null
var _knobs: Array = []
var _sliders: Dictionary = {}   # name -> HSlider
var _values: Dictionary = {}    # name -> Label
var _pending: Dictionary = {}   # name -> float not yet applied
var _timer: Timer = null
var _applying: bool = false     # guard: our own writes come back via state_changed
## Scene-authored generator settings, captured before any saved state is applied.
var _defaults: Dictionary = {}


func setup(p_terrain: Node) -> void:
	terrain = p_terrain
	# Bind to the generator when the map uses one. Detected by a property it
	# owns rather than by class name, so this does not need the extension
	# loaded to be safe.
	var gen: Variant = terrain.get("generator")
	if gen != null and gen is Object and (gen as Object).has_method("get_mountain_gain"):
		_target = gen as Object
		_knobs = GEN_KNOBS
	else:
		_target = terrain
		_knobs = KNOBS
	_timer = Timer.new()
	_timer.one_shot = true
	_timer.wait_time = APPLY_DELAY
	_timer.timeout.connect(_flush)
	add_child(_timer)
	_build()
	# The scene's own generator settings, captured before any saved state lands —
	# the baseline a room with nothing saved falls back to. See _on_map_changing.
	for prop: String in _sliders:
		var cur: Variant = _target.get(prop)
		if cur != null:
			_defaults[prop] = float(cur)
	if GameState:
		GameState.state_changed.connect(_on_state_changed)
		GameState.map_changing.connect(_on_map_changing)
		_load_persisted()


## ROOM ISOLATION — the same two leaks the atmosphere panel has, and worse here
## because a stale value reshapes the WORLD rather than the sky.
##
## 1. A PENDING WRITE. set_state resolves the destination room at FLUSH time, so
##    a value staged in room A and flushed after a switch is written into room B.
## 2. VALUES LEFT APPLIED. A param the new room never saved is never re-emitted,
##    so room A's terrain settings would silently become room B's.
##
## Restoring the baseline costs a rebuild, which is why it is done in one pass
## through _flush rather than per property.
func _on_map_changing(_map_id: String) -> void:
	if _timer:
		_timer.stop()
	_pending.clear()
	for prop: String in _defaults:
		_apply_external(prop, float(_defaults[prop]))


func _build() -> void:
	add_theme_constant_override("separation", 2)

	var noise_row := HBoxContainer.new()
	var noise_lbl := Label.new()
	noise_lbl.text = "use_noise"
	noise_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	noise_row.add_child(noise_lbl)
	var noise_chk := CheckButton.new()
	noise_chk.focus_mode = Control.FOCUS_NONE     # never eat the mode key — see BuildDock
	noise_chk.button_pressed = bool(terrain.get("use_noise"))
	noise_chk.toggled.connect(func(on: bool) -> void: _queue("use_noise", 1.0 if on else 0.0))
	noise_row.add_child(noise_chk)
	add_child(noise_row)

	# EROSION ON/OFF — the filter as a toggleable enhancement over the heightmap.
	#
	# A CHECKBOX rather than a slider because it is structural, not a magnitude:
	# off removes the erosion node from the emitted shader entirely, and with it
	# the whole noise library (60 KB -> 5.6 KB on map_dc_diffusion). Setting
	# ErosionStr to 0 would look the same and cost the same octave loop.
	#
	# Only shown when the target actually has the property, so the built-in
	# DensityConfig path is unaffected.
	if _target != null and _target.get("erosion_enabled") != null:
		var ero_row := HBoxContainer.new()
		var ero_lbl := Label.new()
		ero_lbl.text = "erosion_enabled"
		ero_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		ero_row.add_child(ero_lbl)
		var ero_chk := CheckButton.new()
		ero_chk.focus_mode = Control.FOCUS_NONE   # never eat the mode key
		ero_chk.button_pressed = bool(_target.get("erosion_enabled"))
		ero_chk.toggled.connect(func(on: bool) -> void: _queue("erosion_enabled", 1.0 if on else 0.0))
		ero_row.add_child(ero_chk)
		add_child(ero_row)

	# LOOKDEV COLOURS — the engine's terrain tinted exactly as terrain-lookdev
	# tints it, so the two can be compared BY EYE rather than by a number.
	#
	# This is a SHADER MATERIAL parameter, not a generator property, so it does
	# NOT go through _queue() like every other control here — that path writes
	# to the generator and would silently do nothing. It writes the uniform
	# directly, and reports if there is no ShaderMaterial to write to rather
	# than presenting a control that moves nothing (this panel has shipped
	# twelve of those before).
	var lk_mat: Variant = terrain.get("custom_material") if terrain != null else null
	if lk_mat != null and lk_mat is ShaderMaterial:
		var lk_row := HBoxContainer.new()
		var lk_lbl := Label.new()
		lk_lbl.text = "lookdev_colours"
		lk_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		lk_row.add_child(lk_lbl)
		var lk_chk := CheckButton.new()
		lk_chk.focus_mode = Control.FOCUS_NONE
		lk_chk.button_pressed = int((lk_mat as ShaderMaterial)
				.get_shader_parameter("color_mode") if
				(lk_mat as ShaderMaterial).get_shader_parameter("color_mode") != null
				else 0) == 1
		lk_chk.toggled.connect(func(on: bool) -> void:
			(lk_mat as ShaderMaterial).set_shader_parameter("color_mode", 1 if on else 0)
			print("[terrain_panel] lookdev_colours ", on))
		lk_row.add_child(lk_chk)
		add_child(lk_row)

	# WHAT IS ACTUALLY IN USE, from the graph itself.
	#
	# The knob list below is fixed, so it advertised a mountain layer, a relief
	# layer and a detail layer on a map whose graph contains none of them —
	# sliders that move nothing, and no sign of the heightmap that IS the
	# terrain. The panel now reads the graph: one section per node, named, with
	# that node's own parameters under it. Change the nodes and the panel
	# changes with them.
	_build_graph_sections()

	for k: Array in _knobs:
		_add_knob(String(k[0]), float(k[1]), float(k[2]), float(k[3]))

	var hint := Label.new()
	hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	hint.add_theme_color_override("font_color", Color(0.7, 0.7, 0.75))
	hint.text = "Applies on release — every change rebuilds the whole world."
	add_child(hint)

	_build_perf_section()


## PERFORMANCE — THIS MACHINE. dc-terrain's generation/streaming tunables (its
## "Performance" property group plus the LOD/collision settings), set on the
## DCTerrain node directly and LIVE: none of these rebuild the world.
##
## NOT written to the room. They are how this machine spends its frame, not what the
## world looks like, so a peer must not inherit them. To make a tuning the map's
## default, Ctrl+Shift+F5 dumps it (they are DCTerrain properties, so they land in
## map_changes with the file to edit) and the default changes in source.
##
## name, min, max, step, what it trades
const PERF_KNOBS: Array = [
	["terrain_min_lod",       -2.0,   4.0,  1.0, "finest terrain LOD anywhere (higher = coarser, cheaper)"],
	["edit_min_lod",          -3.0,   2.0,  1.0, "finest LOD around your carves (lower = sharper, more chunks)"],
	["lod_factor",             1.0,   8.0,  0.1, "how far out detail refines"],
	["max_splits_per_frame",   1.0,  64.0,  1.0, "LOD refinement speed vs frame cost"],
	["upload_budget_ms",       0.5,  30.0,  0.5, "main-thread ms per frame for swapping in meshes"],
	["collision_budget_ms",    0.0,  30.0,  0.5, "main-thread ms per frame for building collision (0 = no limit; 1 always goes)"],
	["upload_min_per_frame",   1.0,  64.0,  1.0, "meshes swapped in per frame at minimum"],
	["premesh_queue_depth",    1.0, 256.0,  1.0, "LOD work queued ahead of a carve (lower = carve waits less)"],
	["premesh_min_per_frame",  0.0,  32.0,  1.0, "LOD work dispatched per frame even while busy"],
	["collision_distance",    32.0, 1024.0, 16.0, "radius that gets collision built"],
	["collision_lod_max",      0.0,   8.0,  1.0, "coarsest LOD that gets collision"],
]


func _build_perf_section() -> void:
	if terrain == null:
		return
	var hdr := Label.new()
	hdr.text = "◆ Performance — this machine (live, not saved to the room)"
	hdr.add_theme_color_override("font_color", Color(0.95, 0.78, 0.35))
	add_child(hdr)
	for k: Array in PERF_KNOBS:
		var prop: String = k[0]
		var cur: Variant = terrain.get(prop)
		if cur == null:
			continue                                 # a dc-terrain that predates this knob
		var is_int: bool = typeof(cur) == TYPE_INT
		var row := HBoxContainer.new()
		var name_lbl := Label.new()
		name_lbl.text = prop
		name_lbl.tooltip_text = String(k[4])
		name_lbl.mouse_filter = Control.MOUSE_FILTER_PASS
		name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		name_lbl.add_theme_font_size_override("font_size", 11)
		row.add_child(name_lbl)
		var val := Label.new()
		val.add_theme_font_size_override("font_size", 11)
		val.add_theme_color_override("font_color", Color(0.6, 1.0, 0.7))
		val.text = str(cur) if is_int else "%.2f" % float(cur)
		row.add_child(val)
		add_child(row)
		var s := HSlider.new()
		s.min_value = float(k[1])
		s.max_value = float(k[2])
		s.step = float(k[3])
		s.value = clampf(float(cur), s.min_value, s.max_value)
		s.focus_mode = Control.FOCUS_NONE
		s.custom_minimum_size = Vector2(0, 16)
		s.tooltip_text = String(k[4])
		s.value_changed.connect(func(v: float) -> void:
			if terrain == null or not is_instance_valid(terrain):
				return
			terrain.set(prop, int(round(v)) if is_int else v)
			val.text = str(int(round(v))) if is_int else "%.2f" % v)
		add_child(s)


## One section per GPU node in the graph, in graph order.
func _build_graph_sections() -> void:
	if _target == null or not _target.has_method("get_graph"):
		return
	var graph: Variant = _target.call("get_graph")
	if graph == null or not (graph as Object).has_method("get_node_count"):
		return
	var count: int = (graph as Object).call("get_node_count")
	var shown := 0
	for i in range(count):
		var n: Variant = (graph as Object).call("get_node", i)
		if n == null or not (n as Object).has_method("get_node_title"):
			continue
		if (n as Object).has_method("supports_gpu") and not bool((n as Object).call("supports_gpu")):
			continue
		var title: String = (n as Object).call("get_node_title")

		var hdr := Label.new()
		hdr.text = "◆ " + title
		hdr.add_theme_color_override("font_color", Color(0.95, 0.78, 0.35))
		add_child(hdr)
		shown += 1

		# A node carrying a data file says WHICH file, and how big. Without this
		# there was no way to tell from the running game which heightmap a map
		# was rendering, or whether one had loaded at all.
		if (n as Object).has_method("get_tile_path"):
			var tp: String = (n as Object).call("get_tile_path")
			var sz: int = (n as Object).call("get_tile_size") if (n as Object).has_method("get_tile_size") else 0
			var lbl := Label.new()
			lbl.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
			if tp.is_empty() or sz <= 0:
				lbl.text = "   (no tile loaded — terrain will be flat)"
				lbl.add_theme_color_override("font_color", Color(1.0, 0.5, 0.4))
			else:
				var mpp: float = (n as Object).call("get_tile_mpp") if (n as Object).has_method("get_tile_mpp") else 0.0
				lbl.text = "   %s\n   %d x %d @ %.0f m/px = %.2f km" % [
						tp.get_file(), sz, sz, mpp, sz * mpp / 1000.0]
				lbl.add_theme_color_override("font_color", Color(0.6, 0.85, 0.7))
			add_child(lbl)

		# The node's own parameters, addressed the way the generator exposes
		# them: "<Node Title>/<Socket>".
		var ins: int = (n as Object).call("get_input_count")
		for si in range(ins):
			var v: Variant = (n as Object).call("get_input_default", si)
			if not (v is float or v is int):
				continue
			var sname: String = (n as Object).call("get_input_name", si)
			var prop := title + "/" + sname
			var cur := float(v)
			var lo := 0.0
			var hi := (absf(cur) * 4.0) if absf(cur) > 0.0 else 1.0
			if cur < 0.0:
				lo = cur * 4.0
				hi = absf(cur)
			_add_knob(prop, lo, hi, (hi - lo) / 200.0)

	if shown == 0:
		var none := Label.new()
		none.text = "(no graph — built-in density)"
		none.add_theme_color_override("font_color", Color(0.7, 0.7, 0.75))
		add_child(none)


func _add_knob(prop: String, lo: float, hi: float, step: float) -> void:
	var cur: Variant = _target.get(prop)
	if cur == null:
		return                                   # a libdc that predates this knob
	var row := HBoxContainer.new()
	var name_lbl := Label.new()
	name_lbl.text = prop
	name_lbl.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	name_lbl.add_theme_font_size_override("font_size", 11)
	row.add_child(name_lbl)
	var val := Label.new()
	val.add_theme_font_size_override("font_size", 11)
	val.add_theme_color_override("font_color", Color(0.6, 0.9, 1.0))
	val.text = "%.3f" % float(cur)
	row.add_child(val)
	add_child(row)

	var s := HSlider.new()
	s.min_value = lo
	s.max_value = hi
	s.step = step
	s.value = clampf(float(cur), lo, hi)
	s.focus_mode = Control.FOCUS_NONE
	s.custom_minimum_size = Vector2(0, 16)
	# value_changed updates the READOUT only. The apply is debounced, so dragging
	# stays live to look at without rebuilding the world twenty times.
	s.value_changed.connect(func(v: float) -> void:
		val.text = "%.3f" % v
		_queue(prop, v))
	add_child(s)
	_sliders[prop] = s
	_values[prop] = val


## Stage a value and restart the clock. The last write in a drag is the one that
## lands, which is what "applies on release" means without needing drag_ended (a
## typed value or a remote change has no drag to end).
func _queue(prop: String, v: float) -> void:
	if _applying:
		return
	_pending[prop] = v
	if _timer:
		_timer.start()


func _flush() -> void:
	if _target == null or terrain == null or not is_instance_valid(terrain):
		_pending.clear()
		return
	var wrote := 0
	for prop: String in _pending:
		var v: float = _pending[prop]
		# int-valued knobs must go in as ints or the setter rounds unpredictably
		var cur: Variant = _target.get(prop)
		_target.set(prop, int(round(v)) if typeof(cur) == TYPE_INT else v)
		if GameState:
			GameState.set_state(KEY_PREFIX + prop, v)
		wrote += 1
	_pending.clear()
	if wrote > 0:
		print("[TerrainPanel] applied %d change(s) — full world rebuild" % wrote)


## Remote / restored values. Same path as a local change minus the write-back, so
## a peer's slider move lands here exactly as if it had been dragged.
func _on_state_changed(key: String, value: Variant, _channel: int) -> void:
	if not key.begins_with(KEY_PREFIX) or value == null:
		return
	var prop := key.substr(KEY_PREFIX.length())
	_apply_external(prop, float(value))


func _load_persisted() -> void:
	for key: String in GameState.keys_with_prefix(KEY_PREFIX):
		var v: Variant = GameState.get_state(key)
		if v != null:
			_apply_external(key.substr(KEY_PREFIX.length()), float(v))


func _apply_external(prop: String, v: float) -> void:
	if terrain == null or not is_instance_valid(terrain) or _target == null:
		return
	_applying = true                             # so the slider's signal is not a new write
	# _target, the SAME object _flush saves through: the generator on generator maps.
	# This wrote terrain.set() -- DCTerrain has none of the generator's knobs, so a
	# restored, remote or map-change value moved the slider and never reached the
	# world (level-state audit 2026-10-02 #5, gate_terrain_panel_restore).
	var cur: Variant = _target.get(prop)
	if cur != null:
		_target.set(prop, int(round(v)) if typeof(cur) == TYPE_INT else v)
	if _sliders.has(prop):
		(_sliders[prop] as HSlider).value = v
		(_values[prop] as Label).text = "%.3f" % v
	_applying = false