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())