OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / terrain_material.gd

241 lines · gd ref:
class_name TerrainMaterial
extends RefCounted
## THE TEXTURED TERRAIN'S LOOK, as data + a panel (user 2026-10-06: "terrain textures /
## spom parameters / uv scale / transforms exposed for geiss terrain"; option (a): an
## in-game BuildDock panel).
##
## One Dictionary (cfg) describes terrain_layers.gdshader's inputs: per mask layer the
## texture-pack set, tile size, parallax relief, UV rotation and offset; globally the
## tint / normal strength, blend softness, parallax scale and fade. apply() pushes it into
## the ShaderMaterial — rebuilding the Texture2DArray atlases ONLY when the sets changed
## (TexturePacks.build reads and resizes images: a hitch, so never on a slider drag).
##
## Persistence is the caller's: `save` is called (debounced by the caller's timer) with
## the whole cfg; the map stores it under a durable, map-scoped GameState key and feeds
## remote changes back through apply(). Kept out of here so a gate drives it headless.

const LAYERS := 5
const DEFAULT_TILE_M := 20.0
const MASK_NAMES := ["Sand (low)", "Ochre", "Olive", "Slate (high)", "Sediment (flat)"]

var mat: ShaderMaterial
var res: int
var cfg: Dictionary = {}
## Called with cfg after a UI change (the map debounces and persists).
var save: Callable = Callable()
## The map's authored texture sets — what "Reset to defaults" returns to.
var base_sets: Array = []
var _built_sets: PackedStringArray = PackedStringArray()
var _ui_root: Control = null
var _ui_lock := false


func _init(material: ShaderMaterial, atlas_res: int) -> void:
	mat = material
	res = atlas_res


## The look a set list starts with: each set's own tile / relief from its pack.json.
static func defaults(sets: Array) -> Dictionary:
	var layers: Array = []
	for i in LAYERS:
		var id: String = String(sets[i]) if i < sets.size() else TexturePacks.default_id()
		var e := TexturePacks.entry(id)
		# 20 m tiles by default (user 2026-10-06), not the pack's own ~2 m: at 2 m the
		# repeat read as wallpaper across the terraces.
		layers.append({"set": id, "tile": DEFAULT_TILE_M, "height": float(e.get("height_m", 0.02)),
				"rot": 0.0, "off": [0.0, 0.0]})
	return {"layers": layers, "tint": 0.35, "normal": 1.0, "blend": 0.25,
			"pom_scale": 1.0, "pom_fade_start": 8.0, "pom_fade_end": 14.0,
			"macro": 0.2, "far_scale": 6.0, "far_blend": 0.5, "far_start": 10.0, "far_end": 60.0}


## cfg := defaults overridden by `over` (a saved / remote cfg; unknown keys ignored).
static func merged(base: Dictionary, over: Variant) -> Dictionary:
	var out: Dictionary = base.duplicate(true)
	if not (over is Dictionary):
		return out
	for k: String in out.keys():
		if k == "layers" or not (over as Dictionary).has(k):
			continue
		out[k] = float(over[k])
	var ol: Variant = (over as Dictionary).get("layers")
	if ol is Array:
		for i in mini(LAYERS, (ol as Array).size()):
			var src: Variant = ol[i]
			if not (src is Dictionary):
				continue
			var dst: Dictionary = out["layers"][i]
			if (src as Dictionary).has("set") and TexturePacks.has(String(src["set"])):
				dst["set"] = String(src["set"])
			for f: String in ["tile", "height", "rot"]:
				if (src as Dictionary).has(f):
					dst[f] = float(src[f])
			if (src as Dictionary).get("off") is Array and (src["off"] as Array).size() == 2:
				dst["off"] = [float(src["off"][0]), float(src["off"][1])]
	return out


## Push cfg into the material. Returns "" or why the textures did not load (the caller
## then falls back to the plain rock shading, loudly).
func apply(new_cfg: Dictionary) -> String:
	cfg = new_cfg
	var sets := PackedStringArray()
	for l: Dictionary in cfg["layers"]:
		sets.append(String(l["set"]))
	if sets != _built_sets:
		var packs := TexturePacks.build(sets, res)
		var info: Dictionary = packs.get("info", {})
		var idx := PackedInt32Array()
		for id in sets:
			if not info.has(id):
				return "texture set %s did not load" % id
			idx.append(int(info[id]["layer"]))
		mat.set_shader_parameter(&"tex_atlas", packs["albedo"])
		mat.set_shader_parameter(&"norm_atlas", packs["norm"])
		mat.set_shader_parameter(&"orm_atlas", packs["orm"])
		mat.set_shader_parameter(&"layer_index", idx)
		_built_sets = sets
	var tile := PackedFloat32Array()
	var relief := PackedFloat32Array()
	var rot := PackedFloat32Array()
	var off := PackedVector2Array()
	for l: Dictionary in cfg["layers"]:
		tile.append(maxf(float(l["tile"]), 0.05))
		relief.append(maxf(float(l["height"]), 0.0))
		rot.append(float(l["rot"]))
		off.append(Vector2(float(l["off"][0]), float(l["off"][1])))
	mat.set_shader_parameter(&"tile_m", tile)
	mat.set_shader_parameter(&"height_m", relief)
	mat.set_shader_parameter(&"uv_rot_deg", rot)
	mat.set_shader_parameter(&"uv_off", off)
	mat.set_shader_parameter(&"tint_strength", float(cfg["tint"]))
	mat.set_shader_parameter(&"normal_strength", float(cfg["normal"]))
	mat.set_shader_parameter(&"blend_depth", float(cfg["blend"]))
	mat.set_shader_parameter(&"pom_scale", float(cfg["pom_scale"]))
	mat.set_shader_parameter(&"pom_fade_start", float(cfg["pom_fade_start"]))
	mat.set_shader_parameter(&"pom_fade_end", maxf(float(cfg["pom_fade_end"]), float(cfg["pom_fade_start"]) + 0.1))
	# Anti-tiling (2026-10-06): macro brightness wash; a far_scale x larger tiling blended
	# in between far_start and far_end.
	mat.set_shader_parameter(&"macro_strength", float(cfg["macro"]))
	mat.set_shader_parameter(&"far_scale", maxf(float(cfg["far_scale"]), 1.0))
	mat.set_shader_parameter(&"far_blend", float(cfg["far_blend"]))
	mat.set_shader_parameter(&"far_start", float(cfg["far_start"]))
	mat.set_shader_parameter(&"far_end", maxf(float(cfg["far_end"]), float(cfg["far_start"]) + 0.1))
	if _ui_root != null and is_instance_valid(_ui_root) and not _ui_lock:
		_refresh_ui()
	return ""


# ---- the panel ----------------------------------------------------------------------

## The BuildDock body. Every control writes cfg, apply()s it and calls `save`.
func build_ui() -> Control:
	var root := VBoxContainer.new()
	root.name = "TerrainMaterial"
	_ui_root = root
	_refresh_ui()
	return root


func _refresh_ui() -> void:
	for c in _ui_root.get_children():
		_ui_root.remove_child(c)
		c.queue_free()
	var g := GridContainer.new()
	g.columns = 2
	_ui_root.add_child(g)
	_num(g, "Parallax depth ×", cfg, "pom_scale", 0.0, 4.0, 0.05)
	_num(g, "Parallax fade from (m)", cfg, "pom_fade_start", 0.0, 100.0, 0.5)
	_num(g, "Parallax fade to (m)", cfg, "pom_fade_end", 0.1, 120.0, 0.5)
	_num(g, "Normal strength", cfg, "normal", 0.0, 2.0, 0.05)
	_num(g, "Colour tint", cfg, "tint", 0.0, 1.0, 0.05)
	_num(g, "Blend softness", cfg, "blend", 0.01, 1.0, 0.01)
	_num(g, "Macro variation", cfg, "macro", 0.0, 0.5, 0.01)
	_num(g, "Far tiling ×", cfg, "far_scale", 1.0, 16.0, 0.5)
	_num(g, "Far tiling mix", cfg, "far_blend", 0.0, 1.0, 0.05)
	_num(g, "Far tiling from (m)", cfg, "far_start", 0.0, 200.0, 1.0)
	_num(g, "Far tiling to (m)", cfg, "far_end", 1.0, 400.0, 1.0)
	var all := TexturePacks.ids()
	for i in LAYERS:
		var l: Dictionary = cfg["layers"][i]
		var head := Label.new()
		head.text = "%d. %s" % [i + 1, MASK_NAMES[i]]
		_ui_root.add_child(head)
		var opt := OptionButton.new()
		opt.fit_to_longest_item = false
		for j in all.size():
			opt.add_item(all[j], j)
			if all[j] == String(l["set"]):
				opt.select(j)
		opt.item_selected.connect(func(j: int) -> void: set_value(l, "set", all[j]))
		_ui_root.add_child(opt)
		var lg := GridContainer.new()
		lg.columns = 2
		_ui_root.add_child(lg)
		_num(lg, "Tile (m)", l, "tile", 0.1, 64.0, 0.1)
		_num(lg, "Relief (m)", l, "height", 0.0, 0.5, 0.005)
		_num(lg, "Rotation (°)", l, "rot", -180.0, 180.0, 1.0)
		_num_off(lg, "Offset U", l, 0)
		_num_off(lg, "Offset V", l, 1)
	var reset := Button.new()
	reset.text = "Reset to defaults"
	reset.pressed.connect(func() -> void:
		# The MAP'S own sets (base_sets), not the ones currently picked: building the
		# defaults from the current sets kept a texture change through a reset
		# (user 2026-10-06: "the revert doesnt revert everything").
		var sets: Array = base_sets.duplicate()
		if sets.is_empty():
			for ll: Dictionary in cfg["layers"]:
				sets.append(ll["set"])
		var why := apply(defaults(sets))      # unlocked: every value changed, redraw
		if why != "":
			push_warning("[TerrainMaterial] %s" % why)
		if save.is_valid():
			save.call(cfg))
	_ui_root.add_child(reset)


func _num(parent: Control, label: String, d: Dictionary, key: String, lo: float, hi: float, step: float) -> void:
	var l := Label.new()
	l.text = label
	parent.add_child(l)
	var s := SpinBox.new()
	s.min_value = lo
	s.max_value = hi
	s.step = step
	s.value = float(d[key])
	s.value_changed.connect(func(v: float) -> void: set_value(d, key, v))
	parent.add_child(s)


func _num_off(parent: Control, label: String, l: Dictionary, axis: int) -> void:
	var lab := Label.new()
	lab.text = label
	parent.add_child(lab)
	var s := SpinBox.new()
	s.min_value = -1.0
	s.max_value = 1.0
	s.step = 0.01
	s.value = float(l["off"][axis])
	s.value_changed.connect(func(v: float) -> void:
		var o: Array = (l["off"] as Array).duplicate()
		o[axis] = v
		set_value(l, "off", o))
	parent.add_child(s)


func set_value(d: Dictionary, key: String, v: Variant) -> void:
	d[key] = v
	_changed(cfg)


func _changed(new_cfg: Dictionary) -> void:
	_ui_lock = true                  # don't rebuild the panel under the user's cursor
	var why := apply(new_cfg)
	_ui_lock = false
	if why != "":
		push_warning("[TerrainMaterial] %s" % why)
	if save.is_valid():
		save.call(cfg)