scopedvr-map-editor/src/viewport/map_view_builder.gd
slyphax cda6f48b01 GLB-View (t_9b50b231): echte Modelle im Editor-Viewport — Platzhalter nur als Fallback.
- GlbMeshStore (neu): laedt OID+.glb aus konfigurierbaren Quellen
  (ProjectSettings scopedvr/editor/glb_quellen, Voreinstellung =
  Sibling-Repos, wie gen_thumbs; plattformneutral, relative Pfade),
  backt ALLE MeshInstance3D der Szene in EIN normalisiertes ArrayMesh
  (Fusspunkt y=0, XZ zentriert — wie scopedvr_export.normalisiere),
  Materialien/Texturen bleiben je Oberflaeche erhalten.
- Cache pro OID (Parse nur einmal; fehlende Datei ungcached, Parse-Fail
  negativ gecached).
- map_view_builder: echtes GLB vor Platzhalter; kein half_height-Lift
  (schon Fusspunkt-Koordinaten), Scale ueber Node-Scale + AABB-Metas
  mal Scale; Auswahl-Orange nur als Override im Auswahl-Zustand
  (Texturen bleiben sichtbar). fehlende_glbs-Meldung an die Stats-Zeile.
- interaction (Ghost-Preview): nutzt denselben echten Mesh-Pfad;
  Platzier-Status haengt „(Platzhalter — echte GLB fehlt)“ an.
- tests/test_glb_view.gd (neu, in run_tests.sh): Moosfelsen-Probe
  (echtes GLB > Platzhalter-Vertices), stein_mittel_1 1026>>36,
  Normalisierung y=0/XZ-zentriert, Cache-Identitaet, Viewport-Rebuild
  Fusspunkt == Terrainhoehe (b20dff2), Fallback + fehlende_glbs.
  Suite: 773 checks, alle gruen.
2026-10-17 17:23:56 +00:00

448 lines
No EOL
18 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 SELECT_COLOR := Color(1.0, 0.6, 0.15) # Auswahl klar orange
const TEAM_COLORS := [Color(0.3, 0.6, 1.0), Color(1.0, 0.35, 0.3)] # Blau / Rot
# Wetter → Licht (grobe Stimmung; Feintuning kommt mit dem Wetter-Konsum)
const WEATHER_LIGHT := {
"mittag": {"energy": 1.2, "color": Color(1.0, 0.98, 0.9), "ambient": 0.45, "sky_top": Color(0.28, 0.5, 0.85)},
"abend": {"energy": 0.9, "color": Color(1.0, 0.75, 0.5), "ambient": 0.3, "sky_top": Color(0.35, 0.3, 0.55)},
"regen": {"energy": 0.6, "color": Color(0.8, 0.85, 0.9), "ambient": 0.35, "sky_top": Color(0.45, 0.48, 0.52)},
"nacht": {"energy": 0.25, "color": Color(0.6, 0.7, 1.0), "ambient": 0.12, "sky_top": Color(0.03, 0.05, 0.12)},
}
var selected_index := -1 # aktuelles Auswahl-Objekt (orange gefärbt)
var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrfachauswahl)
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 # orangener AABB-Rahmen um das gewählte Teil
var _doc_ref: Dictionary = {} # letztes rebuild-Doc (Auswahl-Markierung)
var _base_mat: StandardMaterial3D
var _select_mat: StandardMaterial3D
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
_select_mat = StandardMaterial3D.new()
_select_mat.albedo_color = SELECT_COLOR
_select_mat.emission_enabled = true
_select_mat.emission = SELECT_COLOR * 0.35
_select_mat.roughness = 0.7
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"))
_build_environment(weather)
_build_terrain(data)
_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)
## 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 = Farben nur aus der Höhe (Legacy).
func update_terrain(heights: PackedFloat32Array, mats: PackedByteArray = PackedByteArray()) -> void:
if terrain_view != null and is_instance_valid(terrain_view):
terrain_view.call("update_heights", heights, 0, 0.0, 0.0, mats)
## 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)
func _build_environment(weather: String) -> void:
var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"])
var env := Environment.new()
var sky := Sky.new()
var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = preset["sky_top"]
sky_mat.sky_horizon_color = Color(0.6, 0.65, 0.7)
sky_mat.ground_bottom_color = Color(0.1, 0.1, 0.12)
sky_mat.ground_horizon_color = Color(0.4, 0.42, 0.45)
sky.sky_material = sky_mat
env.background_mode = Environment.BG_SKY
env.sky = sky
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = Color(1, 1, 1)
env.ambient_light_energy = preset["ambient"]
var we := WorldEnvironment.new()
we.environment = env
add_child(we)
var sun := DirectionalLight3D.new()
sun.rotation_degrees = Vector3(-50.0, 30.0, 0.0)
sun.light_energy = preset["energy"]
sun.light_color = preset["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]))
marker.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0)))
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)
mi.material_override = _select_mat if _is_selected(i) else (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)
## 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 färben sich
## orange — Gruppen/Mehrfachauswahl sind auf einen Blick erkennbar.
## Echte GLB-Objekte (Meta „glb") bekommen im ungewählten Zustand KEINE
## Grundfarbe drübergelegt — material_override würde die Texturen verstecken
## (Karte t_9b50b231); orange bleibt auch dort das Auswahl-Signal.
func set_selected_multi(indices: Array) -> void:
selected_indices = indices.duplicate()
selected_index = indices.back() if not indices.is_empty() else -1
for marker in object_markers:
if marker == null or not is_instance_valid(marker):
continue
var mi: MeshInstance3D = marker.get_child(0)
var echt := bool(marker.get_meta("glb", false))
mi.material_override = _select_mat if _is_selected(int(marker.get_meta("obj_index", -1))) else (null if echt else _base_mat)
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.
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 if not selected else 0.8)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
if selected:
mat.emission_enabled = true
mat.emission = col * 0.6
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-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).
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 = Color(1.0, 0.85, 0.2) 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: orangener, halbtransparenter Rahmen um das Bauteil.
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 box := MeshInstance3D.new()
var bm := BoxMesh.new()
bm.size = aabb.size + Vector3(0.06, 0.06, 0.06)
box.mesh = bm
box.position = aabb.get_center()
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(SELECT_COLOR.r, SELECT_COLOR.g, SELECT_COLOR.b, 0.35)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.emission_enabled = true
mat.emission = SELECT_COLOR * 0.3
box.material_override = mat
add_child(box)
_gebaeude_sel = box