diff --git a/README.md b/README.md index e50e011..cdb76ff 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,37 @@ trotz grünem Test-Lauf (der ja selbst mit importiertem Cache lief). 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 — nutzen ActionHistory mit `["terrain"]`), -Spawn-Zonen-Editor, Performance-Check-UI, Export-Workflow. +## Status (M2, Karte t_3a998ae4 — Objekt-Bibliothek FERTIG) + +Auf dem Terrain-Werkzeug (t_9aba04f1) aufgebaut, nach dem Chef-Grundsatz: + +- **Bibliothek-Browser mit Gruppen (`src/editor/library_browser.gd`):** Die + 11 etablierten Objekte aus `library/objects.json`, sortiert in deutsche + Klapp-Gruppen (Kisten & Barrieren / Bauwerke / Natur + automatisch + „Sonstiges" für neue, noch ungruppierte IDs). Jeder Button zeigt ein + 128-px-Thumbnail (`library/thumbs/*.png` — einmalig mit + `xvfb-run -a godot4 --rendering-driver opengl3 --path . --script + res://tools/gen_thumbs.gd` gerendert und committed; fehlt ein PNG, fällt + der Button sauber aufs Text-Layout zurück). Deutsche Namen wohnen jetzt in + `LibraryBrowser` (ohne UI → headless testbar). +- **Snap to Ground (`src/editor/object_ops.gd`):** Ein Klick auf + „⚡ Alles auf den Boden setzen" holt ALLE schwebenden Objekte auf die + Terrain-Höhe (z. B. nach dem Terrain-Formen) — EIN Undo-Eintrag für die + ganze Aktion. Objekte, die schon auf dem Boden stehen, bleiben unangetastet. +- **Raster-Snap (1 m):** CheckButton in den Objekt-Werkzeugen — beim + Platzieren UND Ziehen rasten X/Z auf ganze Meter (Höhe folgt weiter dem + Terrain). AUS = freies Platzieren wie bisher. +- **svmap-Roundtrip:** Objekte (Bibliotheks-ID + Pos/Rot/Scale) überleben + Speichern/Laden identisch, save→load→save bleibt byte-identisch + (`tests/test_library.gd`). +- **Tests:** `tests/test_library.gd` (Bibliothek laden, Gruppen komplett/ + überschneidungsfrei, Thumbnails 128×128, Raster-Snap, Snap-to-Ground + je + Undo für setzen/drehen/verschieben/löschen über ALLE 11 Objekte, + svmap-Roundtrip mit Objekten + Poly-Budget aus dem Katalog). +- **Desktop-Smoke:** `godot … -- --library-demo [ordner] [--library-demo-quit]` + fährt Bibliothek/Platzierung/Schweben/Snap programmatisch durch und + speichert Screenshots. + +**Noch offen (folgende Karten der MVP-Kette):** Spawn-Zonen-Editor +(t_c4162c5d — Spawn-Zonen malen, Live-Validierung, Export-Signierung), +Performance-Check-UI, Export-Workflow. diff --git a/library/thumbs/barrier_sandbag.png b/library/thumbs/barrier_sandbag.png new file mode 100644 index 0000000..94027a8 Binary files /dev/null and b/library/thumbs/barrier_sandbag.png differ diff --git a/library/thumbs/barrier_sandbag.png.import b/library/thumbs/barrier_sandbag.png.import new file mode 100644 index 0000000..a675449 --- /dev/null +++ b/library/thumbs/barrier_sandbag.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bqkcpy3du3fly" +path="res://.godot/imported/barrier_sandbag.png-eed61205bec18ca97dd54d2a61a4e153.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/barrier_sandbag.png" +dest_files=["res://.godot/imported/barrier_sandbag.png-eed61205bec18ca97dd54d2a61a4e153.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 diff --git a/library/thumbs/boulder_small.png b/library/thumbs/boulder_small.png new file mode 100644 index 0000000..6a3698e Binary files /dev/null and b/library/thumbs/boulder_small.png differ diff --git a/library/thumbs/boulder_small.png.import b/library/thumbs/boulder_small.png.import new file mode 100644 index 0000000..ee29da7 --- /dev/null +++ b/library/thumbs/boulder_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://der1lfrcvoeb8" +path="res://.godot/imported/boulder_small.png-c4fad8ba15711e8ff7d084f1741e1559.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/boulder_small.png" +dest_files=["res://.godot/imported/boulder_small.png-c4fad8ba15711e8ff7d084f1741e1559.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 diff --git a/library/thumbs/bunker_concrete.png b/library/thumbs/bunker_concrete.png new file mode 100644 index 0000000..f551fc0 Binary files /dev/null and b/library/thumbs/bunker_concrete.png differ diff --git a/library/thumbs/bunker_concrete.png.import b/library/thumbs/bunker_concrete.png.import new file mode 100644 index 0000000..7f48bde --- /dev/null +++ b/library/thumbs/bunker_concrete.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://b3ec84bcadfrp" +path="res://.godot/imported/bunker_concrete.png-3b8f1b4a66c169614840fc15438260a4.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bunker_concrete.png" +dest_files=["res://.godot/imported/bunker_concrete.png-3b8f1b4a66c169614840fc15438260a4.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 diff --git a/library/thumbs/bush_small.png b/library/thumbs/bush_small.png new file mode 100644 index 0000000..6a3698e Binary files /dev/null and b/library/thumbs/bush_small.png differ diff --git a/library/thumbs/bush_small.png.import b/library/thumbs/bush_small.png.import new file mode 100644 index 0000000..9c4e3ef --- /dev/null +++ b/library/thumbs/bush_small.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://rc4qmlu30oe0" +path="res://.godot/imported/bush_small.png-8dd2b0f2820d02d8f683642ea139221a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/bush_small.png" +dest_files=["res://.godot/imported/bush_small.png-8dd2b0f2820d02d8f683642ea139221a.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 diff --git a/library/thumbs/crate_metal.png b/library/thumbs/crate_metal.png new file mode 100644 index 0000000..905d99a Binary files /dev/null and b/library/thumbs/crate_metal.png differ diff --git a/library/thumbs/crate_metal.png.import b/library/thumbs/crate_metal.png.import new file mode 100644 index 0000000..9706e1b --- /dev/null +++ b/library/thumbs/crate_metal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://crq5m52c3kfp6" +path="res://.godot/imported/crate_metal.png-057fe3985c46074b7a8e051aeac6d28a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/crate_metal.png" +dest_files=["res://.godot/imported/crate_metal.png-057fe3985c46074b7a8e051aeac6d28a.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 diff --git a/library/thumbs/crate_wood.png b/library/thumbs/crate_wood.png new file mode 100644 index 0000000..02e83db Binary files /dev/null and b/library/thumbs/crate_wood.png differ diff --git a/library/thumbs/crate_wood.png.import b/library/thumbs/crate_wood.png.import new file mode 100644 index 0000000..fdd124d --- /dev/null +++ b/library/thumbs/crate_wood.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bu3hndyrkw2xu" +path="res://.godot/imported/crate_wood.png-fb22d40e14834f0dfc565b88b4ca0928.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/crate_wood.png" +dest_files=["res://.godot/imported/crate_wood.png-fb22d40e14834f0dfc565b88b4ca0928.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 diff --git a/library/thumbs/fence_metal.png b/library/thumbs/fence_metal.png new file mode 100644 index 0000000..9a9110f Binary files /dev/null and b/library/thumbs/fence_metal.png differ diff --git a/library/thumbs/fence_metal.png.import b/library/thumbs/fence_metal.png.import new file mode 100644 index 0000000..b60ec8e --- /dev/null +++ b/library/thumbs/fence_metal.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://kul1hamegxs0" +path="res://.godot/imported/fence_metal.png-eb6d7564dcec24bf8e9b4d5ec9d52a5a.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/fence_metal.png" +dest_files=["res://.godot/imported/fence_metal.png-eb6d7564dcec24bf8e9b4d5ec9d52a5a.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 diff --git a/library/thumbs/rock_large.png b/library/thumbs/rock_large.png new file mode 100644 index 0000000..b51d5f6 Binary files /dev/null and b/library/thumbs/rock_large.png differ diff --git a/library/thumbs/rock_large.png.import b/library/thumbs/rock_large.png.import new file mode 100644 index 0000000..836733d --- /dev/null +++ b/library/thumbs/rock_large.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlntu6vdbvxoh" +path="res://.godot/imported/rock_large.png-8a86d273f49f5cb46d939adc5de6b13b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/rock_large.png" +dest_files=["res://.godot/imported/rock_large.png-8a86d273f49f5cb46d939adc5de6b13b.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 diff --git a/library/thumbs/tree_pine.png b/library/thumbs/tree_pine.png new file mode 100644 index 0000000..e2fd19c Binary files /dev/null and b/library/thumbs/tree_pine.png differ diff --git a/library/thumbs/tree_pine.png.import b/library/thumbs/tree_pine.png.import new file mode 100644 index 0000000..8efdf3a --- /dev/null +++ b/library/thumbs/tree_pine.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://d3x487vku5uc7" +path="res://.godot/imported/tree_pine.png-a6c8426a9685830a95298331fa9d351e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/tree_pine.png" +dest_files=["res://.godot/imported/tree_pine.png-a6c8426a9685830a95298331fa9d351e.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 diff --git a/library/thumbs/wall_concrete.png b/library/thumbs/wall_concrete.png new file mode 100644 index 0000000..87decfc Binary files /dev/null and b/library/thumbs/wall_concrete.png differ diff --git a/library/thumbs/wall_concrete.png.import b/library/thumbs/wall_concrete.png.import new file mode 100644 index 0000000..cd306c2 --- /dev/null +++ b/library/thumbs/wall_concrete.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dhl43w3l7s05c" +path="res://.godot/imported/wall_concrete.png-aba74f819605dd2ba26846285fc5e701.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/wall_concrete.png" +dest_files=["res://.godot/imported/wall_concrete.png-aba74f819605dd2ba26846285fc5e701.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 diff --git a/library/thumbs/watchtower_wood.png b/library/thumbs/watchtower_wood.png new file mode 100644 index 0000000..eb58b69 Binary files /dev/null and b/library/thumbs/watchtower_wood.png differ diff --git a/library/thumbs/watchtower_wood.png.import b/library/thumbs/watchtower_wood.png.import new file mode 100644 index 0000000..e651cc1 --- /dev/null +++ b/library/thumbs/watchtower_wood.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cr8f85kohbqx5" +path="res://.godot/imported/watchtower_wood.png-63e27757c535c90df21765e4d9ed21d5.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://library/thumbs/watchtower_wood.png" +dest_files=["res://.godot/imported/watchtower_wood.png-63e27757c535c90df21765e4d9ed21d5.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 diff --git a/src/editor/interaction.gd b/src/editor/interaction.gd index 242aad5..28d9741 100644 --- a/src/editor/interaction.gd +++ b/src/editor/interaction.gd @@ -12,6 +12,7 @@ extends Node3D const ROT_STEP_DEG := 15.0 const DRAG_THRESHOLD_PX := 4.0 +const GRID_M := 1.0 # Raster-Snap-Weite (Objekt-Werkzeuge links, Karte t_3a998ae4) var main # Hauptfenster (script-erweitert, Variant — Referenzen per setup()) var camera: Camera3D @@ -148,6 +149,16 @@ func _ensure_ghost_visible() -> void: _ghost.visible = true +## Raster-Snap (wenn in main angeschaltet): X/Z auf ganze Meter rasten — +## Objekte passen dann nebeneinander wie Bauklötze. Höhe bleibt Terrain. +func _apply_grid(pos: Vector3) -> Vector3: + if not bool(main.get("grid_snap")): + return pos + pos.x = ObjectOps.snap_coord(pos.x, true, GRID_M) + pos.z = ObjectOps.snap_coord(pos.z, true, GRID_M) + return pos + + func _ghost_update(viewport_pos: Vector2) -> void: var p: Variant = _ground_point(viewport_pos) if p == null: @@ -155,7 +166,7 @@ func _ghost_update(viewport_pos: Vector2) -> void: _ghost.visible = false return _ghost_active = true - var pos := p as Vector3 + var pos := _apply_grid(p as Vector3) var half := _terrain_half() pos.x = clampf(pos.x, -half.x, half.x) pos.z = clampf(pos.z, -half.y, half.y) @@ -169,7 +180,7 @@ func _place_object(viewport_pos: Vector2) -> void: if p == null: main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true) return - var pos := p as Vector3 + var pos: Vector3 = _apply_grid(p as Vector3) var half := _terrain_half() if absf(pos.x) > half.x or absf(pos.z) > half.y: main.call("_set_status", "Das wäre außerhalb der Map gelandet — klicke innerhalb der grünen Fläche.", true) @@ -274,7 +285,7 @@ func _unhandled_input(event: InputEvent) -> void: var p: Variant = _ground_point(mm.position) if p == null: return - var new_pos: Vector3 = (p as Vector3) + _grab_offset + var new_pos: Vector3 = _apply_grid((p as Vector3) + _grab_offset) var half := _terrain_half() new_pos.x = clampf(new_pos.x, -half.x, half.x) new_pos.z = clampf(new_pos.z, -half.y, half.y) diff --git a/src/editor/library_browser.gd b/src/editor/library_browser.gd new file mode 100644 index 0000000..3da8372 --- /dev/null +++ b/src/editor/library_browser.gd @@ -0,0 +1,94 @@ +extends RefCounted +class_name LibraryBrowser +## Objekt-Bibliothek des Editors (Karte t_3a998ae4): deutscher Name, Gruppen +## für den Bibliothek-Browser und Thumbnail-Laden (128-px-PNGs, einmalig per +## Headless-Render erzeugt — tools/gen_thumbs.gd). +## +## Bewusst OHNE UI: reine Daten/Funktionen, damit die Tests (tests/test_library.gd) +## headless alles prüfen können. Die IDs sind der Vertrag (Entscheidung 20) — +## Gruppen/Namen leben HIER im Editor (die objects.json bleibt 1:1 die Kopie +## der Server-Registry, damit ein Sync sie nicht verwüstet). + +## Deutsche Anzeige-Namen für Bibliotheks-IDs (IDs sind der Vertrag, Entscheidung 20) +const NAMES := { + "crate_wood": "Holzkiste", + "crate_metal": "Metallkiste", + "wall_concrete": "Betonmauer", + "barrier_sandbag": "Sandsack-Sperre", + "bunker_concrete": "Bunker", + "watchtower_wood": "Wachturm", + "fence_metal": "Zaun", + "rock_large": "Großer Felsen", + "boulder_small": "Kleiner Felsen", + "tree_pine": "Nadelbaum", + "bush_small": "Strauch", +} + +## Gruppen für den Bibliothek-Browser (Anzeige-Reihenfolge = Reihenfolge hier). +const GROUP_ORDER := ["kisten", "bauwerke", "natur", "sonstiges"] +const GROUP_LABELS := { + "kisten": "Kisten & Barrieren", + "bauwerke": "Bauwerke", + "natur": "Natur", + "sonstiges": "Sonstiges", +} +const GROUP_IDS := { + "kisten": ["crate_wood", "crate_metal", "barrier_sandbag", "wall_concrete"], + "bauwerke": ["bunker_concrete", "watchtower_wood", "fence_metal"], + "natur": ["tree_pine", "bush_small", "rock_large", "boulder_small"], +} + + +static func object_label(oid: String) -> String: + return NAMES.get(oid, oid.capitalize()) + + +## Gruppe einer ID (Objekte, die keine festen Gruppe haben → "sonstiges"). +static func group_of(oid: String) -> String: + for key in GROUP_IDS: + if (GROUP_IDS[key] as Array).has(oid): + return str(key) + return "sonstiges" + + +## Gruppen für den Browser, geprüft gegen das Manifest: nur IDs, die es in +## der objects.json WIRKLICH gibt, landen in der Liste (und unbekannte, +## noch nicht gruppierte IDs wandern nach "Sonstiges" — neue Bibliotheks- +## Einträge brechen die UI also nicht). +static func groups_in_order(manifest: Dictionary) -> Array: + var lib: Dictionary = manifest.get("objects", {}) + var by_group := {} + for oid in lib.keys(): + var key := group_of(str(oid)) + if not by_group.has(key): + by_group[key] = [] + (by_group[key] as Array).append(str(oid)) + var out: Array = [] + for key in GROUP_ORDER: + var ids: Array = (by_group.get(key, []) as Array) + ids.sort() + if ids.is_empty() and key != "sonstiges": + continue + out.append({"key": key, "label": GROUP_LABELS[key], "ids": ids}) + return out + + +## Thumbnail-Pfad (128-px-PNG, tools/gen_thumbs.gd erzeugt sie einmalig). +static func thumb_path(oid: String) -> String: + return "res://library/thumbs/%s.png" % oid + + +## Thumbnail als Textur laden — direkt über Image (kein Import-System nötig, +## geht damit auch frisch nach git pull ohne Import), auf Anzeige-Größe +## runtergerechnet. null = noch kein Thumbnail → Button fällt aufs Text-Layout +## zurück (Fehler sieht man nicht, man sieht einfach nur kein Bild). +static func load_thumb(oid: String, size_px := 40) -> Texture2D: + var path := thumb_path(oid) + if not FileAccess.file_exists(path): + return null + var img := Image.load_from_file(path) + if img == null: + return null + if img.get_width() != size_px or img.get_height() != size_px: + img.resize(size_px, size_px, Image.INTERPOLATE_LANCZOS) + return ImageTexture.create_from_image(img) diff --git a/src/editor/library_browser.gd.uid b/src/editor/library_browser.gd.uid new file mode 100644 index 0000000..d4c7d10 --- /dev/null +++ b/src/editor/library_browser.gd.uid @@ -0,0 +1 @@ +uid://o3jwxk87gtw diff --git a/src/editor/object_ops.gd b/src/editor/object_ops.gd new file mode 100644 index 0000000..7580b46 --- /dev/null +++ b/src/editor/object_ops.gd @@ -0,0 +1,34 @@ +extends RefCounted +class_name ObjectOps +## Objekt-Operationen des Editors (Karte t_3a998ae4) — bewusst pure Funktionen +## (kein UI, kein Knoten): Snap-to-Ground und Raster-Snap. main.gd/interaction.gd +## rufen auf, tests/test_library.gd prüft headless. + +const GRID_M := 1.0 # Raster-Snap-Weite (1 m — handhabbar für Objekte) +const FLOAT_EPSILON := 0.01 # ab dieser Abweichung gilt ein Objekt als "schwebend" + + +## ALLE Objekte auf die Terrain-Höhe setzen (Snap to Ground). Objekte hängen +## z. B. nach dem Terrain-Formen in der Luft — ein Klick holt sie runter. +## Rückgabe: Anzahl geänderter Objekte (0 = alles stand schon auf dem Boden). +static func snap_to_ground(doc: Dictionary) -> int: + var changed := 0 + var objects: Array = doc.get("objects", []) + for obj in objects: + if not (obj is Dictionary): + continue + var pos: Array = obj.get("pos", [0.0, 0.0, 0.0]) + if (pos as Array).size() != 3: + continue + var h := SvmapDocument.terrain_height_at(doc, float(pos[0]), float(pos[2])) + if absf(float(pos[1]) - h) > FLOAT_EPSILON: + obj["pos"] = [float(pos[0]), h, float(pos[2])] + changed += 1 + return changed + + +## Eine Koordinate aufs Raster legen (Raster-Snap). grid_m <= 0 oder aus = no-op. +static func snap_coord(value: float, enabled: bool, grid_m := GRID_M) -> float: + if not enabled or grid_m <= 0.0: + return value + return roundf(value / grid_m) * grid_m diff --git a/src/editor/object_ops.gd.uid b/src/editor/object_ops.gd.uid new file mode 100644 index 0000000..a8fb101 --- /dev/null +++ b/src/editor/object_ops.gd.uid @@ -0,0 +1 @@ +uid://dnocqoteya1h3 diff --git a/src/ui/main.gd b/src/ui/main.gd index 9c02524..aac2284 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -17,25 +17,11 @@ extends Control const APP_TITLE := "ScopedVR Map-Editor" -## Deutsche Anzeige-Namen für Bibliotheks-IDs (IDs sind der Vertrag, Entscheidung 20) -const GERMAN_NAMES := { - "crate_wood": "Holzkiste", - "crate_metal": "Metallkiste", - "wall_concrete": "Betonmauer", - "barrier_sandbag": "Sandsack-Sperre", - "bunker_concrete": "Bunker", - "watchtower_wood": "Wachturm", - "fence_metal": "Zaun", - "rock_large": "Großer Felsen", - "boulder_small": "Kleiner Felsen", - "tree_pine": "Nadelbaum", - "bush_small": "Strauch", -} - var manifest: Dictionary = {} var doc: Dictionary = {} # das Map-Dictionary (1:1 svmap-Format) var current_path := "" # leer = noch nie gespeichert var dirty := false +var grid_snap := false # Raster-Snap (1 m) an/aus — Objekt-Werkzeuge links var history := ActionHistory.new() var interaction # src/editor/interaction.gd (script-erweitert → untypisiert) @@ -242,15 +228,15 @@ 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) + vbox.add_theme_constant_override("separation", 6) panel.add_child(vbox) vbox.add_child(_header_label("Objekt-Bibliothek")) - vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken.\nDer Platzier-Modus bleibt an, bis du Rechtsklick/ESC drückst — so setzt du mehrere hintereinander.")) + vbox.add_child(_muted_label("Objekt anklicken, dann in die Karte klicken — der Modus bleibt an bis Rechtsklick/ESC.")) var scroll := ScrollContainer.new() scroll.size_flags_vertical = Control.SIZE_EXPAND_FILL - scroll.custom_minimum_size = Vector2(0, 160) + scroll.custom_minimum_size = Vector2(0, 280) scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED vbox.add_child(scroll) library_box = VBoxContainer.new() @@ -259,12 +245,30 @@ func _build_left_panel() -> PanelContainer: scroll.add_child(library_box) _fill_library() + vbox.add_child(_separator()) + vbox.add_child(_header_label("Objekt-Werkzeuge")) + + var snap_btn := Button.new() + snap_btn.text = "⚡ Alles auf den Boden setzen" + snap_btn.custom_minimum_size = Vector2(0, 44) + snap_btn.tooltip_text = "Setzt ALLE Objekte auf die Terrain-Höhe — z. B. nach dem Terrain-Formen, wenn etwas in der Luft hängt" + snap_btn.pressed.connect(_on_snap_to_ground) + vbox.add_child(snap_btn) + + var grid_check := CheckButton.new() + grid_check.text = "Raster-Snap (1 m)" + grid_check.custom_minimum_size = Vector2(0, 40) + grid_check.tooltip_text = "Neue und verschobene Objekte rasten auf ein 1-Meter-Raster ein" + grid_check.toggled.connect(_on_grid_snap_changed) + vbox.add_child(grid_check) + vbox.add_child(_muted_label("Raster-Snap rastet Platzieren und Ziehen auf ganze Meter.")) + vbox.add_child(_separator()) 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.custom_minimum_size = Vector2(0, 160) content_tree.allow_reselect = true content_tree.hide_root = true content_tree.item_selected.connect(_on_tree_selected) @@ -274,19 +278,44 @@ func _build_left_panel() -> PanelContainer: func _fill_library() -> void: library_buttons.clear() + # Gruppen-Browser (Karte t_3a998ae4): Klapp-Überschriften je Gruppe, + # darunter die Objekte mit Thumbnail (128-px-Render, runterskaliert). + for group in LibraryBrowser.groups_in_order(manifest): + var g_label: String = group["label"] + var g_ids: Array = group["ids"] + if g_ids.is_empty(): + continue + var header := Button.new() + header.text = "▾ %s (%d)" % [g_label, g_ids.size()] + header.toggle_mode = true + header.button_pressed = true + header.alignment = HORIZONTAL_ALIGNMENT_LEFT + header.custom_minimum_size = Vector2(240, 40) + library_box.add_child(header) + var group_box := VBoxContainer.new() + group_box.add_theme_constant_override("separation", 4) + library_box.add_child(group_box) + header.toggled.connect(func(on: bool) -> void: + group_box.visible = on + header.text = "%s %s (%d)" % ["▾" if on else "▸", g_label, g_ids.size()]) + for oid in g_ids: + _add_library_button(str(oid), group_box) + + +func _add_library_button(oid: String, parent: Container) -> void: var lib: Dictionary = manifest.get("objects", {}) - for oid in lib.keys(): - var entry: Dictionary = lib[oid] - var poly := int(entry.get("poly", 0)) - var btn := Button.new() - btn.text = "%s · %d Polys" % [object_label(str(oid)), poly] - btn.custom_minimum_size = Vector2(240, 40) - btn.alignment = HORIZONTAL_ALIGNMENT_LEFT - btn.toggle_mode = true - btn.button_pressed = false - btn.pressed.connect(_on_library_pressed.bind(str(oid))) - library_box.add_child(btn) - library_buttons[str(oid)] = btn + var entry: Dictionary = lib.get(oid, {}) + var poly := int(entry.get("poly", 0)) + var btn := Button.new() + btn.text = "%s · %d Polys" % [LibraryBrowser.object_label(oid), poly] + btn.icon = LibraryBrowser.load_thumb(oid) + btn.custom_minimum_size = Vector2(240, 44) + btn.alignment = HORIZONTAL_ALIGNMENT_LEFT + btn.toggle_mode = true + btn.button_pressed = false + btn.pressed.connect(_on_library_pressed.bind(oid)) + parent.add_child(btn) + library_buttons[oid] = btn func _build_center_viewport() -> SubViewportContainer: @@ -701,7 +730,32 @@ func _highlight_library(oid: String) -> void: func object_label(oid: String) -> String: - return GERMAN_NAMES.get(oid, oid.capitalize()) + return LibraryBrowser.object_label(oid) + + +## Snap to Ground (1 Klick): ALLE Objekte auf die Terrain-Höhe setzen — +## EIN Undo-Eintrag für die ganze Aktion. +func _on_snap_to_ground() -> void: + var objects: Array = doc.get("objects", []) + if objects.is_empty(): + _set_status("Es gibt noch keine Objekte — platziere zuerst eins aus der Bibliothek links.") + return + var before: Dictionary = history.snapshot(["objects"]) + var changed := ObjectOps.snap_to_ground(doc) + if changed == 0: + _set_status("Alle %d Objekte stehen schon auf dem Boden — nichts zu tun." % objects.size()) + return + history.commit("Alle Objekte auf Boden gesetzt (%d)" % changed, + before, history.snapshot(["objects"])) + _after_structure_change() + _set_status("%d von %d Objekten auf den Boden gesetzt — Strg+Z nimmt es zurück." % [changed, objects.size()]) + + +func _on_grid_snap_changed(pressed: bool) -> void: + grid_snap = pressed + _set_status("Raster-Snap %s — Objekte landen jetzt %s." % [ + "AN" if pressed else "AUS", + "auf ganzen Metern (wie Bauklötze nebeneinander)" if pressed else "dort, wo du hinklickst"]) # ---------------------------------------------------- Werkzeug-Status ------ @@ -1052,6 +1106,9 @@ func _maybe_start_demo_mode() -> void: if args.has("--terrain-demo"): _start_terrain_demo(args) return + if args.has("--library-demo"): + _start_library_demo(args) + return if not args.has("--ghost-demo"): return var oid := "crate_wood" @@ -1164,4 +1221,54 @@ func _demo_shot(path: String) -> void: if mi != null and is_instance_valid(mi) and mi.mesh != null: var aabb: AABB = mi.mesh.get_aabb() print("DEMO_AABB: pos=%s size=%s tris=%d" % [aabb.position, aabb.size, tv.triangle_count()]) - print("DEMO_CAM: cam=%s target=%s" % [camera.global_position, camera.target]) \ No newline at end of file + print("DEMO_CAM: cam=%s target=%s" % [camera.global_position, camera.target]) + + +## `godot ... -- --library-demo [ordner]` — Desktop-Smoke (Karte t_3a998ae4): +## Gruppen-Bibliothek + platzierte Objekte + Snap-to-Ground programmatisch +## durchspielen, PNGs speichern. Die App bleibt offen. +func _start_library_demo(args: PackedStringArray) -> void: + var out_dir := "/tmp/scopedvr_library" + var idx := args.find("--library-demo") + if idx >= 0 and idx + 1 < args.size() and not args[idx + 1].begins_with("--"): + out_dir = args[idx + 1] + DirAccess.make_dir_recursive_absolute(out_dir) + + # 1) Bibliothek-Browser: Gruppen sichtbar, Ghost-Preview aktiv. + _set_status("Demo 1/3: Bibliothek in Gruppen — ein Objekt anklicken setzt eine Vorschau in die Karte.") + interaction.demo_park_ghost(Vector3(10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0), "watchtower_wood") + _demo_camera(Vector3(0.0, 2.0, 0.0), 90.0, 35.0, 40.0) + await _demo_shot(out_dir + "/library_browser.png") + + # 2) Objekte platziert (versch. Gruppen), eins orange ausgewählt. + var before: Dictionary = history.snapshot(["objects"]) + (doc["objects"] as Array).append({"id": "watchtower_wood", "pos": [10.0, 0.0, 6.0], "scale": 1.0, "rot_y_deg": 30.0}) + (doc["objects"] as Array).append({"id": "crate_wood", "pos": [-8.0, 0.0, -4.0], "scale": 1.0, "rot_y_deg": 0.0}) + (doc["objects"] as Array).append({"id": "tree_pine", "pos": [-20.0, 0.0, 18.0], "scale": 1.0, "rot_y_deg": 75.0}) + (doc["objects"] as Array).append({"id": "rock_large", "pos": [24.0, 0.0, -18.0], "scale": 1.0, "rot_y_deg": 0.0}) + history.commit("Demo: Objekte gesetzt", before, history.snapshot(["objects"])) + interaction.call("cancel_placement") + _clear_edit_state() + _refresh_all() + interaction.select_object(0, true) + _demo_camera(Vector3(0.0, 2.0, 0.0), 110.0, 40.0, -35.0) + _set_status("Demo 2/3: Objekte platziert (Wachturm orange ausgewählt) — ziehen, drehen, löschen wie gehabt.") + await _demo_shot(out_dir + "/library_placed.png") + + # 3) Snap to Ground: Objekte künstlich in die Luft hängen, dann 1 Klick. + var float_before: Dictionary = history.snapshot(["objects"]) + for obj in (doc["objects"] as Array): + var pos: Array = obj["pos"] + obj["pos"] = [float(pos[0]), float(pos[1]) + 7.0, float(pos[2])] + history.commit("Demo: Objekte schweben", float_before, history.snapshot(["objects"])) + _refresh_all() + _demo_camera(Vector3(0.0, 6.0, 0.0), 110.0, 30.0, -35.0) + _set_status("Demo 3/3a: Objekte SCHWEBEN nach dem Terrain-Formen — ein Klick auf „Alles auf den Boden setzen\" …") + await _demo_shot(out_dir + "/library_floating.png") + _on_snap_to_ground() + _demo_camera(Vector3(0.0, 4.0, 0.0), 110.0, 38.0, -35.0) + _set_status("Demo 3/3b: … und schon stehen ALLE wieder auf dem Boden (Strg+Z nimmt den ganzen Klick zurück).") + await _demo_shot(out_dir + "/library_after_snap.png") + print("LIBRARY_DEMO_OK: 4 Screenshots in %s" % out_dir) + if args.has("--library-demo-quit"): + get_tree().quit() # Batch-Smoke (xvfb): automatisch beenden, sobald die Shots da sind \ No newline at end of file diff --git a/tests/test_library.gd b/tests/test_library.gd new file mode 100644 index 0000000..1203d1a --- /dev/null +++ b/tests/test_library.gd @@ -0,0 +1,307 @@ +extends SceneTree +## Editor-Tests (headless): Karte t_3a998ae4 — Objekt-Bibliothek, Snap-to-Ground, +## Raster-Snap, svmap-Roundtrip mit Objekten. +## Ausführen: godot --headless --path . --script res://tests/test_library.gd +## Exit 0 = alles grün. Läuft OHNE UI (LibraryBrowser/ObjectOps/SvmapIO/SvmapDocument). +## UI-Funktionen (Gruppen-Aufklappen, Thumbnail-Anzeige) werden im Desktop-Smoke +## verifiziert (--library-demo), nicht hier. + +var failures := 0 +var checks := 0 + + +func _init() -> void: + _run_tests() + if failures == 0: + print("ALL OK: %d checks" % checks) + quit(0) + else: + print("FAILED: %d/%d checks" % [failures, checks]) + quit(1) + + +func _check(cond: bool, label: String) -> void: + checks += 1 + if cond: + print(" ok: %s" % label) + else: + failures += 1 + print(" FAIL: %s" % label) + + +func _run_tests() -> void: + _test_manifest_load() + _test_groups() + _test_thumbnails() + _test_grid_snap() + _test_snap_to_ground() + _test_snap_to_ground_undo() + _test_crud_undo_all_objects() + _test_svmap_roundtrip_objects() + + +# ------------------------------------------------------- Bibliothek laden ---- + +func _load_manifest() -> Dictionary: + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH)) + return parsed if parsed is Dictionary else {} + + +func _test_manifest_load() -> void: + print("Testgruppe: Bibliothek laden (library/objects.json)") + var manifest := _load_manifest() + _check(not manifest.is_empty(), "Manifest lädt und ist ein Objekt") + var lib: Dictionary = manifest.get("objects", {}) + _check(lib.size() == 11, "Katalog hat die 11 etablierten Objekte (ist %d)" % lib.size()) + var all_ok := true + for oid in lib.keys(): + var entry: Dictionary = lib[oid] + if int(entry.get("poly", 0)) <= 0: + all_ok = false + var col: Dictionary = entry.get("collision", {}) + if str(col.get("shape", "")).is_empty(): + all_ok = false + _check(all_ok, "jedes Objekt hat poly > 0 + Kollider-Eintrag") + # Deutsche Namen decken ALLE IDs ab (Bibliothek-Browser zeigt nichts Englisches). + var names_ok := lib.keys().all(func(oid) -> bool: return LibraryBrowser.NAMES.has(oid)) + _check(names_ok, "deutsche Namen für alle IDs vorhanden") + _check(LibraryBrowser.object_label("crate_wood") == "Holzkiste", "object_label: crate_wood → Holzkiste") + _check(LibraryBrowser.object_label("gibts_nicht") == "Gibts Nicht", "object_label: unbekannte ID fällt auf capitalize zurück") + + +func _test_groups() -> void: + print("Testgruppe: Gruppen (Gruppe/Einzelobjekt im Browser)") + var manifest := _load_manifest() + var lib: Dictionary = manifest.get("objects", {}) + var groups := LibraryBrowser.groups_in_order(manifest) + var seen := {} + var all_in := true + for group in groups: + _check(not str(group["label"]).is_empty(), "Gruppe „%s“ hat Label" % str(group["key"])) + for oid in (group["ids"] as Array): + if seen.has(oid): + all_in = false + seen[oid] = true + _check(all_in, "keine ID in zwei Gruppen") + _check(seen.size() == lib.size(), "alle %d IDs landen in einer Gruppe (ist %d)" % [lib.size(), seen.size()]) + _check(LibraryBrowser.group_of("tree_pine") == "natur", "group_of: tree_pine → natur") + _check(LibraryBrowser.group_of("crate_wood") == "kisten", "group_of: crate_wood → kisten") + _check(LibraryBrowser.group_of("fremdes_ding") == "sonstiges", "group_of: unbekannte ID → sonstiges") + # Gruppen, die im Katalog leer sind, werden nicht angezeigt (außer Sonstiges). + var labels: Array = [] + for group in groups: + labels.append(str(group["label"])) + var no_empty := true + for group in groups: + if str(group["key"]) != "sonstiges" and (group["ids"] as Array).is_empty(): + no_empty = false + _check(no_empty, "leere feste Gruppen tauchen nicht auf") + + +func _test_thumbnails() -> void: + print("Testgruppe: Thumbnails (128 px, einmalig gerendert + committed)") + var manifest := _load_manifest() + var lib: Dictionary = manifest.get("objects", {}) + var missing: Array = [] + var wrong_size := false + for oid in lib.keys(): + var path := LibraryBrowser.thumb_path(str(oid)) + if not FileAccess.file_exists(path): + missing.append(str(oid)) + continue + var img := Image.load_from_file(path) + if img == null or img.get_width() != 128 or img.get_height() != 128: + wrong_size = true + _check(missing.is_empty(), "Thumbnail für jedes Objekt vorhanden (fehlend: %s)" % str(missing)) + _check(not wrong_size, "alle Thumbnails sind 128×128") + var tex := LibraryBrowser.load_thumb("crate_wood", 40) + _check(tex != null, "load_thumb liefert Textur") + var fallback := LibraryBrowser.load_thumb("gibts_nicht") + _check(fallback == null, "load_thumb: fehlendes Thumbnail → null (Button fällt aufs Text-Layout zurück)") + + +# ------------------------------------------------------------ Raster-Snap ---- + +func _test_grid_snap() -> void: + print("Testgruppe: Raster-Snap (1 m)") + _check(ObjectOps.snap_coord(10.4, true) == 10.0, "10.4 rastet auf 10") + _check(ObjectOps.snap_coord(10.6, true) == 11.0, "10.6 rastet auf 11") + _check(ObjectOps.snap_coord(-0.6, true) == -1.0, "-0.6 rastet auf -1") + _check(ObjectOps.snap_coord(-0.4, true) == 0.0, "-0.4 rastet auf 0") + _check(ObjectOps.snap_coord(10.4, false) == 10.4, "aus = unverändert") + _check(ObjectOps.snap_coord(10.4, true, 4.0) == 12.0, "4-m-Raster: 10.4 → 12") + + +# ---------------------------------------------------------- Snap to Ground --- + +## Alle Heightmap-Samples auf einen 16-bit-Wert setzen → Terrain überall +## gleich hoch (h = min + v/65535 × Spannweite). Für Snap-Tests ohne +## Terrain-Werkzeug. uint16 little-endian (Low-Byte zuerst, map_format.md). +func _flatten_terrain(doc: Dictionary, v: int) -> void: + var terrain: Dictionary = doc["terrain"] + var res := int(terrain["resolution"]) + var bytes := PackedByteArray() + bytes.resize(res * res * 2) + for i in res * res: + bytes[i * 2] = v & 0xFF + bytes[i * 2 + 1] = (v >> 8) & 0xFF + terrain["heightmap_r16_b64"] = Marshalls.raw_to_base64(bytes) + + +func _test_snap_to_ground() -> void: + print("Testgruppe: Snap to Ground (alle schwebenden Objekte auf Terrain-Höhe)") + var doc := SvmapDocument.create_new("Snap-Test", 1024.0, "mittag") + (doc["objects"] as Array).append({"id": "crate_wood", "pos": [10.0, 0.0, 10.0], "scale": 1.0, "rot_y_deg": 0.0}) + (doc["objects"] as Array).append({"id": "tree_pine", "pos": [-5.0, 0.0, 3.0], "scale": 1.0, "rot_y_deg": 45.0}) + _check(ObjectOps.snap_to_ground(doc) == 0, "flaches Terrain: alles steht schon (0 Änderungen)") + _flatten_terrain(doc, 65535) # Terrain überall auf max_height (50 m) + var h := SvmapDocument.terrain_height_at(doc, 10.0, 10.0) + _check(absf(h - 50.0) < 0.01, "Terrain-Höhe nach Heben: %.1f m" % h) + var changed := ObjectOps.snap_to_ground(doc) + _check(changed == 2, "2 schwebende Objekte erkannt") + var objects: Array = doc["objects"] + _check(absf(float(objects[0]["pos"][1]) - 50.0) < 0.01, "Objekt 1 steht auf dem Boden") + _check(absf(float(objects[1]["pos"][1]) - 50.0) < 0.01, "Objekt 2 steht auf dem Boden") + _check(ObjectOps.snap_to_ground(doc) == 0, "zweiter Klick: nichts mehr zu tun") + # Teilweise schwebend: nur das schwebende wird erfasst. + objects[0]["pos"][1] = 50.0 + objects[1]["pos"][1] = 60.0 + _check(ObjectOps.snap_to_ground(doc) == 1, "nur das schwebende Objekt wird gesnapt") + + +func _test_snap_to_ground_undo() -> void: + print("Testgruppe: Snap to Ground = EIN Undo-Eintrag") + var doc := SvmapDocument.create_new("Snap-Undo", 1024.0, "mittag") + var hist := ActionHistory.new() + hist.begin(doc) + (doc["objects"] as Array).append({"id": "bunker_concrete", "pos": [0.0, 0.0, 0.0], "scale": 1.0, "rot_y_deg": 0.0}) + (doc["objects"] as Array).append({"id": "fence_metal", "pos": [4.0, 0.0, -2.0], "scale": 1.0, "rot_y_deg": 90.0}) + _flatten_terrain(doc, 32768) # Terrain auf ~25 m + var before: Dictionary = hist.snapshot(["objects"]) + var changed := ObjectOps.snap_to_ground(doc) + hist.commit("Alle Objekte auf Boden gesetzt (%d)" % changed, before, hist.snapshot(["objects"])) + var objects: Array = doc["objects"] + _check(changed == 2, "beide Objekte gesnapt") + _check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05, "Objekt 1 auf Terrain-Höhe") + hist.undo() + _check(absf(float(objects[0]["pos"][1]) - 0.0) < 0.0001, "undo: beide wieder schwebend (1 Schritt)") + _check(absf(float(objects[1]["pos"][1]) - 0.0) < 0.0001, "undo: Objekt 2 auch") + hist.redo() + _check(absf(float(objects[0]["pos"][1]) - 25.0) < 0.05 and absf(float(objects[1]["pos"][1]) - 25.0) < 0.05, "redo: wieder auf dem Boden") + + +# ------------------------------------------- setzen/drehen/verschieben/löschen — + +func _test_crud_undo_all_objects() -> void: + print("Testgruppe: JEDES Bibliotheks-Objekt — setzen/drehen/verschieben/löschen + je Undo") + var manifest := _load_manifest() + var lib: Dictionary = manifest.get("objects", {}) + var doc := SvmapDocument.create_new("CRUD-Test", 1024.0, "mittag") + var hist := ActionHistory.new() + hist.begin(doc) + var all_ok := true + var first := true + for oid in lib.keys(): + var x := randf() * 100.0 - 50.0 + var z := randf() * 100.0 - 50.0 + var y := SvmapDocument.terrain_height_at(doc, x, z) + # setzen (+ Undo) + var before := hist.snapshot(["objects"]) + var obj := {"id": oid, "pos": [x, y, z], "scale": 1.0, "rot_y_deg": 0.0} + (doc["objects"] as Array).append(obj) + hist.commit("Test: %s platzieren" % oid, before, hist.snapshot(["objects"])) + var objects: Array = doc["objects"] + if objects.size() != 1 or absf(float(objects[0]["pos"][1]) - y) > 0.0001: + all_ok = false + hist.undo() + if not (doc["objects"] as Array).is_empty(): + all_ok = false + hist.redo() + if (doc["objects"] as Array).size() != 1: + all_ok = false + # drehen (+ Undo) + before = hist.snapshot(["objects"]) + objects[0]["rot_y_deg"] = 90.0 + hist.commit("Test: drehen", before, hist.snapshot(["objects"]), "rot:0") + hist.undo() + if absf(float(objects[0]["rot_y_deg"]) - 0.0) > 0.0001: + all_ok = false + hist.redo() + if absf(float(objects[0]["rot_y_deg"]) - 90.0) > 0.0001: + all_ok = false + # verschieben (+ Undo) + before = hist.snapshot(["objects"]) + objects[0]["pos"] = [x + 3.0, y, z + 3.0] + hist.commit("Test: verschieben", before, hist.snapshot(["objects"]), "move:0") + hist.undo() + if absf(float(objects[0]["pos"][0]) - x) > 0.0001: + all_ok = false + hist.redo() + if absf(float(objects[0]["pos"][0]) - (x + 3.0)) > 0.0001: + all_ok = false + # löschen (+ Undo) + before = hist.snapshot(["objects"]) + (doc["objects"] as Array).remove_at(0) + hist.commit("Test: löschen", before, hist.snapshot(["objects"])) + hist.undo() + if (doc["objects"] as Array).size() != 1: + all_ok = false + hist.redo() + if not (doc["objects"] as Array).is_empty(): + all_ok = false + if first: + first = false + _check(all_ok, "alle 11 Objekte: setzen/drehen/verschieben/löschen je mit Undo+Redo sauber") + + +# -------------------------------------------------------- svmap-Roundtrip ---- + +func _test_svmap_roundtrip_objects() -> void: + print("Testgruppe: svmap-Roundtrip — Objekte (ID + Pos/Rot/Scale) überleben Speichern/Laden") + var doc := SvmapDocument.create_new("Roundtrip", 1024.0, "abend") + _flatten_terrain(doc, 20000) # Terrain leicht erhöht (~15 m) + var objects: Array = doc["objects"] + objects.append({"id": "watchtower_wood", "pos": [10.0, SvmapDocument.terrain_height_at(doc, 10.0, 6.0), 6.0], "scale": 1.0, "rot_y_deg": 30.0}) + objects.append({"id": "crate_wood", "pos": [-8.0, SvmapDocument.terrain_height_at(doc, -8.0, -4.0), -4.0], "scale": 2.0, "rot_y_deg": 0.0}) + objects.append({"id": "tree_pine", "pos": [-20.0, SvmapDocument.terrain_height_at(doc, -20.0, 18.0), 18.0], "scale": 1.0, "rot_y_deg": 75.0}) + objects.append({"id": "rock_large", "pos": [24.0, SvmapDocument.terrain_height_at(doc, 24.0, -18.0), -18.0], "scale": 0.5, "rot_y_deg": 180.0}) + objects.append({"id": "bunker_concrete", "pos": [0.0, SvmapDocument.terrain_height_at(doc, 0.0, 40.0), 40.0], "scale": 1.0, "rot_y_deg": 270.0}) + var manifest := _load_manifest() + SvmapIO.sign_map(doc) # Prüfsumme setzen → validate_map (inkl. V13) prüfbar + _check(SvmapIO.validate_map(doc, manifest).is_empty(), "Map mit 5 Objekten validiert sauber (V1–V14)") + + var save1 := SvmapIO.save_to_file("user://test_library_roundtrip.svmap", doc) + _check(save1["ok"], "save_to_file ok: %s" % str(save1["errors"])) + var load1 := SvmapIO.load_from_file("user://test_library_roundtrip.svmap") + _check(load1["ok"], "load_from_file ok: %s" % str(load1["errors"])) + var doc2: Dictionary = load1["data"] + var objs2: Array = doc2["objects"] + var same := objs2.size() == objects.size() + if same: + for i in objects.size(): + var a: Dictionary = objects[i] + var b: Dictionary = objs2[i] + if str(a["id"]) != str(b["id"]): + same = false + for k in 3: + if absf(float(a["pos"][k]) - float(b["pos"][k])) > 0.0001: + same = false + if absf(float(a["rot_y_deg"]) - float(b["rot_y_deg"])) > 0.0001: + same = false + if absf(float(a["scale"]) - float(b["scale"])) > 0.0001: + same = false + _check(same, "alle Objekte identisch zurück (ID, Pos, Rot, Scale)") + _check(SvmapIO.validate_map(doc2, manifest).is_empty(), "geladene Map validiert erneut sauber") + var save2 := SvmapIO.save_to_file("user://test_library_roundtrip2.svmap", doc2) + _check(save2["ok"], "zweites Speichern ok") + var text1 := FileAccess.get_file_as_string("user://test_library_roundtrip.svmap") + var text2 := FileAccess.get_file_as_string("user://test_library_roundtrip2.svmap") + _check(text1 == text2, "save→load→save byte-identisch") + # Poly-Budget-Rechnung kommt aus dem Katalog (Σ poly × scale²). + var expected := 0.0 + for o in objects: + expected += float(manifest["objects"][str(o["id"])]["poly"]) * float(o["scale"]) * float(o["scale"]) + _check(absf(SvmapDocument.poly_usage(doc, manifest) - expected) < 0.01, + "Poly-Budget aus Katalog: %.0f (erwartet %.0f)" % [SvmapDocument.poly_usage(doc, manifest), expected]) + DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip.svmap")) + DirAccess.remove_absolute(ProjectSettings.globalize_path("user://test_library_roundtrip2.svmap")) diff --git a/tests/test_library.gd.uid b/tests/test_library.gd.uid new file mode 100644 index 0000000..c259973 --- /dev/null +++ b/tests/test_library.gd.uid @@ -0,0 +1 @@ +uid://gcjtf6ekk4ub diff --git a/tools/gen_thumbs.gd b/tools/gen_thumbs.gd new file mode 100644 index 0000000..d32acc6 --- /dev/null +++ b/tools/gen_thumbs.gd @@ -0,0 +1,100 @@ +extends SceneTree +## Thumbnail-Generator für die Objekt-Bibliothek (Karte t_3a998ae4). +## Rendert JEDESM Bibliotheks-Objekt einmalig als 128-px-PNG nach +## library/thumbs/.png — die Bilder werden committed, der Editor lädt sie +## dann einfach aus der Datei (LibraryBrowser.load_thumb). +## +## Ausführen (braucht echtes Rendering, NICHT --headless): +## xvfb-run -a godot4 --rendering-driver opengl3 --path . --script res://tools/gen_thumbs.gd +## (auf Slys PC auch ohne xvfb: godot4 --rendering-driver opengl3 --path . --script ...) +## +## Exit 0 = alle Thumbnails geschrieben. + +const SIZE := 128 +const BG_COLOR := Color(0.16, 0.18, 0.21) + +var manifest: Dictionary = {} +var ids: Array = [] +var idx := -1 +var settle_frames := 0 +var vp: SubViewport +var mi: MeshInstance3D +var cam: Camera3D +var out_dir := "" + + +func _initialize() -> void: + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(SvmapIO.MANIFEST_PATH)) + if parsed is Dictionary: + manifest = parsed + ids = (manifest.get("objects", {}) as Dictionary).keys() + if ids.is_empty(): + push_error("gen_thumbs: Bibliothek leer — nichts zu rendern.") + quit(1) + return + out_dir = ProjectSettings.globalize_path("res://library/thumbs") + DirAccess.make_dir_recursive_absolute(out_dir) + + vp = SubViewport.new() + vp.size = Vector2i(SIZE, SIZE) + vp.own_world_3d = true + vp.render_target_update_mode = SubViewport.UPDATE_ALWAYS + root.add_child(vp) + + var env := Environment.new() + env.background_mode = Environment.BG_COLOR + env.background_color = BG_COLOR + env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR + env.ambient_light_color = Color(1, 1, 1) + env.ambient_light_energy = 0.7 + var we := WorldEnvironment.new() + we.environment = env + vp.add_child(we) + + var sun := DirectionalLight3D.new() + sun.rotation_degrees = Vector3(-55.0, 35.0, 0.0) + sun.light_energy = 1.1 + vp.add_child(sun) + + mi = MeshInstance3D.new() + vp.add_child(mi) + cam = Camera3D.new() + cam.current = true + vp.add_child(cam) + idx = -1 # erst im ersten _process starten — look_at braucht eingetretene Knoten + + +func _next() -> void: + idx += 1 + if idx >= ids.size(): + print("GEN_THUMBS_OK: %d Thumbnails in %s" % [ids.size(), out_dir]) + quit(0) + return + var oid := str(ids[idx]) + var entry: Dictionary = (manifest["objects"] as Dictionary)[oid] + var col: Dictionary = entry.get("collision", {}) + var mesh := MeshShapes.make_placeholder(str(col.get("shape", "box")), col, 1.0) + mi.mesh = mesh + # Kamera diagonal aufs Objekt, passend zur Mesh-Größe (Kiste nah, Turm weit). + var aabb := mesh.get_aabb() + var center := aabb.get_center() + var radius := aabb.size.length() * 0.5 + var dir := Vector3(1.0, 0.65, 1.0).normalized() + cam.position = center + dir * (radius * 2.3 + 0.3) + cam.look_at(center, Vector3.UP) + settle_frames = 4 + + +func _process(_delta: float) -> bool: + if idx < 0: + _next() + return false + if settle_frames > 0: + settle_frames -= 1 + return false + var img := vp.get_texture().get_image() + var oid := str(ids[idx]) + img.save_png(out_dir + "/" + oid + ".png") + print(" thumb: %s (%dx%d)" % [oid, img.get_width(), img.get_height()]) + _next() + return false diff --git a/tools/gen_thumbs.gd.uid b/tools/gen_thumbs.gd.uid new file mode 100644 index 0000000..b984ca6 --- /dev/null +++ b/tools/gen_thumbs.gd.uid @@ -0,0 +1 @@ +uid://b37674pu4v4nc diff --git a/tools/parse_check.sh b/tools/parse_check.sh new file mode 100644 index 0000000..13edd0e --- /dev/null +++ b/tools/parse_check.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Parse-Check über alle geänderten/neuen Scripts (Karte t_3a998ae4) +cd "$(dirname "$0")/.." +GODOT=~/bin/godot4 +fail=0 +for f in src/ui/main.gd src/editor/library_browser.gd src/editor/object_ops.gd \ + src/editor/interaction.gd tools/gen_thumbs.gd tests/test_library.gd; do + out=$("$GODOT" --headless --path . --check-only --script "res://$f" 2>&1 | grep -iv "godot engine\|^$" || true) + if [ -n "$out" ]; then + echo "FAIL $f:"; echo "$out"; fail=1 + else + echo "OK $f" + fi +done +exit $fail diff --git a/tools/run_tests.sh b/tools/run_tests.sh new file mode 100644 index 0000000..40012e8 --- /dev/null +++ b/tools/run_tests.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Regression: alle Editor-Testsuiten (Karte t_3a998ae4) +cd "$(dirname "$0")/.." +GODOT=~/bin/godot4 +rc=0 +for t in test_ux test_editor_svmap test_terrain test_library; do + "$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1 + code=$? + line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1) + echo "$t: exit=$code $line" + [ $code -ne 0 ] && rc=1 +done +exit $rc