Spielmodus-Auswahl beim Neu-Erstellen + Checkpoint-Zonen im Map-Tab (V21/V22, Undo, 3D-Anzeige)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bb9eb71770
commit
f42e133bae
12 changed files with 562 additions and 4 deletions
|
|
@ -19,7 +19,7 @@ const DEFAULT_AUTHOR := "ScopedVR Creator"
|
||||||
## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte
|
## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte
|
||||||
## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen-
|
## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen-
|
||||||
## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern.
|
## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern.
|
||||||
static func create_new(map_name: String, size_m: float, weather: String) -> Dictionary:
|
static func create_new(map_name: String, size_m: float, weather: String, game_mode := "tdm") -> Dictionary:
|
||||||
var size := maxf(MIN_SIZE_M, minf(size_m, MAX_SIZE_M))
|
var size := maxf(MIN_SIZE_M, minf(size_m, MAX_SIZE_M))
|
||||||
var res := DEFAULT_RESOLUTION
|
var res := DEFAULT_RESOLUTION
|
||||||
var zero_heights := PackedByteArray()
|
var zero_heights := PackedByteArray()
|
||||||
|
|
@ -59,6 +59,11 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
|
||||||
},
|
},
|
||||||
"weather": weather,
|
"weather": weather,
|
||||||
}
|
}
|
||||||
|
# Spielmodus (28.10.): nur ein abweichender Modus wird geschrieben; Checkpoint-
|
||||||
|
# Maps starten mit drei Zonen zwischen den Basen.
|
||||||
|
if game_mode == ModusKatalog.MODE_CHECKPOINTS:
|
||||||
|
data["game_mode"] = game_mode
|
||||||
|
data["checkpoints"] = ModusKatalog.default_checkpoints(data)
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
111
src/svmap/modus_katalog.gd
Normal file
111
src/svmap/modus_katalog.gd
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
class_name ModusKatalog
|
||||||
|
extends RefCounted
|
||||||
|
## Spielmodus + Checkpoints der Map (Karte Checkpoint-Modus, 28.10.).
|
||||||
|
## doc["game_mode"] ("tdm" | "checkpoints", fehlt = tdm) und doc["checkpoints"]
|
||||||
|
## [{id, label, pos:[x,y,z], radius_m}] — Regeln 1:1 wie SvmapLoader im
|
||||||
|
## Match-Server (V21/V22, docs/map_format.md, docs/checkpoints.md).
|
||||||
|
|
||||||
|
const MODE_TDM := "tdm"
|
||||||
|
const MODE_CHECKPOINTS := "checkpoints"
|
||||||
|
const MODES: Array[String] = [MODE_TDM, MODE_CHECKPOINTS]
|
||||||
|
const MODE_LABELS := {
|
||||||
|
MODE_TDM: "Team-Deathmatch",
|
||||||
|
MODE_CHECKPOINTS: "Checkpoints einnehmen",
|
||||||
|
}
|
||||||
|
const DEFAULT_RADIUS := 15.0
|
||||||
|
const MIN_RADIUS := 3.0
|
||||||
|
const MAX_RADIUS := 60.0
|
||||||
|
|
||||||
|
|
||||||
|
static func mode_of(doc: Dictionary) -> String:
|
||||||
|
var m := str(doc.get("game_mode", MODE_TDM))
|
||||||
|
return m if MODES.has(m) else MODE_TDM
|
||||||
|
|
||||||
|
|
||||||
|
static func checkpoints(doc: Dictionary) -> Array:
|
||||||
|
var c: Variant = doc.get("checkpoints", [])
|
||||||
|
return c if c is Array else []
|
||||||
|
|
||||||
|
|
||||||
|
## Nächste freie ID "C1", "C2", …
|
||||||
|
static func next_id(doc: Dictionary) -> String:
|
||||||
|
var used := {}
|
||||||
|
for c in checkpoints(doc):
|
||||||
|
if c is Dictionary:
|
||||||
|
used[str(c.get("id", ""))] = true
|
||||||
|
var i := 1
|
||||||
|
while used.has("C%d" % i):
|
||||||
|
i += 1
|
||||||
|
return "C%d" % i
|
||||||
|
|
||||||
|
|
||||||
|
## Neuer Checkpoint auf dem Boden (y = Terrain-Höhe).
|
||||||
|
static func make_checkpoint(doc: Dictionary, x: float, z: float, label := "") -> Dictionary:
|
||||||
|
var id := next_id(doc)
|
||||||
|
return {
|
||||||
|
"id": id,
|
||||||
|
"label": label if label != "" else "Zone %s" % id.substr(1),
|
||||||
|
"pos": [x, SvmapDocument.terrain_height_at(doc, x, z), z],
|
||||||
|
"radius_m": DEFAULT_RADIUS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
## Drei Start-Zonen zwischen den Basen (West–Ost-Linie), für neue Checkpoint-Maps.
|
||||||
|
static func default_checkpoints(doc: Dictionary) -> Array:
|
||||||
|
var size := float(doc.get("terrain", {}).get("size_x_m", 1024.0))
|
||||||
|
var out: Array = []
|
||||||
|
var names := ["Westposten", "Mitte", "Ostposten"]
|
||||||
|
var xs := [-0.25, 0.0, 0.25]
|
||||||
|
for i in 3:
|
||||||
|
var cp := make_checkpoint({"checkpoints": out, "terrain": doc.get("terrain", {})}, xs[i] * size, 0.0, names[i])
|
||||||
|
out.append(cp)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
static func validate(data: Dictionary, bounds: Dictionary) -> PackedStringArray:
|
||||||
|
var errors := PackedStringArray()
|
||||||
|
var mode = data.get("game_mode", "")
|
||||||
|
if mode != null and str(mode) != "" and not MODES.has(str(mode)):
|
||||||
|
errors.append("V21: game_mode '%s' unbekannt (%s)." % [str(mode), ", ".join(MODES)])
|
||||||
|
var cps = data.get("checkpoints", [])
|
||||||
|
if not (cps is Array):
|
||||||
|
errors.append("V22: checkpoints muss ein Array sein.")
|
||||||
|
return errors
|
||||||
|
if str(mode) == MODE_CHECKPOINTS and (cps as Array).is_empty():
|
||||||
|
errors.append("V21: game_mode 'checkpoints' braucht mindestens einen Checkpoint.")
|
||||||
|
var seen := {}
|
||||||
|
for i in (cps as Array).size():
|
||||||
|
var c = cps[i]
|
||||||
|
if not (c is Dictionary):
|
||||||
|
errors.append("V22: Checkpoint %d ist kein Objekt." % i)
|
||||||
|
continue
|
||||||
|
var cid = c.get("id", "")
|
||||||
|
if not (cid is String) or (cid as String).strip_edges().is_empty():
|
||||||
|
errors.append("V22: Checkpoint %d: id fehlt/leer." % i)
|
||||||
|
elif seen.has(cid):
|
||||||
|
errors.append("V22: Checkpoint-ID '%s' ist doppelt." % cid)
|
||||||
|
else:
|
||||||
|
seen[cid] = true
|
||||||
|
var pos = c.get("pos", null)
|
||||||
|
if not (pos is Array) or (pos as Array).size() != 3 \
|
||||||
|
or not _num(pos[0]) or not _num(pos[1]) or not _num(pos[2]):
|
||||||
|
errors.append("V22: Checkpoint '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(cid))
|
||||||
|
elif not _inside(pos, bounds):
|
||||||
|
errors.append("V22: Checkpoint '%s' liegt außerhalb der Match-Grenzen." % str(cid))
|
||||||
|
var r = c.get("radius_m", DEFAULT_RADIUS)
|
||||||
|
if not _num(r) or float(r) < MIN_RADIUS or float(r) > MAX_RADIUS:
|
||||||
|
errors.append("V22: Checkpoint '%s': radius_m muss 3–60 sein." % str(cid))
|
||||||
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
static func _num(v: Variant) -> bool:
|
||||||
|
return (v is int or v is float) and is_finite(float(v))
|
||||||
|
|
||||||
|
|
||||||
|
static func _inside(pos: Array, bounds: Dictionary) -> bool:
|
||||||
|
var mn: Array = bounds.get("min", [0, 0, 0])
|
||||||
|
var mx: Array = bounds.get("max", [0, 0, 0])
|
||||||
|
for i in 3:
|
||||||
|
if float(pos[i]) < float(mn[i]) - 1.0 or float(pos[i]) > float(mx[i]) + 1.0:
|
||||||
|
return false
|
||||||
|
return true
|
||||||
1
src/svmap/modus_katalog.gd.uid
Normal file
1
src/svmap/modus_katalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cprm4ke4dpj58
|
||||||
|
|
@ -222,6 +222,8 @@ static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> Packe
|
||||||
str(weather_sky), ", ".join(ids)])
|
str(weather_sky), ", ".join(ids)])
|
||||||
# V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog.
|
# V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog.
|
||||||
errors.append_array(LoadoutKatalog.validate(data.get("loadout", null)))
|
errors.append_array(LoadoutKatalog.validate(data.get("loadout", null)))
|
||||||
|
# V21/V22: Spielmodus + Checkpoints (optional), siehe ModusKatalog.
|
||||||
|
errors.append_array(ModusKatalog.validate(data, bounds))
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1289,15 +1289,16 @@ func _playtest_split_lagen_zurueck() -> void:
|
||||||
|
|
||||||
|
|
||||||
func _on_new_map_confirmed() -> void:
|
func _on_new_map_confirmed() -> void:
|
||||||
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather())
|
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather(), new_dialog.get_game_mode())
|
||||||
current_path = ""
|
current_path = ""
|
||||||
dirty = false
|
dirty = false
|
||||||
history.begin(doc)
|
history.begin(doc)
|
||||||
_clear_edit_state()
|
_clear_edit_state()
|
||||||
_refresh_all()
|
_refresh_all()
|
||||||
_test_start_reload() # Karte t_96e3c4ee: neue Map → kein Test-Start gesetzt
|
_test_start_reload() # Karte t_96e3c4ee: neue Map → kein Test-Start gesetzt
|
||||||
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s). Wähle links ein Objekt, um zu bauen!" % [
|
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s, Modus: %s). Wähle links ein Objekt, um zu bauen!" % [
|
||||||
new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()])
|
new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather(),
|
||||||
|
ModusKatalog.MODE_LABELS[new_dialog.get_game_mode()]])
|
||||||
|
|
||||||
|
|
||||||
func _on_open_selected(path: String) -> void:
|
func _on_open_selected(path: String) -> void:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ const UIScale := preload("res://src/ui/ui_scale.gd") # Karte t_297221b5
|
||||||
var name_edit: LineEdit
|
var name_edit: LineEdit
|
||||||
var size_option: OptionButton
|
var size_option: OptionButton
|
||||||
var weather_option: OptionButton
|
var weather_option: OptionButton
|
||||||
|
var mode_option: OptionButton
|
||||||
|
|
||||||
var _sizes: Array[float] = [512.0, 1024.0, 2048.0]
|
var _sizes: Array[float] = [512.0, 1024.0, 2048.0]
|
||||||
var _size_labels := ["Klein — 512 × 512 m", "Mittel — 1024 × 1024 m", "Groß — 2048 × 2048 m"]
|
var _size_labels := ["Klein — 512 × 512 m", "Mittel — 1024 × 1024 m", "Groß — 2048 × 2048 m"]
|
||||||
|
|
@ -51,6 +52,14 @@ func _init() -> void:
|
||||||
weather_option.tooltip_text = "Licht und Stimmung der Karte (Tag/Abend/Regen/Nacht)"
|
weather_option.tooltip_text = "Licht und Stimmung der Karte (Tag/Abend/Regen/Nacht)"
|
||||||
vbox.add_child(weather_option)
|
vbox.add_child(weather_option)
|
||||||
|
|
||||||
|
vbox.add_child(_make_label("Welchen Spielmodus soll die Map haben?"))
|
||||||
|
mode_option = OptionButton.new()
|
||||||
|
for m in ModusKatalog.MODES:
|
||||||
|
mode_option.add_item(ModusKatalog.MODE_LABELS[m])
|
||||||
|
mode_option.custom_minimum_size = UIScale.v2(0, 40)
|
||||||
|
mode_option.tooltip_text = "Team-Deathmatch: Kills zählen. Checkpoints: Teams nehmen Zonen ein und sammeln Punkte. Lässt sich später im Map-Tab ändern."
|
||||||
|
vbox.add_child(mode_option)
|
||||||
|
|
||||||
var hint := _make_label("Die Map startet leer mit 4 Spawn-Punkten pro Team\n(Blau im Westen, Rot im Osten) — beides lässt sich später ändern.")
|
var hint := _make_label("Die Map startet leer mit 4 Spawn-Punkten pro Team\n(Blau im Westen, Rot im Osten) — beides lässt sich später ändern.")
|
||||||
hint.modulate = Color(1, 1, 1, 0.7)
|
hint.modulate = Color(1, 1, 1, 0.7)
|
||||||
vbox.add_child(hint)
|
vbox.add_child(hint)
|
||||||
|
|
@ -77,5 +86,9 @@ func get_size_m() -> float:
|
||||||
return _sizes[size_option.selected]
|
return _sizes[size_option.selected]
|
||||||
|
|
||||||
|
|
||||||
|
func get_game_mode() -> String:
|
||||||
|
return ModusKatalog.MODES[mode_option.selected]
|
||||||
|
|
||||||
|
|
||||||
func get_weather() -> String:
|
func get_weather() -> String:
|
||||||
return _weathers[weather_option.selected]
|
return _weathers[weather_option.selected]
|
||||||
|
|
|
||||||
|
|
@ -255,6 +255,9 @@ func refresh_values() -> void:
|
||||||
prop_water.set_value_no_signal(
|
prop_water.set_value_no_signal(
|
||||||
float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT)
|
float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT)
|
||||||
# Loadout-Bereich (Registry-Gruppe "map") aus dem doc nachziehen.
|
# Loadout-Bereich (Registry-Gruppe "map") aus dem doc nachziehen.
|
||||||
|
var modus_panel: Node = _sections.get_node_or_null("modus") if _sections != null else null
|
||||||
|
if modus_panel != null:
|
||||||
|
modus_panel.call("refresh_values")
|
||||||
var loadout_panel: Node = _sections.get_node_or_null("loadout") if _sections != null else null
|
var loadout_panel: Node = _sections.get_node_or_null("loadout") if _sections != null else null
|
||||||
if loadout_panel != null:
|
if loadout_panel != null:
|
||||||
loadout_panel.call("refresh_values")
|
loadout_panel.call("refresh_values")
|
||||||
|
|
|
||||||
238
src/ui/panels/modus_panel.gd
Normal file
238
src/ui/panels/modus_panel.gd
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
extends "res://src/ui/panels/editor_panel.gd"
|
||||||
|
## Spielmodus-Bereich im Map-Tab (Karte Checkpoint-Modus, 28.10.): Modus der Map
|
||||||
|
## wählen (Team-Deathmatch / Checkpoints) und Checkpoint-Zonen setzen. Die Zonen
|
||||||
|
## erscheinen als farbige Kreise im 3D-Bild. Gespeichert als optionale Felder
|
||||||
|
## doc["game_mode"] und doc["checkpoints"] (Regeln: src/svmap/modus_katalog.gd).
|
||||||
|
##
|
||||||
|
## Jede Änderung ist ein Undo-Schritt (History-Blöcke game_mode + checkpoints;
|
||||||
|
## fehlt der Block, gilt TDM bzw. keine Zonen).
|
||||||
|
|
||||||
|
var mode_option: OptionButton
|
||||||
|
var cp_box: VBoxContainer
|
||||||
|
var rows_box: VBoxContainer
|
||||||
|
var add_button: Button
|
||||||
|
var hint_label: Label
|
||||||
|
|
||||||
|
var _loading := false
|
||||||
|
|
||||||
|
|
||||||
|
static func spec() -> Dictionary:
|
||||||
|
return {"titel": "Spielmodus", "gruppe": "map", "order": 22}
|
||||||
|
|
||||||
|
|
||||||
|
func panel_title() -> String:
|
||||||
|
return "Spielmodus"
|
||||||
|
|
||||||
|
|
||||||
|
func build() -> void:
|
||||||
|
add_child(_header_label("Spielmodus"))
|
||||||
|
add_child(_muted_label("Legt fest, wie auf dieser Karte gewonnen wird."))
|
||||||
|
|
||||||
|
mode_option = OptionButton.new()
|
||||||
|
mode_option.name = "ModusAuswahl"
|
||||||
|
mode_option.tooltip_text = "Team-Deathmatch: Kills zählen. Checkpoints: Die Teams nehmen Zonen ein und sammeln Punkte, solange sie sie halten."
|
||||||
|
for m in ModusKatalog.MODES:
|
||||||
|
mode_option.add_item(ModusKatalog.MODE_LABELS[m])
|
||||||
|
add_child(mode_option)
|
||||||
|
|
||||||
|
cp_box = VBoxContainer.new()
|
||||||
|
cp_box.add_theme_constant_override("separation", UIScale.px(8))
|
||||||
|
add_child(cp_box)
|
||||||
|
|
||||||
|
cp_box.add_child(_field_label("Checkpoints"))
|
||||||
|
rows_box = VBoxContainer.new()
|
||||||
|
rows_box.add_theme_constant_override("separation", UIScale.px(6))
|
||||||
|
cp_box.add_child(rows_box)
|
||||||
|
|
||||||
|
add_button = Button.new()
|
||||||
|
add_button.name = "CheckpointNeu"
|
||||||
|
add_button.text = "+ Checkpoint in Bildmitte setzen"
|
||||||
|
add_button.tooltip_text = "Setzt eine neue Zone dorthin, wo die Kamera hinschaut. Position und Größe lassen sich darunter ändern."
|
||||||
|
cp_box.add_child(add_button)
|
||||||
|
|
||||||
|
hint_label = _muted_label("Mindestens ein Checkpoint nötig. Spieler spawnen zusätzlich an Zonen ihres Teams.")
|
||||||
|
cp_box.add_child(hint_label)
|
||||||
|
|
||||||
|
mode_option.item_selected.connect(func(i: int) -> void:
|
||||||
|
if not _loading:
|
||||||
|
set_mode(ModusKatalog.MODES[i]))
|
||||||
|
add_button.pressed.connect(func() -> void: add_checkpoint_at_view())
|
||||||
|
refresh_values()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Ändern ------
|
||||||
|
|
||||||
|
func set_mode(mode: String) -> void:
|
||||||
|
if main.doc.is_empty() or not ModusKatalog.MODES.has(mode) or ModusKatalog.mode_of(main.doc) == mode:
|
||||||
|
return
|
||||||
|
var before := _snap()
|
||||||
|
if mode == ModusKatalog.MODE_TDM:
|
||||||
|
main.doc.erase("game_mode")
|
||||||
|
else:
|
||||||
|
main.doc["game_mode"] = mode
|
||||||
|
if ModusKatalog.checkpoints(main.doc).is_empty():
|
||||||
|
main.doc["checkpoints"] = ModusKatalog.default_checkpoints(main.doc)
|
||||||
|
_commit(before, "Spielmodus: %s" % ModusKatalog.MODE_LABELS[mode])
|
||||||
|
|
||||||
|
|
||||||
|
func add_checkpoint_at_view() -> void:
|
||||||
|
if main.doc.is_empty():
|
||||||
|
return
|
||||||
|
var at := Vector3.ZERO
|
||||||
|
if main.camera != null and "target" in main.camera:
|
||||||
|
at = main.camera.target
|
||||||
|
var half := _half()
|
||||||
|
add_checkpoint(clampf(at.x, -half.x, half.x), clampf(at.z, -half.y, half.y))
|
||||||
|
|
||||||
|
|
||||||
|
func add_checkpoint(x: float, z: float) -> String:
|
||||||
|
var before := _snap()
|
||||||
|
var cp := ModusKatalog.make_checkpoint(main.doc, x, z)
|
||||||
|
var list: Array = ModusKatalog.checkpoints(main.doc).duplicate(true)
|
||||||
|
list.append(cp)
|
||||||
|
main.doc["checkpoints"] = list
|
||||||
|
_commit(before, "Checkpoint %s gesetzt" % cp["id"])
|
||||||
|
return str(cp["id"])
|
||||||
|
|
||||||
|
|
||||||
|
func remove_checkpoint(id: String) -> void:
|
||||||
|
var before := _snap()
|
||||||
|
var list: Array = []
|
||||||
|
for c in ModusKatalog.checkpoints(main.doc):
|
||||||
|
if str(c.get("id", "")) != id:
|
||||||
|
list.append((c as Dictionary).duplicate(true))
|
||||||
|
if list.size() == ModusKatalog.checkpoints(main.doc).size():
|
||||||
|
return
|
||||||
|
if list.is_empty():
|
||||||
|
main.doc.erase("checkpoints")
|
||||||
|
else:
|
||||||
|
main.doc["checkpoints"] = list
|
||||||
|
_commit(before, "Checkpoint %s gelöscht" % id)
|
||||||
|
|
||||||
|
|
||||||
|
## Name / Position / Radius eines Checkpoints ändern (nur übergebene Felder).
|
||||||
|
func edit_checkpoint(id: String, fields: Dictionary) -> void:
|
||||||
|
var before := _snap()
|
||||||
|
var list: Array = ModusKatalog.checkpoints(main.doc).duplicate(true)
|
||||||
|
for c in list:
|
||||||
|
if str(c.get("id", "")) != id:
|
||||||
|
continue
|
||||||
|
if fields.has("label"):
|
||||||
|
c["label"] = str(fields["label"]).strip_edges().substr(0, 32)
|
||||||
|
if fields.has("radius_m"):
|
||||||
|
c["radius_m"] = clampf(float(fields["radius_m"]), ModusKatalog.MIN_RADIUS, ModusKatalog.MAX_RADIUS)
|
||||||
|
if fields.has("x") or fields.has("z"):
|
||||||
|
var half := _half()
|
||||||
|
var p: Array = c["pos"]
|
||||||
|
var nx := clampf(float(fields.get("x", p[0])), -half.x, half.x)
|
||||||
|
var nz := clampf(float(fields.get("z", p[2])), -half.y, half.y)
|
||||||
|
c["pos"] = [nx, SvmapDocument.terrain_height_at(main.doc, nx, nz), nz]
|
||||||
|
main.doc["checkpoints"] = list
|
||||||
|
_commit(before, "Checkpoint %s geändert" % id, "cp_%s" % id if fields.has("x") or fields.has("z") or fields.has("radius_m") else "")
|
||||||
|
|
||||||
|
|
||||||
|
func _half() -> Vector2:
|
||||||
|
var t: Dictionary = main.doc.get("terrain", {})
|
||||||
|
return Vector2(float(t.get("size_x_m", 1024.0)) * 0.5, float(t.get("size_z_m", 1024.0)) * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
func _snap() -> Dictionary:
|
||||||
|
var out := {}
|
||||||
|
for key in ["game_mode", "checkpoints"]:
|
||||||
|
var v: Variant = main.doc.get(key, null)
|
||||||
|
out[key] = v.duplicate(true) if v is Array or v is Dictionary else v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
func _commit(before: Dictionary, text: String, merge_tag := "") -> void:
|
||||||
|
var after := _snap()
|
||||||
|
if before == after:
|
||||||
|
return
|
||||||
|
main.history.commit(text, before, after, merge_tag)
|
||||||
|
main.dirty = true
|
||||||
|
main.call("_refresh_file_label")
|
||||||
|
main.call("_refresh_history_buttons")
|
||||||
|
if main.view_builder != null:
|
||||||
|
main.view_builder.call("rebuild_checkpoint_markers", main.doc)
|
||||||
|
refresh_values()
|
||||||
|
main.call("_set_status", "%s." % text)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ Anzeige ------
|
||||||
|
|
||||||
|
## UI aus dem doc auffüllen (Laden, Undo/Redo) — löst keinen Schreibzugriff aus.
|
||||||
|
func refresh_values() -> void:
|
||||||
|
if mode_option == null or main == null or main.doc.is_empty():
|
||||||
|
return
|
||||||
|
_loading = true
|
||||||
|
var checkpoints_mode := ModusKatalog.mode_of(main.doc) == ModusKatalog.MODE_CHECKPOINTS
|
||||||
|
mode_option.select(ModusKatalog.MODES.find(ModusKatalog.mode_of(main.doc)))
|
||||||
|
cp_box.visible = checkpoints_mode
|
||||||
|
for c in rows_box.get_children():
|
||||||
|
rows_box.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
for cp in ModusKatalog.checkpoints(main.doc):
|
||||||
|
if cp is Dictionary:
|
||||||
|
rows_box.add_child(_row(cp))
|
||||||
|
hint_label.visible = ModusKatalog.checkpoints(main.doc).is_empty()
|
||||||
|
_loading = false
|
||||||
|
|
||||||
|
|
||||||
|
func _row(cp: Dictionary) -> Control:
|
||||||
|
var id := str(cp.get("id", ""))
|
||||||
|
var pos: Array = cp.get("pos", [0, 0, 0])
|
||||||
|
var half := _half()
|
||||||
|
var box := VBoxContainer.new()
|
||||||
|
box.name = "CP_%s" % id
|
||||||
|
box.add_theme_constant_override("separation", UIScale.px(4))
|
||||||
|
|
||||||
|
var top := HBoxContainer.new()
|
||||||
|
var idl := _field_label(id)
|
||||||
|
idl.custom_minimum_size = UIScale.v2(32, 0)
|
||||||
|
top.add_child(idl)
|
||||||
|
var name_edit := LineEdit.new()
|
||||||
|
name_edit.name = "Name"
|
||||||
|
name_edit.text = str(cp.get("label", id))
|
||||||
|
name_edit.max_length = 32
|
||||||
|
name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
name_edit.text_submitted.connect(func(t: String) -> void:
|
||||||
|
if not _loading:
|
||||||
|
edit_checkpoint(id, {"label": t}))
|
||||||
|
name_edit.focus_exited.connect(func() -> void:
|
||||||
|
if not _loading and name_edit.text != str(cp.get("label", id)):
|
||||||
|
edit_checkpoint(id, {"label": name_edit.text}))
|
||||||
|
top.add_child(name_edit)
|
||||||
|
var del := Button.new()
|
||||||
|
del.name = "Loeschen"
|
||||||
|
del.text = "✕"
|
||||||
|
del.tooltip_text = "Checkpoint löschen"
|
||||||
|
del.pressed.connect(func() -> void: remove_checkpoint(id))
|
||||||
|
top.add_child(del)
|
||||||
|
box.add_child(top)
|
||||||
|
|
||||||
|
var nums := HBoxContainer.new()
|
||||||
|
nums.add_theme_constant_override("separation", UIScale.px(4))
|
||||||
|
nums.add_child(_spin("X", "x", id, float(pos[0]), -half.x, half.x, 1.0))
|
||||||
|
nums.add_child(_spin("Z", "z", id, float(pos[2]), -half.y, half.y, 1.0))
|
||||||
|
nums.add_child(_spin("Radius", "radius_m", id, float(cp.get("radius_m", ModusKatalog.DEFAULT_RADIUS)),
|
||||||
|
ModusKatalog.MIN_RADIUS, ModusKatalog.MAX_RADIUS, 1.0))
|
||||||
|
box.add_child(nums)
|
||||||
|
return box
|
||||||
|
|
||||||
|
|
||||||
|
func _spin(label: String, field: String, id: String, value: float, mn: float, mx: float, step: float) -> Control:
|
||||||
|
var h := HBoxContainer.new()
|
||||||
|
h.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
h.add_child(_field_label(label))
|
||||||
|
var sb := SpinBox.new()
|
||||||
|
sb.name = field
|
||||||
|
sb.min_value = mn
|
||||||
|
sb.max_value = mx
|
||||||
|
sb.step = step
|
||||||
|
sb.set_value_no_signal(value)
|
||||||
|
sb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
sb.value_changed.connect(func(v: float) -> void:
|
||||||
|
if not _loading:
|
||||||
|
edit_checkpoint(id, {field: v}))
|
||||||
|
h.add_child(sb)
|
||||||
|
return h
|
||||||
1
src/ui/panels/modus_panel.gd.uid
Normal file
1
src/ui/panels/modus_panel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dd6aphlxfvcha
|
||||||
|
|
@ -46,6 +46,8 @@ const PANELS: Array = [
|
||||||
"skript": "res://src/ui/panels/test_start_panel.gd"},
|
"skript": "res://src/ui/panels/test_start_panel.gd"},
|
||||||
{"id": "loadout", "titel": "Loadout", "gruppe": "map", "order": 25,
|
{"id": "loadout", "titel": "Loadout", "gruppe": "map", "order": 25,
|
||||||
"skript": "res://src/ui/panels/loadout_panel.gd"},
|
"skript": "res://src/ui/panels/loadout_panel.gd"},
|
||||||
|
{"id": "modus", "titel": "Spielmodus", "gruppe": "map", "order": 22,
|
||||||
|
"skript": "res://src/ui/panels/modus_panel.gd"},
|
||||||
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "map", "order": 30,
|
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "map", "order": 30,
|
||||||
"skript": "res://src/ui/panels/symmetry_panel.gd"},
|
"skript": "res://src/ui/panels/symmetry_panel.gd"},
|
||||||
# --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) ---
|
# --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) ---
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrf
|
||||||
var hover_index := -1 # Hover-Objekt (schwächste Kontur; U6-Abstufung)
|
var hover_index := -1 # Hover-Objekt (schwächste Kontur; U6-Abstufung)
|
||||||
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
|
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
|
||||||
var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis)
|
var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis)
|
||||||
|
var checkpoint_root: Node3D = null # Checkpoint-Zonen (Spielmodus), Kinder je Zone
|
||||||
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
|
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
|
||||||
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
|
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
|
||||||
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
|
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
|
||||||
|
|
@ -115,6 +116,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
||||||
child.queue_free()
|
child.queue_free()
|
||||||
object_markers = []
|
object_markers = []
|
||||||
spawn_markers = []
|
spawn_markers = []
|
||||||
|
checkpoint_root = null
|
||||||
grid_node = null
|
grid_node = null
|
||||||
_doc_ref = data
|
_doc_ref = data
|
||||||
_base_mat = StandardMaterial3D.new()
|
_base_mat = StandardMaterial3D.new()
|
||||||
|
|
@ -150,6 +152,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
|
||||||
grid_node.visible = terrain.is_empty()
|
grid_node.visible = terrain.is_empty()
|
||||||
_build_objects(data.get("objects", []), manifest)
|
_build_objects(data.get("objects", []), manifest)
|
||||||
_rebuild_spawn_markers(data)
|
_rebuild_spawn_markers(data)
|
||||||
|
rebuild_checkpoint_markers(data)
|
||||||
|
|
||||||
|
|
||||||
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
|
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
|
||||||
|
|
@ -513,6 +516,67 @@ func _rebuild_spawn_markers(data: Dictionary) -> void:
|
||||||
add_child(marker)
|
add_child(marker)
|
||||||
|
|
||||||
|
|
||||||
|
## Checkpoint-Zonen (Spielmodus): gelber Boden-Kreis + durchscheinender Zylinder
|
||||||
|
## + Name. Wird nur im Modus "checkpoints" gezeigt.
|
||||||
|
func rebuild_checkpoint_markers(data: Dictionary) -> void:
|
||||||
|
if checkpoint_root != null and is_instance_valid(checkpoint_root):
|
||||||
|
checkpoint_root.queue_free()
|
||||||
|
checkpoint_root = null
|
||||||
|
if ModusKatalog.mode_of(data) != ModusKatalog.MODE_CHECKPOINTS:
|
||||||
|
return
|
||||||
|
checkpoint_root = Node3D.new()
|
||||||
|
checkpoint_root.name = "Checkpoints"
|
||||||
|
add_child(checkpoint_root)
|
||||||
|
var col := Color(1.0, 0.8, 0.2)
|
||||||
|
for cp in ModusKatalog.checkpoints(data):
|
||||||
|
if not (cp is Dictionary):
|
||||||
|
continue
|
||||||
|
var pos: Array = cp.get("pos", [0, 0, 0])
|
||||||
|
var r := float(cp.get("radius_m", ModusKatalog.DEFAULT_RADIUS))
|
||||||
|
var node := Node3D.new()
|
||||||
|
node.name = "CP_%s" % str(cp.get("id", ""))
|
||||||
|
node.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
|
||||||
|
var mat := StandardMaterial3D.new()
|
||||||
|
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
||||||
|
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
|
||||||
|
mat.albedo_color = Color(col.r, col.g, col.b, 0.18)
|
||||||
|
var cyl := MeshInstance3D.new()
|
||||||
|
var cm := CylinderMesh.new()
|
||||||
|
cm.top_radius = r
|
||||||
|
cm.bottom_radius = r
|
||||||
|
cm.height = 8.0
|
||||||
|
cm.cap_top = false
|
||||||
|
cm.cap_bottom = false
|
||||||
|
cyl.mesh = cm
|
||||||
|
cyl.material_override = mat
|
||||||
|
cyl.position.y = 4.0
|
||||||
|
node.add_child(cyl)
|
||||||
|
var ring := MeshInstance3D.new()
|
||||||
|
var tm := TorusMesh.new()
|
||||||
|
tm.inner_radius = r - 0.2
|
||||||
|
tm.outer_radius = r + 0.2
|
||||||
|
tm.rings = 48
|
||||||
|
tm.ring_segments = 6
|
||||||
|
ring.mesh = tm
|
||||||
|
var rmat := StandardMaterial3D.new()
|
||||||
|
rmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
||||||
|
rmat.albedo_color = col
|
||||||
|
ring.material_override = rmat
|
||||||
|
ring.position.y = 0.15
|
||||||
|
node.add_child(ring)
|
||||||
|
var label := Label3D.new()
|
||||||
|
label.text = "⚑ %s" % str(cp.get("label", cp.get("id", "")))
|
||||||
|
label.position.y = 10.0
|
||||||
|
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
label.modulate = col
|
||||||
|
label.outline_size = 12
|
||||||
|
label.font_size = 160
|
||||||
|
label.pixel_size = 0.01
|
||||||
|
node.add_child(label)
|
||||||
|
checkpoint_root.add_child(node)
|
||||||
|
|
||||||
|
|
||||||
## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius.
|
## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius.
|
||||||
func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D:
|
func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D:
|
||||||
var marker := Node3D.new()
|
var marker := Node3D.new()
|
||||||
|
|
|
||||||
117
tests/test_modus.gd
Normal file
117
tests/test_modus.gd
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Editor-Test (headless): Spielmodus + Checkpoints (V21/V22) — Neu-Dialog,
|
||||||
|
## Katalog, Panel mit Undo/Redo, Speichern/Laden. Ausführen:
|
||||||
|
## godot --headless --path . --script res://tests/test_modus.gd
|
||||||
|
|
||||||
|
var failures := 0
|
||||||
|
var checks := 0
|
||||||
|
var main: Control
|
||||||
|
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
_run_all.call_deferred()
|
||||||
|
|
||||||
|
|
||||||
|
func _check(cond: bool, label: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if cond:
|
||||||
|
print(" ok: %s" % label)
|
||||||
|
else:
|
||||||
|
failures += 1
|
||||||
|
print(" FAIL: %s" % label)
|
||||||
|
|
||||||
|
|
||||||
|
func _signed(d: Dictionary) -> Dictionary:
|
||||||
|
var c := d.duplicate(true)
|
||||||
|
SvmapIO.sign_map(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
func _run_all() -> void:
|
||||||
|
print("Testgruppe: Katalog + Neu-Map")
|
||||||
|
var tdm := SvmapDocument.create_new("T", 512.0, "mittag")
|
||||||
|
_check(not tdm.has("game_mode") and not tdm.has("checkpoints"), "TDM-Map ohne Zusatzfelder (altes Format)")
|
||||||
|
_check(SvmapIO.validate_map(_signed(tdm)).is_empty(), "TDM-Map gültig")
|
||||||
|
var cp := SvmapDocument.create_new("C", 512.0, "mittag", "checkpoints")
|
||||||
|
_check(cp["game_mode"] == "checkpoints" and (cp["checkpoints"] as Array).size() == 3, "Checkpoint-Map mit 3 Start-Zonen")
|
||||||
|
_check(SvmapIO.validate_map(_signed(cp)).is_empty(), "Checkpoint-Map gültig: " + str(SvmapIO.validate_map(_signed(cp))))
|
||||||
|
var bad := cp.duplicate(true)
|
||||||
|
bad["checkpoints"] = []
|
||||||
|
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "Modus ohne Zonen ungültig (V21)")
|
||||||
|
bad = cp.duplicate(true)
|
||||||
|
bad["checkpoints"][0]["pos"] = [9999.0, 0.0, 0.0]
|
||||||
|
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "Zone außerhalb ungültig (V22)")
|
||||||
|
bad = cp.duplicate(true)
|
||||||
|
bad["checkpoints"][1]["id"] = "C1"
|
||||||
|
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "doppelte ID ungültig (V22)")
|
||||||
|
bad = cp.duplicate(true)
|
||||||
|
bad["game_mode"] = "ctf"
|
||||||
|
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "unbekannter Modus ungültig (V21)")
|
||||||
|
|
||||||
|
print("Testgruppe: Neu-Dialog")
|
||||||
|
var dlg: ConfirmationDialog = preload("res://src/ui/new_map_dialog.gd").new()
|
||||||
|
root.add_child(dlg)
|
||||||
|
_check(dlg.get_game_mode() == "tdm", "Dialog-Default TDM")
|
||||||
|
dlg.mode_option.select(1)
|
||||||
|
_check(dlg.get_game_mode() == "checkpoints", "Dialog liefert Checkpoints")
|
||||||
|
|
||||||
|
print("Testgruppe: Panel")
|
||||||
|
var scene: PackedScene = load("res://scenes/main.tscn")
|
||||||
|
main = scene.instantiate()
|
||||||
|
root.add_child(main)
|
||||||
|
await process_frame
|
||||||
|
await process_frame
|
||||||
|
main.doc = SvmapDocument.create_new("ModusTest", 512.0, "mittag")
|
||||||
|
main.history.doc = main.doc
|
||||||
|
main.call("_refresh_all")
|
||||||
|
var panel: Node = main.map_mod.sections_host().get_node_or_null("modus")
|
||||||
|
_check(panel != null, "Modus-Panel hängt im Map-Tab")
|
||||||
|
if panel == null:
|
||||||
|
quit(1)
|
||||||
|
return
|
||||||
|
_check(not panel.cp_box.visible, "TDM: Checkpoint-Bereich verborgen")
|
||||||
|
panel.set_mode("checkpoints")
|
||||||
|
_check(main.doc["game_mode"] == "checkpoints" and main.doc["checkpoints"].size() == 3, "Wechsel auf Checkpoints setzt 3 Zonen")
|
||||||
|
_check(panel.cp_box.visible and panel.rows_box.get_child_count() == 3, "3 Zeilen im Panel")
|
||||||
|
_check(main.view_builder.checkpoint_root != null and main.view_builder.checkpoint_root.get_child_count() == 3, "3 Zonen im 3D-Bild")
|
||||||
|
var id: String = panel.add_checkpoint(50.0, -30.0)
|
||||||
|
_check(id == "C4" and main.doc["checkpoints"].size() == 4, "neue Zone C4")
|
||||||
|
panel.edit_checkpoint("C4", {"radius_m": 25.0})
|
||||||
|
panel.edit_checkpoint("C4", {"x": 80.0, "z": 10.0, "label": "Turm"})
|
||||||
|
var c4: Dictionary = main.doc["checkpoints"][3]
|
||||||
|
_check(c4["radius_m"] == 25.0 and c4["pos"][0] == 80.0 and c4["pos"][2] == 10.0, "Radius + Position geändert")
|
||||||
|
panel.edit_checkpoint("C4", {"radius_m": 500.0})
|
||||||
|
_check(main.doc["checkpoints"][3]["radius_m"] == ModusKatalog.MAX_RADIUS, "Radius wird begrenzt")
|
||||||
|
_check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "Map gültig nach Änderungen")
|
||||||
|
panel.remove_checkpoint("C4")
|
||||||
|
_check(main.doc["checkpoints"].size() == 3, "Zone gelöscht")
|
||||||
|
|
||||||
|
main.history.undo()
|
||||||
|
main.call("_refresh_all")
|
||||||
|
_check(main.doc["checkpoints"].size() == 4, "Undo: Zone zurück")
|
||||||
|
while main.history.can_undo():
|
||||||
|
main.history.undo()
|
||||||
|
main.call("_refresh_all")
|
||||||
|
_check(not main.doc.has("game_mode") and not main.doc.has("checkpoints") and not panel.cp_box.visible, "Undo bis Start: Felder weg, UI nachgezogen")
|
||||||
|
main.history.redo()
|
||||||
|
main.call("_refresh_all")
|
||||||
|
_check(main.doc.get("game_mode", "") == "checkpoints", "Redo: Modus wieder da")
|
||||||
|
|
||||||
|
panel.set_mode("tdm")
|
||||||
|
_check(not main.doc.has("game_mode"), "Zurück auf TDM entfernt game_mode")
|
||||||
|
_check(main.view_builder.checkpoint_root == null, "TDM: keine Zonen im Bild")
|
||||||
|
|
||||||
|
panel.set_mode("checkpoints")
|
||||||
|
var path := "user://modus_test.svmap"
|
||||||
|
var doc2 := _signed(main.doc)
|
||||||
|
var res := SvmapIO.save_to_file(path, doc2)
|
||||||
|
_check(res["ok"], "Speichern ok: " + str(res["errors"]))
|
||||||
|
var back := SvmapIO.load_from_file(path)
|
||||||
|
_check(back["ok"] and back["data"]["game_mode"] == "checkpoints" and back["data"]["checkpoints"].size() >= 3, "Laden: Modus + Zonen erhalten")
|
||||||
|
|
||||||
|
if failures == 0:
|
||||||
|
print("ALL OK: %d checks" % checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FAILED: %d/%d checks" % [failures, checks])
|
||||||
|
quit(1)
|
||||||
Loading…
Reference in a new issue