Spielmodi im Editor: Checkpoints → Conquest (Alt-Maps werden beim Laden umgestellt), King of the Hill mit Hill-Liste + Reihenfolge, Deathmatch, eigene Spawns je Modus (mode_spawns, Spawn-Satz-Auswahl, Undo), V21–V24

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-28 20:38:54 +01:00
parent f42e133bae
commit 92e2c688c1
10 changed files with 578 additions and 155 deletions

View file

@ -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 = {}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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.")

View file

@ -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

View file

@ -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.

View file

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

View file

@ -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)

1
tests/test_modus.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://55ke874wv33d