M2: Map-Editor MVP App-Skelett — svmap laden/speichern (V1-V14), Viewport-Grundgeruest, Neue-Map-Dialog (t_5badf371)
This commit is contained in:
parent
259e135fb8
commit
84bc08145b
21 changed files with 1686 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
# Godot
|
||||||
|
.godot/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# System
|
||||||
|
.DS_Store
|
||||||
33
README.md
33
README.md
|
|
@ -39,3 +39,36 @@ Konkret heißt das für JEDE Editor-Karte:
|
||||||
## Milestone-Zuordnung
|
## Milestone-Zuordnung
|
||||||
M2 (siehe PLAN.md §6): Map-Paket-Format → Editor-MVP → erste offizielle Map
|
M2 (siehe PLAN.md §6): Map-Paket-Format → Editor-MVP → erste offizielle Map
|
||||||
(offenes Gelände) → Map-Repo/Release-Workflow über Forgejo (M3).
|
(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.
|
||||||
|
|
|
||||||
6
icon.svg
Normal file
6
icon.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<rect width="128" height="128" rx="20" fill="#1e3a5f"/>
|
||||||
|
<path d="M20 96 L52 40 L72 72 L84 56 L108 96 Z" fill="#4caf50"/>
|
||||||
|
<circle cx="96" cy="34" r="10" fill="#ffd54f"/>
|
||||||
|
<rect x="18" y="100" width="92" height="6" rx="3" fill="#90caf9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
43
icon.svg.import
Normal file
43
icon.svg.import
Normal file
|
|
@ -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
|
||||||
17
library/objects.json
Normal file
17
library/objects.json
Normal file
|
|
@ -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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
26
project.godot
Normal file
26
project.godot
Normal file
|
|
@ -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
|
||||||
12
scenes/main.tscn
Normal file
12
scenes/main.tscn
Normal file
|
|
@ -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")
|
||||||
93
src/svmap/map_document.gd
Normal file
93
src/svmap/map_document.gd
Normal file
|
|
@ -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
|
||||||
1
src/svmap/map_document.gd.uid
Normal file
1
src/svmap/map_document.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cmyaqvftm41uf
|
||||||
382
src/svmap/svmap_io.gd
Normal file
382
src/svmap/svmap_io.gd
Normal file
|
|
@ -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
|
||||||
1
src/svmap/svmap_io.gd.uid
Normal file
1
src/svmap/svmap_io.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b0n1rh37a72rd
|
||||||
525
src/ui/main.gd
Normal file
525
src/ui/main.gd
Normal file
|
|
@ -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
|
||||||
1
src/ui/main.gd.uid
Normal file
1
src/ui/main.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://c5j3bplirtg6k
|
||||||
71
src/ui/new_map_dialog.gd
Normal file
71
src/ui/new_map_dialog.gd
Normal file
|
|
@ -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]
|
||||||
1
src/ui/new_map_dialog.gd.uid
Normal file
1
src/ui/new_map_dialog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://d1tevsdsnf8ih
|
||||||
224
src/viewport/map_view_builder.gd
Normal file
224
src/viewport/map_view_builder.gd
Normal file
|
|
@ -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)
|
||||||
1
src/viewport/map_view_builder.gd.uid
Normal file
1
src/viewport/map_view_builder.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cot16hrvgdx4a
|
||||||
74
src/viewport/orbit_camera.gd
Normal file
74
src/viewport/orbit_camera.gd
Normal file
|
|
@ -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)
|
||||||
1
src/viewport/orbit_camera.gd.uid
Normal file
1
src/viewport/orbit_camera.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bhv55tde24sxg
|
||||||
167
tests/test_editor_svmap.gd
Normal file
167
tests/test_editor_svmap.gd
Normal file
|
|
@ -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)
|
||||||
1
tests/test_editor_svmap.gd.uid
Normal file
1
tests/test_editor_svmap.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b1kge0migmc14
|
||||||
Loading…
Reference in a new issue