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