diff --git a/src/svmap/skybox_katalog.gd b/src/svmap/skybox_katalog.gd new file mode 100644 index 0000000..2aebaba --- /dev/null +++ b/src/svmap/skybox_katalog.gd @@ -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 diff --git a/src/svmap/skybox_katalog.gd.uid b/src/svmap/skybox_katalog.gd.uid new file mode 100644 index 0000000..b8abc2c --- /dev/null +++ b/src/svmap/skybox_katalog.gd.uid @@ -0,0 +1 @@ +uid://cydcauar68si5 diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index a7398dc..f34331a 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -57,7 +57,7 @@ const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"] ## — identische Semantik wie der Client (Weather.apply_preset sky_override): ## "sky_panorama" = Pack-Panorama erzwingen, "prozedural" = ProceduralSky ## 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). 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. var weather_sky = data.get("weather_sky", "") if weather_sky != null and str(weather_sky) != "": - if not (weather_sky is String) or not WEATHER_SKY_OPTIONS.has(weather_sky): - errors.append("V17: weather_sky '%s' ist keine gültige Option (%s)." % [ - str(weather_sky), ", ".join(WEATHER_SKY_OPTIONS)]) + if not SkyboxKatalog.ist_gueltig(weather_sky): + var ids: PackedStringArray = ["prozedural"] + 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 diff --git a/src/ui/main.gd b/src/ui/main.gd index d118aa0..c8c0911 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -2080,6 +2080,8 @@ func _after_structure_change() -> void: # kehrte z. B. nach Strg+Z die Dauersicht zurück, die Sly störte. _sync_contours_to_builder() _refresh_tree() + if map_mod != null: + map_mod.refresh_values() # Undo/Redo: Wetter/Himmel-Auswahl nachziehen _recheck_terrain_mode() if interaction != null: interaction.call("refresh_terrain_cache") # t_4092e3a0: Objekt-Cursor trifft das Gelände diff --git a/src/ui/panels/map_panel.gd b/src/ui/panels/map_panel.gd index f47f4f7..8a44fef 100644 --- a/src/ui/panels/map_panel.gd +++ b/src/ui/panels/map_panel.gd @@ -11,6 +11,7 @@ var prop_desc: LineEdit var prop_version: SpinBox var prop_weather: OptionButton var prop_sky: OptionButton +var sky_preview: TextureRect # Vorschau der gewählten Skybox (obere Panorama-Hälfte) var prop_water: HSlider var terrain_info: Label @@ -67,10 +68,17 @@ func build() -> void: add_child(_field_label("Himmel")) prop_sky = OptionButton.new() # 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("Pack-Skybox (Wüste)") + for e in SkyboxKatalog.EINTRAEGE: + prop_sky.add_item(str(e["name"])) 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 # 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]) -## Karte Atmosphäre/Skybox: Himmel „Pack-Skybox (Wüste)“ → doc["weather_sky"] -## = "sky_panorama" (ein Speicherfeld, checksum-neutral wie tint/gebaeude; -## Wert = der Preset-Key `sky_panorama`, den der Client beim Laden erzwingt). -## „Prozedural (Standard)“ = Index 0 → Feld WIEDER ENTFERNEN (alte Maps -## unverändert — kein "prozedural"-Wert im doc). Sofort-Neuaufbau wie Wetter. +## Skybox-Auswahl: Eintrag → doc["weather_sky"] = Katalog-ID (ein Speicherfeld, +## checksum-neutral wie tint/gebaeude). „Prozedural (Standard)“ = Index 0 → Feld +## ENTFERNEN (alte Maps unverändert). Mit Undo/Redo (History-Block weather_sky). +## Sofort-Neuaufbau wie Wetter — der Viewport zeigt exakt, was der Client zeigt. func _on_sky_changed(index: int) -> void: if _loading_ui_values or main.doc.is_empty(): return - if index == 1: - main.doc["weather_sky"] = "sky_panorama" - else: + var neu := "" if index <= 0 else str(SkyboxKatalog.EINTRAEGE[index - 1]["id"]) + var alt := str(main.doc.get("weather_sky", "")) + if neu == alt: + return + var before := {"weather_sky": (alt if main.doc.has("weather_sky") else null)} + if neu == "": 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.call("_refresh_file_label") + main.call("_refresh_history_buttons") main.view_builder.call("rebuild", main.doc, main.manifest) 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 @@ -202,8 +227,14 @@ func refresh_values() -> void: var weathers := ["mittag", "abend", "regen", "nacht"] var weather := str(main.doc.get("weather", "mittag")) prop_weather.select(maxi(0, weathers.find(weather))) - # Himmel: Pack-Skybox ausgewählt ↔ doc["weather_sky"] == "sky_panorama". - prop_sky.select(1 if str(main.doc.get("weather_sky", "")) == "sky_panorama" else 0) + # Himmel: Auswahl ↔ doc["weather_sky"] (Katalog-ID; unbekannt/leer → Standard). + 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", {}) terrain_info.text = "Größe: %.0f × %.0f m\nAuflösung: %d × %d Höhenpunkte\nHöhenbereich: %.0f bis %.0f m" % [ diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index a1a1d58..d8d6835 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -222,29 +222,25 @@ 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 + # Katalog-ID (weather_sky) > Preset-Key `sky_panorama` > ProceduralSky. + var pano_id := "" 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) + 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=sky_panorama, aber Pack-Skybox fehlt (%s) — Fallback ProceduralSky" % pfad) + 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"] @@ -266,7 +262,9 @@ func _build_environment(weather: String, sky_override: String = "") -> void: # 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_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() diff --git a/tests/test_skybox.gd b/tests/test_skybox.gd index 748b4ff..fab053f 100644 --- a/tests/test_skybox.gd +++ b/tests/test_skybox.gd @@ -50,6 +50,7 @@ func _run_all() -> void: await _t1_validierung() await _t2_panel() await _t3_vorschau() + await _t4_auswahl_undo() _finish() @@ -62,14 +63,14 @@ func _t1_validierung() -> void: var f0: PackedStringArray = SvmapIO.validate_map(alte) _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). - for wert in ["sky_panorama", "prozedural"]: + for wert in ["sky_panorama", "prozedural", "sky_03", "sky_115"]: var d: Dictionary = main.doc.duplicate(true) d["weather_sky"] = wert SvmapIO.sign_map(d) var f: PackedStringArray = SvmapIO.validate_map(d) _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). - for wert in ["panorama", 42]: + for wert in ["panorama", "sky_999", 42]: var d2: Dictionary = main.doc.duplicate(true) d2["weather_sky"] = wert var f2: PackedStringArray = SvmapIO._validate_core(d2) @@ -86,9 +87,11 @@ func _t2_panel() -> void: _check(prop_sky != null, "T2 prop_sky existiert") if prop_sky == null: return - _check(prop_sky.item_count == 2, "T2 Himmel-Optionen: 2 Einträge (ist %d)" % prop_sky.item_count) - _check(str(prop_sky.get_item_text(1)).contains("Pack-Skybox"), - "T2 Eintrag 2 = Pack-Skybox (ist „%s“)" % str(prop_sky.get_item_text(1))) + _check(prop_sky.item_count == 1 + SkyboxKatalog.EINTRAEGE.size(), + "T2 Himmel-Optionen: Standard + %d Katalog-Einträge (ist %d)" % [SkyboxKatalog.EINTRAEGE.size(), prop_sky.item_count]) + _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. main.doc["weather_sky"] = "sky_panorama" map_panel.call("refresh_values") @@ -147,6 +150,56 @@ func _t3_vorschau() -> void: "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: for child in vb.get_children(): if child is WorldEnvironment: