scopedvr-map-core/viewport/map_view_builder.gd
slyphax 83af25b6fb VR-Host: main läuft ohne Fenster, Modelle aus dem Projekt, Terrain in Stücken (nur Quest)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:38:42 +01:00

889 lines
No EOL
37 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://core/theme/editor_theme.tres"
const OUTLINE_SHADER := "res://core/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 zone_root: Node3D = null # Conquest-Zonen bzw. Hills (Spielmodus), Kinder je Zone
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
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 = []
zone_root = null
grid_node = 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)
rebuild_zone_markers(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://core/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)
## Pinsel meldet die geänderten Höhenpunkte (nur im Stücke-Modus genutzt).
func mark_terrain_dirty(rect: Rect2i) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("markiere_hoehen", rect)
## 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()
# Katalog-ID (weather_sky) > Preset-Key `sky_panorama` > ProceduralSky.
var pano_id := ""
if sky_override == "prozedural":
pass
elif sky_override != "":
pano_id = sky_override
elif bool(preset.get("sky_panorama", false)):
pano_id = SkyboxKatalog.ID_PACK_WUESTE
var panorama := false
if pano_id != "":
var tex := SkyboxKatalog.panorama_textur(pano_id)
if tex != null:
var pano_mat := PanoramaSkyMaterial.new()
pano_mat.panorama = tex
pano_mat.energy_multiplier = float(preset.get("panorama_energy", 1.0))
sky.sky_material = pano_mat
panorama = true
else:
push_warning("MAPBUILDER: weather_sky=%s, aber Skybox-Textur fehlt/unbekannt — Fallback ProceduralSky" % pano_id)
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 = SkyboxKatalog.fog_farbe(sky_override,
preset.get("panorama_fog_color", preset["fog_color"]),
float(preset.get("panorama_energy", 1.0))) 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 = SpawnOps.zones_of(data) # bearbeiteter Spawn-Satz (Basis oder Modus)
if not (zones is Dictionary):
return
for team_key in zones.keys():
if not (zones[team_key] is Dictionary):
continue
var team := 0 if str(team_key) == "all" else 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))
var yaw_col: Color = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE
marker.set_meta("yaw_color", yaw_col)
marker.add_child(_yaw_arrow(SpawnOps.zone_radius(data, pid),
SpawnOps.effective_yaw(data, p), SpawnOps.has_yaw(p), yaw_col))
spawn_markers.append(marker)
add_child(marker)
## Zonen des Spielmodus: Conquest = gelb, King of the Hill = orange (mit Nummer der
## Wanderreihenfolge). Boden-Kreis + durchscheinender Zylinder + Name.
func rebuild_zone_markers(data: Dictionary) -> void:
if zone_root != null and is_instance_valid(zone_root):
zone_root.queue_free()
zone_root = null
var mode := ModusKatalog.mode_of(data)
if not ModusKatalog.has_zones(mode):
return
zone_root = Node3D.new()
zone_root.name = "Zonen"
add_child(zone_root)
var col := Color(1.0, 0.8, 0.2) if mode == ModusKatalog.MODE_CONQUEST else Color(1.0, 0.45, 0.15)
var zlist := ModusKatalog.zones(data, mode)
for zi in zlist.size():
var cp = zlist[zi]
if not (cp is Dictionary):
continue
var pos: Array = cp.get("pos", [0, 0, 0])
var r := float(cp.get("radius_m", ModusKatalog.DEFAULT_RADIUS))
var node := Node3D.new()
node.name = "CP_%s" % str(cp.get("id", ""))
node.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.albedo_color = Color(col.r, col.g, col.b, 0.18)
var cyl := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = r
cm.bottom_radius = r
cm.height = 8.0
cm.cap_top = false
cm.cap_bottom = false
cyl.mesh = cm
cyl.material_override = mat
cyl.position.y = 4.0
node.add_child(cyl)
var ring := MeshInstance3D.new()
var tm := TorusMesh.new()
tm.inner_radius = r - 0.2
tm.outer_radius = r + 0.2
tm.rings = 48
tm.ring_segments = 6
ring.mesh = tm
var rmat := StandardMaterial3D.new()
rmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
rmat.albedo_color = col
ring.material_override = rmat
ring.position.y = 0.15
node.add_child(ring)
var label := Label3D.new()
label.text = ("⚑ %s" if mode == ModusKatalog.MODE_CONQUEST else "♛ %d · %%s" % (zi + 1)) % str(cp.get("label", cp.get("id", "")))
label.position.y = 10.0
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.modulate = col
label.outline_size = 12
label.font_size = 160
label.pixel_size = 0.01
node.add_child(label)
if mode == ModusKatalog.MODE_CONQUEST: # Hills sind keine Spawns
node.set_meta("zone_id", str(cp.get("id", "")))
node.set_meta("yaw_radius", r)
node.add_child(_yaw_arrow(r, SpawnOps.effective_yaw(data, cp), SpawnOps.has_yaw(cp), col))
zone_root.add_child(node)
## 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
## Pfeil der Blickrichtung: flach am Boden, zeigt lokal nach -Z, rotation.y = Blickrichtung.
## Gesetzte Richtung kräftig, Standard (zur Kartenmitte) blass grau. Ohne Tiefentest, damit
## Hügel und Objekte ihn nicht verdecken.
func _yaw_arrow(radius: float, yaw_deg: float, is_set: bool, col: Color) -> MeshInstance3D:
var length := yaw_arrow_length(radius)
var half_w := maxf(0.25, length * 0.04)
var head_w := half_w * 3.0
var head_l := minf(length * 0.4, head_w * 2.5)
var neck := -(length - head_l)
var verts := PackedVector3Array([
Vector3(-half_w, 0, 0), Vector3(half_w, 0, 0), Vector3(half_w, 0, neck),
Vector3(-half_w, 0, 0), Vector3(half_w, 0, neck), Vector3(-half_w, 0, neck),
Vector3(-head_w, 0, neck), Vector3(head_w, 0, neck), Vector3(0, 0, -length),
])
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = verts
var mesh := ArrayMesh.new()
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.no_depth_test = true
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = col.lightened(0.35) if is_set else Color(0.75, 0.75, 0.75, 0.55)
var arrow := MeshInstance3D.new()
arrow.name = "YawArrow"
arrow.mesh = mesh
arrow.material_override = mat
arrow.position.y = 0.4
arrow.rotation.y = deg_to_rad(yaw_deg)
arrow.set_meta("yaw_deg", yaw_deg)
arrow.set_meta("yaw_set", is_set)
arrow.set_meta("yaw_length", length)
return arrow
static func yaw_arrow_length(radius: float) -> float:
return maxf(radius, 3.0) + 2.0
func yaw_arrow_of(node: Node3D) -> MeshInstance3D:
return node.get_node_or_null("YawArrow") as MeshInstance3D
## Alle Pfeile zum Anfassen: [{kind:"spawn"|"zone", team, id, node, tip:Vector3}].
func yaw_handles() -> Array:
var out: Array = []
var add := func(node: Node3D, entry: Dictionary) -> void:
var arrow := yaw_arrow_of(node)
if arrow == null:
return
var yaw := deg_to_rad(float(arrow.get_meta("yaw_deg", 0.0)))
var length := float(arrow.get_meta("yaw_length", 0.0))
entry["node"] = node
entry["tip"] = node.global_position + Vector3(-sin(yaw), 0.0, -cos(yaw)) * length
out.append(entry)
for marker in spawn_markers:
if marker != null and is_instance_valid(marker):
add.call(marker, {"kind": "spawn", "team": int(marker.get_meta("spawn_team", -1)), "id": str(marker.get_meta("spawn_id", ""))})
if zone_root != null and is_instance_valid(zone_root):
for node in zone_root.get_children():
if node.has_meta("zone_id"):
add.call(node, {"kind": "zone", "team": -1, "id": str(node.get_meta("zone_id"))})
return out
## 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)
var arrow := yaw_arrow_of(marker)
if arrow != null: # Länge folgt dem Radius
var yaw_deg := float(arrow.get_meta("yaw_deg", 0.0))
var yaw_set := bool(arrow.get_meta("yaw_set", false))
arrow.name = "YawArrowAlt"
arrow.queue_free()
marker.add_child(_yaw_arrow(radius, yaw_deg, yaw_set, marker.get_meta("yaw_color", Color.WHITE)))
## 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
# ------------------------------------------------------ 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)))