Map-Loadout: Loadout-Bereich im Map-Tab (Spieler-Loadout oder festes Map-Loadout mit Aufsaetzen), V18, Undo/Redo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8345903ce7
commit
bb9eb71770
12 changed files with 3743 additions and 1 deletions
3206
library/loadout_katalog.json
Normal file
3206
library/loadout_katalog.json
Normal file
File diff suppressed because it is too large
Load diff
149
src/svmap/loadout_katalog.gd
Normal file
149
src/svmap/loadout_katalog.gd
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
class_name LoadoutKatalog
|
||||||
|
extends RefCounted
|
||||||
|
## Map-Loadout (Karte „Loadout pro Map"): Waffen-/Aufsatz-Katalog des Editors
|
||||||
|
## und die Regeln für den optionalen `loadout`-Block der .svmap (V18, 1:1 wie
|
||||||
|
## MapLoadout im Match-Server, docs/map_format.md).
|
||||||
|
##
|
||||||
|
## doc["loadout"] fehlt oder mode "player" = Spieler-Loadout aus der Lobby.
|
||||||
|
## mode "fixed" = {primary, secondary ("" = keine), attachments:{waffe: "slot=teil;…"}}
|
||||||
|
## — Aufsatz-Text nur mit Abweichungen vom Standard (Draht-Format wie im Client).
|
||||||
|
## Katalog-Quelle: library/loadout_katalog.json (tools/sync_loadout_katalog.py).
|
||||||
|
|
||||||
|
const PATH := "res://library/loadout_katalog.json"
|
||||||
|
const MODE_FIXED := "fixed"
|
||||||
|
const MODE_PLAYER := "player"
|
||||||
|
|
||||||
|
static var _data: Dictionary = {}
|
||||||
|
static var _loaded := false
|
||||||
|
|
||||||
|
|
||||||
|
static func _ensure() -> void:
|
||||||
|
if _loaded:
|
||||||
|
return
|
||||||
|
_loaded = true
|
||||||
|
var f := FileAccess.open(PATH, FileAccess.READ)
|
||||||
|
if f == null:
|
||||||
|
push_warning("LoadoutKatalog: %s fehlt — Loadout-Auswahl bleibt leer" % PATH)
|
||||||
|
return
|
||||||
|
var parsed: Variant = JSON.parse_string(f.get_as_text())
|
||||||
|
if parsed is Dictionary:
|
||||||
|
_data = (parsed as Dictionary).get("waffen", {})
|
||||||
|
|
||||||
|
|
||||||
|
## Alle Waffen-IDs, nach Anzeigename sortiert.
|
||||||
|
static func waffen_ids() -> Array[String]:
|
||||||
|
_ensure()
|
||||||
|
var ids: Array[String] = []
|
||||||
|
for k in _data.keys():
|
||||||
|
ids.append(str(k))
|
||||||
|
ids.sort_custom(func(a: String, b: String) -> bool:
|
||||||
|
return anzeigename(a).naturalnocasecmp_to(anzeigename(b)) < 0)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
static func hat_waffe(id: String) -> bool:
|
||||||
|
_ensure()
|
||||||
|
return _data.has(id)
|
||||||
|
|
||||||
|
|
||||||
|
static func anzeigename(id: String) -> String:
|
||||||
|
_ensure()
|
||||||
|
return str((_data.get(id, {}) as Dictionary).get("name", id))
|
||||||
|
|
||||||
|
|
||||||
|
## Aufsatz-Slots einer Waffe: {slot: {default, optional, options}} (leer = keine).
|
||||||
|
static func slots(id: String) -> Dictionary:
|
||||||
|
_ensure()
|
||||||
|
return (_data.get(id, {}) as Dictionary).get("slots", {})
|
||||||
|
|
||||||
|
|
||||||
|
static func decode(text: String) -> Dictionary:
|
||||||
|
var out := {}
|
||||||
|
for chunk in text.split(";", false):
|
||||||
|
var kv := chunk.split("=", true, 1)
|
||||||
|
if kv.size() == 2 and not kv[0].is_empty():
|
||||||
|
out[kv[0]] = kv[1]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Auswahl {slot: teil} → Draht-Text, nur Abweichungen vom Standard, Slots sortiert.
|
||||||
|
static func encode(id: String, sel: Dictionary) -> String:
|
||||||
|
var sl := slots(id)
|
||||||
|
var keys: Array = sel.keys()
|
||||||
|
keys.sort()
|
||||||
|
var parts: PackedStringArray = []
|
||||||
|
for s in keys:
|
||||||
|
if sl.has(s) and str(sel[s]) != str((sl[s] as Dictionary).get("default", "")):
|
||||||
|
parts.append("%s=%s" % [s, sel[s]])
|
||||||
|
return ";".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
## Aktuelle Teil-Wahl je Slot (Standard, überlagert vom gespeicherten Text).
|
||||||
|
static func auswahl(id: String, wire: String) -> Dictionary:
|
||||||
|
var out := {}
|
||||||
|
var sl := slots(id)
|
||||||
|
var dec := decode(wire)
|
||||||
|
for s in sl.keys():
|
||||||
|
out[s] = str(dec.get(s, (sl[s] as Dictionary).get("default", "")))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
## Grund, warum der Aufsatz-Text für die Waffe ungültig ist ("" = ok).
|
||||||
|
static func pruefe_aufsaetze(id: String, wire: String) -> String:
|
||||||
|
if wire.is_empty():
|
||||||
|
return ""
|
||||||
|
var sl := slots(id)
|
||||||
|
if sl.is_empty():
|
||||||
|
return "Waffe '%s' hat keine Aufsätze" % id
|
||||||
|
var dec := decode(wire)
|
||||||
|
if dec.is_empty():
|
||||||
|
return "Aufsatz-Text unlesbar"
|
||||||
|
for s in dec.keys():
|
||||||
|
if not sl.has(s):
|
||||||
|
return "Slot '%s' gibt es an '%s' nicht" % [s, id]
|
||||||
|
var p := str(dec[s])
|
||||||
|
var sd: Dictionary = sl[s]
|
||||||
|
if p.is_empty():
|
||||||
|
if not bool(sd.get("optional", false)):
|
||||||
|
return "Slot '%s' darf nicht leer sein" % s
|
||||||
|
elif not (sd.get("options", []) as Array).has(p):
|
||||||
|
return "Teil '%s' passt nicht an Slot '%s' von '%s'" % [p, s, id]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
## V18-Fehlerliste (leer = gültig oder nicht vorhanden).
|
||||||
|
static func validate(lo: Variant) -> PackedStringArray:
|
||||||
|
var errors := PackedStringArray()
|
||||||
|
if lo == null:
|
||||||
|
return errors
|
||||||
|
if not (lo is Dictionary):
|
||||||
|
errors.append("V18: loadout muss ein Objekt sein.")
|
||||||
|
return errors
|
||||||
|
var d: Dictionary = lo
|
||||||
|
var mode := str(d.get("mode", MODE_PLAYER))
|
||||||
|
if mode != MODE_FIXED and mode != MODE_PLAYER:
|
||||||
|
errors.append("V18: loadout.mode muss 'fixed' oder 'player' sein (ist '%s')." % mode)
|
||||||
|
return errors
|
||||||
|
if mode == MODE_PLAYER:
|
||||||
|
return errors
|
||||||
|
var prim := str(d.get("primary", "")).strip_edges().to_lower()
|
||||||
|
var sec := str(d.get("secondary", "")).strip_edges().to_lower()
|
||||||
|
if prim.is_empty():
|
||||||
|
errors.append("V18: loadout.primary fehlt (Modus 'fixed').")
|
||||||
|
elif not hat_waffe(prim):
|
||||||
|
errors.append("V18: loadout.primary '%s' ist keine bekannte Waffe." % prim)
|
||||||
|
if not sec.is_empty() and not hat_waffe(sec):
|
||||||
|
errors.append("V18: loadout.secondary '%s' ist keine bekannte Waffe." % sec)
|
||||||
|
var att: Variant = d.get("attachments", {})
|
||||||
|
if not (att is Dictionary):
|
||||||
|
errors.append("V18: loadout.attachments muss ein Objekt sein.")
|
||||||
|
return errors
|
||||||
|
for wid in (att as Dictionary).keys():
|
||||||
|
var w := str(wid).strip_edges().to_lower()
|
||||||
|
if w != prim and w != sec:
|
||||||
|
errors.append("V18: loadout.attachments['%s'] gehört zu keiner Loadout-Waffe." % w)
|
||||||
|
continue
|
||||||
|
var why := pruefe_aufsaetze(w, str(att[wid]))
|
||||||
|
if why != "":
|
||||||
|
errors.append("V18: loadout.attachments['%s']: %s." % [w, why])
|
||||||
|
return errors
|
||||||
1
src/svmap/loadout_katalog.gd.uid
Normal file
1
src/svmap/loadout_katalog.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://jm1cubdr25s8
|
||||||
|
|
@ -220,6 +220,8 @@ static func _validate_core(data: Dictionary, manifest: Dictionary = {}) -> Packe
|
||||||
ids.append(e["id"])
|
ids.append(e["id"])
|
||||||
errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [
|
errors.append("V17: weather_sky '%s' ist keine gültige Skybox (%s)." % [
|
||||||
str(weather_sky), ", ".join(ids)])
|
str(weather_sky), ", ".join(ids)])
|
||||||
|
# V18: loadout (optional) — festes Map-Loadout, siehe LoadoutKatalog.
|
||||||
|
errors.append_array(LoadoutKatalog.validate(data.get("loadout", null)))
|
||||||
return errors
|
return errors
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
225
src/ui/panels/loadout_panel.gd
Normal file
225
src/ui/panels/loadout_panel.gd
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
extends "res://src/ui/panels/editor_panel.gd"
|
||||||
|
## Loadout-Bereich im Map-Tab (Karte „Loadout pro Map"): pro Map wählbar, ob die
|
||||||
|
## Spieler ihr eigenes Lobby-Loadout mitbringen oder die Map ein festes Loadout
|
||||||
|
## (Primär-/Sekundärwaffe + Aufsätze) vorschreibt. Gespeichert als optionaler
|
||||||
|
## Block doc["loadout"] (Regeln: src/svmap/loadout_katalog.gd, V18). Der Server
|
||||||
|
## setzt ein festes Loadout durch, der Client zeigt es an.
|
||||||
|
##
|
||||||
|
## Jede Änderung ist ein Undo-Schritt (History-Block "loadout"; fehlt der Block,
|
||||||
|
## ist das Spieler-Loadout gemeint).
|
||||||
|
|
||||||
|
const KEINE := ""
|
||||||
|
|
||||||
|
var mode_option: OptionButton
|
||||||
|
var primary_option: OptionButton
|
||||||
|
var secondary_option: OptionButton
|
||||||
|
var fixed_box: VBoxContainer
|
||||||
|
var attach_box: VBoxContainer
|
||||||
|
var hint_label: Label
|
||||||
|
|
||||||
|
var _loading := false
|
||||||
|
var _ids: Array[String] = []
|
||||||
|
|
||||||
|
|
||||||
|
static func spec() -> Dictionary:
|
||||||
|
return {"titel": "Loadout", "gruppe": "map", "order": 25}
|
||||||
|
|
||||||
|
|
||||||
|
func panel_title() -> String:
|
||||||
|
return "Loadout"
|
||||||
|
|
||||||
|
|
||||||
|
func build() -> void:
|
||||||
|
add_child(_header_label("Loadout"))
|
||||||
|
add_child(_muted_label("Legt fest, mit welchen Waffen die Spieler diese Karte spielen."))
|
||||||
|
|
||||||
|
mode_option = OptionButton.new()
|
||||||
|
mode_option.tooltip_text = "Eigenes Loadout: Spieler nehmen ihre Auswahl aus der Lobby mit. Festes Loadout: alle bekommen genau diese Waffen, der Server setzt das durch."
|
||||||
|
mode_option.add_item("Eigenes Loadout der Spieler (Lobby)")
|
||||||
|
mode_option.add_item("Festes Map-Loadout")
|
||||||
|
add_child(mode_option)
|
||||||
|
|
||||||
|
fixed_box = VBoxContainer.new()
|
||||||
|
fixed_box.add_theme_constant_override("separation", UIScale.px(8))
|
||||||
|
add_child(fixed_box)
|
||||||
|
|
||||||
|
fixed_box.add_child(_field_label("Primärwaffe (Rücken)"))
|
||||||
|
primary_option = OptionButton.new()
|
||||||
|
fixed_box.add_child(primary_option)
|
||||||
|
fixed_box.add_child(_field_label("Sekundärwaffe (Hüfte)"))
|
||||||
|
secondary_option = OptionButton.new()
|
||||||
|
fixed_box.add_child(secondary_option)
|
||||||
|
|
||||||
|
_ids = LoadoutKatalog.waffen_ids()
|
||||||
|
for id in _ids:
|
||||||
|
primary_option.add_item(LoadoutKatalog.anzeigename(id))
|
||||||
|
secondary_option.add_item(LoadoutKatalog.anzeigename(id))
|
||||||
|
secondary_option.add_item("— keine —")
|
||||||
|
|
||||||
|
attach_box = VBoxContainer.new()
|
||||||
|
attach_box.add_theme_constant_override("separation", UIScale.px(6))
|
||||||
|
fixed_box.add_child(attach_box)
|
||||||
|
|
||||||
|
hint_label = _muted_label("Messer, Minen und Granaten bleiben unverändert.")
|
||||||
|
fixed_box.add_child(hint_label)
|
||||||
|
|
||||||
|
mode_option.item_selected.connect(func(i: int) -> void: set_fixed(i == 1))
|
||||||
|
primary_option.item_selected.connect(func(i: int) -> void:
|
||||||
|
set_weapons(_ids[i], _secondary_id()))
|
||||||
|
secondary_option.item_selected.connect(func(_i: int) -> void:
|
||||||
|
set_weapons(_primary_id(), _secondary_id()))
|
||||||
|
refresh_values()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Lesen -------
|
||||||
|
|
||||||
|
func _primary_id() -> String:
|
||||||
|
return _ids[primary_option.selected] if primary_option.selected >= 0 else ""
|
||||||
|
|
||||||
|
|
||||||
|
func _secondary_id() -> String:
|
||||||
|
var i := secondary_option.selected
|
||||||
|
return _ids[i] if i >= 0 and i < _ids.size() else KEINE
|
||||||
|
|
||||||
|
|
||||||
|
func current() -> Dictionary:
|
||||||
|
return main.doc.get("loadout", {}) if main != null and not main.doc.is_empty() else {}
|
||||||
|
|
||||||
|
|
||||||
|
func is_fixed() -> bool:
|
||||||
|
return str(current().get("mode", LoadoutKatalog.MODE_PLAYER)) == LoadoutKatalog.MODE_FIXED
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- Ändern ------
|
||||||
|
|
||||||
|
## Modus wechseln. Auf „fest" ohne vorherige Wahl: M40 + Pistole 1 als Start.
|
||||||
|
func set_fixed(fixed: bool) -> void:
|
||||||
|
if main.doc.is_empty():
|
||||||
|
return
|
||||||
|
if not fixed:
|
||||||
|
_commit(null, "Loadout: Spieler-Loadout")
|
||||||
|
return
|
||||||
|
var cur := current()
|
||||||
|
if cur.get("mode", "") == LoadoutKatalog.MODE_FIXED:
|
||||||
|
return
|
||||||
|
var prim := "m40" if LoadoutKatalog.hat_waffe("m40") else (_ids[0] if not _ids.is_empty() else "")
|
||||||
|
var sec := "pistol1" if LoadoutKatalog.hat_waffe("pistol1") else KEINE
|
||||||
|
_commit({"mode": LoadoutKatalog.MODE_FIXED, "primary": prim, "secondary": sec, "attachments": {}},
|
||||||
|
"Loadout: festes Map-Loadout")
|
||||||
|
|
||||||
|
|
||||||
|
## Waffen setzen. Aufsätze von Waffen, die nicht mehr im Loadout sind, fallen weg.
|
||||||
|
func set_weapons(primary: String, secondary: String) -> void:
|
||||||
|
if main.doc.is_empty() or not is_fixed():
|
||||||
|
return
|
||||||
|
if not LoadoutKatalog.hat_waffe(primary) or (secondary != KEINE and not LoadoutKatalog.hat_waffe(secondary)):
|
||||||
|
return
|
||||||
|
var lo := current().duplicate(true)
|
||||||
|
lo["primary"] = primary
|
||||||
|
lo["secondary"] = secondary
|
||||||
|
var att: Dictionary = lo.get("attachments", {})
|
||||||
|
for w in att.keys():
|
||||||
|
if w != primary and w != secondary:
|
||||||
|
att.erase(w)
|
||||||
|
lo["attachments"] = att
|
||||||
|
_commit(lo, "Loadout: %s / %s" % [LoadoutKatalog.anzeigename(primary),
|
||||||
|
LoadoutKatalog.anzeigename(secondary) if secondary != KEINE else "keine"])
|
||||||
|
|
||||||
|
|
||||||
|
## Ein Aufsatz-Slot einer Loadout-Waffe setzen (part = Teil-ID, "" = nichts).
|
||||||
|
func set_part(weapon: String, slot: String, part: String) -> void:
|
||||||
|
if main.doc.is_empty() or not is_fixed():
|
||||||
|
return
|
||||||
|
var lo := current().duplicate(true)
|
||||||
|
if weapon != str(lo.get("primary", "")) and weapon != str(lo.get("secondary", "")):
|
||||||
|
return
|
||||||
|
var att: Dictionary = lo.get("attachments", {})
|
||||||
|
var sel := LoadoutKatalog.auswahl(weapon, str(att.get(weapon, "")))
|
||||||
|
if not sel.has(slot):
|
||||||
|
return
|
||||||
|
sel[slot] = part
|
||||||
|
var wire := LoadoutKatalog.encode(weapon, sel)
|
||||||
|
if LoadoutKatalog.pruefe_aufsaetze(weapon, wire) != "":
|
||||||
|
return
|
||||||
|
if wire.is_empty():
|
||||||
|
att.erase(weapon)
|
||||||
|
else:
|
||||||
|
att[weapon] = wire
|
||||||
|
lo["attachments"] = att
|
||||||
|
_commit(lo, "Loadout: Aufsatz %s" % slot)
|
||||||
|
|
||||||
|
|
||||||
|
func _commit(neu: Variant, text: String) -> void:
|
||||||
|
var alt: Variant = main.doc.get("loadout", null)
|
||||||
|
if alt != null:
|
||||||
|
alt = (alt as Dictionary).duplicate(true)
|
||||||
|
if neu == null:
|
||||||
|
main.doc.erase("loadout")
|
||||||
|
else:
|
||||||
|
main.doc["loadout"] = (neu as Dictionary).duplicate(true)
|
||||||
|
var danach: Variant = main.doc.get("loadout", null)
|
||||||
|
if danach != null:
|
||||||
|
danach = (danach as Dictionary).duplicate(true)
|
||||||
|
if alt == danach:
|
||||||
|
return
|
||||||
|
main.history.commit(text, {"loadout": alt}, {"loadout": danach})
|
||||||
|
main.dirty = true
|
||||||
|
main.call("_refresh_file_label")
|
||||||
|
main.call("_refresh_history_buttons")
|
||||||
|
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:
|
||||||
|
return
|
||||||
|
_loading = true
|
||||||
|
var lo := current()
|
||||||
|
var fixed := is_fixed()
|
||||||
|
mode_option.select(1 if fixed else 0)
|
||||||
|
fixed_box.visible = fixed
|
||||||
|
if fixed:
|
||||||
|
var prim := str(lo.get("primary", ""))
|
||||||
|
var sec := str(lo.get("secondary", ""))
|
||||||
|
primary_option.select(maxi(0, _ids.find(prim)))
|
||||||
|
secondary_option.select(_ids.find(sec) if sec != KEINE and _ids.has(sec) else _ids.size())
|
||||||
|
_rebuild_attachments(prim, sec, lo.get("attachments", {}))
|
||||||
|
_loading = false
|
||||||
|
|
||||||
|
|
||||||
|
func _rebuild_attachments(prim: String, sec: String, att: Dictionary) -> void:
|
||||||
|
for c in attach_box.get_children():
|
||||||
|
attach_box.remove_child(c)
|
||||||
|
c.queue_free()
|
||||||
|
for w in [prim, sec]:
|
||||||
|
if w == "" or LoadoutKatalog.slots(w).is_empty():
|
||||||
|
continue
|
||||||
|
attach_box.add_child(_header_label("Aufsätze: %s" % LoadoutKatalog.anzeigename(w)))
|
||||||
|
var sel := LoadoutKatalog.auswahl(w, str(att.get(w, "")))
|
||||||
|
var sl := LoadoutKatalog.slots(w)
|
||||||
|
var slot_names: Array = sl.keys()
|
||||||
|
slot_names.sort()
|
||||||
|
for s in slot_names:
|
||||||
|
var sd: Dictionary = sl[s]
|
||||||
|
var row := HBoxContainer.new()
|
||||||
|
var lab := _field_label(str(s).replace("_", " "))
|
||||||
|
lab.custom_minimum_size = UIScale.v2(90, 0)
|
||||||
|
row.add_child(lab)
|
||||||
|
var ob := OptionButton.new()
|
||||||
|
ob.name = "Teil_%s_%s" % [w, s]
|
||||||
|
ob.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||||
|
var parts: Array = []
|
||||||
|
if bool(sd.get("optional", false)):
|
||||||
|
parts.append("")
|
||||||
|
parts.append_array(sd.get("options", []))
|
||||||
|
for p in parts:
|
||||||
|
ob.add_item("— nichts —" if str(p) == "" else str(p))
|
||||||
|
ob.select(maxi(0, parts.find(sel.get(s, ""))))
|
||||||
|
ob.item_selected.connect(func(i: int) -> void:
|
||||||
|
if not _loading:
|
||||||
|
set_part(w, str(s), str(parts[i])))
|
||||||
|
row.add_child(ob)
|
||||||
|
attach_box.add_child(row)
|
||||||
1
src/ui/panels/loadout_panel.gd.uid
Normal file
1
src/ui/panels/loadout_panel.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://t00n1cb8be2w
|
||||||
|
|
@ -254,4 +254,8 @@ func refresh_values() -> void:
|
||||||
var wl: Variant = main.doc.get("water_level", null)
|
var wl: Variant = main.doc.get("water_level", null)
|
||||||
prop_water.set_value_no_signal(
|
prop_water.set_value_no_signal(
|
||||||
float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT)
|
float(wl) if (wl is float or wl is int) else KEIN_WASSER_WERT)
|
||||||
|
# Loadout-Bereich (Registry-Gruppe "map") aus dem doc nachziehen.
|
||||||
|
var loadout_panel: Node = _sections.get_node_or_null("loadout") if _sections != null else null
|
||||||
|
if loadout_panel != null:
|
||||||
|
loadout_panel.call("refresh_values")
|
||||||
_loading_ui_values = false
|
_loading_ui_values = false
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,8 @@ const PANELS: Array = [
|
||||||
"skript": "res://src/ui/panels/check_panel.gd"},
|
"skript": "res://src/ui/panels/check_panel.gd"},
|
||||||
{"id": "teststart", "titel": "Test-Start", "gruppe": "map", "order": 20,
|
{"id": "teststart", "titel": "Test-Start", "gruppe": "map", "order": 20,
|
||||||
"skript": "res://src/ui/panels/test_start_panel.gd"},
|
"skript": "res://src/ui/panels/test_start_panel.gd"},
|
||||||
|
{"id": "loadout", "titel": "Loadout", "gruppe": "map", "order": 25,
|
||||||
|
"skript": "res://src/ui/panels/loadout_panel.gd"},
|
||||||
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "map", "order": 30,
|
{"id": "symmetrie", "titel": "Symmetrie", "gruppe": "map", "order": 30,
|
||||||
"skript": "res://src/ui/panels/symmetry_panel.gd"},
|
"skript": "res://src/ui/panels/symmetry_panel.gd"},
|
||||||
# --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) ---
|
# --- dialog: Inhalt des Einstellungen-Fensters (Knopf in der Kopfleiste) ---
|
||||||
|
|
|
||||||
119
tests/test_loadout.gd
Normal file
119
tests/test_loadout.gd
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
extends SceneTree
|
||||||
|
## Editor-Test (headless): Map-Loadout (V18) — Katalog, Validierung, Panel mit
|
||||||
|
## Undo/Redo und Speichern/Laden. Ausführen:
|
||||||
|
## godot --headless --path . --script res://tests/test_loadout.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 _run_all() -> void:
|
||||||
|
_test_katalog()
|
||||||
|
await _test_panel()
|
||||||
|
if failures == 0:
|
||||||
|
print("ALL OK: %d checks" % checks)
|
||||||
|
quit(0)
|
||||||
|
else:
|
||||||
|
print("FAILED: %d/%d checks" % [failures, checks])
|
||||||
|
quit(1)
|
||||||
|
|
||||||
|
|
||||||
|
func _test_katalog() -> void:
|
||||||
|
print("Testgruppe: Katalog + Validierung")
|
||||||
|
_check(LoadoutKatalog.waffen_ids().size() >= 50, "Katalog hat Waffen")
|
||||||
|
_check(LoadoutKatalog.hat_waffe("m40") and LoadoutKatalog.hat_waffe("pistol1"), "m40 + pistol1 bekannt")
|
||||||
|
_check(LoadoutKatalog.validate(null).is_empty(), "fehlend gültig")
|
||||||
|
_check(LoadoutKatalog.validate({"mode": "player"}).is_empty(), "player gültig")
|
||||||
|
_check(not LoadoutKatalog.validate({"mode": "fixed"}).is_empty(), "fixed ohne Primär ungültig")
|
||||||
|
_check(not LoadoutKatalog.validate({"mode": "fixed", "primary": "nix"}).is_empty(), "unbekannte Waffe ungültig")
|
||||||
|
_check(not LoadoutKatalog.validate({"mode": "x"}).is_empty(), "unbekannter Modus ungültig")
|
||||||
|
_check(LoadoutKatalog.validate({"mode": "fixed", "primary": "sr1", "secondary": ""}).is_empty(), "fixed ohne Sekundär gültig")
|
||||||
|
_check(not LoadoutKatalog.validate({"mode": "fixed", "primary": "sr1",
|
||||||
|
"attachments": {"ar1": ""}}).is_empty(), "Aufsatz für Fremdwaffe ungültig")
|
||||||
|
_check(not LoadoutKatalog.validate({"mode": "fixed", "primary": "sr1",
|
||||||
|
"attachments": {"sr1": "sight=nix"}}).is_empty(), "unbekanntes Teil ungültig")
|
||||||
|
var sel := LoadoutKatalog.auswahl("sr1", "")
|
||||||
|
sel["muzzle"] = "muzzle_suppressor_01"
|
||||||
|
var wire := LoadoutKatalog.encode("sr1", sel)
|
||||||
|
_check(wire == "muzzle=muzzle_suppressor_01", "encode nur Abweichungen: " + wire)
|
||||||
|
_check(LoadoutKatalog.pruefe_aufsaetze("sr1", wire) == "", "encodierter Text gültig")
|
||||||
|
|
||||||
|
|
||||||
|
func _test_panel() -> void:
|
||||||
|
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("LoadoutTest", 512.0, "mittag")
|
||||||
|
main.history.doc = main.doc
|
||||||
|
main.call("_refresh_all")
|
||||||
|
var panel: Node = main.map_mod.sections_host().get_node_or_null("loadout")
|
||||||
|
_check(panel != null, "Loadout-Panel hängt im Map-Tab")
|
||||||
|
if panel == null:
|
||||||
|
return
|
||||||
|
_check(not panel.is_fixed() and not main.doc.has("loadout"), "Start: Spieler-Loadout, kein Block")
|
||||||
|
_check(not panel.fixed_box.visible, "Waffen-Auswahl verborgen")
|
||||||
|
|
||||||
|
panel.set_fixed(true)
|
||||||
|
_check(panel.is_fixed() and main.doc["loadout"]["primary"] == "m40", "fest: M40 als Start")
|
||||||
|
_check(panel.fixed_box.visible, "Waffen-Auswahl sichtbar")
|
||||||
|
panel.set_weapons("sr1", "")
|
||||||
|
_check(main.doc["loadout"]["primary"] == "sr1" and main.doc["loadout"]["secondary"] == "", "Waffen gesetzt, Sekundär leer")
|
||||||
|
panel.set_part("sr1", "muzzle", "muzzle_suppressor_01")
|
||||||
|
_check(main.doc["loadout"]["attachments"].get("sr1", "") == "muzzle=muzzle_suppressor_01", "Aufsatz gespeichert")
|
||||||
|
panel.set_part("ar1", "muzzle", "muzzle_suppressor_01")
|
||||||
|
_check(not main.doc["loadout"]["attachments"].has("ar1"), "Aufsatz für Nicht-Loadout-Waffe ignoriert")
|
||||||
|
_check(SvmapIO.validate_map(_signed(main.doc)).is_empty(), "Map mit Loadout besteht V1–V18")
|
||||||
|
|
||||||
|
panel.set_weapons("ar1", "")
|
||||||
|
_check(not main.doc["loadout"]["attachments"].has("sr1"), "Waffenwechsel verwirft Aufsätze der alten Waffe")
|
||||||
|
|
||||||
|
# Undo/Redo
|
||||||
|
main.history.undo()
|
||||||
|
main.call("_refresh_all")
|
||||||
|
_check(main.doc["loadout"]["primary"] == "sr1" and main.doc["loadout"]["attachments"].has("sr1"), "Undo: Waffenwechsel zurück")
|
||||||
|
main.history.undo()
|
||||||
|
main.history.undo()
|
||||||
|
main.history.undo()
|
||||||
|
main.call("_refresh_all")
|
||||||
|
_check(not main.doc.has("loadout") and not panel.fixed_box.visible, "Undo bis Start: Block weg, UI nachgezogen")
|
||||||
|
main.history.redo()
|
||||||
|
main.call("_refresh_all")
|
||||||
|
_check(main.doc.has("loadout") and panel.is_fixed(), "Redo: fest wieder da")
|
||||||
|
|
||||||
|
# Zurück auf Spieler-Loadout entfernt den Block
|
||||||
|
panel.set_fixed(false)
|
||||||
|
_check(not main.doc.has("loadout"), "Spieler-Loadout entfernt den Block")
|
||||||
|
|
||||||
|
# Speichern/Laden
|
||||||
|
panel.set_fixed(true)
|
||||||
|
panel.set_weapons("sr2", "pistol2")
|
||||||
|
var path := "user://loadout_test.svmap"
|
||||||
|
var doc2: Dictionary = main.doc.duplicate(true)
|
||||||
|
SvmapIO.sign_map(doc2)
|
||||||
|
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"]["loadout"]["primary"] == "sr2", "Laden: Loadout bleibt erhalten")
|
||||||
|
|
||||||
|
|
||||||
|
func _signed(d: Dictionary) -> Dictionary:
|
||||||
|
var c := d.duplicate(true)
|
||||||
|
SvmapIO.sign_map(c)
|
||||||
|
return c
|
||||||
1
tests/test_loadout.gd.uid
Normal file
1
tests/test_loadout.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cqys3opdjiba7
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
GODOT=${GODOT:-/opt/data/home/bin/godot4}
|
GODOT=${GODOT:-/opt/data/home/bin/godot4}
|
||||||
rc=0
|
rc=0
|
||||||
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_build_spawn_mirror test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein test_ui_aufraeumen test_ui_layout test_ui_scale; do
|
for t in test_ux test_shortcuts test_editor_svmap test_terrain test_library test_glb_view test_spawns test_testrun test_bauen test_build_spawn_mirror test_resize_dialog test_updater test_gui_smoke test_rmb_vorrang test_inspector test_pinsel_werkzeuge test_terrain_mirror test_mal_fein test_ui_aufraeumen test_ui_layout test_ui_scale test_loadout; do
|
||||||
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
|
"$GODOT" --headless --path . --script "res://tests/$t.gd" > "/tmp/$t.log" 2>&1
|
||||||
code=$?
|
code=$?
|
||||||
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)
|
line=$(grep -E "ALL OK|FAILED" "/tmp/$t.log" | tail -1)
|
||||||
|
|
|
||||||
32
tools/sync_loadout_katalog.py
Normal file
32
tools/sync_loadout_katalog.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Waffen- und Aufsatz-Katalog fürs Map-Loadout aus dem Match-Server-Repo holen.
|
||||||
|
|
||||||
|
Aufruf: python3 tools/sync_loadout_katalog.py [pfad/zu/scopedvr-match-server]
|
||||||
|
Schreibt library/loadout_katalog.json (Waffen-ID -> Name, Aufsatz-Slots).
|
||||||
|
Nach neuen Waffen/Aufsätzen im Server erneut ausführen.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
hier = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
server = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/godot-projects/scopedvr-match-server")
|
||||||
|
profile = json.load(open(os.path.join(server, "library/weapons.json"), encoding="utf-8"))["profiles"]
|
||||||
|
aufsaetze = json.load(open(os.path.join(server, "library/attachments.json"), encoding="utf-8"))["weapons"]
|
||||||
|
|
||||||
|
waffen = {}
|
||||||
|
for wid in sorted(profile):
|
||||||
|
p = profile[wid]
|
||||||
|
eintrag = {"name": str(p.get("name", wid))}
|
||||||
|
if wid in aufsaetze:
|
||||||
|
eintrag["slots"] = {
|
||||||
|
s: {"default": d.get("default", ""), "optional": bool(d.get("optional", False)),
|
||||||
|
"options": list(d.get("options", []))}
|
||||||
|
for s, d in sorted(aufsaetze[wid].items())
|
||||||
|
}
|
||||||
|
waffen[wid] = eintrag
|
||||||
|
|
||||||
|
ziel = os.path.join(hier, "..", "library", "loadout_katalog.json")
|
||||||
|
with open(ziel, "w", encoding="utf-8") as f:
|
||||||
|
json.dump({"version": 1, "waffen": waffen}, f, ensure_ascii=False, indent=1, sort_keys=True)
|
||||||
|
print("%d Waffen -> %s" % (len(waffen), os.path.normpath(ziel)))
|
||||||
Loading…
Reference in a new issue