diff --git a/src/editor/spawn_ops.gd b/src/editor/spawn_ops.gd index 1bfe4fc..de420d4 100644 --- a/src/editor/spawn_ops.gd +++ b/src/editor/spawn_ops.gd @@ -25,6 +25,39 @@ const RADIUS_KEY := "spawn_radius_m" # Unter design_notes (Loader-frei, siehe ob const TEAM_LABELS := ["Team Blau", "Team Rot"] const TEAM_PREFIXES := ["A", "B"] +## Welcher Spawn-Satz gerade bearbeitet wird (reiner Editor-Zustand, nicht im Dokument): +## "base" = doc.spawn_zones (Standard für alle Modi), sonst ein Modus aus +## doc.mode_spawns (eigene Spawns dieses Modus, ModusKatalog). Deathmatch kennt nur +## den Schlüssel "all" — beide Team-Werkzeuge schreiben dorthin. +static var edit_set: String = ModusKatalog.SPAWN_SET_BASE +const SET_ID_PREFIX := {"tdm": "T", "conquest": "Q", "koth": "K", "dm": "D"} + + +static func _team_key(team: int) -> String: + return "all" if edit_set == ModusKatalog.MODE_DM else str(clampi(team, 0, 1)) + + +## Zonen-Block des bearbeiteten Satzes: {"0":{points}, "1":{points}} bzw. {"all":{points}}. +## create=true legt fehlende Blöcke an (nur für Schreibzugriffe). +static func zones_of(doc: Dictionary, create := false) -> Dictionary: + if edit_set == ModusKatalog.SPAWN_SET_BASE: + var z = doc.get("spawn_zones", {}) + if not (z is Dictionary): + z = {} + if create: + doc["spawn_zones"] = z + return z + var ms = doc.get("mode_spawns", {}) + if not (ms is Dictionary): + ms = {} + var set_v = (ms as Dictionary).get(edit_set, {}) + if not (set_v is Dictionary): + set_v = {} + if create: + doc["mode_spawns"] = ms + (ms as Dictionary)[edit_set] = set_v + return set_v + static func team_label(team: int) -> String: return TEAM_LABELS[clampi(team, 0, 1)] @@ -36,11 +69,10 @@ static func team_prefix(team: int) -> String: ## Der Zone-Block eines Teams ("0"/"1") — immer ein Dictionary (leer, wenn fehlt). static func zone_dict(doc: Dictionary, team: int) -> Dictionary: - var zones = doc.get("spawn_zones", {}) - if zones is Dictionary: - var zone = zones.get(str(team), {}) - if zone is Dictionary: - return zone + var zones = zones_of(doc) + var zone = zones.get(_team_key(team), {}) + if zone is Dictionary: + return zone return {} @@ -58,8 +90,13 @@ static func count_points(doc: Dictionary, team: int) -> int: ## (Team-Prefixe trennen die Teams, doppelt vergebene Nummern nie). static func next_id(doc: Dictionary, team: int) -> String: var used := {} - var zones = doc.get("spawn_zones", {}) - if zones is Dictionary: + var all_sets: Array = [doc.get("spawn_zones", {})] + var ms = doc.get("mode_spawns", {}) + if ms is Dictionary: + all_sets.append_array((ms as Dictionary).values()) + for zones in all_sets: + if not (zones is Dictionary): + continue for key in zones.keys(): var zone = zones[key] if not (zone is Dictionary): @@ -71,6 +108,8 @@ static func next_id(doc: Dictionary, team: int) -> String: if p is Dictionary: used[str(p.get("id", ""))] = true var prefix := team_prefix(team) + if edit_set != ModusKatalog.SPAWN_SET_BASE: + prefix = str(SET_ID_PREFIX.get(edit_set, "M")) + ("" if edit_set == ModusKatalog.MODE_DM else prefix) var i := 1 while used.has("%s%d" % [prefix, i]): i += 1 @@ -107,6 +146,8 @@ static func set_radius(doc: Dictionary, pid: String, m: float) -> void: ## Alt-Radien nach Undo herumgeistern. static func snapshot_blocks(hist: ActionHistory, doc: Dictionary) -> Dictionary: var out := hist.snapshot(["spawn_zones"]) + var ms: Variant = doc.get("mode_spawns", null) # fehlend = null → Undo löscht den Block wieder + out["mode_spawns"] = ms.duplicate(true) if ms is Dictionary else null out["design_notes"] = (doc.get("design_notes", {}) as Dictionary).duplicate(true) return out @@ -115,10 +156,8 @@ static func snapshot_blocks(hist: ActionHistory, doc: Dictionary) -> Dictionary: ## dem Boden; der Loader prüft nur Grenzen, aber schwebende Spawns verwirren). ## Rückgabe: der neue Punkt (id + pos). static func add_zone(doc: Dictionary, team: int, x: float, z: float) -> Dictionary: - var zones: Dictionary = doc.get("spawn_zones", {}) - if not (zones is Dictionary): - zones = {} - var team_key := str(team) + var zones: Dictionary = zones_of(doc, true) + var team_key := _team_key(team) var zone = zones.get(team_key, {}) if not (zone is Dictionary): zone = {} diff --git a/src/svmap/map_document.gd b/src/svmap/map_document.gd index 9da044e..f8b427e 100644 --- a/src/svmap/map_document.gd +++ b/src/svmap/map_document.gd @@ -59,11 +59,13 @@ static func create_new(map_name: String, size_m: float, weather: String, game_mo }, "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: + # Spielmodus (28.10.): nur ein abweichender Modus wird geschrieben; Conquest- + # und KotH-Maps starten mit drei Zonen/Hills zwischen den Basen. + game_mode = ModusKatalog.normalize(game_mode) + if game_mode != ModusKatalog.MODE_TDM and ModusKatalog.MODES.has(game_mode): data["game_mode"] = game_mode - data["checkpoints"] = ModusKatalog.default_checkpoints(data) + if ModusKatalog.has_zones(game_mode): + data[ModusKatalog.ZONE_KEYS[game_mode]] = ModusKatalog.default_zones(data, game_mode) return data diff --git a/src/svmap/modus_katalog.gd b/src/svmap/modus_katalog.gd index 2144305..82ce886 100644 --- a/src/svmap/modus_katalog.gd +++ b/src/svmap/modus_katalog.gd @@ -1,100 +1,249 @@ 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). +## Spielmodus, Zonen/Hills und Modus-Spawns der Map (28.10.). +## doc["game_mode"] ("tdm" | "conquest" | "koth" | "dm", fehlt = tdm), +## doc["conquest_zones"] und doc["hills"] [{id, label, pos:[x,y,z], radius_m}], +## doc["mode_spawns"] {modus: {"0"/"1"/"all": {points:[{id,pos}]}}} — Regeln 1:1 +## wie SvmapLoader im Match-Server (V21–V24, docs/map_format.md). +## Alt-Namen: game_mode "checkpoints" und Feld "checkpoints" (= Conquest) werden +## beim Laden umbenannt (migrate). const MODE_TDM := "tdm" -const MODE_CHECKPOINTS := "checkpoints" -const MODES: Array[String] = [MODE_TDM, MODE_CHECKPOINTS] +const MODE_CONQUEST := "conquest" +const MODE_KOTH := "koth" +const MODE_DM := "dm" +const MODES: Array[String] = [MODE_TDM, MODE_CONQUEST, MODE_KOTH, MODE_DM] const MODE_LABELS := { MODE_TDM: "Team-Deathmatch", - MODE_CHECKPOINTS: "Checkpoints einnehmen", + MODE_CONQUEST: "Conquest (mehrere Zonen einnehmen)", + MODE_KOTH: "King of the Hill (ein wandernder Hill)", + MODE_DM: "Deathmatch (alle gegen alle)", } +const MODE_SHORT := { + MODE_TDM: "Team-Deathmatch", + MODE_CONQUEST: "Conquest", + MODE_KOTH: "King of the Hill", + MODE_DM: "Deathmatch", +} +const MODE_ALIASES := {"checkpoints": MODE_CONQUEST} +## Feld mit den Zonen je Modus (nur Conquest und KotH haben welche). +const ZONE_KEYS := {MODE_CONQUEST: "conquest_zones", MODE_KOTH: "hills"} +const ZONE_NAMES := {MODE_CONQUEST: "Zone", MODE_KOTH: "Hill"} +const ZONE_PREFIX := {MODE_CONQUEST: "C", MODE_KOTH: "H"} const DEFAULT_RADIUS := 15.0 const MIN_RADIUS := 3.0 const MAX_RADIUS := 60.0 +const SPAWN_SET_BASE := "base" + + +static func normalize(m: String) -> String: + return str(MODE_ALIASES.get(m, m)) static func mode_of(doc: Dictionary) -> String: - var m := str(doc.get("game_mode", MODE_TDM)) + var m := normalize(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", []) +static func has_zones(mode: String) -> bool: + return ZONE_KEYS.has(mode) + + +## Zonen/Hills des Modus (leer für Modi ohne Zonen). +static func zones(doc: Dictionary, mode: String) -> Array: + if not ZONE_KEYS.has(mode): + return [] + var c: Variant = doc.get(ZONE_KEYS[mode], []) return c if c is Array else [] -## Nächste freie ID "C1", "C2", … -static func next_id(doc: Dictionary) -> String: +## Nächste freie ID "C1", "C2", … (Conquest) bzw. "H1", … (Hills). +static func next_id(doc: Dictionary, mode: String) -> String: var used := {} - for c in checkpoints(doc): + for c in zones(doc, mode): if c is Dictionary: used[str(c.get("id", ""))] = true + var prefix: String = ZONE_PREFIX.get(mode, "Z") var i := 1 - while used.has("C%d" % i): + while used.has("%s%d" % [prefix, i]): i += 1 - return "C%d" % i + return "%s%d" % [prefix, 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) +## Neue Zone auf dem Boden (y = Terrain-Höhe). +static func make_zone(doc: Dictionary, mode: String, x: float, z: float, label := "") -> Dictionary: + var id := next_id(doc, mode) return { "id": id, - "label": label if label != "" else "Zone %s" % id.substr(1), + "label": label if label != "" else "%s %s" % [ZONE_NAMES.get(mode, "Zone"), 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: +## Drei Start-Zonen zwischen den Basen (West–Ost-Linie) für neue Maps des Modus. +static func default_zones(doc: Dictionary, mode: String) -> Array: var size := float(doc.get("terrain", {}).get("size_x_m", 1024.0)) var out: Array = [] - var names := ["Westposten", "Mitte", "Ostposten"] + var names := ["Westposten", "Mitte", "Ostposten"] if mode == MODE_CONQUEST else ["Westhang", "Talmitte", "Osthang"] 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) + var probe := {ZONE_KEYS[mode]: out, "terrain": doc.get("terrain", {})} + out.append(make_zone(probe, mode, xs[i] * size, 0.0, names[i])) return out +## Alte Namen auf die heutigen umstellen (nach erfolgreichem Laden). +static func migrate(doc: Dictionary) -> void: + if str(doc.get("game_mode", "")) == "checkpoints": + doc["game_mode"] = MODE_CONQUEST + if doc.has("checkpoints"): + if not doc.has("conquest_zones"): + doc["conquest_zones"] = doc["checkpoints"] + doc.erase("checkpoints") + + +# ----------------------------------------------------------- Modus-Spawns -- + +## Team-Schlüssel eines Spawn-Satzes: Deathmatch hat nur "all". +static func spawn_keys(mode: String) -> Array: + return ["all"] if mode == MODE_DM else ["0", "1"] + + +static func spawn_set(doc: Dictionary, mode: String) -> Dictionary: + var ms: Variant = doc.get("mode_spawns", {}) + if ms is Dictionary and (ms as Dictionary).get(mode, null) is Dictionary: + return (ms as Dictionary)[mode] + return {} + + +static func has_own_spawns(doc: Dictionary, mode: String) -> bool: + return not spawn_set(doc, mode).is_empty() + + +## Eigene Spawns für den Modus anlegen: Kopie der Basis-Spawns (Deathmatch: beide +## Teams in einem Topf). IDs bekommen den Modus-Präfix, damit sie map-weit eindeutig sind. +static func create_spawn_set(doc: Dictionary, mode: String) -> void: + var ms: Dictionary = doc.get("mode_spawns", {}) if doc.get("mode_spawns", {}) is Dictionary else {} + var base: Dictionary = doc.get("spawn_zones", {}) + var out := {} + var tag := mode.to_upper().substr(0, 1) if mode != MODE_DM else "D" + var n := 0 + for key in spawn_keys(mode): + var pts: Array = [] + var src_teams: Array = ["0", "1"] if mode == MODE_DM else [key] + for t in src_teams: + var z: Variant = base.get(t, {}) + if not (z is Dictionary): + continue + for p in (z as Dictionary).get("points", []): + if p is Dictionary: + n += 1 + pts.append({"id": "%s%s%d" % [tag, "" if mode == MODE_DM else ("A" if t == "0" else "B"), n], + "pos": (p.get("pos", [0, 0, 0]) as Array).duplicate()}) + out[key] = {"points": pts} + ms[mode] = out + doc["mode_spawns"] = ms + + +static func remove_spawn_set(doc: Dictionary, mode: String) -> void: + var ms: Variant = doc.get("mode_spawns", null) + if not (ms is Dictionary): + return + (ms as Dictionary).erase(mode) + if (ms as Dictionary).is_empty(): + doc.erase("mode_spawns") + + +# ------------------------------------------------------------- Validierung -- + 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", []) + var raw = data.get("game_mode", "") + var mode := normalize(str(raw)) if raw != null else "" + if mode != "" and not MODES.has(mode): + errors.append("V21: game_mode '%s' unbekannt (%s)." % [mode, ", ".join(MODES)]) + var cz = data.get("conquest_zones", data.get("checkpoints", [])) + errors.append_array(_validate_zone_list(cz, "V22", "Conquest-Zone", bounds)) + if mode == MODE_CONQUEST and cz is Array and (cz as Array).is_empty(): + errors.append("V21: game_mode 'conquest' braucht mindestens eine Zone.") + var hl = data.get("hills", []) + errors.append_array(_validate_zone_list(hl, "V23", "Hill", bounds)) + if mode == MODE_KOTH and hl is Array and (hl as Array).is_empty(): + errors.append("V21: game_mode 'koth' braucht mindestens einen Hill.") + errors.append_array(_validate_mode_spawns(data.get("mode_spawns", null), bounds)) + return errors + + +static func _validate_zone_list(cps, code: String, what: String, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() if not (cps is Array): - errors.append("V22: checkpoints muss ein Array sein.") + errors.append("%s: %s-Liste muss ein Array sein." % [code, what]) 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) + errors.append("%s: %s %d ist kein Objekt." % [code, what, 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) + errors.append("%s: %s %d: id fehlt/leer." % [code, what, i]) elif seen.has(cid): - errors.append("V22: Checkpoint-ID '%s' ist doppelt." % cid) + errors.append("%s: %s-ID '%s' ist doppelt." % [code, what, 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)) + errors.append("%s: %s '%s': pos muss [x, y, z] mit endlichen Werten sein." % [code, what, str(cid)]) elif not _inside(pos, bounds): - errors.append("V22: Checkpoint '%s' liegt außerhalb der Match-Grenzen." % str(cid)) + errors.append("%s: %s '%s' liegt außerhalb der Match-Grenzen." % [code, what, 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)) + errors.append("%s: %s '%s': radius_m muss 3–60 sein." % [code, what, str(cid)]) + return errors + + +static func _validate_mode_spawns(ms, bounds: Dictionary) -> PackedStringArray: + var errors := PackedStringArray() + if ms == null: + return errors + if not (ms is Dictionary): + return PackedStringArray(["V24: mode_spawns muss ein Objekt sein."]) + for mode in (ms as Dictionary).keys(): + if not MODES.has(str(mode)): + errors.append("V24: mode_spawns: Modus '%s' unbekannt." % str(mode)) + continue + var m = ms[mode] + if not (m is Dictionary): + errors.append("V24: mode_spawns.%s muss ein Objekt sein." % str(mode)) + continue + var seen := {} + for key in (m as Dictionary).keys(): + var allowed := spawn_keys(str(mode)) + if not allowed.has(str(key)): + errors.append("V24: mode_spawns.%s: Schlüssel '%s' nicht erlaubt (%s)." % [str(mode), str(key), ", ".join(allowed)]) + continue + var pts = m[key].get("points", []) if m[key] is Dictionary else null + if not (pts is Array) or (pts as Array).is_empty(): + errors.append("V24: mode_spawns.%s.%s braucht mindestens einen Punkt." % [str(mode), str(key)]) + continue + for i in (pts as Array).size(): + var p = pts[i] + var pid = p.get("id", "") if p is Dictionary else "" + if not (pid is String) or (pid as String).strip_edges().is_empty(): + errors.append("V24: mode_spawns.%s.%s Punkt %d: id fehlt/leer." % [str(mode), str(key), i]) + continue + if seen.has(pid): + errors.append("V24: Spawn-ID '%s' ist doppelt (Modus %s)." % [pid, str(mode)]) + seen[pid] = true + var pos = p.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("V24: Spawn '%s': pos muss [x, y, z] mit endlichen Werten sein." % pid) + elif not _inside(pos, bounds): + errors.append("V24: Spawn '%s' liegt außerhalb der Match-Grenzen." % pid) return errors diff --git a/src/svmap/svmap_io.gd b/src/svmap/svmap_io.gd index 7e2b362..a1a7e65 100644 --- a/src/svmap/svmap_io.gd +++ b/src/svmap/svmap_io.gd @@ -107,6 +107,7 @@ static func parse_and_validate(text: String, manifest: Dictionary = {}) -> Dicti if not errors.is_empty(): result["errors"] = Array(errors) return result + ModusKatalog.migrate(data) # Alt-Namen (checkpoints → conquest) NACH der Prüfsumme umstellen result["ok"] = true result["data"] = data return result @@ -222,7 +223,7 @@ 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. + # V21–V24: Spielmodus, Zonen, Hills, Modus-Spawns (optional), siehe ModusKatalog. errors.append_array(ModusKatalog.validate(data, bounds)) return errors diff --git a/src/ui/new_map_dialog.gd b/src/ui/new_map_dialog.gd index 419f54f..34ab0ca 100644 --- a/src/ui/new_map_dialog.gd +++ b/src/ui/new_map_dialog.gd @@ -57,7 +57,7 @@ func _init() -> void: 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." + mode_option.tooltip_text = "Team-Deathmatch: Kills zählen. Conquest: Teams nehmen mehrere Zonen ein. King of the Hill: ein Hill, der wandert. Deathmatch: alle gegen alle. 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.") diff --git a/src/ui/panels/modus_panel.gd b/src/ui/panels/modus_panel.gd index 2544e4d..bad4131 100644 --- a/src/ui/panels/modus_panel.gd +++ b/src/ui/panels/modus_panel.gd @@ -1,17 +1,23 @@ 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). +## Spielmodus-Bereich im Map-Tab (28.10.): Modus der Map wählen (Team-Deathmatch / +## Conquest / King of the Hill / Deathmatch), Zonen bzw. Hills setzen und eigene +## Spawns je Modus anlegen. Zonen erscheinen als farbige Kreise im 3D-Bild. +## Gespeichert als optionale Felder doc["game_mode"], doc["conquest_zones"], +## doc["hills"], doc["mode_spawns"] (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). +## Jede Änderung ist ein Undo-Schritt (History-Blöcke; fehlt ein Block, gilt der +## Standard: TDM, keine Zonen, keine eigenen Spawns). var mode_option: OptionButton -var cp_box: VBoxContainer +var cp_box: VBoxContainer # Zonen-/Hill-Bereich (nur Conquest und KotH) +var zones_title: Label var rows_box: VBoxContainer var add_button: Button var hint_label: Label +var spawn_option: OptionButton # Spawn-Satz, der mit dem Spawn-Werkzeug bearbeitet wird +var spawn_create: Button +var spawn_remove: Button +var spawn_hint: Label var _loading := false @@ -30,7 +36,7 @@ func build() -> void: 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." + mode_option.tooltip_text = "Team-Deathmatch: Kills zählen. Conquest: Teams nehmen mehrere Zonen ein. King of the Hill: ein Hill, der wandert. Deathmatch: alle gegen alle." for m in ModusKatalog.MODES: mode_option.add_item(ModusKatalog.MODE_LABELS[m]) add_child(mode_option) @@ -39,24 +45,48 @@ func build() -> void: cp_box.add_theme_constant_override("separation", UIScale.px(8)) add_child(cp_box) - cp_box.add_child(_field_label("Checkpoints")) + zones_title = _field_label("Zonen") + cp_box.add_child(zones_title) 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.name = "ZoneNeu" + add_button.text = "+ Zone 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.") + hint_label = _muted_label("Mindestens eine Zone nötig.") cp_box.add_child(hint_label) + # Spawns je Modus + add_child(_field_label("Spawns bearbeiten für")) + spawn_option = OptionButton.new() + spawn_option.name = "SpawnSatz" + spawn_option.tooltip_text = "Welche Spawn-Punkte das Spawn-Werkzeug gerade zeigt und ändert. Ohne eigene Spawns nutzt ein Modus die Basis-Spawns." + add_child(spawn_option) + spawn_create = Button.new() + spawn_create.name = "SpawnSatzNeu" + spawn_create.tooltip_text = "Legt eigene Spawn-Punkte für diesen Modus an (Kopie der Basis-Spawns) — danach mit dem Spawn-Werkzeug anpassen." + add_child(spawn_create) + spawn_remove = Button.new() + spawn_remove.name = "SpawnSatzWeg" + spawn_remove.text = "Eigene Spawns löschen" + spawn_remove.tooltip_text = "Entfernt die eigenen Spawns dieses Modus — er nutzt danach wieder die Basis-Spawns." + add_child(spawn_remove) + spawn_hint = _muted_label("") + add_child(spawn_hint) + 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()) + add_button.pressed.connect(func() -> void: add_zone_at_view()) + spawn_option.item_selected.connect(func(i: int) -> void: + if not _loading: + select_spawn_set(_spawn_set_ids()[i])) + spawn_create.pressed.connect(func() -> void: create_spawns(SpawnOps.edit_set)) + spawn_remove.pressed.connect(func() -> void: remove_spawns(SpawnOps.edit_set)) refresh_values() @@ -70,50 +100,88 @@ func set_mode(mode: String) -> void: 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]) + if ModusKatalog.has_zones(mode) and ModusKatalog.zones(main.doc, mode).is_empty(): + main.doc[ModusKatalog.ZONE_KEYS[mode]] = ModusKatalog.default_zones(main.doc, mode) + _commit(before, "Spielmodus: %s" % ModusKatalog.MODE_SHORT[mode]) -func add_checkpoint_at_view() -> void: - if main.doc.is_empty(): +func _zone_key() -> String: + return str(ModusKatalog.ZONE_KEYS.get(ModusKatalog.mode_of(main.doc), "")) + + +func add_zone_at_view() -> void: + if main.doc.is_empty() or _zone_key() == "": 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)) + add_zone(clampf(at.x, -half.x, half.x), clampf(at.z, -half.y, half.y)) -func add_checkpoint(x: float, z: float) -> String: +func add_zone(x: float, z: float) -> String: + var mode := ModusKatalog.mode_of(main.doc) + var key := _zone_key() + if key == "": + return "" 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"]) + var zone := ModusKatalog.make_zone(main.doc, mode, x, z) + var list: Array = ModusKatalog.zones(main.doc, mode).duplicate(true) + list.append(zone) + main.doc[key] = list + _commit(before, "%s %s gesetzt" % [ModusKatalog.ZONE_NAMES[mode], zone["id"]]) + return str(zone["id"]) -func remove_checkpoint(id: String) -> void: +func remove_zone(id: String) -> void: + var mode := ModusKatalog.mode_of(main.doc) + var key := _zone_key() + if key == "": + return var before := _snap() var list: Array = [] - for c in ModusKatalog.checkpoints(main.doc): + for c in ModusKatalog.zones(main.doc, mode): if str(c.get("id", "")) != id: list.append((c as Dictionary).duplicate(true)) - if list.size() == ModusKatalog.checkpoints(main.doc).size(): + if list.size() == ModusKatalog.zones(main.doc, mode).size(): return if list.is_empty(): - main.doc.erase("checkpoints") + main.doc.erase(key) else: - main.doc["checkpoints"] = list - _commit(before, "Checkpoint %s gelöscht" % id) + main.doc[key] = list + _commit(before, "%s %s gelöscht" % [ModusKatalog.ZONE_NAMES[mode], id]) -## Name / Position / Radius eines Checkpoints ändern (nur übergebene Felder). -func edit_checkpoint(id: String, fields: Dictionary) -> void: +## King of the Hill: Reihenfolge der Hills ändern (dir = -1 hoch, +1 runter). +func move_zone(id: String, dir: int) -> void: + var mode := ModusKatalog.mode_of(main.doc) + var key := _zone_key() + if key == "": + return + var list: Array = ModusKatalog.zones(main.doc, mode).duplicate(true) + var idx := -1 + for i in list.size(): + if str(list[i].get("id", "")) == id: + idx = i + var to := idx + dir + if idx < 0 or to < 0 or to >= list.size(): + return var before := _snap() - var list: Array = ModusKatalog.checkpoints(main.doc).duplicate(true) + var tmp: Variant = list[idx] + list[idx] = list[to] + list[to] = tmp + main.doc[key] = list + _commit(before, "%s %s verschoben (Reihenfolge)" % [ModusKatalog.ZONE_NAMES[mode], id]) + + +## Name / Position / Radius einer Zone ändern (nur übergebene Felder). +func edit_zone(id: String, fields: Dictionary) -> void: + var mode := ModusKatalog.mode_of(main.doc) + var key := _zone_key() + if key == "": + return + var before := _snap() + var list: Array = ModusKatalog.zones(main.doc, mode).duplicate(true) for c in list: if str(c.get("id", "")) != id: continue @@ -127,8 +195,57 @@ func edit_checkpoint(id: String, fields: Dictionary) -> void: 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 "") + main.doc[key] = list + _commit(before, "%s %s geändert" % [ModusKatalog.ZONE_NAMES[mode], id], "cp_%s" % id if fields.has("x") or fields.has("z") or fields.has("radius_m") else "") + + +# ---- Spawn-Sätze ---- + +func _spawn_set_ids() -> Array: + var out: Array = [ModusKatalog.SPAWN_SET_BASE] + out.append_array(ModusKatalog.MODES) + return out + + +func select_spawn_set(set_id: String) -> void: + if not _spawn_set_ids().has(set_id): + return + SpawnOps.edit_set = set_id + _refresh_spawn_view() + refresh_values() + main.call("_set_status", "Spawn-Satz: %s." % _set_label(set_id)) + + +func create_spawns(mode: String) -> void: + if main.doc.is_empty() or mode == ModusKatalog.SPAWN_SET_BASE or ModusKatalog.has_own_spawns(main.doc, mode): + return + var before := _snap() + ModusKatalog.create_spawn_set(main.doc, mode) + _commit(before, "Eigene Spawns für %s angelegt" % ModusKatalog.MODE_SHORT[mode]) + _refresh_spawn_view() + + +func remove_spawns(mode: String) -> void: + if main.doc.is_empty() or mode == ModusKatalog.SPAWN_SET_BASE or not ModusKatalog.has_own_spawns(main.doc, mode): + return + var before := _snap() + ModusKatalog.remove_spawn_set(main.doc, mode) + SpawnOps.edit_set = ModusKatalog.SPAWN_SET_BASE + _commit(before, "Eigene Spawns für %s gelöscht" % ModusKatalog.MODE_SHORT[mode]) + _refresh_spawn_view() + + +func _refresh_spawn_view() -> void: + if main.view_builder != null: + main.view_builder.call("_rebuild_spawn_markers", main.doc) + if "spawn_tool" in main and main.spawn_tool != null: + main.spawn_tool.call("refresh_from_doc") + + +func _set_label(set_id: String) -> String: + if set_id == ModusKatalog.SPAWN_SET_BASE: + return "Basis (für alle Modi ohne eigene Spawns)" + return "%s%s" % [ModusKatalog.MODE_SHORT[set_id], " ✔ eigene" if ModusKatalog.has_own_spawns(main.doc, set_id) else ""] func _half() -> Vector2: @@ -138,7 +255,7 @@ func _half() -> Vector2: func _snap() -> Dictionary: var out := {} - for key in ["game_mode", "checkpoints"]: + for key in ["game_mode", "conquest_zones", "hills", "mode_spawns"]: 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 @@ -153,7 +270,7 @@ func _commit(before: Dictionary, text: String, merge_tag := "") -> void: main.call("_refresh_file_label") main.call("_refresh_history_buttons") if main.view_builder != null: - main.view_builder.call("rebuild_checkpoint_markers", main.doc) + main.view_builder.call("rebuild_zone_markers", main.doc) refresh_values() main.call("_set_status", "%s." % text) @@ -165,29 +282,58 @@ 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 + var mode := ModusKatalog.mode_of(main.doc) + mode_option.select(ModusKatalog.MODES.find(mode)) + cp_box.visible = ModusKatalog.has_zones(mode) + if cp_box.visible: + var nm: String = ModusKatalog.ZONE_NAMES[mode] + zones_title.text = "Hills (Reihenfolge = Wanderreihenfolge)" if mode == ModusKatalog.MODE_KOTH else "Zonen" + add_button.text = "+ %s in Bildmitte setzen" % nm + hint_label.text = "Mindestens %s nötig. %s" % [ + "einen Hill" if mode == ModusKatalog.MODE_KOTH else "eine Zone", + "Der Server wechselt nach der eingestellten Zeit zum nächsten Hill (oder zufällig)." if mode == ModusKatalog.MODE_KOTH + else "Spieler spawnen zusätzlich an Zonen ihres Teams."] 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() + var zl := ModusKatalog.zones(main.doc, mode) + for i in zl.size(): + if zl[i] is Dictionary: + rows_box.add_child(_row(zl[i], i, zl.size(), mode)) + hint_label.visible = zl.is_empty() + # Spawn-Sätze + if not _spawn_set_ids().has(SpawnOps.edit_set): + SpawnOps.edit_set = ModusKatalog.SPAWN_SET_BASE + spawn_option.clear() + for sid in _spawn_set_ids(): + spawn_option.add_item(_set_label(sid)) + spawn_option.select(_spawn_set_ids().find(SpawnOps.edit_set)) + var base := SpawnOps.edit_set == ModusKatalog.SPAWN_SET_BASE + var own := not base and ModusKatalog.has_own_spawns(main.doc, SpawnOps.edit_set) + spawn_create.visible = not base and not own + spawn_create.text = "Eigene Spawns für %s anlegen" % (ModusKatalog.MODE_SHORT[SpawnOps.edit_set] if not base else "") + spawn_remove.visible = own + if base: + spawn_hint.text = "Basis-Spawns gelten für jeden Modus ohne eigene Spawns." + elif own: + spawn_hint.text = "Das Spawn-Werkzeug ändert jetzt die eigenen Spawns dieses Modus." + else: + spawn_hint.text = "%s nutzt noch die Basis-Spawns. Mit dem Knopf legst du eigene an." % ModusKatalog.MODE_SHORT[SpawnOps.edit_set] + if SpawnOps.edit_set == ModusKatalog.MODE_DM and own: + spawn_hint.text += " Deathmatch hat keine Teams — alle Punkte gelten für alle." _loading = false -func _row(cp: Dictionary) -> Control: +func _row(cp: Dictionary, index := 0, count := 1, mode := "conquest") -> 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.name = "Z_%s" % id box.add_theme_constant_override("separation", UIScale.px(4)) var top := HBoxContainer.new() - var idl := _field_label(id) + var idl := _field_label("%d·%s" % [index + 1, id] if mode == ModusKatalog.MODE_KOTH else id) idl.custom_minimum_size = UIScale.v2(32, 0) top.add_child(idl) var name_edit := LineEdit.new() @@ -197,16 +343,31 @@ func _row(cp: Dictionary) -> Control: 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})) + edit_zone(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})) + edit_zone(id, {"label": name_edit.text})) top.add_child(name_edit) + if mode == ModusKatalog.MODE_KOTH: + var up := Button.new() + up.name = "Hoch" + up.text = "▲" + up.tooltip_text = "Früher drankommen" + up.disabled = index == 0 + up.pressed.connect(func() -> void: move_zone(id, -1)) + top.add_child(up) + var dn := Button.new() + dn.name = "Runter" + dn.text = "▼" + dn.tooltip_text = "Später drankommen" + dn.disabled = index >= count - 1 + dn.pressed.connect(func() -> void: move_zone(id, 1)) + top.add_child(dn) var del := Button.new() del.name = "Loeschen" del.text = "✕" - del.tooltip_text = "Checkpoint löschen" - del.pressed.connect(func() -> void: remove_checkpoint(id)) + del.tooltip_text = "Löschen" + del.pressed.connect(func() -> void: remove_zone(id)) top.add_child(del) box.add_child(top) @@ -233,6 +394,6 @@ func _spin(label: String, field: String, id: String, value: float, mn: float, mx sb.size_flags_horizontal = Control.SIZE_EXPAND_FILL sb.value_changed.connect(func(v: float) -> void: if not _loading: - edit_checkpoint(id, {field: v})) + edit_zone(id, {field: v})) h.add_child(sb) return h diff --git a/src/viewport/map_view_builder.gd b/src/viewport/map_view_builder.gd index 55dc76b..6ec8769 100644 --- a/src/viewport/map_view_builder.gd +++ b/src/viewport/map_view_builder.gd @@ -90,7 +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 zone_root: Node3D = null # Conquest-Zonen bzw. Hills (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) @@ -116,7 +116,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void: child.queue_free() object_markers = [] spawn_markers = [] - checkpoint_root = null + zone_root = null grid_node = null _doc_ref = data _base_mat = StandardMaterial3D.new() @@ -152,7 +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) + rebuild_zone_markers(data) ## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1): @@ -493,13 +493,13 @@ func _rebuild_spawn_markers(data: Dictionary) -> void: if marker != null and is_instance_valid(marker): marker.queue_free() spawn_markers = [] - var zones = data.get("spawn_zones", {}) + var zones = SpawnOps.zones_of(data) # bearbeiteter Spawn-Satz (Basis oder Modus) if not (zones is Dictionary): return for team_key in zones.keys(): if not (zones[team_key] is Dictionary): continue - var team := int(team_key) + var team := 0 if str(team_key) == "all" else int(team_key) var zone: Dictionary = zones[team_key] var points = zone.get("points", []) if not (points is Array): @@ -516,19 +516,22 @@ 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: +## Zonen des Spielmodus: Conquest = gelb, King of the Hill = orange (mit Nummer der +## Wanderreihenfolge). Boden-Kreis + durchscheinender Zylinder + Name. +func rebuild_zone_markers(data: Dictionary) -> void: + if zone_root != null and is_instance_valid(zone_root): + zone_root.queue_free() + zone_root = null + var mode := ModusKatalog.mode_of(data) + if not ModusKatalog.has_zones(mode): 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): + zone_root = Node3D.new() + zone_root.name = "Zonen" + add_child(zone_root) + var col := Color(1.0, 0.8, 0.2) if mode == ModusKatalog.MODE_CONQUEST else Color(1.0, 0.45, 0.15) + var zlist := ModusKatalog.zones(data, mode) + for zi in zlist.size(): + var cp = zlist[zi] if not (cp is Dictionary): continue var pos: Array = cp.get("pos", [0, 0, 0]) @@ -566,7 +569,7 @@ func rebuild_checkpoint_markers(data: Dictionary) -> void: ring.position.y = 0.15 node.add_child(ring) var label := Label3D.new() - label.text = "⚑ %s" % str(cp.get("label", cp.get("id", ""))) + label.text = ("⚑ %s" if mode == ModusKatalog.MODE_CONQUEST else "♛ %d · %%s" % (zi + 1)) % str(cp.get("label", cp.get("id", ""))) label.position.y = 10.0 label.billboard = BaseMaterial3D.BILLBOARD_ENABLED label.modulate = col @@ -574,7 +577,7 @@ func rebuild_checkpoint_markers(data: Dictionary) -> void: label.font_size = 160 label.pixel_size = 0.01 node.add_child(label) - checkpoint_root.add_child(node) + zone_root.add_child(node) ## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius. diff --git a/tests/probe_drag_echt.gd.uid b/tests/probe_drag_echt.gd.uid new file mode 100644 index 0000000..119e478 --- /dev/null +++ b/tests/probe_drag_echt.gd.uid @@ -0,0 +1 @@ +uid://cnxoexcx8rjkc diff --git a/tests/test_modus.gd b/tests/test_modus.gd index d2fef98..30d4619 100644 --- a/tests/test_modus.gd +++ b/tests/test_modus.gd @@ -1,5 +1,5 @@ extends SceneTree -## Editor-Test (headless): Spielmodus + Checkpoints (V21/V22) — Neu-Dialog, +## Editor-Test (headless): Spielmodus, Conquest-Zonen, Hills, Modus-Spawns (V21–V24) — Neu-Dialog, ## Katalog, Panel mit Undo/Redo, Speichern/Laden. Ausführen: ## godot --headless --path . --script res://tests/test_modus.gd @@ -30,19 +30,36 @@ func _signed(d: Dictionary) -> Dictionary: 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(not tdm.has("game_mode") and not tdm.has("conquest_zones") and not tdm.has("hills") and not tdm.has("mode_spawns"), "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 cp := SvmapDocument.create_new("C", 512.0, "mittag", "conquest") + _check(cp["game_mode"] == "conquest" and (cp["conquest_zones"] as Array).size() == 3, "Conquest-Map mit 3 Start-Zonen") + _check(SvmapIO.validate_map(_signed(cp)).is_empty(), "Conquest-Map gültig: " + str(SvmapIO.validate_map(_signed(cp)))) + var kh := SvmapDocument.create_new("K", 512.0, "mittag", "koth") + _check(kh["game_mode"] == "koth" and (kh["hills"] as Array).size() == 3 and not kh.has("conquest_zones"), "KotH-Map mit 3 Hills") + _check(SvmapIO.validate_map(_signed(kh)).is_empty(), "KotH-Map gültig: " + str(SvmapIO.validate_map(_signed(kh)))) + var dmd := SvmapDocument.create_new("D", 512.0, "mittag", "dm") + _check(dmd["game_mode"] == "dm" and SvmapIO.validate_map(_signed(dmd)).is_empty(), "Deathmatch-Map gültig") + # Alt-Map (vor dem Umbau): "checkpoints" lädt als Conquest, speichert neu + var alt := cp.duplicate(true) + alt["game_mode"] = "checkpoints" + alt["checkpoints"] = alt["conquest_zones"] + alt.erase("conquest_zones") + var alt_signed := _signed(alt) + _check(SvmapIO.validate_map(alt_signed).is_empty(), "Alt-Map (checkpoints) ist gültig") + var alt_res := SvmapIO.parse_and_validate(JSON.stringify(alt_signed)) + _check(alt_res["ok"] and alt_res["data"]["game_mode"] == "conquest" and alt_res["data"].has("conquest_zones") and not alt_res["data"].has("checkpoints"), "Alt-Map wird beim Laden zu Conquest") var bad := cp.duplicate(true) - bad["checkpoints"] = [] + bad["conquest_zones"] = [] _check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "Modus ohne Zonen ungültig (V21)") + bad = kh.duplicate(true) + bad["hills"] = [] + _check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "KotH ohne Hill ungültig (V21)") bad = cp.duplicate(true) - bad["checkpoints"][0]["pos"] = [9999.0, 0.0, 0.0] + bad["conquest_zones"][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" + bad["conquest_zones"][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" @@ -53,7 +70,11 @@ func _run_all() -> void: 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") + _check(dlg.get_game_mode() == "conquest", "Dialog liefert Conquest") + dlg.mode_option.select(2) + _check(dlg.get_game_mode() == "koth", "Dialog liefert King of the Hill") + dlg.mode_option.select(3) + _check(dlg.get_game_mode() == "dm", "Dialog liefert Deathmatch") print("Testgruppe: Panel") var scene: PackedScene = load("res://scenes/main.tscn") @@ -69,45 +90,90 @@ func _run_all() -> void: 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(not panel.cp_box.visible, "TDM: Zonen-Bereich verborgen") + panel.set_mode("conquest") + _check(main.doc["game_mode"] == "conquest" and main.doc["conquest_zones"].size() == 3, "Wechsel auf Conquest 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(main.view_builder.zone_root != null and main.view_builder.zone_root.get_child_count() == 3, "3 Zonen im 3D-Bild") + var id: String = panel.add_zone(50.0, -30.0) + _check(id == "C4" and main.doc["conquest_zones"].size() == 4, "neue Zone C4") + panel.edit_zone("C4", {"radius_m": 25.0}) + panel.edit_zone("C4", {"x": 80.0, "z": 10.0, "label": "Turm"}) + var c4: Dictionary = main.doc["conquest_zones"][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") + panel.edit_zone("C4", {"radius_m": 500.0}) + _check(main.doc["conquest_zones"][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") + panel.remove_zone("C4") + _check(main.doc["conquest_zones"].size() == 3, "Zone gelöscht") main.history.undo() main.call("_refresh_all") - _check(main.doc["checkpoints"].size() == 4, "Undo: Zone zurück") + _check(main.doc["conquest_zones"].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") + _check(not main.doc.has("game_mode") and not main.doc.has("conquest_zones") 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") + _check(main.doc.get("game_mode", "") == "conquest", "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") + _check(main.view_builder.zone_root == null, "TDM: keine Zonen im Bild") - panel.set_mode("checkpoints") + print("Testgruppe: King of the Hill") + panel.set_mode("koth") + _check(main.doc["game_mode"] == "koth" and main.doc["hills"].size() == 3, "KotH setzt 3 Hills") + _check(panel.rows_box.get_child_count() == 3 and main.view_builder.zone_root.get_child_count() == 3, "3 Hill-Zeilen und 3 Marker") + var first_id: String = main.doc["hills"][0]["id"] + panel.move_zone(first_id, 1) + _check(main.doc["hills"][1]["id"] == first_id, "Hill nach hinten geschoben (Reihenfolge)") + panel.move_zone(first_id, 5) + _check(main.doc["hills"][1]["id"] == first_id, "Verschieben über den Rand wirkt nicht") + var hid: String = panel.add_zone(10.0, 10.0) + _check(hid == "H4" and main.doc["hills"].size() == 4, "neuer Hill H4") + main.history.undo() + main.history.undo() + main.call("_refresh_all") + _check(main.doc["hills"][0]["id"] == first_id and main.doc["hills"].size() == 3, "Undo stellt Reihenfolge und Liste wieder her") + _check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "KotH-Map gültig") + + print("Testgruppe: Modus-Spawns") + _check(not main.doc.has("mode_spawns"), "ohne Anlegen kein mode_spawns-Block") + panel.select_spawn_set("koth") + _check(SpawnOps.edit_set == "koth" and SpawnOps.count_points(main.doc, 0) == 0, "KotH-Satz leer, solange nichts angelegt") + panel.create_spawns("koth") + _check(main.doc.has("mode_spawns") and main.doc["mode_spawns"]["koth"]["0"]["points"].size() == 4, "Anlegen kopiert die 4 Basis-Spawns je Team") + _check(SpawnOps.count_points(main.doc, 0) == 4 and SpawnOps.count_points(main.doc, 1) == 4, "Spawn-Werkzeug sieht jetzt den KotH-Satz") + var kid := str(main.doc["mode_spawns"]["koth"]["0"]["points"][0]["id"]) + _check(kid.begins_with("KA") and SpawnOps.next_id(main.doc, 0).begins_with("KA"), "IDs mit Modus-Präfix (%s)" % kid) + var added := SpawnOps.add_zone(main.doc, 0, 30.0, 30.0) + _check(main.doc["mode_spawns"]["koth"]["0"]["points"].size() == 5 and main.doc["spawn_zones"]["0"]["points"].size() == 4, "neuer Punkt landet nur im KotH-Satz") + _check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "Map mit eigenen Spawns gültig: " + str(SvmapIO.validate_map(_signed(main.doc)))) + main.history.undo() + main.call("_refresh_all") + _check(not main.doc.has("mode_spawns"), "Undo entfernt den Spawn-Satz wieder") + panel.select_spawn_set("dm") + panel.create_spawns("dm") + _check(main.doc["mode_spawns"]["dm"].has("all") and main.doc["mode_spawns"]["dm"]["all"]["points"].size() == 8, "Deathmatch: beide Teams in einem Topf (8 Punkte)") + SpawnOps.add_zone(main.doc, 1, 40.0, 40.0) + _check(main.doc["mode_spawns"]["dm"]["all"]["points"].size() == 9 and not main.doc["mode_spawns"]["dm"].has("1"), "Deathmatch: Team-Werkzeug schreibt nach 'all'") + _check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "DM-Spawns gültig") + var bad_ms: Dictionary = main.doc.duplicate(true) + bad_ms["mode_spawns"]["dm"]["0"] = {"points": [{"id": "X", "pos": [0.0, 0.0, 0.0]}]} + _check(not ModusKatalog.validate(bad_ms, bad_ms["bounds"]).is_empty(), "V24: Team-Schlüssel im DM-Satz ungültig") + panel.remove_spawns("dm") + _check(not main.doc.has("mode_spawns") and SpawnOps.edit_set == "base", "Löschen entfernt den Satz und schaltet auf Basis zurück") + SpawnOps.edit_set = "base" + + panel.set_mode("conquest") 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") + _check(back["ok"] and back["data"]["game_mode"] == "conquest" and back["data"]["conquest_zones"].size() >= 3, "Laden: Modus + Zonen erhalten") if failures == 0: print("ALL OK: %d checks" % checks) diff --git a/tests/test_modus.gd.uid b/tests/test_modus.gd.uid new file mode 100644 index 0000000..ae56156 --- /dev/null +++ b/tests/test_modus.gd.uid @@ -0,0 +1 @@ +uid://55ke874wv33d