Editor komplett in den Kern: Oberfläche, Werkzeuge, Ansicht, Theme, Vorschaubilder, Bau-Geometrie
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
|
@ -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
|
|
@ -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
|
||||||
1
editor/bau_katalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://defuinyvy5j8i
|
||||||
204
editor/bau_ops.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bh4sybddgu3mt
|
||||||
256
editor/build_snap.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://irnbwhk0eijq
|
||||||
71
editor/fly_math.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://btbsxedv3fvw
|
||||||
1729
editor/interaction.gd
Normal file
1
editor/interaction.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cdfcdpiga4j0s
|
||||||
167
editor/library_browser.gd
Normal 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)
|
||||||
1
editor/library_browser.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://o3jwxk87gtw
|
||||||
39
editor/mouse_pointer.gd
Normal 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
|
||||||
1
editor/mouse_pointer.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://1nyr0qwwdvnv
|
||||||
461
editor/playtest/playtest_avatar.gd
Normal 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)
|
||||||
1
editor/playtest/playtest_avatar.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cvj6x35cenpbm
|
||||||
581
editor/playtest/playtest_body.gd
Normal 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()
|
||||||
1
editor/playtest/playtest_body.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://csv8o2g8wdtue
|
||||||
536
editor/playtest/playtest_mode.gd
Normal 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)
|
||||||
1
editor/playtest/playtest_mode.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://spymwdq2qcvr
|
||||||
84
editor/playtest/playtest_settings.gd
Normal 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
|
||||||
1
editor/playtest/playtest_settings.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://1m7xfphtjvwd
|
||||||
92
editor/playtest/test_start.gd
Normal 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
|
||||||
1
editor/playtest/test_start.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b4barijsv6qs2
|
||||||
64
editor/playtest/test_start_store.gd
Normal 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)
|
||||||
1
editor/playtest/test_start_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b3gc8f1aecsjl
|
||||||
253
editor/playtest/test_start_tool.gd
Normal 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
|
||||||
1
editor/playtest/test_start_tool.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://byjsw857bu1k8
|
||||||
13
editor/pointer_router.gd
Normal 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
|
||||||
1
editor/pointer_router.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bem4edb2s282
|
||||||
121
editor/shortcut_keys.gd
Normal 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
|
||||||
1
editor/shortcut_keys.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://ctglyo67gpgdc
|
||||||
48
editor/shortcut_policy.gd
Normal 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
|
||||||
1
editor/shortcut_policy.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://0sobeix0nlyg
|
||||||
713
editor/spawn_tool.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dcr747fch22k
|
||||||
647
editor/symmetry_ops.gd
Normal 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
|
||||||
1
editor/symmetry_ops.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://csb4dkvi508of
|
||||||
765
editor/terrain_tool.gd
Normal 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))
|
||||||
1
editor/terrain_tool.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bo7va0ktrfvrj
|
||||||
110
editor/test_run.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cd3gmuvpf5ldv
|
||||||
428
editor/updater.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://nxdbipruop30
|
||||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||||
1
server/admin_client.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dd3mjb50m63hm
|
||||||
101
server/admin_texte.gd
Normal 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)
|
||||||
1
server/admin_texte.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://baspf3qu07cwi
|
||||||
40
server/map_push.gd
Normal 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
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cmuts8c8t2d5i
|
||||||
114
server/server_store.gd
Normal 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]
|
||||||
1
server/server_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dkwfi2v8nu03w
|
||||||
40
shaders/selection_outline.gdshader
Normal 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;
|
||||||
|
}
|
||||||
1
shaders/selection_outline.gdshader.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://b1wjgw8l4w77m
|
||||||
|
|
@ -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:
|
||||||
##
|
##
|
||||||
|
|
|
||||||
281
theme/editor_theme.tres
Normal 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
|
After Width: | Height: | Size: 1.8 KiB |
40
thumbs/acacia_003.png.import
Normal 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
|
After Width: | Height: | Size: 2 KiB |
40
thumbs/acacia_005.png.import
Normal 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
|
After Width: | Height: | Size: 2.5 KiB |
40
thumbs/acacia_006.png.import
Normal 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
|
After Width: | Height: | Size: 2.9 KiB |
40
thumbs/air_defense_001.png.import
Normal 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
|
After Width: | Height: | Size: 2.8 KiB |
40
thumbs/air_defense_002.png.import
Normal 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
|
||||||
BIN
thumbs/air_defense_platform_001.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
40
thumbs/air_defense_platform_001.png.import
Normal 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
|
||||||
BIN
thumbs/air_defense_platform_002.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
40
thumbs/air_defense_platform_002.png.import
Normal 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
|
After Width: | Height: | Size: 1.1 KiB |
40
thumbs/antenna_001.png.import
Normal 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
|
After Width: | Height: | Size: 2.7 KiB |
40
thumbs/apc_001.png.import
Normal 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
|
After Width: | Height: | Size: 2.7 KiB |
40
thumbs/apc_002.png.import
Normal 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
|
After Width: | Height: | Size: 1.5 KiB |
40
thumbs/apc_cap.png.import
Normal 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
|
After Width: | Height: | Size: 1.5 KiB |
40
thumbs/apc_cap_1.png.import
Normal 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
|
After Width: | Height: | Size: 1.9 KiB |
40
thumbs/apc_weapon.png.import
Normal 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
|
After Width: | Height: | Size: 2.6 KiB |
40
thumbs/barracks_001.png.import
Normal 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
|
After Width: | Height: | Size: 2.1 KiB |
40
thumbs/barrel_001.png.import
Normal 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
|
After Width: | Height: | Size: 2.1 KiB |
40
thumbs/barrel_002.png.import
Normal 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
|
After Width: | Height: | Size: 2 KiB |
40
thumbs/barrel_003.png.import
Normal 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
|
After Width: | Height: | Size: 2.1 KiB |
40
thumbs/barrel_004.png.import
Normal 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
|
After Width: | Height: | Size: 2 KiB |
40
thumbs/barrel_005.png.import
Normal 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
|
||||||
BIN
thumbs/barrel_006.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
40
thumbs/barrel_006.png.import
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[remap]
|
||||||
|
|
||||||
|
importer="texture"
|
||||||
|
type="CompressedTexture2D"
|
||||||
|
uid="uid://3vp6eyi4g2bj"
|
||||||
|
path="res://.godot/imported/barrel_006.png-bf1e1fbc93af64203f6371ddc251cc96.ctex"
|
||||||
|
metadata={
|
||||||
|
"vram_texture": false
|
||||||
|
}
|
||||||
|
|
||||||
|
[deps]
|
||||||
|
|
||||||
|
source_file="res://core/thumbs/barrel_006.png"
|
||||||
|
dest_files=["res://.godot/imported/barrel_006.png-bf1e1fbc93af64203f6371ddc251cc96.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_007.png
Normal file
|
After Width: | Height: | Size: 2 KiB |