scopedvr-map-editor/src/viewport/map_view_builder.gd
slyphax 4e973795f6 Malen: echter weicher Übergang am Pinselrand (Misch-Schicht statt Streuung)
Sly 25.10.: „weich“ soll ein Textur-Verlauf am Pinselrand sein, nicht fleckig;
gerade Strichkanten wirkten weiter hart (1-m-Übergang).
- Format (optional, alte Karten gültig): materials_b_r8_b64 (zweites Material
  je Punkt) + materials_w_r8_b64 (Anteil 0–255); ohne Mischung fallen die
  Felder weg. V15 prüft Länge/IDs/Paarigkeit.
- TerrainField.apply_material_brush_mix: innen voll, außen Smoothstep-Anteil;
  nur verstärken, nie abschwächen; Gras drübermalen mischt zurück.
- Shader: Top-2 aus 4 Nachbarpunkten × (a, b) mit Anteilen → nur noch 2
  Albedo-Fetches; Warp bleibt.
- Pinsel/Live-Vorschau, Spiegeln, Symmetrie-Analyse/-Anwenden, Resize,
  Anheben, Undo (terrain-Snapshot) tragen die Misch-Schicht mit.
- Streu-Rand (vorheriger Commit) entfernt.
- Tests: test_mal_fein 30/30 (Verlauf fällt weich, nie abschwächen,
  zurückmischen, Kodierung), Editor-Suite unverändert grün bis auf die
  bekannten Umgebungs-Tests; Kette Editor → Server → Client per
  run_map_download_e2e 15/15 mit gemischter Karte.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-25 00:43:26 +02:00

807 lines
No EOL
34 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

extends Node3D
## Baut die visuelle Repräsentation der Map im Editor-Viewport:
## Terrain-Höhenfeld-Mesh + Maßstabsgitter + Himmel/Licht
## + Echte GLB-Meshes für Bibliotheks-Objekte (GlbMeshStore; ohne GLB =
## Platzhalter-Mesh aus dem Kollisions-Eintrag) + Spawn-Marker mit Beschriftung.
## ##
## Objekt-Marker werden in object_markers gemerkt (Meta: obj_index,
## pick_radius) — Auswahl/Werkzeuge (interaction.gd) greifen darauf zu,
## statt die Szene selbst zu durchsuchen.
const GRID_COLOR := Color(0.35, 0.45, 0.55, 0.6)
const AXIS_COLOR := Color(0.9, 0.7, 0.2, 0.9)
const OBJECT_COLOR := Color(0.55, 0.75, 0.95)
const TEAM_COLORS := [Color(0.3, 0.6, 1.0), Color(1.0, 0.35, 0.3)] # Blau / Rot
# Auswahl-Markierung (U6, t_639135dd): Farbe + Breite kommen aus dem Theme
# (eine Quelle) — Auswahl-Kontur, schwächere Hover-Kontur, dünn-gestrichelte
# Partner-Kontur. Kein Vollflächen-Orange mehr: das Grundmaterial (mit Textur)
# bleibt sichtbar, die Markierung lebt als Außenhüllen-Kontur darum herum.
const THEME_PATH := "res://theme/editor_theme.tres"
const OUTLINE_SHADER := "res://shaders/selection_outline.gdshader"
# Fallback, falls das Theme (noch) nicht geladen werden kann.
const OUTLINE_FALLBACK := Color(1.0, 0.6, 0.15, 0.95)
# Wetter → Licht/Himmel — 1:1-SPIEGEL aus client/scripts/world/weather_presets.gd
# (Karte t_f72913d4: Editor-Vorschau zeigt exakt was der Client zeigt — dieselbe
# Projekt-Konvention wie KolliderBau als 1:1-Spiegel von svmap_world, t_1da96275).
# Bei Preset-Änderungen im Client HIER MITPFLEGEN. Schlüssel = Client-Keys.
const WEATHER_LIGHT := {
"mittag": {
"sky_top": Color(0.30, 0.54, 0.86), "sky_horizon": Color(0.72, 0.83, 0.92),
"ground_bottom": Color(0.20, 0.23, 0.27), "ground_horizon": Color(0.66, 0.75, 0.83),
"sky_energy": 1.0,
"sun_deg": Vector2(-52.0, 30.0), "sun_energy": 1.2, "sun_color": Color(1.0, 0.98, 0.92),
"ambient_source": "sky", "ambient_color": Color(0.60, 0.70, 0.85), "ambient_energy": 1.0,
"fog_enabled": false, "fog_color": Color(0.80, 0.85, 0.90),
"fog_density": 0.0, "fog_sky_affect": 0.0,
"sky_panorama": false,
"panorama_fog_color": Color(0.93, 0.80, 0.66), "panorama_energy": 1.0,
},
"abend": {
"sky_top": Color(0.25, 0.30, 0.55), "sky_horizon": Color(0.95, 0.55, 0.28),
"ground_bottom": Color(0.16, 0.13, 0.14), "ground_horizon": Color(0.80, 0.45, 0.25),
"sky_energy": 1.0,
"sun_deg": Vector2(-8.0, 250.0), "sun_energy": 0.9, "sun_color": Color(1.0, 0.62, 0.35),
"ambient_source": "color", "ambient_color": Color(0.55, 0.40, 0.32), "ambient_energy": 0.8,
"fog_enabled": true, "fog_color": Color(0.85, 0.55, 0.35),
"fog_density": 0.004, "fog_sky_affect": 0.2,
"sky_panorama": false,
"panorama_fog_color": Color(0.95, 0.72, 0.48), "panorama_energy": 0.8,
},
"regen": {
"sky_top": Color(0.38, 0.41, 0.45), "sky_horizon": Color(0.55, 0.58, 0.62),
"ground_bottom": Color(0.22, 0.23, 0.25), "ground_horizon": Color(0.50, 0.53, 0.57),
"sky_energy": 0.9,
"sun_deg": Vector2(-40.0, 120.0), "sun_energy": 0.25, "sun_color": Color(0.75, 0.78, 0.82),
"ambient_source": "color", "ambient_color": Color(0.55, 0.58, 0.62), "ambient_energy": 1.0,
"fog_enabled": true, "fog_color": Color(0.55, 0.58, 0.62),
"fog_density": 0.015, "fog_sky_affect": 0.6,
"sky_panorama": false,
"panorama_fog_color": Color(0.76, 0.72, 0.68), "panorama_energy": 0.55,
},
"nacht": {
"sky_top": Color(0.015, 0.03, 0.08), "sky_horizon": Color(0.05, 0.08, 0.15),
"ground_bottom": Color(0.01, 0.015, 0.025), "ground_horizon": Color(0.04, 0.06, 0.11),
"sky_energy": 1.0,
"sun_deg": Vector2(-65.0, 200.0), "sun_energy": 0.35, "sun_color": Color(0.62, 0.72, 1.0),
"ambient_source": "color", "ambient_color": Color(0.10, 0.14, 0.25), "ambient_energy": 0.5,
"fog_enabled": true, "fog_color": Color(0.04, 0.06, 0.11),
"fog_density": 0.006, "fog_sky_affect": 0.4,
"sky_panorama": false,
"panorama_fog_color": Color(0.10, 0.12, 0.18), "panorama_energy": 0.10,
},
}
## Pack-Panorama-Skybox (Karte t_f72913d4): die Textur liegt im Client-Repo
## (Nachbar-Klon — KEINE Duplikat-Pflicht im Editor, siehe Spec) und wird über
## einen absoluten Pfad geladen; fehlt sie → Fallback ProceduralSky.
const PANORAMA_SKY_CLIENT_REL := "assets/textures/military_desert/Skybox_3.png"
## Absoluter Pfad der Pack-Skybox im Nachbar-Klon ("" = kein Klon gefunden).
static func panorama_sky_pfad() -> String:
var root := TestRun.find_client(ProjectSettings.globalize_path("res://"),
TestRun.load_cfg())
if root.is_empty():
return ""
return root.path_join(PANORAMA_SKY_CLIENT_REL)
var selected_index := -1 # aktuelles Auswahl-Objekt (Kontur-Markierung)
var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrfachauswahl)
var hover_index := -1 # Hover-Objekt (schwächste Kontur; U6-Abstufung)
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis)
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
var gebaeude_markers: Array = [] # MeshInstance3D je Bauteil (Karte t_97ea59c5)
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
var _gebaeude_baked: Dictionary = {} # faces/offsets für Ray-Pick (Bauen-Werkzeug)
var _gebaeude_sel: MeshInstance3D # Kontur-Mesh um das gewählte Bauteil
var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung)
var _base_mat: StandardMaterial3D
var _sym_partners: Array = [] # Indizes der Zwillinge der aktuellen Auswahl
# Kontur-Materialien (U6): Auswahl / Hover / Spiegel-Zwilling — Farben und
# Breiten kommen aus dem Theme, der Shader ist die Außenhüllen-Technik.
var _outline_mat_sel: ShaderMaterial
var _outline_mat_hover: ShaderMaterial
var _outline_mat_partner: ShaderMaterial
func _ready() -> void:
rebuild({}, {})
## Komplette Szene aus dem Map-Dict neu aufbauen. manifest = Bibliotheks-Registry.
func rebuild(data: Dictionary, manifest: Dictionary) -> void:
for child in get_children():
child.queue_free()
object_markers = []
spawn_markers = []
gebaeude_markers = []
grid_node = null
_gebaeude_baked = {}
_gebaeude_sel = null
_doc_ref = data
_base_mat = StandardMaterial3D.new()
_base_mat.albedo_color = OBJECT_COLOR
_base_mat.roughness = 0.8
# U6 (t_639135dd): keine Vollfarben-Materialien mehr — die Markierung
# (Auswahl/Hover/Partner) lebt als Außenhüllen-Kontur um das Modell,
# Farben + Breiten aus dem Theme.
_markierung_theme_laden()
_sym_partners = []
if data.is_empty():
return
var terrain: Dictionary = data.get("terrain", {})
var size_x := float(terrain.get("size_x_m", 1024.0))
var size_z := float(terrain.get("size_z_m", 1024.0))
var ground_y := float(terrain.get("min_height_m", 0.0))
var weather := str(data.get("weather", "mittag"))
# Skybox-Option (Karte t_f72913d4): optionales svmap-Feld `weather_sky`
# ("sky_panorama"/"prozedural"/leer) — dieselbe Semantik wie im Client.
var weather_sky := str(data.get("weather_sky", ""))
_build_environment(weather, weather_sky)
_build_terrain(data)
_build_wasser(data) # Karte t_28fdef03: nach dem Terrain (Senken füllen sich)
_build_grid(size_x, size_z, ground_y)
# Gitter default AUS über sichtbarem Terrain (Karte t_7ac70014): das
# Terrain ist die Bodendarstellung — das Gitter lag als Linien-Overlay
# DARÜBER und wirkte wie ein „unfertiges“ Editor-Raster. Ohne Terrain-
# Block bleibt es als Boden-Referenz sichtbar.
if grid_node != null:
grid_node.visible = terrain.is_empty()
_build_objects(data.get("objects", []), manifest)
_rebuild_spawn_markers(data)
_build_gebaeude(data)
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
## der Terrain-Pinsel schreibt die Vertices live neu (terrain_view.update_terrain).
func _build_terrain(data: Dictionary) -> void:
terrain_view = Node3D.new()
terrain_view.set_script(load("res://src/viewport/terrain_mesh.gd"))
add_child(terrain_view)
terrain_view.call("rebuild_from_doc", data)
## Wasserfläche (Karte t_28fdef03): EINE transparente Fläche auf `water_level`
## über der GANZEN Map (SvmapWasser.baue — Senken unter der Höhe füllen sich
## automatisch). OHNE Feld → kein Wasser (Legacy-Maps unverändert). Dasselbe
## Material-Verfahren wie der Client (SvmapWasser.make_material — Identitäts-
## Vertrag der Vorschau: Editor zeigt exakt, was der Quest-Client zeigt).
## Leicht ÜBER dem Terrain eingehängt (y-Offset +0.01 über Reihenfolge), aber
## die Fläche selbst liegt exakt auf der Wasserhöhe (Z-Fight-Vermeidung macht
## der Shader über die Tiefen-Textur, nicht über einen Off-Aufschlag).
func _build_wasser(data: Dictionary) -> void:
var level := SvmapWasser.level(data)
if not is_finite(level):
return # Legacy/fehlendes Feld — kein Wasser, nichts bauen.
var terrain: Dictionary = data.get("terrain", {})
var wetter := str(data.get("weather", "mittag"))
var wasser := SvmapWasser.baue(terrain, level, wetter)
if wasser != null:
add_child(wasser)
## Live-Update während eines Pinsel-Strichs (aus terrain_tool.gd): nur die
## Vertices werden neu geschrieben — Grid/Objekte/Marker bleiben unangetastet.
## mats (optional) = Material-Schicht (Karte t_603ec1a8, Malen-Modus) —
## leer = terrain_mesh verwendet die zuletzt gesehene Schicht weiter (Cache,
## Karte t_55ed041a); nur bei NIE gesehener Schicht (Legacy-v1) gilt Gras.
## splat_rect_cells (optional, Karte t_7a0a6b79) = Zellrechteck des Mal-
## Schritts (inkl. Spiegelzellen) — terrain_mesh leitet nur die Splat-Region
## neu ab (Sub-Rect-Update, kein Voll-Rebuild pro Mal-Frame).
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray(),
splat_rect_cells: Rect2i = Rect2i()) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats, splat_rect_cells)
## Misch-Schicht für das nächste Live-Update (Malen mit weichem Rand, Karte 25.10.).
func set_terrain_mix(mix_b: PackedByteArray, mix_w: PackedByteArray) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("set_mix", mix_b, mix_w)
## Nur das Terrain neu aufbauen (nach Strich-Commit/Undo) — Objekt-Marker und
## Auswahl bleiben bestehen, kein Flackern der ganzen Szene.
func rebuild_terrain_from_doc(data: Dictionary) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("rebuild_from_doc", data)
## Himmel/Licht — 1:1-Spiegel von client autoload/weather.gd._apply_environment
## (Karte t_f72913d4): Map-Override > Preset-Key > ProceduralSky; ACES-Tonemap;
## Fog koppelt im Panorama-Fall an den Skybox-Horizont (aerial perspective).
## Kein Glow/Bloom/Volumetric — dokumentierte Absage (Mobile/Perf).
func _build_environment(weather: String, sky_override: String = "") -> void:
var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"])
var env := Environment.new()
var sky := Sky.new()
var panorama := false
if sky_override == "prozedural":
panorama = false
elif sky_override == "sky_panorama":
panorama = true
else:
panorama = bool(preset.get("sky_panorama", false))
if panorama:
var pfad := panorama_sky_pfad()
if not pfad.is_empty() and FileAccess.file_exists(pfad):
var img := Image.load_from_file(pfad)
if img != null:
if img.is_compressed():
img.decompress()
img.generate_mipmaps()
var pano_mat := PanoramaSkyMaterial.new()
pano_mat.panorama = ImageTexture.create_from_image(img)
pano_mat.energy_multiplier = float(preset.get("panorama_energy", 1.0))
sky.sky_material = pano_mat
else:
push_warning("MAPBUILDER: Panorama-Textur nicht ladbar (%s) — Fallback ProceduralSky" % pfad)
else:
push_warning("MAPBUILDER: weather_sky=sky_panorama, aber Pack-Skybox fehlt (%s) — Fallback ProceduralSky" % pfad)
if sky.sky_material == null:
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = preset["sky_top"]
sky_mat.sky_horizon_color = preset["sky_horizon"]
sky_mat.ground_bottom_color = preset["ground_bottom"]
sky_mat.ground_horizon_color = preset["ground_horizon"]
sky_mat.sky_energy_multiplier = float(preset["sky_energy"])
sky.sky_material = sky_mat
env.background_mode = Environment.BG_SKY
env.sky = sky
# Ambient: "sky" = aus dem Himmel ableiten, "color" = feste Farbe (Client-Logik).
if str(preset.get("ambient_source", "color")) == "sky":
env.ambient_light_source = Environment.AMBIENT_SOURCE_SKY
else:
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = preset["ambient_color"]
env.ambient_light_energy = float(preset["ambient_energy"])
# Tonemap ACES — derselbe filmische Look wie der Client; Grading bewusst
# NUR über Tonemap + Preset-Farben (kein Fullscreen-Adjustment-Pass).
env.tonemap_mode = Environment.TONE_MAPPER_ACES
env.fog_enabled = bool(preset["fog_enabled"])
env.fog_light_color = preset.get("panorama_fog_color", preset["fog_color"]) if panorama else preset["fog_color"]
env.fog_density = float(preset["fog_density"])
env.fog_sky_affect = float(preset["fog_sky_affect"])
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
var deg: Vector2 = preset["sun_deg"]
sun.rotation_degrees = Vector3(deg.x, deg.y, 0.0)
sun.light_energy = float(preset["sun_energy"])
sun.light_color = preset["sun_color"]
sun.shadow_enabled = true
add_child(sun)
## Maßstabsgitter: Linien alle 100 m, gelbe Mittelachsen (X rot? — Z-Achse
## blau wäre Godot-Konvention; hier neutral gelb für gute Sichtbarkeit).
func _build_grid(size_x: float, size_z: float, ground_y: float) -> void:
var mesh := ImmediateMesh.new()
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
var step := _grid_step(size_x)
var half_x := size_x / 2.0
var half_z := size_z / 2.0
var y := ground_y + 0.05
var x := -half_x
while x <= half_x + 0.01:
var major: bool = absf(fmod(x, step * 5.0)) < 0.01 or absf(x) < 0.01
_add_line(mesh, Vector3(x, y, -half_z), Vector3(x, y, half_z),
AXIS_COLOR if absf(x) < 0.01 else (GRID_COLOR if not major else GRID_COLOR))
x += step
var z := -half_z
while z <= half_z + 0.01:
_add_line(mesh, Vector3(-half_x, y, z), Vector3(half_x, y, z),
AXIS_COLOR if absf(z) < 0.01 else GRID_COLOR)
z += step
mesh.surface_end()
var mi := MeshInstance3D.new()
mi.mesh = mesh
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mi.material_override = mat
grid_node = mi
add_child(mi)
## Gitter ohne Rebuild zu-/abschalten (Toggle in der Top-Bar, Karte t_7ac70014).
func set_grid_visible(visible_now: bool) -> void:
if grid_node != null and is_instance_valid(grid_node):
grid_node.visible = visible_now
func grid_is_visible() -> bool:
return grid_node != null and is_instance_valid(grid_node) and grid_node.visible
func _grid_step(size_m: float) -> float:
# Ziel: ~10–20 Linien pro Achse, runde Werte.
var raw := size_m / 16.0
var candidates := [10.0, 25.0, 50.0, 100.0, 250.0, 500.0]
for c in candidates:
if raw <= float(c):
return float(c)
return 1000.0
func _add_line(mesh: ImmediateMesh, from: Vector3, to: Vector3, color: Color) -> void:
mesh.surface_set_color(color)
mesh.surface_add_vertex(from)
mesh.surface_set_color(color)
mesh.surface_add_vertex(to)
## Platzhalter je Objekt: Form aus dem Bibliotheks-Manifest (wie der Server-
## Kollider), Position = Objekt-Fußpunkt. ECHTES GLB-Mesh vorziehen
## (Karte t_9b50b231, GlbMeshStore): vorhanden = echtes Modell mit Texturen,
## fehlend = Platzhalter + Eintrag in fehlende_glbs (Statuszeilen-Hinweis).
## Marker-Root bekommt Metas für Auswahl/Anfassen: obj_index, pick_radius
## (Kugel-Approximation), half_height, glb (true = echtes Modell — keine
## Grundfarbe drüberlegen, sonst sind die Texturen weg).
func _build_objects(objects: Array, manifest: Dictionary) -> void:
var lib: Dictionary = manifest.get("objects", {})
fehlende_glbs = []
for i in objects.size():
var obj = objects[i]
if not (obj is Dictionary):
object_markers.append(null)
continue
var oid := str(obj.get("id", ""))
if not lib.has(oid):
object_markers.append(null)
continue # Validierung fängt das — defensiv überspringen.
var entry: Dictionary = lib[oid]
var col: Dictionary = entry.get("collision", {})
var scale := float(obj.get("scale", 1.0))
var mesh := GlbMeshStore.mesh_fuer(oid)
var echt := mesh != null
if not echt:
if GlbMeshStore.fehlt(oid):
fehlende_glbs.append(oid)
mesh = MeshShapes.make_placeholder(str(col.get("shape", "box")), col, scale)
var pos: Array = obj.get("pos", [0.0, 0.0, 0.0])
var marker := Node3D.new()
marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
# Karte t_e166e656: 3-Achsen-Rotation — `rot` (Euler xyz) oder Fallback
# rot_y_deg, identische Regel wie im Client/Server (SvmapIO.objekt_rotation).
marker.rotation_degrees = SvmapIO.objekt_rotation(obj)
marker.set_meta("obj_index", i)
var mi := MeshInstance3D.new()
mi.mesh = mesh
if echt:
# Normalisiertes GLB liegt schon in Fußpunkt-Koordinaten
# (Unterkante y = 0) → KEIN half_height-Lift; Objekt-Maßstab
# über Node-Scale. AABB-Metas mal scale (Node-Scale geht nicht
# in mesh.get_aabb() ein).
mi.scale = Vector3.ONE * scale
else:
mi.position.y = MeshShapes.half_height(mesh)
# U6 (t_639135dd): das Grundmaterial wird NIE MEHR durch die Auswahl
# ersetzt — echte GLB-Objekte tragen ihre Texturen weiter (override null),
# Platzhalter die Editor-Grundfarbe. Markiert wird per Kontur (unten).
mi.material_override = null if echt else _base_mat
marker.add_child(mi)
var half: Vector3 = (mesh.get_aabb().size * (scale if echt else 1.0) * 0.5) as Vector3
marker.set_meta("pick_radius", half.length() + maxf(0.6, half.y * 0.25))
marker.set_meta("half_height",
MeshShapes.center_height(mesh) * (scale if echt else 1.0))
marker.set_meta("glb", echt)
object_markers.append(marker)
add_child(marker)
# Farbe je Objekt (Karte t_b0edb807): optionales Feld "tint" — nur
# Objekte MIT Feld bekommen die Überschreibung (Rückwärtskompatibel).
# Die Farbe bleibt jetzt auch bei Markierung sichtbar (U6: Auswahl = Kontur,
# Grundmaterial bleibt) — kein „Orange gewinnt“ mehr.
if SvmapTint.hat(obj):
SvmapTint.anwenden(marker, SvmapTint.von(obj))
# Markierung (Auswahl/Hover/Partner) als Kontur-Hülle anlegen.
_outline_anwenden(marker, i)
## Auswahl nachträglich umfärben (ohne ganzen Rebuild) — z. B. nach Drag.
## Einzel-Variante bleibt für Rückwärtskompatibilität bestehen.
func set_selected(idx: int) -> void:
selected_index = idx
set_selected_multi([idx] if idx >= 0 else [])
## Mehrfachauswahl (Karte t_f6f29d86): ALLE markierten Objekte bekommen die
## Auswahl-Kontur (U6, t_639135dd) — Gruppen/Mehrfachauswahl sind auf einen
## Blick erkennbar, OHNE die Textur zu übermalen. Das Grundmaterial bleibt
## überall sichtbar: echte GLB-Objekte tragen ihre Texturen weiter (Karte
## t_9b50b231), Platzhalter die Editor-Grundfarbe — material_override wird
## von der Markierung nicht mehr angefasst.
func set_selected_multi(indices: Array) -> void:
selected_indices = indices.duplicate()
selected_index = indices.back() if not indices.is_empty() else -1
# sym-Zwillinge (M3, t_34908425): aus dem doc ableiten — Partner der
# Auswahl, die nicht selbst ausgewählt sind, bekommen die dünnere,
# gestrichelte Partner-Kontur (U6-Abstufung statt dunkler Vollfarbe).
_sym_partners = _sym_partners_of(indices)
var objects: Array = _doc_ref.get("objects", [])
for marker in object_markers:
if marker == null or not is_instance_valid(marker):
continue
var idx := int(marker.get_meta("obj_index", -1))
_outline_anwenden(marker, idx)
# Tint-Abgleich (Karte t_b0edb807): ALLE Objekte zeigen ihre doc-Farbe
# (oder — nach „Zurücksetzen“ — wieder die Pack-Farbe). Der Tint bleibt
# jetzt auch bei Markierung sichtbar (Auswahl = Kontur um das Modell).
if idx >= 0 and idx < objects.size() and objects[idx] is Dictionary:
var obj: Dictionary = objects[idx]
if SvmapTint.hat(obj):
SvmapTint.anwenden(marker, SvmapTint.von(obj))
else:
SvmapTint.zuruecknehmen(marker)
func _is_partner(idx: int) -> bool:
return _sym_partners.has(idx)
## sym-Partner der Auswahl aus dem doc bestimmen (M3, t_34908425): Objekte mit
## sym-Kennung, deren Partner NICHT selbst in der Auswahl ist. Keine sym-Felder
## im doc (alte Karten, Modus Aus) → leer, Verhalten wie bisher.
func _sym_partners_of(indices: Array) -> Array:
var objects: Array = _doc_ref.get("objects", [])
var out: Array = []
for i in indices:
if not (i is int) or i < 0 or i >= objects.size() or not (objects[i] is Dictionary):
continue
var key := SymmetryCore.sym_of(objects[i] as Dictionary)
if key.is_empty():
continue
var t := SymmetryCore.find_partner_index(objects, key, i)
if t >= 0 and not indices.has(t) and not out.has(t):
out.append(t)
return out
## Tint frisch aus dem doc auf die MARKIERTEN Objekte anwenden (Inspector-
## Änderung ohne Rebuild). U6: die Farbe bleibt auch bei Markierung sichtbar
## (Auswahl = Kontur um das Modell) — kein „Orange gewinnt“-Skip mehr.
func tint_refresh(indices: Array) -> void:
var objects: Array = _doc_ref.get("objects", [])
for i in indices:
if i < 0 or i >= object_markers.size():
continue
var marker = object_markers[i]
if marker == null or not is_instance_valid(marker):
continue
if i >= objects.size() or not (objects[i] is Dictionary):
continue
var obj: Dictionary = objects[i]
if SvmapTint.hat(obj):
SvmapTint.anwenden(marker, SvmapTint.von(obj))
else:
SvmapTint.zuruecknehmen(marker)
func _is_selected(idx: int) -> bool:
return selected_indices.has(idx)
## Spawn-Zonen (Karte t_c4162c5d): JEDE Zone ist ein flacher, teamfarbiger
## Kreis auf dem Boden (Radius = design_notes.spawn_radius_m, Editor-eigen)
## + Label3D mit der Punkt-ID. Marker-Root bekommt Metas für das Spawn-Tool
## (spawn_team, spawn_id, spawn_radius → Anfassen/Regel).
func _rebuild_spawn_markers(data: Dictionary) -> void:
for marker in spawn_markers:
if marker != null and is_instance_valid(marker):
marker.queue_free()
spawn_markers = []
var zones = data.get("spawn_zones", {})
if not (zones is Dictionary):
return
for team_key in zones.keys():
if not (zones[team_key] is Dictionary):
continue
var team := int(team_key)
var zone: Dictionary = zones[team_key]
var points = zone.get("points", [])
if not (points is Array):
continue
for p in points:
if not (p is Dictionary):
continue
var pid: String = str(p.get("id", "?"))
var pos: Array = p.get("pos", [0.0, 0.0, 0.0])
var marker := _spawn_marker(
Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
team, pid, SpawnOps.zone_radius(data, pid))
spawn_markers.append(marker)
add_child(marker)
## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius.
func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D:
var marker := Node3D.new()
marker.position = pos
marker.set_meta("spawn_team", team)
marker.set_meta("spawn_id", pid)
marker.set_meta("spawn_radius", radius)
marker.set_meta("spawn_pick_radius", maxf(radius + 2.0, 4.0))
marker.set_meta("spawn_selected", false)
marker.add_child(_spawn_disc(radius, team))
var label := Label3D.new()
label.text = pid
label.position.y = 2.5
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.modulate = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE
label.outline_size = 12
label.font_size = 160
label.pixel_size = 0.01
marker.add_child(label)
return marker
## Kreisfläche EINES Markers (neu) bauen — Werkzeuge rufen nach Radius-Änderung
## style_spawn_marker auf. U6 (t_639135dd): die Fläche bleibt IMMER gleich
## dezent (Teamfarbe, halbtransparent) — die Auswahl zeigt sich als schmaler
## Kontur-Ring am Zonenrand (Theme-Farbe), nicht als aufgehellte Vollfläche.
func _spawn_disc(radius: float, team: int, _selected := false) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = radius
cyl.bottom_radius = radius
cyl.height = 0.25
mi.mesh = cyl
mi.position.y = 0.12
var col: Color = TEAM_COLORS[team] if team >= 0 and team < TEAM_COLORS.size() else Color.WHITE
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(col.r, col.g, col.b, 0.55)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mi.material_override = mat
return mi
## Dezenter Auswahl-Ring EINES Spawn-Markers (U6): dünner Torus am Zonenrand,
## Farbe aus dem Theme. Muss NUR bei Auswahl existieren (restlos weg bei
## Abwahl) — style_spawn_marker legt ihn an/entfernt ihn.
func _spawn_ring(radius: float) -> MeshInstance3D:
var mi := MeshInstance3D.new()
var torus := TorusMesh.new()
var t := maxf(0.15, radius * 0.06)
torus.inner_radius = maxf(0.01, radius - t)
torus.outer_radius = radius + t
mi.mesh = torus
mi.position.y = 0.26
var mat := StandardMaterial3D.new()
mat.albedo_color = _markierung_farbe("selection_outline")
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mi.material_override = mat
return mi
## Radius/Anfass-Optik eines Spawn-Markers aktualisieren OHNE Gesamtrebuild
## (Radius-Regel gedreht, Zone ausgewählt/abgewählt).
func style_spawn_marker(marker: Node3D, radius: float, selected: bool) -> void:
if marker == null or not is_instance_valid(marker):
return
var team := int(marker.get_meta("spawn_team", -1))
var pid := str(marker.get_meta("spawn_id", ""))
marker.set_meta("spawn_radius", radius)
marker.set_meta("spawn_pick_radius", maxf(radius + 2.0, 4.0))
marker.set_meta("spawn_selected", selected)
if marker.get_child_count() > 0 and marker.get_child(0) is MeshInstance3D:
(marker.get_child(0) as MeshInstance3D).queue_free()
var disc := _spawn_disc(radius, team, selected)
marker.add_child(disc)
marker.move_child(disc, 0)
# Auswahl-Kontur: alter Ring weg, bei Auswahl neuer (restloser Wechsel).
var alter_ring: Node = null
if marker.has_meta("spawn_ring"):
alter_ring = marker.get_meta("spawn_ring")
if alter_ring != null and is_instance_valid(alter_ring):
alter_ring.queue_free()
marker.remove_meta("spawn_ring")
if selected:
var ring := _spawn_ring(radius)
marker.add_child(ring)
marker.set_meta("spawn_ring", ring)
## Auswahl-Highlight über ALLE Spawn-Marker (Werkzeug ruft bei Änderung).
func set_spawn_selected(team: int, pid: String) -> void:
for marker in spawn_markers:
if marker == null or not is_instance_valid(marker):
continue
var is_sel := int(marker.get_meta("spawn_team", -1)) == team \
and str(marker.get_meta("spawn_id", "")) == pid
if bool(marker.get_meta("spawn_selected", false)) == is_sel:
continue
style_spawn_marker(marker, float(marker.get_meta("spawn_radius", 6.0)), is_sel)
# Label sofort umfärben (auch ohne Disc-Neubau sichtbar) — Farbe aus
# dem Theme statt hartkodiertem Gelb (U6).
var col: Color = TEAM_COLORS[clampi(team, 0, 1)] if team >= 0 else Color.WHITE
if marker.get_child_count() > 1 and marker.get_child(1) is Label3D:
var label := marker.get_child(1) as Label3D
label.modulate = _markierung_farbe("selection_outline") if is_sel else col
# ------------------------------------------------------------ Bauteile ------
# (Karte t_97ea59c5 — Modular-Builder im App-Skelett; Meshes kommen aus der
# CC0-Plugin-Runtime-Geometrie, Materialien = eigene flache Fallback-Farben.)
## Bauteile aus dem doc backen und als MeshInstances aufbauen — plus die
## Welt-Flächen cachen, damit das Bauen-Werkzeug präzise auf Wände/Dächer
## klicken (stapeln) und Teile auswählen kann.
func _build_gebaeude(data: Dictionary) -> void:
_gebaeude_baked = BuildOps.bake_faces(data)
var mats := {}
var list: Array = BuildOps.pieces(data)
for i in list.size():
var teil: Dictionary = list[i]
var mesh := BuildOps.piece_mesh(teil)
if mesh == null:
continue
var pos: Array = teil.get("pos", [0.0, 0.0, 0.0])
var mi := MeshInstance3D.new()
mi.mesh = mesh
mi.material_override = BuildOps.piece_material(str(teil.get("id", "")), mats)
mi.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
mi.rotation.y = deg_to_rad(float(teil.get("rot", 0.0)))
add_child(mi)
gebaeude_markers.append(mi)
## Gebackene Welt-Flächen aller Bauteile (für das Bauen-Werkzeug: Klicken/
## Stapeln/Auswählen). Leeres faces = noch nichts gebaut.
func gebaeude_baked() -> Dictionary:
if _gebaeude_baked.is_empty():
return {"faces": PackedVector3Array(), "offsets": []}
return _gebaeude_baked
## Auswahl-Markierung (U6, t_639135dd): dezente Außenhüllen-Kontur um das
## Bauteil-Mesh — gleiche Technik wie bei den Objekten (selection_outline-
## Shader, Theme-Farbe). Keine halbtransparente Vollbox mehr, die das Teil
## verdeckt.
func set_gebaeude_selected(idx: int) -> void:
if _gebaeude_sel != null and is_instance_valid(_gebaeude_sel):
_gebaeude_sel.queue_free()
_gebaeude_sel = null
var list: Array = BuildOps.pieces(_doc_ref)
if idx < 0 or idx >= list.size():
return
var aabb := BuildOps.piece_aabb(list[idx])
if aabb.size.length() < 0.001:
return
var mesh := BuildOps.piece_mesh(list[idx])
if mesh == null:
return
# Kontur in GLEICHER Transform wie das echte Bauteil-Mesh (aabb ist
# welt-transformiert — nur als Größen-Guard; das Mesh selbst sitzt am
# Teil-Anker mit rot).
var kontur := MeshInstance3D.new()
kontur.mesh = mesh
var pos: Array = list[idx].get("pos", [0.0, 0.0, 0.0])
kontur.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
kontur.rotation.y = deg_to_rad(float(list[idx].get("rot", 0.0)))
kontur.material_override = _outline_mat_sel
add_child(kontur)
_gebaeude_sel = kontur
# ------------------------------------------------------ U6-Markierung -------
# (Karte t_639135dd — dezente Auswahl: Kontur statt Vollorange. Eine Quelle
# für Farben/Breiten ist das Theme; der Shader ist die Außenhüllen-Technik.)
## Theme-Farben + -Breiten in die drei Kontur-Materialien überführen
## (Auswahl / Hover / Partner). Fehlende Einträge → Fallback-Farbe.
func _markierung_theme_laden() -> void:
_outline_mat_sel = _outline_material(
_markierung_farbe("selection_outline"),
_markierung_breite("selection_outline_width_mm"), 0.0)
_outline_mat_hover = _outline_material(
_markierung_farbe("selection_outline_hover"),
_markierung_breite("selection_outline_width_hover_mm"), 0.0)
_outline_mat_partner = _outline_material(
_markierung_farbe("selection_outline_partner"),
_markierung_breite("selection_outline_width_partner_mm"), 3.5)
## Eine Markierungs-Farbe aus dem Theme lesen (fehlt sie → Fallback).
func _markierung_farbe(name: String) -> Color:
var theme_res: Variant = load(THEME_PATH)
if theme_res is Theme:
var th := theme_res as Theme
if th.has_color(name, "Editor"):
return th.get_color(name, "Editor")
return OUTLINE_FALLBACK
## Eine Markierungs-Breite aus dem Theme lesen (Theme speichert Millimeter
## als Ganzzahl — Theme kennt keine Gleitkommakonstanten).
func _markierung_breite(name: String) -> float:
var theme_res: Variant = load(THEME_PATH)
if theme_res is Theme:
var th := theme_res as Theme
if th.has_constant(name, "Editor"):
return float(th.get_constant(name, "Editor")) / 1000.0
return 0.06
## Kontur-Material für einen Zustand bauen (streifen > 0 = gestrichelt).
func _outline_material(farbe: Color, breite: float, streifen: float) -> ShaderMaterial:
var mat := ShaderMaterial.new()
var shader: Variant = load(OUTLINE_SHADER)
if shader is Shader:
mat.shader = shader
mat.set_shader_parameter("linien_farbe", farbe)
mat.set_shader_parameter("breite_m", breite)
mat.set_shader_parameter("streifen_hz", streifen)
return mat
## Zustand eines Objekts (U6-Abstufung): Auswahl > Partner > Hover > nichts.
func _markierung_zustand(idx: int) -> String:
if _is_selected(idx):
return "sel"
if _is_partner(idx):
return "partner"
if idx >= 0 and idx == hover_index:
return "hover"
return ""
## Kontur-Hülle eines Objekt-Markers anlegen/umschalten/restlos entfernen.
## Die Hülle hängt als Kind UNTER dem Mesh (erbt Position + Scale — GLB-
## Skalierung bleibt korrekt) und trägt das Mesh ein zweites Mal mit dem
## Außenhüllen-Material (visuell = zweite Render-Passage um das Modell,
## das Grundmaterial mit seiner Textur bleibt unangetastet).
func _outline_anwenden(marker: Node3D, idx: int) -> void:
if marker == null or not is_instance_valid(marker):
return
var zustand := _markierung_zustand(idx)
var alt: Node = null
if marker.has_meta("outline"):
alt = marker.get_meta("outline")
if zustand.is_empty():
if alt != null and is_instance_valid(alt):
alt.queue_free()
if marker.has_meta("outline"):
marker.remove_meta("outline")
return
var mat: ShaderMaterial = _outline_mat_sel
if zustand == "hover":
mat = _outline_mat_hover
elif zustand == "partner":
mat = _outline_mat_partner
if alt != null and is_instance_valid(alt):
(alt as MeshInstance3D).material_override = mat
return
var mi := marker.get_child(0) as MeshInstance3D
if mi == null or mi.mesh == null:
return
var huelle := MeshInstance3D.new()
huelle.name = "SelectionOutline"
huelle.mesh = mi.mesh
huelle.material_override = mat
mi.add_child(huelle)
marker.set_meta("outline", huelle)
## Hover-Markierung (U6-Abstufung, schwächste Kontur). idx = obj_index oder
## -1 für „kein Hover“. Der Aufrufer ist das Objekt-Werkzeug — der Anschluss
## an die Mausbewegung liegt in interaction.gd (Grenze dieser Karte) und
## kommt als Folge-Karte; die Abstufung selbst ist hier voll wirksam.
func set_hover(idx: int) -> void:
if hover_index == idx:
return
hover_index = idx
for marker in object_markers:
if marker == null or not is_instance_valid(marker):
continue
_outline_anwenden(marker, int(marker.get_meta("obj_index", -1)))