OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_dc_terrain.gd

675 lines · gd ref:
extends "res://scenes/maps/map_core.gd"
## DC Terrain map — procedural terrain with player, creatures, sky, and clouds.
##
## SunshineClouds2 is configured as tscn nodes (inspector-editable):
##   - CompositorEffect on Sky3DExtended's compositor
##   - SunshineCloudsDriverGD tracks sun/moon lights
##   - CloudEffector nodes sculpt cloud density (move them in editor)
##
## NO KEYS OF ITS OWN (key audit 2026-10-01). This map SHIPS -- map_test's
## teleporter leads here -- and it had a ~35-key `match keycode` that ignored
## modifiers, key repeat and the mode: Delete wiped the room's durable edits and
## repeated while held, R rebuilt the terrain, RMB carved AND grappled, F12 took two
## screenshots. Now:
##   carving        the carver weapon, map_core (LMB fire, 1-4 brush, Q, - =)
##   undo           Z in build mode (builder.undo)
##   everything else  the F4 editor dock: "DC Terrain", "Quake Level", "Terrain
##                  Brushes" (registered below)
##   diagnostics    the global Ctrl+F row (keymap.gd debug.*), as on every map
@onready var terrain: Node3D = $DCTerrain
@onready var sky3d: Sky3DExtended = $Sky3DExtended

var _terrain_ready := false
var _wait_frames := 0

# Brush editor ("Terrain Brushes" panel): Pick lifts the convex brush under the
# crosshair (only the ghost shows); the nudge buttons move the ghost; Drop commits
# the accumulated move as ONE re-mesh. Prev/Next select by index in place.
var _selected_brush := -1
var _brush_marker: MeshInstance3D = null
var _move_delta := Vector3.ZERO
var _lifted := {}                       # non-empty = a brush is lifted (removed, ghost only)
var _lifted_center := Vector3.ZERO      # original centre of the selected/lifted brush
var _lifted_tris := PackedVector3Array()  # captured hull for the ghost
const BRUSH_MOVE_STEP := 1.0

var _standalone := false
const DOCK_PANELS: Array[String] = ["dc_terrain", "quake_level", "terrain_brushes"]

## Same five absolute paths map_dc_level.gd declares — they were duplicated here
## as locals at the START.MAP import site, with the same silent degradation.
## ⚠ tex_build_dir is a DIRECTORY and is not listed; see map_dc_level.gd.
func required_assets() -> Array:
	return [
		{"kind": "map", "name": "START.MAP",
			"path": "/storage/projects/gamedev/quake/quakemash/START.MAP"},
		{"kind": "wad", "name": "base.wad",
			"path": "/storage/projects/gamedev/quake/quakemash/gfx/base.wad"},
		{"kind": "palette", "name": "palette.lmp",
			"path": "/storage/projects/gamedev/quake/darkyplafe/id1/gfx/palette.lmp"},
		{"kind": "rtlights", "name": "start.rtlights",
			"path": "/storage/projects/gamedev/quake/quake-rtlights/id1/maps/start.rtlights"},
	]


func _ready() -> void:
	# super does the grapple layer, the universal terrain panel and atmosphere
	# scaling, and now publishes required_assets(). This map used to extend
	# Node3D and re-implement only the first of those.
	super._ready()
	# The editor's tools (builder.undo here) -- active only in build mode, since
	# `builder` requires build_mode. Popped in _exit_tree. Held as self — see
	# map_dc_level.gd (the old map's pop on a map change must not take the new one's).
	Keymap.push_context("builder", self)

	var game := get_tree().root.get_node_or_null("Game")
	if not game:
		_setup_standalone()

	# Headless daemon peer: it ONLY syncs gamestate (keet-daemon + StateBase). It
	# never renders, so the DCTerrain MESH is pure waste — GPU dual-contouring that,
	# with no Vulkan RenderingDevice, CPU-falls-back via OpenMP and saturated ~14
	# cores meshing a surface that's never shown. Terrain EDITS still persist to the
	# StateBase (GameState); only the visual mesh is skipped. Free the terrain + sky
	# sims so a daemon peer is affordable. See spec/daemon-session.md P0.
	if DisplayServer.get_name() == "headless":
		_terrain_ready = true
		if is_instance_valid(terrain):
			terrain.queue_free()
			terrain = null
		if is_instance_valid(sky3d):
			sky3d.queue_free()
			sky3d = null
		var hp := get_node_or_null("Player") as CharacterBody3D
		if hp:
			hp.set_physics_process(true)
			hp.set_process(true)
		print("[MapDCTerrain] Headless daemon — DCTerrain+Sky3D freed (gamestate-sync only, no meshing)")
		return

	# Config lives in DCWorldSetup because map_dc_world instantiates a DCTerrain
	# too, and the grid-align value is a contract with the importer — see there.
	DCWorldSetup.configure_terrain(terrain)

	# SAVED STATE WINS — hyper-games #42 P1. This used to set both unconditionally,
	# so entering this map clobbered whatever sky the room had saved: set the time,
	# change map, lose it. map_dc_level has had the check for current_time for a
	# while; this is the same rule, shared, and covering minutes_per_day too.
	if sky3d:
		var opening: Dictionary = {"current_time": 10.0, "minutes_per_day": 15.0}
		var keys: Array[String] = []
		if GameState:
			keys = GameState.keys_with_prefix(SkyDefaults.PREFIX)
		var wanted: Array[String] = ["current_time", "minutes_per_day"]
		var apply: Array[String] = SkyDefaults.unsaved(wanted, keys)
		for prop: String in apply:
			sky3d.set(prop, float(opening[prop]))
		if apply.size() < wanted.size():
			var kept: Array[String] = []
			for prop: String in wanted:
				if not apply.has(prop):
					kept.append(prop)
			print("[MapDCTerrain] sky: kept the room's saved %s" % str(kept))

	if not Engine.is_editor_hint():
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED

	_register_tool_panels.call_deferred()
	print("[MapDCTerrain] Ready — terrain + sky + clouds")


func _process(delta: float) -> void:
	if not _terrain_ready:
		var active: int = terrain.get_active_chunk_count()
		var collision_q: int = terrain.get_collision_queue_size()
		if active >= 10 and collision_q == 0:
			_wait_frames += 1
			if _wait_frames >= 3:
				_terrain_ready = true
				var player := get_node_or_null("Player") as CharacterBody3D
				if player:
					player.set_physics_process(true)
					player.set_process(true)
				print("[MapDCTerrain] Terrain ready (active: %d)" % active)



# ---- Standalone mode ----

func _setup_standalone() -> void:
	_standalone = true
	var player := get_node_or_null("Player") as CharacterBody3D
	if player:
		player.set_multiplayer_authority(1)
		player.set_physics_process(false)
		player.set_process(false)
		print("[MapDCTerrain] Standalone: player frozen until terrain ready")
	else:
		# The standalone fly camera is gone with the key wall: build mode IS the
		# camera, and it exists only through the game.
		push_warning("[MapDCTerrain] launched without a Game and without a Player -- run it through the game")


# ---- Input ----

func _unhandled_input(event: InputEvent) -> void:
	# map_core owns the carver weapon and the diagnostics for every map.
	super._unhandled_input(event)
	if Keymap.tapped(event, "builder.undo"):
		var mgr := get_tree().get_first_node_in_group("terrain_edit_manager")
		if mgr and mgr.has_method("undo_last_edit"):
			mgr.undo_last_edit()
			print("[MapDCTerrain] undo last durable edit")
		else:
			# NOT clear_edits(): the old C key fell back to wiping EVERY edit when
			# there was no manager. An undo must never be a wipe.
			print("[MapDCTerrain] undo: no TerrainEditManager here -- nothing undone")
		get_viewport().set_input_as_handled()


func _exit_tree() -> void:
	Keymap.pop_context("builder", self)
	var dock := get_node_or_null(^"/root/BuildDock")
	if dock != null:
		for id: String in DOCK_PANELS:
			if dock.has_panel(id):
				dock.unregister(id)


# ---- The F4 editor dock (the old dev keys, as controls) ----

func _register_tool_panels() -> void:
	var dock := get_node_or_null(^"/root/BuildDock")
	if dock == null or terrain == null:
		return
	dock.register("dc_terrain", "DC Terrain", _panel_terrain(), false, dock.SIDE_LEFT)
	dock.register("quake_level", "Quake Level", _panel_quake(), false, dock.SIDE_LEFT)
	dock.register("terrain_brushes", "Terrain Brushes", _panel_brushes(), false, dock.SIDE_LEFT)


func _button(parent: Control, text: String, fn: Callable) -> Button:
	var b := Button.new()
	b.text = text
	b.focus_mode = Control.FOCUS_NONE    # see BuildDock: no panel may eat Tab
	b.pressed.connect(fn)
	parent.add_child(b)
	return b


func _check(parent: Control, text: String, on: bool, fn: Callable) -> CheckBox:
	var c := CheckBox.new()
	c.text = text
	c.button_pressed = on
	c.focus_mode = Control.FOCUS_NONE
	c.toggled.connect(fn)
	parent.add_child(c)
	return c


func _row(parent: Control) -> HBoxContainer:
	var r := HBoxContainer.new()
	parent.add_child(r)
	return r


func _panel_terrain() -> Control:
	var v := VBoxContainer.new()
	_button(v, "Rebuild terrain (re-mesh everything)", func() -> void:
		terrain.rebuild()
		print("[MapDCTerrain] rebuild"))
	# Was Delete -- unguarded, and it auto-repeated while held.
	_button(v, "Clear ALL terrain edits…", request_clear_all)
	if terrain.has_method("set_adaptive_arch_lod"):
		_check(v, "Adaptive architecture LOD (2x near camera)", bool(terrain.get("adaptive_arch_lod")),
				func(on: bool) -> void: terrain.adaptive_arch_lod = on)
	_check(v, "DCTerrain debug logging", bool(terrain.get("debug_logging")),
			func(on: bool) -> void: terrain.debug_logging = on)
	# THIS MAP'S OWN SKY. It owns a WorldEnvironment, so game.gd detaches its own
	# Sky3D here and the Atmosphere panel drives that detached node -- these were the
	# only working time controls on this map, so they come along as they were.
	if sky3d != null:
		var l := Label.new()
		l.text = "Time of day"
		v.add_child(l)
		var r := _row(v)
		_button(r, "−1 h", func() -> void: _shift_time(-1.0))
		_button(r, "+1 h", func() -> void: _shift_time(1.0))
		_button(r, "Pause / resume", func() -> void:
			if sky3d.game_time_enabled: sky3d.pause()
			else: sky3d.resume())
		var r2 := _row(v)
		_button(r2, "Day = 1 min", func() -> void: sky3d.minutes_per_day = 1.0)
		_button(r2, "Day = 15 min", func() -> void: sky3d.minutes_per_day = 15.0)
	return v


func _shift_time(hours: float) -> void:
	if sky3d:
		sky3d.current_time = fmod(sky3d.current_time + hours + 24.0, 24.0)


## The panel's wipe. ASKS FIRST: it erases the room's durable edit journal for
## everyone, and as a bare Delete it fired on a stray press and repeated while held.
func request_clear_all() -> void:
	var dlg := ConfirmationDialog.new()
	dlg.dialog_text = "Erase EVERY terrain edit in this room, for everyone?\nThis cannot be undone."
	dlg.ok_button_text = "Erase all"
	dlg.confirmed.connect(func() -> void:
		var em := get_tree().get_first_node_in_group("terrain_edit_manager")
		if em and em.has_method("clear_edits"):
			em.clear_edits()
			print("[MapDCTerrain] WIPED all durable edits (panel, confirmed)")
		else:
			terrain.clear_edits()
			print("[MapDCTerrain] cleared in-memory edits only (no edit manager found)")
		dlg.queue_free())
	dlg.canceled.connect(dlg.queue_free)
	add_child(dlg)
	dlg.popup_centered()


func _panel_quake() -> Control:
	var v := VBoxContainer.new()
	_button(v, "Import START.MAP (dev paths)", _import_start_map)
	if terrain.has_method("set_use_textures"):
		_check(v, "WAD textures (vs flat debug colours)", bool(terrain.get("use_textures")),
				func(on: bool) -> void: terrain.use_textures = on)
	return v


func _panel_brushes() -> Control:
	var v := VBoxContainer.new()
	_button(v, "Pick brush under crosshair / drop it", _brush_pick_or_drop)
	var r := _row(v)
	_button(r, "◀ Prev", func() -> void: _cycle_brush(-1))
	_button(r, "Next ▶", func() -> void: _cycle_brush(1))
	var l := Label.new()
	l.text = "Nudge the ghost (%.0f m)" % BRUSH_MOVE_STEP
	v.add_child(l)
	for axis: Array in [["X", Vector3.RIGHT], ["Y", Vector3.UP], ["Z", Vector3.BACK]]:
		var ar := _row(v)
		var dir: Vector3 = axis[1]
		_button(ar, "−" + String(axis[0]), func() -> void: _move_selected(-dir * BRUSH_MOVE_STEP))
		_button(ar, "+" + String(axis[0]), func() -> void: _move_selected(dir * BRUSH_MOVE_STEP))
	_button(v, "Stamp fandisk at crosshair (carver size)", _stamp_fandisk)
	return v


## Was the H key: pick the brush under the crosshair (lifting it so only the ghost
## shows), or drop/commit the one already picked -- one clean re-mesh either way.
func _brush_pick_or_drop() -> void:
	if not _lifted.is_empty():
		_drop_lifted()
		_lifted = {}
		_move_delta = Vector3.ZERO
		if _brush_marker: _brush_marker.visible = false
	elif _selected_brush >= 0:
		# In-place fallback (non-convex brush stayed visible): move it in place.
		if _move_delta != Vector3.ZERO:
			terrain.move_brush(_selected_brush, _move_delta)
			print("[BrushEdit] committed move of brush %d by %v" % [_selected_brush, _move_delta])
		_selected_brush = -1
		_move_delta = Vector3.ZERO
		if _brush_marker: _brush_marker.visible = false
	else:
		_pick_brush_at_crosshair()


## Was the M key. TEST (Phase 0) import of Quake START.MAP: brushes, WAD/PBR
## atlases, liquids, teleporters, rtlights and doors. Dev-only absolute paths
## (required_assets() lists them); re-runnable -- the spawners clear the last import.
func _import_start_map() -> void:
	# TEST (Phase 0): import Quake START.MAP as coarse AABB boxes. Non-durable
	# (edit_terrain_box interactive=false — local landscape lane, not synced).
	# Dev-only absolute path; bundle into res:// before shipping. NOTE: applies
	# ~1123 box edits in one shot — expect a brief hitch (this is exactly why
	# Phase 2 collapses the whole map into one SDF-delta brick).
	var importer := QuakeMapImporter.new()
	var map_path := "/storage/projects/gamedev/quake/quakemash/START.MAP"
	var wad_path := "/storage/projects/gamedev/quake/quakemash/gfx/base.wad"
	var pal_path := "/storage/projects/gamedev/quake/darkyplafe/id1/gfx/palette.lmp"
	var rtlights_path := "/storage/projects/gamedev/quake/quake-rtlights/id1/maps/start.rtlights"
	var tex_build_dir := "/storage/projects/gamedev/quake/quake-textures/build_out"
	# Per-face UV projections need each texture's ORIGINAL size (the atlas resizes
	# to squares) → read the WAD dims header-only BEFORE import so the importer can
	# fold texW/texH into every face projection.
	var tex_dims := QuakeWadLoader.load_dims(wad_path)
	# self = light parent (spawns shadowless OmniLights); carve=true so the
	# level sits in a carved void instead of unioning with the terrain.
	# scale 0.028 m/unit: Quake player (56u) → 1.58m = our player capsule.
	var n := importer.import_file(terrain, map_path, 0.028, Vector3(0, 20, 0), 1.0, self, true, tex_dims)
	print("[MapDCTerrain] Quake import: %d brushes stamped, %d materials, %d face-projections" % [n, importer.material_names.size(), importer.projections.size() / 12])
	# Build the WAD texture atlas (layer index == material_id-1) → terrain shader.
	# Toggle textures vs flat debug colours in the Quake Level panel.
	if terrain.has_method("set_texture_atlas"):
		var res := QuakeWadLoader.load(wad_path, pal_path,
			PackedStringArray(importer.material_names), 64)
		if res.has("array"):
			terrain.set_texture_atlas(res["array"])
			print("[MapDCTerrain] WAD atlas: %d layers — the Quake Level panel toggles textures" % int(res["layers"].size()))
		# Upload the per-face projection table (Stage 2) — the shader picks each
		# face's real Quake UV per fragment instead of a world-aligned axial guess.
		if terrain.has_method("set_projection_table"):
			terrain.set_projection_table(importer.projections)
		# PBR (Phase A): roughness (from gloss, shader-flipped) + emissive glow, built by the
		# hemebond/quake-textures pipeline, in the SAME layer order as the WAD atlas.
		if terrain.has_method("set_glow_atlas"):
			var pbr := QuakeWadLoader.load_pbr(tex_build_dir, PackedStringArray(importer.material_names), 64)
			if pbr.has("glow"):
				# glow atlas is RGBA: rgb = emissive, a = gloss/roughness.
				terrain.set_glow_atlas(pbr["glow"])
				if terrain.has_method("set_norm_atlas") and pbr.has("norm"):
					terrain.set_norm_atlas(pbr["norm"])
				print("[MapDCTerrain] PBR: %d gloss + %d glow + %d normal maps" % [int(pbr.get("n_gloss", 0)), int(pbr.get("n_glow", 0)), int(pbr.get("n_norm", 0))])
	# Spawn Quake liquid volumes (water/lava/slime): a warped surface at each
	# volume's top face (faithful EmitWaterPolys) plus a registered AABB the
	# player uses for the screen blend + swim. Teleport volumes are collected
	# separately (importer.teleport_volumes) for the teleporter feature.
	_spawn_liquids(importer, wad_path, pal_path)
	_spawn_teleporters(importer, wad_path, pal_path)
	_spawn_rtlights(importer, rtlights_path)
	_spawn_doors(importer, wad_path, pal_path)


## Brush editor: raycast from the crosshair, pick the brush there, highlight + select it.
func _pick_brush_at_crosshair() -> void:
	var camera: Camera3D = get_viewport().get_camera_3d()
	if not camera: return
	# Cast from the camera through the crosshair (screen centre = the reticle), excluding
	# the LOCAL PLAYER so the ray doesn't stop on the character in front of the third-person
	# camera. The player node is named by peer-id under the game tree, so get_node("Player")
	# never found it — that's why the ray hit the character and "only worked when jumping".
	# The active camera lives inside the player's rig, so walk up to its CharacterBody3D.
	var player: Node = camera
	while player and not (player is CharacterBody3D):
		player = player.get_parent()
	var center := get_viewport().get_visible_rect().size * 0.5
	var from := camera.project_ray_origin(center)
	var dir := camera.project_ray_normal(center)
	var space := get_world_3d().direct_space_state
	var query := PhysicsRayQueryParameters3D.create(from, from + dir * 400.0)
	if player is CollisionObject3D:
		query.exclude = [(player as CollisionObject3D).get_rid()]
	query.collision_mask = 0xFFFFFFFF
	var result := space.intersect_ray(query)
	if result.size() == 0:
		print("[BrushEdit] no terrain under crosshair")
		return
	var hit: Vector3 = result["position"]
	var idx: int = terrain.pick_brush(hit)
	if idx < 0:
		print("[BrushEdit] no brush at %v" % hit)
		if _brush_marker: _brush_marker.visible = false
		return
	_select_brush(idx)


## Accumulate a move on the live PREVIEW ghost only — no terrain re-mesh until commit (H).
func _move_selected(delta: Vector3) -> void:
	if _lifted.is_empty() and _selected_brush < 0:
		return
	_move_delta += delta
	if _brush_marker:
		_brush_marker.global_position = _lifted_center + _move_delta
	print("[BrushEdit] preview delta %v — Pick/drop commits" % _move_delta)


## Select a brush by index (wraps) as an IN-PLACE preview cross-check — does not lift, so
## indices stay stable while cycling. Blocked while a brush is lifted (drop it first).
func _cycle_brush(dir: int) -> void:
	if not _lifted.is_empty():
		print("[BrushEdit] drop the current brush (Pick/drop) before cycling")
		return
	var n: int = terrain.get_edit_count()
	if n <= 0:
		print("[BrushEdit] no brushes to cycle")
		return
	if _selected_brush < 0:
		_selected_brush = 0 if dir > 0 else n - 1
	else:
		_selected_brush = (_selected_brush + dir + n) % n
	_move_delta = Vector3.ZERO
	_lifted_center = terrain.get_brush_center(_selected_brush)
	_lifted_tris = PackedVector3Array()
	if terrain.has_method("get_brush_hull_triangles"):
		_lifted_tris = terrain.get_brush_hull_triangles(_selected_brush)
	print("[BrushEdit] brush %d/%d (in-place) — nudge buttons move it, Pick/drop commits" % [_selected_brush, n])
	_show_preview_mesh(_lifted_tris, _lifted_center)


## Select brush `idx` for the move gizmo: capture its hull, then LIFT it (remove it so the
## real brush disappears and only the ghost remains). Falls back to an in-place preview if
## it can't be lifted (non-convex). H then drops/commits it at the ghost position.
func _select_brush(idx: int) -> void:
	_move_delta = Vector3.ZERO
	_lifted_tris = PackedVector3Array()
	if terrain.has_method("get_brush_hull_triangles"):
		_lifted_tris = terrain.get_brush_hull_triangles(idx)
	_lifted_center = terrain.get_brush_center(idx)
	_lifted = {}
	if terrain.has_method("lift_brush"):
		_lifted = terrain.lift_brush(idx)   # removes the brush; captured tris drive the ghost
	if _lifted.is_empty():
		_selected_brush = idx               # in-place fallback (non-convex stays visible)
		print("[BrushEdit] selected brush %d (in-place) — nudge buttons move it, Pick/drop commits" % idx)
	else:
		_lifted_center = _lifted.get("center", _lifted_center)
		_selected_brush = -1                # lifted (removed) — tracked by _lifted
		print("[BrushEdit] lifted brush %d — nudge buttons move the ghost, Pick/drop drops it" % idx)
	_show_preview_mesh(_lifted_tris, _lifted_center)


## Re-add the lifted brush at the ghost position (translate its planes by the move delta).
func _drop_lifted() -> void:
	var planes: PackedFloat32Array = _lifted.get("planes", PackedFloat32Array())
	if planes.is_empty():
		return
	var d3: Vector3 = _move_delta
	var new_center: Vector3 = _lifted_center + d3
	var tp := PackedFloat32Array()
	tp.resize(planes.size())
	var i := 0
	while i + 3 < planes.size():
		var nx: float = planes[i]
		var ny: float = planes[i + 1]
		var nz: float = planes[i + 2]
		tp[i] = nx
		tp[i + 1] = ny
		tp[i + 2] = nz
		tp[i + 3] = planes[i + 3] + (nx * d3.x + ny * d3.y + nz * d3.z)
		i += 4
	terrain.edit_terrain_convex(tp, new_center, float(_lifted.get("radius", 1.0)),
		float(_lifted.get("strength", 1.0)), true, int(_lifted.get("material", 0)))
	print("[BrushEdit] dropped brush at %v" % new_center)


## Build the ghost mesh from captured hull triangles (centre-relative) and show it at pos.
func _show_preview_mesh(tris: PackedVector3Array, center: Vector3) -> void:
	if _brush_marker == null:
		_brush_marker = MeshInstance3D.new()
		var mat := StandardMaterial3D.new()
		mat.albedo_color = Color(1.0, 0.9, 0.1, 0.45)
		mat.emission_enabled = true
		mat.emission = Color(1.0, 0.9, 0.1)
		mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
		mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		mat.cull_mode = BaseMaterial3D.CULL_DISABLED   # double-sided (winding-agnostic)
		mat.no_depth_test = true   # render through walls so the selection is always visible
		mat.render_priority = 10
		_brush_marker.material_override = mat
		add_child(_brush_marker)
	if tris.size() >= 3:
		var arr := []
		arr.resize(Mesh.ARRAY_MAX)
		arr[Mesh.ARRAY_VERTEX] = tris
		var am := ArrayMesh.new()
		am.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
		_brush_marker.mesh = am
	else:
		var sm := SphereMesh.new()
		sm.radius = 1.5
		sm.height = 3.0
		_brush_marker.mesh = sm
	_brush_marker.visible = true
	_brush_marker.global_position = center + _move_delta


func _stamp_fandisk() -> void:
	var camera: Camera3D = get_viewport().get_camera_3d()
	if not camera: return
	var center := get_viewport().get_visible_rect().size * 0.5
	var from := camera.project_ray_origin(center)
	var dir := camera.project_ray_normal(center)
	var space := get_world_3d().direct_space_state
	var query := PhysicsRayQueryParameters3D.create(from, from + dir * 200.0)
	var player := get_node_or_null("Player")
	if player: query.exclude = [player.get_rid()]
	var result := space.intersect_ray(query)
	if result.size() == 0:
		return
	var hit: Vector3 = result["position"]
	hit.y += _csg_radius * 0.8
	# Route through TerrainEditManager so the stamp PERSISTS (durable
	# world:terrain:edit:* in GameState) + syncs to peers, exactly like box edits.
	# Direct fallback only when no manager (test/standalone) — non-persistent.
	var edit_mgr := get_tree().get_first_node_in_group("terrain_edit_manager")
	if edit_mgr and edit_mgr.has_method("apply_mesh_stamp"):
		edit_mgr.apply_mesh_stamp("res://fandisk.glb", hit, _csg_radius, 3.0)
	else:
		_stamp_fandisk_direct(hit)


## Non-persistent fallback: stamp directly on the terrain node when no
## TerrainEditManager is present (test/standalone scenes).
func _stamp_fandisk_direct(hit: Vector3) -> void:
	var scene: PackedScene = load("res://fandisk.glb")
	if not scene: return
	var instance: Node3D = scene.instantiate()
	var mesh_inst: MeshInstance3D = null
	for child in instance.get_children():
		if child is MeshInstance3D:
			mesh_inst = child
			break
	if not mesh_inst or not mesh_inst.mesh:
		instance.queue_free()
		return
	var mesh: Mesh = mesh_inst.mesh
	var arrays: Array = mesh.surface_get_arrays(0)
	var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
	var idxs: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
	terrain.stamp_mesh_arrays(verts, idxs, hit, _csg_radius, 3.0)
	instance.queue_free()


func _spawn_liquids(importer: QuakeMapImporter, wad_path: String, pal_path: String) -> void:
	# The import is re-runnable — clear liquids from any previous import first.
	var existing := get_node_or_null("QuakeLiquids")
	if existing:
		existing.free()
	if importer.liquid_volumes.is_empty():
		return
	# Load just this map's distinct liquid textures into one Texture2DArray (one layer each).
	var names := {}
	for e in importer.liquid_volumes:
		names[String(e.get("tex", "")).to_lower()] = true
	var res := QuakeWadLoader.load(wad_path, pal_path, PackedStringArray(names.keys()), 64)
	var arr: Texture2DArray = res.get("array", null)
	var layers: Dictionary = res.get("layers", {})
	var container := Node3D.new()
	container.name = "QuakeLiquids"
	add_child(container)
	var spawned := 0
	for e in importer.liquid_volumes:
		var layer: int = int(layers.get(String(e.get("tex", "")).to_lower(), 0))
		var lv := LiquidVolume.create(e, arr, layer, 0.028)
		if lv != null:               # create() returns null for degenerate slivers
			container.add_child(lv)
			spawned += 1
	print("[MapDCTerrain] spawned %d/%d liquid volumes (%d textures) — %d teleport volumes deferred" % [
		spawned, importer.liquid_volumes.size(), layers.size(), importer.teleport_volumes.size()])


func _spawn_teleporters(importer: QuakeMapImporter, wad_path: String, pal_path: String) -> void:
	var existing := get_node_or_null("QuakeTeleporters")
	if existing:
		existing.free()
	var container := Node3D.new()
	container.name = "QuakeTeleporters"
	add_child(container)
	# 1. Behaviour: each trigger_teleport touch volume → its info_teleport_destination by target.
	var wired := 0
	var dests: Dictionary = importer.teleport_destinations
	for trig in importer.teleport_triggers:
		var tgt := String(trig.get("target", ""))
		if not dests.has(tgt):
			continue      # no matching destination (e.g. a changelevel-style target) — skip
		container.add_child(TeleportTrigger.create(trig, dests[tgt]))
		wired += 1
	# 2. Visual: the *teleport swirly pads (animated warp), rendered but NOT swimmable.
	var pads := 0
	if not importer.teleport_volumes.is_empty():
		var names := {}
		for e in importer.teleport_volumes:
			names[String(e.get("tex", "")).to_lower()] = true
		var res := QuakeWadLoader.load(wad_path, pal_path, PackedStringArray(names.keys()), 64)
		var arr: Texture2DArray = res.get("array", null)
		var layers: Dictionary = res.get("layers", {})
		for e in importer.teleport_volumes:
			var layer: int = int(layers.get(String(e.get("tex", "")).to_lower(), 0))
			var lv := LiquidVolume.create(e, arr, layer, 0.028, true)   # visual_only = true
			if lv != null:
				container.add_child(lv)
				pads += 1
	print("[MapDCTerrain] teleporters: %d triggers wired (%d dests) — %d visual pads" % [
		wired, dests.size(), pads])


func _spawn_rtlights(importer: QuakeMapImporter, rtlights_path: String) -> void:
	# Replace the raw .MAP light entities with the hand-curated DarkPlaces rtlights (better
	# colours/radii + shadow flags). If the file is missing, keep the .MAP lights the importer
	# already spawned into "QuakeLights".
	if not FileAccess.file_exists(rtlights_path):
		print("[MapDCTerrain] no rtlights at %s — keeping .MAP lights" % rtlights_path)
		return
	var old := get_node_or_null("RtlightManager")
	if old:
		old.free()
	var mgr := RtlightManager.new()
	mgr.name = "RtlightManager"
	add_child(mgr)
	var n := mgr.setup(rtlights_path, importer.out_map_center, 0.028, importer.out_aligned_offset)
	if n > 0:
		var maplights := get_node_or_null("QuakeLights")
		if maplights:
			maplights.queue_free()   # rtlights replace the .MAP light entities
	else:
		mgr.queue_free()           # nothing loaded — leave .MAP lights in place


func _spawn_doors(importer: QuakeMapImporter, wad_path: String, pal_path: String) -> void:
	# func_door movers as animated models (AnimatableBody3D) — see the models-vs-dc_terrain
	# review. Excluded from the SDF stamp in the importer; spawned + textured here.
	var existing := get_node_or_null("QuakeDoors")
	if existing:
		existing.free()
	if importer.doors.is_empty():
		return
	var names := {}
	for d in importer.doors:
		names[String(d.get("tex", "")).to_lower()] = true
	var res := QuakeWadLoader.load(wad_path, pal_path, PackedStringArray(names.keys()), 64)
	var arr: Texture2DArray = res.get("array", null)
	var layers: Dictionary = res.get("layers", {})
	var container := Node3D.new()
	container.name = "QuakeDoors"
	add_child(container)
	for d in importer.doors:
		var tex: Texture2D = null
		if arr != null:
			var layer: int = int(layers.get(String(d.get("tex", "")).to_lower(), 0))
			var img := arr.get_layer_data(layer)
			if img != null:
				tex = ImageTexture.create_from_image(img)
		container.add_child(DoorMover.create(d, tex))
	print("[MapDCTerrain] spawned %d func_door movers" % importer.doors.size())