Skybox-Auswahl im Map-Panel: 13 neue Himmel, Vorschau, Undo/Redo, V17 über Katalog

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-25 15:30:38 +01:00
parent e3a6b2ad21
commit fcc7f16f1c
7 changed files with 257 additions and 44 deletions

125
src/svmap/skybox_katalog.gd Normal file
View file

@ -0,0 +1,125 @@
class_name SkyboxKatalog
## Skybox-Katalog (Karte Skybox-Auswahl): welche Himmels-Panoramas eine Map
## über das optionale svmap-Feld `weather_sky` wählen kann. 1:1-Spiegel von
## client/scripts/world/skybox_katalog.gd — beide Seiten pflegen dieselben IDs. Der Match-Server kennt nur das ID-Muster (V17), keine Texturen.
##
## IDs: "" = Preset entscheidet (alle Presets: ProceduralSky), "prozedural" =
## ProceduralSky erzwingen, "sky_panorama" = Pack-Wüste (Skybox_3, Altbestand),
## "sky_NN" = Panorama aus assets/textures/skybox/ (2048×1024, VRAM-komprimiert,
## ~1,4 MB je Textur; es wird immer nur EINE geladen). Unbekannte ID → Fallback
## ProceduralSky (alte Clients/neuere Maps brechen nie).
##
## `fog`: Farbe am Horizont des Panoramas (aerial perspective), gemessen am
## Texturband direkt über dem Horizont.
const ID_PACK_WUESTE := "sky_panorama"
const ID_PROZEDURAL := "prozedural"
## Relativ zum Client-Klon (Nachbar-Ordner, keine Duplikate im Editor).
const TEXTUR_DIR_REL := "assets/textures/skybox/"
const PACK_WUESTE_REL := "assets/textures/military_desert/Skybox_3.png"
## Reihenfolge = Reihenfolge im Editor-Auswahlfeld.
const EINTRAEGE := [
{"id": "sky_panorama", "name": "Wüste (Pack)", "fog": Color(0.93, 0.80, 0.66)},
{"id": "sky_03", "name": "Goldene Wolken (Sonnenuntergang)", "fog": Color(0.57, 0.63, 0.47)},
{"id": "sky_06", "name": "Glutroter Abend", "fog": Color(0.66, 0.40, 0.45)},
{"id": "sky_12", "name": "Roter Horizont (Abendrot)", "fog": Color(0.87, 0.44, 0.40)},
{"id": "sky_14", "name": "Wolkenfront (Tag)", "fog": Color(0.48, 0.58, 0.71)},
{"id": "sky_17", "name": "Violette Nacht (Milchstraße)", "fog": Color(0.58, 0.45, 0.65)},
{"id": "sky_82", "name": "Dämmerung mit Milchstraße", "fog": Color(0.54, 0.48, 0.53)},
{"id": "sky_84", "name": "Sternenhimmel mit Wolkenbändern", "fog": Color(0.58, 0.66, 0.65)},
{"id": "sky_90", "name": "Nachtwolken", "fog": Color(0.22, 0.36, 0.53)},
{"id": "sky_94", "name": "Sonne hinter Wolken", "fog": Color(0.47, 0.51, 0.61)},
{"id": "sky_98", "name": "Sternschnuppen (Nacht)", "fog": Color(0.33, 0.54, 0.71)},
{"id": "sky_103", "name": "Sonnenaufgang (Strahlen)", "fog": Color(0.50, 0.69, 0.75)},
{"id": "sky_107", "name": "Goldene Stunde", "fog": Color(0.79, 0.54, 0.33)},
{"id": "sky_115", "name": "Abendglühen (Wolkentürme)", "fog": Color(0.59, 0.44, 0.43)},
]
static func eintrag(id: String) -> Dictionary:
for e in EINTRAEGE:
if e["id"] == id:
return e
return {}
static func ist_bekannt(id: String) -> bool:
return id == ID_PROZEDURAL or not eintrag(id).is_empty()
## V17: gültig = leer/fehlend, "prozedural" oder bekannte ID.
static func ist_gueltig(id: Variant) -> bool:
if id == null:
return true
if not (id is String):
return false
return id == "" or ist_bekannt(id)
## Pfad relativ zum Client-Klon ("" = ProceduralSky).
static func textur_rel(id: String) -> String:
if id == ID_PACK_WUESTE:
return PACK_WUESTE_REL
if eintrag(id).is_empty():
return ""
return TEXTUR_DIR_REL + id + ".png"
## Absoluter Pfad im Nachbar-Klon ("" = ID unbekannt oder kein Client-Klon).
static func textur_pfad(id: String) -> String:
var rel := textur_rel(id)
if rel.is_empty():
return ""
var root := TestRun.find_client(ProjectSettings.globalize_path("res://"),
TestRun.load_cfg())
return "" if root.is_empty() else root.path_join(rel)
static func fog_farbe(id: String, fallback: Color, energie: float = 1.0) -> Color:
if id == ID_PACK_WUESTE:
return fallback
var e := eintrag(id)
return (e["fog"] * minf(energie, 1.0)) if not e.is_empty() else fallback
## Geladene Panorama-Texturen (volle Größe, mit Mipmaps) — ein Eintrag je ID,
## damit jeder Rebuild nicht erneut 2k-PNGs dekodiert. null = nicht ladbar.
static var _tex_cache: Dictionary = {}
static var _thumb_cache: Dictionary = {}
static func panorama_textur(id: String) -> Texture2D:
if _tex_cache.has(id):
return _tex_cache[id]
var tex: Texture2D = null
var pfad := textur_pfad(id)
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()
tex = ImageTexture.create_from_image(img)
if tex != null:
_tex_cache[id] = tex # Fehlschläge NICHT cachen (Klon kann später da sein)
return tex
## Vorschau-Bild für das Auswahlfeld: obere Panorama-Hälfte (Himmel), klein.
static func vorschau(id: String) -> Texture2D:
if _thumb_cache.has(id):
return _thumb_cache[id]
var pfad := textur_pfad(id)
if pfad.is_empty() or not FileAccess.file_exists(pfad):
return null
var img := Image.load_from_file(pfad)
if img == null:
return null
if img.is_compressed():
img.decompress()
var halb := img.get_region(Rect2i(0, 0, img.get_width(), img.get_height() / 2))
halb.resize(320, 80, Image.INTERPOLATE_LANCZOS)
var tex := ImageTexture.create_from_image(halb)
_thumb_cache[id] = tex
return tex

View file

@ -0,0 +1 @@
uid://cydcauar68si5

View file

@ -57,7 +57,7 @@ const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"]
## — identische Semantik wie der Client (Weather.apply_preset sky_override): ## — identische Semantik wie der Client (Weather.apply_preset sky_override):
## "sky_panorama" = Pack-Panorama erzwingen, "prozedural" = ProceduralSky ## "sky_panorama" = Pack-Panorama erzwingen, "prozedural" = ProceduralSky
## erzwingen, leer/fehlend = Preset entscheidet (in allen Presets aus). ## erzwingen, leer/fehlend = Preset entscheidet (in allen Presets aus).
const WEATHER_SKY_OPTIONS: Array[String] = ["sky_panorama", "prozedural"] const WEATHER_SKY_OPTIONS: Array[String] = ["sky_panorama", "prozedural"] # ALT; gültig = SkyboxKatalog.ist_gueltig
# Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4). # Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4).
const MAX_FILE_BYTES := 50 * 1024 * 1024 # V1 const MAX_FILE_BYTES := 50 * 1024 * 1024 # V1
@ -214,9 +214,12 @@ static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> Packe
# die Server-Validierung nicht, nur WERT-Fehler fangen wir hier ab. # die Server-Validierung nicht, nur WERT-Fehler fangen wir hier ab.
var weather_sky = data.get("weather_sky", "") var weather_sky = data.get("weather_sky", "")
if weather_sky != null and str(weather_sky) != "": if weather_sky != null and str(weather_sky) != "":
if not (weather_sky is String) or not WEATHER_SKY_OPTIONS.has(weather_sky): if not SkyboxKatalog.ist_gueltig(weather_sky):
errors.append("V17: weather_sky '%s' ist keine gültige Option (%s)." % [ var ids: PackedStringArray = ["prozedural"]
str(weather_sky), ", ".join(WEATHER_SKY_OPTIONS)]) for e in SkyboxKatalog.EINTRAEGE:
ids.append(e["id"])
errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [
str(weather_sky), ", ".join(ids)])
return errors return errors

View file

@ -2080,6 +2080,8 @@ func _after_structure_change() -> void:
# kehrte z. B. nach Strg+Z die Dauersicht zurück, die Sly störte. # kehrte z. B. nach Strg+Z die Dauersicht zurück, die Sly störte.
_sync_contours_to_builder() _sync_contours_to_builder()
_refresh_tree() _refresh_tree()
if map_mod != null:
map_mod.refresh_values() # Undo/Redo: Wetter/Himmel-Auswahl nachziehen
_recheck_terrain_mode() _recheck_terrain_mode()
if interaction != null: if interaction != null:
interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände

View file

@ -11,6 +11,7 @@ var prop_desc: LineEdit
var prop_version: SpinBox var prop_version: SpinBox
var prop_weather: OptionButton var prop_weather: OptionButton
var prop_sky: OptionButton var prop_sky: OptionButton
var sky_preview: TextureRect # Vorschau der gewählten Skybox (obere Panorama-Hälfte)
var prop_water: HSlider var prop_water: HSlider
var terrain_info: Label var terrain_info: Label
@ -67,10 +68,17 @@ func build() -> void:
add_child(_field_label("Himmel")) add_child(_field_label("Himmel"))
prop_sky = OptionButton.new() prop_sky = OptionButton.new()
# Karte Atmosphäre/Skybox: Panorama-Skybox aus dem Pack als Map-Option. # Karte Atmosphäre/Skybox: Panorama-Skybox aus dem Pack als Map-Option.
prop_sky.tooltip_text = "Himmel: Prozedural (Standard) oder Pack-Skybox (Wüstenpanorama). „Standard“ lässt die Karte unverändert." prop_sky.tooltip_text = "Himmel der Karte: Prozedural (Standard, lässt die Karte unverändert) oder eine Skybox aus dem Katalog. Der Viewport zeigt sie sofort; Strg+Z macht die Wahl rückgängig."
prop_sky.add_item("Prozedural (Standard)") prop_sky.add_item("Prozedural (Standard)")
prop_sky.add_item("Pack-Skybox (Wüste)") for e in SkyboxKatalog.EINTRAEGE:
prop_sky.add_item(str(e["name"]))
add_child(prop_sky) add_child(prop_sky)
sky_preview = TextureRect.new()
sky_preview.custom_minimum_size = Vector2(0, 80)
sky_preview.expand_mode = TextureRect.EXPAND_IGNORE_SIZE
sky_preview.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_COVERED
sky_preview.tooltip_text = "Vorschau: oberer Teil der gewählten Skybox"
add_child(sky_preview)
# Wasser (Karte t_28fdef03): Wasserhöhe der Map — Senken unter der Höhe # Wasser (Karte t_28fdef03): Wasserhöhe der Map — Senken unter der Höhe
# füllen sich automatisch. Ganz links = „Kein Wasser“ (Feld entfernen, # füllen sich automatisch. Ganz links = „Kein Wasser“ (Feld entfernen,
@ -140,23 +148,40 @@ func _on_weather_changed(index: int) -> void:
main.call("_set_status", "Wetter geändert auf „%s“." % weathers[index]) main.call("_set_status", "Wetter geändert auf „%s“." % weathers[index])
## Karte Atmosphäre/Skybox: Himmel „Pack-Skybox (Wüste)“ → doc["weather_sky"] ## Skybox-Auswahl: Eintrag → doc["weather_sky"] = Katalog-ID (ein Speicherfeld,
## = "sky_panorama" (ein Speicherfeld, checksum-neutral wie tint/gebaeude; ## checksum-neutral wie tint/gebaeude). „Prozedural (Standard)“ = Index 0 → Feld
## Wert = der Preset-Key `sky_panorama`, den der Client beim Laden erzwingt). ## ENTFERNEN (alte Maps unverändert). Mit Undo/Redo (History-Block weather_sky).
## „Prozedural (Standard)“ = Index 0 → Feld WIEDER ENTFERNEN (alte Maps ## Sofort-Neuaufbau wie Wetter — der Viewport zeigt exakt, was der Client zeigt.
## unverändert — kein "prozedural"-Wert im doc). Sofort-Neuaufbau wie Wetter.
func _on_sky_changed(index: int) -> void: func _on_sky_changed(index: int) -> void:
if _loading_ui_values or main.doc.is_empty(): if _loading_ui_values or main.doc.is_empty():
return return
if index == 1: var neu := "" if index <= 0 else str(SkyboxKatalog.EINTRAEGE[index - 1]["id"])
main.doc["weather_sky"] = "sky_panorama" var alt := str(main.doc.get("weather_sky", ""))
else: if neu == alt:
return
var before := {"weather_sky": (alt if main.doc.has("weather_sky") else null)}
if neu == "":
main.doc.erase("weather_sky") main.doc.erase("weather_sky")
else:
main.doc["weather_sky"] = neu
var after := {"weather_sky": (neu if neu != "" else null)}
var name := "Prozedural (Standard)" if neu == "" else str(SkyboxKatalog.eintrag(neu)["name"])
main.history.commit("Himmel: %s" % name, before, after)
main.dirty = true main.dirty = true
main.call("_refresh_file_label") main.call("_refresh_file_label")
main.call("_refresh_history_buttons")
main.view_builder.call("rebuild", main.doc, main.manifest) main.view_builder.call("rebuild", main.doc, main.manifest)
main.call("_sync_grid_to_builder") main.call("_sync_grid_to_builder")
main.call("_set_status", "Himmel geändert auf „%s“." % ["Prozedural (Standard)", "Pack-Skybox (Wüste)"][index]) _update_sky_preview(neu)
var hinweis := ""
if neu != "" and SkyboxKatalog.panorama_textur(neu) == null:
hinweis = " Achtung: Textur nicht gefunden (Client-Ordner neben dem Editor fehlt) — Vorschau zeigt den Standard-Himmel."
main.call("_set_status", "Himmel geändert auf „%s“.%s" % [name, hinweis])
func _update_sky_preview(id: String) -> void:
sky_preview.texture = SkyboxKatalog.vorschau(id) if id != "" else null
sky_preview.visible = sky_preview.texture != null
## Wasser (Karte t_28fdef03): Slider → doc["water_level"]. Ganz links ## Wasser (Karte t_28fdef03): Slider → doc["water_level"]. Ganz links
@ -202,8 +227,14 @@ func refresh_values() -> void:
var weathers := ["mittag", "abend", "regen", "nacht"] var weathers := ["mittag", "abend", "regen", "nacht"]
var weather := str(main.doc.get("weather", "mittag")) var weather := str(main.doc.get("weather", "mittag"))
prop_weather.select(maxi(0, weathers.find(weather))) prop_weather.select(maxi(0, weathers.find(weather)))
# Himmel: Pack-Skybox ausgewählt ↔ doc["weather_sky"] == "sky_panorama". # Himmel: Auswahl ↔ doc["weather_sky"] (Katalog-ID; unbekannt/leer → Standard).
prop_sky.select(1 if str(main.doc.get("weather_sky", "")) == "sky_panorama" else 0) var sky_id := str(main.doc.get("weather_sky", ""))
var sky_idx := 0
for i in SkyboxKatalog.EINTRAEGE.size():
if SkyboxKatalog.EINTRAEGE[i]["id"] == sky_id:
sky_idx = i + 1
prop_sky.select(sky_idx)
_update_sky_preview(sky_id if sky_idx > 0 else "")
var terrain: Dictionary = main.doc.get("terrain", {}) var terrain: Dictionary = main.doc.get("terrain", {})
terrain_info.text = "Größe: %.0f × %.0f m\nAuflösung: %d × %d Höhenpunkte\nHöhenbereich: %.0f bis %.0f m" % [ terrain_info.text = "Größe: %.0f × %.0f m\nAuflösung: %d × %d Höhenpunkte\nHöhenbereich: %.0f bis %.0f m" % [

View file

@ -222,29 +222,25 @@ func _build_environment(weather: String, sky_override: String = "") -> void:
var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"]) var preset: Dictionary = WEATHER_LIGHT.get(weather, WEATHER_LIGHT["mittag"])
var env := Environment.new() var env := Environment.new()
var sky := Sky.new() var sky := Sky.new()
var panorama := false # Katalog-ID (weather_sky) > Preset-Key `sky_panorama` > ProceduralSky.
var pano_id := ""
if sky_override == "prozedural": if sky_override == "prozedural":
panorama = false pass
elif sky_override == "sky_panorama": elif sky_override != "":
panorama = true pano_id = sky_override
else: elif bool(preset.get("sky_panorama", false)):
panorama = bool(preset.get("sky_panorama", false)) pano_id = SkyboxKatalog.ID_PACK_WUESTE
if panorama: var panorama := false
var pfad := panorama_sky_pfad() if pano_id != "":
if not pfad.is_empty() and FileAccess.file_exists(pfad): var tex := SkyboxKatalog.panorama_textur(pano_id)
var img := Image.load_from_file(pfad) if tex != null:
if img != null:
if img.is_compressed():
img.decompress()
img.generate_mipmaps()
var pano_mat := PanoramaSkyMaterial.new() var pano_mat := PanoramaSkyMaterial.new()
pano_mat.panorama = ImageTexture.create_from_image(img) pano_mat.panorama = tex
pano_mat.energy_multiplier = float(preset.get("panorama_energy", 1.0)) pano_mat.energy_multiplier = float(preset.get("panorama_energy", 1.0))
sky.sky_material = pano_mat sky.sky_material = pano_mat
panorama = true
else: else:
push_warning("MAPBUILDER: Panorama-Textur nicht ladbar (%s) — Fallback ProceduralSky" % pfad) push_warning("MAPBUILDER: weather_sky=%s, aber Skybox-Textur fehlt/unbekannt — Fallback ProceduralSky" % pano_id)
else:
push_warning("MAPBUILDER: weather_sky=sky_panorama, aber Pack-Skybox fehlt (%s) — Fallback ProceduralSky" % pfad)
if sky.sky_material == null: if sky.sky_material == null:
var sky_mat := ProceduralSkyMaterial.new() var sky_mat := ProceduralSkyMaterial.new()
sky_mat.sky_top_color = preset["sky_top"] sky_mat.sky_top_color = preset["sky_top"]
@ -266,7 +262,9 @@ func _build_environment(weather: String, sky_override: String = "") -> void:
# NUR über Tonemap + Preset-Farben (kein Fullscreen-Adjustment-Pass). # NUR über Tonemap + Preset-Farben (kein Fullscreen-Adjustment-Pass).
env.tonemap_mode = Environment.TONE_MAPPER_ACES env.tonemap_mode = Environment.TONE_MAPPER_ACES
env.fog_enabled = bool(preset["fog_enabled"]) 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_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_density = float(preset["fog_density"])
env.fog_sky_affect = float(preset["fog_sky_affect"]) env.fog_sky_affect = float(preset["fog_sky_affect"])
var we := WorldEnvironment.new() var we := WorldEnvironment.new()

View file

@ -50,6 +50,7 @@ func _run_all() -> void:
await _t1_validierung() await _t1_validierung()
await _t2_panel() await _t2_panel()
await _t3_vorschau() await _t3_vorschau()
await _t4_auswahl_undo()
_finish() _finish()
@ -62,14 +63,14 @@ func _t1_validierung() -> void:
var f0: PackedStringArray = SvmapIO.validate_map(alte) var f0: PackedStringArray = SvmapIO.validate_map(alte)
_check(f0.is_empty(), "T1 Karte ohne weather_sky validiert ohne Fehler (%s)" % str(f0)) _check(f0.is_empty(), "T1 Karte ohne weather_sky validiert ohne Fehler (%s)" % str(f0))
# Gültige Werte (mit frischer Prüfsumme — Feld fließt in die Payload). # Gültige Werte (mit frischer Prüfsumme — Feld fließt in die Payload).
for wert in ["sky_panorama", "prozedural"]: for wert in ["sky_panorama", "prozedural", "sky_03", "sky_115"]:
var d: Dictionary = main.doc.duplicate(true) var d: Dictionary = main.doc.duplicate(true)
d["weather_sky"] = wert d["weather_sky"] = wert
SvmapIO.sign_map(d) SvmapIO.sign_map(d)
var f: PackedStringArray = SvmapIO.validate_map(d) var f: PackedStringArray = SvmapIO.validate_map(d)
_check(f.is_empty(), "T1 weather_sky='%s' validiert (%s)" % [wert, str(f)]) _check(f.is_empty(), "T1 weather_sky='%s' validiert (%s)" % [wert, str(f)])
# Ungültige Werte → V17 (_validate_core ohne Prüfsumme, wie test_symmetry_core). # Ungültige Werte → V17 (_validate_core ohne Prüfsumme, wie test_symmetry_core).
for wert in ["panorama", 42]: for wert in ["panorama", "sky_999", 42]:
var d2: Dictionary = main.doc.duplicate(true) var d2: Dictionary = main.doc.duplicate(true)
d2["weather_sky"] = wert d2["weather_sky"] = wert
var f2: PackedStringArray = SvmapIO._validate_core(d2) var f2: PackedStringArray = SvmapIO._validate_core(d2)
@ -86,9 +87,11 @@ func _t2_panel() -> void:
_check(prop_sky != null, "T2 prop_sky existiert") _check(prop_sky != null, "T2 prop_sky existiert")
if prop_sky == null: if prop_sky == null:
return return
_check(prop_sky.item_count == 2, "T2 Himmel-Optionen: 2 Einträge (ist %d)" % prop_sky.item_count) _check(prop_sky.item_count == 1 + SkyboxKatalog.EINTRAEGE.size(),
_check(str(prop_sky.get_item_text(1)).contains("Pack-Skybox"), "T2 Himmel-Optionen: Standard + %d Katalog-Einträge (ist %d)" % [SkyboxKatalog.EINTRAEGE.size(), prop_sky.item_count])
"T2 Eintrag 2 = Pack-Skybox (ist „%s“)" % str(prop_sky.get_item_text(1))) _check(prop_sky.item_count == 15, "T2 15 Einträge (Standard, Wüste, 13 neue)")
_check(str(prop_sky.get_item_text(1)).contains("Wüste"),
"T2 Eintrag 2 = Wüste (ist „%s“)" % str(prop_sky.get_item_text(1)))
# doc mit Panorama → UI zeigt Pack-Skybox. # doc mit Panorama → UI zeigt Pack-Skybox.
main.doc["weather_sky"] = "sky_panorama" main.doc["weather_sky"] = "sky_panorama"
map_panel.call("refresh_values") map_panel.call("refresh_values")
@ -147,6 +150,56 @@ func _t3_vorschau() -> void:
"T3 ohne weather_sky → ProceduralSkyMaterial (alte Maps unverändert)") "T3 ohne weather_sky → ProceduralSkyMaterial (alte Maps unverändert)")
## Neue Skyboxen: Auswahl → doc + Vorschau-Bild + Viewport-Sky; Undo/Redo.
func _t4_auswahl_undo() -> void:
print("== T4: Skybox-Auswahl mit Undo/Redo ==")
var prop_sky: OptionButton = map_panel.get("prop_sky")
var vb = main.view_builder
main.doc.erase("weather_sky")
map_panel.call("refresh_values")
var idx := -1
for i in SkyboxKatalog.EINTRAEGE.size():
if SkyboxKatalog.EINTRAEGE[i]["id"] == "sky_98":
idx = i + 1
_check(idx > 0, "T4 sky_98 im Katalog")
var h = main.history
var n0: int = h.entries.size()
prop_sky.select(idx)
map_panel.call("_on_sky_changed", idx)
_check(str(main.doc.get("weather_sky", "")) == "sky_98", "T4 Auswahl schreibt weather_sky=sky_98")
_check(h.entries.size() == n0 + 1, "T4 Auswahl landet in der Undo-Historie")
var prev: TextureRect = map_panel.get("sky_preview")
var tex_ok := SkyboxKatalog.textur_pfad("sky_98") != "" and FileAccess.file_exists(SkyboxKatalog.textur_pfad("sky_98"))
if tex_ok:
_check(prev.texture != null and prev.visible, "T4 Vorschau-Bild sichtbar")
await process_frame
var we := _welt_env(vb)
_check(we != null and we.environment.sky.sky_material is PanoramaSkyMaterial,
"T4 Viewport zeigt Panorama (sky_98)")
# Gleiche Auswahl nochmal = kein neuer Historien-Eintrag.
map_panel.call("_on_sky_changed", idx)
_check(h.entries.size() == n0 + 1, "T4 gleiche Wahl erzeugt keinen Eintrag")
# Undo → Feld weg, UI zurück auf Standard.
main.call("_do_undo")
_check(not main.doc.has("weather_sky"), "T4 Undo entfernt weather_sky")
_check(prop_sky.selected == 0, "T4 Undo: Auswahlfeld zeigt Standard")
main.call("_do_redo")
_check(str(main.doc.get("weather_sky", "")) == "sky_98", "T4 Redo setzt sky_98 wieder")
_check(prop_sky.selected == idx, "T4 Redo: Auswahlfeld zeigt sky_98")
# Wechsel auf anderen Himmel, Undo → zurück auf sky_98 (nicht Standard).
map_panel.call("_on_sky_changed", 2)
main.call("_do_undo")
_check(str(main.doc.get("weather_sky", "")) == "sky_98", "T4 Undo nach Wechsel → vorheriger Himmel")
# Aufräumen.
map_panel.call("_on_sky_changed", 0)
_check(not main.doc.has("weather_sky"), "T4 Aufräumen")
# Katalog-Vertrag: Server-Feld überlebt Speichern/Laden.
var d: Dictionary = main.doc.duplicate(true)
d["weather_sky"] = "sky_14"
SvmapIO.sign_map(d)
_check(SvmapIO.validate_map(d).is_empty(), "T4 Map mit sky_14 signiert + valide")
func _welt_env(vb) -> WorldEnvironment: func _welt_env(vb) -> WorldEnvironment:
for child in vb.get_children(): for child in vb.get_children():
if child is WorldEnvironment: if child is WorldEnvironment: