From f42e133baed71ee75044e8001cd28e2e2fc6f9bf Mon Sep 17 00:00:00 2001 From: slyphax Date: Wed, 28 Oct 2026 09:21:13 +0100 Subject: [PATCH] Spielmodus-Auswahl beim Neu-Erstellen + Checkpoint-Zonen im Map-Tab (V21/V22, Undo, 3D-Anzeige) Co-Authored-By: Claude Sonnet 5.5 --- src/svmap/map_document.gd | 7 +- src/svmap/modus_katalog.gd | 111 ++++++++++++++ src/svmap/modus_katalog.gd.uid | 1 + src/svmap/svmap_io.gd | 2 + src/ui/main.gd | 7 +- src/ui/new_map_dialog.gd | 13 ++ src/ui/panels/map_panel.gd | 3 + src/ui/panels/modus_panel.gd | 238 +++++++++++++++++++++++++++++++ src/ui/panels/modus_panel.gd.uid | 1 + src/ui/panels/registry.gd | 2 + src/viewport/map_view_builder.gd | 64 +++++++++ tests/test_modus.gd | 117 +++++++++++++++ 12 files changed, 562 insertions(+), 4 deletions(-) create mode 100644 src/svmap/modus_katalog.gd create mode 100644 src/svmap/modus_katalog.gd.uid create mode 100644 src/ui/panels/modus_panel.gd create mode 100644 src/ui/panels/modus_panel.gd.uid create mode 100644 tests/test_modus.gd diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 30a619d..9da044e 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -19,7 +19,7 @@ const DEFAULT_AUTHOR := "ScopedVR Creator" ## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte ## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen- ## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern. -static func create_new(map_name: String, size_m: float, weather: String) -> Dictionary: +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 res := DEFAULT_RESOLUTION var zero_heights := PackedByteArray() @@ -59,6 +59,11 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict }, "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 diff --git a/src/svmap/modus_katalog.gd b/src/svmap/modus_katalog.gd new file mode 100644 index 0000000..2144305 --- /dev/null +++ b/src/svmap/modus_katalog.gd @@ -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 diff --git a/src/svmap/modus_katalog.gd.uid b/src/svmap/modus_katalog.gd.uid new file mode 100644 index 0000000..3234abe --- /dev/null +++ b/src/svmap/modus_katalog.gd.uid @@ -0,0 +1 @@ +uid://cprm4ke4dpj58 diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index 7d032e3..7e2b362 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -222,6 +222,8 @@ static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> Packe str(weather_sky), ", ".join(ids)]) # V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog. 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 diff --git a/src/ui/main.gd b/src/ui/main.gd index 166fb30..80d6644 100644 --- a/src/ui/main.gd +++ b/src/ui/main.gd @@ -1289,15 +1289,16 @@ func _playtest_split_lagen_zurueck() -> 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 = "" dirty = false history.begin(doc) _clear_edit_state() _refresh_all() _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!" % [ - new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()]) + _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(), + ModusKatalog.MODE_LABELS[new_dialog.get_game_mode()]]) func _on_open_selected(path: String) -> void: diff --git a/src/ui/new_map_dialog.gd b/src/ui/new_map_dialog.gd index e374ac5..419f54f 100644 --- a/src/ui/new_map_dialog.gd +++ b/src/ui/new_map_dialog.gd @@ -7,6 +7,7 @@ const UIScale := preload("res://src/ui/ui_scale.gd") # Karte t_297221b5 var name_edit: LineEdit var size_option: OptionButton var weather_option: OptionButton +var mode_option: OptionButton var _sizes: Array[float] = [512.0, 1024.0, 2048.0] var _size_labels := ["Klein — 512 × 512 m", "Mittel — 1024 × 1024 m", "Groß — 2048 × 2048 m"] @@ -51,6 +52,14 @@ func _init() -> void: weather_option.tooltip_text = "Licht und Stimmung der Karte (Tag/Abend/Regen/Nacht)" 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.") hint.modulate = Color(1, 1, 1, 0.7) vbox.add_child(hint) @@ -77,5 +86,9 @@ func get_size_m() -> float: return _sizes[size_option.selected] +func get_game_mode() -> String: + return ModusKatalog.MODES[mode_option.selected] + + func get_weather() -> String: return _weathers[weather_option.selected] diff --git a/src/ui/panels/map_panel.gd b/src/ui/panels/map_panel.gd index c2a076e..081cee4 100644 --- a/src/ui/panels/map_panel.gd +++ b/src/ui/panels/map_panel.gd @@ -255,6 +255,9 @@ func refresh_values() -> void: prop_water.set_value_no_signal( float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT) # 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 if loadout_panel != null: loadout_panel.call("refresh_values") diff --git a/src/ui/panels/modus_panel.gd b/src/ui/panels/modus_panel.gd new file mode 100644 index 0000000..2544e4d --- /dev/null +++ b/src/ui/panels/modus_panel.gd @@ -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 diff --git a/src/ui/panels/modus_panel.gd.uid b/src/ui/panels/modus_panel.gd.uid new file mode 100644 index 0000000..ad8aa37 --- /dev/null +++ b/src/ui/panels/modus_panel.gd.uid @@ -0,0 +1 @@ +uid://dd6aphlxfvcha diff --git a/src/ui/panels/registry.gd b/src/ui/panels/registry.gd index 4750a82..bb03579 100644 --- a/src/ui/panels/registry.gd +++ b/src/ui/panels/registry.gd @@ -46,6 +46,8 @@ const PANELS: Array = [ "skript": "res://src/ui/panels/test_start_panel.gd"}, {"id": "loadout", "titel": "Loadout", "gruppe": "map", "order": 25, "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, "skript": "res://src/ui/panels/symmetry_panel.gd"}, # --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) --- diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index cee8f99..55dc76b 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -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 object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects) 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 terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant) 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() object_markers = [] spawn_markers = [] + checkpoint_root = null grid_node = null _doc_ref = data _base_mat = StandardMaterial3D.new() @@ -150,6 +152,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: grid_node.visible = terrain.is_empty() _build_objects(data.get("objects", []), manifest) _rebuild_spawn_markers(data) + rebuild_checkpoint_markers(data) ## 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) +## 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. func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D: var marker := Node3D.new() diff --git a/tests/test_modus.gd b/tests/test_modus.gd new file mode 100644 index 0000000..d2fef98 --- /dev/null +++ b/tests/test_modus.gd @@ -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)