M2: Map-Editor MVP App-Skelett — svmap laden/speichern (V1-V14), Viewport-Grundgeruest, Neue-Map-Dialog (t_5badf371)

This commit is contained in:
slyphax 2026-10-15 11:34:32 +02:00
parent 259e135fb8
commit 84bc08145b
21 changed files with 1686 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
# Godot
.godot/
*.tmp
# System
.DS_Store

View file

@ -39,3 +39,36 @@ Konkret heißt das für JEDE Editor-Karte:
## Milestone-Zuordnung
M2 (siehe PLAN.md §6): Map-Paket-Format → Editor-MVP → erste offizielle Map
(offenes Gelände) → Map-Repo/Release-Workflow über Forgejo (M3).
## Status (M2, Karte t_5badf371 — App-Skelett FERTIG)
- **Godot-4.x-Desktop-App:** startet als eigenständiges Fenster (Hauptfenster mit
Menüleiste „Datei“, 3D-Viewport mit Orbit-Kamera, Objekt-Baum links,
Map-Eigenschaften rechts, Statusleiste unten). Kein Spiel-Code-Import.
- **Map-Datenmodell:** 1:1 das svmap-Format v1 (`docs/map_format.md` im
scopedvr-match-server-Repo) — Terrain-Parameter (Heightmap R16/LE/Base64),
Objekt-Liste, Spawn-Zonen (Team 0/1), Metadaten + Wetter-Preset.
Implementierung: `src/svmap/svmap_io.gd` (Validierung V1–V14 wie der
Server-Loader, identische Kanonisierung/Prüfsumme — interop-getestet in
beide Richtungen), `src/svmap/map_document.gd` (Neue-Map-Defaults).
- **Laden/Speichern:** `.svmap` über Datei-Dialog; speichern validiert VOR dem
Schreiben und signiert (sha256 über kanonisierte Payload). Speichern →
Laden → Speichern ist byte-identisch. Ungültige Maps werden mit klaren,
deutschsprachigen Meldungen abgelehnt.
- **Viewport-Grundgerüst:** Orbit/Zoom/Pan per Maus (kein Tastenkürzel nötig),
Maßstabsgitter + Boden-Referenz in Terrain-Größe, Platzhalter-Meshes für
Bibliotheks-Objekte, Spawn-Marker teamfarbig mit Beschriftung,
Wetter-Preset färbt Licht/Himmel.
- **Neue-Map-Dialog:** Name, Größe (512/1024/2048 m), Wetter-Preset. Neue Maps
sind sofort speicherbar (4 Default-Spawn-Punkte pro Team, V11 erfüllt).
- **Tests:** `tests/test_editor_svmap.gd` (31 Checks: Roundtrip byte-identisch,
V2–V13-Negativfälle, Poly-Budget, Server-Kompatibilität der Prüfsumme).
Lauf: `godot --headless --path . --script res://tests/test_editor_svmap.gd`
**Bibliothek:** `library/objects.json` ist die Editor-Kopie der offiziellen
Registry aus dem Match-Server-Repo — neue Objekte DORT registrieren und hier
synchron ziehen (IDs sind der Vertrag, Entscheidung 20).
**Noch offen (folgende Karten der MVP-Kette):** Terrain-Werkzeuge
(heben/senken/glätten), Objekt-Platzierung (Bibliothek-Browser),
Spawn-Zonen-Editor, Performance-Check-UI, Export-Workflow.

6
icon.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View file

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

382
src/svmap/svmap_io.gd Normal file
View 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

View file

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

525
src/ui/main.gd Normal file
View 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
View file

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

71
src/ui/new_map_dialog.gd Normal file
View 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]

View file

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

View 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)

View file

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

View 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)

View file

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

167
tests/test_editor_svmap.gd Normal file
View 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)

View file

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