Compare commits

...

2 commits

Author SHA1 Message Date
slyphax
d196086cce Loadout-Katalog im Kern, Himmel zuerst aus dem Projekt
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:34:04 +01:00
slyphax
d0388d243b Editor komplett in den Kern: Oberfläche, Werkzeuge, Ansicht, Theme, Vorschaubilder, Bau-Geometrie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:31:33 +01:00
1073 changed files with 44119 additions and 7 deletions

View file

@ -1,11 +1,13 @@
# ScopedVR Map-Kern # ScopedVR Map-Kern
Gemeinsamer Code für den PC-Map-Editor und die Quest-App: .svmap lesen und Der Map-Editor selbst, als gemeinsamer Code für die PC-App und die Quest-App:
schreiben, Terrain-Daten, Objekte, Spawns, Zonen, Blickrichtung, Loadout, Undo. Oberfläche, Werkzeuge, Ansicht, .svmap, Undo. Beide Apps haben so dieselben Funktionen.
Eingebunden als Git-Submodul unter `res://core/`. Eingebunden als Git-Submodul unter `res://core/`.
Regeln: Regeln:
- Kein Code für Maus, Tastatur, Fenster oder Panels. Das gehört in die App. - Eingaben kommen als PointerEvent an die Werkzeuge. Maus (mouse_pointer.gd) und
VR-Controller (Client) sind nur Übersetzer.
- Was nur am PC geht, steht hinter `main.vr_host`.
- Keine App-spezifischen Pfade. Daten wie `res://library/objects.json` liefert die App. - Keine App-spezifischen Pfade. Daten wie `res://library/objects.json` liefert die App.
- Maps gehören nicht in dieses Repo. - Maps gehören nicht in dieses Repo.

448
editor/bau_katalog.gd Normal file
View file

@ -0,0 +1,448 @@
extends RefCounted
class_name BauKatalog
## Bauteil-Katalog des Bauen-Modus (Karte "Bauen sauber integrieren", 25.10.).
##
## Ein Bauteil ist ein GANZ NORMALES Bibliotheks-Objekt (`bau_*`, Kategorie
## "bauteile"): gesetzte Teile stehen in doc["objects"], tragen ein
## Gruppen-Feld (= ein Gebäude) und laufen damit durch dieselbe Kette wie jedes
## Objekt — Undo, Kopieren, Löschen, Symmetrie, Speichern, Server-Registry,
## Client-Loader, Trimesh-Kollision. Es gibt keinen Sonderblock "gebaeude" mehr.
##
## Geometrie: die CC0-Runtime des Modular Builder (addons/modular_builder,
## Michel Savadim, v0.2.0 — Lizenz siehe ADDON_LIZENZ.txt). Hier wird sie nur
## PARAMETRIERT (Wandhöhe 3 m, 2-m-Raster) und auf die Fußpunkt-Konvention der
## Bibliothek normalisiert: Unterkante y = 0, XZ um die Mitte zentriert
## (docs/objekt_fragmente.md). Die Farbe ist die Material-Grundfarbe (glTF
## baseColorFactor) — keine Texturen, keine Lizenzfragen, 1 Draw-Call je Teil;
## Editor, Client und Thumbnails laden sie dadurch identisch.
##
## Meshes entstehen aus diesem Katalog (tools/gen_bauteile.gd schreibt GLB +
## Trimesh-JSON + Registry-Fragment), der Editor zeigt die GLBs wie jedes andere
## Objekt. mesh() ist die Quelle für Generator UND Tests.
const GEO_SCRIPT := "res://core/vendor/modular_builder/modular_piece_geometry.gd"
const DETAIL_SCRIPT := "res://core/vendor/modular_builder/modular_building_detail.gd"
const KATEGORIE := "bauteile"
const PREFIX := "bau_"
const RASTER_M := 2.0 # Zellgröße (XZ)
const WAND_H := 3.0 # Wandhöhe eines Geschosses
const DICKE_M := 0.2 # Wand-/Plattendicke
# Geometrie-Typen der Addon-Runtime (PieceType-Enum, Kopie der Werte)
const T_FUNDAMENT := 0
const T_BODEN := 2
const T_WAND := 3
const T_GIEBEL := 5
const T_DACH_GIEBEL := 6
const T_DACH_PYRAMIDE := 9
const T_TREPPE := 10
const D_FENSTER := 0
const D_TUER := 1
## Snap-Arten (build_snap.gd):
## "zelle" Mitte einer 2×2-Zelle (Fundament, Boden, Dächer)
## "kante" Mitte einer Zellkante (Wände) — Drehung ergibt sich aus der Kante
## "ecke" Zell-Ecke (Pfeiler)
## "treppe" 4×2-m-Fläche über zwei Zellen
const GRUPPEN := [
{"key": "grund", "label": "Fundament & Boden", "oids": ["bau_fundament", "bau_boden"]},
{"key": "waende", "label": "Wände", "oids": ["bau_wand", "bau_wand_tuer", "bau_wand_fenster",
"bau_wand_scharte", "bau_bruestung", "bau_pfeiler"]},
{"key": "dach", "label": "Dach & Giebel (klein 2 m)", "oids": ["bau_giebelwand", "bau_dach_giebel", "bau_dach_pyramide"]},
{"key": "dach_mittel", "label": "Dach & Giebel (mittel 4 m)", "oids": ["bau_giebelwand_4", "bau_dach_giebel_4", "bau_dach_pyramide_4"]},
{"key": "dach_gross", "label": "Dach & Giebel (groß 6 m)", "oids": ["bau_giebelwand_6", "bau_dach_giebel_6", "bau_dach_pyramide_6"]},
{"key": "dach_teile", "label": "Dach aus Einzelteilen (45°)", "oids": ["bau_dach_schraege", "bau_dach_ecke_aussen",
"bau_dach_ecke_innen", "bau_dach_plateau", "bau_dach_giebel_ende"]},
{"key": "treppe", "label": "Treppe", "oids": ["bau_treppe"]},
]
const QUADRAT := [[-1.0, 0.0, -1.0], [1.0, 0.0, -1.0], [1.0, 0.0, 1.0], [-1.0, 0.0, 1.0]]
const QUADRAT_4 := [[-2.0, 0.0, -2.0], [2.0, 0.0, -2.0], [2.0, 0.0, 2.0], [-2.0, 0.0, 2.0]]
const QUADRAT_6 := [[-3.0, 0.0, -3.0], [3.0, 0.0, -3.0], [3.0, 0.0, 3.0], [-3.0, 0.0, 3.0]]
## Reihenfolge = Anzeige-Reihenfolge (GRUPPEN nutzt dieselben IDs).
## "traeger": darauf darf gestapelt werden (Oberkante zählt als Ebene).
const KINDS := {
"bau_fundament": {
"label": "Fundament", "info": "2×2 m, 20 cm dick — Basis für Wände", "snap": "zelle", "traeger": true,
"typ": T_FUNDAMENT, "punkte": QUADRAT, "farbe": Color(0.42, 0.42, 0.40),
},
"bau_boden": {
"label": "Boden / Decke", "info": "2×2 m Platte — Fußboden oder Decke fürs nächste Geschoss",
"snap": "zelle", "traeger": true,
"typ": T_BODEN, "punkte": QUADRAT, "farbe": Color(0.50, 0.46, 0.40),
},
"bau_wand": {
"label": "Wand", "info": "2 m breit, 3 m hoch", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
},
"bau_wand_tuer": {
"label": "Wand mit Tür", "info": "Türöffnung 1,2 × 2,2 m — man läuft hindurch", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
"details": [{"typ": D_TUER, "pos": [1.0, 1.1, 0.0], "w": 1.2, "h": 2.2}],
},
"bau_wand_fenster": {
"label": "Wand mit Fenster", "info": "Fensteröffnung 1,2 × 1,2 m, Brüstung 1,1 m", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
"details": [{"typ": D_FENSTER, "pos": [1.0, 1.7, 0.0], "w": 1.2, "h": 1.2}],
},
"bau_wand_scharte": {
"label": "Wand mit Schießscharte", "info": "Schlitz 1,0 × 0,4 m auf Schulterhöhe", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.62, 0.53, 0.38),
"details": [{"typ": D_FENSTER, "pos": [1.0, 1.5, 0.0], "w": 1.0, "h": 0.4}],
},
"bau_bruestung": {
"label": "Brüstung", "info": "2 m breit, 1,1 m hoch — Deckung, Dachrand", "snap": "kante", "traeger": true,
"typ": T_WAND, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "farbe": Color(0.56, 0.48, 0.35), "hoehe": 1.1,
},
"bau_pfeiler": {
"label": "Eckpfeiler", "info": "Schließt die Ecke, wo zwei Wände zusammenstoßen", "snap": "ecke", "traeger": true,
"box": Vector3(0.3, 3.0, 0.3), "farbe": Color(0.42, 0.42, 0.40),
},
"bau_giebelwand": {
"label": "Giebelwand", "info": "Dreieck-Abschluss unter dem Satteldach (2 m breit, 1 m Spitze)",
"snap": "kante", "traeger": false,
"typ": T_GIEBEL, "punkte": [[0.0, 0.0, 0.0], [2.0, 0.0, 0.0]], "rise": 1.0, "farbe": Color(0.62, 0.53, 0.38),
},
"bau_dach_giebel": {
"label": "Satteldach", "info": "2×2 m, First in Richtung der Vorschau (R dreht)", "snap": "zelle", "traeger": false,
"typ": T_DACH_GIEBEL, "punkte": QUADRAT, "rise": 1.0, "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_pyramide": {
"label": "Pyramidendach", "info": "2×2 m, Spitze in der Mitte", "snap": "zelle", "traeger": false,
"typ": T_DACH_PYRAMIDE, "punkte": QUADRAT, "rise": 1.0, "farbe": Color(0.40, 0.25, 0.18),
},
# Große Dächer (Test-Befund 25.10.: 2×2 m reichte nur für eine Zelle). Immer 45°
# (Spitze = halbe Breite); Giebelwand und Dach gleicher Größe passen zusammen.
"bau_giebelwand_4": {
"label": "Giebelwand 4 m", "info": "Dreieck-Abschluss, 4 m breit, 2 m Spitze — zum Satteldach 4 m",
"snap": "kante", "traeger": false,
"typ": T_GIEBEL, "punkte": [[0.0, 0.0, 0.0], [4.0, 0.0, 0.0]], "rise": 2.0, "farbe": Color(0.62, 0.53, 0.38),
},
"bau_dach_giebel_4": {
"label": "Satteldach 4×4 m", "info": "4×4 m, First in Richtung der Vorschau (R dreht)", "snap": "zelle", "traeger": false,
"typ": T_DACH_GIEBEL, "punkte": QUADRAT_4, "rise": 2.0, "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_pyramide_4": {
"label": "Pyramidendach 4×4 m", "info": "4×4 m, Spitze in der Mitte", "snap": "zelle", "traeger": false,
"typ": T_DACH_PYRAMIDE, "punkte": QUADRAT_4, "rise": 2.0, "farbe": Color(0.40, 0.25, 0.18),
},
"bau_giebelwand_6": {
"label": "Giebelwand 6 m", "info": "Dreieck-Abschluss, 6 m breit, 3 m Spitze — zum Satteldach 6 m",
"snap": "kante", "traeger": false,
"typ": T_GIEBEL, "punkte": [[0.0, 0.0, 0.0], [6.0, 0.0, 0.0]], "rise": 3.0, "farbe": Color(0.62, 0.53, 0.38),
},
"bau_dach_giebel_6": {
"label": "Satteldach 6×6 m", "info": "6×6 m, First in Richtung der Vorschau (R dreht)", "snap": "zelle", "traeger": false,
"typ": T_DACH_GIEBEL, "punkte": QUADRAT_6, "rise": 3.0, "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_pyramide_6": {
"label": "Pyramidendach 6×6 m", "info": "6×6 m, Spitze in der Mitte", "snap": "zelle", "traeger": false,
"typ": T_DACH_PYRAMIDE, "punkte": QUADRAT_6, "rise": 3.0, "farbe": Color(0.40, 0.25, 0.18),
},
# Dach aus Einzelteilen: alles 2×2-Zellen mit 45° (2 m Lauf = 2 m Höhe), massiv
# (rundum geschlossen, keine Lücke von innen). "profil" → _dach_mesh(). Tiefe
# Kante = -Z lokal (R dreht), Ecken: tiefer Punkt bei (-X,-Z).
"bau_dach_schraege": {
"label": "Dachschräge 45°", "info": "2×2 m, 2 m hoch — eine Seite des Dachs; die tiefe Kante zeigt in Richtung der Vorschau (R dreht)",
"snap": "zelle", "traeger": false, "profil": "schraege", "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_ecke_aussen": {
"label": "Dachecke außen", "info": "Walm-Ecke (Grat): zwei Schrägen treffen sich an einer Hausecke, die tiefe Ecke zeigt nach außen",
"snap": "zelle", "traeger": false, "profil": "ecke_aussen", "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_ecke_innen": {
"label": "Dachecke innen", "info": "Kehle: zwei Schrägen laufen in einer einspringenden Ecke (L-/T-Dach) zusammen",
"snap": "zelle", "traeger": false, "profil": "ecke_innen", "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_plateau": {
"label": "Dachplateau", "info": "2×2 m Block, 2 m hoch, oben flach — Mittelstück zwischen zwei Schrägen (breite Häuser), Schornstein-Sockel",
"snap": "zelle", "traeger": true, "profil": "plateau", "farbe": Color(0.40, 0.25, 0.18),
},
"bau_dach_giebel_ende": {
"label": "Dachstirn 45°", "info": "Senkrechte Dreieck-Stirn (2 m breit, 2 m hoch) — Giebel-Abschluss unter einer Schräge, wenn das Dach an der Seite offen bleibt",
"snap": "kante", "traeger": false, "profil": "stirn", "farbe": Color(0.62, 0.53, 0.38),
},
"bau_treppe": {
"label": "Treppe", "info": "4 m lang, 2 m breit, 3 m hoch — führt in ein Geschoss darüber",
"snap": "treppe", "traeger": false,
"typ": T_TREPPE, "punkte": [[0.0, 0.0, 0.0], [4.0, 3.0, 0.0]], "stufen": 15, "breite": 2.0,
"farbe": Color(0.46, 0.45, 0.42),
},
}
static var _mesh_cache: Dictionary = {}
static func ist_bau(oid: String) -> bool:
return KINDS.has(oid)
static func oids() -> Array:
return KINDS.keys()
static func label(oid: String) -> String:
return str((KINDS.get(oid, {}) as Dictionary).get("label", oid))
static func snap_art(oid: String) -> String:
return str((KINDS.get(oid, {}) as Dictionary).get("snap", "zelle"))
## Platte = flaches Zell-Teil, auf dem man steht (Fundament, Boden/Decke).
static func ist_platte(oid: String) -> bool:
return snap_art(oid) == "zelle" and ist_traeger(oid)
static func ist_traeger(oid: String) -> bool:
return bool((KINDS.get(oid, {}) as Dictionary).get("traeger", false))
## Ausmaße (m) des normalisierten Meshes: x = Länge/Breite, y = Höhe, z = Tiefe.
static func groesse(oid: String) -> Vector3:
var m := mesh(oid)
return m.get_aabb().size if m != null else Vector3.ZERO
# ------------------------------------------------------------------- Mesh ---
## Normalisiertes Mesh (Fußpunkt unten-Mitte, Vertex-Farben) oder null.
static func mesh(oid: String) -> ArrayMesh:
if _mesh_cache.has(oid):
return _mesh_cache[oid]
var kind: Dictionary = KINDS.get(oid, {})
if kind.is_empty():
return null
var roh: ArrayMesh
if kind.has("box"):
roh = _box_mesh(kind["box"])
elif kind.has("profil"):
roh = _dach_mesh(str(kind["profil"]))
else:
roh = _addon_mesh(kind)
var fertig := _normalisieren(roh, kind["farbe"] as Color)
_mesh_cache[oid] = fertig
return fertig
static func _box_mesh(size: Vector3) -> ArrayMesh:
var bm := BoxMesh.new()
bm.size = size
var out := ArrayMesh.new()
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, bm.get_mesh_arrays())
return out
## Massive Dach-Teile aus Dreiecken (Zelle 2×2 m, lokal x,z ∈ [-1,1], 45°).
## Oberfläche h(x,z): Schräge = z+1, Ecke außen = min(x,z)+1, Ecke innen = max(x,z)+1,
## Plateau = 2. Dach-Stirn: dünnes Dreieck-Prisma.
static func _dach_mesh(profil: String) -> ArrayMesh:
var tris: Array = [] # je Dreieck [a, b, c] als Vector3
var V := func(x: float, y: float, z: float) -> Vector3: return Vector3(x, y, z)
var quad := func(a: Vector3, b: Vector3, c: Vector3, d: Vector3) -> void:
tris.append([a, b, c])
tris.append([a, c, d])
var tri := func(a: Vector3, b: Vector3, c: Vector3) -> void:
tris.append([a, b, c])
var mitte := Vector3(0.0, 0.7, 0.0)
match profil:
"schraege":
quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 2, 1), V.call(-1, 2, 1)) # Hang
quad.call(V.call(-1, 0, 1), V.call(1, 0, 1), V.call(1, 2, 1), V.call(-1, 2, 1)) # Rückwand
tri.call(V.call(-1, 0, -1), V.call(-1, 0, 1), V.call(-1, 2, 1)) # Stirn links
tri.call(V.call(1, 0, -1), V.call(1, 0, 1), V.call(1, 2, 1)) # Stirn rechts
quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 0, 1), V.call(-1, 0, 1)) # Boden
"ecke_aussen":
tri.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 2, 1))
tri.call(V.call(-1, 0, -1), V.call(1, 2, 1), V.call(-1, 0, 1))
tri.call(V.call(1, 0, -1), V.call(1, 0, 1), V.call(1, 2, 1))
tri.call(V.call(-1, 0, 1), V.call(1, 0, 1), V.call(1, 2, 1))
quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 0, 1), V.call(-1, 0, 1))
"ecke_innen":
tri.call(V.call(-1, 0, -1), V.call(1, 2, -1), V.call(1, 2, 1))
tri.call(V.call(-1, 0, -1), V.call(1, 2, 1), V.call(-1, 2, 1))
tri.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 2, -1))
tri.call(V.call(-1, 0, -1), V.call(-1, 2, 1), V.call(-1, 0, 1))
quad.call(V.call(1, 0, -1), V.call(1, 0, 1), V.call(1, 2, 1), V.call(1, 2, -1))
quad.call(V.call(-1, 0, 1), V.call(1, 0, 1), V.call(1, 2, 1), V.call(-1, 2, 1))
quad.call(V.call(-1, 0, -1), V.call(1, 0, -1), V.call(1, 0, 1), V.call(-1, 0, 1))
"plateau":
var bm := BoxMesh.new()
bm.size = Vector3(2.0, 2.0, 2.0)
var arr := bm.get_mesh_arrays()
var out := ArrayMesh.new()
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arr)
return out
"stirn":
# Dünnes Dreieck-Prisma (Kante entlang X, 2 m breit, 2 m hoch, 0,2 m dick)
var d := 0.1
tri.call(V.call(-1, 0, -d), V.call(1, 0, -d), V.call(-1, 2, -d))
tri.call(V.call(-1, 0, d), V.call(-1, 2, d), V.call(1, 0, d))
quad.call(V.call(-1, 0, -d), V.call(-1, 0, d), V.call(1, 0, d), V.call(1, 0, -d))
quad.call(V.call(-1, 0, -d), V.call(-1, 2, -d), V.call(-1, 2, d), V.call(-1, 0, d))
quad.call(V.call(1, 0, -d), V.call(1, 0, d), V.call(-1, 2, d), V.call(-1, 2, -d))
mitte = Vector3(-0.33, 0.67, 0.0)
var pos := PackedVector3Array()
var nrm := PackedVector3Array()
for t in tris:
var a: Vector3 = t[0]
var b: Vector3 = t[1]
var c: Vector3 = t[2]
var n := (b - a).cross(c - a).normalized()
# nach außen drehen: Fläche muss vom Körper-Mittelpunkt wegzeigen
if n.dot((a + b + c) / 3.0 - mitte) < 0.0:
var tmp := b
b = c
c = tmp
n = -n
pos.append_array(PackedVector3Array([a, b, c]))
nrm.append_array(PackedVector3Array([n, n, n]))
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = pos
arrays[Mesh.ARRAY_NORMAL] = nrm
var m := ArrayMesh.new()
m.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return m
static func _addon_mesh(kind: Dictionary) -> ArrayMesh:
var geo := Node3D.new()
geo.set_script(load(GEO_SCRIPT))
geo.set("piece_type", int(kind["typ"]))
geo.set("unit_size", float(kind.get("hoehe", WAND_H)))
geo.set("thickness", DICKE_M)
if kind.has("rise"):
geo.set("rise", float(kind["rise"]))
if kind.has("stufen"):
geo.set("stair_count", int(kind["stufen"]))
geo.set("stair_width", float(kind["breite"]))
var pts := PackedVector3Array()
for p in (kind["punkte"] as Array):
pts.append(Vector3(float(p[0]), float(p[1]), float(p[2])))
geo.set("custom_points", pts)
for d in (kind.get("details", []) as Array):
var detail := Node3D.new()
detail.set_script(load(DETAIL_SCRIPT))
detail.set("detail_type", int(d["typ"]))
detail.set("width", float(d["w"]))
detail.set("height", float(d["h"]))
detail.position = Vector3(float(d["pos"][0]), float(d["pos"][1]), float(d["pos"][2]))
geo.add_child(detail)
var m: ArrayMesh = geo.call("_make_mesh")
geo.free()
return m
## Alle Flächen zu EINER Fläche verschmelzen, Fußpunkt-normalisieren, Material-
## Farbe setzen.
static func _normalisieren(roh: ArrayMesh, farbe: Color) -> ArrayMesh:
var aabb := roh.get_aabb()
var versatz := Vector3(aabb.position.x + aabb.size.x * 0.5, aabb.position.y,
aabb.position.z + aabb.size.z * 0.5)
var pos := PackedVector3Array()
var nrm := PackedVector3Array()
for s in roh.get_surface_count():
var arr := roh.surface_get_arrays(s)
var v: PackedVector3Array = arr[Mesh.ARRAY_VERTEX]
var n: Variant = arr[Mesh.ARRAY_NORMAL]
var idx: Variant = arr[Mesh.ARRAY_INDEX]
var order: Array = []
if idx is PackedInt32Array and not (idx as PackedInt32Array).is_empty():
for i in (idx as PackedInt32Array):
order.append(i)
else:
for i in v.size():
order.append(i)
for i in order:
pos.append(v[i] - versatz)
nrm.append((n as PackedVector3Array)[i] if n is PackedVector3Array else Vector3.UP)
# Die Addon-Geometrie hat Dreiecke gegen die Normalenrichtung gewickelt (sie
# rendert ohne Backface-Culling). Godot culled Rückseiten → Wicklung an die
# Normale anpassen, sonst sieht man Innenseiten und Dächer werden schwarz.
for t in range(0, pos.size() - 2, 3):
var gn := (pos[t + 2] - pos[t]).cross(pos[t + 1] - pos[t])
if gn.dot(nrm[t]) < 0.0:
var tmp := pos[t + 1]
pos[t + 1] = pos[t + 2]
pos[t + 2] = tmp
var tn := nrm[t + 1]
nrm[t + 1] = nrm[t + 2]
nrm[t + 2] = tn
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = pos
arrays[Mesh.ARRAY_NORMAL] = nrm
var out := ArrayMesh.new()
out.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
var mat := StandardMaterial3D.new()
mat.albedo_color = farbe
mat.roughness = 0.9
out.surface_set_material(0, mat)
return out
static func dreiecke(oid: String) -> int:
var m := mesh(oid)
return int(m.get_faces().size() / 3) if m != null else 0
# ------------------------------------------------- Kollision / Registry ------
## Trimesh-Datei (library/kollisions/<oid>.json) — gleiche Felder wie die
## Pack-Dateien: Dreiecks-Suppe in Fußpunkt-Koordinaten. Türen, Fenster und
## Scharten sind echte Löcher (die Öffnung hat keine Dreiecke).
static func kollisions_json(oid: String) -> Dictionary:
var m := mesh(oid)
if m == null:
return {}
var verts: Array = []
for v in m.get_faces():
verts.append([snappedf(v.x, 0.0001), snappedf(v.y, 0.0001), snappedf(v.z, 0.0001)])
var aabb := m.get_aabb()
return {
"oid": oid,
"quelle_glb": "res://assets/objekte/military_desert/%s/%s.glb" % [KATEGORIE, oid],
"dreiecke": int(verts.size() / 3),
"aabb_min": [snappedf(aabb.position.x, 0.001), snappedf(aabb.position.y, 0.001), snappedf(aabb.position.z, 0.001)],
"aabb_max": [snappedf(aabb.end.x, 0.001), snappedf(aabb.end.y, 0.001), snappedf(aabb.end.z, 0.001)],
"vertices": verts,
}
## Registry-Eintrag (Fragment library/objects.d/bauteile.json).
static func registry_eintrag(oid: String) -> Dictionary:
var g := groesse(oid)
return {
"poly": dreiecke(oid),
"collision": {
"quelle": "kollisions/%s.json" % oid,
"shape": "trimesh",
"size": [snappedf(g.x, 0.001), snappedf(g.y, 0.001), snappedf(g.z, 0.001)],
},
"glb": "res://assets/objekte/military_desert/%s/%s.glb" % [KATEGORIE, oid],
"kategorie": KATEGORIE,
"name_de": label(oid),
}
## GLB schreiben (eigenes Mesh mit Vertex-Farben). Rückgabe: Godot-Fehlercode.
static func glb_schreiben(oid: String, pfad: String) -> int:
var m := mesh(oid)
if m == null:
return ERR_DOES_NOT_EXIST
var root := Node3D.new()
root.name = oid
var mi := MeshInstance3D.new()
mi.name = oid
mi.mesh = m
root.add_child(mi)
var gdoc := GLTFDocument.new()
var gstate := GLTFState.new()
var err := gdoc.append_from_scene(root, gstate)
if err == OK:
err = gdoc.write_to_filesystem(gstate, pfad)
root.free()
return err

View file

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

204
editor/bau_ops.gd Normal file
View file

@ -0,0 +1,204 @@
extends RefCounted
class_name BauOps
## Dokument-Operationen des Bauen-Modus (pure Funktionen, headless testbar).
##
## Ein Gebäude = eine Gruppe (GroupOps, Feld "group" + group_names) aus `bau_*`-
## Objekten. Alles Weitere — Verschieben, Drehen, Kopieren, Löschen, Symmetrie,
## Speichern — läuft über die normalen Objekt-Funktionen. Dieser Teil kennt nur,
## was speziell am Bauen ist: Teil setzen/ersetzen, Gebäude-Gruppen, Migration
## der alten "gebaeude"-Sektion (Karte t_97ea59c5 → Umbau 25.10.).
static func teile_zahl(doc: Dictionary) -> int:
var n := 0
for o in (doc.get("objects", []) as Array):
if o is Dictionary and BauKatalog.ist_bau(str((o as Dictionary).get("id", ""))):
n += 1
return n
## Dreiecke aller Bauteile (grobe Poly-Übersicht für die Statuszeile).
static func dreiecke_gesamt(doc: Dictionary) -> int:
var n := 0
for o in (doc.get("objects", []) as Array):
if o is Dictionary:
n += BauKatalog.dreiecke(str((o as Dictionary).get("id", "")))
return n
## Neue Gebäude-Gruppe anlegen und benennen. Rückgabe: Gruppen-ID.
static func gruppe_neu(doc: Dictionary) -> int:
var gid := GroupOps.next_group_id(doc)
GroupState.set_group_name(doc, gid, "Gebäude %d" % (gebaeude_zahl(doc) + 1))
return gid
## Anzahl Gebäude-Gruppen (Gruppen, deren erstes Mitglied ein Bauteil ist).
static func gebaeude_zahl(doc: Dictionary) -> int:
return gebaeude_gruppen(doc).size()
## [{gid, name, indizes, dreiecke}] für alle Gebäude-Gruppen.
static func gebaeude_gruppen(doc: Dictionary) -> Array:
var out: Array = []
var objects: Array = doc.get("objects", [])
for gid in GroupOps.active_group_ids(doc):
var members := GroupOps.members_of(doc, int(gid))
if members.is_empty():
continue
var bau := false
var tris := 0
for i in members:
var oid := str((objects[i] as Dictionary).get("id", ""))
if BauKatalog.ist_bau(oid):
bau = true
tris += BauKatalog.dreiecke(oid)
if bau:
out.append({"gid": int(gid), "name": GroupState.display_name(doc, int(gid)),
"indizes": members, "dreiecke": tris})
return out
## Teil setzen oder ersetzen (snap = BuildSnap.berechne-Ergebnis, ok=true).
## Mutiert doc["objects"]; der Aufrufer macht snapshot/commit.
## gid: Zielgruppe (Gebäude); bei Symmetrie bekommt der Zwilling ein eigenes
## Spiegel-Gebäude (spiegel_gid, vom Aufrufer gemerkt: 1 Original-Gebäude ↔ 1 Spiegel).
## Rückgabe: {index, twin, ersetzt}.
static func setze_teil(doc: Dictionary, oid: String, snap: Dictionary, gid: int,
sym_mode: int, spiegel_gid := -1) -> Dictionary:
var objects: Array = doc["objects"]
var pos: Vector3 = snap["pos"]
var rot := float(snap["rot"])
var ersetze := int(snap.get("ersetze", -1))
if ersetze >= 0 and ersetze < objects.size():
var o: Dictionary = objects[ersetze]
o["id"] = oid
o["pos"] = [pos.x, pos.y, pos.z]
SvmapIO.rot_setzen(o, Vector3(0.0, rot, 0.0))
# Symmetrie: der Zwilling bekommt dasselbe Teil an seiner (gespiegelten) Stelle.
var key := SymmetryCore.sym_of(o)
if sym_mode != SymmetryCore.Mode.OFF and not key.is_empty():
var partner := SymmetryCore.find_partner_index(objects, key, ersetze)
if partner >= 0 and objects[partner] is Dictionary:
(objects[partner] as Dictionary)["id"] = oid
return {"index": ersetze, "twin": -1, "ersetzt": true}
var neu := {"id": oid, "pos": [pos.x, pos.y, pos.z], "scale": 1.0, "rot_y_deg": rot, "group": gid}
objects.append(neu)
var index := objects.size() - 1
var twin := -1
if sym_mode != SymmetryCore.Mode.OFF:
var res: Dictionary = ObjectOps.symmetry_place_twin(doc, index, sym_mode)
twin = int(res.get("twin", -1))
if twin >= 0 and spiegel_gid >= 0:
(objects[twin] as Dictionary)["group"] = spiegel_gid
return {"index": index, "twin": twin, "ersetzt": false}
## Eine ganze Gebäude-Gruppe als Indizes (für „Ganzes Gebäude wählen“).
static func gebaeude_von(doc: Dictionary, index: int) -> Array:
var objects: Array = doc.get("objects", [])
if index < 0 or index >= objects.size():
return []
var gid := GroupOps.group_id(objects[index])
if gid == GroupOps.UNGROUPED:
return [index]
return GroupOps.members_of(doc, gid)
# ----------------------------------------------------------- Demo / Test ----
## Kleines Haus über die echte Klick-Logik setzen (Fundament, Wände, Tür, Fenster,
## Giebel, Dach, Treppe) — für Demo und Tests. Jeder Schritt = BuildSnap-Treffer
## wie im Editor. Rückgabe: Gruppen-ID. history kann null sein.
static func haus_demo(doc: Dictionary, history: Variant, mitte: Vector3) -> int:
var gid := gruppe_neu(doc)
var terrain := func(_x: float, _z: float) -> float: return mitte.y
var before: Dictionary = history.snapshot(["objects", "group_names"]) if history != null else {}
# 2×2 Zellen (4×4 m): Zellmitten bei (±1, ±1) relativ zur Mitte.
var ox := floorf(mitte.x / 2.0) * 2.0
var oz := floorf(mitte.z / 2.0) * 2.0
var schritte := [
["bau_fundament", Vector3(ox - 1, mitte.y, oz - 1), 0.0],
["bau_fundament", Vector3(ox + 1, mitte.y, oz - 1), 0.0],
["bau_fundament", Vector3(ox - 1, mitte.y, oz + 1), 0.0],
["bau_fundament", Vector3(ox + 1, mitte.y, oz + 1), 0.0],
# Wände liegen GANZ in der Zelle, in der die Maus ist (Außenseite bündig mit
# der Kante) — deshalb zielen die Klicks 10 cm ins Hausinnere.
["bau_wand", Vector3(ox - 1, mitte.y, oz - 1.9), 0.0],
["bau_wand_fenster", Vector3(ox + 1, mitte.y, oz - 1.9), 0.0],
["bau_wand_tuer", Vector3(ox - 1, mitte.y, oz + 1.9), 0.0],
["bau_wand", Vector3(ox + 1, mitte.y, oz + 1.9), 0.0],
["bau_wand", Vector3(ox - 1.9, mitte.y, oz - 1), 90.0],
["bau_wand_scharte", Vector3(ox - 1.9, mitte.y, oz + 1), 90.0],
["bau_wand", Vector3(ox + 1.9, mitte.y, oz - 1), 90.0],
["bau_wand", Vector3(ox + 1.9, mitte.y, oz + 1), 90.0],
]
for st in schritte:
_demo_setzen(doc, str(st[0]), st[1] as Vector3, float(st[2]), gid, terrain)
# Ein großes Satteldach (4×4 m) auf die Wandoberkante.
var oben := mitte.y + BauKatalog.WAND_H
_demo_setzen(doc, "bau_dach_giebel_4", Vector3(ox, oben, oz), 0.0, gid, terrain)
if history != null:
history.commit("Haus gebaut (Demo)", before, history.snapshot(["objects", "group_names"]))
return gid
static func _demo_setzen(doc: Dictionary, oid: String, hit: Vector3, dreh: float, gid: int,
gelaende: Callable) -> void:
var teile := BuildSnap.sammle_teile(doc)
var snap := BuildSnap.berechne(oid, hit, dreh, teile, gelaende)
if bool(snap["ok"]):
setze_teil(doc, oid, snap, gid, SymmetryCore.Mode.OFF)
# --------------------------------------------------------------- Migration --
## Alte Sektion doc["gebaeude"] (Karte t_97ea59c5, 10 prozedurale Teile ohne
## Registry) → bau_*-Objekte in EINER Gruppe, dann Sektion entfernen. Beste
## Näherung: Teil-Ursprünge der alten Teile lagen an Wandanfang/Zellmitte, die
## neuen Bauteile an der Mitte der Grundfläche; Höhen (2 m → 3 m) ändern sich.
## Rückgabe: Anzahl übernommener Teile.
const _ALT := {
"fundament": "bau_fundament", "boden": "bau_boden", "wand": "bau_wand",
"tuer": "bau_wand_tuer", "fenster": "bau_wand_fenster",
"giebelwand": "bau_giebelwand", "giebelwand_rechts": "bau_giebelwand",
"dach_giebel": "bau_dach_giebel", "dach_pyramide": "bau_dach_pyramide",
"treppe": "bau_treppe",
}
static func migriere_gebaeude(doc: Dictionary) -> int:
if not doc.has("gebaeude"):
return 0
var alt: Variant = doc["gebaeude"]
doc.erase("gebaeude")
if not (alt is Array) or (alt as Array).is_empty():
return 0
if not doc.has("objects") or not (doc["objects"] is Array):
doc["objects"] = []
var objects: Array = doc["objects"]
var gid := gruppe_neu(doc)
var n := 0
for teil in alt:
if not (teil is Dictionary):
continue
var oid: String = _ALT.get(str((teil as Dictionary).get("id", "")), "")
if oid.is_empty():
continue
var p: Array = (teil as Dictionary).get("pos", [0.0, 0.0, 0.0])
var rot := float((teil as Dictionary).get("rot", 0.0))
var pos := Vector3(float(p[0]), float(p[1]), float(p[2]))
var local := Vector3.ZERO
match str((teil as Dictionary).get("id", "")):
"wand", "tuer", "fenster", "giebelwand", "giebelwand_rechts":
local = Vector3(1.0, 0.0, 0.0) # alt: Ursprung am Wandanfang
"treppe":
local = Vector3(2.0, 0.0, 0.0) # alt: 2 m lang ab Ursprung
"boden":
pos.y -= BauKatalog.DICKE_M # alt: Oberkante am Ursprung
pos += Basis(Vector3.UP, deg_to_rad(rot)) * local
objects.append({"id": oid, "pos": [pos.x, pos.y, pos.z], "scale": 1.0,
"rot_y_deg": fposmod(rot, 360.0), "group": gid})
n += 1
if n == 0:
GroupState.set_group_name(doc, gid, "")
return n

1
editor/bau_ops.gd.uid Normal file
View file

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

256
editor/build_snap.gd Normal file
View file

@ -0,0 +1,256 @@
extends RefCounted
class_name BuildSnap
## Raster- und Ebenen-Logik des Bauen-Modus (pure Funktionen, headless testbar).
##
## Warum neu: die alte Logik rastete jedes Teil NUR auf eine Zellecke und nahm
## als Höhe die Klickhöhe — ein Klick auf die Seite einer Wand setzte das neue
## Teil mitten in die Luft, Wände ließen sich nur über Rotationsraten an die
## richtige Kante bringen. Jetzt:
## • XZ: Zellen-Teile rasten in die Zellmitte, Wände auf die NÄCHSTE Zellkante
## (Drehung ergibt sich aus der Kante), Pfeiler auf die nächste Ecke,
## Treppen auf eine 4×2-m-Fläche über zwei Zellen.
## • Y: nie die Klickhöhe, sondern eine EBENE: Gelände (höchster Punkt unter
## der Grundfläche) oder Oberkante eines tragenden Bauteils darunter. Gewählt
## wird die höchste Ebene, die nicht mehr als TOL_M über dem Klickpunkt liegt.
## • Ein gleichartiges Teil auf demselben Platz wird ERSETZT (Tür in eine
## vorhandene Wand einsetzen); Klick auf die Oberkante stapelt stattdessen.
##
## Position eines Teils = Fußpunkt (Unterkante, Mitte der Grundfläche) — wie
## jedes Bibliotheks-Objekt (BauKatalog.mesh ist so normalisiert).
const TOL_M := 0.35 # so weit darf eine Ebene ÜBER dem Klickpunkt liegen
const SHRINK_M := 0.05 # Grundflächen-Schnitt: minimale Überlappung ignorieren
const SAME_M := 0.08 # Toleranz für "gleiche Stelle"
const UEBERSTAND_M := 0.01 # Wand-Außenseite steht 1 cm vor der Plattenkante: keine deckungsgleichen
# Flächen (Z-Fighting/Flackern an Boden↔Wand)
## Ergebnis-Wörterbuch:
## ok false = hier nicht setzbar (grund erklärt warum)
## pos Vector3 Fußpunkt
## rot Y-Drehung in Grad
## ersetze Index des Objekts, das ersetzt wird (-1 = neues Teil)
## ebene gewählte Höhe (= pos.y)
## grund Klartext (bei ok=false) bzw. Hinweis
##
## teile: Array von {"i": Index, "oid": String, "pos": Vector3, "rot": float}
## (nur Bauteile, siehe sammle_teile).
## gelaende: Callable(x: float, z: float) -> float
## dreh: Nutzerdrehung in Grad (wird auf 90° gerastet)
## kamera_dir: Blickrichtung des Klickstrahls (für Zell-Wahl an Wandkanten), optional
## ebene_fix: feste Geschosshöhe (Geschoss-Wahl im Bauen-Tab); NAN = automatisch.
## Dann hängt die Höhe NICHT mehr an Wänden/Böden darunter — Decken und Böden
## lassen sich auch mitten im großen Raum setzen.
static func berechne(oid: String, hit: Vector3, dreh: float, teile: Array,
gelaende: Callable, hit_ist_objekt := false, kamera_dir := Vector3.ZERO,
ebene_fix := NAN) -> Dictionary:
var art := BauKatalog.snap_art(oid)
var gr := BauKatalog.groesse(oid)
var q := int(roundf(dreh / 90.0))
var viertel := ((q % 4) + 4) % 4
var p := Vector2(hit.x, hit.z)
# Treffer auf einem Objekt: Wände liegen GANZ in einer Zelle — die Quer-Achse
# kommt dann von der Wand (Decke/Dach landet in der Zelle der Wand). Bei anderen
# Objekten die Zelle auf der Kameraseite wählen (Klick liegt auf einer Grenze).
var wand_getroffen := {}
if hit_ist_objekt:
wand_getroffen = _wand_unter_klick(teile, p, hit.y)
if hit_ist_objekt and art != "kante":
if not wand_getroffen.is_empty():
var wp: Vector3 = wand_getroffen["pos"]
if absf(sin(deg_to_rad(float(wand_getroffen["rot"])))) > 0.5:
p.x = wp.x
else:
p.y = wp.z
elif kamera_dir != Vector3.ZERO:
var d2 := Vector2(kamera_dir.x, kamera_dir.z)
if d2.length() > 0.01:
p -= d2.normalized() * 0.2
var mitte := Vector2.ZERO
var rot := 0.0
var r := BauKatalog.RASTER_M
match art:
"zelle":
# Teile über mehrere Zellen (große Dächer): gerade Zellzahl → Mitte auf
# einer Zellecke, ungerade → Mitte einer Zelle.
mitte = Vector2(_raster_mitte(p.x, gr.x), _raster_mitte(p.y, gr.z))
rot = viertel * 90.0
"ecke":
mitte = Vector2(roundf(p.x / r) * r, roundf(p.y / r) * r)
"treppe":
rot = viertel * 90.0
if viertel % 2 == 0: # Lauf entlang X: Mitte auf gerader X-, ungerader Z-Linie
mitte = Vector2(roundf(p.x / r) * r, floorf(p.y / r) * r + r * 0.5)
else:
mitte = Vector2(floorf(p.x / r) * r + r * 0.5, roundf(p.y / r) * r)
_: # "kante"
var vorhanden := wand_getroffen
if not vorhanden.is_empty():
# Klick auf eine vorhandene Wand (Seite = ersetzen, Oberkante = stapeln):
# GENAU ihre Lage übernehmen, nicht neu aus der Klickstelle raten.
var tp: Vector3 = vorhanden["pos"]
mitte = Vector2(tp.x, tp.z)
rot = 90.0 if absf(sin(deg_to_rad(float(vorhanden["rot"])))) > 0.5 else 0.0
else:
# Wand liegt GANZ IN der Zelle, in der der Mauszeiger ist: Außenseite
# bündig mit der Zellkante (Boden/Fundament), nicht halb in der Luft.
# Dadurch schließen sich auch Ecken ohne Lücke (Test-Befund 25.10.).
var laenge := gr.x
var halbe_dicke := gr.z * 0.5
var fx := p.x / r
var fz := p.y / r
var h_mitte := Vector2(_raster_mitte(p.x, laenge), roundf(fz) * r) # Kante entlang X
var v_mitte := Vector2(roundf(fx) * r, _raster_mitte(p.y, laenge)) # Kante entlang Z
if p.distance_squared_to(h_mitte) <= p.distance_squared_to(v_mitte):
mitte = h_mitte
mitte.y += (halbe_dicke - UEBERSTAND_M) * (1.0 if p.y >= h_mitte.y else -1.0)
rot = 0.0
else:
mitte = v_mitte
mitte.x += (halbe_dicke - UEBERSTAND_M) * (1.0 if p.x >= v_mitte.x else -1.0)
rot = 90.0
rot += 180.0 * float(viertel % 2) # R dreht die Wand um (Blickseite der Teile)
rot = fposmod(rot, 360.0)
var quer := absf(sin(deg_to_rad(rot))) > 0.5 # 90°/270°: X- und Z-Ausdehnung getauscht
var half := Vector2(gr.z, gr.x) * 0.5 if quer else Vector2(gr.x, gr.z) * 0.5
var rect := Rect2(mitte - half, half * 2.0)
# Ebenen sammeln: Gelände + Oberkanten tragender Teile unter der Grundfläche.
var gel_max := -INF
for sx in [0.0, 0.5, 1.0]:
for sz in [0.0, 0.5, 1.0]:
gel_max = maxf(gel_max, float(gelaende.call(rect.position.x + rect.size.x * sx,
rect.position.y + rect.size.y * sz)))
var ebenen: Array = [gel_max]
var kern := rect.grow(-SHRINK_M)
for t in teile:
var troid := str(t["oid"])
var tr := teil_rect(t)
if not tr.intersects(kern):
continue
var top: float = (t["pos"] as Vector3).y + BauKatalog.groesse(troid).y
if not BauKatalog.ist_traeger(troid):
continue
if (art == "kante" or art == "ecke") and BauKatalog.ist_platte(troid):
# Wände/Pfeiler zählen von der UNTERKANTE einer Platte (Boden/Decke/
# Fundament): jedes Geschoss liegt dadurch auf Vielfachen der Wandhöhe —
# mit oder ohne Decke darunter sitzen obere Wände gleich hoch
# (Test-Befund 25.10.). Die Platte selbst liegt auf der Wandoberkante.
ebenen.append((t["pos"] as Vector3).y)
else:
ebenen.append(top)
var ebene := ebene_fix if not is_nan(ebene_fix) else _waehle_ebene(ebenen, hit.y)
# Platz belegt? Gleiche Art + gleiche Stelle + gleiche Ebene → ersetzen/ablehnen.
var ersetze := -1
for t in teile:
var troid := str(t["oid"])
if BauKatalog.snap_art(troid) != art:
continue
var tp: Vector3 = t["pos"]
if absf(tp.y - ebene) > SAME_M:
continue
if Vector2(tp.x, tp.z).distance_to(mitte) > SAME_M:
continue
if art == "kante" and absf(fposmod(float(t["rot"]), 180.0) - fposmod(rot, 180.0)) > 1.0:
continue
if art == "treppe" and absf(fposmod(float(t["rot"]), 360.0) - rot) > 1.0:
continue
if troid == oid and absf(fposmod(float(t["rot"]) - rot, 360.0)) < 1.0:
return {"ok": false, "pos": Vector3(mitte.x, ebene, mitte.y), "rot": rot, "ersetze": -1,
"ebene": ebene, "grund": "Hier steht schon %s." % BauKatalog.label(oid)}
ersetze = int(t["i"])
break
return {"ok": true, "pos": Vector3(mitte.x, ebene, mitte.y), "rot": rot, "ersetze": ersetze,
"ebene": ebene, "grund": ""}
## Mitte eines Teils der Länge `laenge` entlang einer Achse: gerade Zellzahl →
## auf der nächsten Zellgrenze, ungerade → Mitte der Zelle.
static func _raster_mitte(wert: float, laenge: float) -> float:
var r := BauKatalog.RASTER_M
var zellen := maxi(1, int(roundf(laenge / r)))
if zellen % 2 == 0:
return roundf(wert / r) * r
return floorf(wert / r) * r + r * 0.5
## Vorhandene Wand, auf die der Klick trifft (Seite oder Oberkante): Rückgabe das
## Teil-Wörterbuch oder {}. `p` = Klickpunkt in XZ, `y` = Klickhöhe.
static func _wand_unter_klick(teile: Array, p: Vector2, y: float) -> Dictionary:
var bester := {}
var bester_d := INF
for t in teile:
var troid := str(t["oid"])
if BauKatalog.snap_art(troid) != "kante":
continue
var tr := teil_rect(t).grow(0.06)
if not tr.has_point(p):
continue
var unten: float = (t["pos"] as Vector3).y
var oben := unten + BauKatalog.groesse(troid).y
if y < unten - 0.1 or y > oben + 0.1:
continue
var d := p.distance_squared_to(tr.get_center())
if d < bester_d:
bester_d = d
bester = t
return bester
## Höchste Ebene ≤ hit_y + TOL; gibt es keine (Klick tief unter allen), die tiefste.
static func _waehle_ebene(ebenen: Array, hit_y: float) -> float:
var best := -INF
var tiefste := INF
for e in ebenen:
var ev := float(e)
tiefste = minf(tiefste, ev)
if ev <= hit_y + TOL_M and ev > best:
best = ev
return best if best > -INF else tiefste
## Grundfläche (XZ) eines gesetzten Bauteils in Weltkoordinaten.
static func teil_rect(t: Dictionary) -> Rect2:
var gr := BauKatalog.groesse(str(t["oid"]))
var rot := float(t["rot"])
var quer := absf(sin(deg_to_rad(rot))) > 0.5
var half := Vector2(gr.z, gr.x) * 0.5 if quer else Vector2(gr.x, gr.z) * 0.5
var pos: Vector3 = t["pos"]
return Rect2(Vector2(pos.x, pos.z) - half, half * 2.0)
## Alle Bauteile aus doc["objects"] (Index, oid, pos, rot) — nur `bau_*`.
static func sammle_teile(doc: Dictionary) -> Array:
var out: Array = []
var objects: Array = doc.get("objects", [])
for i in objects.size():
var o: Variant = objects[i]
if not (o is Dictionary):
continue
var oid := str((o as Dictionary).get("id", ""))
if not BauKatalog.ist_bau(oid):
continue
var pos: Array = (o as Dictionary).get("pos", [0.0, 0.0, 0.0])
if pos.size() != 3:
continue
out.append({
"i": i, "oid": oid,
"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
"rot": float(SvmapIO.objekt_rotation(o as Dictionary).y),
})
return out
## Höhe des untersten Geschosses (= Fußpunkt der tiefsten Platte/Wand im Umkreis von
## `radius` um `p`), sonst das Gelände bei `p`. Geschoss k liegt bei Basis + k·WAND_H.
static func geschoss_basis(teile: Array, p: Vector2, gelaende: Callable, radius := 40.0) -> float:
var tiefste := INF
for t in teile:
var tp: Vector3 = t["pos"]
if Vector2(tp.x, tp.z).distance_to(p) > radius:
continue
var oid := str(t["oid"])
if BauKatalog.ist_platte(oid) or BauKatalog.snap_art(oid) == "kante":
tiefste = minf(tiefste, tp.y)
return tiefste if tiefste < INF else float(gelaende.call(p.x, p.y))

1
editor/build_snap.gd.uid Normal file
View file

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

71
editor/fly_math.gd Normal file
View file

@ -0,0 +1,71 @@
extends RefCounted
class_name FlyMath
## Freelook-Flug-Mathe (Karte t_c46d31c2, Karte 1/3) — bewusst pure Funktionen
## (kein Knoten, keine Kamera): speed_for/wheel_speed/move_delta/forward- und
## Euler-Umrechnung. tests/test_shortcuts.gd prüft headless; orbit_camera.gd
## ist nur der dünne Anwender.
##
## Euler-Konvention identisch zur Orbit-Kamera (_apply in orbit_camera.gd):
## Blickrichtung forward(yaw, pitch) =
## (−sin(yaw)·cos(pitch), −sin(pitch), −cos(yaw)·cos(pitch))
## pitch positiv = Kamera schaut von OBEN herab (Orbit-Pitch 5…85°),
## im Flug erlaubt: −85…+85° (auch von unten hochschauen).
const BASE_SPEED_MPS := 30.0 # Grundgeschwindigkeit (Mausrad ändert sie dauerhaft)
const FAST_MULT := 3.0 # Strg halten = schneller (Shooter-Konvention)
const SLOW_MULT := 0.3 # Alt halten = langsamer
const WHEEL_MULT := 1.15 # Mausrad-Tick: +15 % / −13 %
const SPEED_MIN_MPS := 2.0
const SPEED_MAX_MPS := 300.0
const PITCH_MAX_DEG := 85.0 # Flug-Blick grenzwertig (±) — kein Gimbal-Kippen
## Flug-Tempo: Strg dominiert, wenn (unüblicherweise) beide Modifier unten sind.
static func speed_for(base_mps: float, fast: bool, slow: bool) -> float:
if fast:
return base_mps * FAST_MULT
if slow:
return base_mps * SLOW_MULT
return base_mps
## Mausrad-Tick: Grundgeschwindigkeit DAUERHAFT ändern (nicht nur solange
## gedrückt) — up=true schneller, down=false langsamer, immer geklemmt.
static func wheel_speed(base_mps: float, up: bool) -> float:
var next := base_mps * WHEEL_MULT if up else base_mps / WHEEL_MULT
return clampf(next, SPEED_MIN_MPS, SPEED_MAX_MPS)
## Bewegungs-Delta für einen Frame. forward = BLICKRICHTUNG (3D, mit Pitch —
## W/S fliegen dorthin, wo man hinsieht), right = horizontal-rechts, und
## vertikal ist Welt-UP (Leertaste/Shift heben/senken unabhängig vom Blick —
## Shooter-Konvention, Sly-Wunsch 17.10.).
## Diagonal-Eingaben werden normalisiert (kein √2-Tempo). Rückgabe: Meter.
static func move_delta(forward: Vector3, right: Vector3, fwd_input: float,
side_input: float, vert_input: float, speed_mps: float, delta_s: float) -> Vector3:
var dir := forward * fwd_input + right * side_input + Vector3.UP * vert_input
if dir.length_squared() < 0.0000001:
return Vector3.ZERO
return dir.normalized() * speed_mps * delta_s
## Blickrichtung → (yaw_deg, pitch_deg) in der Orbit-Konvention (siehe oben).
static func euler_from_forward(forward: Vector3) -> Vector2:
var f := forward
if f.length_squared() < 0.0000001:
return Vector2(0.0, 0.0)
f = f.normalized()
var pitch := rad_to_deg(asin(clampf(-f.y, -1.0, 1.0)))
var yaw := rad_to_deg(atan2(-f.x, -f.z))
return Vector2(yaw, pitch)
## (yaw_deg, pitch_deg) → Blickrichtung (Inverse von euler_from_forward).
static func forward_from_euler(yaw_deg: float, pitch_deg: float) -> Vector3:
var yaw := deg_to_rad(yaw_deg)
var pitch := deg_to_rad(clampf(pitch_deg, -PITCH_MAX_DEG, PITCH_MAX_DEG))
return Vector3(
-sin(yaw) * cos(pitch),
-sin(pitch),
-cos(yaw) * cos(pitch))

1
editor/fly_math.gd.uid Normal file
View file

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

1729
editor/interaction.gd Normal file

File diff suppressed because it is too large Load diff

View file

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

167
editor/library_browser.gd Normal file
View file

@ -0,0 +1,167 @@
extends RefCounted
class_name LibraryBrowser
## Objekt-Bibliothek des Editors (Karte t_3a998ae4): deutscher Name, Gruppen
## für den Bibliothek-Browser und Thumbnail-Laden (128-px-PNGs, einmalig per
## Headless-Render erzeugt — tools/gen_thumbs.gd).
##
## Bewusst OHNE UI: reine Daten/Funktionen, damit die Tests (tests/test_library.gd)
## headless alles prüfen können. Die IDs sind der Vertrag (Entscheidung 20) —
## Gruppen/Namen leben HIER im Editor (die objects.json bleibt 1:1 die Kopie
## der Server-Registry, damit ein Sync sie nicht verwüstet).
## Deutsche Anzeige-Namen für Bibliotheks-IDs, die KEIN `name_de` im Manifest
## haben (IDs sind der Vertrag, Entscheidung 20). Seit der Military-Pack-
## Umstellung (25.10.2026) hat jedes Objekt ein `name_de` — die Liste bleibt
## leer und dient nur als Override-Möglichkeit.
const NAMES := {}
## Fallback-Gruppe für Objekte ohne bekannte Kategorie.
const GROUP_ORDER := ["sonstiges"]
const GROUP_LABELS := {
"sonstiges": "Sonstiges",
}
## Kategorien des Military-Desert-Packs (Manifest-Feld `kategorie`, siehe
## library/objects.json; Quelle der Slugs/Namen: scopedvr-match-server
## tools/pack_katalog.py). Reihenfolge = Anzeige-Reihenfolge. Unbekannte
## Kategorien (z. B. spätere Bauteil-Module) tauchen automatisch mit ihrem
## Slug als Gruppe auf, ganz vor "Sonstiges".
const KAT_ORDER := ["bauteile", "panzer", "radfahrzeuge", "luftfahrzeuge", "stellungen", "fahrzeugteile", "gebaeude", "zelte", "tuerme", "mauern", "sperren", "kisten", "technik", "ausruestung", "training", "baeume", "pflanzen", "felsen", "boden", "decals"]
const KAT_GROUPS := {
"bauteile": {"key": "bauteile", "label": "Bauteile (eigene Häuser)"},
"panzer": {"key": "panzer", "label": "Panzer & Transportpanzer"},
"radfahrzeuge": {"key": "radfahrzeuge", "label": "Radfahrzeuge"},
"luftfahrzeuge": {"key": "luftfahrzeuge", "label": "Flugzeuge & Hubschrauber"},
"stellungen": {"key": "stellungen", "label": "Geschütze & Stellungen"},
"fahrzeugteile": {"key": "fahrzeugteile", "label": "Fahrzeugteile"},
"gebaeude": {"key": "gebaeude", "label": "Gebäude & Hangars"},
"zelte": {"key": "zelte", "label": "Zelte"},
"tuerme": {"key": "tuerme", "label": "Türme & Masten"},
"mauern": {"key": "mauern", "label": "Mauern & Wände"},
"sperren": {"key": "sperren", "label": "Sperren & Zäune"},
"kisten": {"key": "kisten", "label": "Kisten, Fässer & Container"},
"technik": {"key": "technik", "label": "Technik & Funk"},
"ausruestung": {"key": "ausruestung", "label": "Ausrüstung & Zubehör"},
"training": {"key": "training", "label": "Training & Ziele"},
"baeume": {"key": "baeume", "label": "Bäume & Totholz"},
"pflanzen": {"key": "pflanzen", "label": "Pflanzen, Kakteen & Gras"},
"felsen": {"key": "felsen", "label": "Felsen & Steine"},
"boden": {"key": "boden", "label": "Boden, Straßen & Dünen"},
"decals": {"key": "decals", "label": "Boden-Decals"},
}
## Anzeige-Größe der Vorschaubilder im Browser (Karte t_59fc0f21): vorher 40 px,
## jetzt Kacheln mit 128 px (25.10.: größer, deutlicher) (128-px-PNGs werden auf die Anzeige-Größe skaliert).
const THUMB_DISPLAY_PX := 128
## Manifest-Cache (vom Editor beim Laden gesetzt): damit object_label() und
## group_of() auch ohne Manifest-Parameter den deutschen Namen / die Kategorie
## kennen (Statuszeile, Szenen-Baum, Inspector).
static var _manifest_cache: Dictionary = {}
static func setze_manifest(manifest: Dictionary) -> void:
_manifest_cache = (manifest.get("objects", {}) as Dictionary) if manifest.has("objects") else {}
static func object_label(oid: String) -> String:
if BauKatalog.ist_bau(oid):
return BauKatalog.label(oid)
if NAMES.has(oid):
return str(NAMES[oid])
var entry: Dictionary = _manifest_cache.get(oid, {}) as Dictionary
var name_de := str(entry.get("name_de", ""))
if not name_de.is_empty():
return name_de
return oid.capitalize()
## Gruppe einer ID (ohne Manifest-Eintrag → "sonstiges").
static func group_of(oid: String) -> String:
if BauKatalog.ist_bau(oid):
return "bauteile"
var entry: Dictionary = _manifest_cache.get(oid, {}) as Dictionary
return group_of_entry(oid, entry)
## Gruppe eines Katalog-Eintrags: Manifest-Feld `kategorie` (KAT_GROUPS);
## unbekannte Kategorie → eigene Gruppe mit dem Slug als Schlüssel; kein
## Eintrag/keine Kategorie → "sonstiges". entry darf leer sein.
static func group_of_entry(_oid: String, entry: Dictionary) -> String:
var kat := str(entry.get("kategorie", ""))
if kat.is_empty():
return "sonstiges"
if KAT_GROUPS.has(kat):
return str(KAT_GROUPS[kat]["key"])
return kat
## Deutscher Anzeigename: Manifest-Feld `name_de`, sonst NAMES/fallback.
static func entry_label(oid: String, entry: Dictionary) -> String:
var name_de := str(entry.get("name_de", ""))
if not name_de.is_empty():
return name_de
return object_label(oid)
## Gruppen für den Browser, geprüft gegen das Manifest: nur Gruppen mit
## Objekten erscheinen. Unbekannte Kategorien wandern als eigene Gruppe vor
## "Sonstiges" — neue Bibliotheks-Einträge brechen die UI nicht.
static func groups_in_order(manifest: Dictionary) -> Array:
var lib: Dictionary = manifest.get("objects", {})
var by_group := {}
for oid in lib.keys():
var entry: Dictionary = lib.get(oid, {}) as Dictionary
var key := group_of_entry(str(oid), entry)
if not by_group.has(key):
by_group[key] = []
(by_group[key] as Array).append(str(oid))
var reihenfolge: Array = KAT_ORDER.duplicate()
var fremde: Array = []
for key in by_group.keys():
if not reihenfolge.has(key) and not GROUP_ORDER.has(key):
fremde.append(key)
fremde.sort()
reihenfolge.append_array(fremde)
reihenfolge.append_array(GROUP_ORDER)
var out: Array = []
for key in reihenfolge:
var ids: Array = (by_group.get(key, []) as Array)
ids.sort()
if ids.is_empty() and key != "sonstiges":
continue
out.append({"key": key, "label": _group_label(key), "ids": ids})
return out
static func _group_label(key: String) -> String:
if GROUP_LABELS.has(key):
return str(GROUP_LABELS[key])
for kat in KAT_GROUPS.values():
if str(kat["key"]) == key:
return str(kat["label"])
return key.capitalize()
## Thumbnail-Pfad (128-px-PNG, tools/gen_thumbs.gd erzeugt sie einmalig).
static func thumb_path(oid: String) -> String:
return "res://core/thumbs/%s.png" % oid
## Thumbnail als Textur laden — direkt über Image (kein Import-System nötig,
## geht damit auch frisch nach git pull ohne Import), auf Anzeige-Größe
## runtergerechnet. size_px steuert die Anzeige-Größe (Default = THUMB_DISPLAY_PX
## = 96, Karte t_59fc0f21; vorher fest 40). null = noch kein Thumbnail →
## Button fällt aufs Text-Layout zurück (Fehler sieht man nicht, man sieht
## einfach nur kein Bild).
static func load_thumb(oid: String, size_px := THUMB_DISPLAY_PX) -> Texture2D:
var path := thumb_path(oid)
if not FileAccess.file_exists(path):
return null
var img := Image.load_from_file(path)
if img == null:
return null
if img.get_width() != size_px or img.get_height() != size_px:
img.resize(size_px, size_px, Image.INTERPOLATE_LANCZOS)
return ImageTexture.create_from_image(img)

View file

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

39
editor/mouse_pointer.gd Normal file
View file

@ -0,0 +1,39 @@
class_name MousePointer
extends RefCounted
## Übersetzt Maus und Esc-Taste in PointerEvent. Die rechte Maustaste fehlt
## absichtlich: sie gehört dem Freelook-Flug (orbit_camera.gd).
static func from_input(event: InputEvent) -> PointerEvent:
if event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
var p := PointerEvent.make(PointerEvent.Kind.MOVE, mm.position)
p.shift = mm.shift_pressed
p.ctrl = mm.ctrl_pressed
p.command = mm.is_command_or_control_pressed()
p.pressure = mm.pressure
return p
if event is InputEventMouseButton:
var mb := event as InputEventMouseButton
var kind := PointerEvent.Kind.MOVE
var scroll := 0
match mb.button_index:
MOUSE_BUTTON_LEFT:
kind = PointerEvent.Kind.PRESS if mb.pressed else PointerEvent.Kind.RELEASE
MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN:
if not mb.pressed:
return null
kind = PointerEvent.Kind.SCROLL
scroll = 1 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else -1
_:
return null
var p := PointerEvent.make(kind, mb.position)
p.shift = mb.shift_pressed
p.ctrl = mb.ctrl_pressed
p.command = mb.is_command_or_control_pressed()
p.scroll = scroll
return p
if event is InputEventKey and (event as InputEventKey).pressed \
and (event as InputEventKey).keycode == KEY_ESCAPE:
return PointerEvent.make(PointerEvent.Kind.CANCEL)
return null

View file

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

View file

@ -0,0 +1,461 @@
class_name PlaytestAvatar
extends Node3D
## Playtest-Avatar (Karte t_17a35c59) — SPIEGEL des Bot-Avatars aus dem
## Quest-Client (scripts/match/bot_avatar.gd + MatchRemoteAvatar), Sly-Wunsch
## 20.10.: „das Player Model in den Map Editor … genau so wie im Spiel bei
## den Bots" + „direkt natürlich auch mit den Animationen bitte!".
##
## Quelle der Wahrheit: bot_avatar.gd. Dieses Modul übernimmt 1:1:
## - das GLB assets/avatars/quaternius_ual/ual1_standard.glb (Quaternius
## UAL, CC0) + das Rifle assets/weapons/m40_rifle.glb an hand_r —
## aufgelöst über den NACHBAR-KLON ../scopedvr-client (gleiche Auflösung
## wie GlbMeshStore/TestRun), NICHT dupliziert. Lädt mit GLTFDocument
## (die GLB liegt außerhalb res:// — kein Import-System).
## - die AnimationTree-Pipeline (A4/W14): BlendSpace1D Idle/Walk/Jog mit
## Blend-Punkten auf den Clip-Naturgeschwindigkeiten (0,0 / 0,979 /
## 2,620 m/s), sync = true, TimeScale-Subtrees für die Kadenz, darüber
## die StateMachine mit dem Motion-State. Crossfade ~0,2 s.
## - die Kadenz-Mathe (BOT2/vc23): Kadenz = m/s / Ziel-Schrittlänge
## (Walk 0,95 m / Jog 0,85 m), TimeScale = Kadenz × Zykluslänge / 2
## Schritte pro Zyklus, Klammern CADENCE 1,0–4,5 und FOOT_SYNC 0,6–2,0.
## - die Gait-Schwellen (A2 §6.2): ≤ 0,3 m/s Idle, ≤ 2,6 m/s Walk, sonst
## Jog — im Editor aus der ECHTEN PlaytestBody-Geschwindigkeit gespeist.
## - die Frontachse (vc21-Fix): das UAL-GLB blickt nach +Z, der Body-Yaw
## richtet −Z aus → MODEL_YAW_OFFSET = π auf dem Modell-Kind. Blick-
## richtung == Bewegungsrichtung.
## - den Blend-Ruckel-Fix: Sollwerte werden in _process weich nachgezogen
## (BLEND_SMOOTH_S = 0,25 s), Parameter-Writes nur bei Änderung.
##
## NICHT gespiegelt (Match-only, kein Trigger im Editor-Testlauf): Engage/
## Shoot/Hit/Death-One-Shots, Team-Tönung, Namensschild, Netzwerk-Interpo-
## lation, Ragdoll. Die StateMachine trägt deshalb nur den Motion-State —
## die Loop-Pipeline (die Sly sieht) ist identisch zum Spiel.
##
## Regel (wie KolliderBau/svmap_tint): Ändert der Client die Pipeline, wird
## dieser Spiegel MIT geändert. Bewusst KEINE Editor-eigenen Maße oder
## Anim-Parameter — Proportionen/Konstanten strikt aus der Client-Quelle.
##
## Fehlt der Client-Klon (oder die GLB): lade_avatar() gibt null zurück —
## PlaytestMode bleibt dann komplett in First Person (graceful, kein Crash).
## ------------------------------------------------ Quelle (Client-Klon) ----
## Relative Pfade IM Client-Repo (Nachbar-Klon, nicht dupliziert).
const MODEL_REL := "assets/avatars/quaternius_ual/ual1_standard.glb"
const RIFLE_REL := "assets/weapons/m40_rifle.glb"
## ------------------------------------- Clips (MatchRemoteAvatar-Erbe) -----
const CLIP_IDLE := "Idle"
const CLIP_WALK := "Walk"
const CLIP_JOG := "Jog_Fwd"
## Server-/Inferenz-States (A1-Vertrag, bot_avatar.gd): 0=IDLE 1=WALK 2=JOG.
const STATE_IDLE := 0
const STATE_WALK := 1
const STATE_JOG := 2
## Gait-Schwellen (A2 §6.2, bot_avatar.gd): Velocity → State.
const IDLE_SPEED_MAX_MPS := 0.3
const WALK_SPEED_MAX_MPS := 2.6
## Blend-Punkte = Clip-Naturgeschwindigkeiten (H1-Messung, bot_avatar.gd).
const WALK_NATURAL_MPS := 0.979
const JOG_NATURAL_MPS := 2.620
const BLEND_IDLE_MPS := 0.0
const BLEND_MAX_MPS := 4.0
## Kadenz-Kalibrierung (BOT2/vc23, bot_avatar.gd): Ziel-Schrittlängen + Klammern.
const WALK_STEP_M := 0.95
const JOG_STEP_M := 0.85
const CADENCE_MIN := 1.0
const CADENCE_MAX := 4.5
const FOOT_SYNC_MIN := 0.6
const FOOT_SYNC_MAX := 2.0
## Crossfade (A4) + Blend-Glättung (vc21-Ruckel-Fix, bot_avatar.gd).
const CROSSFADE_S := 0.2
const BLEND_SMOOTH_S := 0.25
## Modell-Anker (bot_avatar.gd): Knoten = Kopf-Anker in Augenhöhe (1,65 m),
## GLB-Kind hängt mit negativem Y-Versatz (Füße am Boden, Modell 1,83 m).
const MODEL_EYE_HEIGHT := 1.65
const MODEL_Y_OFFSET := -1.65
## Frontachse (vc21-Messung): GLB blickt nach +Z → 180°-Korrektur auf dem
## Modell-Kind, damit die Front auf das lokale −Z (Body-Vorwärts) zeigt.
const MODEL_FORWARD_LOCAL := Vector3(0.0, 0.0, 1.0)
const MODEL_YAW_OFFSET := PI
## Rifle-Mount (vc21-Messung, bot_avatar.gd): hand_r, Lauf → Hand-+Y,
## Rifle-oben → Hand-+Z = reine +90°-X-Drehung; Griff 0,067 m auf Hand-+Y.
const RIFLE_HAND_BONE := "hand_r"
const RIFLE_ROT_BASIS := Basis(Vector3(1.0, 0.0, 0.0), deg_to_rad(90.0))
const RIFLE_POS := Vector3(0.0, 0.067, 0.0)
## AnimationTree-Vertrag (bot_avatar.gd — Namen sind Teil des Vertrags).
const SM_MOTION := "Motion"
const P_BLEND := "parameters/Motion/Speed/blend_position"
const P_WALK_SCALE := "parameters/Motion/Speed/Walk/WalkTs/scale"
const P_JOG_SCALE := "parameters/Motion/Speed/Jog/JogTs/scale"
const P_PLAYBACK := "parameters/playback"
## GLB-Instanz (Modell-Wurzel), AnimationPlayer (Clip-Lieferant), Tree.
var _model: Node3D = null
var _anim: AnimationPlayer = null
var _tree: AnimationTree = null
var _playback: AnimationNodeStateMachinePlayback = null
## Sichtbarer Zustand (Tests/Debug): Soll-Clip + Soll-Blend.
var _cur_clip := ""
var state := STATE_IDLE
var speed_mps := 0.0
var _blend_cur := 0.0
var _blend_target := 0.0
var _walk_scale_cur := 1.0
var _walk_scale_target := 1.0
var _jog_scale_cur := 1.0
var _jog_scale_target := 1.0
## Client-Projektordner (Nachbar-Klon oder konfigurierter Pfad) — dieselbe
## Auflösung wie der alte Client-Start (test_run.gd). "" = keiner gefunden.
static func client_wurzel() -> String:
return TestRun.find_client(ProjectSettings.globalize_path("res://"),
TestRun.load_cfg())
## Absoluter Modell-/Rifle-Pfad im Client-Klon ("" = kein Klon gefunden).
static func model_pfad() -> String:
var root := client_wurzel()
if root.is_empty():
return ""
return root.path_join(MODEL_REL)
static func rifle_pfad() -> String:
var root := client_wurzel()
if root.is_empty():
return ""
return root.path_join(RIFLE_REL)
## State aus der echten Geschwindigkeit (Spiegel von _track_velocity,
## bot_avatar.gd — dieselben Schwellen, ohne Netzwerk-Glättung).
static func zustand_fuer(speed: float) -> int:
if speed <= IDLE_SPEED_MAX_MPS:
return STATE_IDLE
if speed <= WALK_SPEED_MAX_MPS:
return STATE_WALK
return STATE_JOG
## Avatar aus dem Client-Klon laden (null = fehlt/nicht ladbar → graceful
## Fallback auf First Person beim Aufrufer). Baut Modell + AnimationTree
## + Rifle-Mount exakt wie bot_avatar._ready/_build_model/_build_anim_tree.
static func lade_avatar() -> PlaytestAvatar:
var glb_pfad := model_pfad()
if glb_pfad.is_empty() or not FileAccess.file_exists(glb_pfad):
push_warning("PLAYTEST_AVATAR: Modell fehlt (%s) — Third Person nicht verfügbar" % glb_pfad)
return null
var gltf := GLTFDocument.new()
var state := GLTFState.new()
if gltf.append_from_file(glb_pfad, state) != OK:
push_warning("PLAYTEST_AVATAR: GLB nicht ladbar: %s" % glb_pfad)
return null
var szene: Node = gltf.generate_scene(state)
if szene == null:
push_warning("PLAYTEST_AVATAR: GLB-Szene leer: %s" % glb_pfad)
return null
var avatar := PlaytestAvatar.new()
avatar.name = "Avatar"
avatar._model = szene as Node3D
if avatar._model == null:
push_warning("PLAYTEST_AVATAR: GLB-Wurzel kein Node3D: %s" % glb_pfad)
szene.free()
return null
avatar._model.name = "Body"
# Kopf-Anker-Konvention (bot_avatar._build_model): das GLB-Kind hängt mit
# MODEL_Y_OFFSET nach unten (GLB-Ursprung = Füße) — der Avatar-Node selbst
# sitzt bei PlaytestBody in Augenhöhe über dem Füßpunkt (set_avatar).
# vc21-Frontachse: GLB blickt nach +Z → 180° auf das Modell-Kind, damit
# die Modell-Front auf das Avatar-lokale −Z zeigt (= Body-Vorwärts).
avatar._model.position = Vector3(0.0, MODEL_Y_OFFSET, 0.0)
avatar._model.rotation.y = MODEL_YAW_OFFSET
avatar.add_child(avatar._model)
for child in avatar._model.find_children("*", "AnimationPlayer", true, false):
avatar._anim = child
if avatar._anim == null:
push_warning("PLAYTEST_AVATAR: kein AnimationPlayer im GLB — Avatar unbrauchbar")
avatar.free()
return null
# Importer-Semantik nachbilden: der Godot-Editor-Import (Client .import)
# streicht Animations-Namens-ENDUNGEN "-loop"/"_loop" und setzt dann
# Loop-Mode LOOP_LINEAR. GLTFDocument (Runtime-Ladung) liefert die rohen
# Namen ("Idle_Loop", "Walk_Loop", …) OHNE Loop-Mode — ohne diese
# Normalisierung hätten die Loops den falschen Namen (Bot-Vertrag: Idle/
# Walk/Jog_Fwd) und würden nach einem Zyklus stehen bleiben.
avatar._normalisiere_clips()
if avatar._anim.get_animation_list().is_empty():
push_warning("PLAYTEST_AVATAR: kein AnimationPlayer/Clips im GLB — Avatar unbrauchbar")
avatar.free()
return null
avatar._baue_anim_tree()
avatar._baue_rifle()
# Start-Anim: Idle (wie bot_avatar._ready — kein T-Pose-Blitz).
avatar.apply_state(STATE_IDLE, 0.0)
return avatar
## Importer-Semantik (siehe Aufrufkommentar): "_loop"/"-loop"-Endungen
## streichen, LOOP_LINEAR setzen — genau das, was der Client-.import mit
## derselben GLB macht (dort heißen die Clips Idle/Walk/Jog_Fwd, Loop-Mode
## im Asset korrekt, siehe bot_avatar.gd-Clip-Doku).
func _normalisiere_clips() -> void:
for lib_name in _anim.get_animation_library_list():
var lib := _anim.get_animation_library(lib_name)
for clip_name in lib.get_animation_list():
var animation: Animation = lib.get_animation(clip_name)
var lower := String(clip_name).to_lower()
if lower.ends_with("_loop") or lower.ends_with("-loop"):
var clean := String(clip_name).substr(0, String(clip_name).length() - 5)
lib.rename_animation(clip_name, clean)
animation.loop_mode = Animation.LOOP_LINEAR
## AnimationTree (A4-Pipeline, Motion-Zweig): BlendSpace1D Idle/Walk/Jog +
## TimeScale-Subtrees, darüber die StateMachine mit dem Motion-State.
## Verdrahtungs-Fallen (bot_avatar.gd A4-Sektion, Probe 18.10.): Clips als
## EIGENE Library auf die Tree kopieren (anim_player-Compatibility-Pfad
## überspringt die GLB-Default-Library), root_node = Modell-Wurzel
## (Track-Pfade "Armature/Skeleton3D:*"), Player bleibt reiner Clip-Lieferant.
func _baue_anim_tree() -> void:
_anim.stop()
var tree := AnimationTree.new()
tree.name = "AnimTree"
_model.add_child(tree)
var lib := AnimationLibrary.new()
for clip in _anim.get_animation_list():
lib.add_animation(clip, _anim.get_animation(clip))
tree.add_animation_library("", lib)
tree.root_node = tree.get_path_to(_model)
var motion := AnimationNodeBlendTree.new()
var blend := AnimationNodeBlendSpace1D.new()
var idle := AnimationNodeAnimation.new()
idle.animation = CLIP_IDLE
blend.add_blend_point(idle, BLEND_IDLE_MPS, 0, "Idle")
blend.add_blend_point(_speed_scaled_clip(CLIP_WALK, "WalkAnim", "WalkTs"),
WALK_NATURAL_MPS, 1, "Walk")
blend.add_blend_point(_speed_scaled_clip(CLIP_JOG, "JogAnim", "JogTs"),
JOG_NATURAL_MPS, 2, "Jog")
blend.min_space = 0.0
blend.max_space = BLEND_MAX_MPS
blend.sync = true # vc21: gemeinsame Phase beim Blenden (kein Fuß-Pop)
motion.add_node("Speed", blend)
motion.connect_node("output", 0, "Speed")
var sm := AnimationNodeStateMachine.new()
sm.add_node(SM_MOTION, motion)
tree.tree_root = sm
tree.active = true
tree.process_callback = AnimationTree.ANIMATION_PROCESS_IDLE
_tree = tree
# _playback bewusst NICHT hier holen: der Baum hängt noch nicht im
# Szenenbaum — tree.get(P_PLAYBACK) liefert vorher null. Der Motion-
# Start passiert in apply_state() über _playback_holen() (nach dem
# Baum-Eintritt, wie _ensure_motion im Client).
## A4-Helfer (1:1 bot_avatar.gd): Clip-Subtree mit AnimationNodeTimeScale.
func _speed_scaled_clip(clip: String, anim_name: String, ts_name: String) -> AnimationNodeBlendTree:
var bt := AnimationNodeBlendTree.new()
var an := AnimationNodeAnimation.new()
an.animation = clip
var ts := AnimationNodeTimeScale.new()
bt.add_node(anim_name, an)
bt.add_node(ts_name, ts)
bt.connect_node(ts_name, 0, anim_name)
bt.connect_node("output", 0, ts_name)
return bt
## Rifle an hand_r (1:1 bot_avatar._build_rifle). Fehlt das Rifle-GLB oder
## der Knochen: warnen und ohne Waffe weiter (Avatar bleibt brauchbar).
func _baue_rifle() -> void:
var rifle_pfad_datei := rifle_pfad()
if rifle_pfad_datei.is_empty() or not FileAccess.file_exists(rifle_pfad_datei):
push_warning("PLAYTEST_AVATAR: Rifle fehlt (%s) — Avatar ohne Waffe" % rifle_pfad_datei)
return
var skeleton := _skeleton()
if skeleton == null:
push_warning("PLAYTEST_AVATAR: kein Skeleton3D — Rifle bleibt unattached")
return
if skeleton.find_bone(RIFLE_HAND_BONE) < 0:
push_warning("PLAYTEST_AVATAR: Hand-Knochen '%s' fehlt — Rifle bleibt unattached" % RIFLE_HAND_BONE)
return
var gltf := GLTFDocument.new()
var state := GLTFState.new()
if gltf.append_from_file(rifle_pfad_datei, state) != OK:
push_warning("PLAYTEST_AVATAR: Rifle-GLB nicht ladbar: %s" % rifle_pfad_datei)
return
var szene: Node = gltf.generate_scene(state)
if szene == null:
return
var attach := BoneAttachment3D.new()
attach.name = "RifleMount"
attach.bone_name = RIFLE_HAND_BONE
skeleton.add_child(attach)
var rifle := szene as Node3D
rifle.name = "Rifle"
rifle.basis = RIFLE_ROT_BASIS
rifle.position = RIFLE_POS
attach.add_child(rifle)
## Erstes Skeleton3D unter dem GLB (bot_avatar._build_model-Suche).
func _skeleton() -> Skeleton3D:
if _model == null:
return null
for child in _model.find_children("*", "Skeleton3D", true, false):
return child
return null
## ---------------------------------------------------------------- Anim ----
## Playtest-Movement-State anlegen (Kartenvorgabe: aus der ECHTEN Bewegung).
## mps < 0 = "nicht bekannt" → Kadenz unverändert lassen (wie bot_avatar).
func apply_state(new_state: int, new_speed_mps: float = -1.0) -> void:
if new_state != STATE_IDLE and new_state != STATE_WALK and new_state != STATE_JOG:
return # unbekannter State: nichts ändern (wie _clip_for_state "").
state = new_state
if new_speed_mps >= 0.0:
speed_mps = new_speed_mps
var clip := _clip_for_state(state)
if clip.is_empty():
return
_cur_clip = clip
if state == STATE_WALK:
_blend_target = WALK_NATURAL_MPS
_walk_scale_target = gait_scale_for(state, speed_mps)
_jog_scale_target = 1.0
elif state == STATE_JOG:
_blend_target = JOG_NATURAL_MPS
_jog_scale_target = gait_scale_for(state, speed_mps)
_walk_scale_target = 1.0
else:
_blend_target = BLEND_IDLE_MPS
_walk_scale_target = 1.0
_jog_scale_target = 1.0
if not is_processing():
# Ohne _process-Treiber (Tests vor dem ersten Frame) sofort schreiben.
_blend_cur = _blend_target
_walk_scale_cur = _walk_scale_target
_jog_scale_cur = _jog_scale_target
_write_blend_params()
var playback := _playback_holen()
if playback != null:
# Wie bot_avatar._ensure_motion: Loop-State halten bzw. zurück.
# Der pre-tree start() der StateMachine kann beim Baum-Eintritt
# verworfen werden (leerer Knoten) → hier sauber (neu) starten.
var node := String(playback.get_current_node())
if node.is_empty():
playback.start(SM_MOTION)
elif node != SM_MOTION:
playback.travel(SM_MOTION)
## Bot2-Kadenz (1:1 bot_avatar.gd): Kadenz = m/s / Ziel-Schrittlänge, geklemmt.
func gait_cadence_for(s: int, mps: float) -> float:
var step := WALK_STEP_M if s == STATE_WALK else JOG_STEP_M
if s != STATE_WALK and s != STATE_JOG:
return 0.0
return clampf(mps / maxf(step, 0.05), CADENCE_MIN, CADENCE_MAX)
## Bot2-TimeScale (1:1 bot_avatar.gd): Kadenz × Zykluslänge / 2 Schritte.
func gait_scale_for(s: int, mps: float) -> float:
var cadence := gait_cadence_for(s, mps)
if cadence <= 0.0:
return 1.0
var cycle := _cycle_len_for(s)
if cycle <= 0.0:
return 1.0
return clampf(cadence * cycle / 2.0, FOOT_SYNC_MIN, FOOT_SYNC_MAX)
func _cycle_len_for(s: int) -> float:
var clip := _clip_for_state(s)
if clip.is_empty() or _anim == null or not _anim.has_animation(clip):
return 0.0
return _anim.get_animation(clip).length
## StateMachine-Playback faul holen: erst NACH dem Baum-Eintritt kann die
## Tree den "parameters/playback"-Parameter auflösen (vorher null).
func _playback_holen() -> AnimationNodeStateMachinePlayback:
if _playback == null and _tree != null:
_playback = _tree.get(P_PLAYBACK) as AnimationNodeStateMachinePlayback
return _playback
## Clip-Auflösung (1:1 bot_avatar._clip_for_state, ohne ENGAGE — Match-only).
func _clip_for_state(s: int) -> String:
match s:
STATE_IDLE:
return CLIP_IDLE
STATE_WALK:
return CLIP_WALK
STATE_JOG:
return CLIP_JOG
_:
return ""
## vc21: Welt-Richtung, in die das MESH blickt (Vertrag wie
## bot_avatar.model_forward: Front == Bewegungsrichtung). Hier wird die
## ECHTE Modell-Transform gemessen — der MODEL_YAW_OFFSET sitzt in diesem
## Spiegel auf dem Modell-Kind (nicht auf dem Node wie im Client), die
## Messung über _model.global_transform ist davon unabhängig korrekt.
func model_forward() -> Vector3:
if _model == null:
return Vector3(0.0, 0.0, -1.0)
var f: Vector3 = _model.global_transform.basis * MODEL_FORWARD_LOCAL
f.y = 0.0
return f.normalized()
## Aktueller StateMachine-State ("" ohne Baum) — Tests/Debug.
func tree_state() -> String:
var playback := _playback_holen()
if playback == null:
return ""
return String(playback.get_current_node())
## Soll-Clip des aktuellen States (wie bot_avatar.current_clip, ohne One-Shots).
func current_clip() -> String:
return _cur_clip
## AnimationTree aktiv? (Tests: "animiert überhaupt".)
func anim_running() -> bool:
return _tree != null and _tree.active
func _process(delta: float) -> void:
# vc21: Blend/Kadenz weich nachziehen (kein sichtbares Springen).
if _tree == null:
return
var k := clampf(delta / BLEND_SMOOTH_S, 0.0, 1.0)
_blend_cur = lerpf(_blend_cur, _blend_target, k)
_walk_scale_cur = lerpf(_walk_scale_cur, _walk_scale_target, k)
_jog_scale_cur = lerpf(_jog_scale_cur, _jog_scale_target, k)
_write_blend_params()
## vc21: geglätteten Stand in die Tree schreiben (nur bei Änderung).
func _write_blend_params() -> void:
if _tree == null:
return
if absf(float(_tree.get(P_BLEND)) - _blend_cur) > 0.0005:
_tree.set(P_BLEND, _blend_cur)
if absf(float(_tree.get(P_WALK_SCALE)) - _walk_scale_cur) > 0.0005:
_tree.set(P_WALK_SCALE, _walk_scale_cur)
if absf(float(_tree.get(P_JOG_SCALE)) - _jog_scale_cur) > 0.0005:
_tree.set(P_JOG_SCALE, _jog_scale_cur)

View file

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

View file

@ -0,0 +1,581 @@
extends CharacterBody3D
class_name PlaytestBody
## Lauf-Modus Spieler (Karte t_1cdc1a72, Physik-Neubau t_1da96275):
## erste Person, WASD + Maus, Shift = rennen, Space = springen, Gravitation.
##
## ECHTE PHYSIK — identisch zum Quest-3-Client (Sly-Forderung 20.10.:
## „Die Möglichkeiten des Map Editors sollen ABSOLUT identisch mit der
## Quest-3-Version sein“). Dieser Body ist ein CharacterBody3D mit EXAKT
## denselben Parametern wie client/scripts/world/testmap_player.gd
## (TestmapPlayer): Kapsel 0.35 × 1.8, WALK/SPRINT/JUMP/GRAVITY,
## Step-Up 0.45 m nach move_and_slide (RayCast-Kanten-Abtastung, 1:1
## gespiegelt inkl. Decken-Guard). Die Kollider kommen von KolliderBau
## (Spiegel von svmap_world.gd) — dieselben Trimeshe wie im Spiel.
##
## Der alte AABB-Blocking-Code (vorgecachte Kisten, eigener step()-Solver
## ohne Physik-Server) ist ENTFERNT — es gibt genau EINEN Bewegungscode,
## denselben wie auf der Quest.
##
## Steuerung: _physics_process pollt die Tasten (oder die Autopilot-Haken
## auto_move_dir/auto_jump/auto_run für Suiten und spätere Automatisierung)
## und fährt dieselbe Logik wie der Client. ESC wird NICHT hier gefangen:
## main.gd behandelt Esc VOR der normalen Esc-Kette, solange der Lauf-Modus
## aktiv ist („Esc = zurück zum Editor“).
##
## Koordinaten-Konvention (wie Client): Body-Ursprung = KAPSEL-MITTE,
## Kamera sitzt EYE_HEIGHT über der Mitte (TestmapPlayer.EYE_HEIGHT).
signal exit_requested
# --- Parameter 1:1 aus client/scripts/world/testmap_player.gd ---------------
const WALK_SPEED := 6.0
const SPRINT_SPEED := 14.0
const JUMP_SPEED := 4.5
const GRAVITY := 9.8
const EYE_HEIGHT := 0.65 # Kamera über der Kapsel-Mitte (Kapsel 1.8 m)
const KILL_Y_DROP_M := -200.0 # Notfall: unter die Welt gefallen → Respawn
const KAPSEL_RADIUS := 0.35
const KAPSEL_HEIGHT := 1.8
const HALF_H := 0.9 # Kapsel-Halbhöhe (Kapsel 1.8 m) — Füße = y − 0.9
const STEP_HEIGHT := 0.45 # = TestmapPlayer.STEP_HEIGHT (Karte t_f9adf97c)
const STEP_PROBE_DIST := 0.7
const STEP_LIFT_EPS := 0.02
# --- Editor-seitige Konventionen (unverändert gegenüber dem alten Body) -----
const DEFAULT_MOUSE_SENS := 0.25 # Grad pro Pixel Mausbewegung
const PITCH_LIMIT := 89.0 # Kopfüber/Nackenschmerz-Grenze
const TELEPORT_REICHWEITE := 4000.0
## Spielbare Parameter
var mouse_sens: float = DEFAULT_MOUSE_SENS
## Map-Dokument (nur LESEND — Terrain-Raster für Spawn/HUD/Notfall)
var doc: Dictionary = {}
## Fliegen / God-Mode (Karte t_aaff5dcc, Taste G): KEINE Gravitation, Bewegung
## in Blickrichtung (inkl. Nickwinkel), Space = steigen, Noclip — der Tester
## kommt auch über Wände/Hügel schnell ans Ziel.
var fly_mode := false
## Autopilot-Haken (wie TestmapPlayer.auto_move_dir, Karte t_f9adf97c):
## nicht-leer = DIESER Welt-Vektor ist der Bewegungswunsch (Tastatur aus).
var auto_move_dir := Vector3.ZERO
## Autopilot-Ergänzung für Suiten: Sprung/Run erzwingen ohne Tastatur.
var auto_jump := false
var auto_run := false
## Suite-Hook: nicht-leer = komplettes Input-Dictionary überschreibt ALLES
## (gleiche Form wie der alte step()-Input: fwd/back/left/right/run/jump).
var auto_input: Dictionary = {}
## Bewegungszustand (Kompatibilität zur alten Oberfläche)
var yaw_deg: float = 0.0 # Gierwinkel Blickrichtung (Grad)
var pitch_deg: float = 0.0 # Nickwinkel Blickrichtung (Grad)
var on_floor: bool = false
## Kinder: die Spiel-Kamera (Augen) — PlaytestMode hängt sie an und macht current
var look_cam: Camera3D
## Höhenfeld-Cache (Zeile: res*res Meter) — von PlaytestMode via set_terrain()
## gefüllt (Spawn/HUD/jump_to_spawn; die PHYSIK läuft über den Trimesh-Kollider).
var heights: PackedFloat32Array = PackedFloat32Array()
var res: int = 0
var size_x: float = 0.0
var size_z: float = 0.0
var hmin: float = 0.0
var hmax: float = 0.0
var _spawn_pos := Vector3.ZERO
func _ready() -> void:
_spawn_pos = global_position
var cs := CollisionShape3D.new()
var capsule := CapsuleShape3D.new()
capsule.radius = KAPSEL_RADIUS
capsule.height = KAPSEL_HEIGHT
cs.shape = capsule
add_child(cs)
look_cam = Camera3D.new()
look_cam.name = "Camera"
look_cam.position.y = EYE_HEIGHT
look_cam.far = 4000.0
add_child(look_cam)
look_cam.current = true
_baue_dritte_kamera()
_apply_look()
## Third-Person-Kamera (Karte t_17a35c59): SpringArm3D = RayCast hinter dem
## Spieler — die Kamera wird an die Kollision zurückgezogen (kein Clipping
## durch Wände). Pivot auf Augenhöhe, Arm-Länge 3,5 m + 0,3 m Margin, der
## eigene Body ist ausgeschlossen (sonst trifft der Strahl die eigene Kapsel).
func _baue_dritte_kamera() -> void:
spring = SpringArm3D.new()
spring.name = "ThirdPersonArm"
spring.position = Vector3(0.0, EYE_HEIGHT, 0.0)
spring.spring_length = 3.5
spring.margin = 0.3
spring.collision_mask = 1
tpp_cam = Camera3D.new()
tpp_cam.name = "ThirdPersonCamera"
tpp_cam.far = 4000.0
spring.add_child(tpp_cam)
add_child(spring)
spring.add_excluded_object(get_rid())
tpp_cam.current = false
## Kinder: die Third-Person-Kamera (SpringArm + Kamera) — Karte t_17a35c59.
var spring: SpringArm3D = null
var tpp_cam: Camera3D = null
## Playtest-Avatar (Spiegel des Bot-Rigs, playtest_avatar.gd). null = kein
## Client-Klon/Modell → Third Person bleibt gesperrt (graceful Fallback).
var avatar: Node3D = null
## Perspektive (Taste V, persistiert in PlaytestSettings): false = First
## Person (Standard), true = Third Person (Avatar sichtbar, Kamera hinten).
var perspektive_dritte := false
## Blickrichtung auf Körper + Kameras übertragen.
## Der BODY rotiert mit dem Blick-Yaw (wie TestmapPlayer.rotate_y) — das
## trägt die Avatar-Front (Kind des Körpers) und die Third-Person-Kamera.
## Die WASD-Bewegung selbst folgt dem Yaw über _fahren (parallel gefixt in
## 52919b3, dort aus yaw_deg gebaut — beide Wege ergeben dieselbe Richtung).
## Vor beiden Fixes blieb der Body ungedreht (Beweis-Probe
## tests/probe_move_dir.gd: yaw −90° + W → −Z statt +X = weltachsen-fixiert).
func _apply_look() -> void:
# 1:1 TestmapPlayer: der BODY trägt den Yaw (rotate_y), die Kamera nur
# den Pitch — sonst doppelt sich die Drehung (Kamera hängt am Körper).
rotation.y = deg_to_rad(yaw_deg)
if look_cam == null:
return
look_cam.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0)
look_cam.position = Vector3(0.0, EYE_HEIGHT, 0.0)
# Third Person: die SpringArm-Kamera teilt dieselbe Blickachse (Yaw über
# den Körper, Pitch über den Arm) — Blickrichtung == Kamerarichtung.
if spring != null:
spring.rotation = Vector3(deg_to_rad(pitch_deg), 0.0, 0.0)
## Füß-Höhe über der Welt (Kapsel-Mitte minus Halbhöhe) — HUD/Tests.
func fuss_hoehe() -> float:
return global_position.y - HALF_H
## Volle 3D-Blickrichtung (Karte t_aaff5dcc): -Z um Pitch (X) und Yaw (Y)
## gedreht — genau die Blickachse der Kamera. Fliegen bewegt entlang DIESEM
## Vektor (Blick nach unten = abwärts fliegen), Gehen nur horizontal.
func view_direction() -> Vector3:
var yaw_rad := deg_to_rad(yaw_deg)
var pitch_rad := deg_to_rad(pitch_deg)
return Vector3(-sin(yaw_rad) * cos(pitch_rad), sin(pitch_rad),
-cos(yaw_rad) * cos(pitch_rad))
## Echter Physics-Step: Input zusammenbauen (Tasten ODER Autopilot) → fahren.
func _physics_process(delta: float) -> void:
var input := {}
if not auto_input.is_empty():
input = auto_input
else:
input = {
"fwd": Input.is_key_pressed(KEY_W),
"back": Input.is_key_pressed(KEY_S),
"left": Input.is_key_pressed(KEY_A),
"right": Input.is_key_pressed(KEY_D),
"run": Input.is_key_pressed(KEY_SHIFT) or auto_run,
"jump": Input.is_key_pressed(KEY_SPACE) or auto_jump,
}
if auto_move_dir.length_squared() > 0.0:
input["auto_dir"] = auto_move_dir
_fahren(delta, input)
## Ein Bewegungs-Schritt (physics frame) mit EXPLIZITEM Input ausführen.
## input = {fwd, back, left, right, run, jump: bool, auto_dir: Vector3}.
## DIESE Methode ist 1:1 die _physics_process des TestmapPlayers —
## identische Bewegungsmathe, identische Step-Up-Aufrufreihenfolge.
func _fahren(delta: float, input: Dictionary) -> void:
# --- Fliegen / God-Mode (Karte t_aaff5dcc, Taste G) ----------------------
# Keine Gravitation, Bewegung in der VOLLEN Blickrichtung, Space = steigen,
# Noclip (direkt positionieren — kein move_and_slide, kein Kollider).
if fly_mode:
var fly_speed := WALK_SPEED if not bool(input.get("run", false)) else SPRINT_SPEED
var fly_yaw_rad := deg_to_rad(yaw_deg)
var fly_right := Vector3(cos(fly_yaw_rad), 0.0, -sin(fly_yaw_rad))
var wish3: Vector3
var ad: Variant = input.get("auto_dir", null)
if ad is Vector3 and (ad as Vector3).length_squared() > 0.0:
wish3 = (ad as Vector3).normalized() * fly_speed
else:
wish3 = view_direction() * (1.0 if bool(input.get("fwd", false)) else 0.0) \
- view_direction() * (1.0 if bool(input.get("back", false)) else 0.0) \
+ fly_right * (1.0 if bool(input.get("right", false)) else 0.0) \
- fly_right * (1.0 if bool(input.get("left", false)) else 0.0)
if bool(input.get("jump", false)):
wish3 += Vector3.UP
if wish3.length_squared() > 0.0:
wish3 = wish3.normalized() * fly_speed
velocity = wish3
position += velocity * delta
on_floor = false
_apply_look()
return
# Welt-Richtung aus dem Blickwinkel (y-only, wie Client). WICHTIG: aus
# yaw_deg bauen, NICHT aus transform.basis (Sly-Befund 20.10.: WASD lief
# sonst stur in eine Himmelsrichtung). Seit t_17a35c59 rotiert der
# Körperknoten ZUSÄTZLICH mit dem Yaw (_apply_look — Avatar-Front und
# Third-Person-Kamera hängen daran); die Bewegung bleibt bewusst an
# yaw_deg gekoppelt — beide Wege führen auf dieselbe Richtung, die
# Entkopplung hält die Bewegung unabhängig vom Avatar-Zustand.
# Identisch zu view_direction().
var yaw_rad := deg_to_rad(yaw_deg)
var forward := Vector3(-sin(yaw_rad), 0.0, -cos(yaw_rad))
var right := Vector3(cos(yaw_rad), 0.0, -sin(yaw_rad))
var dir := Vector3.ZERO
var ad2: Variant = input.get("auto_dir", null)
if ad2 is Vector3 and (ad2 as Vector3).length_squared() > 0.0:
dir = ad2 as Vector3
else:
if bool(input.get("fwd", false)):
dir += forward
if bool(input.get("back", false)):
dir -= forward
if bool(input.get("right", false)):
dir += right
if bool(input.get("left", false)):
dir -= right
var speed := SPRINT_SPEED if bool(input.get("run", false)) else WALK_SPEED
if dir.length_squared() > 0.0:
dir = dir.normalized() * speed
velocity.x = dir.x
velocity.z = dir.z
if is_on_floor() and bool(input.get("jump", false)):
velocity.y = JUMP_SPEED
if not is_on_floor():
velocity.y -= GRAVITY * delta
var pre_pos := global_position
move_and_slide()
# Stufen-Hilfe (Karte t_f9adf97c, 1:1 aus TestmapPlayer): nach
# move_and_slide() prüfen, ob die horizontale Bewegung von einer Wand
# gestoppt wurde, während der Wunsch weiter in die Wand zeigt.
var wish_xz := Vector3(dir.x, 0.0, dir.z)
var blocked := false
if wish_xz.length_squared() > 0.0001:
if is_on_wall() and get_wall_normal().dot(wish_xz.normalized()) < -0.1:
blocked = true
else:
var got := global_position - pre_pos
got.y = 0.0
var want := wish_xz * delta
if got.length() < want.length() * 0.4:
blocked = true
if blocked:
try_step_up(wish_xz)
if global_position.y < KILL_Y_DROP_M:
# Sicherheitsnetz (sollte mit Trimesh-Terrain nicht passieren).
global_position = _spawn_pos
velocity = Vector3.ZERO
on_floor = is_on_floor()
_apply_look()
## ------------------------------------------------------------------
## Stufen-Hilfe — 1:1 aus client/scripts/world/testmap_player.gd
## (Karte t_f9adf97c): Stufen bis STEP_HEIGHT erklimmen. Nach
## move_and_slide(), wenn die horizontale Bewegung von einer Wand gestoppt
## wurde UND der Bewegungswunsch in die Wand zeigt:
## Ray 1 (Fußhöhe vorwärts) → Wand-/Kanten-Fläche finden,
## Ray 2 (Kopfhöhe vorwärts) → Wand überragt die Stufenhöhe? → blockt,
## Ray 3 (über der Kante abwärts) → neue Standfläche messen,
## Decken-Strahl → Platz für die Kapsel über der Stufe?
## ------------------------------------------------------------------
func _capsule_half_height() -> float:
for child in get_children():
var cs := child as CollisionShape3D
if cs != null and cs.shape is CapsuleShape3D:
return (cs.shape as CapsuleShape3D).height * 0.5
return 0.9
func try_step_up(wish: Vector3) -> bool:
var dir := Vector3(wish.x, 0.0, wish.z)
if not is_on_floor() or dir.length_squared() < 0.0001:
return false
dir = dir.normalized()
var space := get_world_3d().direct_space_state
if space == null:
return false
var half_h := _capsule_half_height()
var feet := global_position.y - half_h
var mask := collision_mask # nur die Welt tasten, nicht den eigenen Körper
var exclude: Array[RID] = [get_rid()]
# Ray 1: vorwärts in Knöchelhöhe — gibt es überhaupt eine Stufe/Wand?
var ankle := global_position + Vector3(0.0, -half_h + 0.05, 0.0)
var q1 := PhysicsRayQueryParameters3D.create(
ankle + dir * 0.1, ankle + dir * STEP_PROBE_DIST, mask, exclude)
var hit1 := space.intersect_ray(q1)
if hit1.is_empty():
return false
if (hit1["normal"] as Vector3).dot(dir) > -0.3:
return false # Fläche zeigt nicht gegen die Laufrichtung (Rampe/Kante)
# Ray 2: vorwärts knapp ÜBER der maximalen Stufenhöhe — trifft er die
# Wand noch, ist sie höher als STEP_HEIGHT → kein Anheben.
var top_probe := global_position + Vector3(0.0, -half_h + STEP_HEIGHT + 0.01, 0.0)
var q2 := PhysicsRayQueryParameters3D.create(
top_probe + dir * 0.1, top_probe + dir * STEP_PROBE_DIST, mask, exclude)
if not space.intersect_ray(q2).is_empty():
return false # Wand überragt die Stufenhöhe
# Ray 3: über der Kante abwärts — neue Standfläche messen (Startpunkt
# liegt über Ray 2 frei, also außerhalb der Wand).
var edge := (hit1["position"] as Vector3) + dir * 0.1
var q3 := PhysicsRayQueryParameters3D.create(
Vector3(edge.x, feet + STEP_HEIGHT + 0.05, edge.z),
Vector3(edge.x, feet - 0.05, edge.z), mask, exclude)
var hit3 := space.intersect_ray(q3)
if hit3.is_empty():
return false
var new_feet := (hit3["position"] as Vector3).y
if new_feet < feet - 0.01: # Abgrund statt Fläche? Nichts anheben.
return false
var diff := new_feet - feet
if diff > STEP_HEIGHT + 0.005 or diff <= 0.0:
return false
if (hit3["normal"] as Vector3).y < 0.5:
return false # keine begehbare Fläche (Decke/Schräge von unten)
# Decken-Check: Platz für die volle Kapselhöhe über der neuen Standfläche?
# An der Ziel-XZ prüfen (etwas vorwärts — dort landt die Kapsel nach dem
# Schritt), sonst entgeht die Terrassen-Decke über dem Stufenkopf.
var check := global_position + dir * 0.45
var q4 := PhysicsRayQueryParameters3D.create(
Vector3(check.x, new_feet + 0.05, check.z),
Vector3(check.x, new_feet + half_h * 2.0 + 0.05, check.z),
mask, exclude)
if not space.intersect_ray(q4).is_empty():
return false # Decke zu tief (z. B. Terrassen-Untersicht): unten bleiben
# Sanftes Anheben: Positions-Snap auf die neue Standfläche …
global_position.y = new_feet + half_h + STEP_LIFT_EPS
# … und Bewegung fortführen: der zweite move_and_slide()-Schritt trägt
# die Kapsel VOR die Kante auf die neue Stufe (sonst steht sie nur
# tangential auf der Kante und fällt zurück).
move_and_slide()
return true
## Maus-Bewegung (nur der Spieler selbst fängt sie — die Editor-Werkzeuge
## sind im Lauf-Modus eingefroren).
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
var mm := event as InputEventMouseMotion
yaw_deg = wrapf(yaw_deg - mm.relative.x * mouse_sens, -180.0, 180.0)
pitch_deg = clampf(pitch_deg - mm.relative.y * mouse_sens,
-PITCH_LIMIT, PITCH_LIMIT)
_apply_look()
get_viewport().set_input_as_handled()
## ESC-Request — wird von main.gd angerufen (die Esc-Priorität gehört dort
## hin, damit die normale Esc-Kette NICHT auch noch zieht).
func request_exit() -> void:
exit_requested.emit()
## Terrain-Raster EINMALIG aus dem Dokument dekodieren (R16/LE/Base64 →
## Meter-Array). Nutzt den Standard-Decoder SvmapIO.decode_heights (derselbe
## Code wie beim Editor-Rendering) — nichts neu rechnen. Die PHYSIK läuft
## über den Trimesh-Kollider (KolliderBau.terrain_form, identisch zum
## Client); dieses Raster dient Spawn/jump_to_spawn/HUD-Referenz.
func set_terrain(d: Dictionary) -> void:
doc = d
heights = PackedFloat32Array()
res = 0
size_x = 0.0
size_z = 0.0
hmin = 0.0
hmax = 0.0
if d.is_empty():
return
var terrain: Dictionary = d.get("terrain", {})
res = int(terrain.get("resolution", 0))
size_x = float(terrain.get("size_x_m", 0.0))
size_z = float(terrain.get("size_z_m", 0.0))
hmin = float(terrain.get("min_height_m", 0.0))
hmax = float(terrain.get("max_height_m", 0.0))
if res < 2 or size_x <= 0.0 or size_z <= 0.0:
return
var decoded: PackedFloat32Array = SvmapIO.decode_heights(terrain)
if decoded.size() < res * res:
return
heights = decoded
## Teleport zum Punkt unter dem Fadenkreuz (Karte t_96e3c4ee, Taste T im
## Testlauf): Strahl aus dem Auge gegen die ECHTE Physik-Welt (Trimesh-
## Terrain + Objekt-/Bauteil-Kollider — dieselben Flächen wie im Spiel).
## Gelandet wird auf dem Boden DARUNTER (Strahl senkrecht abwärts). Nie im
## Objekt landen: ist die Kapsel am Zielpunkt nicht frei, wird der Teleport
## abgelehnt (ok=false). Rückgabe: {ok, x, z, ground, msg}.
func teleport_to_ground(origin: Vector3, dir: Vector3) -> Dictionary:
var space := get_world_3d().direct_space_state
if space == null:
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
"msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
var d := dir
if d.length_squared() > 0.000000001:
d = d.normalized()
var q := PhysicsRayQueryParameters3D.create(origin, origin + d * TELEPORT_REICHWEITE)
q.exclude = [get_rid()]
var hit := space.intersect_ray(q)
if hit.is_empty():
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0,
"msg": "Kein Boden unterm Fadenkreuz — hier kann der Test-Start nicht hin."}
var ziel := hit["position"] as Vector3
# Boden DARUNTER: senkrechter Strahl ab etwas über dem Treffer — trifft
# er nichts (Treffer war eine Wandfläche ohne Boden drunter), Terrain-
# Raster als Rückfallebene.
var ground := ziel.y
var q2 := PhysicsRayQueryParameters3D.create(
Vector3(ziel.x, ziel.y + 0.5, ziel.z),
Vector3(ziel.x, ziel.y - 50.0, ziel.z))
q2.exclude = [get_rid()]
var hit2 := space.intersect_ray(q2)
if hit2.is_empty():
ground = terrain_height(ziel.x, ziel.z)
else:
ground = (hit2["position"] as Vector3).y
var tx := ziel.x
var tz := ziel.z
# Landeplatz prüfen: passt die Kapsel dort frei? (Nie im Objekt landen.)
var probe := CapsuleShape3D.new()
probe.radius = KAPSEL_RADIUS
probe.height = KAPSEL_HEIGHT
var qp := PhysicsShapeQueryParameters3D.new()
qp.shape = probe
qp.transform = Transform3D(Basis.IDENTITY,
Vector3(tx, ground + HALF_H + 0.02, tz))
qp.exclude = [get_rid()]
if not space.intersect_shape(qp, 1).is_empty():
return {"ok": false, "x": tx, "z": tz, "ground": ground,
"msg": "Da steht etwas im Weg (%.1f, %.1f) — dort kannst du nicht landen." % [tx, tz]}
position = Vector3(tx, ground + HALF_H + 0.02, tz)
velocity = Vector3.ZERO
on_floor = true
_apply_look()
return {"ok": true, "x": tx, "z": tz, "ground": ground, "msg": ""}
## Terrain-Höhe an Welt-X/Z — aus dem DOCUMENT-Raster (nichts neu rechnen).
## Referenz für Spawn/jump_to_spawn/HUD; die Physik nutzt den Trimesh.
func terrain_height(x: float, z: float) -> float:
if res < 2 or size_x <= 0.0 or size_z <= 0.0:
return hmin
if heights.size() < res * res:
return hmin
var last := float(res - 1)
var fx := clampf((x / size_x + 0.5) * last, 0.0, last)
var fz := clampf((z / size_z + 0.5) * last, 0.0, last)
var xi := int(floor(fx))
var zi := int(floor(fz))
var tx := fx - float(xi)
var tz := fz - float(zi)
var xi1 := mini(xi + 1, res - 1)
var zi1 := mini(zi + 1, res - 1)
var h00 := _sample(xi, zi)
var h10 := _sample(xi1, zi)
var h01 := _sample(xi, zi1)
var h11 := _sample(xi1, zi1)
return lerpf(lerpf(h00, h10, tx), lerpf(h01, h11, tx), tz)
func _sample(xi: int, zi: int) -> float:
var idx := zi * res + xi
if idx < 0 or idx >= heights.size():
return hmin
return heights[idx]
## Maus-Empfindlichkeit als Multiplikator setzen (Karte t_aaff5dcc, 0,1–5,0,
## Standard 1,0): die Body-Basis (DEFAULT_MOUSE_SENS Grad/Pixel) wird mit dem
## Multiplikator skaliert — jede PC/Maus-Kombination ist anders.
func set_sensitivity(mult: float) -> void:
var m := clampf(mult, PlaytestSettings.MIN_SENS, PlaytestSettings.MAX_SENS)
mouse_sens = DEFAULT_MOUSE_SENS * m
# ------------------------------------------ Avatar + Perspektive (V) -------
# Karte t_17a35c59: derselbe Avatar wie die Bots im Spiel (Spiegel-Modul
# playtest_avatar.gd, GLB aus dem Nachbar-Klon), umschaltbar First/Third.
## Avatar (aus PlaytestAvatar.lade_avatar) anhängen. Anker: Kopf-Anker-
## Konvention des Clients (bot_avatar.gd) — Avatar-Node in Augenhöhe
## (MODEL_EYE_HEIGHT 1,65 m) über dem Füßpunkt, GLB-Kind hängt mit
## MODEL_Y_OFFSET nach unten. null = kein Modell (Third bleibt gesperrt).
func set_avatar(node: Node3D) -> void:
if avatar != null and is_instance_valid(avatar):
avatar.queue_free()
avatar = node
if avatar == null:
perspektive_dritte = false
_apply_perspektive()
return
# add_child reparentet NICHT — ein Node mit altem Parent würde still
# abgelehnt (Probe 20.10.: Avatar blieb unter root). Erst lösen, dann
# als Kind anhängen.
if avatar.get_parent() != null:
avatar.get_parent().remove_child(avatar)
add_child(avatar)
avatar.position = Vector3(0.0,
PlaytestAvatar.MODEL_EYE_HEIGHT - HALF_H, 0.0)
# Start-Zustand sauber in den Baum schreiben (apply_state vor add_child
# könnte Tree-Parameter noch nicht flushen).
avatar.call("apply_state", PlaytestAvatar.STATE_IDLE, 0.0)
_apply_perspektive()
## Taste V: Perspektive umschalten. Rückgabe: neuer Zustand (true = Third).
## Ohne Avatar bleibt First Person aktiv (graceful Fallback — Meldung macht
## der Aufrufer/PlaytestMode).
func toggle_perspektive() -> bool:
var neu := not perspektive_dritte
if avatar == null:
neu = false
return set_perspektive(neu)
## Perspektive direkt setzen (Enter-Pfad liest PlaytestSettings). Rückgabe:
## der tatsächlich aktive Zustand (ohne Avatar nie Third).
func set_perspektive(dritte: bool) -> bool:
if avatar == null:
dritte = false
perspektive_dritte = dritte
_apply_perspektive()
return perspektive_dritte
## Sichtbarkeit + aktive Kamera je Perspektive:
## First Person — Avatar unsichtbar (Kapsel bleibt physisch aktiv,
## Kamera auf Augenhöhe wie Client-TestmapPlayer),
## Third Person — Avatar sichtbar, SpringArm-Kamera hinter/über dem
## Spieler mit Wand-Kollision (kein Clipping).
func _apply_perspektive() -> void:
if avatar != null and is_instance_valid(avatar):
avatar.visible = perspektive_dritte
if look_cam != null:
look_cam.current = not perspektive_dritte
if tpp_cam != null:
tpp_cam.current = perspektive_dritte
## Echte horizontale Geschwindigkeit (m/s) aus dem letzten Physik-Schritt —
## Quelle für die Avatar-Gait-Speisung (Stillstand/Walk/Jog aus der realen
## Bewegung, nicht aus dem Input-Wunsch).
func horizontale_geschwindigkeit() -> float:
var v := velocity
v.y = 0.0
return v.length()

View file

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

View file

@ -0,0 +1,536 @@
extends Node
## Lauf-Modus (Karte t_1cdc1a72): erste Person IM Map-Builder selbst —
## kein Client-Ordner, kein zweiter Prozess, kein VR/OpenXR, kein Server.
##
## Verantwortung dieses Zustands:
## - Beim Start: die map_view_builder-Ausgabe (Modelle + Pack-Texturen)
## 1:1 weiterverwenden (der Node3D-Baum bleibt stehen, nur Editor-UI/Input
## geht weg), Maus-Capture-Wunsch setzen, Panels ausblenden.
## - Kollider-Bau via KolliderBau (core/svmap/kollider_bau.gd — 1:1-Spiegel
## des Quest-Clients, Karte t_1da96275): Terrain-Trimesh + Objekt-Kollider
## (box/kugel/zylinder/verbund/trimesh inkl. library/kollisions-Quellen)
## + Bauteil-Trimeshe (bau_*-Objekte: Treppen/Türen/Wände begehbar
## wie echte Geometrie).
## - PlaytestBody (CharacterBody3D, IDENTISCHE Parameter wie der
## Testmap-Spieler inkl. Step-Up 0.45 m) anlegen, Terrain-Raster EINMAL
## dekodieren, Start = erster Spawnpunkt (Team Blau), sonst Kartenmitte.
## - ESC → exit_requested → main.gd stellt den Editor-Zustand 1:1 wieder her
## (Panels, Maus, Kamera, Auswahl, Dokument, Undo-Stapel unverändert).
## - HUD (Position, FPS, „ESC = zurück zum Editor") als CanvasLayer-Overlay.
##
## Bewusst KEIN _physics_process hier: alle Bewegung/Eingabe lebt im
## PlaytestBody; dieser Zustand managed nur Lifecycle + HUD.
const BODY_SCRIPT := "res://core/editor/playtest/playtest_body.gd"
const KOLLIDER_SCRIPT := "res://core/svmap/kollider_bau.gd"
const AVATAR_SCRIPT := "res://core/editor/playtest/playtest_avatar.gd"
## Der laufende Spieler (PlaytestBody, CharacterBody3D mit echter Physik)
var body: Node3D = null
## Welt-Kollider (Terrain + Objekte + Bauteile) — Kind dieses Modus,
## wird mit exit() abgeräumt.
var _kollider_root: Node3D = null
## Editor-UI-Verweise (vom main.gd-Handler gesetzt): die Panels, die
## AUSGEBLENDET werden sollen (Variant statt Control-Typ — keine UI-Kopie).
var ui_panels: Array = [] # Array[Control]
var ui_status: Variant = null # Statuszeilen-Label (nur visible umschalten)
var help_label: Variant = null # Hilfe-Label (nur visible umschalten)
## Maus-Capture-Wunsch (Suite testet diesen Zustand)
var _want_capture := false
## HUD-Elemente
var hud: CanvasLayer = null
var hud_pos: Label = null
var hud_fps: Label = null
var hud_hint: Label = null
var hud_msg: Label = null # Klartext-Nachricht (z. B. Test-Start via T)
var hud_dir: Label = null # Blickrichtung + Höhe (Karte t_aaff5dcc)
## Hilfe-Overlay (F1, Karte t_aaff5dcc): alle Tasten im Klartext.
var help_layer: CanvasLayer = null
## Kartenmitte/Achsen-Linien (Taste X, Karte t_aaff5dcc — praktisch beim
## gespiegelten Bauen, Karte M1): X-Rot/Z-Blau/Y-Grün durch die Kartenmitte.
var _axis_node: Node3D = null
## FPS-Smoothing (Anzeige, keine Physik)
var _fps_smooth := 0.0
## Ist der Lauf-Modus gerade aktiv? (Reentrancy-Schutz für enter/exit)
var active := false
## Avatar-Modelle waren beim Start verfügbar? (Karte t_17a35c59 — ohne
## Client-Klon bleibt Third Person gesperrt, graceful Fallback.)
var _avatar_da := false
signal exit_requested
## Starten: übernimmt die Sicht (Panels aus, Maus-Capture-Wunsch an) und
## legt den Spieler am Startpunkt an. Fallback-Reihenfolge (Karte t_96e3c4ee,
## je mit Klartext-Hinweis): gesetzter Test-Start (start_override) → erster
## Spawnpunkt (Team Blau) → Kartenmitte. start_override = Vector3 (nur x/z
## zählen, Höhe kommt automatisch vom Terrain) oder null (= altes Verhalten).
## view_builder = der map_view_builder-Node (Marker-Tree mit pick_radius/
## half_height-Metas bleibt stehen und liefert die Hindernis-Blöcke).
func enter(doc: Dictionary, view_builder: Node3D, start_override: Variant = null) -> void:
if active:
return
active = true
_set_ui_visible(false)
_set_status_capture(true)
body = load(BODY_SCRIPT).new()
body.call("set_terrain", doc)
var start := _spawn_start(doc)
if start_override != null:
var sp := start_override as Vector3
start = Vector3(sp.x, 0.0, sp.z)
# Spawn AUF der Terrainhöhe: Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz,
# die Physik lässt den Body sanft auf den Trimesh fallen — wie im Spiel).
body.position = Vector3(start.x,
body.terrain_height(start.x, start.z) + PlaytestBody.HALF_H + 0.05,
start.z)
if start_override == null:
var first := _first_spawn(doc)
if first.has(SpawnOps.YAW_KEY):
body.yaw_deg = float(first[SpawnOps.YAW_KEY])
# Kollider 1:1 vom Quest-Client (Karte t_1da96275): Terrain-Trimesh +
# Objekt-Kollider aus der Registry + Bauteil-Trimeshe. Alles als Kind
# dieses Modus — exit() räumt es mit ab.
_baue_kollider(doc)
add_child(body)
# Maus-Empfindlichkeit aus den Einstellungen (Karte t_aaff5dcc): der
# gespeicherte Multiplikator (0,1–5,0) skaliert die Body-Basis.
body.call("set_sensitivity", PlaytestSettings.load_sens())
# Playtest-Avatar (Karte t_17a35c59): derselbe Rig wie die Bots im Spiel
# (GLB aus dem Nachbar-Klon ../scopedvr-client). Fehlt der Klon/Modell:
# graceful — First Person bleibt, Third Person wird per Meldung gesperrt.
var avatar: Node3D = load(AVATAR_SCRIPT).call("lade_avatar") as Node3D
_avatar_da = avatar != null
if _avatar_da:
body.call("set_avatar", avatar)
# Persistierte Perspektive (Taste V): Third nur mit Avatar — sonst
# First Person wie bisher + Klartext-Hinweis.
if PlaytestSettings.load_third():
if _avatar_da:
body.call("set_perspektive", true)
else:
show_message("Gespeicherte Third-Person-Einstellung ist ohne Avatar-Modelle nicht verfügbar (Client-Klon ../scopedvr-client fehlt) — First Person aktiv.")
_build_hud()
exit_requested.connect(_on_exit_requested, CONNECT_ONE_SHOT)
## Zurück in den Editor: HUD weg, Spieler weg, Panels sichtbar.
## Editor-Zustand (Kamera, Auswahl, Dokument, Undo-Stapel) wird NICHT hier
## angefasst — das macht main.gd nach dem Signal.
func exit() -> void:
if not active:
return
active = false
_set_status_capture(false)
_set_ui_visible(true)
if body != null:
body.queue_free()
body = null
if _kollider_root != null:
_kollider_root.queue_free()
_kollider_root = null
if hud != null:
hud.queue_free()
hud = null
if help_layer != null:
help_layer.queue_free()
help_layer = null
if _axis_node != null:
_axis_node.queue_free()
_axis_node = null
func _on_exit_requested() -> void:
exit()
## Status/Maus-Capture-Wunsch: nur der Zustand (Suite testet wants_capture).
func _set_status_capture(want: bool) -> void:
_want_capture = want
## Panelselemente ausblenden/einblenden. Nur die als ui_panels übergebenen
## Controls werden angefasst — keine weiteren.
func _set_ui_visible(vis: bool) -> void:
for p in ui_panels:
if p is Control:
(p as Control).visible = vis
if ui_status is Control:
(ui_status as Control).visible = vis
if help_label is Control:
(help_label as Control).visible = vis
## Start-Punkt: erster Spawn-Punkt aus dem Dokument (spawn_zones-Team „0",
## erster Eintrag der points-Liste); sonst Kartenmitte aus den Bounds.
## doc-Format (svmap): {"spawn_zones": {"0": {"points": [{"id", "pos": [x,y,z]}]}, ...}}
func _first_spawn(doc: Dictionary) -> Dictionary:
var team0 = (doc.get("spawn_zones", {}) as Dictionary).get("0", {})
if team0 is Dictionary:
var points: Array = (team0 as Dictionary).get("points", [])
if not points.is_empty() and points[0] is Dictionary:
return points[0]
return {}
func _spawn_start(doc: Dictionary) -> Vector3:
var zones: Dictionary = doc.get("spawn_zones", {})
var team0 = zones.get("0", {})
if team0 is Dictionary:
var points: Array = (team0 as Dictionary).get("points", [])
if not points.is_empty() and points[0] is Dictionary:
var pos = (points[0] as Dictionary).get("pos", [])
if pos is Array and (pos as Array).size() >= 3:
return Vector3(float(pos[0]), 0.0, float(pos[2]))
var bounds: Dictionary = doc.get("bounds", {})
var mn: Array = bounds.get("min", [-50.0, -10.0, -50.0])
var mx: Array = bounds.get("max", [50.0, 80.0, 50.0])
return Vector3((float(mn[0]) + float(mx[0])) * 0.5, 0.0,
(float(mn[2]) + float(mx[2])) * 0.5)
## Echter Kollider-Bau (Karte t_1da96275 — „Editor-Playtest-Physik = Quest 3
## identisch"): der alte AABB-Blocking-Code ist ENTFERNT. Es gibt genau EINEN
## Kollider-Pfad, denselben wie der Quest-Client:
## - Terrain + Registry-Objekte: KolliderBau.baue_welt_kollider (1:1-Spiegel
## von svmap_world.gd — box/kugel/zylinder/verbund/trimesh inkl.
## library/kollisions-Quelldateien).
## - Bauteile (bau_*) sind normale Registry-Objekte mit Trimesh aus
## library/kollisions — Türen/Fenster bleiben Durchgänge, Treppen begehbar.
func _baue_kollider(doc: Dictionary) -> void:
var kb := load(KOLLIDER_SCRIPT)
_kollider_root = kb.call("baue_welt_kollider", doc)
add_child(_kollider_root)
# ------------------------------------------------------------------- HUD ----
func _build_hud() -> void:
hud = CanvasLayer.new()
hud.layer = 100
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_top", 16)
margin.set_anchors_preset(Control.PRESET_TOP_LEFT)
hud.add_child(margin)
var vbox := VBoxContainer.new()
margin.add_child(vbox)
hud_pos = Label.new()
hud_fps = Label.new()
hud_hint = Label.new()
hud_msg = Label.new()
hud_dir = Label.new()
hud_hint.text = "WASD + Maus · Shift = rennen · Space = springen/steigen · G = Fliegen · V = 1./3.-Person · 1-4 = Spawnpunkt · T = Test-Start · H = HUD · X = Achsen · F1 = Hilfe · ESC = zurück zum Editor"
hud_msg.text = ""
for l: Label in [hud_pos, hud_dir, hud_fps, hud_hint, hud_msg]:
l.add_theme_font_size_override("font_size", 16)
l.add_theme_color_override("font_color", Color(0.55, 0.8, 1.0))
vbox.add_child(l)
add_child(hud)
## Klartext-Nachricht im HUD (die Editor-Statuszeile ist im Lauf-Modus
## ausgeblendet — hier landet das Feedback für Taste T, Karte t_96e3c4ee).
func show_message(text: String) -> void:
if hud_msg != null and is_instance_valid(hud_msg):
hud_msg.text = text
## Taste T im Testlauf (Karte t_96e3c4ee, „jederzeit“): setzt den Test-Start
## auf den Punkt unterm Fadenkreuz und versetzt den Spieler SOFORT dorthin
## (Augenhöhe 1,70 m via look_cam-Offset, Kollision geprüft — nie in Objekten).
## Das Speichern (Sidecar) + Marker-Update macht main.gd mit der Rückgabe.
## Rückgabe: {ok, x, z, ground, msg} aus PlaytestBody.teleport_to_ground.
func teleport_crosshair() -> Dictionary:
if not active or body == null or body.look_cam == null:
return {"ok": false, "x": 0.0, "z": 0.0, "ground": 0.0, "msg": ""}
var vp: Viewport = body.look_cam.get_viewport()
var center: Vector2 = (vp.get_visible_rect().size if vp != null else Vector2(800, 600)) * 0.5
var origin: Vector3 = body.look_cam.project_ray_origin(center)
var dir: Vector3 = body.look_cam.project_ray_normal(center)
var res: Dictionary = body.call("teleport_to_ground", origin, dir)
if bool(res.get("ok")):
show_message("Test-Start gesetzt: x=%.0f z=%.0f — du bist jetzt dort (Höhe automatisch)." % [
float(res.get("x")), float(res.get("z"))])
else:
show_message(str(res.get("msg", "")))
return res
func _process(delta: float) -> void:
if not active or body == null:
return
_speise_avatar()
# FPS glätten (Anzeige-Genauigkeit, keine Physik-Betroffenheit)
if _fps_smooth <= 0.0:
_fps_smooth = 1.0 / maxf(delta, 0.0001)
else:
_fps_smooth = lerpf(_fps_smooth, 1.0 / maxf(delta, 0.0001), 0.1)
if hud_pos != null:
hud_pos.text = "Pos: %.1f, %.1f, %.1f" % [body.position.x, body.position.y, body.position.z]
if hud_dir != null:
# Höhe = FUSSPUNKT (Kapsel-Mitte minus Halbhöhe) — direkt vergleichbar
# mit der Terrainhöhe unter den Füßen (Karte t_1da96275).
hud_dir.text = "Blick: %.0f° / %.0f° · Höhe: %.1f m" % [
float(body.get("yaw_deg")), float(body.get("pitch_deg")),
body.position.y - PlaytestBody.HALF_H]
if hud_fps != null:
hud_fps.text = "FPS: %d" % int(round(_fps_smooth))
## Maus-Capture-Wunsch für die Suite/Debug sichtbar machen.
func wants_capture() -> bool:
return _want_capture
## ESC → Signal an main.gd (die Esc-Priorität gehört in main.gd, damit die
## normale Esc-Kette im Editor-Modus unangetastet bleibt).
func handle_escape() -> void:
if active and body != null:
exit_requested.emit()
# ------------------------------------------------- Komfort (t_aaff5dcc) -----
## Tasten im Lauf-Modus (Karte t_aaff5dcc): G = Fliegen/God-Mode, 1–4 =
## Sprung zu Spawnpunkt, H = HUD an/aus, F1 = Hilfe, X = Achsen. ESC/T bleiben
## bewusst bei main.gd (Esc-Kette + Test-Start-Vertrag). Die Editor-Tasten
## sind im Lauf-Modus eingefroren (main gibt hier nichts anderes durch).
func _unhandled_input(event: InputEvent) -> void:
if not active or body == null:
return
var key_evt := event as InputEventKey
if key_evt == null or not key_evt.is_pressed() or key_evt.echo:
return
var key := key_evt.keycode
var handled := true
match key:
KEY_G:
toggle_fly()
KEY_V:
toggle_perspektive()
KEY_1:
jump_to_spawn(1)
KEY_2:
jump_to_spawn(2)
KEY_3:
jump_to_spawn(3)
KEY_4:
jump_to_spawn(4)
KEY_H:
toggle_hud()
KEY_F1:
toggle_help()
KEY_X:
toggle_axis()
_:
handled = false
if handled:
get_viewport().set_input_as_handled()
## Empfindlichkeit live setzen (aus dem Einstellungen-Panel, Registry-Weg —
## main.gd bleibt unangetastet). mult = Multiplikator 0,1–5,0.
func apply_sensitivity(mult: float) -> void:
if body != null:
body.call("set_sensitivity", mult)
## Taste V (Karte t_17a35c59): First/Third-Person umschalten. Der neue
## Zustand wird persistiert (PlaytestSettings, wirksam über Neustarts).
## Ohne Avatar-Modelle (kein Client-Klon ../scopedvr-client) bleibt First
## Person aktiv und der Grund steht im HUD — kein Crash (graceful Fallback).
func toggle_perspektive() -> bool:
if body == null:
return false
var neu: bool = body.call("toggle_perspektive")
if not _avatar_da:
show_message("Kein Avatar-Modell gefunden (Client-Klon ../scopedvr-client fehlt) — Third Person nicht verfügbar.")
return false
PlaytestSettings.save_third(neu)
if neu:
show_message("Dritte Person AN — derselbe Avatar wie die Bots im Spiel. V = zurück zur ersten Person.")
else:
show_message("Erste Person AN (Standard). V = dritte Person.")
return neu
## Avatar-Gait aus der ECHTEN Bewegung speisen (Kartenvorgabe: Stillstand/
## Walk/Jog aus der tatsächlichen Geschwindigkeit, Blickrichtung ==
## Bewegungsrichtung über die Body-Rotation). Jeden Frame — die Kadenz-
## Mathe des Avatars glättet selbst (BLEND_SMOOTH_S, wie die Bots).
func _speise_avatar() -> void:
if body == null:
return
var av: Node3D = body.get("avatar") as Node3D
if av == null or not is_instance_valid(av):
return
var speed: float = body.call("horizontale_geschwindigkeit")
av.call("apply_state", PlaytestAvatar.zustand_fuer(speed), speed)
## Fliegen / God-Mode umschalten (Taste G). Rückgabe: neuer Zustand.
func toggle_fly() -> bool:
if body == null:
return false
var new_state: bool = not bool(body.get("fly_mode"))
body.set("fly_mode", new_state)
body.set("velocity", Vector3.ZERO) # kein Fall-Übernehmen aus dem Gang
if new_state:
show_message("Fliegen AN (God-Mode): keine Gravitation — Blickrichtung fliegen, Space = steigen, G = aus.")
else:
show_message("Fliegen AUS — wieder mit Gravitation.")
return new_state
## Spawnpunkt-Punkt für Taste index (1 = Team A/Zone 0, 2 = Team B/Zone 1,
## 3/4 = weitere Zonen 2/3): erster Punkt der Zone. Rückgabe {} = keine Zone.
## pos = [x,y,z] aus dem Dokument.
func _spawn_point_for(index: int) -> Dictionary:
if body == null:
return {}
var zones: Dictionary = (body.get("doc") as Dictionary).get("spawn_zones", {})
var zone: Variant = zones.get(str(index - 1))
if not (zone is Dictionary):
return {}
var points: Array = (zone as Dictionary).get("points", [])
if points.is_empty() or not (points[0] is Dictionary):
return {}
var p := points[0] as Dictionary
var pos: Array = p.get("pos", [])
if pos.size() < 3:
return {}
return {"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
"label": str((zone as Dictionary).get("label", "Zone %d" % index)),
"yaw": p.get(SpawnOps.YAW_KEY, null)}
## Sprung zu einem Spawnpunkt (Tasten 1–4): exakt auf den Punkt gesetzt
## (x/z aus dem Dokument, Höhe vom Terrain — dieselbe Konvention wie der
## Lauf-Modus-Start). Rückgabe true = gelandet.
func jump_to_spawn(index: int) -> bool:
var sp := _spawn_point_for(index)
if sp.is_empty() or body == null:
show_message("Kein Spawnpunkt für Taste %d — die Karte hat diese Zone nicht." % index)
return false
var target: Vector3 = sp["pos"]
var y: float = body.terrain_height(target.x, target.z)
# Kapsel-Mitte = Füße + Halbhöhe (+ Toleranz) — die Physik lässt den Body
# sanft auf den Trimesh fallen (wie beim Start, Karte t_1da96275).
body.set("position", Vector3(target.x, y + PlaytestBody.HALF_H + 0.02, target.z))
body.set("velocity", Vector3.ZERO)
body.set("on_floor", true)
if sp["yaw"] != null:
body.set("yaw_deg", float(sp["yaw"]))
show_message("Spawnpunkt %d = %s: x=%.1f z=%.1f (Höhe %.1f) — Blickrichtung steht im HUD." % [
index, str(sp["label"]), target.x, target.z, y])
return true
## HUD (Position/Blick/FPS/Hinweis) an/aus (Taste H). Rückgabe: sichtbar?
func toggle_hud() -> bool:
if hud == null:
return false
hud.visible = not hud.visible
if hud.visible:
show_message("HUD an.") # aus: Meldung wäre selbst unsichtbar
return hud.visible
## Hilfe-Overlay an/aus (Taste F1). Rückgabe: sichtbar?
func toggle_help() -> bool:
if help_layer == null:
_build_help()
help_layer.visible = not help_layer.visible
return help_layer.visible
## Kartenmitte/Achsen-Linien an/aus (Taste X). Rückgabe: sichtbar?
func toggle_axis() -> bool:
if _axis_node == null:
_build_axis()
_axis_node.visible = not _axis_node.visible
if _axis_node.visible:
show_message("Kartenmitte/Achsen AN (X = aus): X-Rot, Z-Blau, Mitte Grün.")
else:
show_message("Kartenmitte/Achsen AUS.")
return _axis_node.visible
## Hilfe-Overlay (F1): Panel mit allen Tasten im Klartext, mittig unten.
func _build_help() -> void:
help_layer = CanvasLayer.new()
help_layer.layer = 101
var center := CenterContainer.new()
center.set_anchors_preset(Control.PRESET_FULL_RECT)
center.mouse_filter = Control.MOUSE_FILTER_IGNORE
help_layer.add_child(center)
var panel := PanelContainer.new()
center.add_child(panel)
var margin := MarginContainer.new()
margin.add_theme_constant_override("margin_left", 16)
margin.add_theme_constant_override("margin_right", 16)
margin.add_theme_constant_override("margin_top", 12)
margin.add_theme_constant_override("margin_bottom", 12)
panel.add_child(margin)
var label := Label.new()
label.text = "TESTLAUF — ALLE TASTEN
WASD — bewegen · Maus — umsehen
Shift — rennen · Space — springen / im Flug steigen
G — Fliegen (God-Mode) an/aus: keine Gravitation, Blickrichtung fliegen
V — Perspektive umschalten (erste / dritte Person)
1 — Sprung zu Team A (Spawnpunkt) · 2 — Team B
3 / 4 — weitere Spawnpunkte (Zonen 2/3)
T — Test-Start auf den Blickpunkt setzen (sofort dort)
H — HUD an/aus (Position, Blick, FPS, Höhe)
X — Kartenmitte/Achsen ein/aus (X-Rot, Z-Blau)
F1 — diese Hilfe · ESC — zurück zum Editor"
label.add_theme_font_size_override("font_size", 18)
margin.add_child(label)
add_child(help_layer)
help_layer.visible = false
## Achsen-Linien durch die Kartenmitte (Taste X): X-Achse rot, Z-Achse blau,
## senkrechte Mitte grün — beim gespiegelten Bauen (M1) auf einen Blick.
func _build_axis() -> void:
var doc: Dictionary = (body.get("doc") if body != null else {}) as Dictionary
var bounds: Dictionary = doc.get("bounds", {})
var mn: Array = bounds.get("min", [-50.0, -10.0, -50.0])
var mx: Array = bounds.get("max", [50.0, 80.0, 50.0])
var cx := (float(mn[0]) + float(mx[0])) * 0.5
var cz := (float(mn[2]) + float(mx[2])) * 0.5
var y0: float = body.terrain_height(cx, cz) if body != null else 0.0
var mesh := ImmediateMesh.new()
mesh.surface_begin(Mesh.PRIMITIVE_LINES)
# X-Achse (rot): ganze Kartenbreite durch die Mitte.
mesh.surface_set_color(Color(1.0, 0.25, 0.25))
mesh.surface_add_vertex(Vector3(float(mn[0]), y0, cz))
mesh.surface_add_vertex(Vector3(float(mx[0]), y0, cz))
# Z-Achse (blau): ganze Kartentiefe durch die Mitte.
mesh.surface_set_color(Color(0.3, 0.5, 1.0))
mesh.surface_add_vertex(Vector3(cx, y0, float(mn[2])))
mesh.surface_add_vertex(Vector3(cx, y0, float(mx[2])))
# Senkrechte Mitte (grün): vom Terrain bis zur oberen Grenze.
mesh.surface_set_color(Color(0.3, 1.0, 0.4))
mesh.surface_add_vertex(Vector3(cx, y0, cz))
mesh.surface_add_vertex(Vector3(cx, float(mx[1]), cz))
mesh.surface_end()
var mi := MeshInstance3D.new()
mi.name = "PlaytestAchsen"
mi.mesh = mesh
# Vertexfarben sichtbar machen: unshaded + vertex_color_use_as_albedo
# (ohne das ignoriert der Default-Material-Shader die Linien-Farben).
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.vertex_color_use_as_albedo = true
mat.no_fog = true
mi.material_override = mat
mi.position = Vector3.ZERO
_axis_node = mi
add_child(_axis_node)

View file

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

View file

@ -0,0 +1,84 @@
extends RefCounted
class_name PlaytestSettings
## Einstellungen-Speicher für den Lauf-Modus (Karte t_aaff5dcc, T3 Komfort) —
## bewusst NICHT im .svmap-Format und NICHT in main.gd: der Operator hat für
## Einstellungen den Registry-Weg vorgegeben (Panel meldet sich in
## core/ui/panels/registry.gd an), der Zustand lebt HIER als statischer Store.
##
## Ablage: user://scopedvr_playtest.cfg, Abschnitt [playtest].
## Wert: Maus-Empfindlichkeit als Multiplikator (0,1–5,0, Standard 1,0) auf
## die Body-Basis 0,25 Grad/Pixel — jede PC/Maus-Kombination ist anders.
##
## Statischer Cache: ein Lese-Vorgang pro Editor-Start; save_sens() hält den
## Cache frisch. Tests können mit reset_cache() neu laden erzwingen.
const CFG_PATH := "user://scopedvr_playtest.cfg"
const SECTION := "playtest"
const KEY_SENS := "maus_empfindlichkeit"
const DEFAULT_SENS := 1.0
const MIN_SENS := 0.1
const MAX_SENS := 5.0
## Perspektive im Testlauf (Karte t_17a35c59, Taste V): false = First Person
## (Standard, wie bisher), true = Third Person (Avatar sichtbar).
const KEY_THIRD := "dritte_person"
## Cache (null = noch nicht gelesen). static var ist Godot 4.1+.
static var _cache: Variant = null
## Cache für den Perspektive-Eintrag (null = noch nicht gelesen).
static var _cache_third: Variant = null
## Gespeicherte Empfindlichkeit lesen (geclampt auf 0,1–5,0), Standard 1,0.
static func load_sens() -> float:
if _cache != null:
return float(_cache)
var cfg := ConfigFile.new()
var raw: Variant = DEFAULT_SENS
if cfg.load(CFG_PATH) == OK:
raw = cfg.get_value(SECTION, KEY_SENS, DEFAULT_SENS)
var val := clampf(float(raw), MIN_SENS, MAX_SENS)
_cache = val
return val
## Empfindlichkeit speichern (geclampt auf 0,1–5,0) und Cache frisch halten.
## Rückgabe: der tatsächlich gespeicherte Wert.
static func save_sens(value: float) -> float:
var val := clampf(value, MIN_SENS, MAX_SENS)
var cfg := ConfigFile.new()
cfg.load(CFG_PATH) # Fehler → leere Config, nur unser Key wird angelegt.
cfg.set_value(SECTION, KEY_SENS, val)
cfg.save(CFG_PATH)
_cache = val
return val
## Gespeicherte Perspektive lesen (Karte t_17a35c59): true = Third Person.
## Default FALSE = First Person — der Testlauf startet so wie bisher.
static func load_third() -> bool:
if _cache_third != null:
return bool(_cache_third)
var cfg := ConfigFile.new()
var raw: Variant = false
if cfg.load(CFG_PATH) == OK:
raw = cfg.get_value(SECTION, KEY_THIRD, false)
var val := bool(raw)
_cache_third = val
return val
## Perspektive speichern (persistiert über Editor-Neustarts) und Cache frisch
## halten. Rückgabe: der tatsächlich gespeicherte Wert.
static func save_third(value: bool) -> bool:
var cfg := ConfigFile.new()
cfg.load(CFG_PATH) # Fehler → leere Config, nur unser Key wird angelegt.
cfg.set_value(SECTION, KEY_THIRD, value)
cfg.save(CFG_PATH)
_cache_third = value
return value
## Cache verwerfen (für Tests / frisches Neulesen von der Platte).
static func reset_cache() -> void:
_cache = null
_cache_third = null

View file

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

View file

@ -0,0 +1,92 @@
extends RefCounted
class_name TestStart
## Test-Startpunkt (Karte t_96e3c4ee): der Punkt, an dem „▶ Karte testen“ (F5)
## den Spieler absetzt — KEIN Spiel-Spawnpunkt (taucht nicht in doc,
## Spawn-Checks/Analysen oder Validator auf, ändert keine Zonen).
##
## Bewusste Entscheidungen dieser Karte:
## - Der Punkt lebt NUR editor-seitig (Sidecar, test_start_store.gd) — das
## .svmap-Format (Validator V1–V16, Client/Server) bleibt UNBERÜHRT.
## - Gespeichert wird nur x/z. Die Höhe kommt IMMER automatisch aus dem
## Terrain an der Stelle (SvmapDocument.terrain_height_at) — Terrain
## formen lässt den Punkt nicht schweben und versinken.
## - Fallback-Reihenfolge F5 (je mit Klartext-Hinweis): gesetzter Test-Start
## → erster Team-Spawnpunkt (Team Blau, wie bisher) → Kartenmitte.
## - Trifft der Klick ein Objekt/Bauteil: landet der Punkt auf dem BODEN
## DARUNTER (nie im Objekt schwebend) — das Tool meldet es im Klartext.
##
## Alles pure Statics (kein UI, kein Node-Zugriff) → headless testbar in
## tests/test_playtest_spawn.gd.
## Kein Punkt gesetzt (Sentinel-Typ für Variant-Vergleiche in main.gd).
## Start-Punkt für den Testlauf auflösen (Fallback-Kette). test_point ist
## Vector3 (nur x/z zählen) oder null. Rückgabe:
## {pos: Vector3 (y = Terrainhöhe), quelle: "test"|"team"|"mitte",
## status: Klartext für die Statuszeile}
static func resolve_start(doc: Dictionary, test_point: Variant) -> Dictionary:
if test_point != null:
var p: Vector3 = test_point
var y := SvmapDocument.terrain_height_at(doc, p.x, p.z)
return {
"pos": Vector3(p.x, y, p.z),
"quelle": "test",
"status": "Start an deinem gesetzten Test-Startpunkt (%.0f, %.0f, Höhe automatisch)." % [p.x, p.z],
}
# Erster Team-Spawnpunkt (Team Blau = „0“) — exakt wie bisher (t_1cdc1a72).
var zones: Dictionary = doc.get("spawn_zones", {})
var team0 = zones.get("0", {})
if team0 is Dictionary:
var points: Array = (team0 as Dictionary).get("points", [])
if not points.is_empty() and points[0] is Dictionary:
var pos = (points[0] as Dictionary).get("pos", [])
if pos is Array and (pos as Array).size() >= 3:
return {
"pos": Vector3(float(pos[0]), float(pos[1]), float(pos[2])),
"quelle": "team",
"status": "Kein Test-Start gesetzt — Start am ersten Team-Blau-Spawnpunkt.",
}
# Kartenmitte aus den Bounds.
var bounds: Dictionary = doc.get("bounds", {})
var mn: Array = bounds.get("min", [-50.0, -10.0, -50.0])
var mx: Array = bounds.get("max", [50.0, 80.0, 50.0])
var cx := (float(mn[0]) + float(mx[0])) * 0.5
var cz := (float(mn[2]) + float(mx[2])) * 0.5
return {
"pos": Vector3(cx, SvmapDocument.terrain_height_at(doc, cx, cz), cz),
"quelle": "mitte",
"status": "Weder Test-Start noch Team-Spawnpunkte — Start in der Kartenmitte.",
}
## Strahl → erster Treffer auf einer AABB (Slab-Methode) oder -1.0.
## Für „Klick trifft Bauteil“ im Editor-Tool und „Fadenkreuz trifft Hindernis“
## im Testlauf (kein Physik-Server im Editor/SubViewport).
## Rückgabe: Distanz t >= 0 (origin + dir * t) oder -1.0 = kein Treffer.
static func ray_aabb(origin: Vector3, dir: Vector3, box: AABB) -> float:
var d := dir
if d.length_squared() < 0.000000001:
return -1.0
d = d.normalized()
var tmin := 0.0
var tmax := INF
for axis in 3:
var o := origin[axis]
var dv := d[axis]
var lo := box.position[axis]
var hi := box.end[axis]
if absf(dv) < 0.0000001:
if o < lo or o > hi:
return -1.0
continue
var t1 := (lo - o) / dv
var t2 := (hi - o) / dv
if t1 > t2:
var tmp := t1
t1 = t2
t2 = tmp
tmin = maxf(tmin, t1)
tmax = minf(tmax, t2)
if tmin > tmax:
return -1.0
return tmin

View file

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

View file

@ -0,0 +1,64 @@
extends RefCounted
class_name TestStartStore
## Sidecar-Speicher für den Test-Startpunkt (Karte t_96e3c4ee) — bewusst
## NICHT im .svmap-Format (Validator V1–V16 + Client/Server bleiben unberührt;
## eine Map ohne den Punkt verhält sich exakt wie bisher).
##
## Ablage: user://scopedvr_teststart.cfg, Abschnitt [test_start], je Kartenpfad
## ein Schlüssel (globalisierter Pfad). Ungespeicherte Maps landen unter dem
## Sammel-Schlüssel UNGESPEICHERT (Session-Speicher über Neustarts hinaus nur,
## wenn die Map gespeichert wird — der Punkt wandert beim Speichern mit).
## Gespeichert werden nur x/z: die Höhe kommt immer automatisch vom Terrain.
const CFG_PATH := "user://scopedvr_teststart.cfg"
const SECTION := "test_start"
const UNGESPEICHERT := "__ungespeichert__"
## Schlüssel für einen Kartenpfad (leer = ungespeicherte Map).
static func key_for(map_path: String) -> String:
if map_path.is_empty():
return UNGESPEICHERT
return ProjectSettings.globalize_path(map_path)
## Gespeicherten Punkt lesen (Vector3 mit y=0 — Höhe kommt vom Terrain)
## oder null, wenn keiner gesetzt ist.
static func load_point(map_path: String) -> Variant:
var cfg := ConfigFile.new()
if cfg.load(CFG_PATH) != OK:
return null
var k := key_for(map_path)
var x: Variant = cfg.get_value(SECTION, k + "/x", null)
var z: Variant = cfg.get_value(SECTION, k + "/z", null)
if x == null or z == null:
return null
return Vector3(float(x), 0.0, float(z))
## Punkt speichern (nur x/z — Höhe automatisch).
static func save_point(map_path: String, pos: Vector3) -> void:
var cfg := ConfigFile.new()
cfg.load(CFG_PATH) # Fehler → leere Config, Punkt wird neu angelegt.
var k := key_for(map_path)
cfg.set_value(SECTION, k + "/x", pos.x)
cfg.set_value(SECTION, k + "/z", pos.z)
cfg.save(CFG_PATH)
## Punkt löschen (Datei bleibt — andere Maps sind unberührt).
## Bewusst Sektions-Neuaufbau statt erase_section_key: Godots
## erase_section_key löscht Keys mit "/" im Namen still NICHT (Probe 20.10.).
static func clear_point(map_path: String) -> void:
var cfg := ConfigFile.new()
if cfg.load(CFG_PATH) != OK:
return
var k := key_for(map_path)
var keep := {}
for key in cfg.get_section_keys(SECTION):
if not key.begins_with(k + "/"):
keep[key] = cfg.get_value(SECTION, key)
cfg.erase_section(SECTION)
for key in keep:
cfg.set_value(SECTION, key, keep[key])
cfg.save(CFG_PATH)

View file

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

View file

@ -0,0 +1,253 @@
extends Node3D
## Test-Startpunkt-Werkzeug (Karte t_96e3c4ee): „hinklicken, wo man beim
## Testen spawnt“ — ein Klick auf Boden/Terrain setzt den Test-Startpunkt.
## KEIN Spiel-Spawnpunkt: der Punkt lebt NICHT im .svmap (Sidecar via
## TestStartStore), taucht in keinem Spawn-Check/Keiner Analyse auf und hat
## KEINEN Undo-Eintrag (editor-seitiger Session-Zustand, wie die UI-Größe).
##
## Bedienung (Einbindung in main.gd, wie T1 den Lauf-Modus angemeldet hat):
## Toolbar-Knopf „🎯 Test-Start“ oder Taste T → Werkzeug an
## Linksklick auf den Boden → Test-Start setzen (Höhe automatisch)
## Linksklick auf Objekt/Bauteil → Punkt auf den Boden DARUNTER (Klartext-
## Hinweis: „auf dem Boden unter dem Objekt“)
## Entf (im Werkzeug) / „Löschen“-Knopf rechts → Test-Start löschen
## ESC → Werkzeug beenden
## Rechte Maustaste: bewusst NICHT behandelt — sie gehört dem Freelook-Flug
## (Karte t_2470de89, absolute Priorität wie in jedem anderen Werkzeug).
##
## Marker (schmale Säule + Ring, Editor-Akzentfarbe) bleibt sichtbar, solange
## ein Punkt gesetzt ist — auch außerhalb des Werkzeugs. Vorschau-Ring folgt
## dem Cursor nur IM Werkzeug (wie der Spawn-Zonen-Ghost).
## Editor-Akzentfarbe (wie Statuszeile/Kurzhilfe in main.gd) — „Farbe aus dem
## Theme“ laut Karte; dieselbe Konstante, keine neue Farbwelt.
const ACCENT := Color(0.55, 0.8, 1.0)
var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D
var view_builder # Welt-Bauer (script-erweitert, Variant)
var interaction # Objekt-Interaktion (für Objekt-Treffer-Test, nur LESEND)
var active := false
# Terrain-Cache für Cursor-Raycast (nur LESEN — dieses Werkzeug formt nichts)
var _heights := PackedFloat32Array()
var _res := 0
var _sx := 0.0
var _sz := 0.0
var _ghost: Node3D
var _marker: Node3D
var _cursor_valid := false
var _cursor := Vector3.ZERO
func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
interaction = p_interaction
_ensure_ghost()
_ensure_marker()
# ------------------------------------------------------------ Aktivierung ---
func activate() -> bool:
active = true
refresh_from_doc()
return true # nie verweigert — der Test-Start braucht kein Auflösungs-Limit
func deactivate() -> void:
active = false
_cursor_valid = false
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
## Nach Laden/Neu/Speichern (Pfadwechsel → anderer Sidecar-Schlüssel) und
## nach Terrain-Änderungen: Cache + Marker aus main.test_start_point frisch.
func refresh_from_doc() -> void:
var terrain: Dictionary = main.doc.get("terrain", {})
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
_heights = SvmapIO.decode_heights(terrain)
else:
_heights = PackedFloat32Array()
_update_marker()
# --------------------------------------------------------------- Eingaben ---
func _unhandled_input(event: InputEvent) -> void:
var p := MousePointer.from_input(event)
if p != null and handle_pointer(p):
get_viewport().set_input_as_handled()
func handle_pointer(p: PointerEvent) -> bool:
if not active:
return false
if p.kind == PointerEvent.Kind.MOVE:
_update_cursor(p.position)
elif p.kind == PointerEvent.Kind.PRESS:
_click(p.position)
return true
return false
func _click(viewport_pos: Vector2) -> void:
# 1) Objekt-Treffer? (Kugel-Approx der interaction — derselbe Pfad wie die
# Auswahl; bewusst nur GERUFEN, interaction.gd bleibt unangetastet.)
var obj_idx: Variant = interaction.call("_pick_object", viewport_pos)
var hit_obj := false
if obj_idx is int and int(obj_idx) >= 0:
hit_obj = true
# 3) Punkt setzen: x/z vom Strahl-Treffer, Höhe IMMER vom Terrain (nie
# im Objekt schwebend, nie versunken).
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var p: Vector3 = _clamped(hit as Vector3)
var hinweis := ""
if hit_obj:
hinweis = "auf dem Boden unter dem Objekt"
main.call("_test_start_apply", p.x, p.z, hinweis)
func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
_cursor_valid = false
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
return
_cursor_valid = true
_cursor = _clamped(hit as Vector3)
if _ghost != null and is_instance_valid(_ghost):
_ghost.position = Vector3(_cursor.x, _cursor.y + 0.06, _cursor.z)
_ghost.visible = true
## Strahl → Punkt auf dem Terrain (wie Terrain-/Spawn-Werkzeug;
## TerrainField.raycast — favorisiert echte Geländeberührung).
func _ray_hit(viewport_pos: Vector2) -> Variant:
if _res >= 2 and _heights.size() >= _res * _res:
return TerrainField.raycast(_heights, _res, _sx, _sz,
camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos))
var terrain: Dictionary = main.doc.get("terrain", {})
if terrain.is_empty():
return null
var plane_y := float(terrain.get("min_height_m", 0.0))
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
if absf(dir.y) < 0.0001:
return null
var t := (plane_y - origin.y) / dir.y
if t <= 0.0:
return null
return origin + dir * t
func _clamped(p: Vector3) -> Vector3:
var half := Vector2(_sx, _sz) * 0.5
p.x = clampf(p.x, -half.x, half.x)
p.z = clampf(p.z, -half.y, half.y)
p.y = SvmapDocument.terrain_height_at(main.doc, p.x, p.z)
return p
# ----------------------------------------------------------------- Marker ---
## Marker an den gesetzten Punkt hängen (oder ausblenden). Höhe IMMER
## automatisch: Terrainhöhe an (x, z) — nach Terrain-Formen bleibt der
## Punkt auf dem Boden (kein Schweben, kein Versinken).
func _update_marker() -> void:
_ensure_marker()
var point: Variant = main.get("test_start_point")
if point == null:
_marker.visible = false
return
var p: Vector3 = point
p.y = SvmapDocument.terrain_height_at(main.doc, p.x, p.z)
_marker.position = p
_marker.visible = true
# ------------------------------------------------------- Optik (Ghost/Marker) --
func _accent_mat(alpha: float) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(ACCENT.r, ACCENT.g, ACCENT.b, alpha)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.emission_enabled = true
mat.emission = ACCENT * 0.35
return mat
func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
return
_ghost = Node3D.new()
_ghost.visible = false
add_child(_ghost)
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = 0.65
torus.outer_radius = 0.85
torus.rings = 48
torus.ring_segments = 6
ring.mesh = torus
ring.position.y = 0.05
ring.material_override = _accent_mat(0.6)
_ghost.add_child(ring)
## Marker: schmale Säule + Ring am Boden (Karten-Vorgabe), Akzentfarbe.
func _ensure_marker() -> void:
if _marker != null and is_instance_valid(_marker):
return
_marker = Node3D.new()
_marker.visible = false
add_child(_marker)
var pillar := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = 0.12
cyl.bottom_radius = 0.12
cyl.height = 4.0
pillar.mesh = cyl
pillar.position.y = 2.0 # Fußpunkt auf dem Terrain
pillar.material_override = _accent_mat(0.55)
_marker.add_child(pillar)
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = 0.55
torus.outer_radius = 0.75
torus.rings = 48
torus.ring_segments = 6
ring.mesh = torus
ring.position.y = 0.05
ring.material_override = _accent_mat(0.85)
_marker.add_child(ring)
# ------------------------------------------------------------ Demo/Tests ----
## Demo-Hook (Suite/Renderprobe): Punkt programmatisch setzen — macht exakt,
## was ein Klick machen würde (ohne echte Maus-Ereignisse). hinweis = derselbe
## Klartext wie beim echten Klick ("" = freier Boden, sonst „unter dem Objekt“).
func demo_set(x: float, z: float, hinweis := "") -> void:
main.call("_test_start_apply", x, z, hinweis)
## Demo-Hook: Ghost an Position parken (Renderprobe ohne Maus).
func demo_park_ghost(x: float, z: float) -> void:
_cursor = _clamped(Vector3(x, 0.0, z))
_cursor_valid = true
_ghost.position = Vector3(_cursor.x, _cursor.y + 0.06, _cursor.z)
_ghost.visible = true

View file

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

13
editor/pointer_router.gd Normal file
View file

@ -0,0 +1,13 @@
class_name PointerRouter
extends RefCounted
## Gibt eine Zeige-Eingabe in derselben Reihenfolge an die Werkzeuge wie Godot
## die Maus: tiefer und später im Baum zuerst, das erste Werkzeug nimmt sie.
## Für Eingaben, die nicht über den Viewport kommen, also den VR-Controller.
static func dispatch(main: Node, p: PointerEvent) -> bool:
for key in ["test_start_tool", "spawn_tool", "terrain_tool", "gizmo", "interaction"]:
var werkzeug: Variant = main.get(key)
if werkzeug != null and bool(werkzeug.call("handle_pointer", p)):
return true
return false

View file

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

121
editor/shortcut_keys.gd Normal file
View file

@ -0,0 +1,121 @@
extends RefCounted
class_name ShortcutKeys
## InputMap-Actions der Editor-Tastatur-Shortcuts (Karte t_c46d31c2, Karte 1/3).
##
## Konvention (alle Shortcut-Karten): Tastaturcode-basiert — JEDER Action wird
## ein InputEventKey mit PHYSICAL keycode zugewiesen (KEY_*-Konstante). Die
## physische Taste liegt unabhängig vom Tastaturlayout an derselben Position
## (QWERTZ wie QWERTY bei W/A/S/D/Leertaste/Shift), damit deutsche und
## Windows-Tastaturen identisch bedienen — keine OS-Hardcodes (PLAN.md
## Plattform-Regel).
##
## Registrierung per Code statt project.godot: KEY_*-Konstanten bleiben im
## Quelltext lesbar und der Test kann exakt dieselben Konstanten prüfen.
## register() ist idempotent (mehrfacher Aufruf = keine Doppel-Events).
## Action → Liste von Events. Einträge:
## KEY_X = einfache physische Taste
## {"key": KEY_X, "shift": true} = Taste + Modifier (z. B. Umschalt+F)
const ACTIONS := {
# --- Freelook-Flug (Rechte Maustaste halten bzw. Umschalt+F) -------------
"editor_freelook_toggle": [{"key": KEY_F, "shift": true}],
"editor_freelook": [MOUSE_BUTTON_RIGHT],
"editor_fly_forward": [KEY_W],
"editor_fly_back": [KEY_S],
"editor_fly_left": [KEY_A],
"editor_fly_right": [KEY_D],
# Flug-Vertikal + Tempo (Shooter-Konvention, Sly-Wunsch 17.10.):
# Leertaste = hoch, Shift = runter (Half-Life/CS-Konvention), Strg halten =
# temporär schneller, Alt halten = langsamer. Mausrad bleibt Grundtempo.
# Konflikt-Freiheit: Leertaste/Shift/Strg SIND im Editor sonst nur als
# Modifier-Kombos belegt (Strg+Z, Umschalt+Klick …) — allein gedrückt
# triggern sie KEINE Werkzeug-Aktion (Tests: test_shortcuts.gd,
# Konflikt-Gruppe). Im Flugmodus gibt der Kamera-Einstieg den GUI-Fokus
# frei (orbit_camera._enter_fly), damit Leertaste keinen fokussierten
# Button auslöst.
"editor_fly_up": [KEY_SPACE],
"editor_fly_down": [KEY_SHIFT],
"editor_fly_fast": [KEY_CTRL],
"editor_fly_slow": [KEY_ALT],
# --- Auswahl / Objekt-Operationen ----------------------------------------
"editor_focus_selection": [KEY_F],
"editor_cancel": [KEY_ESCAPE],
"editor_delete_selection": [KEY_DELETE],
"editor_duplicate_selection": [{"key": KEY_D, "ctrl": true}],
# Strg+LMB = Mehrfachauswahl umschalten (Maus-Event, handler prüft den
# Modifier direkt am InputEventMouseButton — Action dokumentiert die Idee).
"editor_multi_select": [{"key": KEY_NONE, "mouse_left_ctrl": true}],
# --- Objekt-Manipulation (Karte t_55257792, Karte 2/3) -------------------
"editor_rotate_90": [KEY_R],
"editor_nudge_up": [KEY_UP],
"editor_nudge_down": [KEY_DOWN],
"editor_nudge_left": [KEY_LEFT],
"editor_nudge_right": [KEY_RIGHT],
"editor_lift_to_floor": [{"key": KEY_PAGEUP, "ctrl": true}],
"editor_grab": [KEY_G],
# --- Terrain-Pinsel (nur im Terrain-Werkzeug aktiv, main gated) ----------
"editor_brush_smaller": [KEY_BRACKETLEFT],
"editor_brush_larger": [KEY_BRACKETRIGHT],
"editor_brush_raise": [KEY_1],
"editor_brush_lower": [KEY_2],
"editor_brush_smooth": [KEY_3],
"editor_brush_paint": [KEY_4],
"editor_brush_flatten": [KEY_5],
"editor_brush_ramp": [KEY_6],
# --- Datei & Undo (Karte t_11b5c25b, Karte 3/3) --------------------------
# Strg+Z/Strg+Y laufen seit dem M2-UX-Fundament; hier NACHGEBUGGEN als
# dokumentierte Actions (alle 3 Shortcut-Karten: ACTIONS-Dict = Source of
# truth). Strg+Umschalt+Z ist der zweite Redo-Weg (beide belegt).
"editor_undo": [{"key": KEY_Z, "ctrl": true}],
"editor_redo_y": [{"key": KEY_Y, "ctrl": true}],
"editor_redo_shift_z": [{"key": KEY_Z, "ctrl": true, "shift": true}],
"editor_save": [{"key": KEY_S, "ctrl": true}],
"editor_save_as": [{"key": KEY_S, "ctrl": true, "shift": true}],
"editor_open_map": [{"key": KEY_O, "ctrl": true}],
"editor_new_map": [{"key": KEY_N, "ctrl": true}],
# --- Werkzeugwechsel (F-Tasten statt Ziffern, Slys Weiche) ---------------
"editor_tool_terrain": [KEY_F1],
"editor_tool_objects": [KEY_F2],
"editor_tool_bauen": [KEY_F3],
"editor_tool_spawns": [KEY_F4],
# --- Kurzhilfe-Overlay + Test-Platzhalter --------------------------------
"editor_help_overlay": [KEY_H],
"editor_test_map": [KEY_F5],
# --- Höhenlinien fixieren (Karte t_89c0ec95) ------------------------------
# L = Nutzer-Wahl „Linien immer an/aus" — ohne Fix sind sie NUR im
# Terrain-Werkzeug (F1) sichtbar (Werkzeug-Kopplung in main.gd). L ist
# sonst unbesetzt (Kollisions-Prüfung Karte t_89c0ec95: keine Taste in
# ACTIONS nutzt KEY_L, Ziffern bleiben Terrain-Pinsel allein vorbehalten).
"editor_contours_toggle": [KEY_L],
}
static func register() -> void:
for action in ACTIONS:
if InputMap.has_action(action):
continue
InputMap.add_action(action)
for entry in ACTIONS[action]:
InputMap.action_add_event(action, _make_event(entry))
static func _make_event(entry) -> InputEvent:
if entry is Dictionary:
if entry.get("mouse_left_ctrl", false):
var mb := InputEventMouseButton.new()
mb.button_index = MOUSE_BUTTON_LEFT
mb.ctrl_pressed = true
return mb
var key := InputEventKey.new()
key.physical_keycode = int(entry.get("key", KEY_NONE))
key.shift_pressed = bool(entry.get("shift", false))
key.ctrl_pressed = bool(entry.get("ctrl", false))
key.alt_pressed = bool(entry.get("alt", false))
return key
if entry is int and entry >= MOUSE_BUTTON_NONE and entry <= MOUSE_BUTTON_RIGHT:
var mouse := InputEventMouseButton.new()
mouse.button_index = int(entry)
return mouse
var simple := InputEventKey.new()
simple.physical_keycode = int(entry)
return simple

View file

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

48
editor/shortcut_policy.gd Normal file
View file

@ -0,0 +1,48 @@
extends RefCounted
class_name ShortcutPolicy
## Esc-Verhalten des Map-Editors (Karte t_c46d31c2, Karte 1/3) — bewusst pure
## Funktion: main.gd fragt pro Esc-Taste ab WELCHE Zustandsänderung fällig ist
## und macht nur noch den Text. tests/test_shortcuts.gd prüft alle Kombos.
##
## Priorität (spec „Esc = Auswahl aufheben / Werkzeug abbrechen / Ghost abbrechen"):
## 1. aktives Sonder-Werkzeug verlassen (Terrain/Spawns → Objekt-Werkzeug)
## 2. Ghost-Preview (Platzieren) abbrechen
## 3. Auswahl aufheben
## Rückgabe-Werte:
## "exit_terrain" | "exit_spawns" | "exit_teststart" | "cancel_grab"
## "cancel_placement" | "clear_selection" | "none"
## grabbing (Karte t_55257792): G-Greifen läuft — Esc rollt die Positionen
## zurück (Blender-Semantik). Vorrang NACH Werkzeug-Exit (im Terrain-
## Werkzeug gibt es kein Greifen), VOR Platzieren/Greifen-Auswahlheben.
static func esc_action(tool_mode: String, placing: bool, has_selection: bool,
grabbing := false) -> String:
if tool_mode == "terrain":
return "exit_terrain"
if tool_mode == "spawns":
return "exit_spawns"
if tool_mode == "teststart":
# Karte t_96e3c4ee (Wiederanbindung 20.10.): Esc verlässt das
# Test-Start-Werkzeug — der gesetzte Punkt BLEIBT bestehen.
return "exit_teststart"
if grabbing:
return "cancel_grab"
if placing:
return "cancel_placement"
if has_selection:
return "clear_selection"
return "none"
## Karte 3/3 (t_11b5c25b): Dürfen die globalen Tasten (F1–F5, H) gerade wirken?
## Nein, wenn ein Textfeld/SpinBox fokussiert ist (Tippen geht vor) oder ein
## Dialog/Fenster offen ist (Taste soll nicht HINTER dem Dialog wirken).
## Pure Funktion — main.gd liefert nur die beiden Fakten (testbar, wie esc_action).
static func tool_keys_allowed(text_focused: bool, dialog_open: bool) -> bool:
return not text_focused and not dialog_open
## Karte 3/3: Verlust-Operation (Neue Map / Öffnen) bei ungespeicherten
## Änderungen → erst NACHFRAGEN (deutscher Dialog), sonst direkt ausführen.
## Pure Entscheidung — der Dialog selbst lebt in main.gd.
static func needs_dirty_confirm(dirty: bool) -> bool:
return dirty

View file

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

713
editor/spawn_tool.gd Normal file
View file

@ -0,0 +1,713 @@
extends Node3D
## Spawn-Zonen-Werkzeug (M2, Karte t_c4162c5d): Zonen für Team Blau/Rot auf
## den Boden malen — im Editor-Modus (Chef-Grundsatz Sly: easy + Spaß).
##
## Bedienung:
## Linksklick = Zone setzen (Klick wie beim Objekt-Platzieren)
## Linksklick auf Zone = Zone auswählen; ZIEHEN verschiebt sie (Terrain-Höhe)
## Pfeilspitze ziehen = Blickrichtung der Zone (auch Conquest-Zonen; grau = Standard)
## Regel rechts = Radius der neuen Zonen; mit Auswahl: Radius DER Zone
## Entf = ausgewählte Zone löschen
## ESC = Auswahl weg / Werkzeug beendet (ESC in main.gd;
## Rechte Maustaste = immer Flug, t_2470de89)
##
## Jede Operation ist EIN Undo-Eintrag über die gemeinsame ActionHistory
## (Blöcke spawn_zones + design_notes, SpawnOps.snapshot_blocks).
##
## Feedback: halbtransparenter Kreis folgt dem Cursor auf dem Terrain
## (Vorschau VOR dem Klick — wie der Objekt-Ghost), die gesetzte Zone leuchtet.
const MODE_HELP := "Linksklick: Zone setzen · Klick+Ziehen: Zone verschieben · Pfeilspitze ziehen: Blickrichtung · Entf: löschen ·\nRadius/Team rechts · Mittlere Maustaste: Kamera · Strg+Z: zurück"
## Symmetrie (M4, Karte t_ffa207b1): Partner-Kopplung gespiegelter Spawn-Zonen.
## Liegt in design_notes (Loader-frei, rundtrip-sicher — wie spawn_radius_m):
## {"A5": "B3", "B3": "A5"} — direkte Partner-IDs, beide Richtungen gesetzt.
const SPAWN_MIRROR_KEY := "spawn_mirror"
var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D
var view_builder # Welt-Bauer (script-erweitert, Variant)
var interaction # Objekt-Interaktion (für Auswahl-Entkoppelung)
var active := false
var team := 0 # 0 = Blau, 1 = Rot (TDM-Konvention)
var zone_radius := SpawnOps.DEFAULT_ZONE_RADIUS
var selected_team := -1
var selected_id := ""
# Auswähl-Ziehen
var _dragging := false
var _drag_moved := false
var _press_pos := Vector2.ZERO
var _grab_offset := Vector3.ZERO
# Pfeilspitze ziehen (Blickrichtung): Griff aus view_builder.yaw_handles(), {} = keiner
const ARROW_PICK_M := 2.5
const ARROW_SNAP_DEG := 5.0
var _arrow_grab: Dictionary = {}
var _arrow_yaw := 0.0
# Terrain-Cache für Cursor-Raycast (nur LESEN — dieses Werkzeug formt nichts)
var _heights := PackedFloat32Array()
var _res := 0
var _sx := 0.0
var _sz := 0.0
var _cursor_valid := false
var _cursor := Vector3.ZERO
# Ghost-Ring (Radius-Ring) + Fläche als Vorschau
var _ghost: Node3D
func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
interaction = p_interaction
_ensure_ghost()
func _ensure_ghost() -> void:
if _ghost != null and is_instance_valid(_ghost):
return
_ghost = Node3D.new()
_ghost.visible = false
add_child(_ghost)
# ------------------------------------------------------------ Aktivierung ---
func activate() -> bool:
active = true
selected_team = -1
selected_id = ""
_dragging = false
_cursor_valid = false
_refresh_from_doc()
if view_builder != null:
view_builder.call("set_spawn_selected", -1, "")
refresh_selection_panel()
return true # nie verweigert — Spawn-Zonen brauchen kein Auflösungs-Limit
func deactivate() -> void:
active = false
_dragging = false
selected_team = -1
selected_id = ""
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
## Nach Undo/Redo/Laden/Neu: Terrain-Cache neu lesen, Auswahl weg.
func refresh_from_doc() -> void:
_refresh_from_doc()
var still_there := false
if selected_team >= 0:
for p in SpawnOps.points(main.doc, selected_team):
if p is Dictionary and str(p.get("id", "")) == selected_id:
still_there = true
break
if not still_there:
selected_team = -1
selected_id = ""
func _refresh_from_doc() -> void:
var terrain: Dictionary = main.doc.get("terrain", {})
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
if _res >= 2 and _sx > 0.0 and _sz > 0.0:
_heights = SvmapIO.decode_heights(terrain)
else:
_heights = PackedFloat32Array()
# ------------------------------------------------------------ Werkzeug-UI ---
func set_team(next_team: int) -> void:
team = clampi(next_team, 0, 1)
_refresh_ghost_look()
if active:
main.call("_set_status", "Neue Zonen landen für %s (IDs %s1, %s2, …). Bestehende Zonen anklicken zum Verschieben." % [
SpawnOps.team_label(team), SpawnOps.team_prefix(team), SpawnOps.team_prefix(team)])
func set_radius(m: float) -> void:
zone_radius = clampf(m, SpawnOps.MIN_ZONE_RADIUS, SpawnOps.MAX_ZONE_RADIUS)
_refresh_ghost_look()
# Ausgewählte Zone? Dann gilt die Regel SOFORT für sie (mit Undo, merge-gekoppelt).
if active and selected_team >= 0 and selected_id != "":
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
SpawnOps.set_radius(main.doc, selected_id, zone_radius)
main.history.commit("Radius von %s: %d m" % [selected_id, int(zone_radius)],
before, SpawnOps.snapshot_blocks(main.history, main.doc),
"radius:%s" % selected_id)
main.call("_refresh_history_buttons")
main.call("_refresh_spawn_status")
_refresh_ghost_look()
var marker: Node3D = _marker_of(selected_team, selected_id)
if marker != null and is_instance_valid(marker):
view_builder.call("style_spawn_marker", marker,
SpawnOps.zone_radius(main.doc, selected_id), true)
# --------------------------------------------------------------- Eingaben ---
func _unhandled_input(event: InputEvent) -> void:
var p := MousePointer.from_input(event)
if p != null and handle_pointer(p):
get_viewport().set_input_as_handled()
## Rechte Maustaste kommt hier nie an (Karte t_2470de89): sie gehört mit
## absolutem Vorrang dem Freelook-Flug. Deselect/Werkzeug beenden läuft über
## ESC (main.gd), die Auswahl bleibt im Flug erhalten.
func handle_pointer(p: PointerEvent) -> bool:
if not active:
return false
match p.kind:
PointerEvent.Kind.MOVE:
_update_cursor(p.position)
if not _arrow_grab.is_empty():
_drag_arrow(p.position)
return false
if _dragging and selected_team >= 0:
var hit: Variant = _ray_hit(p.position)
if hit == null:
return false
var pos: Vector3 = _clamped(hit as Vector3) + _grab_offset
var half := _terrain_half()
pos.x = clampf(pos.x, -half.x + zone_radius, half.x - zone_radius)
pos.z = clampf(pos.z, -half.y + zone_radius, half.y - zone_radius)
pos.y = SvmapDocument.terrain_height_at(main.doc, pos.x, pos.z)
_drag_moved = true
var marker: Node3D = _marker_of(selected_team, selected_id)
if marker != null and is_instance_valid(marker):
marker.position = pos
PointerEvent.Kind.PRESS:
_on_left_press(p.position)
return true
PointerEvent.Kind.RELEASE:
_on_left_release()
return false
func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
_cursor_valid = false
if _ghost != null and is_instance_valid(_ghost):
_ghost.visible = false
return
_cursor_valid = true
_cursor = _clamped(hit as Vector3)
if _ghost != null and is_instance_valid(_ghost):
_ghost.position = Vector3(_cursor.x, _cursor.y + 0.15, _cursor.z)
_ghost.visible = true
## Strahl → Punkt auf dem Terrain (Favorisiert echte Geländeberührung wie
## der Terrain-Pinsel; ohne Höhenfeld: Bodenebene).
func _ray_hit(viewport_pos: Vector2) -> Variant:
if _res >= 2 and _heights.size() >= _res * _res:
return TerrainField.raycast(_heights, _res, _sx, _sz,
camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos))
var terrain: Dictionary = main.doc.get("terrain", {})
if terrain.is_empty():
return null
var plane_y := float(terrain.get("min_height_m", 0.0))
var origin := camera.project_ray_origin(viewport_pos)
var dir := camera.project_ray_normal(viewport_pos)
if absf(dir.y) < 0.0001:
return null
var t := (plane_y - origin.y) / dir.y
if t <= 0.0:
return null
return origin + dir * t
func _clamped(p: Vector3) -> Vector3:
var half := _terrain_half()
p.x = clampf(p.x, -half.x, half.x)
p.z = clampf(p.z, -half.y, half.y)
p.y = SvmapDocument.terrain_height_at(main.doc, p.x, p.z)
return p
func _terrain_half() -> Vector2:
return Vector2(_sx, _sz) * 0.5
# ----------------------------------------------------- Setzen/Auswählen -----
func _on_left_press(viewport_pos: Vector2) -> void:
_arrow_grab = _pick_arrow(viewport_pos)
if not _arrow_grab.is_empty():
_drag_arrow(viewport_pos)
return
var picked := _pick_spawn(viewport_pos)
if picked.is_empty():
_place_zone(viewport_pos)
return
# Bestehende Zone anfassen → auswählen + Greifen vorbereiten.
selected_team = int(picked["team"])
selected_id = str(picked["id"])
view_builder.call("set_spawn_selected", selected_team, selected_id)
_dragging = true
_drag_moved = false
_press_pos = viewport_pos
_grab_offset = Vector3.ZERO
var hit: Variant = _ray_hit(viewport_pos)
var marker: Node3D = _marker_of(selected_team, selected_id)
if hit != null and marker != null and is_instance_valid(marker):
_grab_offset = marker.position - (_clamped(hit as Vector3))
_grab_offset.y = 0.0
refresh_selection_panel()
main.call("_set_status", "%s (%s) ausgewählt — ziehen zum Verschieben, Entf zum Löschen, der Radius-Regel rechts gilt jetzt für SIE." % [
selected_id, SpawnOps.team_label(selected_team)])
func _on_left_release() -> void:
if not _arrow_grab.is_empty():
_commit_arrow()
return
if not _dragging:
return
_dragging = false
if not (_drag_moved and selected_team >= 0 and selected_id != ""):
_drag_moved = false
return
var marker: Node3D = _marker_of(selected_team, selected_id)
if marker == null or not is_instance_valid(marker):
_drag_moved = false
return
var half := _terrain_half()
var pos: Vector3 = marker.position
pos.x = clampf(pos.x, -half.x, half.x)
pos.z = clampf(pos.z, -half.y, half.y)
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
SpawnOps.move_point(main.doc, selected_team, selected_id, pos.x, pos.z)
# Symmetrie (M4): Zwilling folgt der gespiegelten neuen Position.
var mirror_msg: String = spawn_mirror_move(main.doc, selected_team, selected_id, _sym_mode())
main.history.commit("%s verschoben" % selected_id,
before, SpawnOps.snapshot_blocks(main.history, main.doc))
main.call("_after_structure_change")
_point_selection_at_doc()
view_builder.call("set_spawn_selected", selected_team, selected_id)
main.call("_set_status", "%s verschoben — Strg+Z macht das rückgängig.%s" % [
selected_id, "" if mirror_msg.is_empty() else " " + mirror_msg])
_drag_moved = false
func _place_zone(viewport_pos: Vector2) -> void:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
main.call("_set_status", "Da war kein Boden unter dem Mauszeiger — klicke IN die Karte (die grüne Fläche).", true)
return
var half := _terrain_half()
var pos := _clamped(hit as Vector3)
if absf(pos.x) > half.x - zone_radius or absf(pos.z) > half.y - zone_radius:
pos.x = clampf(pos.x, -half.x + zone_radius, half.x - zone_radius)
pos.z = clampf(pos.z, -half.y + zone_radius, half.y - zone_radius)
main.call("_set_status", "Der Kreis würde über den Rand hinausschauen — ich habe ihn ganz nach innen geschoben.")
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
var point := SpawnOps.add_zone(main.doc, team, pos.x, pos.z)
var pid := str(point["id"])
SpawnOps.set_radius(main.doc, pid, zone_radius)
# Symmetrie (M4): Zwilling im Gegner-Team — innerhalb desselben Undo-Schritts.
var mirror_res: Dictionary = spawn_mirror_add(main.doc, team, pid, _sym_mode())
main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(team)],
before, SpawnOps.snapshot_blocks(main.history, main.doc))
main.call("_after_structure_change")
# Frische Zone auswählen und anfassen — Radius-Regel gilt ab jetzt für SIE.
selected_team = team
selected_id = pid
view_builder.call("set_spawn_selected", selected_team, selected_id)
refresh_selection_panel()
var mirror_text := str(mirror_res["status"])
main.call("_set_status", "Spawn-Zone %s (%s) gesetzt.%s Radius: Regler rechts · verschieben: klicken+ziehen · Entf löscht · Strg+Z macht alles rückgängig." % [
pid, SpawnOps.team_label(team), "" if mirror_text.is_empty() else " " + mirror_text])
## Kursor-Strahl → nächste Pfeilspitze in Greifweite oder {}.
func _pick_arrow(viewport_pos: Vector2) -> Dictionary:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
return {}
var p: Vector3 = hit as Vector3
var best := {}
var best_d := ARROW_PICK_M
for h in view_builder.yaw_handles():
var tip: Vector3 = h["tip"]
var d := Vector2(p.x - tip.x, p.z - tip.z).length()
if d <= best_d:
best = h
best_d = d
return best
func _drag_arrow(viewport_pos: Vector2) -> void:
var hit: Variant = _ray_hit(viewport_pos)
var node: Node3D = _arrow_grab.get("node")
if hit == null or node == null or not is_instance_valid(node):
return
var c := node.global_position
var yaw := SpawnOps.yaw_towards(c, Vector2((hit as Vector3).x, (hit as Vector3).z))
_arrow_yaw = fposmod(snappedf(yaw, ARROW_SNAP_DEG), 360.0)
var arrow: MeshInstance3D = view_builder.yaw_arrow_of(node)
if arrow != null:
arrow.rotation.y = deg_to_rad(_arrow_yaw)
main.call("_set_status", "Blickrichtung von %s: %d°" % [str(_arrow_grab["id"]), int(_arrow_yaw)])
func _commit_arrow() -> void:
var grab := _arrow_grab
_arrow_grab = {}
if str(grab["kind"]) == "zone":
apply_zone_yaw(str(grab["id"]), _arrow_yaw)
return
selected_team = int(grab["team"])
selected_id = str(grab["id"])
set_selected_yaw(_arrow_yaw)
## Blickrichtung der ausgewählten Zone setzen (yaw = null: Standard). EIN Undo-Eintrag.
func set_selected_yaw(yaw: Variant) -> void:
if selected_team < 0 or selected_id == "":
return
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
if not SpawnOps.set_point_yaw(main.doc, selected_team, selected_id, yaw):
return
spawn_mirror_yaw(main.doc, selected_id, yaw, _sym_mode())
main.history.commit("Blickrichtung von %s" % selected_id,
before, SpawnOps.snapshot_blocks(main.history, main.doc))
main.call("_after_structure_change")
view_builder.call("set_spawn_selected", selected_team, selected_id)
refresh_selection_panel()
main.call("_set_status", "Blickrichtung von %s: %s — Strg+Z macht das rückgängig." % [
selected_id, "Standard (zur Kartenmitte)" if yaw == null else "%d°" % int(yaw)])
## Dasselbe für eine Conquest-Zone (der Griff im Bild, das Panel geht über modus_panel).
func apply_zone_yaw(zid: String, yaw: Variant) -> void:
var zones: Variant = main.doc.get("conquest_zones", [])
if not (zones is Array):
return
var before := {"conquest_zones": (zones as Array).duplicate(true)}
for z in zones:
if z is Dictionary and str(z.get("id", "")) == zid:
SpawnOps.write_yaw(z, yaw)
main.history.commit("Blickrichtung von %s" % zid, before,
{"conquest_zones": (zones as Array).duplicate(true)})
main.call("_after_structure_change")
main.call("_set_status", "Blickrichtung von %s: %d° — Strg+Z macht das rückgängig." % [zid, int(yaw)])
## Kursor-Strahl → getroffene Zone ({team, id}) oder {}.
func _pick_spawn(viewport_pos: Vector2) -> Dictionary:
var hit: Variant = _ray_hit(viewport_pos)
if hit == null:
return {}
var p: Vector3 = hit as Vector3
var best := {}
var best_d := INF
for marker in view_builder.spawn_markers:
if marker == null or not is_instance_valid(marker):
continue
var mpos: Vector3 = marker.global_position
var d := Vector2(p.x - mpos.x, p.z - mpos.z).length()
var pick_r := float(marker.get_meta("spawn_pick_radius", 4.0))
if d <= pick_r and d < best_d:
best = {"team": int(marker.get_meta("spawn_team", -1)), "id": str(marker.get_meta("spawn_id", ""))}
best_d = d
return best
func _marker_of(team_i: int, pid: String) -> Node3D:
for marker in view_builder.spawn_markers:
if marker == null or not is_instance_valid(marker):
continue
if int(marker.get_meta("spawn_team", -1)) == team_i and str(marker.get_meta("spawn_id", "")) == pid:
return marker
return null
func _point_selection_at_doc() -> void:
# Nach rebuild: Auswahl-Referenzen im DOC halten (selbes Team/ID-Prinzip).
for p in SpawnOps.points(main.doc, selected_team):
if p is Dictionary and str(p.get("id", "")) == selected_id:
return
selected_team = -1
selected_id = ""
func _clear_selection() -> void:
selected_team = -1
selected_id = ""
if _dragging:
_dragging = false
view_builder.call("set_spawn_selected", -1, "")
refresh_selection_panel()
func delete_selected() -> void:
if selected_team < 0 or selected_id == "":
main.call("_set_status", "Nichts ausgewählt — klicke zuerst eine Spawn-Zone an, die weg soll.", true)
return
var label := "%s (%s)" % [selected_id, SpawnOps.team_label(selected_team)]
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
if not SpawnOps.remove_point(main.doc, selected_team, selected_id):
_clear_selection()
return
# Symmetrie (M4): Zwilling mit entfernen (innerhalb desselben Undo-Schritts).
var mirror_msg: String = spawn_mirror_delete(main.doc, selected_id, _sym_mode())
main.history.commit("%s löschen" % label, before,
SpawnOps.snapshot_blocks(main.history, main.doc))
_clear_selection()
main.call("_after_structure_change")
main.call("_set_status", "Spawn-Zone %s gelöscht — Strg+Z holt sie zurück.%s %s braucht mindestens %d Zonen!" % [
label, "" if mirror_msg.is_empty() else " " + mirror_msg,
SpawnOps.team_label(team), SvmapIO.MIN_SPAWN_POINTS_PER_TEAM])
## Panel-Zeile rechts (Welche Zone ist an?) synchron halten — ohne Statuszeile anzufassen.
func refresh_selection_panel() -> void:
if main.has_method("_sync_spawn_selection"):
main.call("_sync_spawn_selection")
# ------------------------------------------------- Symmetrie (M4) Statics ---
## Karte t_ffa207b1: Gespiegelte Spawn-Zonen. ENTScheidung (Spec vc27,
## „Teams bleiben einander gegenüber fair"): der Zwilling landet im GEGNER-
## Team — Blau (West) ↔ Rot (Ost), denn genau auf den Gegenseiten liegen die
## Default-Streifen der beiden Teams. Jede gesetzte Zone gibt dem anderen Team
## automatisch die faire Gegenstelle. Alles pure Statics (kein
## UI, kein Node-Zugriff) → headless testbar; spawn_ops.gd bleibt unangetastet.
## Partner-ID einer Spawn-Zone ("" = keiner).
static func spawn_mirror_partner(doc: Dictionary, pid: String) -> String:
var m := _mirror_map_of(doc)
return str(m.get(pid, ""))
static func _mirror_map_of(doc: Dictionary) -> Dictionary:
var notes = doc.get("design_notes", {})
if not (notes is Dictionary):
return {}
var m = (notes as Dictionary).get(SPAWN_MIRROR_KEY, {})
return m if m is Dictionary else {}
static func _mirror_map_set(doc: Dictionary, pid_a: String, pid_b: String) -> void:
var notes = doc.get("design_notes", {})
if not (notes is Dictionary):
notes = {}
doc["design_notes"] = notes
var m = (notes as Dictionary).get(SPAWN_MIRROR_KEY, {})
if not (m is Dictionary):
m = {}
(notes as Dictionary)[SPAWN_MIRROR_KEY] = m
m[pid_a] = pid_b
m[pid_b] = pid_a
static func _mirror_map_clear(doc: Dictionary, pid_a: String, pid_b: String) -> void:
var m := _mirror_map_of(doc)
if m.is_empty():
return
m.erase(pid_a)
m.erase(pid_b)
## Gespiegelte Spawn-Zone zu `pid` anlegen (Symmetrie an). Rückgabe:
## {"ok": bool, "status": Klartext}. ok=false + V9-Text = Zwilling NICHT
## gespeichert (Grenzen scharf — Spec vc27). Auf der Symmetrie-Mitte liegende
## Zonen werden nicht gedoppelt (Spec).
static func spawn_mirror_add(doc: Dictionary, team: int, pid: String, mode: int) -> Dictionary:
if mode == SymmetryCore.Mode.OFF:
return {"ok": true, "status": ""}
var original := _spawn_point_of(doc, team, pid)
if original.is_empty():
return {"ok": false, "status": "Spawn-Zone %s nicht gefunden — Zwilling nicht gesetzt." % pid}
var pos := _spawn_pos_of(original)
var center := SymmetryCore.map_center(doc)
if SymmetryCore.maps_to_self(mode, pos, center):
return {"ok": true, "status": "Spawn-Zone %s liegt auf der Symmetrie-Mitte — nicht gedoppelt." % pid}
var mirror_pos := SymmetryCore.mirror_point(mode, pos, center)
var bounds: Dictionary = doc.get("bounds", {})
if not bounds.is_empty() and not SvmapIO._inside_bounds(
[mirror_pos.x, mirror_pos.y, mirror_pos.z], bounds):
return {"ok": false, "status": "❌ V9: Gespiegelter Spawn läge außerhalb der Match-Grenzen — nicht gesetzt."}
var twin := SpawnOps.add_zone(doc, 1 - team, mirror_pos.x, mirror_pos.z)
var twin_id := str(twin["id"])
SpawnOps.set_radius(doc, twin_id, SpawnOps.zone_radius(doc, pid))
_mirror_map_set(doc, pid, twin_id)
if SpawnOps.has_yaw(original):
SpawnOps.write_yaw(twin, SymmetryCore.mirror_facing(mode, float(original[SpawnOps.YAW_KEY])))
return {"ok": true, "status": "Gespiegelte Zone %s (%s) gesetzt." % [
twin_id, SpawnOps.team_label(1 - team)]}
## Verschiebung spiegeln: Zwilling bekommt die gespiegelte Position der NEUEN
## Position des Originals (y = Terrain-Höhe am gespiegelten Punkt, wie beim
## normalen Verschieben). Rückgabe: Klartext-Status ("" = nichts zu tun).
static func spawn_mirror_move(doc: Dictionary, team: int, pid: String, mode: int) -> String:
if mode == SymmetryCore.Mode.OFF:
return ""
var partner := spawn_mirror_partner(doc, pid)
if partner.is_empty():
return ""
var original := _spawn_point_of(doc, team, pid)
if original.is_empty():
return ""
var pos := _spawn_pos_of(original)
var center := SymmetryCore.map_center(doc)
if SymmetryCore.maps_to_self(mode, pos, center):
return ""
var mirror_pos := SymmetryCore.mirror_point(mode, pos, center)
var partner_team := _team_of_point(doc, partner)
if partner_team < 0:
return ""
if not SpawnOps.move_point(doc, partner_team, partner, mirror_pos.x, mirror_pos.z):
return ""
return "Gespiegelte Zone %s mitverschoben." % partner
## Blickrichtung spiegeln: Zwilling schaut gespiegelt (yaw = null: auch er auf Standard).
static func spawn_mirror_yaw(doc: Dictionary, pid: String, yaw: Variant, mode: int) -> void:
if mode == SymmetryCore.Mode.OFF:
return
var partner := spawn_mirror_partner(doc, pid)
var partner_team := _team_of_point(doc, partner) if not partner.is_empty() else -1
if partner_team >= 0:
SpawnOps.set_point_yaw(doc, partner_team, partner,
null if yaw == null else SymmetryCore.mirror_facing(mode, float(yaw)))
## Löschung spiegeln (entfernen → beide weg, inkl. Radius-Eintrag und
## Partner-Map). Rückgabe: Klartext-Status ("" = nur das Original war weg).
static func spawn_mirror_delete(doc: Dictionary, pid: String, mode: int) -> String:
if mode == SymmetryCore.Mode.OFF:
return ""
var partner := spawn_mirror_partner(doc, pid)
if partner.is_empty():
return ""
var partner_team := _team_of_point(doc, partner)
if partner_team < 0:
_mirror_map_clear(doc, pid, partner)
return ""
if SpawnOps.remove_point(doc, partner_team, partner):
_mirror_map_clear(doc, pid, partner)
return "Gespiegelte Zone %s (%s) mit entfernt." % [partner, SpawnOps.team_label(partner_team)]
return ""
static func _spawn_point_of(doc: Dictionary, team: int, pid: String) -> Dictionary:
for p in SpawnOps.points(doc, team):
if p is Dictionary and str(p.get("id", "")) == pid:
return p as Dictionary
return {}
static func _team_of_point(doc: Dictionary, pid: String) -> int:
for team in [0, 1]:
if not _spawn_point_of(doc, team, pid).is_empty():
return team
return -1
static func _spawn_pos_of(point: Dictionary) -> Vector3:
var pos: Array = point.get("pos", [0.0, 0.0, 0.0])
return Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
# ------------------------------------------------------------ Ghost-Optik ---
func _refresh_ghost_look() -> void:
if _ghost == null or not is_instance_valid(_ghost):
return
# Kreisfläche: flacher Cylinder (Zonen-Scheibe), teamfarbig halbtransparent.
for child in _ghost.get_children():
child.queue_free()
var disc := MeshInstance3D.new()
var cyl := CylinderMesh.new()
cyl.top_radius = zone_radius
cyl.bottom_radius = zone_radius
cyl.height = 0.25
disc.mesh = cyl
disc.position.y = 0.12
var col: Color = view_builder.TEAM_COLORS[team]
var mat := StandardMaterial3D.new()
mat.albedo_color = Color(col.r, col.g, col.b, 0.4)
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.emission_enabled = true
mat.emission = col * 0.35
disc.material_override = mat
_ghost.add_child(disc)
var ring := MeshInstance3D.new()
var torus := TorusMesh.new()
torus.inner_radius = zone_radius - 0.2
torus.outer_radius = zone_radius + 0.2
torus.rings = 48
torus.ring_segments = 6
ring.mesh = torus
ring.position.y = 0.05
ring.material_override = mat
_ghost.add_child(ring)
# ------------------------------------------------------- Demo/Screenshot ----
## Demo-Hook (Desktop-Smoke): Zone programmatisch setzen (inkl. History).
## Macht exakt, was ein Klick machen würde — ohne echte Maus-Ereignisse.
func demo_place(zone_team: int, x: float, z: float, radius: float) -> String:
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
var point := SpawnOps.add_zone(main.doc, zone_team, x, z)
var pid := str(point["id"])
if radius > 0.0:
SpawnOps.set_radius(main.doc, pid, radius)
# Symmetrie (M4): Zwilling im Gegner-Team — innerhalb desselben Undo-Schritts.
spawn_mirror_add(main.doc, zone_team, pid, _sym_mode())
main.history.commit("Spawn-Zone %s (%s) gesetzt" % [pid, SpawnOps.team_label(zone_team)],
before, SpawnOps.snapshot_blocks(main.history, main.doc))
return pid
## Demo-Hook: Zone programmatisch verschieben (inkl. History).
func demo_move(zone_team: int, pid: String, x: float, z: float) -> void:
var before: Dictionary = SpawnOps.snapshot_blocks(main.history, main.doc)
SpawnOps.move_point(main.doc, zone_team, pid, x, z)
# Symmetrie (M4): Zwilling folgt der gespiegelten neuen Position.
spawn_mirror_move(main.doc, zone_team, pid, _sym_mode())
main.history.commit("%s verschoben" % pid,
before, SpawnOps.snapshot_blocks(main.history, main.doc))
## Aktiver Symmetrie-Modus aus main (M1-Schalter). Defensive über main.get():
## Mock-Mains (Tests) haben sym_mode nicht — dann gilt OFF (altes Verhalten),
## statt mit SCRIPT ERROR abzustürzen (Lektion: SCRIPT ERROR in -s-Suiten = rot).
func _sym_mode() -> int:
var v: Variant = main.get("sym_mode") if main != null else null
return SymmetryCore.Mode.OFF if v == null else int(v)
## Demo-Hook: Ghost an Position parken (für Screenshots ohne Maus).
func demo_park_ghost(x: float, z: float, team_i: int, radius: float) -> void:
set_team(team_i)
if radius > 0.0:
set_radius_demo_only(radius)
_refresh_ghost_look()
_cursor = Vector3(x, SvmapDocument.terrain_height_at(main.doc, x, z), z)
_cursor_valid = true
_ghost.position = Vector3(_cursor.x, _cursor.y + 0.15, _cursor.z)
_ghost.visible = true
## Nur-Editor-Radius (kein Doc-Eintrag, kein Undo) für die Vorschau im Demo-Hook.
func set_radius_demo_only(m: float) -> void:
zone_radius = clampf(m, SpawnOps.MIN_ZONE_RADIUS, SpawnOps.MAX_ZONE_RADIUS)

1
editor/spawn_tool.gd.uid Normal file
View file

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

647
editor/symmetry_ops.gd Normal file
View file

@ -0,0 +1,647 @@
extends RefCounted
class_name SymmetryOps
## M5 Ein-Klick „Karte symmetrisch machen“ + Symmetrie-Check (Karte t_60014669,
## Spec /opt/data/scopedvr/KARTEN_MIRROR_vc27.md). Baut auf M1–M4 auf:
## Spiegel-Mathe und Partner-Links kommen aus SymmetryCore, das Anwenden auf
## Objekten aus ObjectOps, auf Bauteilen (normale Objekte) und auf Spawn-Zonen aus
## dem Spawn-Spiegel-Mechanismus (spawn_tool.gd — dort NUR BENUTZT, nie
## geändert). Diese Datei orchestriert den GANZEN Vorgang und den Check:
##
## analyze(doc, mode) → ehrliche Bestandsaufnahme (nur lesen): Paare, die
## schon passen, Paare, die widersprechen, Elemente
## ohne Zwilling, abweichende Boden-Zellen.
## apply(doc, mode, quelle_hoch) → gleicht die Karte an: alles aus der
## Quell-Hälfte wird ERGÄNZT (Objekte, Bau-Teile,
## Spawn-Punkte, Boden zellgenau); widersprüchliche
## Paare bekommen die Quelle zugesprochen (nichts wird
## gelöscht, die Zahl wurde vorher genannt).
## check(doc, mode) → Klartext-Bericht: „Alles symmetrisch ✓“ oder
## „N Stellen weichen ab: Objekt #12, Boden (31,17) …“
##
## Seiten-Logik ohne Mode-Wirrwarr: Jedes Element wird mit seinem Spiegelbild
## verglichen. Die „linke“/„vordere“ Seite eines Paares ist die, deren
## Spiegelbild weiter rechts/hinten liegt (lex-Antwort: erst x, dann z).
## Die Quell-Hälfte-Wahl (quelle_hoch=false = links/vorn, true = rechts/hinten)
## entscheidet NUR, welche Seite eines Paares bei Widerspruch gewinnt und in
## welche Richtung einzelstehende Elemente ergänzt werden.
##
## Ein-Klick-Anwendung (Panel) macht den GANZEN Vorgang zu EINEM Undo-Schritt:
## ActionHistory snapshotet das ganze Dokument — hier wird nur mutiert, das
## Committen bleibt beim Aufrufer (Panel/main, Muster wie beim Resize R3).
##
## Alles static + reine Funktionen (mutieren nur das übergebene doc) →
## headless testbar wie SymmetryCore/ObjectOps.
const SpawnToolScript := preload("res://core/editor/spawn_tool.gd")
## Wie nah ein Spiegelbild an einem Element liegen darf, damit sie als Paar
## gelten (Meter, XZ-Ebene). Handgebaute Karten sitzen nie auf den Millimeter —
## aber exakt gespiegelte Paare aus M2–M4 liegen auf 0.
const PAIR_TOLERANCE_M := 1.0
## Ab wann Höhen als verschieden gelten (Terrain-R16-Schritt ist viel kleiner).
const HEIGHT_EPS_M := 0.02
## Kleine Vergleichstoleranzen für Position/Drehung/Maßstab/Farbe.
const POS_EPS_M := 0.01
const ROT_EPS_DEG := 0.5
const VALUE_EPS := 0.01
## Wie viele Beispiele der Klartext-Bericht maximal einzeln nennt.
const MAX_EXAMPLES := 8
# ------------------------------------------------------------ Seiten-Logik --
## Klartext-Namen der beiden Hälften für den Modus ([niedrige Seite, hohe]).
static func side_labels(mode: int) -> Array:
if mode == SymmetryCore.Mode.AXIS_Z:
return ["vorn", "hinten"]
return ["links", "rechts"]
static func _center_of(doc: Dictionary) -> Vector2:
return SymmetryCore.map_center(doc)
static func _mirror(mode: int, doc: Dictionary, p: Vector3) -> Vector3:
return SymmetryCore.mirror_point(mode, p, _center_of(doc))
static func _pos_of(arr: Array) -> Vector3:
if (arr as Array).size() < 3:
return Vector3.ZERO
return Vector3(float(arr[0]), float(arr[1]), float(arr[2]))
## „self“ = liegt auf Mitte/Achse (wird nie gedoppelt, nie geprüft).
## „low“ = lex-erste Seite (links bzw. vorn), „high“ = die andere.
## Bei Punkt-Spiegelung entscheidet auf der Mittel-Linie z statt x.
static func _side_of(mode: int, p: Vector3, doc: Dictionary) -> String:
if SymmetryCore.maps_to_self(mode, p, _center_of(doc)):
return "self"
var m := _mirror(mode, doc, p)
if p.x < m.x - 0.0001:
return "low"
if p.x > m.x + 0.0001:
return "high"
return "low" if p.z < m.z - 0.0001 else "high"
## Templat-loses Sammeln: items = [{"idx": int, "pos": Vector3}]. Paarung
## IMMERS vom low-Spiegel aus (deterministisch, Richtung der Quell-Wahl
## ändert die Paare nicht). Rückgabe:
## pairs: [{"low": idx, "high": idx, "mirror": Vector3}]
## singles_low / singles_high: idx-Listen der Zwillinglosen
static func _pair_items(mode: int, doc: Dictionary, items: Array) -> Dictionary:
var out := {"pairs": [], "singles_low": [], "singles_high": []}
var low_pool: Array = []
var high_pool: Array = []
for it in items:
if not (it is Dictionary):
continue
var side: String = _side_of(mode, it["pos"], doc)
if side == "self":
continue
if side == "low":
low_pool.append(it)
else:
high_pool.append(it)
var used: Dictionary = {}
for d in low_pool:
var m := _mirror(mode, doc, d["pos"])
var best: int = -1
var best_d: float = PAIR_TOLERANCE_M
for k in high_pool.size():
if used.has(k):
continue
var t: Dictionary = high_pool[k]
var dxy := Vector2(m.x - t["pos"].x, m.z - t["pos"].z).length()
if dxy <= best_d:
best = k
best_d = dxy
if best >= 0:
used[best] = true
out["pairs"].append({
"low": int(d["idx"]),
"high": int(high_pool[best]["idx"]),
"mirror": m,
})
for k in high_pool.size():
if not used.has(k):
out["singles_high"].append(int(high_pool[k]["idx"]))
for d in low_pool:
var paired := false
for p in out["pairs"]:
if int(p["low"]) == int(d["idx"]):
paired = true
break
if not paired:
out["singles_low"].append(int(d["idx"]))
return out
# -------------------------------------------------------------- analyze() ---
## Ehrliche Bestandsaufnahme (liest NUR). Siehe Klassenkopf für die Struktur.
static func analyze(doc: Dictionary, mode: int) -> Dictionary:
var an := {
"mode": mode,
"objects": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"spawns": {"pairs": [], "singles_low": [], "singles_high": [], "diff": 0},
"terrain_cells": [],
"material_cells": [], # Material-Raster (fein, Karte 24.10.) — eigenes Gitter
"deviations": [],
"links": 0, # positionierende Paare ohne sym-Kopplung (werden verknüpft)
"conflicts": 0, # widersprüchliche Paare + Boden-Zellen
"adds": 0, # Zwillinge, die ergänzt werden
"_dev_total": 0,
"total_changes": 0,
"total_deviations": 0,
}
if mode == SymmetryCore.Mode.OFF:
return an
_analyze_objects(doc, mode, an)
_analyze_spawns(doc, mode, an)
_analyze_terrain(doc, mode, an)
an["conflicts"] = int(an["objects"]["diff"]) \
+ int(an["spawns"]["diff"]) + (an["terrain_cells"] as Array).size() \
+ (an["material_cells"] as Array).size()
an["adds"] = (an["objects"]["singles_low"] as Array).size() \
+ (an["objects"]["singles_high"] as Array).size() \
+ (an["spawns"]["singles_low"] as Array).size() \
+ (an["spawns"]["singles_high"] as Array).size()
an["total_deviations"] = int(an["_dev_total"])
an["total_changes"] = int(an["conflicts"]) + int(an["adds"]) + int(an["links"])
return an
## Meldet eine Abweichung: Zähler ZÄHLT IMMER, die Beispielliste ist gekappt.
static func _push_dev(an: Dictionary, label: String) -> void:
an["_dev_total"] = int(an["_dev_total"]) + 1
var devs: Array = an["deviations"]
if devs.size() < MAX_EXAMPLES:
devs.append(label)
static func _valid_link(list: Array, idx: int) -> int:
## sym-Partner-Index NUR wenn der Partner wirklich existiert — sonst gilt
## das Element als verkoppelt-lost und läuft über die Paarung mit.
var item: Dictionary = list[idx]
var key := SymmetryCore.sym_of(item)
if key.is_empty():
return -1
return SymmetryCore.find_partner_index(list, key, idx)
## Frische gemeinsame sym-Kennung für ein Paar. Hingen die Elemente vorher an
## ANDEREN Partnern, verlieren diese ihre Kennung mit — sonst bliebe ein
## V16-Waise übrig (eine Kennung = GENAU 2 Elemente). Merkwurzeln einer
## beschädigten Karte können so pro Lauf kleiner werden; ein zweiter Klick
## erledigt den Rest.
static func _fresh_pair_link(doc: Dictionary, list: Array, donor_i: int,
twin_i: int) -> void:
for idx in [donor_i, twin_i]:
var key := SymmetryCore.sym_of(list[idx])
if key.is_empty():
continue
var old_partner := SymmetryCore.find_partner_index(list, key, idx)
if old_partner >= 0 and old_partner != donor_i and old_partner != twin_i:
SymmetryCore.clear_sym(list[old_partner])
SymmetryCore.clear_sym(list[idx])
var nk := SymmetryCore.next_sym_key(doc)
SymmetryCore.set_sym(list[donor_i], nk)
SymmetryCore.set_sym(list[twin_i], nk)
## Spawn-Gegenstück: Kopplung design_notes.spawn_mirror sauber neu setzen
## (beide Richtungen überschreiben, alte Partner beidseitig auskoppeln).
static func _fresh_spawn_link(doc: Dictionary, donor_id: String,
twin_id: String) -> void:
for pid in [donor_id, twin_id]:
var old := _spawn_linked(doc, pid)
if not old.is_empty() and old != donor_id and old != twin_id:
SpawnToolScript._mirror_map_clear(doc, pid, old)
SpawnToolScript._mirror_map_set(doc, donor_id, twin_id)
static func _obj_pair_differs(src: Dictionary, twin: Dictionary, mp: Vector3,
mode: int) -> bool:
var tpos := _pos_of(twin.get("pos", []))
if Vector2(mp.x - tpos.x, mp.z - tpos.z).length() > POS_EPS_M:
return true
if absf(tpos.y - mp.y) > POS_EPS_M:
return true
# 3 Achsen (Karte t_e166e656): der Vergleich läuft über die gespiegelte
# Euler-Orientierung (für reines Y-Drehen identisch zur alten rot_y-Regel).
var gespiegelt := SymmetryCore.mirror_rot_euler(mode, SvmapIO.objekt_rotation(src))
var twin_euler := SvmapIO.objekt_rotation(twin)
var trot := fposmod(twin_euler.y - gespiegelt.y, 360.0)
var rotd := minf(trot, 360.0 - trot)
if rotd > ROT_EPS_DEG:
return true
var kipp := fposmod(twin_euler.x - gespiegelt.x + 180.0, 360.0) - 180.0
if absf(kipp) > ROT_EPS_DEG:
return true
kipp = fposmod(twin_euler.z - gespiegelt.z + 180.0, 360.0) - 180.0
if absf(kipp) > ROT_EPS_DEG:
return true
if absf(float(twin.get("scale", 1.0)) - float(src.get("scale", 1.0))) > VALUE_EPS:
return true
if str(twin.get("id", "")) != str(src.get("id", "")):
return true
var s_tint: Variant = src.get("tint", null)
var t_tint: Variant = twin.get("tint", null)
if (s_tint is Array) != (t_tint is Array):
return true
if s_tint is Array and t_tint is Array:
var sa: Array = s_tint
var ta: Array = t_tint
if (sa as Array).size() != (ta as Array).size():
return true
for k in sa.size():
if absf(float(sa[k]) - float(ta[k])) > VALUE_EPS:
return true
return false
static func _analyze_objects(doc: Dictionary, mode: int, an: Dictionary) -> void:
var objects: Array = doc.get("objects", [])
var items: Array = []
for i in objects.size():
var obj = objects[i]
if not (obj is Dictionary):
continue
var pos: Array = obj.get("pos", [])
if (pos as Array).size() < 3:
continue
items.append({"idx": i, "pos": _pos_of(pos)})
var paired := _pair_items(mode, doc, items)
var pairs: Array = paired["pairs"]
var diff_n := 0
for p in pairs:
var low_i: int = p["low"]
var high_i: int = p["high"]
var low_ok: bool = _valid_link(objects, low_i) == high_i
var high_ok: bool = _valid_link(objects, high_i) == low_i
if not (low_ok and high_ok):
an["links"] = int(an["links"]) + 1
var donor: Dictionary = objects[low_i]
var twin: Dictionary = objects[high_i]
var differs := _obj_pair_differs(donor, twin, p["mirror"], mode)
if differs:
diff_n += 1
_push_dev(an, "Objekt #%d" % low_i)
an["objects"]["pairs"] = pairs
an["objects"]["singles_low"] = paired["singles_low"]
an["objects"]["singles_high"] = paired["singles_high"]
an["objects"]["diff"] = diff_n
for idx in (paired["singles_low"] as Array) + (paired["singles_high"] as Array):
_push_dev(an, "Objekt #%d hat keinen Zwilling" % int(idx))
# ------------------------------------------------------------------ Spawns --
## Nur Teams 0/1 (TDM-Konvention wie M4): der Spiegel-Zwilling eines Spawn-
## Punkts liegt im GEGNER-Team. Kopplung läuft über design_notes.spawn_mirror.
static func _spawn_items(doc: Dictionary) -> Array:
var items: Array = []
for team in [0, 1]:
for p in SpawnOps.points(doc, team):
if not (p is Dictionary):
continue
var pos: Array = p.get("pos", [])
if (pos as Array).size() < 3:
continue
items.append({
"team": team,
"id": str(p.get("id", "")),
"pos": _pos_of(pos),
})
return items
static func _spawn_linked(doc: Dictionary, pid: String) -> String:
return SpawnToolScript.spawn_mirror_partner(doc, pid)
static func _analyze_spawns(doc: Dictionary, mode: int, an: Dictionary) -> void:
var items := _spawn_items(doc)
var paired := _pair_items_spawn(mode, doc, items)
var pairs: Array = paired["pairs"]
var diff_n := 0
for p in pairs:
var low_ok: bool = _spawn_linked(doc, str(p["low_id"])) == str(p["high_id"])
var high_ok: bool = _spawn_linked(doc, str(p["high_id"])) == str(p["low_id"])
if not (low_ok and high_ok):
an["links"] = int(an["links"]) + 1
var donor: Dictionary = p["low_point"]
var mp := SymmetryCore.mirror_point(mode, donor["pos"], _center_of(doc))
var twin: Dictionary = p["high_point"]
if Vector2(mp.x - twin["pos"].x, mp.z - twin["pos"].z).length() > POS_EPS_M:
diff_n += 1
_push_dev(an, "Spawn-Punkt %s" % str(p["low_id"]))
an["spawns"]["pairs"] = pairs
an["spawns"]["singles_low"] = paired["singles_low"]
an["spawns"]["singles_high"] = paired["singles_high"]
an["spawns"]["diff"] = diff_n
for s in (paired["singles_low"] as Array) + (paired["singles_high"] as Array):
_push_dev(an, "Spawn-Punkt %s hat keinen Zwilling" % str(s["id"]))
## Spawn-Variante der Paarung: items tragen team/id; Singles bleiben Objekte
## ({team, id, pos}) statt idx (Spawn-Punkte hängen an Teams, nicht am Array).
static func _pair_items_spawn(mode: int, doc: Dictionary, items: Array) -> Dictionary:
var out := {"pairs": [], "singles_low": [], "singles_high": []}
var low_pool: Array = []
var high_pool: Array = []
for it in items:
var side: String = _side_of(mode, it["pos"], doc)
if side == "self":
continue
if side == "low":
low_pool.append(it)
else:
high_pool.append(it)
var used: Dictionary = {}
for d in low_pool:
var m := _mirror(mode, doc, d["pos"])
var best: int = -1
var best_d: float = PAIR_TOLERANCE_M
for k in high_pool.size():
if used.has(k):
continue
var t: Dictionary = high_pool[k]
var dxy := Vector2(m.x - t["pos"].x, m.z - t["pos"].z).length()
if dxy <= best_d:
best = k
best_d = dxy
if best >= 0:
used[best] = true
out["pairs"].append({
"low_team": int(d["team"]), "low_id": str(d["id"]),
"high_team": int(high_pool[best]["team"]),
"high_id": str(high_pool[best]["id"]),
"low_point": d, "high_point": high_pool[best],
})
for k in high_pool.size():
if not used.has(k):
out["singles_high"].append(high_pool[k])
for d in low_pool:
var paired := false
for p in out["pairs"]:
if str(p["low_id"]) == str(d["id"]):
paired = true
break
if not paired:
out["singles_low"].append(d)
return out
# ---------------------------------------------------------------- Terrain ---
## Abweichende Zell-PAARE (je einmal, kanonisch = lex-erste Zelle des Paares).
static func _analyze_terrain(doc: Dictionary, mode: int, an: Dictionary) -> void:
var terrain: Dictionary = doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
if res < 2:
return
var heights := SvmapIO.decode_heights(terrain)
if heights.size() < res * res:
return
var cells: Array = an["terrain_cells"]
var eps := HEIGHT_EPS_M # absolut in Metern
for j in res:
for i in res:
var mc := SymmetryCore.mirror_cell(mode, i, j, res)
if mc.x == i and mc.y == j:
continue
# Nur das lex-erste Paarmitglied behandeln (keine Doppel-Meldung).
var is_low := i < mc.x or (i == mc.x and j < mc.y)
if not is_low:
continue
var ia := j * res + i
var ib := mc.y * res + mc.x
var dh := absf(heights[ia] - heights[ib])
if dh > eps:
cells.append(Vector2i(i, j))
if cells.size() <= MAX_EXAMPLES:
_push_dev(an, "Boden (%d,%d)" % [i, j])
# Material auf seinem eigenen Raster (Byte exakt vergleichen).
var mres := SvmapIO.material_resolution(terrain)
var mats := SvmapIO.decode_materials(terrain)
if mres < 2 or mats.size() < mres * mres:
return
var mix := SvmapIO.decode_mix(terrain) # weiche Ränder: b + Anteil mitvergleichen
var mb: PackedByteArray = mix[0]
var mw: PackedByteArray = mix[1]
var mcells: Array = an["material_cells"]
for j in mres:
for i in mres:
var mc := SymmetryCore.mirror_cell(mode, i, j, mres)
if not (i < mc.x or (i == mc.x and j < mc.y)):
continue
var ia := j * mres + i
var ib := mc.y * mres + mc.x
if mats[ia] != mats[ib] or mb[ia] != mb[ib] or mw[ia] != mw[ib]:
mcells.append(Vector2i(i, j))
if mcells.size() <= MAX_EXAMPLES:
_push_dev(an, "Boden-Material (%d,%d)" % [i, j])
# ----------------------------------------------------------------- check() --
## Klartext-Bericht (nur lesen). report: {"ok": bool, "text": String,
## "deviations": Array}. „Alles symmetrisch ✓“ / „N Stellen weichen ab: …“.
static func check(doc: Dictionary, mode: int) -> Dictionary:
if mode == SymmetryCore.Mode.OFF:
return {"ok": false,
"text": "Der Symmetrie-Modus ist Aus — erst einen Modus wählen (Punkt / Achse), dann prüfen.",
"deviations": [], "analyze": analyze(doc, mode)}
var an := analyze(doc, mode)
var devs: Array = an["deviations"]
var total := int(an["total_deviations"])
var text := ""
if total == 0:
text = "Alles symmetrisch ✓"
else:
var shown: Array = []
for d in devs:
shown.append(str(d))
var rest := total - shown.size()
var listing := ", ".join(PackedStringArray(shown))
if rest > 0:
listing += " … und %d weitere" % rest
text = "%d Stellen weichen ab: %s" % [total, listing]
return {"ok": total == 0, "text": text, "deviations": devs,
"analyze": an}
# ----------------------------------------------------------------- apply() --
## Karte angleichen. quelle_hoch=false → links/vorn ist die Quelle (Gewinner
## bei Widersprüchen), true → rechts/hinten. Mutiert doc IN PLACE (Pattern
## wie die übrigen Tools: der Aufrufer snapshotet drum herum für EIN Undo).
## Rückgabe: {"ok": bool, "report": Klartext (mehrzeilig), "counts": {...}}.
static func apply(doc: Dictionary, mode: int, quelle_hoch: bool) -> Dictionary:
if mode == SymmetryCore.Mode.OFF:
return {"ok": false, "report": "Der Symmetrie-Modus ist Aus — ohne Modus weiß ich nicht, wie gespiegelt werden soll.",
"counts": {}}
var an := analyze(doc, mode)
var c := {
"objects_added": 0, "objects_fixed": 0, "objects_linked": 0,
"spawns_added": 0, "spawns_fixed": 0, "spawns_linked": 0,
"terrain_cells": 0, "skipped": 0,
}
_apply_objects(doc, mode, an, quelle_hoch, c)
_apply_terrain(doc, mode, an, quelle_hoch, c)
# Spawns ZULETZT: die Zwillinge sitzen auf der Terrain-Höhe — die stimmt
# erst nach dem Boden-Ausgleich (M2/M4-Konvention: y = Terrainhöhe).
_apply_spawns(doc, mode, an, quelle_hoch, c)
return {"ok": true, "counts": c, "report": _apply_report(c, quelle_hoch, mode)}
static func _apply_report(c: Dictionary, quelle_hoch: bool, mode: int) -> String:
var erg: Array = []
for entry in [["objects_added", "Objekte ergänzt"],
["spawns_added", "Spawn-Punkte ergänzt"], ["terrain_cells", "Bodenstellen angeglichen"],
["objects_fixed", "Objekt-Widersprüche"],
["spawns_fixed", "Spawn-Widersprüche"], ["objects_linked", "Objekt-Paare verknüpft"],
["spawns_linked", "Spawn-Paare verknüpft"]]:
var n := int(c[entry[0]])
if n > 0:
erg.append("%d %s" % [n, entry[1]])
if erg.is_empty():
return "Nichts zu tun — die Karte ist schon symmetrisch ✓"
var sides := side_labels(mode)
var conflict_sum := int(c["objects_fixed"]) + int(c["spawns_fixed"]) + int(c["terrain_cells"])
var text := "Fertig ✓ — " + ", ".join(PackedStringArray(erg))
if conflict_sum > 0:
text += ". Bei %d Widersprüchen hat %s gewonnen" % [conflict_sum, str(sides[1] if quelle_hoch else sides[0])]
return text + ". Ein Strg+Z macht alles rückgängig."
static func _write_object_from(twin: Dictionary, donor: Dictionary, mp: Vector3,
mode: int) -> void:
twin["pos"] = [mp.x, mp.y, mp.z]
# 3 Achsen (Karte t_e166e656): Kippen wird mitgespiegelt; für reines
# Y-Drehen exakt die alte mirror_rot_y-Regel (gleiche Zahlenwerte).
SvmapIO.rot_setzen(twin, SymmetryCore.mirror_rot_euler(
mode, SvmapIO.objekt_rotation(donor)))
twin["scale"] = donor.get("scale", 1.0)
if donor.has("tint"):
twin["tint"] = (donor["tint"] as Array).duplicate()
else:
twin.erase("tint")
twin["id"] = donor.get("id", "")
static func _apply_objects(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
var objects: Array = doc.get("objects", [])
if (objects as Array).is_empty():
return
# a) Paare: verknüpfen + Widersprüche zugunsten der Quelle entscheiden.
for p in (an["objects"]["pairs"] as Array):
var low_i: int = p["low"]
var high_i: int = p["high"]
var donor_i: int = high_i if quelle_hoch else low_i
var twin_i: int = low_i if quelle_hoch else high_i
var donor: Dictionary = objects[donor_i]
var twin: Dictionary = objects[twin_i]
var key_d := SymmetryCore.sym_of(donor)
var key_t := SymmetryCore.sym_of(twin)
if key_d != key_t or key_d.is_empty():
# Ohne/widerstreitende Kopplung: frische gemeinsame Kennung.
_fresh_pair_link(doc, objects, donor_i, twin_i)
c["objects_linked"] = int(c["objects_linked"]) + 1
var mp := SymmetryCore.mirror_point(mode, _pos_of(donor.get("pos", [])), _center_of(doc))
if _obj_pair_differs(donor, twin, mp, mode):
_write_object_from(twin, donor, mp, mode)
c["objects_fixed"] = int(c["objects_fixed"]) + 1
# b) Zwillinglose ergänzen (aus welcher Seite auch immer — nichts gelöscht).
for idx in (an["objects"]["singles_low"] as Array) + (an["objects"]["singles_high"] as Array):
var res := ObjectOps.symmetry_place_twin(doc, int(idx), mode)
if int(res["twin"]) >= 0:
c["objects_added"] = int(c["objects_added"]) + 1
else:
c["skipped"] = int(c["skipped"]) + 1
static func _apply_terrain(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
_apply_materials(doc, mode, an, quelle_hoch, c)
var cells: Array = an["terrain_cells"]
if (cells as Array).is_empty():
return
var terrain: Dictionary = doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
if res < 2:
return
var heights := SvmapIO.decode_heights(terrain)
var hmin := float(terrain.get("min_height_m", 0.0))
var hmax := float(terrain.get("max_height_m", 1.0))
for cell in cells:
var cc: Vector2i = cell
var mc := SymmetryCore.mirror_cell(mode, cc.x, cc.y, res)
var src := cc if not quelle_hoch else mc
var dst := mc if not quelle_hoch else cc
var si: int = src.y * res + src.x
var di: int = dst.y * res + dst.x
heights[di] = heights[si]
c["terrain_cells"] = int(c["terrain_cells"]) + 1
terrain["heightmap_r16_b64"] = TerrainField.encode(heights, hmin, hmax)
## Material-Schicht angleichen (eigenes feines Raster, Karte 24.10.).
static func _apply_materials(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
var cells: Array = an.get("material_cells", [])
if cells.is_empty():
return
var terrain: Dictionary = doc.get("terrain", {})
var mres := SvmapIO.material_resolution(terrain)
var mats := SvmapIO.decode_materials(terrain)
if mres < 2 or mats.size() < mres * mres:
return
var mix := SvmapIO.decode_mix(terrain)
var mb: PackedByteArray = mix[0]
var mw: PackedByteArray = mix[1]
for cell in cells:
var cc: Vector2i = cell
var mc := SymmetryCore.mirror_cell(mode, cc.x, cc.y, mres)
var src := cc if not quelle_hoch else mc
var dst := mc if not quelle_hoch else cc
var di := dst.y * mres + dst.x
var si := src.y * mres + src.x
mats[di] = mats[si]
mb[di] = mb[si]
mw[di] = mw[si]
c["terrain_cells"] = int(c["terrain_cells"]) + 1
terrain["materials_r8_b64"] = SvmapIO.encode_materials(mats)
SvmapIO.encode_mix(terrain, mb, mw)
static func _apply_spawns(doc: Dictionary, mode: int, an: Dictionary,
quelle_hoch: bool, c: Dictionary) -> void:
# a) Paare: verknüpfen (design_notes.spawn_mirror) + Quelle entscheidet.
for p in (an["spawns"]["pairs"] as Array):
var donor_team: int = int(p["high_team"]) if quelle_hoch else int(p["low_team"])
var donor_id: String = str(p["high_id"]) if quelle_hoch else str(p["low_id"])
var linked_d := _spawn_linked(doc, donor_id)
var twin_id: String = str(p["low_id"]) if quelle_hoch else str(p["high_id"])
if linked_d != twin_id:
# Beide Richtungen sauber neu setzen, alte Partner auskoppeln.
_fresh_spawn_link(doc, donor_id, twin_id)
c["spawns_linked"] = int(c["spawns_linked"]) + 1
var donor_point: Dictionary = p["low_point"] if not quelle_hoch else p["high_point"]
var mp := SymmetryCore.mirror_point(mode, donor_point["pos"], _center_of(doc))
var twin_point: Dictionary = p["high_point"] if not quelle_hoch else p["low_point"]
if Vector2(mp.x - twin_point["pos"].x, mp.z - twin_point["pos"].z).length() > POS_EPS_M:
var msg := SpawnToolScript.spawn_mirror_move(doc, donor_team, donor_id, mode)
if not msg.is_empty():
c["spawns_fixed"] = int(c["spawns_fixed"]) + 1
# b) Zwillinglose ergänzen (Team wechselt beim Spiegeln — M4-Regel).
for s in (an["spawns"]["singles_low"] as Array) + (an["spawns"]["singles_high"] as Array):
var res := SpawnToolScript.spawn_mirror_add(doc, int(s["team"]), str(s["id"]), mode)
if bool(res["ok"]) and str(res["status"]).contains("gesetzt"):
c["spawns_added"] = int(c["spawns_added"]) + 1
else:
c["skipped"] = int(c["skipped"]) + 1

View file

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

765
editor/terrain_tool.gd Normal file
View file

@ -0,0 +1,765 @@
extends Node3D
## Terrain-Pinsel (M2, Karte t_9aba04f1): Höhenfeld heben/senken/glätten im
## 3D-Viewport — im Terrain-Modus (Chef-Grundsatz Sly: maximal easy + Spaß).
##
## Bedienung:
## Linksklick-Ziehen = gewählter Pinsel-Modus (Heben / Senken / Glätten)
## Strg+Linksklick = das Gegenteil (bei Heben senkt er, bei Senken hebt
## er — Invert-Konvention wie Blender/Photoshop; im
## Malen-Modus malt Strg+Klick Gras zurück). Karte
## t_2470de89: die RECHTE Maustaste gehört IMMER dem
## Freelook-Flug — sie startet hier keinen Strich mehr.
## Der ganze Strich = EIN Undo-Eintrag (Strg+Z stellt die Form VOR dem
## Strich wieder her, nicht nur eine Zwischenstufe).
##
## Feedback: Pinsel-Ring folgt dem Cursor AUF dem Gelände (ray-march auf das
## Höhenfeld), das Mesh folgt dem Strich LIVE (Vertices pro Frame neu).
##
## Der Doc wird nur ZUM Strich-Ende verändert (encode → heightmap_r16_b64)
## — während des Strichs lebt ein dekodiertes PackedFloat32Array, das Mesh
## liest daraus. Undo/Redo läuft über die gemeinsame ActionHistory (terrain-Block).
const SMOOTH_RATE_DIVISOR := 20.0 # Stärke-Slider (m/s) → Glätt-Tempo (Anteil/s)
const MODE_LABELS := ["Heben", "Senken", "Glätten", "Malen", "Einebnen", "Rampe"]
const FLATTEN_RATE_DIVISOR := 8.0 # Stärke-Slider → Einebnen-Tempo (Anteil/s)
var main # Hauptfenster (script-erweitert, Variant)
var camera: Camera3D
var view_builder # TerrainMesh-Ansicht (script-erweitert, Variant)
var interaction # Objekt-Interaktion (für Modus-Wechsel)
var active := false
var brush_mode: int = TerrainField.Mode.RAISE
var brush_radius := 12.0 # Meter
## Kante beim Malen: 0 = hart, 1 = ganz weich (Karte 25.10.).
var brush_weichheit := 0.4
## Pinsel-Falloff (Karte 25.10.): 0 = hart (linear), 1 = weich (quadratisch).
var brush_falloff := 0.5
## Druck 0.05–1 (Pen-Druck / Controller-Trigger / Maus = 1.0), skaliert die Stärke.
var pressure := 1.0
var brush_strength := 8.0 # Meter pro Sekunde (Heben/Senken)
var paint_material: int = 0 # Material-ID im Malen-Modus (0=Gras, 1=Sand, 2=Fels)
# Stroke-Zustand
var _stroking := false
var _stroke_mode: int = TerrainField.Mode.RAISE
var _stroke_material: int = 0
var _flatten_target := 0.0 # Einebnen: Höhe unter dem Cursor beim Strichstart
var _ramp_a := Vector2.ZERO # Rampe: Startpunkt (Welt-X/Z) + Höhe
var _ramp_ha := 0.0
var _ramp_b := Vector2.ZERO # Rampe: aktueller Endpunkt
var _heights := PackedFloat32Array()
var _mats := PackedByteArray() # Material-Schicht (res²) — seit _refresh_
# _from_doc immer dekodiert (t_55ed041a),
# Mal-Strich schreibt live hinein.
var _res := 0
var _mres := 0 # Raster der Material-Schicht (feines Raster, Karte 24.10.; alt = _res)
var _mix_b := PackedByteArray() # zweites Material je Punkt (weiche Ränder, Karte 25.10.)
var _mix_w := PackedByteArray() # Anteil des zweiten Materials 0–255
var _sx := 0.0
var _sz := 0.0
var _hmin := 0.0
var _hmax := 0.0
var _cursor_valid := false
var _cursor := Vector3.ZERO
# Pinsel-Ring
var _ring: MeshInstance3D
var _ramp_line: MeshInstance3D # Rampen-Vorschau: Linie Start → Cursor
var _ring_radius := -1.0
var _ring_mode := -1
func setup(p_main, p_camera: Camera3D, p_builder, p_interaction) -> void:
main = p_main
camera = p_camera
view_builder = p_builder
interaction = p_interaction
_ensure_ring()
func _ensure_ring() -> void:
if _ring != null and is_instance_valid(_ring):
return
_ring = MeshInstance3D.new()
_ring.visible = false
add_child(_ring)
_ramp_line = MeshInstance3D.new()
_ramp_line.visible = false
_ramp_line.mesh = ImmediateMesh.new()
var lm := StandardMaterial3D.new()
lm.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
lm.albedo_color = Color(1.0, 0.85, 0.3)
lm.no_depth_test = true
_ramp_line.material_override = lm
add_child(_ramp_line)
# ------------------------------------------------------------ Aktivierung ---
## Terrain-Modus einschalten. false = abgelehnt (Auflösungs-Limit) — main
## bleibt dann im Objekt-Modus und erklärt warum.
func activate() -> bool:
var terrain: Dictionary = main.doc.get("terrain", {})
var res := int(terrain.get("resolution", 0))
if res > TerrainField.EDITOR_MAX_RESOLUTION:
main.call("_set_status",
"Diese Map hat eine Terrain-Auflösung von %d — das Editor-MVP formt Terrain nur bis %d (Performance). Neu angelegte Maps erfüllen das automatisch." % [res, TerrainField.EDITOR_MAX_RESOLUTION],
true)
return false
if not _refresh_from_doc():
return false
active = true
_stroking = false
_ring.visible = false
return true
func deactivate() -> void:
active = false
if _stroking:
_end_stroke()
if _ring != null and is_instance_valid(_ring):
_ring.visible = false
if _ramp_line != null and is_instance_valid(_ramp_line):
_ramp_line.visible = false
## Nach Undo/Redo/Laden/Neue-Map: Feld neu dekodieren (Cache ist sonst alt).
func refresh_from_doc() -> bool:
if not _refresh_from_doc():
return false
return true
func _refresh_from_doc() -> bool:
var terrain: Dictionary = main.doc.get("terrain", {})
if terrain.is_empty():
return false
_res = int(terrain.get("resolution", 0))
_sx = float(terrain.get("size_x_m", 0.0))
_sz = float(terrain.get("size_z_m", 0.0))
_hmin = float(terrain.get("min_height_m", 0.0))
_hmax = float(terrain.get("max_height_m", 0.0))
if _res < 2 or _sx <= 0.0 or _sz <= 0.0 or _hmax <= _hmin:
return false
_heights = SvmapIO.decode_heights(terrain)
# Material-Schicht mit dekodieren (Karte t_55ed041a): der Live-Rebuild
# beim Heben/Senken/Glätten braucht sie, sonst fällt das Mesh während
# des Strichs auf alles-Gras zurück (Sand/Fels „verschwinden" live).
_mats = SvmapIO.decode_materials(terrain)
_mres = SvmapIO.material_resolution(terrain)
_load_mix(terrain)
return _heights.size() >= _res * _res
# ------------------------------------------------------------ Pinsel-Optionen
func set_brush_mode(mode: int) -> void:
# Clamp bis PAINT (3) — der alte Clamp 0..2 hätte den Malen-Modus still
# zu Glätten degradiert (Karte t_55257792, Ziffer 4 = Malen).
brush_mode = clampi(mode, 0, MODE_LABELS.size() - 1)
_refresh_ring_mesh()
func set_radius(m: float) -> void:
brush_radius = clampf(m, 1.0, 64.0) # ab 1 m (Karte 25.10., feines Raster)
_refresh_ring_mesh()
## Misch-Schicht (Karte 25.10.) aus dem terrain-Block — fehlt sie, Nullen.
func _load_mix(terrain: Dictionary) -> void:
var mix := SvmapIO.decode_mix(terrain)
_mix_b = mix[0]
_mix_w = mix[1]
## Materialpunkt als ein int (a | b<<8 | w<<16) — Spiegeln kopiert alle drei.
func _punkt(idx: int) -> int:
return int(_mats[idx]) | (int(_mix_b[idx]) << 8) | (int(_mix_w[idx]) << 16)
## Anteil (0–255) des Materials m an einem gepackten Punkt.
static func _anteil(v: int, m: int) -> int:
var a := v & 0xFF
var b := (v >> 8) & 0xFF
var w := (v >> 16) & 0xFF
return (255 - w if a == m else 0) + (w if b == m else 0)
func _setze_punkt(idx: int, v: int) -> void:
_mats[idx] = v & 0xFF
_mix_b[idx] = (v >> 8) & 0xFF
_mix_w[idx] = (v >> 16) & 0xFF
## Regler „Kante: hart ↔ weich“ (nur Malen).
func set_weichheit(w: float) -> void:
brush_weichheit = clampf(w, 0.0, 1.0)
func set_falloff(f: float) -> void:
brush_falloff = clampf(f, 0.0, 1.0)
## Druck setzen (XR-Controller-Trigger 0–1 ruft das direkt; Pen/Maus laufen
## über _unhandled_input). Wert wird über TerrainField.pressure_of begrenzt.
func set_pressure(p: float) -> void:
pressure = TerrainField.pressure_of(p, 0.0)
func set_strength(mps: float) -> void:
brush_strength = clampf(mps, 1.0, 20.0)
func set_paint_material(id: int) -> void:
paint_material = clampi(id, 0, SvmapIO.MATERIAL_IDS.size() - 1)
_refresh_ring_mesh()
# --------------------------------------------------------------- Eingaben ---
func _unhandled_input(event: InputEvent) -> void:
var p := MousePointer.from_input(event)
if p == null:
return
# Stift: pressure > 0; Maus meldet 0 (oder 1) → bleibt voll.
if p.kind == PointerEvent.Kind.MOVE:
p.pressure = TerrainField.pressure_of(p.pressure, _trigger_axis())
if handle_pointer(p):
get_viewport().set_input_as_handled()
## Rechte Maustaste kommt hier nie an: sie gehört mit absolutem Vorrang dem
## Freelook-Flug (orbit_camera.gd, Karte t_2470de89), in jedem Werkzeugzustand.
func handle_pointer(p: PointerEvent) -> bool:
if not active:
return false
match p.kind:
PointerEvent.Kind.MOVE:
_update_cursor(p.position)
pressure = p.pressure
PointerEvent.Kind.PRESS:
# Karte t_2470de89: Strg = Invert-Modifier. Heben↔Senken
# tauschen (Blender/Photoshop-Konvention), im Malen-Modus
# malt Strg+Klick Gras zurück (alter Radierer-Weg).
var mode := brush_mode
var mat := -1
if p.ctrl:
match brush_mode:
TerrainField.Mode.RAISE:
mode = TerrainField.Mode.LOWER
TerrainField.Mode.LOWER:
mode = TerrainField.Mode.RAISE
TerrainField.Mode.PAINT:
mat = 0 # Gras zurückmalen
_begin_stroke(mode, mat)
return true
PointerEvent.Kind.RELEASE:
_end_stroke()
return true
return false
## Gamepad-Trigger (rechts) des ersten Pads; 0 ohne Pad. Nur Eingabe-Lesen,
## keine OS-Annahme.
func _trigger_axis() -> float:
var pads := Input.get_connected_joypads()
if pads.is_empty():
return 0.0
return Input.get_joy_axis(pads[0], JOY_AXIS_TRIGGER_RIGHT)
func _update_cursor(viewport_pos: Vector2) -> void:
var hit: Variant = TerrainField.raycast(_heights, _res, _sx, _sz,
camera.project_ray_origin(viewport_pos), camera.project_ray_normal(viewport_pos))
if hit == null:
_cursor_valid = false
if _ring != null:
_ring.visible = false
return
_cursor_valid = true
_cursor = hit as Vector3
var half_x := _sx * 0.5
var half_z := _sz * 0.5
_cursor.x = clampf(_cursor.x, -half_x, half_x)
_cursor.z = clampf(_cursor.z, -half_z, half_z)
# Ring minimal über dem Gelände, damit er nicht im Mesh verschwindet.
_ring.position = Vector3(_cursor.x, _height_at_stroke(_cursor.x, _cursor.z) + 0.3, _cursor.z)
_ring.visible = true
func _height_at_stroke(x: float, z: float) -> float:
return TerrainField.height_at(_heights, _res, _sx, _sz, x, z)
# ----------------------------------------------------------------- Stroke ---
func _begin_stroke(mode: int, material_id: int = -1) -> void:
if _stroking:
return
if not _cursor_valid:
main.call("_set_status",
"Der Pinsel braucht einen Punkt AUF dem Gelände — führe die Maus über die Karte und halte dann die Maustaste gedrückt.",
true)
return
_stroke_mode = mode
_stroke_material = paint_material if material_id < 0 else material_id
_stroking = true
if mode == TerrainField.Mode.FLATTEN:
_flatten_target = _height_at_stroke(_cursor.x, _cursor.z)
elif mode == TerrainField.Mode.RAMP:
_ramp_a = Vector2(_cursor.x, _cursor.z)
_ramp_ha = _height_at_stroke(_cursor.x, _cursor.z)
_ramp_b = _ramp_a
# Im Malen-Modus lebt die Material-Schicht während des Strichs
# (dekodiert — Legacy-Maps ohne Schicht starten mit alles-Gras).
if mode == TerrainField.Mode.PAINT:
_mats = SvmapIO.decode_materials(main.doc.get("terrain", {}))
_mres = SvmapIO.material_resolution(main.doc.get("terrain", {}))
_load_mix(main.doc.get("terrain", {}))
_refresh_ring_mesh()
main.call("_set_status", "%s läuft — Maustaste gedrückt halten. Beim Loslassen wird der ganze Strich EIN Rückgängig-Schritt (Strg+Z)." % _stroke_label())
## Lesbarer Stroke-Name: Heben/Senken/Glätten bzw. „Malen (Sand)".
func _stroke_label() -> String:
if _stroke_mode == TerrainField.Mode.PAINT:
return "Malen (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)]
return MODE_LABELS[_stroke_mode]
func _process(delta: float) -> void:
if not _stroking or not _cursor_valid:
return
if _stroke_mode == TerrainField.Mode.PAINT:
var painted := _paint_step(_cursor.x, _cursor.z)
if painted:
# Karte t_7a0a6b79: Malen lebt im Sub-Rect — update_terrain bekommt
# das betroffene Zellrechteck (inkl. Spiegelzellen) und leitet nur
# die Splat-Region neu ab; das Mesh selbst bleibt unangetastet
# (Vertex-Farben hängen NICHT vom Material ab).
if view_builder.has_method("set_terrain_mix"):
view_builder.call("set_terrain_mix", _mix_b, _mix_w)
view_builder.call("update_terrain", _heights, _mats, _splat_rect)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
return
if _stroke_mode == TerrainField.Mode.RAMP:
# Rampe wirkt erst beim Loslassen — hier nur der Endpunkt + Ring-Hinweis.
_ramp_b = Vector2(_cursor.x, _cursor.z)
_update_ramp_line()
return
var amount := _step_amount(_stroke_mode, delta)
# Während des Strichs NICHT auf die Format-Range clampen — die zieht der
# Range-Expander danach nach (sonst friert Heben/Senken an der Kante ein).
var changed := _height_step(_cursor.x, _cursor.z, amount)
if changed:
_expand_range_if_needed()
# Karte t_55ed041a: Material-Schicht MITGEBEN — ohne sie hat der
# Live-Rebuild alles als Gras gerendert (Sand/Fels „verschwanden"
# während des Heben/Senk-Strichs und kehrten erst am Stroke-Ende
# mit dem Vollrebuild zurück).
view_builder.call("update_terrain", _heights, _mats)
_ring.position.y = _height_at_stroke(_cursor.x, _cursor.z) + 0.3
## Stärke dieses Frames: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten/
## Einebnen) — skaliert mit dem Druck.
func _step_amount(mode: int, dt: float) -> float:
match mode:
TerrainField.Mode.SMOOTH:
return clampf(brush_strength / SMOOTH_RATE_DIVISOR * dt * pressure, 0.0, 1.0)
TerrainField.Mode.FLATTEN:
return clampf(brush_strength / FLATTEN_RATE_DIVISOR * dt * pressure, 0.0, 1.0)
return brush_strength * dt * pressure
## Höhe spannt das Höhenfeld automatisch auf, wenn der Pinsel über die
## Format-Grenzen (min/max_height) hinaus will — sonst wäre Senken auf einer
## frisch angelegten (flachen) Map unmöglich und langes Heben würde clampen.
## Die Range gehört zum terrain-Block → Undo stellt sie mit zurück. Spannweite
## bleibt ≤ 500 m (V6).
func _expand_range_if_needed() -> void:
var lo := TerrainField._min_h(_heights)
var hi := TerrainField._max_h(_heights)
if lo < _hmin:
_hmin = floorf(lo - 1.0)
if hi > _hmax:
_hmax = ceilf(hi + 1.0)
if _hmax - _hmin > 500.0:
if lo < _hmin:
_hmin = _hmax - 500.0
else:
_hmax = _hmin + 500.0
func _end_stroke() -> void:
if not _stroking:
return
_stroking = false
if _stroke_mode == TerrainField.Mode.RAMP:
_ramp_line.visible = false
if not _ramp_apply():
main.call("_set_status", "Rampe: Start und Ende liegen zu nah beieinander — Klick, ziehen, loslassen.")
return # nichts geändert → kein leerer Undo-Eintrag
# Doc erst jetzt ändern: der GANZE Strich ist ein History-Eintrag.
var before: Dictionary = main.history.snapshot(["terrain"])
var terrain: Dictionary = main.doc.get("terrain", {})
if _stroke_mode == TerrainField.Mode.PAINT:
terrain["materials_r8_b64"] = SvmapIO.encode_materials(_mats)
SvmapIO.encode_mix(terrain, _mix_b, _mix_w)
main.history.commit("Boden gemalt (%s)" % SvmapIO.MATERIAL_IDS[clampi(_stroke_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)],
before, main.history.snapshot(["terrain"]))
else:
terrain["min_height_m"] = _hmin
terrain["max_height_m"] = _hmax
terrain["heightmap_r16_b64"] = TerrainField.encode(_heights, _hmin, _hmax)
main.history.commit("Boden geformt (%s)" % MODE_LABELS[_stroke_mode],
before, main.history.snapshot(["terrain"]))
main.call("_on_terrain_stroke_committed")
## Rampe anwenden (Strichende): Start-Höhe = Höhe beim Klick, End-Höhe = Höhe
## unter dem Endpunkt. Breite = 2 × Radius. Symmetrie wie bei den Pinseln.
func _ramp_apply() -> bool:
if _ramp_a.distance_to(_ramp_b) < 1.0:
return false
var hb := _height_at_stroke(_ramp_b.x, _ramp_b.y)
var width := brush_radius * 2.0
var snap := {}
var g := _world_to_grid(_ramp_a.x, _ramp_a.y)
if _sym_active():
var g2 := _world_to_grid(_ramp_b.x, _ramp_b.y)
var r := _brush_region(g).merge(_brush_region(g2))
snap = _snap_heights(r)
var changed := TerrainField.apply_ramp(_heights, _res, _sx, _sz,
_ramp_a, _ramp_b, _ramp_ha, hb, width, brush_falloff)
if changed and not snap.is_empty():
_mirror_heights(snap, g)
if changed:
_expand_range_if_needed()
view_builder.call("update_terrain", _heights, _mats)
return changed
## Rampen-Vorschau: gelbe Linie vom Startpunkt zum Cursor (auf Geländehöhe).
func _update_ramp_line() -> void:
var im := _ramp_line.mesh as ImmediateMesh
im.clear_surfaces()
im.surface_begin(Mesh.PRIMITIVE_LINES)
im.surface_add_vertex(Vector3(_ramp_a.x, _ramp_ha + 0.5, _ramp_a.y))
im.surface_add_vertex(Vector3(_ramp_b.x, _height_at_stroke(_ramp_b.x, _ramp_b.y) + 0.5, _ramp_b.y))
im.surface_end()
_ramp_line.visible = true
## Programmgesteuerter Stroke (Demo/Screenshot-Hook): macht exakt, was eine
## Maus auch tun würde — inkl. History-Eintrag.
func demo_stroke(x: float, z: float, mode: int, seconds: float) -> void:
_refresh_from_doc()
_cursor = Vector3(x, 0.0, z)
_cursor_valid = true
_begin_stroke(mode)
var steps := int(ceil(seconds / 0.05))
var dt := seconds / float(maxi(steps, 1))
var save_x := _cursor.x
var save_z := _cursor.z
for i in steps:
var amount := _step_amount(mode, dt)
if mode == TerrainField.Mode.PAINT:
_paint_step(save_x, save_z)
continue
_height_step(save_x, save_z, amount)
_expand_range_if_needed()
_end_stroke()
_refresh_from_doc()
## Demo-Hook: Pinsel-Ring sichtbar an einer Weltposition parken (für
## Screenshots ohne echte Maus-Ereignisse).
func demo_park_brush(x: float, z: float) -> void:
_refresh_ring_mesh()
_cursor = Vector3(x, 0.0, z)
_cursor_valid = true
_ring.position = Vector3(x, _height_at_stroke(x, z) + 0.3, z)
_ring.visible = true
# ------------------------------------------------------------- Symmetrie ----
## M2 (Karte t_f9eff526): Im Symmetrie-Modus (main.sym_mode ≠ AUS) schreibt
## jeder Strich seine Zellen EXAKT gespiegelt in die andere Kartenseite —
## Höhe ist ein Skalar → 1:1 identisch übernommen, Material ebenso. Die
## Zell-Abbildung kommt aus M1 (SymmetryCore.mirror_cell), nichts neu erfunden.
##
## Ablauf pro Pinsel-Schritt:
## 1. Pinsel EINMAL an der Strich-Position anwenden — bewusst NICHT zweimal
## (Strichpunkt + Spiegelpunkt): sonst bekämen Zellen im Überlappband den
## Effekt doppelt und an der Rastermitte entstünde ein Grat.
## 2. Die seit dem Snapshot geänderten Zellen des Pinsel-Rechtecks ermitteln
## und jede Änderung in die gespiegelte Schwesterzelle kopieren.
## Liegt der Strich genau auf der Mitte/Achse, bildet mirror_cell die Zellen
## auf sich selbst bzw. auf Partner mit IDENTISCHEm Wert ab (spiegelsymmetrischer
## Fußabdruck, gleicher Falloff-Abstand) → nichts wird gedoppelt.
## Überlappen Fußabdruck und Spiegelbild (Strich nahe der Mitte), gewinnt pro
## Zellpaar die Seite, die NÄHER am Strichpunkt liegt — die andere Hälfte ist
## exakte Kopie der Strich-Seite; bei exaktem Gleichstand entscheidet der
## kleinere Grid-Index (deterministisch, Row-major-Verarbeitung).
## Bei Modus AUS: keine Snapshot-/Kopierarbeit — exakt das alte Verhalten.
## Zusätzlicher Rand um die grobe Pinsel-Region (TerrainField arbeitet mit
## floor/ceil ± 1; hier großzügig ± 2 — der Snapshot-Vergleich entscheidet
## ohnehin zellgenau, was wirklich geändert wurde).
const SYM_REGION_MARGIN := 2
func _sym_mode() -> int:
if main == null:
return SymmetryCore.Mode.OFF
# .get statt direkter Property: Haupts ohne sym_mode-Feld (z. B. alte
# Test-Stubs, Tools außerhalb des Editors) fallen auf AUS zurück —
# exakt das Verhalten vor M2.
var mode: Variant = main.get("sym_mode")
if mode == null or not (mode is int):
return SymmetryCore.Mode.OFF
return int(mode)
func _sym_active() -> bool:
return _sym_mode() != SymmetryCore.Mode.OFF
## Welt-X/Z → Raster-Koordinaten (gleiche Konvention wie TerrainField.height_at:
## Index 0 = −X/−Z-Ecke, x-Index läuft MIT +WeltX).
func _world_to_grid(x: float, z: float) -> Vector2:
return _world_to_grid_r(x, z, _res)
## Wie _world_to_grid, aber auf einem beliebigen Raster (Material: _mres).
func _world_to_grid_r(x: float, z: float, r: int) -> Vector2:
var last := float(r - 1)
return Vector2((x / _sx + 0.5) * last, (z / _sz + 0.5) * last)
## Grobes Rechteck um die Raster-Position (g), das den Pinsel-Fußabdruck
## jedes Modus garantiert abdeckt (Radius in Zellen + Margin).
func _brush_region(g: Vector2) -> Rect2i:
return _brush_region_r(g, _res)
## Wie _brush_region, auf dem Raster r (Material-Schicht: _mres).
func _brush_region_r(g: Vector2, r: int) -> Rect2i:
var cell_x := _sx / float(r - 1)
var cell_z := _sz / float(r - 1)
var rx := brush_radius / cell_x + float(SYM_REGION_MARGIN)
var rz := brush_radius / cell_z + float(SYM_REGION_MARGIN)
var x0 := maxi(int(floorf(g.x - rx)), 0)
var x1 := mini(int(ceilf(g.x + rx)), r - 1)
var z0 := maxi(int(floorf(g.y - rz)), 0)
var z1 := mini(int(ceilf(g.y + rz)), r - 1)
return Rect2i(x0, z0, x1 - x0 + 1, z1 - z0 + 1)
## Abstand einer Rasterzelle zum Strichpunkt in Metern (gleiche Zell-Metrik
## wie TerrainField.apply_brush: Zellindex-Abstand × Zellgröße).
func _cell_dist(pos: Vector2i, g: Vector2, cell_x: float, cell_z: float) -> float:
var dx := (float(pos.x) - g.x) * cell_x
var dz := (float(pos.y) - g.y) * cell_z
return sqrt(dx * dx + dz * dz)
## Ein Pinsel-Schritt HÖHE (Heben/Senken/Glätten) inkl. Spiegel-Kopie.
## amount: Meter (Heben/Senken) bzw. Mischfaktor 0–1 (Glätten) — unverändert
## zur bisherigen apply_brush-Semantik; ohne Clamp (Range-Expander zieht nach).
func _height_step(x: float, z: float, amount: float) -> bool:
var g := _world_to_grid(x, z)
var snap := {}
if _sym_active():
snap = _snap_heights(_brush_region(g))
var changed := TerrainField.apply_brush(_heights, _res, _sx, _sz,
-100000.0, 100000.0, x, z, brush_radius, _stroke_mode, amount,
brush_falloff, _flatten_target)
if changed and not snap.is_empty():
_mirror_heights(snap, g)
return changed
## Ein Pinsel-Schritt MATERIAL (Malen) inkl. Spiegel-Kopie.
func _paint_step(x: float, z: float) -> bool:
# Material lebt auf seinem eigenen (feinen) Raster _mres (Karte 24.10.).
var g := _world_to_grid_r(x, z, _mres)
# VOR dem Pinsel: Zellrect des Fußabdrucks merken (deckt Spiegelzellen mit)
# — der Live-Splat-Refresh (t_7a0a6b79) leitet genau diese Region neu ab.
_splat_rect = _splat_region(g)
var snap := {}
if _sym_active() and _mats.size() >= _mres * _mres:
snap = _snap_mats(_brush_region_r(g, _mres))
var changed := TerrainField.apply_material_brush_mix(_mats, _mix_b, _mix_w, _mres,
_sx, _sz, x, z, brush_radius, _stroke_material, brush_weichheit, pressure)
if changed and not snap.is_empty():
_mirror_mats(snap, g)
return changed
## Snapshot {Vector2i(x, z) → Höhe} über das Pinsel-Rechteck (NUR bei
## Symmetrie AN erzeugt — bei AUS bleibt es bei exakt dem alten Verhalten).
func _snap_heights(rect: Rect2i) -> Dictionary:
var snap := {}
for z: int in range(rect.position.y, rect.end.y):
for x: int in range(rect.position.x, rect.end.x):
snap[Vector2i(x, z)] = _heights[z * _res + x]
return snap
## Spiegel-Pass für die Höhe: jede seit dem Snapshot geänderte Zelle wird in
## ihre gespiegelte Schwester kopiert (Strich-Seite ist Master, siehe oben).
func _mirror_heights(snap: Dictionary, g: Vector2) -> void:
var sym := _sym_mode()
var cell_x := _sx / float(_res - 1)
var cell_z := _sz / float(_res - 1)
var done := {}
var positions: Array = snap.keys()
# Row-major (kleinster Index zuerst): der Gleichstand-Tie-Break „kleinerer
# Index gewinnt“ ist damit deterministisch, und ein Paar wird genau einmal
# an seinem kleineren Index entschieden.
positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return (a.y * _res + a.x) < (b.y * _res + b.x))
for pos: Vector2i in positions:
if done.has(pos):
continue
var idx: int = pos.y * _res + pos.x
var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _res)
done[pos] = true
if mirror == pos:
continue # Selbstabbildung (Rastermitte/Achse) — nichts zu kopieren
var midx: int = mirror.y * _res + mirror.x
var value: float = _heights[idx]
if snap.has(mirror):
# Beide Seiten wurden vom Pinsel geändert (Fußabdruck überlappt
# sein Spiegelbild): näher am Strichpunkt gewinnt, Gleichstand
# → kleinere Index (== diese pos, da Row-major zuerst kam).
if _cell_dist(mirror, g, cell_x, cell_z) < _cell_dist(pos, g, cell_x, cell_z):
value = _heights[midx]
_heights[idx] = value
_heights[midx] = value
done[mirror] = true
## Snapshot {Vector2i(x, z) → Material-ID} über das Pinsel-Rechteck.
func _snap_mats(rect: Rect2i) -> Dictionary:
var snap := {}
for z: int in range(rect.position.y, rect.end.y):
for x: int in range(rect.position.x, rect.end.x):
snap[Vector2i(x, z)] = _punkt(z * _mres + x)
return snap
## Spiegel-Pass für die Material-Schicht (identische Paar-Logik wie _mirror_heights).
func _mirror_mats(snap: Dictionary, g: Vector2) -> void:
var sym := _sym_mode()
var cell_x := _sx / float(_mres - 1)
var cell_z := _sz / float(_mres - 1)
var done := {}
var positions: Array = snap.keys()
positions.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return (a.y * _mres + a.x) < (b.y * _mres + b.x))
# Karte 25.10. (Sly: harte gerade Kanten mit Symmetrie): NUR Punkte spiegeln,
# die dieser Pinsel-Schritt wirklich verändert hat. Vorher wurde das ganze
# Snapshot-Quadrat auf die Gegenseite kopiert — weiche Verläufe drüben
# wurden an den Quadratkanten hart abgeschnitten.
for pos: Vector2i in positions:
if done.has(pos):
continue
done[pos] = true
var idx: int = pos.y * _mres + pos.x
if _punkt(idx) == int(snap[pos]):
continue # nicht bemalt → Gegenseite bleibt unangetastet
var mirror: Vector2i = SymmetryCore.mirror_cell(sym, pos.x, pos.y, _mres)
if mirror == pos:
continue
var midx: int = mirror.y * _mres + mirror.x
var value: int = _punkt(idx)
if snap.has(mirror) and _punkt(midx) != int(snap[mirror]):
# Beide Seiten bemalt (Pinsel überlappt sein Spiegelbild, z. B. nahe
# der Kartenmitte): der Wert mit dem GRÖSSEREN Anteil des Mal-
# Materials gewinnt. Vorher „näher am Strichpunkt“ — das zog entlang
# der Mittelsenkrechte eine harte gerade Kante durch weiche Verläufe
# (Sly 25.10.). Max ist stetig und symmetrisch.
if _anteil(_punkt(midx), _stroke_material) > _anteil(value, _stroke_material):
value = _punkt(midx)
_setze_punkt(idx, value)
done[mirror] = true
_setze_punkt(midx, value)
# ----------------------------------------------------------- Ring-Optik -----
func _refresh_ring_mesh() -> void:
if _ring == null:
return
if _ring_radius == brush_radius and _ring_mode == brush_mode:
return
_ring_radius = brush_radius
_ring_mode = brush_mode
var torus := TorusMesh.new()
torus.inner_radius = brush_radius - 0.25
torus.outer_radius = brush_radius + 0.25
torus.rings = 48
torus.ring_segments = 8
_ring.mesh = torus
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
match brush_mode:
TerrainField.Mode.RAISE:
mat.albedo_color = Color(0.4, 1.0, 0.45, 0.9)
TerrainField.Mode.LOWER:
mat.albedo_color = Color(1.0, 0.45, 0.3, 0.9)
TerrainField.Mode.PAINT:
# Ring in der Farbe des gewählten Materials (Karte t_603ec1a8).
var mc: int = clampi(paint_material, 0, SvmapIO.MATERIAL_IDS.size() - 1)
mat.albedo_color = (terrain_mesh_color(mc) as Color)
mat.albedo_color.a = 0.9
TerrainField.Mode.FLATTEN:
mat.albedo_color = Color(1.0, 0.85, 0.3, 0.9)
TerrainField.Mode.RAMP:
mat.albedo_color = Color(0.85, 0.5, 1.0, 0.9)
_:
mat.albedo_color = Color(0.4, 0.7, 1.0, 0.9)
mat.emission_enabled = true
mat.emission = (mat.albedo_color as Color) * 0.5
_ring.material_override = mat
## Material-ID → Renderer-Farbe (gleiche Palette wie terrain_mesh.gd).
func terrain_mesh_color(id: int) -> Color:
match id:
1:
return Color(0.78, 0.70, 0.45) # Sand
2:
return Color(0.45, 0.44, 0.42) # Fels
_:
return Color(0.30, 0.45, 0.22) # Gras
# ------------------------------------------------ Live-Splat-Sub-Rect (Karte
# t_7a0a6b79): Zellrechteck des letzten Mal-Schritts für das Live-Update der
# Splat-Gewichtstextur. Am Dateiende angefügt (Karten-Regel).
## Zellrect des letzten Mal-Schritts (Pinsel-Fußabdruck ∪ gespiegelte
## Schwesterzellen) — vom Stroke-Code als 3. Parameter an update_terrain
## (view_builder) weitergereicht.
var _splat_rect := Rect2i()
## Zellrect des Pinsel-Fußabdrucks (∪ Spiegelzellen): dieselben Region-
## Grenzen wie apply_material_brush, mit dem SYM_REGION_MARGIN-Rand — der
## deckt auch die Kernel-Nachbarschaft der Splat-Ableitung mit ab.
## g und Ergebnis im Material-Raster (_mres) — so rechnet auch die Splat.
func _splat_region(g: Vector2) -> Rect2i:
var rect := _brush_region_r(g, _mres)
var sym := _sym_mode()
if sym == SymmetryCore.Mode.OFF:
return rect
var a := SymmetryCore.mirror_cell(sym, rect.position.x, rect.position.y, _mres)
var b := SymmetryCore.mirror_cell(sym, rect.end.x - 1, rect.end.y - 1, _mres)
return rect.merge(Rect2i(mini(a.x, b.x), mini(a.y, b.y),
absi(a.x - b.x) + 1, absi(a.y - b.y) + 1))

View file

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

110
editor/test_run.gd Normal file
View file

@ -0,0 +1,110 @@
class_name TestRun
extends RefCounted
## ALT (Karte t_1cdc1a72): dieser Client-Start ist seit dem Lauf-Modus im
## Map-Builder selbst NICHT mehr der Standard-Weg — „▶ Karte testen" startet
## jetzt IN DIESER APP (core/editor/playtest/, kein Client-Ordner, kein
## zweiter Prozess, kein VR). Der Rückbau dieser Datei kommt in einer
## späteren Karte; bis dahin bleibt die Export-/Such-Logik als Fallback
## erhalten und getestet (tests/test_testrun.gd).
##
## Ursprünglicher Zweck (Karte t_b84af40f): Export der AKTUELLEN Map nach Temp
## + Start des scopedvr-client als EIGENES Fenster im Testlauf-Modus
## (--testmap <pfad>). Der Editor bleibt offen — der Client ist ein eigener
## Prozess; das Spiel beendet sich selbst sauber (ESC = „Zurück zum Editor").
##
## Client-Suche (erste Treffer-Zeile gewinnt):
## 1. Konfigurationsdatei user://scopedvr_editor.cfg, Abschnitt [test]:
## client_dir="/pfad/zu/scopedvr-client"
## 2. Klon neben dem Editor: <Editor-Projektordner>/../scopedvr-client
## (erkennbar an project.godot im Ordner).
##
## Godot-Binary für den Start: das Binary, unter dem der Editor selbst läuft
## (OS.get_executable_path — Sly startet den Editor über den Godot-Editor,
## also kann DAS auch den Client starten). Läuft der Editor irgendwann als
## exportierte App, muss [test] godot_path gesetzt werden.
##
## Alles static + testbar ohne UI (tests/test_testrun.gd).
const CFG_PATH := "user://scopedvr_editor.cfg"
const TESTRUN_DIR := "user://testrun"
static func load_cfg() -> ConfigFile:
var cfg := ConfigFile.new()
if FileAccess.file_exists(CFG_PATH):
cfg.load(CFG_PATH) # Fehler → Default-Logik greift weiter unten.
return cfg
## Liefert den Client-Projektordner (mit project.godot) oder "".
static func find_client(project_dir: String, cfg: ConfigFile = null) -> String:
var candidates: Array = []
if cfg != null:
var configured := str(cfg.get_value("test", "client_dir", ""))
# Explizit gesetzter Pfad gewinnt, sobald der ORDNER existiert
# (bewusst ohne project.godot-Check — wer ihn konfiguriert, weiß es besser).
if not configured.is_empty() and DirAccess.dir_exists_absolute(configured):
return configured
var parent := project_dir.simplify_path().get_base_dir()
candidates.append(parent.path_join("scopedvr-client"))
for candidate in candidates:
var dir := str(candidate).simplify_path()
if FileAccess.file_exists(dir.path_join("project.godot")):
return dir
return ""
## Godot-Binary zum Starten des Clients (Editor-Binary oder Konfiguration).
static func find_godot(cfg: ConfigFile = null) -> String:
var exe := OS.get_executable_path()
if exe.get_file().to_lower().contains("godot"):
return exe
if cfg != null:
return str(cfg.get_value("test", "godot_path", ""))
return ""
## Deterministischer Temp-Pfad aus dem Map-Namen (erneuter Testlauf
## überschreibt die alte Export-Datei — kein Müll-Berg).
static func temp_map_path(doc: Dictionary) -> String:
var raw := str(doc.get("info", {}).get("name", "")).strip_edges()
if raw.is_empty():
raw = "karte"
var safe := ""
for ch in raw:
var c := ch.to_lower()
if (c >= "a" and c <= "z") or (c >= "0" and c <= "9") or ch == "_" or ch == "-" or ch == " ":
safe += ch
else:
safe += "_"
safe = safe.strip_edges().replace(" ", "_").rstrip("_")
if safe.is_empty():
safe = "karte"
return TESTRUN_DIR.path_join(safe + ".svmap")
## Exportiert das AKTUELLE Dokument (auch ungespeicherte Maps!) nach Temp.
## Rückgabe: {ok, errors, path} — path ist globalisiert (für den Client-Prozess).
static func export_temp_map(doc: Dictionary) -> Dictionary:
var rel := temp_map_path(doc)
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(TESTRUN_DIR))
var result := SvmapIO.save_to_file(rel, doc)
result["path"] = ProjectSettings.globalize_path(rel)
return result
## Hand-Haltungs-Tool: liegt im Client-Projekt, weil es dessen Rig und Waffen
## direkt benutzt (kein VR, eigenes Fenster).
static func hand_pose_args(client_dir: String) -> PackedStringArray:
return PackedStringArray(["--xr-mode", "off", "--path", client_dir,
"res://tools/hand_pose_tool/hand_pose_tool.tscn"])
## Import-Lauf für den Client, nötig nach neuem Stand (neue Modelle/Skripte).
static func import_args(client_dir: String) -> PackedStringArray:
return PackedStringArray(["--headless", "--path", client_dir, "--import"])
## Argumentliste für den Client-Start (getestet: exakt diese Form).
static func launch_args(client_dir: String, map_path: String) -> PackedStringArray:
return PackedStringArray(["--path", client_dir, "--", "--testmap", map_path, "--mock-master"])

1
editor/test_run.gd.uid Normal file
View file

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

428
editor/updater.gd Normal file
View file

@ -0,0 +1,428 @@
class_name EditorUpdater
extends RefCounted
## Editor-Selbstupdate (Karte t_d95f087a) — Update-Check über Git, Update-
## Ausführung (git pull + Godot-Import-Lauf) und plattformneutrale Restart-
## Auflösung. Alles static und ohne UI (tests/test_updater.gd).
##
## Regeln (Karte + Sly-Ansatz „das Programm sich selbst updaten lassen“):
## - Transport = Git (Klon des Editors), KEIN Binary-Download.
## - Keine Linux-Pfade hartkodiert: git (default "git") und ein optionaler
## Restart-Befehl kommen aus user://scopedvr_editor.cfg, Abschnitt [update]
## (git_command / restart_command). Ohne Konfiguration: unter Linux wird
## erkannt, ob eine systemd-User-Unit „map-editor“ existiert — dann klingelt
## ein Transient-Timer-Kick (systemd-run) den Unit-Neustart (der Kick läuft
## im EIGENEN Scope und überlebt die Stop-Phase). Sonst: der Editor startet
## sich selbst neu (mode "self", self_restart_args).
## - Import-Lauf-Lektion 17.10.: ein --import-Lauf kann mit Exit-Code 0
## ENDEN und trotzdem Parse-/Script-Fehler enthalten (class_name-Cache).
## Die Ausgabe wird deshalb zusätzlich gescannt — bei Fehlern wird NICHT
## neu gestartet (Abbruch mit Meldung), sonst würde Sly einen Editor ohne
## Map-Ansicht sehen.
## - Offline / Tunnel weg → status "unreachable" → die UI bleibt still
## (kein Banner, kein Fehler-Geballer).
## - Git darf NIE interaktiv werden (Karte t_1457e935): kein GUI-Credential-
## Dialog mitten im Editor! Jeder git-Lauf läuft deshalb mit
## credential.helper=store und abgeschaltetem Askpass (spawn_argv) —
## fehlen die Zugangsdaten, endet der Lauf sofort mit einer klaren
## Fehlermeldung (status "auth_failed"), nie mit einem Prompt oder Hang.
## Hintergrund 17.10.: der systemd-User-Manager exportiert SSH_ASKPASS
## (ksshaskpass) — ein nackter git-Spawn hat damit mitten im Editor das
## Login-Fenster geöffnet. GIT_ASKPASS-/core.askPass-Override schlägt das
## geerbte SSH_ASKPASS (verifiziert am PC, 17.10.).
const BRANCH := "main"
const UNIT_NAME := "map-editor"
## Statuszeilen-Text für den auth_failed-Pfad (UI + Tests).
const AUTH_ERROR_TEXT := "Updates nicht prüfbar — Zugangsdaten fehlen (kein Login-Dialog geöffnet)."
## Belt-and-braces (Karte t_1457e935): argv für einen NICHT-interaktiven
## git-Lauf bauen. Rückgabe: [0] = Programm, Rest = Argumente.
## - "-c credential.helper=" leert ERST alle geerbten Helfer (sonst öffnet
## z. B. der Windows-Credential-Manager sein GUI-Fenster), danach
## "-c credential.helper=store": ausschließlich der Credential-Store
## (~/.git-credentials per HOME).
## - POSIX: Spawn über /usr/bin/env mit GIT_TERMINAL_PROMPT=0 und
## GIT_ASKPASS=/bin/true — das überstimmt auch ein GEERBTES SSH_ASKPASS
## (KDE/GNOME-Login-Dialog!). HOME kommt aus der eigenen Umgebung
## (OS.get_environment, kein Hardcode) und wird nur mitgegeben, wenn da.
## - Fallback ohne env-Wrapper (env fehlt / kein HOME): trotzdem kein Prompt
## möglich, weil core.askPass=/bin/true das Askpass-Fallback abschaltet.
static func spawn_argv(git_exe: String, project_dir: String, args: PackedStringArray) -> PackedStringArray:
var git_args := PackedStringArray(["-C", project_dir,
"-c", "credential.helper=", "-c", "credential.helper=store"])
if not OS.has_feature("windows"):
git_args.append_array(PackedStringArray(["-c", "core.askPass=/bin/true"]))
git_args.append_array(args)
var home := OS.get_environment("HOME")
if not OS.has_feature("windows") and FileAccess.file_exists("/usr/bin/env") \
and not home.is_empty():
var out := PackedStringArray(["/usr/bin/env",
"GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS=/bin/true", "HOME=" + home])
out.append(git_exe)
out.append_array(git_args)
return out
var out := PackedStringArray([git_exe])
out.append_array(git_args)
return out
## Ein Git-Kommando im Projektordner ausführen — IMMER nicht-interaktiv
## (spawn_argv). Rückgabe {ok, code, output} (output = stdout+stderr gemischt,
## damit Fehlerklassifikation sehen kann, was git gesagt hat).
static func git_run(git_exe: String, project_dir: String, args: PackedStringArray) -> Dictionary:
var argv := spawn_argv(git_exe, project_dir, args)
var exe := str(argv[0])
var rest := argv.slice(1)
var output: Array = []
var code := OS.execute(exe, rest, output, true)
var text := ""
for line in output:
text += str(line)
return {"ok": code == 0, "code": code, "output": text}
## Pure Prüfung (testbar): deutet die git-Ausgabe auf FEHLENDE Zugangsdaten?
## (Askpass ist abgeschaltet — „could not read Username“ ist dann der
## dokumentierte Nicht-Interaktiv-Fehler, kein Anlass für einen Retry.)
static func looks_like_auth_error(output: String) -> bool:
return output.contains("could not read Username") \
or output.contains("Authentication failed") \
or output.contains("terminal prompts disabled") \
or output.contains("Unauthorized")
## Git-Binary: Konfiguration (user://scopedvr_editor.cfg → [update]
## git_command) gewinnt, sonst das Standard-"git" (PATH).
static func git_exe(cfg: ConfigFile = null) -> String:
if cfg != null:
var custom := str(cfg.get_value("update", "git_command", ""))
if not custom.is_empty():
return custom
return "git"
## Lokaler HEAD-SHA. "" = kein Git-Repository (oder git nicht gefunden).
static func head_sha(git_exe: String, project_dir: String) -> String:
var r := git_run(git_exe, project_dir, PackedStringArray(["rev-parse", "HEAD"]))
if not bool(r["ok"]):
return ""
var text := str(r["output"]).strip_edges()
if text.is_empty():
return ""
return text.split("\n")[0].strip_edges()
## Remote-SHA per git ls-remote (cheap, lesend — keine lokalen Referenzen
## verändert). "" = nicht erreichbar / Branch fehlt / Credentials fehlen.
static func remote_sha(git_exe: String, project_dir: String, branch: String) -> String:
var r := git_run(git_exe, project_dir, PackedStringArray([
"ls-remote", "origin", "refs/heads/%s" % branch]))
if not bool(r["ok"]):
return ""
var text := str(r["output"]).strip_edges()
if text.is_empty():
return ""
return sha_aus_ls_remote(text, branch)
## Update-Check: lokaler HEAD vs. origin/main.
## Rückgabe {status, local, remote, error} — status:
## "update_available" | "up_to_date" | "unreachable" | "error" | "auth_failed"
## "error" = kein Repo/git nicht da (Editor-spezifisches Problem)
## "unreachable" = Origin nicht erreichbar (offline, Tunnel weg)
## "auth_failed" = Zugangsdaten fehlen → UI zeigt klaren Hinweis, NIEMALS
## einen Login-Prompt (Karte t_1457e935).
static func check_update(git_exe: String, project_dir: String) -> Dictionary:
var local := head_sha(git_exe, project_dir)
if local.is_empty():
return {"status": "error", "local": "", "remote": "",
"error": "Kein Git-Repository (oder git nicht gefunden)."}
var r := git_run(git_exe, project_dir, PackedStringArray([
"ls-remote", "origin", "refs/heads/%s" % BRANCH]))
if not bool(r["ok"]):
var text := str(r["output"])
if looks_like_auth_error(text):
return {"status": "auth_failed", "local": local, "remote": "",
"error": AUTH_ERROR_TEXT}
return {"status": "unreachable", "local": local, "remote": "",
"error": "Origin nicht erreichbar (offline oder Tunnel weg)."}
var text2 := str(r["output"]).strip_edges()
if text2.is_empty():
return {"status": "unreachable", "local": local, "remote": "",
"error": "Origin nicht erreichbar (offline oder Tunnel weg)."}
var remote := sha_aus_ls_remote(text2, BRANCH)
if remote.is_empty():
# Kein echter Commit in der Ausgabe (nur Warnungen/leer) — NICHT als
# Update deuten, sonst hängt das Banner endlos (Karte t_50a07d4b-Live-Bug).
return {"status": "unreachable", "local": local, "remote": "",
"error": "Origin lieferte keinen Commit (nur Warnungen)."}
if local == remote:
return {"status": "up_to_date", "local": local, "remote": remote, "error": ""}
# Befund 25.10.: „Update verfügbar“ blieb nach dem Update stehen, weil nur
# HEAD != origin/main geprüft wurde. Steht der Klon auf einem anderen
# Branch oder VOR main (z. B. Feature-Branch), ist das KEIN Update:
# nur melden, wenn der Remote-Commit NICHT schon in HEAD steckt.
if remote_in_head(git_exe, project_dir, remote):
return {"status": "up_to_date", "local": local, "remote": remote, "error": ""}
return {"status": "update_available", "local": local, "remote": remote, "error": ""}
## Ist `remote` schon Vorfahre von HEAD (Klon gleich oder VORAUS)? Unbekannter
## Commit (Exit 128) = noch nicht geholt = echtes Update → false.
static func remote_in_head(git_exe: String, project_dir: String, remote: String) -> bool:
var r := git_run(git_exe, project_dir, PackedStringArray([
"merge-base", "--is-ancestor", remote, "HEAD"]))
return bool(r["ok"])
## Aus der gemischten ls-remote-Ausgabe den Commit für `branch` lesen.
## WARUM: `git_run` mischt stdout+stderr, und der git-Client schreibt bei einer
## HTTP-Umleitung zuerst "warning: Umleitung nach ..." — die alte Fassung nahm
## stumpf die ERSTE Zeile und hielt "warning" für den Ziel-Commit. Ergebnis war
## ein Banner "Update verfügbar: <sha> → warning", das nie verschwand, obwohl
## der Pull korrekt lief (live belegt 20.10., Klon stand auf origin/main).
## Regeln: nur Zeilen "<sha>\t<ref>" zählen, sha = echter Hex-String.
static func sha_aus_ls_remote(text: String, branch: String) -> String:
var ziel := "refs/heads/%s" % branch
var locker := ""
for zeile in text.split("\n"):
var z := zeile.strip_edges()
if z.is_empty():
continue
var klein := z.to_lower()
if klein.begins_with("warning") or klein.begins_with("hint") \
or klein.begins_with("fatal") or klein.begins_with("error"):
continue
var teile := z.split("\t")
if teile.size() < 2:
continue
var sha := teile[0].strip_edges()
if not ist_sha(sha):
continue
if teile[1].strip_edges() == ziel:
return sha
if locker.is_empty():
locker = sha
return locker
## Sieht der Text wie ein Git-Objekt-Hash aus (7-40 Hex-Zeichen)?
static func ist_sha(text: String) -> bool:
var t := text.strip_edges()
if t.length() < 7 or t.length() > 40:
return false
for i in t.length():
var c := t[i]
if c >= "0" and c <= "9":
continue
if c >= "a" and c <= "f":
continue
if c >= "A" and c <= "F":
continue
return false
return true
## Kurzer SHA für Anzeigen (7 Zeichen, wie git log --oneline).
static func short_sha(sha: String) -> String:
return sha.substr(0, 7)
## Banner-Text (pure, testbar).
static func banner_text(local_sha: String, remote_sha: String) -> String:
return "🔄 Update verfügbar: %s → %s" % [short_sha(local_sha), short_sha(remote_sha)]
## Klartext-Status für die Statuszeile (Karte t_f73a1cc5): Wenn lokale
## (meist von Godot erzeugte) Dateien das Update blockieren WÜRDEN, legt der
## Pull sie selbst beiseite — Sly liest diesen Satz statt einer rohen Git-
## Zeile („Bitte verschieben oder entfernen Sie diese…“).
const AUTO_STASH_INFO_TEXT := "Update blockiert: eigene erzeugte Dateien — werden automatisch beiseitegelegt und das Update läuft weiter."
## Ehrliche Meldung, wenn das Zurückholen kollidiert ist (Stash bleibt als
## Sicherung liegen; Godot baut die erzeugten Dateien beim Import neu).
const STASH_KEPT_TEXT := "Erzeugte Dateien bleiben beiseitegelegt — beim nächsten Start neu aufgebaut (Sicherungs-Stash bleibt liegen, nichts verloren)."
## Kennung der eigenen Auto-Stashes (Verwerfen alter Einträge, damit sie
## sich nicht anhäufen).
const AUTO_STASH_MESSAGE := "auto: Selbst-Update"
## Update ausführen: git pull --ff-only (Klon zieht nur nach, dreht nichts
## um). Vorher werden lokal Erzeugtes/Geändertes AUTOMATISCH beiseitegelegt
## (Karte t_f73a1cc5): `git stash push -u` erfasst GEÄNDERT **und** UNTRACKED
## Dateien (`--autostash` allein reicht nicht — es lässt untracked liegen;
## genau die haben am 19.10. den Pull blockiert). Nach dem Pull wird das
## Beiseitegelegte zurückgeholt; kollidiert das (Typfall: lose `.import`-
## Datei trifft auf dieselbe Datei neu getrackt), bleibt der Stash als
## Sicherung liegen und die Statuszeile bekommt den Klartext — NIE ein halb
## gepoppter Zustand. Rückgabe {ok, output, errors, info} (info = Klartext
## fürs Statuszeile-Fenster, leer wenn nichts besonderes passiert ist).
static func pull(git_exe: String, project_dir: String) -> Dictionary:
var stash_info := _stash_local_changes(git_exe, project_dir)
# Gezielt origin/main ziehen (nicht den Upstream des aktuellen Branches).
var r := git_run(git_exe, project_dir, PackedStringArray(["pull", "--ff-only", "origin", BRANCH]))
var errors: Array = []
var text := str(r["output"]).strip_edges()
var info := ""
if not bool(r["ok"]):
# Pull fehlgeschlagen (z. B. offline): Beiseitegelegtes zurückholen,
# damit nichts „verschwindet", wenn der Fehler nur am Netz lag.
if not stash_info.is_empty():
_restore_stash(git_exe, project_dir)
errors.append("git pull --ff-only fehlgeschlagen (Exit-Code %s):" % str(r["code"]))
if not text.is_empty():
errors.append(text)
else:
errors.append("(keine Ausgabe — git nicht gefunden oder Verbindung sofort weg)")
if looks_like_auth_error(text):
errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).")
else:
if not stash_info.is_empty():
info = AUTO_STASH_INFO_TEXT
var restored := _restore_stash(git_exe, project_dir)
if not bool(restored["ok"]):
info += "\n" + STASH_KEPT_TEXT
# Der Map-Kern ist ein Submodul: ein Pull allein holt ihn nicht nach.
var sub := git_run(git_exe, project_dir, PackedStringArray(["submodule", "update", "--init", "--recursive"]))
if not bool(sub["ok"]):
errors.append("git submodule update fehlgeschlagen (Exit-Code %s):" % str(sub["code"]))
errors.append(str(sub["output"]).strip_edges())
if looks_like_auth_error(str(sub["output"])):
errors.append("Zugangsdaten fehlen — es wird bewusst KEIN Login-Dialog geöffnet (Credential-Store prüfen).")
return {"ok": false, "output": str(sub["output"]), "errors": errors, "info": info}
return {"ok": bool(r["ok"]), "output": str(r["output"]), "errors": errors, "info": info}
## Lokales Beiseitelegen vor dem Pull (Karte t_f73a1cc5). Rückgabe: Referenz
## auf den eigenen Auto-Stash (z. B. "stash@{0}") oder "" wenn es nichts zu
## beiseitelegen gab / der Stash nicht angelegt werden konnte.
## - Zuerst alte EIGENE Auto-Stashes verwerfen (gleiche Kennung) — sie
## enthalten nur erzeugte Dateien und dürfen sich nicht anhäufen.
## - "No local changes to save" endet ebenfalls mit Exit 0 → NICHT am Text
## matchen, sondern prüfen, ob ein Eintrag mit unserer Kennung entstand.
static func _stash_local_changes(git_exe: String, project_dir: String) -> String:
_drop_auto_stashes(git_exe, project_dir)
var r := git_run(git_exe, project_dir, PackedStringArray([
"stash", "push", "-u", "-m", AUTO_STASH_MESSAGE]))
if not bool(r["ok"]):
return ""
return _auto_stash_ref(git_exe, project_dir)
## Referenz des eigenen Auto-Stashes ("stash@{N}") oder "".
static func _auto_stash_ref(git_exe: String, project_dir: String) -> String:
var r := git_run(git_exe, project_dir, PackedStringArray([
"stash", "list", "--pretty=format:%gd|%s"]))
if not bool(r["ok"]):
return ""
for line in str(r["output"]).split("\n"):
var parts := str(line).strip_edges().split("|", true, 1)
if parts.size() == 2 and str(parts[1]).contains(AUTO_STASH_MESSAGE):
return str(parts[0])
return ""
## Alle EIGENEN Auto-Stashes (Kennung AUTO_STASH_MESSAGE) verwerfen — von
## hinten, weil die Indizes sich beim Drop verschieben.
static func _drop_auto_stashes(git_exe: String, project_dir: String) -> void:
var r := git_run(git_exe, project_dir, PackedStringArray([
"stash", "list", "--pretty=format:%gd|%s"]))
if not bool(r["ok"]):
return
var refs: Array = []
for line in str(r["output"]).split("\n"):
var parts := str(line).strip_edges().split("|", true, 1)
if parts.size() == 2 and str(parts[1]).contains(AUTO_STASH_MESSAGE):
refs.append(str(parts[0]))
refs.reverse()
for ref in refs:
git_run(git_exe, project_dir, PackedStringArray(["stash", "drop", ref]))
## Beiseitegelegtes zurückholen. apply (nicht pop), damit die Referenz nicht
## am Stack verschiebt; bei Erfolg wird der Eintrag gedroppt. Kollidiert das
## Zurückholen (Typfall: erzeugte Datei ist inzwischen getrackt), wird mit
## `git reset --hard` der halb gepoppte Zustand weggeräumt und der Stash ALS
## SICHERUNG LIEGEN GELASSEN — kein Datenverlust, Godot erzeugt die Dateien
## beim nächsten Import-Lauf neu. Bewusst KEIN `git clean`: untracked
## Nutzdaten (z. B. Maps/) dürfen nie angefasst werden.
## Rückgabe {ok, kept, output}.
static func _restore_stash(git_exe: String, project_dir: String) -> Dictionary:
var ref := _auto_stash_ref(git_exe, project_dir)
if ref.is_empty():
return {"ok": true, "kept": false, "output": ""}
var r := git_run(git_exe, project_dir, PackedStringArray(["stash", "apply", ref]))
if bool(r["ok"]):
git_run(git_exe, project_dir, PackedStringArray(["stash", "drop", ref]))
return {"ok": true, "kept": false, "output": str(r["output"])}
git_run(git_exe, project_dir, PackedStringArray(["reset", "--hard"]))
return {"ok": false, "kept": true, "output": str(r["output"])}
## Import-Lauf: <godot> --headless --path <projekt> --import. Muss NACH dem
## Pull laufen (neue class_name-Dateien in den Cache), FEHLSCHLAG = Update-
## Abbruch OHNE Neustart (Lektion 17.10.). Rückgabe {ok, errors, output}.
static func run_import(godot_exe: String, project_dir: String) -> Dictionary:
var args := PackedStringArray(["--headless", "--path", project_dir, "--import"])
var output: Array = []
var code := OS.execute(godot_exe, args, output, true)
var errors := import_errors(output)
if code != 0:
errors.append("Import-Lauf fehlgeschlagen (Exit-Code %s)" % str(code))
return {"ok": errors.is_empty(), "errors": errors, "output": _output_text(output)}
## Pure Prüfung (testbar): enthält die Import-Ausgabe Parse-/Script-Fehler?
## Exit-Code 0 allein beweist NICHT einen sauberen Import (Lektion 17.10.).
static func import_errors(output: Array) -> Array:
var errors: Array = []
for line in output:
var text := str(line)
if text.contains("Parse Error") or text.contains("SCRIPT ERROR"):
errors.append(text.strip_edges())
return errors
static func _output_text(output: Array) -> String:
var text := ""
for line in output:
text += str(line)
return text
## Restart-Auflösung (plattformneutral, getestet):
## 1. cfg [update] restart_command gesetzt → mode "command", argv = an
## Leerzeichen gesplittet. (z. B. systemd-Unit-Kick, Batch-Skript — jede
## Plattform setzt hier ihren eigenen Befehl ein.)
## 2. Linux + systemd-User-Unit „map-editor“ existiert → Transient-Timer-
## Kick (systemd-run, 3 s): der Kick läuft in einem EIGENEN Scope und
## wird beim Stoppen des Editors (Unit-Neustart) nicht mitgekillt.
## 3. Sonst: mode "self" — der Editor startet sich selbst neu (gleiche
## Godot-Binary, --editor --path <projekt>) und beendet sich danach.
static func resolve_restart(cfg: ConfigFile = null) -> Dictionary:
if cfg != null:
var cmd := str(cfg.get_value("update", "restart_command", ""))
if not cmd.is_empty():
return {"mode": "command", "argv": PackedStringArray(cmd.split(" ", false))}
if OS.has_feature("linux"):
var probe: Array = []
var code := OS.execute("systemctl", PackedStringArray([
"--user", "list-unit-files", UNIT_NAME + ".service", "--no-legend"]),
probe, true)
var text := ""
for line in probe:
text += str(line)
if code == 0 and not text.strip_edges().is_empty():
return {"mode": "command", "argv": PackedStringArray([
"systemd-run", "--user", "--on-active=3s",
"systemctl", "--user", "restart", UNIT_NAME + ".service"])}
return {"mode": "self", "argv": PackedStringArray()}
## Argumente für den Selbst-Restart: dieselbe Godot-Binary startet die
## Map-Editor-APP über deren main.tscn (kein "--editor" — das würde den
## Godot-Editor-Shell öffnen, nicht unsere App; Live-Befund 17.10.:
## Nach dem Selbst-Update ging der Godot-Editor auf statt dem Map Editor).
static func self_restart_args(project_dir: String) -> PackedStringArray:
return PackedStringArray(["--path", project_dir])

1
editor/updater.gd.uid Normal file
View file

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

3206
library/loadout_katalog.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -34,7 +34,7 @@ extends RefCounted
## byteidentisch, siehe tools/sync_wasser.sh im Map-Editor). ## byteidentisch, siehe tools/sync_wasser.sh im Map-Editor).
const SHADER_KANDIDATEN: Array[String] = [ const SHADER_KANDIDATEN: Array[String] = [
"res://shaders/wasser.gdshader", # scopedvr-client "res://shaders/wasser.gdshader", # scopedvr-client
"res://src/viewport/wasser.gdshader", # scopedvr-map-editor (Spiegel) "res://core/viewport/wasser.gdshader", # scopedvr-map-editor (Spiegel)
] ]
## Plausibilitätsbereich des svmap-Feldes `water_level` (Meter, y-Höhe). ## Plausibilitätsbereich des svmap-Feldes `water_level` (Meter, y-Höhe).

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://core/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")

112
server/admin_client.gd Normal file
View file

@ -0,0 +1,112 @@
class_name AdminClient
extends Node
## Client der Match-Server-Admin-API v1 (Doku: docs/admin-api.md im
## scopedvr-match-server-Repo). Knoten in den Baum hängen, dann
## `var res = await client.call_api("GET", "/status")`.
##
## Antwort: {ok: bool (HTTP 2xx), status: int, body: Dictionary, transport_error: String}
## transport_error ist deutsch und leer, wenn der Server geantwortet hat.
## Das Token geht NUR als Header raus und wird nie geloggt oder zurückgegeben.
const TIMEOUT_S := 30.0
const MAX_RESPONSE_BYTES := 8 * 1024 * 1024
var host: String = ""
var port: int = 24580
var tls: bool = false
var token: String = ""
## Server-Eintrag aus dem ServerStore übernehmen.
func use(entry: Dictionary) -> void:
host = str(entry.get("host", ""))
port = int(entry.get("port", 24580))
tls = bool(entry.get("tls", false))
token = str(entry.get("token", ""))
static func base_url(p_host: String, p_port: int, p_tls: bool) -> String:
var h := p_host
if h.contains(":") and not h.begins_with("["):
h = "[%s]" % h # IPv6
return "%s://%s:%d/api/v1" % ["https" if p_tls else "http", h, p_port]
## Prüft die Eingabe für „Server hinzufügen“. Leerer Text = ok.
static func check_address(p_host: String, p_port: int) -> String:
var h := p_host.strip_edges()
if h.is_empty():
return "Bitte eine Adresse eingeben (IP oder Name)."
if h.contains("/") or h.contains(" ") or h.contains("://"):
return "Nur die Adresse eintragen, ohne http:// und ohne Schrägstriche (z. B. 192.168.1.20)."
if p_port < 1 or p_port > 65535:
return "Der Port muss zwischen 1 und 65535 liegen."
return ""
func call_api(method: String, path: String, body: Variant = null, query: Dictionary = {}, raw: PackedByteArray = PackedByteArray()) -> Dictionary:
var err := check_address(host, port)
if err != "":
return _transport_fail(err)
var url := base_url(host, port, tls) + path
if not query.is_empty():
var parts: PackedStringArray = []
for k in query.keys():
parts.append("%s=%s" % [str(k).uri_encode(), str(query[k]).uri_encode()])
url += "?" + "&".join(parts)
var headers: PackedStringArray = ["Accept: application/json"]
if token != "":
headers.append("Authorization: Bearer " + token)
var payload := raw
if body != null:
payload = JSON.stringify(body).to_utf8_buffer()
headers.append("Content-Type: application/json")
elif not raw.is_empty():
headers.append("Content-Type: application/octet-stream")
var http := HTTPRequest.new()
http.timeout = TIMEOUT_S
http.body_size_limit = MAX_RESPONSE_BYTES
http.accept_gzip = false
add_child(http)
var m := _method(method)
var rerr := http.request_raw(url, headers, m, payload)
if rerr != OK:
http.queue_free()
return _transport_fail("Anfrage konnte nicht gestartet werden (Fehler %d)." % rerr)
var done: Array = await http.request_completed
http.queue_free()
var result: int = done[0]
if result != HTTPRequest.RESULT_SUCCESS:
return _transport_fail(_result_text(result))
var code: int = done[1]
var parsed: Variant = JSON.parse_string((done[3] as PackedByteArray).get_string_from_utf8())
var out: Dictionary = parsed if parsed is Dictionary else {}
return {"ok": code >= 200 and code < 300, "status": code, "body": out, "transport_error": ""}
static func _method(m: String) -> int:
match m.to_upper():
"POST": return HTTPClient.METHOD_POST
"PUT": return HTTPClient.METHOD_PUT
"PATCH": return HTTPClient.METHOD_PATCH
"DELETE": return HTTPClient.METHOD_DELETE
return HTTPClient.METHOD_GET
static func _transport_fail(msg: String) -> Dictionary:
return {"ok": false, "status": 0, "body": {}, "transport_error": msg}
static func _result_text(result: int) -> String:
match result:
HTTPRequest.RESULT_CANT_CONNECT, HTTPRequest.RESULT_CONNECTION_ERROR:
return "Keine Verbindung zum Server. Läuft er, ist die Admin-API eingeschaltet, und stimmen Adresse und Port?"
HTTPRequest.RESULT_CANT_RESOLVE:
return "Die Adresse wurde nicht gefunden. Tippfehler?"
HTTPRequest.RESULT_TIMEOUT:
return "Der Server antwortet nicht (Zeitüberschreitung)."
HTTPRequest.RESULT_TLS_HANDSHAKE_ERROR:
return "Sichere Verbindung (https) fehlgeschlagen. Gibt der Server wirklich https her?"
HTTPRequest.RESULT_BODY_SIZE_LIMIT_EXCEEDED:
return "Die Antwort des Servers ist zu groß."
return "Verbindungsfehler (Code %d)." % result

View file

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

101
server/admin_texte.gd Normal file
View file

@ -0,0 +1,101 @@
class_name AdminTexte
extends RefCounted
## Verständliche deutsche Texte zu Admin-API-Antworten (kein Fachchinesisch,
## kein rohes JSON) – inklusive der Fehlerliste (422) beim Map-Upload.
const TUNNEL_HINWEIS := "Tipp: Die Admin-API hört standardmäßig nur auf dem Server selbst (localhost). Von außen erreichst du sie sicher über einen SSH-Tunnel (z. B. ssh -L 24580:127.0.0.1:24580 benutzer@server, dann hier Adresse 127.0.0.1) oder über einen Reverse-Proxy mit https. Die Verbindung selbst ist sonst unverschlüsselt."
## Erklärungen für häufige Prüfregeln der Map (Server und Editor nutzen dieselben IDs).
const REGEL_HILFE := {
"V1": "Die Datei ist zu groß.",
"V2": "Die Datei ist keine gültige Map-Datei.",
"V3": "Format-Version passt nicht – Editor und Server müssen zusammenpassen.",
"V4": "Ein Pflichtbereich der Map fehlt.",
"V6": "Das Terrain ist zu groß oder zu detailreich.",
"V8": "Zu viele Objekte oder zu viele Polygone – Objekte entfernen.",
"V11": "Pro Team werden mindestens 4 Spawn-Punkte gebraucht (Spawn-Zonen malen).",
"V14": "Die Map ragt über die erlaubte Größe hinaus.",
}
## Antwort → Text für den Nutzer ("" bei Erfolg ohne Besonderheiten).
static func erklaere(res: Dictionary) -> String:
if str(res.get("transport_error", "")) != "":
return str(res["transport_error"]) + "\n\n" + TUNNEL_HINWEIS
var status := int(res.get("status", 0))
var body: Dictionary = res.get("body", {})
var server_text := str(body.get("error", ""))
match status:
401:
return "Das Token stimmt nicht (oder fehlt). Es steht beim Server in der Admin-Datei (state_file) – bitte neu eintragen."
429:
return "Zu viele falsche Token-Versuche. Der Server sperrt dich kurz – bitte in ein paar Minuten erneut versuchen."
404:
if str(body.get("code", "")) == "not_found":
return "Diese Funktion kennt der Server nicht. Ist er auf dem neuesten Stand?"
413:
return "Die Datei ist größer als der Server erlaubt. " + server_text
422:
return erklaere_map_fehler(body)
if server_text != "":
return server_text
if status >= 200 and status < 300:
return ""
return "Unerwartete Antwort vom Server (Status %d)." % status
## Fehlerliste einer abgelehnten Map als Aufzählung mit Hilfetexten.
static func erklaere_map_fehler(body: Dictionary) -> String:
var errs: Array = body.get("errors", [])
var out := "Der Server hat die Map abgelehnt"
out += " (%d Problem%s):\n" % [errs.size(), "" if errs.size() == 1 else "e"] if not errs.is_empty() else ".\n"
var hints := {}
for e in errs:
var t := str(e)
out += "• " + t + "\n"
var rule := t.split(":")[0].strip_edges()
for k in REGEL_HILFE.keys():
if rule == k or rule.begins_with(k) and not rule.substr(k.length(), 1).is_valid_int():
hints[k] = REGEL_HILFE[k]
if not hints.is_empty():
out += "\nSo behebst du es:\n"
for k in hints.keys():
out += "• %s: %s\n" % [k, hints[k]]
return out.strip_edges()
## Dateiname für den Upload: a–z, 0–9, _ und -, höchstens 40 Zeichen (wie der Server).
static func slugify(text: String) -> String:
var t := text.strip_edges().to_lower()
for pair in [["ä", "ae"], ["ö", "oe"], ["ü", "ue"], ["ß", "ss"]]:
t = t.replace(pair[0], pair[1])
var out := ""
for c in t:
out += c if ((c >= "a" and c <= "z") or (c >= "0" and c <= "9") or c == "-") else "_"
while out.contains("__"):
out = out.replace("__", "_")
out = out.lstrip("_-").rstrip("_")
return out.substr(0, 40)
## Wirkung einer Einstellung in Klartext.
static func wirkung(effect: String) -> String:
match effect:
"live": return "sofort"
"next_map": return "ab der nächsten Map"
"restart": return "nach Server-Neustart"
return effect
static func neustart_zeitueberschreitung(seconds: int) -> String:
return "Der Server ist nach %d s nicht zurück. Er kommt nur von selbst wieder, wenn Docker (Restart-Policy), systemd oder tools/start_server.* ihn gestartet haben." % seconds
static func sperrdauer_text(minutes: int) -> String:
if minutes <= 0:
return "dauerhaft"
if minutes < 60:
return "%d Minuten" % minutes
if minutes < 1440:
return "%d Stunden" % (minutes / 60)
return "%d Tage" % (minutes / 1440)

View file

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

40
server/map_push.gd Normal file
View file

@ -0,0 +1,40 @@
class_name MapPush
extends RefCounted
## „Map auf Server pushen“: aktuelle Map lokal prüfen und signieren
## (SvmapIO.to_signed_text), dann per Admin-API hochladen. UI-frei und testbar.
## opts: {name: String ("" = aus Map-Name), overwrite: bool, add_to_rotation: bool}
## Rückgabe: {ok, stage ("lokal"|"upload"), text (verständliche Meldung),
## id (Server-Map-ID), exists (bool: 409 – Nachfrage nötig), status}
static func push(client: AdminClient, doc: Dictionary, opts: Dictionary = {}) -> Dictionary:
var signed := SvmapIO.to_signed_text(doc)
if not bool(signed["ok"]):
var lines := "Die Map ist noch nicht gültig und wurde nicht hochgeladen:\n"
for e in signed["errors"]:
lines += "• %s\n" % str(e)
return {"ok": false, "stage": "lokal", "text": lines.strip_edges(), "id": "", "exists": false, "status": 0}
var info: Dictionary = doc.get("info", {})
var slug := str(opts.get("name", "")).strip_edges()
if slug == "":
slug = AdminTexte.slugify(str(info.get("name", "")))
if slug == "":
return {"ok": false, "stage": "lokal", "text": "Die Map braucht einen Namen (Tab „Map“), bevor sie hochgeladen werden kann.", "id": "", "exists": false, "status": 0}
var bytes: PackedByteArray = str(signed["text"]).to_utf8_buffer()
var query := {"name": slug}
if bool(opts.get("overwrite", false)):
query["overwrite"] = "1"
if bool(opts.get("add_to_rotation", false)):
query["add_to_rotation"] = "1"
var res: Dictionary = await client.call_api("POST", "/maps", null, query, bytes)
var body: Dictionary = res.get("body", {})
if bool(res.get("ok", false)):
var msg := "Hochgeladen als „%s“ (%s, %d Objekte)." % [str(body.get("name", slug)), _groesse(bytes.size()), int(body.get("objects", 0))]
if bool(body.get("replaced", false)):
msg += " Die vorherige Version wurde ersetzt."
return {"ok": true, "stage": "upload", "text": msg, "id": str(body.get("id", "")), "exists": false, "status": 200}
var exists := int(res.get("status", 0)) == 409 and str(body.get("code", "")) == "exists"
return {"ok": false, "stage": "upload", "text": AdminTexte.erklaere(res), "id": "", "exists": exists, "status": int(res.get("status", 0))}
static func _groesse(n: int) -> String:
return "%.1f MB" % (n / 1048576.0) if n >= 1048576 else "%d KB" % maxi(1, n / 1024)

1
server/map_push.gd.uid Normal file
View file

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

114
server/server_store.gd Normal file
View file

@ -0,0 +1,114 @@
class_name ServerStore
extends RefCounted
## Gespeicherte Admin-Server des Editors (Name, Adresse, Port, https, Token).
##
## Das Token liegt NICHT im Klartext auf der Platte: die Datei ist mit einem
## aus Rechner-ID + Nutzerordner abgeleiteten Schlüssel verschlüsselt. Das hält
## Zufallsleser, Cloud-Sync und Screenshots von Ordnern raus – gegen Schadsoftware
## mit Zugriff auf deinen Benutzer schützt es nicht (dafür gibt es keine
## plattformübergreifende Lösung ohne Passwort-Manager). Tokens werden nie geloggt.
const DEFAULT_PATH := "user://admin_server.dat"
const DEFAULT_PORT := 24580
const MAX_SERVERS := 50
var path: String = DEFAULT_PATH
var servers: Array = [] # [{id, name, host, port, tls, token}]
var last_error: String = ""
func _init(p: String = DEFAULT_PATH) -> void:
path = p
func _key() -> String:
return ("svr-editor-admin|%s|%s" % [OS.get_unique_id(), OS.get_user_data_dir()]).sha256_text()
func load_store() -> void:
servers.clear()
last_error = ""
if not FileAccess.file_exists(path):
return
var fa := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, _key())
if fa == null:
last_error = "Gespeicherte Server konnten nicht gelesen werden (andere Installation oder beschädigte Datei)."
return
var parsed: Variant = JSON.parse_string(fa.get_as_text())
if parsed is Array:
for e in parsed:
if e is Dictionary and str(e.get("host", "")) != "":
servers.append({
"id": str(e.get("id", _new_id())), "name": str(e.get("name", e["host"])),
"host": str(e["host"]), "port": int(e.get("port", DEFAULT_PORT)),
"tls": bool(e.get("tls", false)), "token": str(e.get("token", ""))})
func save_store() -> bool:
var fa := FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, _key())
if fa == null:
last_error = "Server-Liste konnte nicht gespeichert werden (Fehler %d)." % FileAccess.get_open_error()
return false
fa.store_string(JSON.stringify(servers))
return true
## Neuer oder aktualisierter Eintrag. Rückgabe: id ("" = Eingabe ungültig → last_error).
func upsert(entry: Dictionary) -> String:
var err := AdminClient.check_address(str(entry.get("host", "")), int(entry.get("port", 0)))
if err != "":
last_error = err
return ""
var e := {
"id": str(entry.get("id", "")), "name": str(entry.get("name", "")).strip_edges(),
"host": str(entry["host"]).strip_edges(), "port": int(entry["port"]),
"tls": bool(entry.get("tls", false)), "token": str(entry.get("token", "")).strip_edges()}
if e["name"] == "":
e["name"] = "%s:%d" % [e["host"], e["port"]]
if e["id"] == "":
if servers.size() >= MAX_SERVERS:
last_error = "Maximal %d gespeicherte Server." % MAX_SERVERS
return ""
e["id"] = _new_id()
servers.append(e)
else:
var i := index_of(e["id"])
if i < 0:
servers.append(e)
else:
servers[i] = e
save_store()
return e["id"]
func remove(id: String) -> bool:
var i := index_of(id)
if i < 0:
return false
servers.remove_at(i)
save_store()
return true
func index_of(id: String) -> int:
for i in servers.size():
if str(servers[i]["id"]) == id:
return i
return -1
func get_entry(id: String) -> Dictionary:
var i := index_of(id)
return servers[i].duplicate() if i >= 0 else {}
## Für Anzeige/Logs: nie das Token.
func public_view(id: String) -> Dictionary:
var e := get_entry(id)
e.erase("token")
e["has_token"] = str(get_entry(id).get("token", "")) != ""
return e
static func _new_id() -> String:
return "s%d_%d" % [int(Time.get_unix_time_from_system()), randi() % 100000]

View file

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

View file

@ -0,0 +1,40 @@
shader_type spatial;
render_mode unshaded, cull_front, shadows_disabled, blend_mix;
// Außenhüllen-Kontur (U6, Karte t_639135dd): dezente Auswahl-Markierung
// statt Vollflächen-Orange. Das Grundmaterial (inkl. Textur) bleibt komplett
// sichtbar — die Kontur lebt als eigene Hülle um das Modell:
//
// VERTEX += NORMAL * breite_m bläht das Volumen entlang der Normalen auf,
// cull_front rendert nur die RÜCKseiten der Hülle. Sichtbar bleibt darum
// genau der schmale Saum, der außerhalb der Silhouette liegt (der Rest
// wird vom Objekt selbst per Tiefentest verdeckt) — die klassische
// Inverted-Hull-Outline-Technik.
//
// Farbton, Deckkraft und Breite kommen aus dem Theme (theme/editor_theme.tres,
// Abschnitt "Editor") — der Shader selbst kennt keine festen Farben.
//
// Gestrichelte Variante (Spiegel-Zwilling): streifen_hz > 0 unterteilt die
// Kontur in Bänder entlang der Welthöhe — dünn + gestrichelt statt voll.
uniform vec4 linien_farbe : source_color = vec4(1.0, 0.6, 0.15, 0.95);
uniform float breite_m : hint_range(0.0, 1.0) = 0.08;
uniform float streifen_hz : hint_range(0.0, 40.0) = 0.0;
varying vec3 welt_pos;
void vertex() {
VERTEX += NORMAL * breite_m;
welt_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz;
}
void fragment() {
if (streifen_hz > 0.5) {
// Gestrichelt (Zwilling): Bänder entlang der Welthöhe, ~55 % Strich.
if (fract(welt_pos.y * streifen_hz) > 0.55) {
discard;
}
}
ALBEDO = linien_farbe.rgb;
ALPHA = linien_farbe.a;
}

View file

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

View file

@ -1,7 +1,7 @@
extends RefCounted extends RefCounted
class_name GroupState class_name GroupState
## Gruppen-ZUSTAND über das Objekt-Feld "group" hinaus (Karte t_a4bf9b8c, U3 — ## Gruppen-ZUSTAND über das Objekt-Feld "group" hinaus (Karte t_a4bf9b8c, U3 —
## Gruppen im Szenen-Baum). GroupOps (src/editor/group_ops.gd) besitzt die ## Gruppen im Szenen-Baum). GroupOps (core/editor/group_ops.gd) besitzt die
## Zugehörigkeit (Optionales Objekt-Feld "group", int ≥ 0) — diese Datei ergänzt ## Zugehörigkeit (Optionales Objekt-Feld "group", int ≥ 0) — diese Datei ergänzt
## NUR ADDITIV, was der Baum additionally braucht: ## NUR ADDITIV, was der Baum additionally braucht:
## ##

View file

@ -9,9 +9,9 @@ extends RefCounted
## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client). ## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client).
## In beiden Modi optional: grenades:{id: anzahl} und mines:anzahl = Vorrat pro ## In beiden Modi optional: grenades:{id: anzahl} und mines:anzahl = Vorrat pro
## Leben für Spieler und Bots; fehlen sie, gilt der Standard des Servers. ## Leben für Spieler und Bots; fehlen sie, gilt der Standard des Servers.
## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py). ## Katalog-Quelle: core/library/loadout_katalog.json (tools/sync_loadout_katalog.py).
const PATH := "res://library/loadout_katalog.json" const PATH := "res://core/library/loadout_katalog.json"
const MODE_FIXED := "fixed" const MODE_FIXED := "fixed"
const MODE_PLAYER := "player" const MODE_PLAYER := "player"

View file

@ -68,6 +68,10 @@ static func textur_rel(id: String) -> String:
## Absoluter Pfad im Nachbar-Klon ("" = ID unbekannt oder kein Client-Klon). ## Absoluter Pfad im Nachbar-Klon ("" = ID unbekannt oder kein Client-Klon).
static func textur_pfad(id: String) -> String: static func textur_pfad(id: String) -> String:
# Im Spiel liegen die Himmel im eigenen Projekt.
var im_projekt := "res://" + textur_rel(id)
if not textur_rel(id).is_empty() and ResourceLoader.exists(im_projekt):
return im_projekt
var rel := textur_rel(id) var rel := textur_rel(id)
if rel.is_empty(): if rel.is_empty():
return "" return ""

281
theme/editor_theme.tres Normal file
View file

@ -0,0 +1,281 @@
[gd_resource type="Theme" load_steps=23 format=3]
[sub_resource type="StyleBoxFlat" id="panel"]
bg_color = Color(0.12, 0.14, 0.17, 1)
border_color = Color(0.3, 0.36, 0.42, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="tab_sel"]
bg_color = Color(0.24, 0.3, 0.36, 1)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_top = 2
corner_radius_top_left = 4
corner_radius_top_right = 4
content_margin_left = 10.0
content_margin_right = 10.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="tab_hover"]
bg_color = Color(0.19, 0.24, 0.29, 1)
border_color = Color(0.35, 0.55, 0.7, 0.4)
border_width_top = 2
corner_radius_top_left = 4
corner_radius_top_right = 4
content_margin_left = 10.0
content_margin_right = 10.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="tab_unsel"]
bg_color = Color(0.15, 0.18, 0.21, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
content_margin_left = 10.0
content_margin_right = 10.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="tabbar"]
bg_color = Color(0.1, 0.12, 0.14, 1)
[sub_resource type="StyleBoxFlat" id="btn_normal"]
bg_color = Color(0.19, 0.23, 0.27, 1)
border_color = Color(0.3, 0.36, 0.42, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="btn_hover"]
bg_color = Color(0.25, 0.31, 0.37, 1)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="btn_pressed"]
bg_color = Color(0.14, 0.19, 0.24, 1)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="btn_disabled"]
bg_color = Color(0.16, 0.17, 0.19, 1)
border_color = Color(0.24, 0.26, 0.29, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="btn_focus"]
draw_center = false
border_color = Color(0.55, 0.8, 1, 0.85)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="line_normal"]
bg_color = Color(0.09, 0.11, 0.13, 1)
border_color = Color(0.3, 0.36, 0.42, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="line_focus"]
bg_color = Color(0.1, 0.13, 0.15, 1)
border_color = Color(0.55, 0.8, 1, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 8.0
content_margin_right = 8.0
[sub_resource type="StyleBoxFlat" id="tooltip"]
bg_color = Color(0.08, 0.09, 0.11, 0.96)
border_color = Color(0.35, 0.55, 0.7, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
content_margin_left = 6.0
content_margin_right = 6.0
content_margin_top = 4.0
content_margin_bottom = 4.0
[sub_resource type="StyleBoxFlat" id="split_grab"]
bg_color = Color(0.22, 0.27, 0.32, 1)
corner_radius_top_left = 2
corner_radius_top_right = 2
corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
[sub_resource type="StyleBoxFlat" id="split_grab_hover"]
bg_color = Color(0.35, 0.55, 0.7, 0.6)
corner_radius_top_left = 2
corner_radius_top_right = 2
corner_radius_bottom_right = 2
corner_radius_bottom_left = 2
[sub_resource type="StyleBoxFlat" id="scroll_bg"]
bg_color = Color(0.1, 0.12, 0.14, 0.4)
[sub_resource type="StyleBoxFlat" id="scroll_grab"]
bg_color = Color(0.42, 0.49, 0.56, 0.6)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="scroll_grab_hover"]
bg_color = Color(0.55, 0.63, 0.7, 0.8)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="scroll_grab_press"]
bg_color = Color(0.55, 0.8, 1, 0.85)
corner_radius_top_left = 3
corner_radius_top_right = 3
corner_radius_bottom_right = 3
corner_radius_bottom_left = 3
[sub_resource type="StyleBoxFlat" id="progress_bg"]
bg_color = Color(0.09, 0.11, 0.13, 1)
border_color = Color(0.3, 0.36, 0.42, 1)
border_width_left = 1
border_width_top = 1
border_width_right = 1
border_width_bottom = 1
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxFlat" id="progress_fill"]
bg_color = Color(0.55, 0.8, 1, 1)
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4
[sub_resource type="StyleBoxEmpty" id="scroll_empty"]
[resource]
default_font_size = 16
Editor/colors/accent = Color(0.55, 0.8, 1, 1)
Editor/colors/status_ok = Color(0.75, 0.95, 0.75, 1)
Editor/colors/status_error = Color(1, 0.6, 0.5, 1)
Editor/colors/muted = Color(1, 1, 1, 0.74)
Editor/colors/field = Color(1, 1, 1, 0.9)
Editor/colors/title = Color(1, 1, 1, 0.82)
Editor/colors/warning = Color(1, 0.62, 0.3, 1)
Editor/colors/badge_outline = Color(0, 0, 0, 1)
Editor/colors/help_bg = Color(0.09, 0.11, 0.13, 0.97)
Editor/colors/help_border = Color(0.35, 0.55, 0.7, 1)
Editor/colors/update_bg = Color(0.36, 0.25, 0.06, 0.97)
Editor/colors/update_border = Color(0.95, 0.72, 0.15, 1)
Editor/colors/rubber_fill = Color(0.45, 0.8, 1, 0.16)
Editor/colors/rubber_line = Color(0.45, 0.8, 1, 0.9)
Editor/colors/selection_outline = Color(1, 0.6, 0.15, 0.95)
Editor/colors/selection_outline_hover = Color(1, 0.6, 0.15, 0.45)
Editor/colors/selection_outline_partner = Color(1, 0.78, 0.4, 0.85)
Editor/constants/selection_outline_width_mm = 80
Editor/constants/selection_outline_width_hover_mm = 45
Editor/constants/selection_outline_width_partner_mm = 45
PanelContainer/styles/panel = SubResource("panel")
TabContainer/styles/panel = SubResource("panel")
TabContainer/styles/tab_selected = SubResource("tab_sel")
TabContainer/styles/tab_hovered = SubResource("tab_hover")
TabContainer/styles/tab_unselected = SubResource("tab_unsel")
TabContainer/styles/tabbar_background = SubResource("tabbar")
TabContainer/colors/font_selected_color = Color(1, 1, 1, 1)
TabContainer/colors/font_unselected_color = Color(1, 1, 1, 0.88)
Button/styles/normal = SubResource("btn_normal")
Button/styles/hover = SubResource("btn_hover")
Button/styles/pressed = SubResource("btn_pressed")
Button/styles/disabled = SubResource("btn_disabled")
Button/styles/focus = SubResource("btn_focus")
Button/colors/font_hover_color = Color(1, 1, 1, 1)
Button/colors/font_pressed_color = Color(0.8, 0.9, 1, 1)
Button/colors/font_disabled_color = Color(1, 1, 1, 0.5)
LineEdit/styles/normal = SubResource("line_normal")
LineEdit/styles/focus = SubResource("line_focus")
LineEdit/colors/font_color = Color(1, 1, 1, 1)
LineEdit/colors/font_placeholder_color = Color(1, 1, 1, 0.58)
HSplitContainer/styles/grabber_area = SubResource("split_grab")
HSplitContainer/styles/grabber_area_highlight = SubResource("split_grab_hover")
VSplitContainer/styles/grabber_area = SubResource("split_grab")
VSplitContainer/styles/grabber_area_highlight = SubResource("split_grab_hover")
ScrollContainer/styles/panel = SubResource("scroll_empty")
VScrollBar/styles/scroll = SubResource("scroll_bg")
VScrollBar/styles/grabber = SubResource("scroll_grab")
VScrollBar/styles/grabber_highlight = SubResource("scroll_grab_hover")
VScrollBar/styles/grabber_pressed = SubResource("scroll_grab_press")
HScrollBar/styles/scroll = SubResource("scroll_bg")
HScrollBar/styles/grabber = SubResource("scroll_grab")
HScrollBar/styles/grabber_highlight = SubResource("scroll_grab_hover")
HScrollBar/styles/grabber_pressed = SubResource("scroll_grab_press")
ProgressBar/styles/background = SubResource("progress_bg")
ProgressBar/styles/fill = SubResource("progress_fill")
ProgressBar/colors/font_color = Color(1, 1, 1, 1)
HSlider/styles/grabber_area = SubResource("progress_fill")
HSlider/styles/grabber_area_highlight = SubResource("progress_fill")
TooltipPanel/styles/panel = SubResource("tooltip")
TooltipLabel/colors/font_color = Color(1, 1, 1, 1)

BIN
thumbs/acacia_003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bsnwxasr4kxnw"
path="res://.godot/imported/acacia_003.png-49a9ced50b30b6d6eaf8fde6f7c9611f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/acacia_003.png"
dest_files=["res://.godot/imported/acacia_003.png-49a9ced50b30b6d6eaf8fde6f7c9611f.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

BIN
thumbs/acacia_005.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dpng4qbmhgaxn"
path="res://.godot/imported/acacia_005.png-f44002b723f89b248078d6195e099cbf.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/acacia_005.png"
dest_files=["res://.godot/imported/acacia_005.png-f44002b723f89b248078d6195e099cbf.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

BIN
thumbs/acacia_006.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://0vjob86u0mkh"
path="res://.godot/imported/acacia_006.png-1b9ce5d662b2c0fa9ce9be7d19023a1a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/acacia_006.png"
dest_files=["res://.godot/imported/acacia_006.png-1b9ce5d662b2c0fa9ce9be7d19023a1a.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

BIN
thumbs/air_defense_001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dxjkjfi837uo7"
path="res://.godot/imported/air_defense_001.png-eb0e8e33303a3e8d166565a1dbaca56b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/air_defense_001.png"
dest_files=["res://.godot/imported/air_defense_001.png-eb0e8e33303a3e8d166565a1dbaca56b.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

BIN
thumbs/air_defense_002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dmkfqnti7k861"
path="res://.godot/imported/air_defense_002.png-2f8aa6e0069f96ad4eeacf95eb7535f5.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/air_defense_002.png"
dest_files=["res://.godot/imported/air_defense_002.png-2f8aa6e0069f96ad4eeacf95eb7535f5.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cyvumt7k7tfkt"
path="res://.godot/imported/air_defense_platform_001.png-160acbcde1d7fef8e625c7afbec5977a.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/air_defense_platform_001.png"
dest_files=["res://.godot/imported/air_defense_platform_001.png-160acbcde1d7fef8e625c7afbec5977a.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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://blm2ew1gswl1e"
path="res://.godot/imported/air_defense_platform_002.png-dcb2adbe864891c2a6ca48f38513fd29.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/air_defense_platform_002.png"
dest_files=["res://.godot/imported/air_defense_platform_002.png-dcb2adbe864891c2a6ca48f38513fd29.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

BIN
thumbs/antenna_001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://cu2km1kitwe32"
path="res://.godot/imported/antenna_001.png-384a2221c556e630d3471ecce14aaa32.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/antenna_001.png"
dest_files=["res://.godot/imported/antenna_001.png-384a2221c556e630d3471ecce14aaa32.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

BIN
thumbs/apc_001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

40
thumbs/apc_001.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://ckp5nasktsw6d"
path="res://.godot/imported/apc_001.png-d008eeb8b81737eb9d55844b93c0ebbc.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/apc_001.png"
dest_files=["res://.godot/imported/apc_001.png-d008eeb8b81737eb9d55844b93c0ebbc.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

BIN
thumbs/apc_002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

40
thumbs/apc_002.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://b75b4yhnejh2m"
path="res://.godot/imported/apc_002.png-7b6a91012ea309e1a9cb9b866229ce8b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/apc_002.png"
dest_files=["res://.godot/imported/apc_002.png-7b6a91012ea309e1a9cb9b866229ce8b.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

BIN
thumbs/apc_cap.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

40
thumbs/apc_cap.png.import Normal file
View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://08m553i6la3k"
path="res://.godot/imported/apc_cap.png-232f17eb7efe055b948255afbdd4217f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/apc_cap.png"
dest_files=["res://.godot/imported/apc_cap.png-232f17eb7efe055b948255afbdd4217f.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

BIN
thumbs/apc_cap_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dkxw5r6shmdeo"
path="res://.godot/imported/apc_cap_1.png-9b6fee694e04ad2228678a4684329f4b.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/apc_cap_1.png"
dest_files=["res://.godot/imported/apc_cap_1.png-9b6fee694e04ad2228678a4684329f4b.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

BIN
thumbs/apc_weapon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://clmsnievmhcdv"
path="res://.godot/imported/apc_weapon.png-9ea6fa1fb0eee874cd8c3d13ac62b73f.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/apc_weapon.png"
dest_files=["res://.godot/imported/apc_weapon.png-9ea6fa1fb0eee874cd8c3d13ac62b73f.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

BIN
thumbs/barracks_001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://bhqfuk76plea1"
path="res://.godot/imported/barracks_001.png-12946249c6dd7571051e0a5b3c7cecad.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/barracks_001.png"
dest_files=["res://.godot/imported/barracks_001.png-12946249c6dd7571051e0a5b3c7cecad.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

BIN
thumbs/barrel_001.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://8d3x37mwv6xr"
path="res://.godot/imported/barrel_001.png-bef224f28b42bc3c1cb70c9153b02176.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/barrel_001.png"
dest_files=["res://.godot/imported/barrel_001.png-bef224f28b42bc3c1cb70c9153b02176.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

BIN
thumbs/barrel_002.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://x036pdk80s03"
path="res://.godot/imported/barrel_002.png-7062eaf20902780bd584d834de43d6da.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/barrel_002.png"
dest_files=["res://.godot/imported/barrel_002.png-7062eaf20902780bd584d834de43d6da.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

BIN
thumbs/barrel_003.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://clandxfaibniy"
path="res://.godot/imported/barrel_003.png-5fcd83720a21d80d1a51cd0c221f64ca.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/barrel_003.png"
dest_files=["res://.godot/imported/barrel_003.png-5fcd83720a21d80d1a51cd0c221f64ca.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

BIN
thumbs/barrel_004.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://r3uuorqbq6g7"
path="res://.godot/imported/barrel_004.png-abdeeae3e15f27c4a137ea172a5702f8.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/barrel_004.png"
dest_files=["res://.godot/imported/barrel_004.png-abdeeae3e15f27c4a137ea172a5702f8.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

BIN
thumbs/barrel_005.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://d1bivgvwdvioh"
path="res://.godot/imported/barrel_005.png-57073d19efec96bde302d98dd1310518.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://core/thumbs/barrel_005.png"
dest_files=["res://.godot/imported/barrel_005.png-57073d19efec96bde302d98dd1310518.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

Some files were not shown because too many files have changed in this diff Show more