Spielmodus-Auswahl beim Neu-Erstellen + Checkpoint-Zonen im Map-Tab (V21/V22, Undo, 3D-Anzeige)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
slyphax 2026-10-28 09:21:13 +01:00
parent bb9eb71770
commit f42e133bae
12 changed files with 562 additions and 4 deletions

View file

@ -19,7 +19,7 @@ const DEFAULT_AUTHOR := "ScopedVR Creator"
## (V1–V14 gültig) — deshalb bekommt sie automatisch die 4 Pflicht-Spawn-Punkte
## pro Team (Entscheidung 11) an sinnvollen Startpositionen. Der Spawn-Zonen-
## Editor (eigene Karte) kann sie später verschieben/löschen/erweitern.
static func create_new(map_name: String, size_m: float, weather: String) -> Dictionary:
static func create_new(map_name: String, size_m: float, weather: String, game_mode := "tdm") -> Dictionary:
var size := maxf(MIN_SIZE_M, minf(size_m, MAX_SIZE_M))
var res := DEFAULT_RESOLUTION
var zero_heights := PackedByteArray()
@ -59,6 +59,11 @@ static func create_new(map_name: String, size_m: float, weather: String) -> Dict
},
"weather": weather,
}
# Spielmodus (28.10.): nur ein abweichender Modus wird geschrieben; Checkpoint-
# Maps starten mit drei Zonen zwischen den Basen.
if game_mode == ModusKatalog.MODE_CHECKPOINTS:
data["game_mode"] = game_mode
data["checkpoints"] = ModusKatalog.default_checkpoints(data)
return data

111
src/svmap/modus_katalog.gd Normal file
View file

@ -0,0 +1,111 @@
class_name ModusKatalog
extends RefCounted
## Spielmodus + Checkpoints der Map (Karte Checkpoint-Modus, 28.10.).
## doc["game_mode"] ("tdm" | "checkpoints", fehlt = tdm) und doc["checkpoints"]
## [{id, label, pos:[x,y,z], radius_m}] — Regeln 1:1 wie SvmapLoader im
## Match-Server (V21/V22, docs/map_format.md, docs/checkpoints.md).
const MODE_TDM := "tdm"
const MODE_CHECKPOINTS := "checkpoints"
const MODES: Array[String] = [MODE_TDM, MODE_CHECKPOINTS]
const MODE_LABELS := {
MODE_TDM: "Team-Deathmatch",
MODE_CHECKPOINTS: "Checkpoints einnehmen",
}
const DEFAULT_RADIUS := 15.0
const MIN_RADIUS := 3.0
const MAX_RADIUS := 60.0
static func mode_of(doc: Dictionary) -> String:
var m := str(doc.get("game_mode", MODE_TDM))
return m if MODES.has(m) else MODE_TDM
static func checkpoints(doc: Dictionary) -> Array:
var c: Variant = doc.get("checkpoints", [])
return c if c is Array else []
## Nächste freie ID "C1", "C2", …
static func next_id(doc: Dictionary) -> String:
var used := {}
for c in checkpoints(doc):
if c is Dictionary:
used[str(c.get("id", ""))] = true
var i := 1
while used.has("C%d" % i):
i += 1
return "C%d" % i
## Neuer Checkpoint auf dem Boden (y = Terrain-Höhe).
static func make_checkpoint(doc: Dictionary, x: float, z: float, label := "") -> Dictionary:
var id := next_id(doc)
return {
"id": id,
"label": label if label != "" else "Zone %s" % id.substr(1),
"pos": [x, SvmapDocument.terrain_height_at(doc, x, z), z],
"radius_m": DEFAULT_RADIUS,
}
## Drei Start-Zonen zwischen den Basen (West–Ost-Linie), für neue Checkpoint-Maps.
static func default_checkpoints(doc: Dictionary) -> Array:
var size := float(doc.get("terrain", {}).get("size_x_m", 1024.0))
var out: Array = []
var names := ["Westposten", "Mitte", "Ostposten"]
var xs := [-0.25, 0.0, 0.25]
for i in 3:
var cp := make_checkpoint({"checkpoints": out, "terrain": doc.get("terrain", {})}, xs[i] * size, 0.0, names[i])
out.append(cp)
return out
static func validate(data: Dictionary, bounds: Dictionary) -> PackedStringArray:
var errors := PackedStringArray()
var mode = data.get("game_mode", "")
if mode != null and str(mode) != "" and not MODES.has(str(mode)):
errors.append("V21: game_mode '%s' unbekannt (%s)." % [str(mode), ", ".join(MODES)])
var cps = data.get("checkpoints", [])
if not (cps is Array):
errors.append("V22: checkpoints muss ein Array sein.")
return errors
if str(mode) == MODE_CHECKPOINTS and (cps as Array).is_empty():
errors.append("V21: game_mode 'checkpoints' braucht mindestens einen Checkpoint.")
var seen := {}
for i in (cps as Array).size():
var c = cps[i]
if not (c is Dictionary):
errors.append("V22: Checkpoint %d ist kein Objekt." % i)
continue
var cid = c.get("id", "")
if not (cid is String) or (cid as String).strip_edges().is_empty():
errors.append("V22: Checkpoint %d: id fehlt/leer." % i)
elif seen.has(cid):
errors.append("V22: Checkpoint-ID '%s' ist doppelt." % cid)
else:
seen[cid] = true
var pos = c.get("pos", null)
if not (pos is Array) or (pos as Array).size() != 3 \
or not _num(pos[0]) or not _num(pos[1]) or not _num(pos[2]):
errors.append("V22: Checkpoint '%s': pos muss [x, y, z] mit endlichen Werten sein." % str(cid))
elif not _inside(pos, bounds):
errors.append("V22: Checkpoint '%s' liegt außerhalb der Match-Grenzen." % str(cid))
var r = c.get("radius_m", DEFAULT_RADIUS)
if not _num(r) or float(r) < MIN_RADIUS or float(r) > MAX_RADIUS:
errors.append("V22: Checkpoint '%s': radius_m muss 3–60 sein." % str(cid))
return errors
static func _num(v: Variant) -> bool:
return (v is int or v is float) and is_finite(float(v))
static func _inside(pos: Array, bounds: Dictionary) -> bool:
var mn: Array = bounds.get("min", [0, 0, 0])
var mx: Array = bounds.get("max", [0, 0, 0])
for i in 3:
if float(pos[i]) < float(mn[i]) - 1.0 or float(pos[i]) > float(mx[i]) + 1.0:
return false
return true

View file

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

View file

@ -222,6 +222,8 @@ static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> Packe
str(weather_sky), ", ".join(ids)])
# V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog.
errors.append_array(LoadoutKatalog.validate(data.get("loadout", null)))
# V21/V22: Spielmodus + Checkpoints (optional), siehe ModusKatalog.
errors.append_array(ModusKatalog.validate(data, bounds))
return errors

View file

@ -1289,15 +1289,16 @@ func _playtest_split_lagen_zurueck() -> void:
func _on_new_map_confirmed() -> void:
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather())
doc = SvmapDocument.create_new(new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_weather(), new_dialog.get_game_mode())
current_path = ""
dirty = false
history.begin(doc)
_clear_edit_state()
_refresh_all()
_test_start_reload() # Karte t_96e3c4ee: neue Map → kein Test-Start gesetzt
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s). Wähle links ein Objekt, um zu bauen!" % [
new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather()])
_set_status("Neue Map „%s“ erstellt (%.0f × %.0f m, Wetter: %s, Modus: %s). Wähle links ein Objekt, um zu bauen!" % [
new_dialog.get_map_name(), new_dialog.get_size_m(), new_dialog.get_size_m(), new_dialog.get_weather(),
ModusKatalog.MODE_LABELS[new_dialog.get_game_mode()]])
func _on_open_selected(path: String) -> void:

View file

@ -7,6 +7,7 @@ const UIScale := preload("res://src/ui/ui_scale.gd") # Karte t_297221b5
var name_edit: LineEdit
var size_option: OptionButton
var weather_option: OptionButton
var mode_option: OptionButton
var _sizes: Array[float] = [512.0, 1024.0, 2048.0]
var _size_labels := ["Klein — 512 × 512 m", "Mittel — 1024 × 1024 m", "Groß — 2048 × 2048 m"]
@ -51,6 +52,14 @@ func _init() -> void:
weather_option.tooltip_text = "Licht und Stimmung der Karte (Tag/Abend/Regen/Nacht)"
vbox.add_child(weather_option)
vbox.add_child(_make_label("Welchen Spielmodus soll die Map haben?"))
mode_option = OptionButton.new()
for m in ModusKatalog.MODES:
mode_option.add_item(ModusKatalog.MODE_LABELS[m])
mode_option.custom_minimum_size = UIScale.v2(0, 40)
mode_option.tooltip_text = "Team-Deathmatch: Kills zählen. Checkpoints: Teams nehmen Zonen ein und sammeln Punkte. Lässt sich später im Map-Tab ändern."
vbox.add_child(mode_option)
var hint := _make_label("Die Map startet leer mit 4 Spawn-Punkten pro Team\n(Blau im Westen, Rot im Osten) — beides lässt sich später ändern.")
hint.modulate = Color(1, 1, 1, 0.7)
vbox.add_child(hint)
@ -77,5 +86,9 @@ func get_size_m() -> float:
return _sizes[size_option.selected]
func get_game_mode() -> String:
return ModusKatalog.MODES[mode_option.selected]
func get_weather() -> String:
return _weathers[weather_option.selected]

View file

@ -255,6 +255,9 @@ func refresh_values() -> void:
prop_water.set_value_no_signal(
float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT)
# Loadout-Bereich (Registry-Gruppe "map") aus dem doc nachziehen.
var modus_panel: Node = _sections.get_node_or_null("modus") if _sections != null else null
if modus_panel != null:
modus_panel.call("refresh_values")
var loadout_panel: Node = _sections.get_node_or_null("loadout") if _sections != null else null
if loadout_panel != null:
loadout_panel.call("refresh_values")

View file

@ -0,0 +1,238 @@
extends "res://src/ui/panels/editor_panel.gd"
## Spielmodus-Bereich im Map-Tab (Karte Checkpoint-Modus, 28.10.): Modus der Map
## wählen (Team-Deathmatch / Checkpoints) und Checkpoint-Zonen setzen. Die Zonen
## erscheinen als farbige Kreise im 3D-Bild. Gespeichert als optionale Felder
## doc["game_mode"] und doc["checkpoints"] (Regeln: src/svmap/modus_katalog.gd).
##
## Jede Änderung ist ein Undo-Schritt (History-Blöcke game_mode + checkpoints;
## fehlt der Block, gilt TDM bzw. keine Zonen).
var mode_option: OptionButton
var cp_box: VBoxContainer
var rows_box: VBoxContainer
var add_button: Button
var hint_label: Label
var _loading := false
static func spec() -> Dictionary:
return {"titel": "Spielmodus", "gruppe": "map", "order": 22}
func panel_title() -> String:
return "Spielmodus"
func build() -> void:
add_child(_header_label("Spielmodus"))
add_child(_muted_label("Legt fest, wie auf dieser Karte gewonnen wird."))
mode_option = OptionButton.new()
mode_option.name = "ModusAuswahl"
mode_option.tooltip_text = "Team-Deathmatch: Kills zählen. Checkpoints: Die Teams nehmen Zonen ein und sammeln Punkte, solange sie sie halten."
for m in ModusKatalog.MODES:
mode_option.add_item(ModusKatalog.MODE_LABELS[m])
add_child(mode_option)
cp_box = VBoxContainer.new()
cp_box.add_theme_constant_override("separation", UIScale.px(8))
add_child(cp_box)
cp_box.add_child(_field_label("Checkpoints"))
rows_box = VBoxContainer.new()
rows_box.add_theme_constant_override("separation", UIScale.px(6))
cp_box.add_child(rows_box)
add_button = Button.new()
add_button.name = "CheckpointNeu"
add_button.text = "+ Checkpoint in Bildmitte setzen"
add_button.tooltip_text = "Setzt eine neue Zone dorthin, wo die Kamera hinschaut. Position und Größe lassen sich darunter ändern."
cp_box.add_child(add_button)
hint_label = _muted_label("Mindestens ein Checkpoint nötig. Spieler spawnen zusätzlich an Zonen ihres Teams.")
cp_box.add_child(hint_label)
mode_option.item_selected.connect(func(i: int) -> void:
if not _loading:
set_mode(ModusKatalog.MODES[i]))
add_button.pressed.connect(func() -> void: add_checkpoint_at_view())
refresh_values()
# ------------------------------------------------------------- Ändern ------
func set_mode(mode: String) -> void:
if main.doc.is_empty() or not ModusKatalog.MODES.has(mode) or ModusKatalog.mode_of(main.doc) == mode:
return
var before := _snap()
if mode == ModusKatalog.MODE_TDM:
main.doc.erase("game_mode")
else:
main.doc["game_mode"] = mode
if ModusKatalog.checkpoints(main.doc).is_empty():
main.doc["checkpoints"] = ModusKatalog.default_checkpoints(main.doc)
_commit(before, "Spielmodus: %s" % ModusKatalog.MODE_LABELS[mode])
func add_checkpoint_at_view() -> void:
if main.doc.is_empty():
return
var at := Vector3.ZERO
if main.camera != null and "target" in main.camera:
at = main.camera.target
var half := _half()
add_checkpoint(clampf(at.x, -half.x, half.x), clampf(at.z, -half.y, half.y))
func add_checkpoint(x: float, z: float) -> String:
var before := _snap()
var cp := ModusKatalog.make_checkpoint(main.doc, x, z)
var list: Array = ModusKatalog.checkpoints(main.doc).duplicate(true)
list.append(cp)
main.doc["checkpoints"] = list
_commit(before, "Checkpoint %s gesetzt" % cp["id"])
return str(cp["id"])
func remove_checkpoint(id: String) -> void:
var before := _snap()
var list: Array = []
for c in ModusKatalog.checkpoints(main.doc):
if str(c.get("id", "")) != id:
list.append((c as Dictionary).duplicate(true))
if list.size() == ModusKatalog.checkpoints(main.doc).size():
return
if list.is_empty():
main.doc.erase("checkpoints")
else:
main.doc["checkpoints"] = list
_commit(before, "Checkpoint %s gelöscht" % id)
## Name / Position / Radius eines Checkpoints ändern (nur übergebene Felder).
func edit_checkpoint(id: String, fields: Dictionary) -> void:
var before := _snap()
var list: Array = ModusKatalog.checkpoints(main.doc).duplicate(true)
for c in list:
if str(c.get("id", "")) != id:
continue
if fields.has("label"):
c["label"] = str(fields["label"]).strip_edges().substr(0, 32)
if fields.has("radius_m"):
c["radius_m"] = clampf(float(fields["radius_m"]), ModusKatalog.MIN_RADIUS, ModusKatalog.MAX_RADIUS)
if fields.has("x") or fields.has("z"):
var half := _half()
var p: Array = c["pos"]
var nx := clampf(float(fields.get("x", p[0])), -half.x, half.x)
var nz := clampf(float(fields.get("z", p[2])), -half.y, half.y)
c["pos"] = [nx, SvmapDocument.terrain_height_at(main.doc, nx, nz), nz]
main.doc["checkpoints"] = list
_commit(before, "Checkpoint %s geändert" % id, "cp_%s" % id if fields.has("x") or fields.has("z") or fields.has("radius_m") else "")
func _half() -> Vector2:
var t: Dictionary = main.doc.get("terrain", {})
return Vector2(float(t.get("size_x_m", 1024.0)) * 0.5, float(t.get("size_z_m", 1024.0)) * 0.5)
func _snap() -> Dictionary:
var out := {}
for key in ["game_mode", "checkpoints"]:
var v: Variant = main.doc.get(key, null)
out[key] = v.duplicate(true) if v is Array or v is Dictionary else v
return out
func _commit(before: Dictionary, text: String, merge_tag := "") -> void:
var after := _snap()
if before == after:
return
main.history.commit(text, before, after, merge_tag)
main.dirty = true
main.call("_refresh_file_label")
main.call("_refresh_history_buttons")
if main.view_builder != null:
main.view_builder.call("rebuild_checkpoint_markers", main.doc)
refresh_values()
main.call("_set_status", "%s." % text)
# ------------------------------------------------------------ Anzeige ------
## UI aus dem doc auffüllen (Laden, Undo/Redo) — löst keinen Schreibzugriff aus.
func refresh_values() -> void:
if mode_option == null or main == null or main.doc.is_empty():
return
_loading = true
var checkpoints_mode := ModusKatalog.mode_of(main.doc) == ModusKatalog.MODE_CHECKPOINTS
mode_option.select(ModusKatalog.MODES.find(ModusKatalog.mode_of(main.doc)))
cp_box.visible = checkpoints_mode
for c in rows_box.get_children():
rows_box.remove_child(c)
c.queue_free()
for cp in ModusKatalog.checkpoints(main.doc):
if cp is Dictionary:
rows_box.add_child(_row(cp))
hint_label.visible = ModusKatalog.checkpoints(main.doc).is_empty()
_loading = false
func _row(cp: Dictionary) -> Control:
var id := str(cp.get("id", ""))
var pos: Array = cp.get("pos", [0, 0, 0])
var half := _half()
var box := VBoxContainer.new()
box.name = "CP_%s" % id
box.add_theme_constant_override("separation", UIScale.px(4))
var top := HBoxContainer.new()
var idl := _field_label(id)
idl.custom_minimum_size = UIScale.v2(32, 0)
top.add_child(idl)
var name_edit := LineEdit.new()
name_edit.name = "Name"
name_edit.text = str(cp.get("label", id))
name_edit.max_length = 32
name_edit.size_flags_horizontal = Control.SIZE_EXPAND_FILL
name_edit.text_submitted.connect(func(t: String) -> void:
if not _loading:
edit_checkpoint(id, {"label": t}))
name_edit.focus_exited.connect(func() -> void:
if not _loading and name_edit.text != str(cp.get("label", id)):
edit_checkpoint(id, {"label": name_edit.text}))
top.add_child(name_edit)
var del := Button.new()
del.name = "Loeschen"
del.text = "✕"
del.tooltip_text = "Checkpoint löschen"
del.pressed.connect(func() -> void: remove_checkpoint(id))
top.add_child(del)
box.add_child(top)
var nums := HBoxContainer.new()
nums.add_theme_constant_override("separation", UIScale.px(4))
nums.add_child(_spin("X", "x", id, float(pos[0]), -half.x, half.x, 1.0))
nums.add_child(_spin("Z", "z", id, float(pos[2]), -half.y, half.y, 1.0))
nums.add_child(_spin("Radius", "radius_m", id, float(cp.get("radius_m", ModusKatalog.DEFAULT_RADIUS)),
ModusKatalog.MIN_RADIUS, ModusKatalog.MAX_RADIUS, 1.0))
box.add_child(nums)
return box
func _spin(label: String, field: String, id: String, value: float, mn: float, mx: float, step: float) -> Control:
var h := HBoxContainer.new()
h.size_flags_horizontal = Control.SIZE_EXPAND_FILL
h.add_child(_field_label(label))
var sb := SpinBox.new()
sb.name = field
sb.min_value = mn
sb.max_value = mx
sb.step = step
sb.set_value_no_signal(value)
sb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
sb.value_changed.connect(func(v: float) -> void:
if not _loading:
edit_checkpoint(id, {field: v}))
h.add_child(sb)
return h

View file

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

View file

@ -46,6 +46,8 @@ const PANELS: Array = [
"skript": "res://src/ui/panels/test_start_panel.gd"},
{"id": "loadout", "titel": "Loadout", "gruppe": "map", "order": 25,
"skript": "res://src/ui/panels/loadout_panel.gd"},
{"id": "modus", "titel": "Spielmodus", "gruppe": "map", "order": 22,
"skript": "res://src/ui/panels/modus_panel.gd"},
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "map", "order": 30,
"skript": "res://src/ui/panels/symmetry_panel.gd"},
# --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) ---

View file

@ -90,6 +90,7 @@ var selected_indices: Array = [] # komplette Auswahl (Karte t_f6f29d86 — Mehrf
var hover_index := -1 # Hover-Objekt (schwächste Kontur; U6-Abstufung)
var object_markers: Array = [] # Node3D je Objekt (gleiche Reihenfolge wie doc.objects)
var fehlende_glbs: Array = [] # OIDs ohne echte GLB (Platzhalter-Fallback — Statuszeilen-Hinweis)
var checkpoint_root: Node3D = null # Checkpoint-Zonen (Spielmodus), Kinder je Zone
var spawn_markers: Array = [] # Node3D je Spawn-Point (Meta: spawn_team/spawn_id/spawn_radius)
var terrain_view # TerrainMesh-Ansicht (script-erweitert, Variant)
var grid_node: MeshInstance3D # Maßstabsgitter (Toggle „⊞ Gitter“, Karte t_7ac70014)
@ -115,6 +116,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
child.queue_free()
object_markers = []
spawn_markers = []
checkpoint_root = null
grid_node = null
_doc_ref = data
_base_mat = StandardMaterial3D.new()
@ -150,6 +152,7 @@ func rebuild(data: Dictionary, manifest: Dictionary) -> void:
grid_node.visible = terrain.is_empty()
_build_objects(data.get("objects", []), manifest)
_rebuild_spawn_markers(data)
rebuild_checkpoint_markers(data)
## Terrain als echtes Höhenfeld-Mesh statt flacher Ebene (Karte t_9aba04f1):
@ -513,6 +516,67 @@ func _rebuild_spawn_markers(data: Dictionary) -> void:
add_child(marker)
## Checkpoint-Zonen (Spielmodus): gelber Boden-Kreis + durchscheinender Zylinder
## + Name. Wird nur im Modus "checkpoints" gezeigt.
func rebuild_checkpoint_markers(data: Dictionary) -> void:
if checkpoint_root != null and is_instance_valid(checkpoint_root):
checkpoint_root.queue_free()
checkpoint_root = null
if ModusKatalog.mode_of(data) != ModusKatalog.MODE_CHECKPOINTS:
return
checkpoint_root = Node3D.new()
checkpoint_root.name = "Checkpoints"
add_child(checkpoint_root)
var col := Color(1.0, 0.8, 0.2)
for cp in ModusKatalog.checkpoints(data):
if not (cp is Dictionary):
continue
var pos: Array = cp.get("pos", [0, 0, 0])
var r := float(cp.get("radius_m", ModusKatalog.DEFAULT_RADIUS))
var node := Node3D.new()
node.name = "CP_%s" % str(cp.get("id", ""))
node.position = Vector3(float(pos[0]), float(pos[1]), float(pos[2]))
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.cull_mode = BaseMaterial3D.CULL_DISABLED
mat.albedo_color = Color(col.r, col.g, col.b, 0.18)
var cyl := MeshInstance3D.new()
var cm := CylinderMesh.new()
cm.top_radius = r
cm.bottom_radius = r
cm.height = 8.0
cm.cap_top = false
cm.cap_bottom = false
cyl.mesh = cm
cyl.material_override = mat
cyl.position.y = 4.0
node.add_child(cyl)
var ring := MeshInstance3D.new()
var tm := TorusMesh.new()
tm.inner_radius = r - 0.2
tm.outer_radius = r + 0.2
tm.rings = 48
tm.ring_segments = 6
ring.mesh = tm
var rmat := StandardMaterial3D.new()
rmat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
rmat.albedo_color = col
ring.material_override = rmat
ring.position.y = 0.15
node.add_child(ring)
var label := Label3D.new()
label.text = "⚑ %s" % str(cp.get("label", cp.get("id", "")))
label.position.y = 10.0
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.modulate = col
label.outline_size = 12
label.font_size = 160
label.pixel_size = 0.01
node.add_child(label)
checkpoint_root.add_child(node)
## Ein Spawn-Marker bauen (flacher Kreis + Label). radius = angezeigter Zonen-Radius.
func _spawn_marker(pos: Vector3, team: int, pid: String, radius: float) -> Node3D:
var marker := Node3D.new()

117
tests/test_modus.gd Normal file
View file

@ -0,0 +1,117 @@
extends SceneTree
## Editor-Test (headless): Spielmodus + Checkpoints (V21/V22) — Neu-Dialog,
## Katalog, Panel mit Undo/Redo, Speichern/Laden. Ausführen:
## godot --headless --path . --script res://tests/test_modus.gd
var failures := 0
var checks := 0
var main: Control
func _initialize() -> void:
_run_all.call_deferred()
func _check(cond: bool, label: String) -> void:
checks += 1
if cond:
print(" ok: %s" % label)
else:
failures += 1
print(" FAIL: %s" % label)
func _signed(d: Dictionary) -> Dictionary:
var c := d.duplicate(true)
SvmapIO.sign_map(c)
return c
func _run_all() -> void:
print("Testgruppe: Katalog + Neu-Map")
var tdm := SvmapDocument.create_new("T", 512.0, "mittag")
_check(not tdm.has("game_mode") and not tdm.has("checkpoints"), "TDM-Map ohne Zusatzfelder (altes Format)")
_check(SvmapIO.validate_map(_signed(tdm)).is_empty(), "TDM-Map gültig")
var cp := SvmapDocument.create_new("C", 512.0, "mittag", "checkpoints")
_check(cp["game_mode"] == "checkpoints" and (cp["checkpoints"] as Array).size() == 3, "Checkpoint-Map mit 3 Start-Zonen")
_check(SvmapIO.validate_map(_signed(cp)).is_empty(), "Checkpoint-Map gültig: " + str(SvmapIO.validate_map(_signed(cp))))
var bad := cp.duplicate(true)
bad["checkpoints"] = []
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "Modus ohne Zonen ungültig (V21)")
bad = cp.duplicate(true)
bad["checkpoints"][0]["pos"] = [9999.0, 0.0, 0.0]
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "Zone außerhalb ungültig (V22)")
bad = cp.duplicate(true)
bad["checkpoints"][1]["id"] = "C1"
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "doppelte ID ungültig (V22)")
bad = cp.duplicate(true)
bad["game_mode"] = "ctf"
_check(not ModusKatalog.validate(bad, bad["bounds"]).is_empty(), "unbekannter Modus ungültig (V21)")
print("Testgruppe: Neu-Dialog")
var dlg: ConfirmationDialog = preload("res://src/ui/new_map_dialog.gd").new()
root.add_child(dlg)
_check(dlg.get_game_mode() == "tdm", "Dialog-Default TDM")
dlg.mode_option.select(1)
_check(dlg.get_game_mode() == "checkpoints", "Dialog liefert Checkpoints")
print("Testgruppe: Panel")
var scene: PackedScene = load("res://scenes/main.tscn")
main = scene.instantiate()
root.add_child(main)
await process_frame
await process_frame
main.doc = SvmapDocument.create_new("ModusTest", 512.0, "mittag")
main.history.doc = main.doc
main.call("_refresh_all")
var panel: Node = main.map_mod.sections_host().get_node_or_null("modus")
_check(panel != null, "Modus-Panel hängt im Map-Tab")
if panel == null:
quit(1)
return
_check(not panel.cp_box.visible, "TDM: Checkpoint-Bereich verborgen")
panel.set_mode("checkpoints")
_check(main.doc["game_mode"] == "checkpoints" and main.doc["checkpoints"].size() == 3, "Wechsel auf Checkpoints setzt 3 Zonen")
_check(panel.cp_box.visible and panel.rows_box.get_child_count() == 3, "3 Zeilen im Panel")
_check(main.view_builder.checkpoint_root != null and main.view_builder.checkpoint_root.get_child_count() == 3, "3 Zonen im 3D-Bild")
var id: String = panel.add_checkpoint(50.0, -30.0)
_check(id == "C4" and main.doc["checkpoints"].size() == 4, "neue Zone C4")
panel.edit_checkpoint("C4", {"radius_m": 25.0})
panel.edit_checkpoint("C4", {"x": 80.0, "z": 10.0, "label": "Turm"})
var c4: Dictionary = main.doc["checkpoints"][3]
_check(c4["radius_m"] == 25.0 and c4["pos"][0] == 80.0 and c4["pos"][2] == 10.0, "Radius + Position geändert")
panel.edit_checkpoint("C4", {"radius_m": 500.0})
_check(main.doc["checkpoints"][3]["radius_m"] == ModusKatalog.MAX_RADIUS, "Radius wird begrenzt")
_check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "Map gültig nach Änderungen")
panel.remove_checkpoint("C4")
_check(main.doc["checkpoints"].size() == 3, "Zone gelöscht")
main.history.undo()
main.call("_refresh_all")
_check(main.doc["checkpoints"].size() == 4, "Undo: Zone zurück")
while main.history.can_undo():
main.history.undo()
main.call("_refresh_all")
_check(not main.doc.has("game_mode") and not main.doc.has("checkpoints") and not panel.cp_box.visible, "Undo bis Start: Felder weg, UI nachgezogen")
main.history.redo()
main.call("_refresh_all")
_check(main.doc.get("game_mode", "") == "checkpoints", "Redo: Modus wieder da")
panel.set_mode("tdm")
_check(not main.doc.has("game_mode"), "Zurück auf TDM entfernt game_mode")
_check(main.view_builder.checkpoint_root == null, "TDM: keine Zonen im Bild")
panel.set_mode("checkpoints")
var path := "user://modus_test.svmap"
var doc2 := _signed(main.doc)
var res := SvmapIO.save_to_file(path, doc2)
_check(res["ok"], "Speichern ok: " + str(res["errors"]))
var back := SvmapIO.load_from_file(path)
_check(back["ok"] and back["data"]["game_mode"] == "checkpoints" and back["data"]["checkpoints"].size() >= 3, "Laden: Modus + Zonen erhalten")
if failures == 0:
print("ALL OK: %d checks" % checks)
quit(0)
else:
print("FAILED: %d/%d checks" % [failures, checks])
quit(1)