From 84bc08145b99317ad19551ea2cb36865acbca50a Mon Sep 17 00:00:00 2001 From: slyphax Date: Thu, 15 Oct 2026 11:34:32 +0200 Subject: [PATCH] =?UTF-8?q?M2:=20Map-Editor=20MVP=20App-Skelett=20?= =?UTF-8?q?=E2=80=94=20svmap=20laden/speichern=20(V1-V14),=20Viewport-Grun?= =?UTF-8?q?dgeruest,=20Neue-Map-Dialog=20(t=5F5badf371)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 6 + README.md | 33 ++ icon.svg | 6 + icon.svg.import | 43 +++ library/objects.json | 17 + project.godot | 26 ++ scenes/main.tscn | 12 + src/svmap/map_document.gd | 93 +++++ src/svmap/map_document.gd.uid | 1 + src/svmap/svmap_io.gd | 382 +++++++++++++++++++ src/svmap/svmap_io.gd.uid | 1 + src/ui/main.gd | 525 +++++++++++++++++++++++++++ src/ui/main.gd.uid | 1 + src/ui/new_map_dialog.gd | 71 ++++ src/ui/new_map_dialog.gd.uid | 1 + src/viewport/map_view_builder.gd | 224 ++++++++++++ src/viewport/map_view_builder.gd.uid | 1 + src/viewport/orbit_camera.gd | 74 ++++ src/viewport/orbit_camera.gd.uid | 1 + tests/test_editor_svmap.gd | 167 +++++++++ tests/test_editor_svmap.gd.uid | 1 + 21 files changed, 1686 insertions(+) create mode 100644 .gitignore create mode 100644 icon.svg create mode 100644 icon.svg.import create mode 100644 library/objects.json create mode 100644 project.godot create mode 100644 scenes/main.tscn create mode 100644 src/svmap/map_document.gd create mode 100644 src/svmap/map_document.gd.uid create mode 100644 src/svmap/svmap_io.gd create mode 100644 src/svmap/svmap_io.gd.uid create mode 100644 src/ui/main.gd create mode 100644 src/ui/main.gd.uid create mode 100644 src/ui/new_map_dialog.gd create mode 100644 src/ui/new_map_dialog.gd.uid create mode 100644 src/viewport/map_view_builder.gd create mode 100644 src/viewport/map_view_builder.gd.uid create mode 100644 src/viewport/orbit_camera.gd create mode 100644 src/viewport/orbit_camera.gd.uid create mode 100644 tests/test_editor_svmap.gd create mode 100644 tests/test_editor_svmap.gd.uid diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bcad1fe --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Godot +.godot/ +*.tmp + +# System +.DS_Store diff --git a/README.md b/README.md index f0fd30e..1105aef 100644 --- a/README.md +++ b/README.md @@ -39,3 +39,36 @@ Konkret heißt das für JEDE Editor-Karte: ## Milestone-Zuordnung M2 (siehe PLAN.md §6): Map-Paket-Format → Editor-MVP → erste offizielle Map (offenes Gelände) → Map-Repo/Release-Workflow über Forgejo (M3). + +## Status (M2, Karte t_5badf371 — App-Skelett FERTIG) + +- **Godot-4.x-Desktop-App:** startet als eigenständiges Fenster (Hauptfenster mit + Menüleiste „Datei“, 3D-Viewport mit Orbit-Kamera, Objekt-Baum links, + Map-Eigenschaften rechts, Statusleiste unten). Kein Spiel-Code-Import. +- **Map-Datenmodell:** 1:1 das svmap-Format v1 (`docs/map_format.md` im + scopedvr-match-server-Repo) — Terrain-Parameter (Heightmap R16/LE/Base64), + Objekt-Liste, Spawn-Zonen (Team 0/1), Metadaten + Wetter-Preset. + Implementierung: `src/svmap/svmap_io.gd` (Validierung V1–V14 wie der + Server-Loader, identische Kanonisierung/Prüfsumme — interop-getestet in + beide Richtungen), `src/svmap/map_document.gd` (Neue-Map-Defaults). +- **Laden/Speichern:** `.svmap` über Datei-Dialog; speichern validiert VOR dem + Schreiben und signiert (sha256 über kanonisierte Payload). Speichern → + Laden → Speichern ist byte-identisch. Ungültige Maps werden mit klaren, + deutschsprachigen Meldungen abgelehnt. +- **Viewport-Grundgerüst:** Orbit/Zoom/Pan per Maus (kein Tastenkürzel nötig), + Maßstabsgitter + Boden-Referenz in Terrain-Größe, Platzhalter-Meshes für + Bibliotheks-Objekte, Spawn-Marker teamfarbig mit Beschriftung, + Wetter-Preset färbt Licht/Himmel. +- **Neue-Map-Dialog:** Name, Größe (512/1024/2048 m), Wetter-Preset. Neue Maps + sind sofort speicherbar (4 Default-Spawn-Punkte pro Team, V11 erfüllt). +- **Tests:** `tests/test_editor_svmap.gd` (31 Checks: Roundtrip byte-identisch, + V2–V13-Negativfälle, Poly-Budget, Server-Kompatibilität der Prüfsumme). + Lauf: `godot --headless --path . --script res://tests/test_editor_svmap.gd` + +**Bibliothek:** `library/objects.json` ist die Editor-Kopie der offiziellen +Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier +synchron ziehen (IDs sind der Vertrag, Entscheidung 20). + +**Noch offen (folgende Karten der MVP-Kette):** Terrain-Werkzeuge +(heben/senken/glätten), Objekt-Platzierung (Bibliothek-Browser), +Spawn-Zonen-Editor, Performance-Check-UI, Export-Workflow. diff --git a/icon.svg b/icon.svg new file mode 100644 index 0000000..f3475d2 --- /dev/null +++ b/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/icon.svg.import b/icon.svg.import new file mode 100644 index 0000000..2d7af42 --- /dev/null +++ b/icon.svg.import @@ -0,0 +1,43 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b6p16m4tdfcc8" +path="res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://icon.svg" +dest_files=["res://.godot/imported/icon.svg-218a8f2b3041327d8a5756f3a245f83b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 +svg/scale=1.0 +editor/scale_with_editor_scale=false +editor/convert_colors_with_editor_theme=false diff --git a/library/objects.json b/library/objects.json new file mode 100644 index 0000000..2d670fc --- /dev/null +++ b/library/objects.json @@ -0,0 +1,17 @@ +{ + "library_version": 1, + "note": "Editor-Kopie der offiziellen Bibliotheks-Registry (geschlossener Baukasten, PLAN Entscheidung 20). Quelle: scopedvr-match-server library/objects.json — beim Registrieren neuer Objekte DORT zuerst eintragen und hier synchron ziehen. 'poly' = Referenz-Dreieckszahl (Budget-Rechnung, map_format.md §4 V8); 'collision' = Approximation für Platzhalter-Darstellung im Viewport.", + "objects": { + "crate_wood": { "poly": 12, "collision": { "shape": "box", "size": [1.2, 1.2, 1.2] } }, + "crate_metal": { "poly": 12, "collision": { "shape": "box", "size": [1.0, 1.0, 1.0] } }, + "wall_concrete": { "poly": 12, "collision": { "shape": "box", "size": [4.0, 2.5, 0.4] } }, + "barrier_sandbag": { "poly": 36, "collision": { "shape": "box", "size": [2.0, 0.9, 0.8] } }, + "bunker_concrete": { "poly": 96, "collision": { "shape": "box", "size": [6.0, 2.8, 4.0] } }, + "watchtower_wood": { "poly": 240, "collision": { "shape": "box", "size": [3.0, 6.0, 3.0] } }, + "fence_metal": { "poly": 24, "collision": { "shape": "box", "size": [4.0, 1.8, 0.15] } }, + "rock_large": { "poly": 128, "collision": { "shape": "sphere", "radius": 1.5 } }, + "boulder_small": { "poly": 96, "collision": { "shape": "sphere", "radius": 0.8 } }, + "tree_pine": { "poly": 180, "collision": { "shape": "cylinder", "radius": 0.4, "height": 12.0 } }, + "bush_small": { "poly": 64, "collision": { "shape": "sphere", "radius": 0.8 } } + } +} diff --git a/project.godot b/project.godot new file mode 100644 index 0000000..68da78f --- /dev/null +++ b/project.godot @@ -0,0 +1,26 @@ +; Engine configuration file — ScopedVR Map Editor (M2, App-Skelett) +; Desktop-App (kein XR) — Creator bauen Maps ohne Godot-Editor zu sehen. + +config_version=5 + +[application] + +config/name="ScopedVR Map Editor" +config/description="Standalone-Editor für ScopedVR-Maps (.svmap). Nutzbar ohne Godot-Kenntnisse." +run/main_scene="res://scenes/main.tscn" +config/features=PackedStringArray("4.7") +config/icon="res://icon.svg" + +[display] + +window/size/viewport_width=1600 +window/size/viewport_height=900 +window/stretch/mode="disabled" + +[editor_plugins] + +enabled=PackedStringArray() + +[rendering] + +anti_aliasing/quality/msaa_3d=2 diff --git a/scenes/main.tscn b/scenes/main.tscn new file mode 100644 index 0000000..dec2022 --- /dev/null +++ b/scenes/main.tscn @@ -0,0 +1,12 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://src/ui/main.gd" id="1_main"] + +[node name="Main" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_main") diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd new file mode 100644 index 0000000..44d26de --- /dev/null +++ b/src/svmap/map_document.gd @@ -0,0 +1,93 @@ +extends RefCounted +class_name SvmapDocument +## Map-Datenmodell des Editors: ein Dictionary, 1:1 das svmap-Format v1 +## (docs/map_format.md im Match-Server-Repo). Kein internes Zwischenformat → +## kein Konverter, Speichern/Laden direkt auf .svmap (README Architektur). +## +## Alles static + reine Funktionen: der Besitzer des Dicts ist main.gd. + +const DEFAULT_RESOLUTION := 257 # mittleres Grid (17–513 erlaubt) +const DEFAULT_MIN_HEIGHT := 0.0 +const DEFAULT_MAX_HEIGHT := 50.0 +const DEFAULT_TARGET_GAME_VERSION := "0.1.0" +const DEFAULT_AUTHOR := "ScopedVR Creator" + + +## Neue, leere Map erzeugen. Bedingung: Sie muss SOFORT speicherbar sein +## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte +## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen- +## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern. +static func create_new(map_name: String, size_m: float, weather: String) -> Dictionary: + var size := maxf(MIN_SIZE_M, minf(size_m, MAX_SIZE_M)) + var res := DEFAULT_RESOLUTION + var zero_heights := PackedByteArray() + zero_heights.resize(res * res * 2) # alles 0 → flaches Terrain auf min_height + var half := size / 2.0 + var data: Dictionary = { + "format_version": SvmapIO.FORMAT_VERSION, + "info": { + "name": map_name, + "author": DEFAULT_AUTHOR, + "description": "", + "version": 1, + "created_utc": Time.get_datetime_string_from_system(true) + "Z", + "target_game_version": DEFAULT_TARGET_GAME_VERSION, + }, + "terrain": { + "type": "heightmap", + "size_x_m": size, + "size_z_m": size, + "min_height_m": DEFAULT_MIN_HEIGHT, + "max_height_m": DEFAULT_MAX_HEIGHT, + "resolution": res, + "heightmap_r16_b64": Marshalls.raw_to_base64(zero_heights), + }, + "objects": [], + "spawn_zones": _default_spawn_zones(size), + "bounds": { + "min": [-half, -10.0, -half], + "max": [half, 80.0, half], + }, + "weather": weather, + } + return data + + +const MIN_SIZE_M := 256.0 +const MAX_SIZE_M := 4096.0 + + +## Default-Spawns: 4 Punkte je Team als Streck vor den gegenüberliegenden +## Kanten (Team Rot = Osten, Team Blau = Westen — Anzeige-Sache, MVP-Skelett). +static func _default_spawn_zones(size: float) -> Dictionary: + var half := size / 2.0 + var x_blue := -half * 0.8 + var x_red := half * 0.8 + var zs := [-0.3, -0.1, 0.1, 0.3] + var team0_points: Array = [] + var team1_points: Array = [] + for i in zs.size(): + var z: float = zs[i] * size + team0_points.append({"id": "A%d" % (i + 1), "pos": [x_blue, 0.2, z]}) + team1_points.append({"id": "B%d" % (i + 1), "pos": [x_red, 0.2, z]}) + return { + "0": {"label": "Team Blau", "points": team0_points}, + "1": {"label": "Team Rot", "points": team1_points}, + } + + +## Objekt-Anzahl (für Statusleiste/Budget-Anzeige). +static func object_count(data: Dictionary) -> int: + return (data.get("objects", []) as Array).size() + + +## Verbrauchtes Poly-Budget (Σ poly × scale² — wie V8b im Loader). +static func poly_usage(data: Dictionary, manifest: Dictionary) -> float: + var lib: Dictionary = manifest.get("objects", {}) + var total := 0.0 + for obj in (data.get("objects", []) as Array): + if obj is Dictionary and lib.has(str(obj.get("id", ""))): + var poly := float(lib[str(obj["id"])].get("poly", 0)) + var scale := float(obj.get("scale", 1.0)) + total += poly * scale * scale + return total diff --git a/src/svmap/map_document.gd.uid b/src/svmap/map_document.gd.uid new file mode 100644 index 0000000..79c3c6e --- /dev/null +++ b/src/svmap/map_document.gd.uid @@ -0,0 +1 @@ +uid://cmyaqvftm41uf diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd new file mode 100644 index 0000000..7e3ae86 --- /dev/null +++ b/src/svmap/svmap_io.gd @@ -0,0 +1,382 @@ +extends RefCounted +class_name SvmapIO +## SvmapIO — Editor-seitige Implementierung des `.svmap`-Map-Paket-Formats (v1). +## +## Grundlage: docs/map_format.md im scopedvr-match-server-Repo (Karte t_74976e9f). +## Bewusst EIGENE Implementierung (kein Match-Server-Import): das Format-Doku +## ist der Vertrag. Validierungs-IDs V1..V14 und Kanonisierung/Prüfsumme sind +## 1:1 zur Doku übernommen, damit eine vom Editor gespeicherte Map vom +## Match-Server akzeptiert wird (und umgekehrt). +## +## API (alles static, testbar ohne UI): +## parse_and_validate(text, manifest = {}) -> {ok, errors, data} +## load_from_file(path) -> {ok, errors, data} +## save_to_file(path, data) -> {ok, errors} (validiert VOR dem Schreiben) +## validate_map(data, manifest) -> PackedStringArray +## sign_map(data) -> void (setzt/erneuert Prüfsumme) + +const FORMAT_VERSION := 1 +const WEATHER_PRESETS: Array[String] = ["mittag", "abend", "regen", "nacht"] + +# Perf-/Plausibilitäts-Limits — identisch zum Server-Loader (docs/map_format.md §4). +const MAX_FILE_BYTES := 50 * 1024 * 1024 # V1 +const MAX_OBJECTS := 5000 # V8a +const MAX_TOTAL_POLY := 1_500_000 # V8b (× scale² gewichtet) +const MAX_TERRAIN_RESOLUTION := 513 # V6 +const MIN_TERRAIN_RESOLUTION := 17 +const MAX_TERRAIN_SIZE_M := 8192.0 # V6/V14 +const MIN_TERRAIN_SIZE_M := 16.0 +const MAX_HEIGHT_RANGE_M := 500.0 # V6 +const MAX_BOUNDS_EXTENT_M := 8192.0 # V14 +const BOUNDS_TOLERANCE_M := 1.0 # V9 +const MIN_SPAWN_POINTS_PER_TEAM := 4 # V11 (Entscheidung 11) + +const MANIFEST_PATH := "res://library/objects.json" +const JSON_INDENT := " " + + +static func _add(result: Dictionary, msg: String) -> void: + (result["errors"] as Array).append(msg) + + +# --------------------------------------------------------------- Öffentlich -- + +## Komplette Pipeline: JSON-Text → Validierung (inkl. Prüfsumme) → Map-Daten. +## errors bewusst als Array: Godot 4 kopiert PackedArrays beim Dict-Zugriff, +## Mutationen würden sonst verloren gehen. +static func parse_and_validate(text: String, manifest: Dictionary = {}) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}} + if text.length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [text.length(), MAX_FILE_BYTES]) + return result + var parsed: Variant = JSON.parse_string(text) + if parsed == null or not (parsed is Dictionary): + _add(result, "V2: Kein gültiges JSON-Objekt (Datei beschädigt oder keine .svmap?).") + return result + var data: Dictionary = parsed + var errors := validate_map(data, manifest) + if not errors.is_empty(): + result["errors"] = Array(errors) + return result + result["ok"] = true + result["data"] = data + return result + + +static func load_from_file(path: String) -> Dictionary: + var result := {"ok": false, "errors": [], "data": {}} + if not FileAccess.file_exists(path): + _add(result, "Datei nicht gefunden: %s" % path) + return result + var fa := FileAccess.open(path, FileAccess.READ) + if fa == null: + _add(result, "Datei nicht lesbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return result + if fa.get_length() > MAX_FILE_BYTES: + _add(result, "V1: Map-Datei zu groß (%d Bytes, Limit %d)." % [fa.get_length(), MAX_FILE_BYTES]) + return result + return parse_and_validate(fa.get_as_text()) + + +## Kern-Validierung → Kanonisieren → Signieren → Schreiben. Nur gültige Maps +## landen auf Disk. Kanonisieren VOR dem Schreiben (alle Zahlen → float): +## macht Dateien deterministisch — speichern→laden→speichern bleibt +## byte-identisch, weil Godots JSON-Parser ints ohnehin als float zurückgibt. +## Kompatibel zum Server: dessen Prüfsumme läuft über die kanonisierte Payload, +## int- und float-Form kanonisieren zum selben Hash. +static func save_to_file(path: String, data: Dictionary) -> Dictionary: + var errors := _validate_core(data) + if not errors.is_empty(): + return {"ok": false, "errors": errors} + var canonical: Dictionary = _canonicalize(data) + sign_map(canonical) + var fa := FileAccess.open(path, FileAccess.WRITE) + if fa == null: + var out := {"ok": false, "errors": []} + _add(out, "Datei nicht schreibbar: %s (Fehler %d)" % [path, FileAccess.get_open_error()]) + return out + fa.store_string(JSON.stringify(canonical, JSON_INDENT)) + return {"ok": true, "errors": PackedStringArray()} + + +## Validierung OHNE Prüfsumme (V3–V12 + V14); validate_map = Kern + Prüfsumme. +## manifest = Bibliotheks-Registry (leer → Default aus res://library/objects.json). +static func validate_map(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: + var errors := _validate_core(data, manifest) + if not errors.is_empty(): + return errors + return _validate_checksum(data) + + +## Setzt/erneuert die Prüfsumme (save = neue Map-Version). +static func sign_map(data: Dictionary) -> void: + data["checksum"] = {"algorithm": "sha256", "value": _payload_sha256(data)} + + +# ------------------------------------------------------------- Validierung -- + +static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> PackedStringArray: + var errors := PackedStringArray() + + # V3: Versions-Stempel + var fv = data.get("format_version", null) + if not (fv is int or fv is float) or int(fv) != FORMAT_VERSION: + errors.append("V3: format_version muss %d sein (ist %s). Diese Datei ist keine .svmap-Map v1." % [FORMAT_VERSION, str(fv)]) + return errors # Ohne passende Version ist alles Weitere sinnlos. + + # V4: Pflichtblöcke + Typen (checksum prüft validate_map separat). + for key in ["info", "terrain", "spawn_zones", "bounds"]: + if not data.has(key) or not (data[key] is Dictionary): + errors.append("V4: Pflichtblock '%s' fehlt oder ist kein Objekt." % key) + if not data.has("objects") or not (data["objects"] is Array): + errors.append("V4: Pflichtblock 'objects' fehlt oder ist kein Array.") + if not errors.is_empty(): + return errors + + errors.append_array(_validate_info(data["info"])) + errors.append_array(_validate_terrain(data["terrain"])) + errors.append_array(_validate_bounds(data["bounds"])) + + var terrain: Dictionary = data["terrain"] + var bounds: Dictionary = data["bounds"] + errors.append_array(_validate_objects(data["objects"], bounds, manifest)) + errors.append_array(_validate_spawns(data["spawn_zones"], bounds)) + + # V12: Wetter (optional, aber dann gültig) + var weather = data.get("weather", "") + if weather != null and str(weather) != "": + if not (weather is String) or not WEATHER_PRESETS.has(weather): + errors.append("V12: weather '%s' ist kein gültiges Preset (%s)." % [ + str(weather), ", ".join(WEATHER_PRESETS)]) + return errors + + +static func _validate_info(info: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var name_v = info.get("name", "") + if not (name_v is String) or (name_v as String).strip_edges().is_empty() \ + or (name_v as String).length() > 64: + errors.append("V4: info.name muss 1–64 Zeichen lang sein.") + var author = info.get("author", "") + if not (author is String) or (author as String).strip_edges().is_empty(): + errors.append("V4: info.author darf nicht leer sein.") + var version = info.get("version", 0) + if not (version is int or version is float) or int(version) < 1: + errors.append("V4: info.version muss eine Zahl ≥ 1 sein.") + var tgv = info.get("target_game_version", "") + if tgv != null and str(tgv) != "" and not (tgv is String): + errors.append("V4: info.target_game_version muss ein String sein.") + return errors + + +static func _validate_terrain(terrain: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if terrain.get("type", "") != "heightmap": + errors.append("V4: terrain.type muss 'heightmap' sein.") + return errors + var res = terrain.get("resolution", 0) + if not (res is int or res is float) or int(res) < MIN_TERRAIN_RESOLUTION \ + or int(res) > MAX_TERRAIN_RESOLUTION: + errors.append("V6: terrain.resolution muss %d–%d sein (ist %s)." % [ + MIN_TERRAIN_RESOLUTION, MAX_TERRAIN_RESOLUTION, str(res)]) + return errors + var sx = terrain.get("size_x_m", 0.0) + var sz = terrain.get("size_z_m", 0.0) + for pair in [["size_x_m", sx], ["size_z_m", sz]]: + if not _is_finite_num(pair[1]) or float(pair[1]) < MIN_TERRAIN_SIZE_M \ + or float(pair[1]) > MAX_TERRAIN_SIZE_M: + errors.append("V6: terrain.%s muss %.0f–%.0f m sein (ist %s)." % [ + pair[0], MIN_TERRAIN_SIZE_M, MAX_TERRAIN_SIZE_M, str(pair[1])]) + var hmin = terrain.get("min_height_m", 0.0) + var hmax = terrain.get("max_height_m", 0.0) + if not _is_finite_num(hmin) or not _is_finite_num(hmax) or float(hmin) >= float(hmax) \ + or (float(hmax) - float(hmin)) > MAX_HEIGHT_RANGE_M: + errors.append("V6: terrain.min_height_m < max_height_m (Spannweite ≤ %.0f m) verletzt." % MAX_HEIGHT_RANGE_M) + # V5: Höhenfeld-Länge + var res_i := int(res) + var raw: PackedByteArray = Marshalls.base64_to_raw(str(terrain.get("heightmap_r16_b64", ""))) + if raw.size() != res_i * res_i * 2: + errors.append("V5: heightmap_r16_b64 dekodiert zu %d Bytes, erwartet %d (= resolution² × 2)." % [ + raw.size(), res_i * res_i * 2]) + return errors + + +static func _validate_bounds(bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var mn = bounds.get("min", null) + var mx = bounds.get("max", null) + if not (mn is Array) or not (mx is Array) or (mn as Array).size() != 3 \ + or (mx as Array).size() != 3: + errors.append("V4: bounds.min/max müssen je [x, y, z] sein.") + return errors + for i in 3: + if not _is_finite_num(mn[i]) or not _is_finite_num(mx[i]): + errors.append("V9: bounds enthalten nicht-endliche Werte.") + return errors + if float(mx[i]) <= float(mn[i]): + errors.append("V14: bounds.max[%d] muss > bounds.min[%d] sein." % [i, i]) + var extent := Vector3(float(mx[0]) - float(mn[0]), float(mx[1]) - float(mn[1]), float(mx[2]) - float(mn[2])) + if extent.length() > MAX_BOUNDS_EXTENT_M: + errors.append("V14: bounds-Diagonale %.0f m > %.0f m." % [extent.length(), MAX_BOUNDS_EXTENT_M]) + return errors + + +static func _validate_objects(objects: Array, bounds: Dictionary, manifest: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if objects.size() > MAX_OBJECTS: + errors.append("V8: %d Objekte > Limit %d." % [objects.size(), MAX_OBJECTS]) + return errors + var lib: Dictionary = _manifest_or(manifest).get("objects", {}) + var total_poly := 0.0 + for i in objects.size(): + var obj = objects[i] + if not (obj is Dictionary): + errors.append("V4: objects[%d] ist kein Objekt." % i) + continue + var oid = obj.get("id", "") + if not (oid is String) or not lib.has(oid): + errors.append("V7: objects[%d]: unbekannte Objekt-ID '%s' (nicht in der Bibliothek)." % [i, str(oid)]) + continue + var pos = obj.get("pos", null) + if not (pos is Array) or (pos as Array).size() != 3 \ + or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]): + errors.append("V9: objects[%d]: pos muss [x, y, z] mit endlichen Werten sein." % i) + elif not _inside_bounds(pos, bounds): + errors.append("V9: objects[%d] (%s) liegt außerhalb der Match-Grenzen." % [i, str(oid)]) + var scale = obj.get("scale", 1.0) + if not _is_finite_num(scale) or float(scale) < 0.05 or float(scale) > 50.0: + errors.append("V10: objects[%d]: scale muss 0.05–50 sein (ist %s)." % [i, str(scale)]) + continue + var rot = obj.get("rot_y_deg", 0.0) + if not _is_finite_num(rot): + errors.append("V9: objects[%d]: rot_y_deg muss endlich sein." % i) + # V8b: Poly-Budget (× scale² — Flächen-/Kantenwachstum der Meshes) + var poly = float(lib[oid].get("poly", 0)) + total_poly += poly * float(scale) * float(scale) + if total_poly > MAX_TOTAL_POLY: + errors.append("V8: Poly-Budget überschritten (%.0f > %d)." % [total_poly, MAX_TOTAL_POLY]) + return errors + + +static func _validate_spawns(zones: Dictionary, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + var seen_ids := {} + for team in [0, 1]: + var key := str(team) + if not zones.has(key) or not (zones[key] is Dictionary): + errors.append("V11: spawn_zones für Team %d fehlt." % team) + continue + var points = zones[key].get("points", []) + if not (points is Array) or points.size() < MIN_SPAWN_POINTS_PER_TEAM: + errors.append("V11: Team %d hat %s Spawn-Punkte, mindestens %d nötig." % [ + team, str(points.size()), MIN_SPAWN_POINTS_PER_TEAM]) + continue + for i in points.size(): + var p = points[i] + if not (p is Dictionary): + errors.append("V11: Team %d Punkt %d ist kein Objekt." % [team, i]) + continue + var pid = p.get("id", "") + if not (pid is String) or (pid as String).strip_edges().is_empty(): + errors.append("V11: Team %d Punkt %d: id fehlt/leer." % [team, i]) + elif seen_ids.has(pid): + errors.append("V11: Spawn-ID '%s' ist doppelt (Team %d, Punkt %d)." % [pid, team, i]) + else: + seen_ids[pid] = true + var pos = p.get("pos", null) + if not (pos is Array) or (pos as Array).size() != 3 \ + or not _is_finite_num(pos[0]) or not _is_finite_num(pos[1]) or not _is_finite_num(pos[2]): + errors.append("V9: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(pid)) + elif not _inside_bounds(pos, bounds): + errors.append("V9: Spawn '%s' liegt außerhalb der Match-Grenzen." % str(pid)) + return errors + + +static func _validate_checksum(data: Dictionary) -> PackedStringArray: + if not data.has("checksum") or not (data["checksum"] is Dictionary): + return PackedStringArray(["V4: checksum-Block fehlt oder ist kein Objekt."]) + var cs: Dictionary = data["checksum"] + var algo = cs.get("algorithm", "") + var value = cs.get("value", "") + if algo != "sha256": + return PackedStringArray(["V13: checksum.algorithm muss 'sha256' sein (ist %s)." % str(algo)]) + if not (value is String) or not (value as String).is_valid_hex_number() \ + or (value as String).length() != 64: + return PackedStringArray(["V13: checksum.value muss ein 64-Zeichen-Hex-String sein."]) + var expected := _payload_sha256(data) + if (value as String).to_lower() != expected: + return PackedStringArray(["V13: Prüfsumme stimmt nicht — die Map wurde nach dem Speichern verändert oder ist beschädigt."]) + return PackedStringArray() + + +# ---------------------------------------------------------------- Prüfung -- + +## SHA-256 über die kanonisierte Payload (alles außer 'checksum'). +## Kanonisierung: alle Zahlen rekursiv → float (sonst unterscheiden sich +## save- und load-seitiges JSON.stringify bei int/float — docs/map_format.md §5). +## EXAKT dieselbe Prozedur wie der Server-Loader → Prüsummen sind kompatibel. +static func _payload_sha256(data: Dictionary) -> String: + var payload: Dictionary = _canonicalize(data) + payload.erase("checksum") + var text := JSON.stringify(payload, JSON_INDENT) + return text.sha256_text() + + +static func _canonicalize(value: Variant) -> Variant: + if value is Array: + var arr := [] + for v in value: + arr.append(_canonicalize(v)) + return arr + if value is Dictionary: + var dict := {} + for k in value.keys(): + dict[str(k)] = _canonicalize(value[k]) + return dict + if value is int: + return float(value) + if value is bool or value is String or value is float or value == null: + return value + # PackedByteArray & Co. dürfen im svmap-Dict nicht vorkommen — als String. + return str(value) + + +static func _is_finite_num(v: Variant) -> bool: + return (v is int or v is float) and is_finite(float(v)) + + +static func _inside_bounds(pos: Array, bounds: Dictionary) -> bool: + var mn: Array = bounds["min"] + var mx: Array = bounds["max"] + for i in 3: + if float(pos[i]) < float(mn[i]) - BOUNDS_TOLERANCE_M \ + or float(pos[i]) > float(mx[i]) + BOUNDS_TOLERANCE_M: + return false + return true + + +# ------------------------------------------------------------------ Utils -- + +static func _manifest_or(manifest: Dictionary) -> Dictionary: + if not manifest.is_empty(): + return manifest + if FileAccess.file_exists(MANIFEST_PATH): + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(MANIFEST_PATH)) + if parsed is Dictionary: + return parsed + return {} + + +## Höhenfeld dekodieren: uint16 little-endian, row-major, absolut in Metern. +## h = min_height + (v / 65535) * (max_height - min_height) +static func decode_heights(terrain: Dictionary) -> PackedFloat32Array: + var res := int(terrain["resolution"]) + var raw := Marshalls.base64_to_raw(str(terrain["heightmap_r16_b64"])) + var hmin := float(terrain["min_height_m"]) + var hmax := float(terrain["max_height_m"]) + var out := PackedFloat32Array() + out.resize(res * res) + for i in res * res: + var v: int = raw.decode_u16(i * 2) + out[i] = hmin + (float(v) / 65535.0) * (hmax - hmin) + return out diff --git a/src/svmap/svmap_io.gd.uid b/src/svmap/svmap_io.gd.uid new file mode 100644 index 0000000..3ef96d6 --- /dev/null +++ b/src/svmap/svmap_io.gd.uid @@ -0,0 +1 @@ +uid://b0n1rh37a72rd diff --git a/src/ui/main.gd b/src/ui/main.gd new file mode 100644 index 0000000..eecebfe --- /dev/null +++ b/src/ui/main.gd @@ -0,0 +1,525 @@ +extends Control +## ScopedVR Map-Editor — Hauptfenster (M2 MVP-Skelett, Karte t_5badf371). +## +## Alles UI wird hier per Code gebaut (eine einzige .tscn mit Root-Knoten). +## Struktur: Top-Bar (Datei-Menü) | links Objekt-Baum + Budget | Mitte 3D-Viewport +## | rechts Map-Eigenschaften | unten Statusleiste. +## +## MVP-Umfang: .svmap laden/speichern (V1–V14, wie im Server-Loader), neue Map, +## Map-Metadaten editieren, Orbit-Viewport mit Gitter/Boden. Terrain-Werkzeuge +## und Objekt-Platzierung kommen mit eigenen Karten (MVP-Kette im README). + +const APP_TITLE := "ScopedVR Map-Editor" + +var manifest: Dictionary = {} +var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format) +var current_path := "" # leer = noch nie gespeichert +var dirty := false + +# UI-Referenzen +var content_tree: Tree +var stats_objects: Label +var stats_poly: Label +var status_label: Label +var file_label: Label +var view_builder: Node3D +var camera: Camera3D + +# Eigenschaften-Panel +var prop_name: LineEdit +var prop_author: LineEdit +var prop_desc: LineEdit +var prop_version: SpinBox +var prop_weather: OptionButton +var terrain_info: Label + +# Dialoge +var new_dialog: AcceptDialog +var open_dialog: FileDialog +var save_dialog: FileDialog +var error_dialog: AcceptDialog +var error_text: Label + +var _loading_ui_values := false # verhindert dirty beim Auffüllen der Felder + + +func _ready() -> void: + manifest = _load_manifest() + _build_ui() + _start_with_new_map() + + +func _load_manifest() -> Dictionary: + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH)) + if parsed is Dictionary: + return parsed + push_error("Bibliothek library/objects.json konnte nicht geladen werden!") + return {} + + +# ------------------------------------------------------------------ UI-Bau -- + +func _build_ui() -> void: + var theme := Theme.new() + theme.default_font_size = 16 + self.theme = theme + + var vbox := VBoxContainer.new() + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + vbox.add_theme_constant_override("separation", 4) + add_child(vbox) + + # --- Top-Bar --- + var top := PanelContainer.new() + var top_h := HBoxContainer.new() + top_h.add_theme_constant_override("separation", 10) + top.add_child(top_h) + vbox.add_child(top) + + var file_menu := MenuButton.new() + file_menu.text = "Datei" + file_menu.flat = false + file_menu.custom_minimum_size = Vector2(90, 40) + var popup := file_menu.get_popup() + popup.add_item("Neue Map…", 0) + popup.add_item("Öffnen…", 1) + popup.add_separator() + popup.add_item("Speichern", 2) + popup.add_item("Speichern unter…", 3) + popup.add_separator() + popup.add_item("Beenden", 4) + popup.id_pressed.connect(_on_menu_action) + top_h.add_child(file_menu) + + var title_label := Label.new() + title_label.text = " %s" % APP_TITLE + title_label.add_theme_font_size_override("font_size", 20) + top_h.add_child(title_label) + + var spacer := Control.new() + spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL + top_h.add_child(spacer) + + file_label = Label.new() + file_label.text = "" + file_label.modulate = Color(1, 1, 1, 0.6) + top_h.add_child(file_label) + + # --- Hauptbereich --- + var split := HSplitContainer.new() + split.size_flags_vertical = Control.SIZE_EXPAND_FILL + split.size_flags_horizontal = Control.SIZE_EXPAND_FILL + split.split_offset = -320 + split.collapsed = true # rechte Eigenschaften-Klappen via "expand"-Flag unten + vbox.add_child(split) + + split.add_child(_build_left_panel()) + split.add_child(_build_center_viewport()) + split.add_child(_build_right_panel()) + + # --- Statusleiste --- + var status_panel := PanelContainer.new() + var status_h := HBoxContainer.new() + status_h.add_theme_constant_override("separation", 16) + status_panel.add_child(status_h) + vbox.add_child(status_panel) + + status_label = Label.new() + status_label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + status_label.clip_text = true + status_h.add_child(status_label) + + stats_objects = Label.new() + stats_objects.modulate = Color(1, 1, 1, 0.75) + status_h.add_child(stats_objects) + + stats_poly = Label.new() + stats_poly.modulate = Color(1, 1, 1, 0.75) + status_h.add_child(stats_poly) + + # --- Dialoge --- + new_dialog = preload("res://src/ui/new_map_dialog.gd").new() + new_dialog.confirmed.connect(_on_new_map_confirmed) + add_child(new_dialog) + + open_dialog = FileDialog.new() + open_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE + open_dialog.access = FileDialog.ACCESS_FILESYSTEM + open_dialog.title = "Map öffnen" + open_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"]) + open_dialog.size = Vector2i(800, 560) + open_dialog.file_selected.connect(_on_open_selected) + add_child(open_dialog) + + save_dialog = FileDialog.new() + save_dialog.file_mode = FileDialog.FILE_MODE_SAVE_FILE + save_dialog.access = FileDialog.ACCESS_FILESYSTEM + save_dialog.title = "Map speichern unter" + save_dialog.filters = PackedStringArray(["*.svmap ; ScopedVR Map"]) + save_dialog.size = Vector2i(800, 560) + save_dialog.file_selected.connect(_on_save_selected) + add_child(save_dialog) + + error_dialog = AcceptDialog.new() + error_dialog.title = "Fehler" + error_dialog.ok_button_text = "Alles klar" + error_text = Label.new() + error_text.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + error_text.custom_minimum_size = Vector2(560, 0) + error_dialog.add_child(error_text) + add_child(error_dialog) + + +func _build_left_panel() -> PanelContainer: + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(280, 0) + var vbox := VBoxContainer.new() + vbox.add_theme_constant_override("separation", 8) + panel.add_child(vbox) + + vbox.add_child(_header_label("Map-Inhalt")) + + content_tree = Tree.new() + content_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL + content_tree.custom_minimum_size = Vector2(0, 200) + content_tree.allow_reselect = false + content_tree.hide_root = true + vbox.add_child(content_tree) + + var hint := Label.new() + hint.text = "Objekte platzieren kommt mit der\nnächsten Version — zuerst Terrain!" + hint.modulate = Color(1, 1, 1, 0.55) + hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(hint) + return panel + + +func _build_center_viewport() -> SubViewportContainer: + var container := SubViewportContainer.new() + container.stretch = true + container.size_flags_horizontal = Control.SIZE_EXPAND_FILL + container.size_flags_vertical = Control.SIZE_EXPAND_FILL + + var viewport := SubViewport.new() + viewport.own_world_3d = true + viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS + container.add_child(viewport) + + var world := Node3D.new() + viewport.add_child(world) + + view_builder = Node3D.new() + view_builder.set_script(load("res://src/viewport/map_view_builder.gd")) + world.add_child(view_builder) + + camera = Camera3D.new() + camera.set_script(load("res://src/viewport/orbit_camera.gd")) + camera.current = true + world.add_child(camera) + + # Bedien-Hinweis im Viewport (Overlay) + var help := Label.new() + help.text = "Maus: Mitte ziehen = drehen · Mausrad = zoomen · Mitte + Shift = verschieben" + help.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) + help.offset_left = 12 + help.offset_bottom = -8 + help.offset_top = -34 + help.modulate = Color(1, 1, 1, 0.65) + container.add_child(help) + return container + + +func _build_right_panel() -> PanelContainer: + var panel := PanelContainer.new() + panel.custom_minimum_size = Vector2(320, 0) + var scroll := ScrollContainer.new() + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + panel.add_child(scroll) + var vbox := VBoxContainer.new() + vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL + vbox.add_theme_constant_override("separation", 8) + scroll.add_child(vbox) + + vbox.add_child(_header_label("Map-Eigenschaften")) + + vbox.add_child(_field_label("Name")) + prop_name = LineEdit.new() + prop_name.max_length = 64 + vbox.add_child(prop_name) + + vbox.add_child(_field_label("Autor")) + prop_author = LineEdit.new() + vbox.add_child(prop_author) + + vbox.add_child(_field_label("Beschreibung")) + prop_desc = LineEdit.new() + vbox.add_child(prop_desc) + + vbox.add_child(_field_label("Version")) + prop_version = SpinBox.new() + prop_version.min_value = 1 + prop_version.max_value = 99999 + prop_version.step = 1 + vbox.add_child(prop_version) + + vbox.add_child(_field_label("Wetter")) + prop_weather = OptionButton.new() + prop_weather.add_item("Mittag (sonnig, klar)") + prop_weather.add_item("Abend (warmes Licht)") + prop_weather.add_item("Regen (grau, kühl)") + prop_weather.add_item("Nacht (dunkel)") + vbox.add_child(prop_weather) + + vbox.add_child(HSeparator.new()) + vbox.add_child(_header_label("Terrain")) + terrain_info = Label.new() + terrain_info.text = "" + terrain_info.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + vbox.add_child(terrain_info) + var note := Label.new() + note.text = "Terrain-Höhen bearbeiten kommt mit\nder nächsten Version." + note.modulate = Color(1, 1, 1, 0.55) + vbox.add_child(note) + + # Signale (Änderungen → Dokument + Ansicht) + prop_name.text_changed.connect(_on_prop_changed.bind("name")) + prop_author.text_changed.connect(_on_prop_changed.bind("author")) + prop_desc.text_changed.connect(_on_prop_changed.bind("description")) + prop_version.value_changed.connect(_on_version_changed) + prop_weather.item_selected.connect(_on_weather_changed) + return panel + + +func _header_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.add_theme_font_size_override("font_size", 19) + label.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0)) + return label + + +func _field_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.modulate = Color(1, 1, 1, 0.7) + return label + + +# ------------------------------------------------------------ Map-Aktionen -- + +func _start_with_new_map() -> void: + doc = SvmapDocument.create_new("Neue Map", 1024.0, "mittag") + current_path = "" + dirty = false + _refresh_all() + _set_status("Willkommen! Es wurde eine neue Map angelegt — über „Datei → Neue Map…“ kannst du sie neu starten.") + + +func _on_menu_action(id: int) -> void: + match id: + 0: # Neue Map + new_dialog.popup_centered() + 1: # Öffnen + open_dialog.popup_centered() + 2: # Speichern + _save() + 3: # Speichern unter + save_dialog.popup_centered() + 4: # Beenden + get_tree().quit() + + +func _on_new_map_confirmed() -> void: + doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather()) + current_path = "" + dirty = false + _refresh_all() + _set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s)." % [ + new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()]) + + +func _on_open_selected(path: String) -> void: + var result := SvmapIO.load_from_file(path) + if not result["ok"]: + _show_errors("Map konnte NICHT geladen werden:", result["errors"]) + _set_status("Laden fehlgeschlagen: %s" % path.get_file(), true) + return + doc = result["data"] + current_path = path + dirty = false + _refresh_all() + var map_name := str(doc.get("info", {}).get("name", "?")) + _set_status("Map „%s“ geladen (%d Objekte)." % [map_name, SvmapDocument.object_count(doc)]) + + +func _save() -> void: + if current_path.is_empty(): + save_dialog.popup_centered() + return + _save_to(current_path) + + +func _on_save_selected(path: String) -> void: + if not path.get_extension().is_empty() and path.get_extension() != "svmap": + path += ".svmap" + _save_to(path) + + +func _save_to(path: String) -> void: + # Felder stehen im Dokument (Live-Bindung) — vor dem Speichern noch einmal + # sicherstellen, dass Name/Autor nicht leer sind (verständliche Meldung statt V4-Kauderwelsch). + var name_v := str(doc.get("info", {}).get("name", "")).strip_edges() + if name_v.is_empty(): + _show_errors("Map konnte nicht gespeichert werden:", ["Die Map braucht einen Namen — trag ihn rechts unter „Map-Eigenschaften“ ein."]) + return + var result := SvmapIO.save_to_file(path, doc) + if not result["ok"]: + _show_errors("Map konnte nicht gespeichert werden (ungültige Map-Daten):", result["errors"]) + _set_status("Speichern fehlgeschlagen.", true) + return + current_path = path + dirty = false + _refresh_file_label() + _set_status("Map gespeichert: %s" % path) + _refresh_tree() + + +func _show_errors(title: String, errors: Array) -> void: + var lines := "" + for e in errors: + lines += "• %s\n" % str(e) + error_text.text = "%s\n%s" % [title, lines] + error_dialog.title = "Fehler" + error_dialog.popup_centered() + + +# -------------------------------------------------------- Eigenschaften ---- + +func _on_prop_changed(text: String, field: String) -> void: + if _loading_ui_values or doc.is_empty(): + return + var info: Dictionary = doc["info"] + match field: + "name": + info["name"] = text + "author": + info["author"] = text + "description": + info["description"] = text + dirty = true + _refresh_file_label() + + +func _on_version_changed(value: float) -> void: + if _loading_ui_values or doc.is_empty(): + return + doc["info"]["version"] = int(value) + dirty = true + _refresh_file_label() + + +func _on_weather_changed(index: int) -> void: + if _loading_ui_values or doc.is_empty(): + return + var weathers := ["mittag", "abend", "regen", "nacht"] + doc["weather"] = weathers[index] + dirty = true + _refresh_file_label() + # Licht/Stimmung im Viewport sofort anpassen + view_builder.call("rebuild", doc, manifest) + _set_status("Wetter geändert auf „%s“." % weathers[index]) + + +# --------------------------------------------------------------- Anzeige --- + +func _refresh_all() -> void: + _loading_ui_values = true + var info: Dictionary = doc.get("info", {}) + prop_name.text = str(info.get("name", "")) + prop_author.text = str(info.get("author", "")) + prop_desc.text = str(info.get("description", "")) + prop_version.value = float(info.get("version", 1)) + var weathers := ["mittag", "abend", "regen", "nacht"] + var weather := str(doc.get("weather", "mittag")) + prop_weather.select(maxi(0, weathers.find(weather))) + + var terrain: Dictionary = doc.get("terrain", {}) + terrain_info.text = "Größe: %.0f × %.0f m\nAuflösung: %d × %d Höhenpunkte\nHöhenbereich: %.0f bis %.0f m" % [ + float(terrain.get("size_x_m", 0.0)), float(terrain.get("size_z_m", 0.0)), + int(terrain.get("resolution", 0)), + int(terrain.get("resolution", 0)), + float(terrain.get("min_height_m", 0.0)), float(terrain.get("max_height_m", 0.0))] + _loading_ui_values = false + + _refresh_tree() + _refresh_stats() + _refresh_file_label() + _refresh_viewport() + + +func _refresh_viewport() -> void: + view_builder.call("rebuild", doc, manifest) + camera.call("frame_map", float(doc.get("terrain", {}).get("size_x_m", 1024.0))) + + +func _refresh_tree() -> void: + content_tree.clear() + var root := content_tree.create_item() + var objects: Array = doc.get("objects", []) + + var obj_item := content_tree.create_item(root) + obj_item.set_text(0, "Objekte (%d)" % objects.size()) + for i in objects.size(): + var obj = objects[i] + var item := content_tree.create_item(obj_item) + if obj is Dictionary: + item.set_text(0, "%s #%d" % [str(obj.get("id", "?")), i + 1]) + else: + item.set_text(0, "ungültig #%d" % (i + 1)) + + var zones: Dictionary = doc.get("spawn_zones", {}) + var spawn_item := content_tree.create_item(root) + spawn_item.set_text(0, "Spawn-Zonen") + for team_key in ["0", "1"]: + if not zones.has(team_key): + continue + var zone: Dictionary = zones[team_key] + var team_item := content_tree.create_item(spawn_item) + var team_name := "Team Blau" if team_key == "0" else "Team Rot" + team_item.set_text(0, "%s (%d Punkte)" % [team_name, (zone.get("points", []) as Array).size()]) + for p in (zone.get("points", []) as Array): + if p is Dictionary: + var point_item := content_tree.create_item(team_item) + point_item.set_text(0, "%s (%.0f, %.0f)" % [ + str(p.get("id", "?")), float(p.get("pos", [0, 0, 0])[0]), float(p.get("pos", [0, 0, 0])[2])]) + + +func _refresh_stats() -> void: + var count := SvmapDocument.object_count(doc) + var poly := SvmapDocument.poly_usage(doc, manifest) + stats_objects.text = "Objekte: %d / 5000" % count + stats_poly.text = "Poly-Budget: %s / 1.500.000" % _fmt_int(int(poly)) + + +func _refresh_file_label() -> void: + var map_name := str(doc.get("info", {}).get("name", "Ohne Namen")) + if current_path.is_empty(): + file_label.text = "„%s“ (noch nicht gespeichert)%s" % [map_name, " •" if dirty else ""] + else: + file_label.text = "„%s“ — %s%s" % [map_name, current_path.get_file(), " •" if dirty else ""] + get_window().title = "%s — %s" % [APP_TITLE, map_name] + + +func _set_status(text: String, is_error := false) -> void: + status_label.text = text + status_label.add_theme_color_override("font_color", + Color(1.0, 0.6, 0.5) if is_error else Color(0.75, 0.95, 0.75)) + + +func _fmt_int(value: int) -> String: + var s := str(value) + var out := "" + while s.length() > 3: + out = "." + s.substr(s.length() - 3) + out + s = s.substr(0, s.length() - 3) + return s + out diff --git a/src/ui/main.gd.uid b/src/ui/main.gd.uid new file mode 100644 index 0000000..115239e --- /dev/null +++ b/src/ui/main.gd.uid @@ -0,0 +1 @@ +uid://c5j3bplirtg6k diff --git a/src/ui/new_map_dialog.gd b/src/ui/new_map_dialog.gd new file mode 100644 index 0000000..f1305ec --- /dev/null +++ b/src/ui/new_map_dialog.gd @@ -0,0 +1,71 @@ +extends ConfirmationDialog +## „Neue Map“-Dialog: Name, Größe, Wetter-Preset — bewusst nur 3 Fragen, +## alles Weitere setzt vernünftige Defaults (Chef-Grundsatz: easy). + +var name_edit: LineEdit +var size_option: OptionButton +var weather_option: OptionButton + +var _sizes: Array[float] = [512.0, 1024.0, 2048.0] +var _size_labels := ["Klein — 512 × 512 m", "Mittel — 1024 × 1024 m", "Groß — 2048 × 2048 m"] +var _weathers := ["mittag", "abend", "regen", "nacht"] +var _weather_labels := ["Mittag (sonnig, klar)", "Abend (warmes Licht)", "Regen (grau, kühl)", "Nacht (dunkel)"] + + +func _init() -> void: + title = "Neue Map" + ok_button_text = "Map erstellen" + cancel_button_text = "Abbrechen" + min_size = Vector2i(460, 0) + + var vbox := VBoxContainer.new() + vbox.anchors_preset = Control.PRESET_FULL_RECT + vbox.set_anchors_preset(Control.PRESET_FULL_RECT) + vbox.add_theme_constant_override("separation", 12) + add_child(vbox) + + vbox.add_child(_make_label("Wie soll die Map heißen?")) + name_edit = LineEdit.new() + name_edit.text = "Neue Map" + name_edit.max_length = 64 + name_edit.custom_minimum_size = Vector2(0, 40) + vbox.add_child(name_edit) + + vbox.add_child(_make_label("Wie groß soll das Gelände sein?")) + size_option = OptionButton.new() + for label in _size_labels: + size_option.add_item(label) + size_option.select(1) # Mittel als Default + size_option.custom_minimum_size = Vector2(0, 40) + vbox.add_child(size_option) + + vbox.add_child(_make_label("Welches Wetter soll die Map haben?")) + weather_option = OptionButton.new() + for label in _weather_labels: + weather_option.add_item(label) + weather_option.custom_minimum_size = Vector2(0, 40) + vbox.add_child(weather_option) + + var hint := _make_label("Die Map startet leer mit 4 Spawn-Punkten pro Team\n(Blau im Westen, Rot im Osten) — beides lässt sich später ändern.") + hint.modulate = Color(1, 1, 1, 0.7) + vbox.add_child(hint) + + +func _make_label(text: String) -> Label: + var label := Label.new() + label.text = text + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + return label + + +func get_map_name() -> String: + var n := name_edit.text.strip_edges() + return n if not n.is_empty() else "Neue Map" + + +func get_size_m() -> float: + return _sizes[size_option.selected] + + +func get_weather() -> String: + return _weathers[weather_option.selected] diff --git a/src/ui/new_map_dialog.gd.uid b/src/ui/new_map_dialog.gd.uid new file mode 100644 index 0000000..4bc1260 --- /dev/null +++ b/src/ui/new_map_dialog.gd.uid @@ -0,0 +1 @@ +uid://d1tevsdsnf8ih diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd new file mode 100644 index 0000000..becd19e --- /dev/null +++ b/src/viewport/map_view_builder.gd @@ -0,0 +1,224 @@ +extends Node3D +## Baut die visuelle Repräsentation der Map im Editor-Viewport: +## Boden-Referenz (Plane in Terrain-Größe) + Maßstabsgitter + Himmel/Licht +## + Platzhalter-Meshes für Bibliotheks-Objekte + Spawn-Marker mit Beschriftung. +## Reine Ansicht — Interaktion/Werkzeuge kommen mit eigenen Karten. + +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 GROUND_COLOR := Color(0.23, 0.32, 0.24) +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 + +# 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)}, +} + + +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() + + 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_ground(size_x, size_z, ground_y) + _build_grid(size_x, size_z, ground_y) + _build_objects(data.get("objects", []), manifest) + _build_spawns(data.get("spawn_zones", {})) + + +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) + + +func _build_ground(size_x: float, size_z: float, ground_y: float) -> void: + var mi := MeshInstance3D.new() + var plane := PlaneMesh.new() + plane.size = Vector2(size_x, size_z) + mi.mesh = plane + mi.position.y = ground_y + var mat := StandardMaterial3D.new() + mat.albedo_color = GROUND_COLOR + mat.roughness = 1.0 + mi.material_override = mat + add_child(mi) + + +## 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 + add_child(mi) + + +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. +func _build_objects(objects: Array, manifest: Dictionary) -> void: + var lib: Dictionary = manifest.get("objects", {}) + var mat := StandardMaterial3D.new() + mat.albedo_color = OBJECT_COLOR + mat.roughness = 0.8 + for obj in objects: + if not (obj is Dictionary): + continue + var oid := str(obj.get("id", "")) + if not lib.has(oid): + 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 mi := MeshInstance3D.new() + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + mi.mesh = _placeholder_mesh(str(col.get("shape", "box")), col, scale) + mi.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + mi.rotation.y = deg_to_rad(float(obj.get("rot_y_deg", 0.0))) + mi.position.y += _mesh_half_height(mi.mesh) + mi.material_override = mat + add_child(mi) + + +func _placeholder_mesh(shape: String, col: Dictionary, scale: float) -> Mesh: + match shape: + "sphere": + var s := SphereMesh.new() + var r := float(col.get("radius", 1.0)) * scale + s.radius = r + s.height = r * 2.0 + return s + "cylinder": + var c := CylinderMesh.new() + c.top_radius = float(col.get("radius", 1.0)) * scale + c.bottom_radius = c.top_radius + c.height = float(col.get("height", 1.0)) * scale + return c + _: + var b := BoxMesh.new() + var size: Array = col.get("size", [1.0, 1.0, 1.0]) + b.size = Vector3(float(size[0]), float(size[1]), float(size[2])) * scale + return b + + +func _mesh_half_height(mesh: Mesh) -> float: + if mesh is BoxMesh: + return (mesh as BoxMesh).size.y * 0.5 + if mesh is CylinderMesh: + return (mesh as CylinderMesh).height * 0.5 + if mesh is SphereMesh: + return (mesh as SphereMesh).radius + return 0.0 + + +## Spawn-Marker: halbtransparente Säule + Label3D mit der Punkt-ID, teamfarbig. +func _build_spawns(zones: Dictionary) -> void: + for team_key in zones.keys(): + var team := int(team_key) + var zone: Dictionary = zones[team_key] + var points: Array = zone.get("points", []) + var color: Color = TEAM_COLORS[team] if team < TEAM_COLORS.size() else Color.WHITE + for p in points: + if not (p is Dictionary): + continue + var pos: Array = p.get("pos", [0.0, 0.0, 0.0]) + var marker := Node3D.new() + marker.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2])) + var mi := MeshInstance3D.new() + var cyl := CylinderMesh.new() + cyl.top_radius = 1.5 + cyl.bottom_radius = 1.5 + cyl.height = 6.0 + mi.mesh = cyl + mi.position.y = 3.0 + var mat := StandardMaterial3D.new() + mat.albedo_color = Color(color.r, color.g, color.b, 0.55) + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mi.material_override = mat + marker.add_child(mi) + var label := Label3D.new() + label.text = str(p.get("id", "?")) + label.position.y = 7.5 + label.billboard = BaseMaterial3D.BILLBOARD_ENABLED + label.modulate = color + label.font_size = 220 + label.pixel_size = 0.01 + marker.add_child(label) + add_child(marker) diff --git a/src/viewport/map_view_builder.gd.uid b/src/viewport/map_view_builder.gd.uid new file mode 100644 index 0000000..1761658 --- /dev/null +++ b/src/viewport/map_view_builder.gd.uid @@ -0,0 +1 @@ +uid://cot16hrvgdx4a diff --git a/src/viewport/orbit_camera.gd b/src/viewport/orbit_camera.gd new file mode 100644 index 0000000..22b92b4 --- /dev/null +++ b/src/viewport/orbit_camera.gd @@ -0,0 +1,74 @@ +extends Camera3D +## Orbit-Kamera für den Editor-Viewport (MVP-Grundgerüst). +## Steuerung (Maus, keine Tastenkürzel nötig — Chef-Grundsatz): +## Mittlere Maustaste ziehen = Drehen (Orbit) +## Mittlere Maustaste + Shift = Verschieben (Pan, parallel zum Boden) +## Mausrad = Zoom +## Ziel-Punkt, Winkel und Abstand bleiben intern; Position wird jede Frame +## aus ihnen berechnet. + +var target := Vector3.ZERO +var yaw_deg := 45.0 +var pitch_deg := 35.0 +var distance := 300.0 + +var _dragging := false +var _panning := false + + +func _ready() -> void: + _apply() + + +func _unhandled_input(event: InputEvent) -> void: + if event is InputEventMouseButton: + var mb := event as InputEventMouseButton + if mb.button_index == MOUSE_BUTTON_MIDDLE: + _dragging = mb.pressed and not mb.shift_pressed + _panning = mb.pressed and mb.shift_pressed + elif mb.button_index == MOUSE_BUTTON_WHEEL_UP and mb.pressed: + distance = maxf(5.0, distance * 0.9) + _apply() + elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN and mb.pressed: + distance = minf(distance * 1.1, distance_max()) + _apply() + elif event is InputEventMouseMotion: + var mm := event as InputEventMouseMotion + if _dragging: + yaw_deg = wrapf(yaw_deg - mm.relative.x * 0.3, -180.0, 180.0) + pitch_deg = clampf(pitch_deg + mm.relative.y * 0.3, 5.0, 85.0) + _apply() + elif _panning: + var right := transform.basis.x + var fwd := -transform.basis.z + fwd.y = 0.0 + fwd = fwd.normalized() + var scale := distance * 0.0015 + target -= right * mm.relative.x * scale + target += fwd * mm.relative.y * scale + _apply() + + +func distance_max() -> float: + return 6000.0 + + +## Kamera auf die Map zentrieren (nach Laden/Neu). +func frame_map(size_m: float) -> void: + target = Vector3.ZERO + distance = maxf(80.0, size_m * 0.6) + yaw_deg = 45.0 + pitch_deg = 35.0 + _apply() + + +func _apply() -> void: + var pitch := deg_to_rad(pitch_deg) + var yaw := deg_to_rad(yaw_deg) + var offset := Vector3( + sin(yaw) * cos(pitch), + sin(pitch), + cos(yaw) * cos(pitch) + ) * distance + position = target + offset + look_at(target, Vector3.UP) diff --git a/src/viewport/orbit_camera.gd.uid b/src/viewport/orbit_camera.gd.uid new file mode 100644 index 0000000..d848fc0 --- /dev/null +++ b/src/viewport/orbit_camera.gd.uid @@ -0,0 +1 @@ +uid://bhv55tde24sxg diff --git a/tests/test_editor_svmap.gd b/tests/test_editor_svmap.gd new file mode 100644 index 0000000..b245498 --- /dev/null +++ b/tests/test_editor_svmap.gd @@ -0,0 +1,167 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_5badf371 — svmap laden/speichern. +## Ausführen: godot --headless --path . --script res://tests/test_editor_svmap.gd +## Exit 0 = alles grün. Läuft OHNE UI (nur SvmapIO/SvmapDocument). + +var failures := 0 +var checks := 0 + + +func _init() -> void: + # user:// persistiert über Godot-Läufe — vor UND nach dem Test aufräumen + # (Lektion aus t_74976e9f: Stale-Reste maskierten sonst Verifikationen). + _cleanup() + _run_tests() + _cleanup() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _cleanup() -> void: + for path in ["user://test_roundtrip.svmap", "user://test_roundtrip2.svmap"]: + if FileAccess.file_exists(path): + DirAccess.remove_absolute(ProjectSettings.globalize_path(path)) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +func _errors_contain(errors: Array, needle: String) -> bool: + for e in errors: + if str(e).contains(needle): + return true + return false + + +func _run_tests() -> void: + print("== Neue Map: default-Dokument ==") + var doc := SvmapDocument.create_new("Testmap", 1024.0, "abend") + _check(int(doc["format_version"]) == 1, "format_version 1") + _check(str(doc["info"]["name"]) == "Testmap", "Name übernommen") + _check(str(doc["weather"]) == "abend", "Wetter übernommen") + _check((doc["objects"] as Array).is_empty(), "leere Objektliste") + _check((doc["spawn_zones"]["0"]["points"] as Array).size() == 4, "Team 0: 4 Default-Spawns") + _check((doc["spawn_zones"]["1"]["points"] as Array).size() == 4, "Team 1: 4 Default-Spawns") + var terrain: Dictionary = doc["terrain"] + _check(int(terrain["resolution"]) == 257, "Default-Auflösung 257") + var raw := Marshalls.base64_to_raw(str(terrain["heightmap_r16_b64"])) + _check(raw.size() == 257 * 257 * 2, "Höhenfeld-Länge = resolution² × 2") + var flat := true + for i in raw.size(): + if raw[i] != 0: + flat = false + break + _check(flat, "Default-Höhenfeld flach (alles 0)") + + print("== Speichern (Validierung vor dem Schreiben) ==") + var save1 := SvmapIO.save_to_file("user://test_roundtrip.svmap", doc) + _check(save1["ok"], "save_to_file ok: %s" % str(save1["errors"])) + + print("== Roundtrip: speichern → laden → speichern → byte-identisch ==") + var load1 := SvmapIO.load_from_file("user://test_roundtrip.svmap") + _check(load1["ok"], "load_from_file ok: %s" % str(load1["errors"])) + var doc2: Dictionary = load1["data"] + _check(str(doc2["info"]["name"]) == "Testmap", "Name im Roundtrip erhalten") + _check(str(doc2["weather"]) == "abend", "Wetter im Roundtrip erhalten") + _check((doc2["spawn_zones"]["1"]["points"] as Array).size() == 4, "Spawns im Roundtrip erhalten") + var save2 := SvmapIO.save_to_file("user://test_roundtrip2.svmap", doc2) + _check(save2["ok"], "Re-Save ok") + var text1 := FileAccess.get_file_as_string("user://test_roundtrip.svmap") + var text2 := FileAccess.get_file_as_string("user://test_roundtrip2.svmap") + _check(text1 == text2, "Dateien byte-identisch (Prüfsumme stabil über save/load/save)") + + print("== Server-Kompatibilität: Editor-Prüfsumme == Loader-Kanonisierung ==") + # Der Loader im Match-Server nutzt exakt: kanonisieren → stringify(„ ") → + # sha256_text. Der Editor muss denselben Hash erzeugen (geprüft an der + # geladenen Map, die ein checksum-Feld trägt): + var doc_signed: Dictionary = _deep_copy(doc2) + SvmapIO.sign_map(doc_signed) + var payload: Dictionary = SvmapIO._canonicalize(doc_signed) + payload.erase("checksum") + var expected: String = JSON.stringify(payload, " ").sha256_text() + _check(str(doc_signed["checksum"]["value"]) == expected, "sign_map-Hash = Kanonisierungs-Hash") + + print("== Negative Fälle (Ablehnung mit verständlicher Meldung) ==") + var res: Dictionary + + res = SvmapIO.parse_and_validate("{{{definitiv kaputt") + _check(not res["ok"] and _errors_contain(res["errors"], "V2"), "kaputtes JSON → V2") + + res = SvmapIO.parse_and_validate('{"format_version": 2}') + _check(not res["ok"] and _errors_contain(res["errors"], "V3"), "falsche Version → V3") + + var bad: Dictionary = _deep_copy(doc2) + bad["weather"] = "sturm" + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V12"), "unbekanntes Wetter → V12") + + bad = _deep_copy(doc2) + (bad["spawn_zones"]["0"]["points"] as Array)[1]["id"] = "A1" + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V11"), "Spawn-ID-Dublette → V11") + + bad = _deep_copy(doc2) + (bad["spawn_zones"]["1"]["points"] as Array).resize(3) + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V11"), "zu wenige Spawns → V11") + + bad = _deep_copy(doc2) + (bad["objects"] as Array).append({"id": "taube_aus_dem_wald", "pos": [0.0, 1.0, 0.0], "rot_y_deg": 0.0, "scale": 1.0}) + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V7"), "unbekannte Objekt-ID → V7") + + bad = _deep_copy(doc2) + (bad["objects"] as Array).append({"id": "crate_wood", "pos": [99999.0, 0.0, 0.0], "rot_y_deg": 0.0, "scale": 1.0}) + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V9"), "Objekt außerhalb der Grenzen → V9") + + bad = _deep_copy(doc2) + bad["terrain"]["resolution"] = 700 + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V6"), "Auflösung zu groß → V6") + + bad = _deep_copy(doc2) + bad["terrain"]["heightmap_r16_b64"] = "AAAA" + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V5"), "Höhenfeld falsche Länge → V5") + + bad = _deep_copy(doc2) + bad["checksum"]["value"] = "0".repeat(64) + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V13"), "manipulierte Prüfsumme → V13") + + bad = _deep_copy(doc2) + bad["info"]["author"] = "" + res = SvmapIO.parse_and_validate(JSON.stringify(bad)) + _check(not res["ok"] and _errors_contain(res["errors"], "V4"), "leerer Autor → V4") + + print("== Poly-Budget (injiziertes Manifest) ==") + var mega_manifest: Dictionary = {"objects": {"mega": {"poly": 2_000_000, "collision": {"shape": "box", "size": [1, 1, 1]}}}} + var budget_doc := SvmapDocument.create_new("Budget", 512.0, "mittag") + (budget_doc["objects"] as Array).append({"id": "mega", "pos": [0.0, 1.0, 0.0], "rot_y_deg": 0.0, "scale": 1.0}) + res = SvmapIO.parse_and_validate(JSON.stringify(budget_doc), mega_manifest) + _check(not res["ok"] and _errors_contain(res["errors"], "V8"), "Poly-Budget überschritten → V8") + + print("== Poly-Nutzung (Editor-Budgetanzeige) ==") + var usage_doc := SvmapDocument.create_new("Usage", 512.0, "mittag") + (usage_doc["objects"] as Array).append({"id": "crate_wood", "pos": [0.0, 0.6, 0.0], "rot_y_deg": 0.0, "scale": 2.0}) + # crate_wood poly=12, scale²=4 → 48 + _check(int(SvmapDocument.poly_usage(usage_doc, SvmapIO._manifest_or({}))) == 48, "poly_usage = poly × scale²") + + # Sentinel: falls ein Script-Error einen Abschnitt abbricht, fehlt dieser + # Check → kein falsches „ALL OK". + _check(true, "Alle Testabschnitte liefen bis zum Ende durch") + + +func _deep_copy(value: Variant) -> Variant: + return SvmapIO._canonicalize(value) diff --git a/tests/test_editor_svmap.gd.uid b/tests/test_editor_svmap.gd.uid new file mode 100644 index 0000000..a53e5c0 --- /dev/null +++ b/tests/test_editor_svmap.gd.uid @@ -0,0 +1 @@ +uid://b1kge0migmc14